Skip to content

feat(fluids): WebGL2 steps the particle pools in RGBA32F ping-pong targets, refused by name without them (#759) - #778

Merged
pasquelin merged 14 commits into
developfrom
759-particle-webgl2
Sep 26, 2026
Merged

pasquelin merged 14 commits into
developfrom
759-particle-webgl2

Conversation

@pasquelin

Copy link
Copy Markdown
Owner

Closes #759

This branch sits on #420's branch (420-particle-pool, 054a73f), which is not merged yet. Once #420 merges it is rebased on develop, and the diff below then holds only #759's commits.

Design change (CTO decision, recorded on #759). The design note called for half-float ping-pong targets. Half floats stop a particle's age from growing: at about 16 s at 144 Hz, or 64 s at 60 Hz. A longer-lived particle then never died on WebGL2, and positions lost precision past about 32 m from the emitter. The CTO chose one format instead: RGBA32F targets, with EXT_color_buffer_float required. Without that extension, every WebGL2 pool is refused by name (PARTICLES_UNSUPPORTED). The half-float path is removed entirely: there is no EXT_color_buffer_half_float fallback and no lifetime cap. WebGL2 now keeps the same 32 bits as WebGPU.

What changed

  • packages/sdk-browser/src/particles/webglParticles.ts: the WebGL2 particle step.
    • Each pool has two RGBA32F targets (512 particles per row, two texels each) and a third for its staged records, allocated once when the pool first moves.
    • One full-screen fragment pass draws them in turn, reading the other target and the pool's staged records.
    • The records are uploaded as RGBA32F texels straight from the pool's staging: whole rows, then the rest, with unpack flip and premultiply turned off.
    • The viewport covers only the rows the emitted slots fill. An idle pool (no record, no time) draws nothing.
    • Positions are emitter-relative (the pool's origin), so the step is translation-invariant and no origin goes into the uniforms.
    • Nothing is allocated per frame and nothing is read back.
    • A lost context drops the targets along with the program.
    • Each image asks for EXT_color_buffer_float (floatTargets, beside halfFloatTargets). A context without it, first or restored, sets every pool refused and throws PARTICLES_UNSUPPORTED. A context that grants half floats alone is refused too. Once the extension is granted again, the pools step again.
  • packages/sdk-browser/src/webgl/core/renderTarget.ts: a float option (RGBA32F, FLOAT, NEAREST) on the existing render target, instead of a second target made by hand, and floatTargets.
  • packages/sdk-core/src/fluids/particles.ts: the largest pool's WebGL2 texture pair is 1024 × 2048.
  • packages/sdk-browser/src/particles/poolStates.ts: the part the two steps share (AGENTS.md rule 6), and nothing more:
    • createPoolStates makes a pool's state the first time it moves, and frees it on the first image after the world lets the pool go.
    • usedSlots gives the dispatch or rows that cover only the emitted slots.
    • anyMoving is the frame-hold rule.
    • webgpuParticles.ts now uses all three in place of its own map and release.
  • packages/sdk-browser/src/world/render/compose.ts: Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420's WebGL2 refusal is replaced by the step.
    • The step is made by the first pool, then runs on every image an engine draws on the host, after present (an engine that presents its own surface steps its own pools).
    • The destination is bound again after a step that drew.
    • An image in which a pool moved is drawn, never the kept copy.
    • The step's targets are freed when the composer is disposed.
  • packages/sdk-browser/src/particles/stepModels.fixture.ts: CPU models of both steps. Each one runs its shader's arithmetic in 32-bit floats on exactly what its step handed the GPU:
    • WebGPU: the words, the records and the workgroup count.
    • WebGL2: the uniforms, the texel uploads and the viewport rows, in two targets in turn.
    • webgl(), a fake context's WebGL2 step, shared by both test files.
  • tests/browser/probes/particles-step-gpu.ts: Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420's WebGPU probe also steps a 60 s life at 144 Hz for 62 s and reads it dead.
  • tests/browser/probes/particles-step-webgl2.ts with its page particlesStepWebglPage.ts: the engine's own createWebglParticles on a real WebGL2 context in Chromium. Readback happens in the probe page only.
  • docs/ENGINE.md: one line on the WebGL2 step and its refusal.

Proof

  • New fast tests (they fail before this change: the step does not exist, and the composer refused every pool):
    • webglParticles.test.ts: the step's words (texel uploads by row, uStep, uRing), the viewport over the emitted rows only, the ping-pong targets alternating, no draw without time or record, targets made once and freed once the world drops the pool.
    • webglParticles.test.ts: without EXT_color_buffer_float, with the half-float extension alone, the pools are refused by name and stop asking for frames.
    • webglParticles.test.ts: a context that stops granting EXT_color_buffer_float (restored without it) refuses the pools by name, never with an incomplete target; granted again, they step again.
    • particles.test.ts: 64 images of a reference emission (5 particles an image, speeds up to 5 m/s, a ring that wraps). The WebGL2 and WebGPU models give exactly the same 32-bit floats on every slot. The test fails on a 0.01% acceleration error and on a wrong ring start.
    • webglParticles.test.ts: 10 steps of 1 mm, 10 km from the world origin, each within 2^-20 m.
    • webglParticles.test.ts: a 60 s lifetime stepped at 144 Hz dies within 3 steps of step 8640, and after that it neither ages nor moves while its pool keeps stepping. With the half-float storage of the earlier version this test fails, because the age stops growing.
    • compose.test.ts: the pools step before the engine draws, and the destination is bound again after; an idle pool lets the kept frame be reused and a moving one does not; the refusal by name.
    • renderTarget.test.ts: a float target is RGBA32F, FLOAT, NEAREST.
  • pnpm run check:changed, pnpm run test:changed (1453 pass after review), pnpm run validate --group quick, pnpm run validate --group typescript: green.
  • Size: node scripts/check-pr-size.ts counts 1143 against develop, because it includes Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420's lines. Against origin/420-particle-pool this branch adds 598 lines.
  • Measurer's proof, WebGL2 in Chromium (not run here, AGENTS.md rule 2): node tests/browser/probes/particles-step-webgl2.ts. It checks:
    • the GLSL compiles;
    • a newborn 10 km out moves 1 mm (within 1 µm) on each of two images, the second read from the other target;
    • a particle born dead stays as staged, and a slot nobody emitted into stays untouched;
    • a 60 s-lifetime particle stepped at 144 Hz dies at 60 s and stays where it died while its pool steps on.
  • Measurer's proof, WebGPU in Chromium (not run here): node --experimental-strip-types tests/browser/probes/particles-step-gpu.ts. Beside Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420's checks, a 60 s-lifetime particle stepped at 144 Hz for 62 s has an age between 60 s and 60.02 s: it died at 60 s and aged no more.

Local review before push

  • Simplification pass: reviewer: the real simplify skill, 4 review agents (reuse, simplification, efficiency, altitude), 633 lines brought to 593. Fixed: the WebGPU-vs-WebGL2 comparison test moved into particles.test.ts beside its original computeRecorder (no more moved recorder), the fake-context webgl() helper in the fixture; the staging texture made by createWebglRenderTarget(…, { float: true }) instead of by hand (rule 6); floatTargets beside halfFloatTargets and checked each image, which also refuses by name a context restored without the extension (new test); unpack flags set once per image; anyMoving takes the world's optional list; shorter comments; a probe assertion implied by the one before, and a probe unbind, removed. Skipped: keep as plain release (a Map iterator per image); two render passes into MRT (a To-do for later); async program compile (no KHR_parallel_shader_compile in any WebGL pass yet); one format option instead of hdr/float (three callers outside the diff); dropping the frame-hold gate until Particles draw without a global sort: additive fire, sorted-per-emitter smoke, soft edges #755 draws particles (the design requires it); a fakeDevice compute recorder (outside the diff).
    • coder: 2 review agents (reuse and altitude; simplification and efficiency).
    • Fixed: one anyMoving rule in poolStates.ts in place of a moving predicate defined in two files; the redundant viewport call per pool (the framebuffer is bound directly); computeRecorder shared by both tests instead of a second fake encoder; the pool states moved inside boundToContext, so a lost context drops them with the program and the forget special case is gone.
    • Skipped: an endFullscreenPass helper and a float data-texture helper (both would rewrite effects/webglEffects.ts and webgl/cluster/rectGlsl.ts, outside this issue); dropping the step's final framebuffer unbind (part of its contract); caching the refusal error (failure path only); the probe's drawArrays hook (the engine would need a probe-only accessor).
  • Correctness review: reviewer: the real code-review skill (--fix), 8 findings. Fixed: a WebGL2 step clears a pool's refused as the WebGPU step does, so a pool refused once steps again when it can (test); the largest pool's WebGL2 pair is 1024 × 2048, not 1024 × 1024. Skipped: targets of a released pool kept while an engine presents its own surface (freed at dispose; the step runs only after present by design); a capture steps the pools (as before, invisible); getExtension each image (deliberate, tested); a target failing mid-loop leaves the pass state bound (failure path); French probe names and the run line, as the sibling probes write them. Auditor's list: every To-do and Proof item delivered under the CTO's decision (RGBA32F replaces half floats; the WebGL2 model matches WebGPU exactly instead of within half-float tolerance); 60 s death shown by the fast test and both probes; no image loss; no readback in the engine; no allocation per image; labels in review, physics; ENGINE.md follows.
    • coder: 2 review agents, 10 findings, then a second pass on the 32-bit commit with 4 findings.
    • Fixed: the staged upload turns off UNPACK_FLIP_Y_WEBGL and UNPACK_PREMULTIPLY_ALPHA_WEBGL, which image textures leave on; the context-bound rebuild asks for the extension again after a restore; the build no longer leaves its program bound; the WebGL model uses PARTICLE_FLOATS instead of a literal; the WebGL model steps each slot once.
    • The half-float age finding is resolved by the CTO's decision above.
    • Skipped (reported to the lead):

Lead verification

Read by the lead on head ad931b0 (15e33bc reviewed OK on #420's branch, then a clean merge of develop after #776) against #759, its design note, the CTO decision (RGBA32F only) and the recorded deviation (shared poolStates.ts, no single backend type).

  • WebGL2 particle update behind Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420's pool, and the composer hook that runs it: delivered in packages/sdk-browser/src/particles/webglParticles.ts:53 (createWebglParticles: RGBA32F ping-pong targets made once through createWebglRenderTarget(…, { float: true }), only emitted rows drawn, targets swapped per image) and world/render/compose.ts:40 (the step after an engine that presents its own surface; an image the pools moved in is never the kept copy), proved by WebGL2: the records land as float texels, only emitted rows are drawn, the targets swap (webglParticles.test.ts:10) and WebGL2 steps the pools ahead of the engine, which draws an image they moved in (compose.test.ts:123).
  • One format, refused by name without EXT_color_buffer_float (CTO decision): delivered in webglParticles.ts:104 and webgl/core/renderTarget.ts:37 (floatTargets), proved by WebGL2 without a 32-bit float colour target refuses the pools by name, half floats too (webglParticles.test.ts:41), WebGL2: a context restored without 32-bit float targets refuses the pools, until granted (:49) and WebGL2 without a 32-bit float target refuses the pools by name, never draws without them (compose.test.ts:140).
  • Precision held anywhere in the world (emitter-relative): delivered by Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420's pool origin, no origin in the uniforms, proved by WebGL2: a 1 mm step holds ten kilometres from the world origin (webglParticles.test.ts:63).
  • Proof, the WebGL2 step matches the WebGPU step: delivered, proved by WebGL2 and WebGPU step a reference emission to the same 32-bit floats (particles.test.ts:92).
  • Proof, a 60 s lifetime dies on both backends (CTO): delivered, proved by WebGL2: a particle of a 60 s lifetime dies after 60 s of 144 Hz steps, and stays put (webglParticles.test.ts:78) and on real devices by the probes tests/browser/probes/particles-step-webgl2.ts and particles-step-gpu.ts (measurer, after the merge per the CTO's rule).
  • Whole promise: delivered, every To-do and Proof item of Particles update on WebGL2 and keep their precision anywhere in the world #759.
  • Tests that bite: delivered, the new tests fail on develop (WebGL2 step absent; the 60 s test fails on half floats, the equivalence test on a 0.01 % acceleration error — checked by the coder); no delay.
  • No image loss: delivered, nothing is drawn yet (Particles draw without a global sort: additive fire, sorted-per-emitter smoke, soft edges #755); a WebGL2 world without a pool takes no new work and keeps its held frame.
  • Reuse: delivered, the staging and ping-pong targets come from the one createWebglRenderTarget (a float format, not a twin); poolStates.ts is what both steps share; createWebglParticles searched: no twin.
  • Docs follow the code: delivered, docs/ENGINE.md (the WebGL2 step, its format and refusal); no public API (Fluids P2: GPU particles, fire, smoke flipbooks, heat distortion, wind API #423).
  • Measured first: delivered, the fluids spike; its cost is the measurer's after the merge, per tier with Particles draw without a global sort: additive fire, sorted-per-emitter smoke, soft edges #755's bench.
  • Path: delivered, the CTO decision and the deviation are written on Particles update on WebGL2 and keep their precision anywhere in the world #759 before the merge; in review removed and to measure set at hand-over. Left to Particles draw without a global sort: additive fire, sorted-per-emitter smoke, soft edges #755: async compile and warm-up of WebGL2 programs (Fluids: water, fire and smoke beside Jolt #417 rule 8), one fragment per particle (MRT), one step state per engine.
  • Streaming without holes: rules and objectives for geometry, memory and shadows #483 and CONTRIBUTING.md §Streaming, memory and shadows: delivered, rule 8 (WebGL2 degraded by a named refusal, never broken), zero allocation per frame, no readback in the engine.
  • rounds: 2 (the CTO's format decision sent the coder back once)

Not proven / left out

  • The GPU run itself: the GLSL compiles, the RGBA32F targets render, the readback matches, and the 60 s death happens. This is the measurer's WebGL2 probe above.
  • In a comparison that shows WebGPU and WebGL2 side by side, both steps flush the same pool, so the second one sees no records and no time. The fix belongs to the pool (Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420).
  • A WebGL2 context without EXT_color_buffer_float shows no particles; it refuses them by name.

pasquelin and others added 14 commits September 26, 2026 05:03
…asks its extension again after a lost context (#759)
…or the staging and checks its extension each image; the comparison test sits beside the WebGPU recorder (#759)
…it; the largest pool's WebGL2 texture pair is 1024 x 2048 (#759)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

audited Image proved and promise kept (recette)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant