feat(fluids): one GPU particle pool, stepped by a WebGPU compute pass; WebGL2 refused by name until #759 (#420) - #776
Merged
Merged
Conversation
This was referenced Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #420
What changed
Scope after the CTO's split:
420-particle-webgl2ate3db5fd3f: the half-float ping-pong stepparticles/webglParticles.tswithwebglParticles.test.ts, stepped by the composer. It uses the same emitter-relative layout.420-particle-benchat1b10fc595, stacked on the WebGL2 branch:--particles 16k|64k|256kand--particles-update-onlyon--scene fluids.Changes:
packages/sdk-core/src/fluids/particles.ts:ParticlePool.Float32Arraymade at creation, eight words each: position then age, velocity then lifetime.flush()hands each image its step{ first, count, dt }as the same object every time.dtis clamped to 1/15 s. An idle pool (nothing staged, nothing alive) takes no time, so it neither dispatches nor breaks the hold.origin, the emitter's place in the world, fixed for its life.emit()takes world positions and subtracts the origin in double precision before rounding to 32 bits. A particle 10 km out still moves by 0.4 mm per step.packages/sdk-browser/src/particles/webgpuParticles.ts:PARTICLES_WGSLruns one compute pass labelledTrillion3D particles.passesGpushows it, and the stage profile files it underphysics.particles-unavailable, and its pools arerefused: they stop asking frames and count what is emitted into them asdropped.createCheckedShaderModule. Bindings use the sharedbounceLayoutandbounceGrouphelpers.encodeBlend, the stage every WebGPU image path reaches, beside the water pass.particlesMovedbreaks the hold, next toguidesMovedandeffectsMoved.worldSwitches.held.particles. They flow through the session options intoBackendContext.particlesand the WebGL2 composer, the same path guides and effects take.attachParticles(world, pool)andParticlePool.attachParticlesadvances the pool by the world's frame time, and keeps asking for frames while the pool moves, checked after every frame hook has emitted. A pool attached twice, or to a world never made, is refused asPARTICLES_ATTACH.PARTICLES_UNSUPPORTEDrather than drawing without the particles, until Particles update on WebGL2 and keep their precision anywhere in the world #759 lands (AGENTS.md rule 1, Streaming without holes: rules and objectives for geometry, memory and shadows #483). It marks the poolsrefusedfirst, so they ask no frame of their own.docs/ENGINE.md(physics timing): thephysicsstage's GPU column is the particle step.Proof
node --test packages/sdk-core/src/fluids/particles.test.ts: 6 pass. They cover:node --test packages/sdk-browser/src/particles/particles.test.ts: 3 pass.node --test packages/sdk-browser/src/world/core/worldSession.test.ts:attachParticlesholds the pool, refuses a second attach and a world never made, asks for frames only while it moves, hands the world's frame time to the step, and is undone by the remover, which asks one image.node --test packages/sdk-browser/src/world/render/compose.test.ts: a world with a pool is refused by name on WebGL2, never drawn without it, and its pool stops moving.engineShaders.test.ts:PARTICLES_WGSLis on the engine's shader list and declares every name it uses.pnpm run build:native, aftergit submodule update --init;pnpm run validate --group typescript;pnpm run check:changed(1384 pass);pnpm run test:changed;pnpm run validate --group quick.node scripts/check-pr-size.ts: 597 hand-written lines added (limit 600).node --experimental-strip-types tests/browser/probes/particles-step-gpu.ts, a new probe thatpnpm run test:gpualso runs. It compilesPARTICLES_WGSLon a real WebGPU device and runs one step of a pool whose origin is 10 km out. The newborn particle must drift 0.4 mm and rise, and age by the step. The particle born dead must stay as staged, and the never-emitted slot untouched. Only the probe reads the state back.encodeBlend, the hold check and the composer:pnpm run test:gpu.420-particle-bench, which lands with Particles draw without a global sort: additive fire, sorted-per-emitter smoke, soft edges #755. The command then isnode bench/runner/bench.ts --scene fluids --moteur webgpu --particles 16k|64k|256k [--particles-update-only].Local review before push
releasefor a let-go pool anddispose, which also frees a released pool while an idle one stays; dispatches only over the emitted slots; the pool's deadmoving ?branch inflush; gravity fromGRAVITY_PRESETS.earth; a one-caller test helper inlined and a duplicated layout assertion dropped;particlesMovedin one line; comments trimmed to the line budget; skipped: the probe's bindings throughnamedBufferEntries(the probe cannot be run here), the glue moved towebgpu/pages/render/and the registry folded intoAccess(lines,world.tsat its 200-line cap), the WebGL2 refusal moved intoattachParticles(only the composer knows the engine does not step), the frame's step settled on submit and the WGSL load reordered (lines).bounceLayoutandbounceGroup;ParticleBackendinterface removed (it returns with Particles update on WebGL2 and keep their precision anywhere in the world #759);moving,particlesMovedbesideguidesMovedandeffectsMoved), andattachParticlesasks for frames only while it moves;setPipelineonce per pass;createStepWords).particlesas a field of the registry's access record:world.tsis at its 200-line cap;attachParticles: the composer's refusal also covers a session reopened on WebGL2.onFrame, after every hook's emission; WebGL2's refusal marks the poolsrefused, so it does not repeat at display rate; a pool attached twice or to a world never made is refused (PARTICLES_ATTACH); the remover asks the image that frees the buffers. Skipped: one pool attached to two worlds (3-4 lines past the budget, an internal entry until Fluids P2: GPU particles, fire, smoke flipbooks, heat distortion, wind API #423); an emission from outside a frame on an idle world wakes nothing (Fluids P2: GPU particles, fire, smoke flipbooks, heat distortion, wind API #423); the WebGL timer query left open by the composer's throw (same as the existingHOST_DRAW_UNSUPPORTED, belongs todraw.ts); a harmless extrareleaseon a pool's first frame. Auditor list:Closes #420on the scope the CTO's split comments leave; the lead's design note followed (WebGPU step beside the water, own timed pass, no allocation per frame, no GPU→CPU read in the engine, emitter-relative positions, WebGL2 refused by name); every changed behaviour has a fast test that fails ondevelop; ENGINE.md follows; no path without a pool changes (an empty pool list returns before any work, the hold check sees no pool moving).Trillion3D particleshas its row in the stage table (physics) instead of swellinggeometry.moving, so the world redraws without end. This happens if the pipeline fails to compile (fixed by the reviewer: the pools arerefused).PARTICLES_ATTACH).Lead verification
Read by the lead on head 28598dd (054a73f reviewed OK, then a clean merge of develop) against #420 as split by the CTO (drawing → #755, TAA and flipbooks → #756, WebGL2 backend → #759), its design note and scope comments, and #417's rules 1–3, 8 and 10.
createCheckedShaderModule, the bounce bind-group helpers and the water's place inencodeBlend; the open world's code is not read into it.ParticlePool: ring cursor, fixed staging, zero allocation after creation, emitter-relative positions per the CTO), proved bythe ring wraps past capacity onto the oldest slots, and allocates nothing after creation,a full staging refuses and counts; the step clamps its time and consumes itanda particle ten kilometres out keeps its sub-millimetre steps: positions are from the origin(sdk-core/src/fluids/particles.test.ts).PARTICLES_PASS, dispatch over the emitted slots only, idle pool = no dispatch), run from webgpu/pages/render/encodeBlend.ts:36, proved byWebGPU: one timed pass writes the step words and the staged records, onceandWebGPU: a still frame is held until one of the world's pools moves(sdk-browser/src/particles/particles.test.ts), and on a real device by the probe tests/browser/probes/particles-step-gpu.ts (measurer, after the merge per the CTO's rule).PARTICLES_UNSUPPORTED, world/render/compose.ts:39) until Particles update on WebGL2 and keep their precision anywhere in the world #759, proved bya world with a particle pool is refused by name on WebGL2, never drawn without it(compose.test.ts:123).attachParticles, refused twice or without a world:PARTICLES_ATTACH), proved byattachParticles gives the pool to every session and the frames the world draws (#420)(worldSession.test.ts:19).WebGPU: a step that cannot compile is heard, and its pools stop asking frames.physics(docs/ENGINE.md).ParticlePool,createWebgpuParticles,attachParticlessearched in sdk-core and sdk-browser: no twin.attachParticlesis a measurement entry.in reviewremoved andto measureset at hand-over.Not proven / left out
physicsstage is my choice; the measurer should confirm it.