Particles: a CPU simulator, a four-vertex draw, and three places that disagreed
Particles are the feature where every engine quietly reveals its architecture, because they touch simulation, rendering, asset authoring and importing all at once, and they are allowed to be approximate.
Awaken simulates them on the CPU and draws them with one instanced call per emitter. Both halves are more interesting than they sound.
Why the CPU, in 2026
The fashionable answer is a compute shader. I did not write one, for three reasons.
A particle system's cost is rarely the integration. It is the authoring loop: tweak a curve, see it, tweak again. A CPU pool is trivially inspectable, steppable and unit-testable, and the editor preview runs the same integrator the game does rather than an approximation of it.
Second, determinism. The pool uses a seeded mulberry32 and never Math.random, and that is tested. The same effect with the same seed produces the same particles on every machine, which makes a whole class of "it looks different here" question answerable.
Third, the seam is already there. The simulator is a pure function: advance a pool by dt, write a flat instance buffer. A compute backend replaces the integrator and the upload, and nothing above it (the asset, the component, the GPU record) moves. Writing the seam first cost nothing; writing the compute path before I had a single game that needed it would have cost the authoring loop.
The pool is arrays, not objects
The live pool is structure-of-arrays: x, y, z, vx, vy, vz, life, rotation, size, each a Float32Array sized to maxParticles. No per-particle objects, no allocation during a burst.
It is side-state keyed by entity, never serialised, and it bypasses the ECS dirty-set entirely. That last part matters: a per-frame particle update must not mark the world structurally changed, or every frame costs a full draw-cache rebuild. CPU skinning follows the same rule for the same reason.
Trail history is allocated only for an effect that has a trail, as a ring buffer of samples per particle. An emitter without a trail pays nothing, not even an empty array.
The draw: no vertex buffer at all
Per emitter, the renderer keeps a storage buffer of 16-float instance records and issues:
pass.draw(4, count)Four vertices, count instances, and no vertex buffer bound. The vertex shader expands a 4-vertex triangle strip per particle from the instance record. There is no geometry to upload because a billboard has no geometry worth uploading.
The record is 16 floats, 64 bytes: pos.xyz, size, rgba, rotation, vel.xyz, ageFrac, and three reserved lanes. It was 12 floats until ageFrac had to be added, and the reason is a nice illustration of why you cannot always recompute on the GPU: the fragment stage needs the normalised age to pick a flipbook cell, and age is not recoverable from anything else that reaches the GPU. The CPU has already collapsed it into colour and size through two user-authored curves that are routinely constant and never required to be monotonic. The three reserved lanes are the padding WGSL would have inserted anyway, so they cost nothing.
The buffer grows in power-of-two steps from a minimum of 64, so a ramping emitter reallocates a handful of times rather than every frame.
The bug that ran for weeks
Three places construct the render options for an effect: the runtime's scene emitters, the runtime's one-shot bursts, and the editor's live preview, which bypasses the runtime entirely.
For a long time each picked the fields it needed out of the effect by hand. Every new field therefore reached some of them and not others, and the symptom was always the same: the preview looked different from the game.
That is the most confusing divergence a tool can have. You tune an effect until the preview is right, press Play, and it is wrong. You do not suspect the plumbing, you suspect your eyes, and then you tune it again in the other direction.
The fix is boring and total. One function derives the render options, all three call it, and it tolerates a config written before a field existed, so an old project cannot put undefined into a GPU float lane where it becomes NaN rather than 0.
The general shape: when N call sites each destructure the same struct by hand, a new field is a bug with N-1 chances to happen, and it will manifest as a difference between two views of the same thing rather than as an error.
What arrived later
The first version had gravity and drag and nothing else. Three additions changed what could actually be built:
- Trails. A ribbon per particle, with a
minDistancebefore a new sample is recorded (zero records every frame, which is smooth until the particle slows and the ribbon collapses on itself) and aratioso only a fraction of particles get one. A projectile with no trail reads as a dot with no motion. - Sub-emitters. Child effects spawned as particles are born or die, with a probability. A spark that leaves smoke, a shell that bursts on landing. Without them the secondary effect is not approximated, it is simply absent.
- Mesh mode. Draw real geometry instead of a card. Debris, coins and shell casings stop being flat.
Plus a constant force distinct from gravity (a wind or a jet is not a fall, and sources expose them separately), and a speed ceiling that pulls a particle back toward the limit rather than clamping it dead, because smoke is authored to launch hard and settle.
Importing is approximation, and says so
A Unity ParticleSystem or a Godot GPUParticles3D becomes an Awaken effect plus an emitter, carrying the concepts all three engines share: rate, bursts, lifetime, shape, speed and spread, gravity, colour and size over life, and now trails, sub-emitters and emission by distance.
Everything richer is sampled down, and the docs say exactly how: source curves and gradients keep their endpoints, Godot's explosiveness becomes either a burst or a continuous rate, and its damping converts to exponential drag.
This is the one importer where I am comfortable approximating, and it is worth saying why, because the Unreal material work refuses to. A particle system is a look, tuned by eye against a preview, and the person importing it will open it and tune it again. A material is a computation whose author is not in the room. Approximating the first invites a small edit; approximating the second ships a lie.
The one I would change
Particles are still world-space only. worldSpace is stored and shown, and every particle is integrated in world space after spawn, with the emitter's transform applied at spawn alone. Move an emitter and it drags a trail behind it rather than carrying its particles along.
For sparks and smoke that is correct and is what you want. For an effect attached to a fast-moving object it is visibly wrong, and it is the next thing I would fix.