Skip to content

feat(fluids): particles drawn on WebGPU without a global sort; WebGL2 refuses them by name and the session carries on (#755) - #853

Merged
pasquelin merged 28 commits into
developfrom
755-particle-draw
Sep 26, 2026
Merged

pasquelin merged 28 commits into
developfrom
755-particle-draw

Conversation

@pasquelin

Copy link
Copy Markdown
Owner

Closes #755

What changed

Particle pools are now drawn on WebGPU without a global sort. On WebGL2 they are refused by name with a notice, and the session carries on. Each WebGPU pool is one instanced draw of eye-facing discs, placed after the transparents and before TAA. The draw reads the step's state in place; the pool stays the only store of particles.

  • Pool spec (packages/sdk-core/src/fluids/particles.ts):
    • New fields blend (additive | premultiplied), color, size and softness.
    • An unknown blend is refused as PARTICLE_BLEND. A size or softness that is not positive is refused as PARTICLE_SIZE. A colour of other than four finite numbers is refused as PARTICLE_COLOR.
  • Shared words and order (packages/sdk-browser/src/particles/drawWords.ts):
    • The clip matrix from the pool's origin (matrixAtRenderOrigin) and its inverse are built in double precision.
    • The eye is expressed relative to the origin.
    • Pools are sorted far to near by origin (coarse, per emitter) with an in-place insertion sort, so nothing is allocated each frame.
  • WebGPU draw (particles/webgpuParticleDraw.ts, wired by drawParticles in particles/webgpuParticles.ts and webgpu/pages/render/encodeBlend.ts):
    • One render pass, Trillion3D particle draw, over the lit HDR image, mapped to the transparents stage.
    • Additive fire adds its light and keeps the image's alpha. Premultiplied smoke covers colour and alpha alike.
    • The soft edge reads the opaque depth32float as a texture; there is no depth attachment.
    • Both pipelines compile asynchronously (buildRenderPipeline). A pipeline that fails is heard (particles-unavailable), and the step refuses the pools and dispatches none.
    • The draw's words buffer and bind group live in the step's per-pool state, so there is one registry and one dispose, and no new frame target.
  • Refusals (PARTICLES_UNSUPPORTED):
    • On WebGPU without the visibility buffer (the fallback image), pools are refused and heard once on the same particlesRefused notice as WebGL2 (carried by backend/types.ts and session/backends.ts); a step made before the buffer was dropped frees its buffers.
    • A pool refused by its draw stays refused at the next step.
  • WebGL2: refused, never failing the session (world/render/compose.ts):
  • A scene with no pool, or with no live particle, encodes no pass and no draw: 0 px.
  • docs/ENGINE.md is updated. The WGSL is added to the engine shader list.

Proof

  • pnpm run check:changed (1476 pass, 0 fail, with the Jolt submodule and the native compiler built), pnpm run test:changed and pnpm run validate --group quick: all pass. check-pr-size: 427 hand-written lines.
  • Tests in packages/sdk-browser/src/particles/particleDraw.test.ts:
    • "a pool 10 km out is drawn from its origin: the words hold to the millimetre"
    • "WebGPU: one pass, fire then the nearer smoke, each with its blend; none without particles"
    • "WebGPU: a draw that cannot compile is heard, and the next step keeps its pools refused"
    • "WebGPU without the visibility buffer refuses the pools by name, heard once, and frees the step"
  • Test in packages/sdk-browser/src/world/render/compose.test.ts: "WebGL2 refuses the pools by name from the first frame, heard once, and draws on".
    • It runs two contexts: one with 32-bit float targets and one without.
    • In both, the first frame throws nothing and both frames are drawn. The pool is refused, and its emission is refused. The reason is heard once, as PARTICLES_UNSUPPORTED. No particle is drawn.
    • It fails on develop's composer, which throws on the float case and steps unrefused pools on the other.
  • Test in packages/sdk-core/src/fluids/particles.test.ts: "a pool blends, sizes and softens as told, and refuses by name what no renderer draws" (colour included).
  • Browser proof, left to the measurer:

Local review before push

  • Simplification pass: the real simplify (4 agents: reuse, simplification, efficiency, altitude). Fixed:
    • matrixAtRenderOrigin instead of a hand-rolled origin column; buildRenderPipeline instead of a bare createRenderPipelineAsync.
    • One refusal channel: WebGPU without the visibility buffer now says particles-refused through particlesRefused, like WebGL2, instead of a diagnosticFailure path-failure (kept for pipeline errors only); its Error is no longer built each frame.
    • One WebGL2 reason (the float-target branch only changed the wording and copied webglParticles.ts's text).
    • A failed draw pipeline refused in one place (the step), which now also skips the dispatch; a blend table instead of per-factor ternaries; DrawState.depth typed.
    • Skipped: deleting particles/webglParticles.ts and its fixture/probe (parked for WebGL2 draws the particle pools #844, which needs the step); a noticeParticleRefusal factory (notices.once already says it once); hoisting the render-pass descriptor (negligible); a vertex run per dead slot (needs an indirect count, out of scope).
  • Correctness review: the real code-review --fix. Fixed: a visibility buffer dropped mid-session kept every pool's GPU buffers (the step is now freed, tested); an unchecked color length gave the previous pool's opacity or a RangeError mid-frame (PARTICLE_COLOR, tested). Not fixed, for Fluids P2: GPU particles, fire, smoke flipbooks, heat distortion, wind API #423 (one step state per engine): in a WebGL2-beside-WebGPU comparison the shared pools' refused flag is set by the WebGL2 side each frame.
  • Auditor list: Closes #755; both To-do items delivered (WebGPU draw; WebGL2 refusal that never fails the session); each changed behaviour has a test that fails when its fix is reverted; no PARTICLES_UNSUPPORTED throw is reachable from compose.ts or encodeParticles (the only throw left, webglParticles.ts, is reached by its fixture and probe alone, parked for WebGL2 draws the particle pools #844); docs/ENGINE.md updated; particlesRefused is an internal measured-world option, fed by the world notice, with no public API.

Lead verification

  • Drawing without a global sort on WebGPU — additive fire: delivered in packages/sdk-browser/src/particles/webgpuParticleDraw.ts:53 (additive blend, order-free), wired at webgpu/pages/render/encodeBlend.ts:176, proved by "WebGPU: one pass, fire then the nearer smoke, each with its blend; none without particles".
  • Premultiplied smoke with a coarse per-emitter sort: delivered in particles/drawWords.ts:34 (drawOrder, far to near by origin, nothing allocated) and webgpuParticleDraw.ts:57, proved by the same test (the nearer smoke drawn last).
  • Soft particles fading with the visibility buffer's depth: delivered in webgpuParticleDraw.ts:37 (opaque depth read as a texture), proved by the same test's bindings and the measurer's image.
  • WebGL2 refuses, the session carries on: delivered in world/render/compose.ts:130 (refuseAll + particlesRefused, no throw) through world/core/worldSwitches.ts:39 (notices.once('particles-refused')); WebGPU without the visibility buffer on the same channel (particles/webgpuParticles.ts:147), proved by "WebGL2 refuses the pools by name from the first frame, heard once, and draws on" (compose.test.ts, fails on develop's composer) and "WebGPU without the visibility buffer refuses the pools by name, heard once, and frees the step".
  • Proof, measurer: "prove Particles draw without a global sort: additive fire, sorted-per-emitter smoke, soft edges #755 on 755-particle-draw" PASS on 35b94ca (base develop 9f01c29): WebGPU both pools draw (7,456 emitted each, 0 dropped, no notice), soft fade at the floor, image as on afab4b3; WebGL2 default context keeps drawing (82 frames in 4 s, no "World session failed"), exactly one particles-refused notice, pools refused; 0 px vs develop on crates-under-a-spotlight, shadows-case-by-case and a-first-world, both backends, A/A 0. Draw cost per tier on Fluids S2 (spike): one GPU particle system on WebGPU and WebGL2 #420 comes with Particles hold under TAA: previous position, reactive mask, flipbooks with motion vectors #756's bench setting.
  • rounds: 5 (full review; bite-test round; memory round; WebGL2 depth-format round after the measurer's FAIL; WebGPU-only full review after the CTO's split), plus two short re-reviews.

Checked by the lead on the diff:

Not proven / left out

…ve or premultiplied, soft on the scene depth, on WebGPU and WebGL2 (#755)
…, and WebGL2 refuses a depth it cannot copy once (#755)
…aws; a refused WebGL2 draw finishes its frame (#755)
…e WebGL2 refusal returns for the frame to finish (#755)
…write the untoned mark, an earlier GL error never refuses (#755)
…rite the untoned mark, an earlier GL error never refuses; trimmed (#755)
…format, stencil-packed or not, and the fake GL refuses a blit between two (#755)
… never failing the session; its draw moves to #844 (#755)
…ld notice; the draw reuses matrixAtRenderOrigin and buildRenderPipeline (#755)
…rs, and a dropped visibility buffer frees the particle step (#755)
@pasquelin
pasquelin merged commit bed13ba into develop Sep 26, 2026
7 checks passed
@pasquelin
pasquelin deleted the 755-particle-draw branch September 26, 2026 17:35
@pasquelin pasquelin added the audited Image proved and promise kept (recette) label Sep 26, 2026
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