import { EmitterSettings, System, ParamDef, Literal, PylinkaProject } from '@pylinka/graph'; import { A as AtlasPlay } from '../atlas-D83bp0cn.js'; /** * Interpret a pylinka System's graph into WebGL2 engine parameters. This is the * pragmatic v1 runtime path (REQUIREMENTS §5.4 "non-GPU/interpreter preview" + * §13.12 WebGL2 fallback): recognised node patterns map to uniforms of a fixed * simulation model. Covers the common slot-VFX set — spawn shape, random init * velocity + life, gravity, directional wind (knob-driven), drag, colour-over- * life, scale-over-life. Unrecognised nodes are ignored (the effect still runs). */ interface EngineParams { capacity: number; emitter: EmitterSettings; blend: 'normal' | 'add' | 'screen'; gravity: [number, number]; drag: number; windPower: number; windDir: number; /** knob names driving the wind (so runtime setKnob can update it), if any. */ windPowerKnob?: string; windDirKnob?: string; velMin: [number, number]; velMax: [number, number]; lifeMin: number; lifeMax: number; /** 0 point, 1 circle, 2 rect, 3 torus, 4 burstRing, 5 polygonal chain */ shape: 0 | 1 | 2 | 3 | 4 | 5; shapeRadius: number; shapeSize: [number, number]; /** per-shape: point offset | torus (inner, outer) | chain start */ shapeA: [number, number]; /** per-shape: chain end */ shapeB: [number, number]; colorFrom: [number, number, number, number]; colorTo: [number, number, number, number]; colorEase: number; sizeFrom: number; sizeTo: number; sizeEase: number; /** Alpha ramp (gen.alphaOverLife). 1→1 with ease 0 is the no-op default. */ alphaFrom: number; alphaTo: number; alphaEase: number; /** * Birth angle range in radians (`output.initRotation`). A constant collapses * to [v, v]; a `gen.randomRange` gives each particle its own start angle. */ rotStart: [number, number]; /** Angular velocity range in rad/s (`gen.spin`). [0,0] = no spin. */ spin: [number, number]; /** Eased rotation ramp over life in radians (`gen.rotationOverLife`). */ rotFrom: number; rotTo: number; rotEase: number; /** * Flattened ease LUT, `EASE_LUT_CHANNELS × EASE_LUT_N` samples in channel * order size, colour, alpha, rotation. Only the channels whose ease index is -1 (i.e. * a custom curve the fixed shader cannot name) are meaningful; the rest are * left as the identity ramp. */ easeLut: number[]; /** * Up to 4 point fields (field.vortex / field.radial). tangential = swirl * px/s² (sign = direction), pull = inward suction (+) / push (−), radius = * linear falloff distance (0 = global), relative = center is emitter-relative. */ pointFields: { center: [number, number]; tangential: number; pull: number; radius: number; relative: 0 | 1; }[]; /** field.turbulence: [strength, noise cell px, speed] ([0,…] = off). */ turbulence: [number, number, number]; /** * field.obstacle — up to 4 bodies moving through the field. `center` and * `velocity` are absolute world px and are usually knob-bound, so a cursor or * a flying object can drive them live (see `setKnob(name, x, y)`). */ obstacles: { center: [number, number]; velocity: [number, number]; radius: number; strength: number; softness: number; swirl: number; carry: number; /** 1 = centre is emitter-relative (structural `space: 'emitter'`). */ relative: 0 | 1; }[]; /** output.collide* — up to 4 solids, resolved after integration. */ colliders: { /** 1 plane · 2 rect inside · 3 rect outside · 4 circle outside · 5 circle inside */ kind: 1 | 2 | 3 | 4 | 5; /** plane: point · rect: min · circle: centre */ a: [number, number]; /** plane: normal · rect: max · circle: the disc's own velocity */ b: [number, number]; radius: number; restitution: number; friction: number; /** 1 = geometry is emitter-relative (structural `space: 'emitter'`). */ relative: 0 | 1; }[]; /** output.deathBurst — sub-emitter burst: countMin..countMax spawns per * parent event, up to `max` (child-pool multiplier + pass count), each * inheriting `inherit` of the parent's velocity at that moment. `on` picks * the event: the parent's death (debris) or its birth (a flash). */ deathBurst?: { max: number; countMin: number; countMax: number; inherit: number; on: 'death' | 'birth'; }; /** which parent edge a sub-emitter child spawns on, even with no burst node. */ subOn: 'death' | 'birth'; } /** Knob values as the interpreted runtime holds them: scalar or vec2. */ type KnobValues = Record; /** '#rrggbbaa' → [r,g,b,a] in 0..1. */ declare function parseColor(l: Literal | undefined, d: [number, number, number, number]): [number, number, number, number]; declare function extractParams(system: System, params: ParamDef[], knobValues: KnobValues): EngineParams; /** * WebGL2 transform-feedback particle engine (REQUIREMENTS.md §13.12). The * simulation runs entirely on the GPU (TF vertex program, ping-pong buffers); * spawning uses the cursor-window scheme (no atomics). Rendering is one instanced * draw. No per-particle CPU work in steady state. */ /** A sub-emitter's parent: the child spawns on this engine's particle deaths. */ interface SubSource { parent: WebGL2Engine; } /** * Emission-mask point table: emitter-relative spawn offsets (px), one xy pair * per emitting texel of the authored mask. Built CPU-side, sampled per spawn. */ interface MaskConfig { /** interleaved xy offsets, length == 2 * count */ points: Float32Array; count: number; } /** Resolved atlas-sequence config the engine renders. */ interface AtlasConfig { image: TexImageSource; width: number; height: number; cols: number; rows: number; frameW: number; frameH: number; pad: number; fps: number; play: 0 | 1 | 2; pick: 0 | 1; row: number; } declare class WebGL2Engine { private readonly gl; private readonly capacity; /** parent-slot count for a sub-emitter (death detection loops these); * capacity == parentCap × burstMax under a death-burst. */ private parentCap; private burstMax; private hasBurst; /** which parent edge fires a child spawn — baked into the sub shader */ private subOn; private updateProg; private renderProg; private bufs; private updateVAOs; /** sub-emitter only: [childCur][parentCur][burstCopy] VAOs binding the parent's buffers. */ private subVAOs; private renderVAOs; private tf; private readonly uUpdate; private readonly uRender; private cur; private spawnBase; private frame; private timeAcc; private readonly pfA; private readonly pfB; private readonly obA; private readonly obB; private readonly obSoft; private readonly obRel; private readonly colA; private readonly colB; private readonly colRel; private readonly feat; private readonly sizeScale; private readonly atlas; private tex; private readonly sub; private readonly mask; private maskTex; private maskCount; /** * Context-loss state. A lost context invalidates every GL object we hold, and * calling into it just generates errors, so the engine goes quiet until the * browser hands the context back. The rebuild is deferred to the next step() * rather than done in the event handler: a sub-emitter's VAOs reference its * PARENT's buffers, and step() order already guarantees the parent goes first. */ private lost; private needsRebuild; private readonly onLost; private readonly onRestored; private readonly canvas; private readonly handleLost; private readonly handleRestored; /** True while the GL context is gone. step()/render() are no-ops meanwhile. */ get contextLost(): boolean; /** current ping-pong index (which buffer holds this-frame state). */ get curIndex(): number; bufferAt(i: number): WebGLBuffer; get capacityValue(): number; constructor(gl: WebGL2RenderingContext, params: EngineParams, sizeScale?: number, atlas?: AtlasConfig, sub?: SubSource, mask?: MaskConfig, hooks?: { onContextLost?: () => void; onContextRestored?: () => void; }); /** * Build (or rebuild) every GL object this engine owns. Called once from the * constructor and again after the context comes back. Particle state does not * survive: the buffers come back zeroed and the pool refills from the emitter. */ private createResources; private makeBuffer; private makeUpdateVAO; /** Sub-emitter update VAO. Child state is read from burst region `childOff` * (0 without a burst), so `max` passes cover the pool; parent cur/prev are * read from region 0 (parent slot = vertex id). i_pVel feeds inheritance. */ private makeSubUpdateVAO; private makeRenderVAO; /** Advance the simulation by dt, spawning `spawnCount` new particles. */ step(dt: number, spawnCount: number, emitter: readonly [number, number], wind: readonly [number, number], p: EngineParams): void; /** Draw the current state into the bound framebuffer at the given size. */ render(width: number, height: number, p: EngineParams, viewX?: number, viewY?: number): void; /** * Count alive particles by reading back the current state buffer. NOTE: this * is a synchronous GPU readback and stalls the pipeline — use for debugging / * a stats HUD, not every frame. */ aliveCount(): number; destroy(): void; } /** * What the interpreted backend can actually run. * * This backend does not evaluate a graph. It recognises node PATTERNS and * drives a fixed set of uniforms from them, which is what makes it small and * lets it live-edit without recompiling — and also means a node it does not * recognise contributes nothing. Silently: the effect simply comes out wrong, * with no error, until you notice and switch to a compiled backend, which does * evaluate the graph and implements the whole catalog. * * This is the list, so an editor can say so on the node instead of leaving * someone to work it out. `webgl-support.test.ts` holds it against the source * of `params.ts`, so support added there without a line here fails the build. */ /** Node kinds `extractParams` reads. Everything else is inert here. */ declare const INTERPRETED_KINDS: ReadonlySet; /** Is this node kind understood by the interpreted backend? */ declare function isInterpreted(kind: string): boolean; /** Every node in `system` this backend will ignore, by id. */ declare function unsupportedNodes(system: System): string[]; /** * @pylinka/core/webgl — a usable WebGL2 particle runtime. * * Drop a pylinka project onto a canvas and drive it. The simulation runs on the * GPU via transform feedback (REQUIREMENTS §13.12); no WebGPU required. This is * the pragmatic v1 runtime — it interprets the common node patterns (spawn * shape, random velocity/life, gravity, wind, drag, colour/scale over life) into * a fixed GPU model. Effects with unrecognised nodes still run (those nodes are * ignored). * * @example * ```ts * import { createParticles } from '@pylinka/core/webgl'; * const fx = createParticles(canvas, project); * fx.setEmitter(x, y); * app.ticker?.add?.(() => fx.update(1 / 60)); // or your own rAF loop * fx.setKnob('windPower', 40); * ``` */ interface ParticlesHandle { /** Step the simulation and render one frame. Call once per rAF tick. */ update(dtSeconds: number): void; /** * Move where new particles are born (world/canvas pixels). * * `teleport` moves the emitter WITHOUT counting the distance travelled. That * distance is what `rateOverDistance` turns into spawns — the feature that * lays a trail behind a dragged emitter — so jumping the emitter somewhere * for one frame otherwise fires a spawn proportional to how far it jumped, * and another one when it jumps back. Teleport when the move is a cut rather * than a motion: placing a one-off burst, or repositioning between shots. */ setEmitter(x: number, y: number, teleport?: boolean): void; /** Emit an extra burst next frame. */ spawnBurst(count: number): void; /** * Set a named knob live (e.g. 'windPower'). Pass a second component for a * vec2 knob — that's how a cursor or a moving object drives `field.obstacle` * / `output.collide*` positions: `fx.setKnob('cursor', x, y)`. */ setKnob(name: string, x: number, y?: number): void; /** * Re-read an edited project into the running effect with no restart (the * uniform-driven live-edit path for editors). Returns false if a change needs * a full re-create (only pool capacity does) — recreate via createParticles. */ apply(project: PylinkaProject): boolean; /** * How much world the canvas shows, live. * * `1` maps one world unit to one canvas PIXEL, so on a 2x-density display an * effect authored at 100px covers 50 CSS px — pass `1 / devicePixelRatio` to * make world units device-independent. Values above 1 show more world in the * same canvas (zoom out), below 1 less (zoom in), and because it changes what * the renderer draws rather than how a finished image is stretched, zooming * this way stays sharp at any level. Emitter coordinates are still canvas * pixels; they are converted for you. */ zoom: number; /** * Slide the view without moving the effect, in world units. * * Panning by transforming the canvas ELEMENT moves finished pixels: the * drawn area slides away from the viewport and leaves an empty margin, and * it cannot be combined with a rendered zoom. This shifts the window the * renderer draws through instead. `[0, 0]` is the default. */ viewOffset: [number, number]; /** Whether the canvas should be cleared each frame (default true). */ autoClear: boolean; /** * What `autoClear` clears to, as straight (non-premultiplied) `[r,g,b,a]` in * 0..1. Defaults to fully transparent. * * This matters more than it looks. The canvas is premultiplied, so a light * blend mode can only add to pixels that are IN this framebuffer — over a * transparent clear there is nothing to add to, and the page behind the * canvas is out of reach. Clearing to the colour the effect will really play * on is what makes `add` and `screen` show what they will actually do. */ clearColor: [number, number, number, number]; /** Alive particle count. Synchronous GPU readback — for debug/stats, not per-frame. */ aliveCount(): number; /** * True while the WebGL context is gone (backgrounded tab, GPU reset, driver * hiccup). `update()` is a no-op meanwhile and resumes on its own once the * browser restores the context; particle state does not survive, so the pool * refills from the emitter. */ readonly contextLost: boolean; destroy(): void; } interface ParticlesOptions { /** Which system to run (defaults to the first enabled system). */ systemName?: string; /** dt clamp in seconds (default 0.05). */ maxDt?: number; /** * View zoom-out factor (default 1). Renders a larger world region into the * canvas, so effects authored for a full-size game view fit inside small * thumbnails. Emitter/mouse coordinates stay in canvas pixels. */ zoom?: number; /** Particle sprite size multiplier (default 1) — keeps thumbnails legible. */ sizeScale?: number; /** * Render particles as an animated atlas sequence (e.g. a spinning coin). The * atlas is a uniform grid: each ROW is a sequence, each COLUMN a frame. A * random row is picked per particle (or fixed), the column advances with age. */ atlas?: AtlasOptions; /** * Make this a SUB-EMITTER of another running effect: its particles spawn on * the death of the parent's particles (at the death position). The parent * handle must share the same canvas/context and be updated before this one. * The child mirrors the parent 1:1 and inherits the parent's pool capacity. */ subParent?: ParticlesHandle; /** * Emit only inside a painted/image area: opaque texels of `image` become * spawn positions (replaces the graph's analytic spawn shape). The mask is * centred on the emitter and moves with it. Ignored for sub-emitters. */ emissionMask?: EmissionMaskOptions; /** Called when the GL context is lost. Recovery is automatic; this is for UI. */ onContextLost?: () => void; /** Called after the context came back and the effect was rebuilt. */ onContextRestored?: () => void; } interface EmissionMaskOptions { /** mask image — see `channel` for which pixels emit */ image: TexImageSource; /** world width the mask maps to (px); height defaults to the aspect ratio */ width: number; height?: number; /** offset of the mask centre from the emitter (px, default [0, 0]) */ offset?: [number, number]; /** * Which channel is the mask. 'alpha': transparent = empty, opaque = emit. * 'luminance': black = empty, white = emit (for opaque B/W textures). * 'auto' (default): alpha when the image has transparency, else luminance. */ channel?: 'alpha' | 'luminance' | 'auto'; /** * Treat gray/semi-transparent texels as spawn DENSITY (default true): * white/opaque areas emit up to 4× more often than faint ones. false = hard * stencil at the 50% threshold. */ weighted?: boolean; } interface AtlasOptions { /** A loaded image / bitmap / canvas. */ image: TexImageSource; cols: number; rows: number; frameW: number; frameH: number; pad?: number; /** atlas pixel size (derived from the image if omitted). */ width?: number; height?: number; /** frames/second when looping (default 12). */ fps?: number; /** see `AtlasPlay` — 'loop' (default), 'once' (stretched over life), 'hold'. */ play?: AtlasPlay; /** 'per-particle' (default) random sequence per particle; 'per-spawn' fixed. */ pick?: 'per-particle' | 'per-spawn'; /** which row when pick === 'per-spawn' (default random). */ row?: number; } declare function createParticles(target: HTMLCanvasElement | WebGL2RenderingContext, project: PylinkaProject, opts?: ParticlesOptions): ParticlesHandle; export { type AtlasOptions, type EmissionMaskOptions, type EngineParams, INTERPRETED_KINDS, type KnobValues, type ParticlesHandle, type ParticlesOptions, WebGL2Engine, createParticles, extractParams, isInterpreted, parseColor, unsupportedNodes };