Core concepts
Projects, systems and graphs
A project is the top-level document. It holds one or more systems, a set of project-wide knobs, and any texture assets. Each system is one particle system: a graph, an emitter, a pool capacity and a blend mode.
The graph inside a system is a typed, acyclic dataflow of
nodes joined by edges. Ports carry one of five types:
f32, vec2, vec4, color, bool.
A system is well-formed once it has exactly one output.spawnPosition and one
output.initLife. Everything else is optional.
What costs a recompile
Every edit lands in one of two buckets, and the split is why the editor feels instant:
| Kind of edit | Example | Cost |
|---|---|---|
| Value | drag a number, change a colour, move a knob | one uniform write, next frame |
| Structure | add or remove a node or edge, change an ease | recompile, around 30 ms, debounced |
Unconnected input values are lowered to GPU uniforms, so scrubbing them never recompiles. Only a change to the shape of the generated code does.
Knobs
Any value can be promoted to a knob: a named control like
windPower with a range and a linear or log scale. Knobs are shared across a project
and driven at runtime.
pylinka.params.set('windPower', 40); // fans out to every system using it Promotion is free. The uniform slot already exists, so it never triggers a recompile.
World space and moving emitters
Particles live in the view's local space, and the emitter position is consumed only at spawn. Move the emitter and you change where new particles are born while the existing ones stay put. A coin flying across the board leaves sparks that hang and fade where they were made.
That is why the view belongs on a static layer with the target driving the emitter
through follow(). Reparent the view onto a moving sprite and every live particle
gets transformed with it, which throws away the whole point of world-space simulation.
Emission
Emitters run in one of three modes:
- flow, a steady
ratein particles per second, optionally withrateOverDistance, particles per pixel the emitter travels, for trails without gaps. - burst, a
counteveryintervalseconds. - once, a single burst on start.
Within a frame, spawns are spread along the emitter's path. Fast trails come out continuous instead of arriving in clumps at 60 Hz.
Forces, obstacles and solids
Forces accumulate. Every field.* node wired into an output.addForce
adds into the same register, then the integrator applies the sum once. Gravity, wind, drag,
radial pull, vortex and turbulence all stack this way.
field.obstacle is the one that behaves like a body rather than a field. It pushes
particles out of a disc with a falloff you control, adds a tangential swirl, and drags them
toward its own velocity. That last term, carry, is what makes a moving obstacle
read as moving: particles pile up in front of it and curl in behind. Its centre and
velocity are ordinary vec2 ports, so binding them to knobs lets a cursor or a
flying sprite drive the thing every frame without touching the graph.
Solid geometry is separate, because it happens after the integrator rather than inside it. The
output.collide* family covers a plane (floor, wall, slope), a rectangle used either
as a container or as a crate to bounce off, and a circle. Each one resolves the penetration
first, putting the particle back on the surface, and only then reflects the normal component of
velocity with restitution and friction. Reflecting without correcting
the position is how you get particles that sit outside a wall and flip sign every frame.
All four take a structural space. In world space the coordinates are
absolute, which is what a cursor knob wants. In emitter space they are offsets from
the emitter, so a floor stays under a character that walks around, and an effect keeps working
when the canvas changes size.
You pay for these only when you use them. The compiled backends generate code from your graph, so a graph without an obstacle contains no obstacle instructions at all. The interpreted WebGL engine builds one shader for everything, so it splices those blocks in only when the graph actually holds those nodes.
Rotation
A sprite’s angle is three independent terms that add up, and picking the wrong one is the
usual reason rotation looks broken. output.writeRotation sets an ANGLE. Wiring a
constant into it pins every particle at that angle forever, which is not turning — it is
just a different still frame.
- Angle at birth —
output.initRotation. Feed it agen.randomRangeso each particle starts somewhere different, or a literal to pin them all. Without this every particle spawns at zero, so even a working spin stays in phase across a burst and the whole thing reads as one rigid object. - Spin while alive —
gen.spinintooutput.writeRotation. Itsrateis an angular VELOCITY in radians per second, integrated over the particle’s own age. A negative rate turns the other way, and agen.randomRangeonrategives every shard its own tumble. - An exact sweep —
gen.rotationOverLife, which eases from one angle to another across the lifetime. This is the one for a tile that turns exactly ninety degrees as it falls and then stops.
Every angle port in the catalog is radians, because that is what the trigonometry takes. Drop a
math.radians node in front of one to author in degrees instead — the
rotation recipes are all wired that way, so forking one gives you the
pattern to copy.
Rotation turns the sprite quad, not the texture lookup, so an animated atlas cell rotates as a whole instead of shearing. The default untextured particle is a soft radial dot and is symmetric under rotation — if nothing appears to turn, check that the system has a texture with a visible orientation.
Sub-emitters: spawning from another emitter
A system can be born from another system's particles instead of from the cursor. Point its parent at the emitter you want in the emitter strip, then pick which moment fires it:
- on their deaths — debris where a projectile ends, sparks where a rocket burns out. This is the default and what sub-emitters always did.
- on their births — a flash the instant one appears. A bolt of lightning and the light it throws are born on the same frame, and this is how you say that.
Both are one-frame edges on the same parent slot, read from the same buffers, so a birth costs
exactly what a death costs. The setting lives on the child's output.deathBurst node
(on), which also decides how many children each event spawns and how much of the
parent's velocity they inherit — one for a flash, a dozen for an explosion.
Sprite sheets: which modes read fps
An animated atlas advances through its columns one of three ways, and only two of them look at
fps:
- loop at fps — cycle the columns forever.
- stretch over life — the strip is mapped across the particle's lifetime,
so the sequence always finishes exactly as the particle dies, whatever its life.
fpsis ignored. This is the mode where changing fps looks broken; it isn't, the mode simply has no use for it. - once at fps, hold — play through once at
fps, then stay on the last frame.
Reach for hold when the frame rate is the thing you mean, and stretch when “the animation finishes with the particle” is.
Pools and lifetime
Each system pre-allocates a fixed capacity of particle slots. A particle dies on
its own when age reaches life, so there is no kill node for end of life. If emission outruns
capacity the new particles are dropped and overflowCount ticks up. The editor warns
when rate multiplied by max life exceeds capacity, which is the usual cause.
Determinism
Pylinka uses an integer-hash PRNG. Runs are bit-reproducible on the same device, driver and fixed-step mode. Across different GPUs float math drifts, so expect statistical similarity rather than identical trajectories. Nothing here is a cross-client sync guarantee, and building gameplay on one would be a mistake.