feat(fluids): WebGL2 steps the particle pools in RGBA32F ping-pong targets, refused by name without them (#759) - #778
Merged
Merged
Conversation
…the slots they cover (#759)
…r context, one moving rule for both steps (#759)
…asks its extension again after a lost context (#759)
…xt without EXT_color_buffer_float (#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)
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 #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_floatrequired. Without that extension, every WebGL2 pool is refused by name (PARTICLES_UNSUPPORTED). The half-float path is removed entirely: there is noEXT_color_buffer_half_floatfallback 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.origin), so the step is translation-invariant and no origin goes into the uniforms.EXT_color_buffer_float(floatTargets, besidehalfFloatTargets). A context without it, first or restored, sets every poolrefusedand throwsPARTICLES_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: afloatoption (RGBA32F,FLOAT,NEAREST) on the existing render target, instead of a second target made by hand, andfloatTargets.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:createPoolStatesmakes a pool's state the first time it moves, and frees it on the first image after the world lets the pool go.usedSlotsgives the dispatch or rows that cover only the emitted slots.anyMovingis the frame-hold rule.webgpuParticles.tsnow 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.present(an engine that presents its own surface steps its own pools).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: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.tswith its pageparticlesStepWebglPage.ts: the engine's owncreateWebglParticleson 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
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: withoutEXT_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 grantingEXT_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: afloattarget 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.node scripts/check-pr-size.tscounts 1143 against develop, because it includes Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420's lines. Againstorigin/420-particle-poolthis branch adds 598 lines.node tests/browser/probes/particles-step-webgl2.ts. It checks: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
simplifyskill, 4 review agents (reuse, simplification, efficiency, altitude), 633 lines brought to 593. Fixed: the WebGPU-vs-WebGL2 comparison test moved intoparticles.test.tsbeside its originalcomputeRecorder(no more moved recorder), the fake-contextwebgl()helper in the fixture; the staging texture made bycreateWebglRenderTarget(…, { float: true })instead of by hand (rule 6);floatTargetsbesidehalfFloatTargetsand checked each image, which also refuses by name a context restored without the extension (new test); unpack flags set once per image;anyMovingtakes the world's optional list; shorter comments; a probe assertion implied by the one before, and a probe unbind, removed. Skipped:keepas plainrelease(a Map iterator per image); two render passes into MRT (a To-do for later); async program compile (noKHR_parallel_shader_compilein any WebGL pass yet); oneformatoption instead ofhdr/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); afakeDevicecompute recorder (outside the diff).anyMovingrule inpoolStates.tsin place of amovingpredicate defined in two files; the redundant viewport call per pool (the framebuffer is bound directly);computeRecordershared by both tests instead of a second fake encoder; the pool states moved insideboundToContext, so a lost context drops them with the program and theforgetspecial case is gone.endFullscreenPasshelper and a float data-texture helper (both would rewriteeffects/webglEffects.tsandwebgl/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'sdrawArrayshook (the engine would need a probe-only accessor).code-reviewskill (--fix), 8 findings. Fixed: a WebGL2 step clears a pool'srefusedas 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 afterpresentby design); a capture steps the pools (as before, invisible);getExtensioneach 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; labelsin review,physics; ENGINE.md follows.UNPACK_FLIP_Y_WEBGLandUNPACK_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 usesPARTICLE_FLOATSinstead of a literal; the WebGL model steps each slot once.flush);HOST_DRAW_UNSUPPORTED;keep, which behaved the same way before;hdr/floatflags of the render target (one format option would change every caller).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).createWebglParticles: RGBA32F ping-pong targets made once throughcreateWebglRenderTarget(…, { 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 byWebGL2: the records land as float texels, only emitted rows are drawn, the targets swap(webglParticles.test.ts:10) andWebGL2 steps the pools ahead of the engine, which draws an image they moved in(compose.test.ts:123).EXT_color_buffer_float(CTO decision): delivered in webglParticles.ts:104 and webgl/core/renderTarget.ts:37 (floatTargets), proved byWebGL2 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) andWebGL2 without a 32-bit float target refuses the pools by name, never draws without them(compose.test.ts:140).WebGL2: a 1 mm step holds ten kilometres from the world origin(webglParticles.test.ts:63).WebGL2 and WebGPU step a reference emission to the same 32-bit floats(particles.test.ts:92).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).createWebglRenderTarget(afloatformat, not a twin);poolStates.tsis what both steps share;createWebglParticlessearched: no twin.in reviewremoved andto measureset 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.Not proven / left out
EXT_color_buffer_floatshows no particles; it refuses them by name.