Skip to content

WebGL2: a multiply or subtractive surface under an effect pass draws the frame without the chain, named, never a throw (#349) - #780

Merged
pasquelin merged 17 commits into
developfrom
349-effect-blend-gate
Sep 26, 2026
Merged

pasquelin merged 17 commits into
developfrom
349-effect-blend-gate

Conversation

@pasquelin

Copy link
Copy Markdown
Owner

Closes #349

What changed

This is the audit fix of #651. On WebGL2, with a pass in world.effects, a transparent surface in multiply or subtractive blending threw the WebGL2 effect chain cannot draw … blending in the middle of the draw, which stopped the frame.

Design choice. The combination is refused for the frame, never thrown mid-draw.

  • What happens. While such a surface is drawn, WebGL2 draws the frame whole, every surface included, without the effect chain.
  • What the user sees. The world's diagnostic channel (diagnostic.createChannel().observe) reports effects-refused-blending once per world, with { blending }. The chain comes back by itself once no such surface is drawn.
  • Why not refuse at host/surfaceGate.ts. Refusing the surface there would leave a hole in the image (Streaming without holes: rules and objectives for geometry, memory and shadows #483 rules 1 and 8).
  • Why not refuse at world.effects.add. It would miss the other three orders of events, and it would refuse a pass that WebGPU draws.
  • Why the composer. It is the one place that binds the chain's linear target, before any draw. A check there on every frame it draws covers all four orders: a pass added while such a surface exists, a surface entering a world with a pass, a surface's blending switched, and a world falling back to WebGL2 (its session opens on what the world already holds).
  • WebGPU is unchanged. No new error code.

Where it is.

  • The check. It is read from the scene draw's own walk of its graph, and no second walk is added.
    • webgl/cluster/sceneDraw.ts walks the display graph once per drawn image (updateMatrixWorld + collect), at the first of host.linearRefusal() and host.drawHostGeometry, and the draw reuses what the walk collected.
    • linearRefusal() then scans only the see-through meshes that walk collected, with linearRefusalOf (webgl/cluster/linearRefusal.ts): visible mesh under visible parents, visible transparent surface, an instanced mesh placed at least once, blending that refusesLinear names.
  • A transmissive surface counts too. The unlit view (lighting/unlitAlbedo.ts) zeroes its transmission in onBeforeRender, after the check, and the draw then binds it in that mode.
  • No culling. The check reads the graph, not what the camera culls, so the bloom does not blink as such a surface enters and leaves the view.
  • Where the walk runs. It runs neither in render(), which backend/autonomous/pages.ts calls before the engine's frame writes the graph and which also runs on held frames, nor on a held frame.
  • The composer. world/render/compose.ts (passesOf) reads backend.linearRefusal?.() in O(1) before effects.begin. ComposedChain.refused hears the mode on each refused frame.
  • The backend face. drawHostGeometry and linearRefusal form BackendHostDraw (backend/hostDraw.ts), which RenderBackend extends, split out of backend/types.ts as BackendSceneUpdates is. pages.ts spreads the scene draw's host face.
  • The notice. world/diagnostic/worldNotices.ts noticeEffectRefusal says effects-refused-blending through notices.once, the world's existing notice channel, like effect-targets-over-budget; past its first word it builds nothing. It is wired through worldSwitches (its new notices parameter), the session option effectsRefused, and hostState.
  • The guard. coversLinear (webgl/cluster/materialBinding.ts) keeps a guard for a caller that skips the check, now the path's named refusal (refuseCluster, CLUSTER_MATERIAL_UNSUPPORTED), and shares refusesLinear with it.
  • Docs. docs/SDK.md (effects paragraph), docs/ENGINE.md (WebGL2 bullet), and the EffectChain docstring. That docstring feeds the API reference; its sentence is added to the 14 translations in site/content/reference/api.*.json.

Cost: no walk added.

  • The WebGL2 draw's per-frame collect of its display graph is develop's, not added here. The architect's Merges of 24 Sept. agree: visible toggle, retired joints, format keys, blend parity, reuse #558 finding (2026-09-25) on per-frame scene walks covers it.
  • This change adds no walk of its own. The refusal reads the see-through list of that walk: nothing on frames without a chain, one pass over the transparent meshes on frames with one, and nothing on held frames.
  • Bench witnesses. The exact and three witnesses have no linearRefusal: with a chain and a multiply surface, a bench A/B shows the engine without the chain and the witness with it. That limit belongs to the engine's linear program; do not read it as a measure ko.

Proof

  • packages/sdk-browser/src/world/render/composeRefusal.test.ts has 10 behaviour tests on the WebGL2 path. They use the real createSceneDraw, GraphScene, surfaces, composer and world notices, with no fixed delay.
    • For {multiply, subtractive} × {pass added, surface entering, blending switched, session opened on WebGL2}, every frame submits every surface, the chain is off, and effects-refused-blending is said once. The chain comes back when the surface is hidden.
    • An instanced multiply mesh placed nowhere keeps the chain on until it is placed.
    • A transmissive multiply surface under the real unlit view draws whole, without the chain, said once; with the transmissive exemption the walk had before review, it fails with Autonomous WebGL2 refused: the WebGL2 effect chain cannot draw multiply blending.
    • world/render/composeWalk.test.ts counts every read of a node's children over 5 drawn frames. With a bloom it counts exactly as many as without a chain, and every frame asserts that the chain ran. The composer walks nothing, and the draw walks once per image, as on develop. At cef7d0281, where the composer walked the scene, it fails: 105 reads against 80.
    • composeWalk.test.ts also proves the walk is lazy. A multiply surface the engine writes between render() and the composition turns the chain off, with both meshes drawn, and hiding it brings the chain back. Held frames, with or without a chain, read no node's children. Both tests fail if the walk is moved into render().
    • With develop's compose.ts, all 10 refusal tests fail with the WebGL2 effect chain cannot draw multiply blending (subtractive likewise).
  • webgl/cluster/materialBinding.test.ts: the guard refuses with CLUSTER_MATERIAL_UNSUPPORTED.
  • world/core/worldSwitches.test.ts: the session options' effectsRefused says effects-refused-blending on the world's notices.
  • pnpm run check:changed passes.
  • pnpm run test:changed: 2090 pass, 0 fail (after the merge of origin/develop at fed87923a).
  • pnpm run validate --group quick passes.
  • pnpm run validate --group typescript passes.
  • node scripts/check-pr-size.ts: 477 hand-written lines added (limit 600).

Local review before push

  • Simplification pass: the real simplify skill (4 agents: reuse, simplification, efficiency, altitude) found 5 things to fix and 6 to skip.
    • Fixed: the walk kept a transmissive multiply or subtractive surface off the refusal. The unlit view zeroes that surface's transmission in onBeforeRender, after the check, and the draw then threw mid-frame. The exemption is gone, and a test with the real unlit view covers it.
    • Fixed: the guard in coversLinear throws the path's named refusal (refuseCluster), not a plain Error.
    • Fixed: the composer's refusal state repeated the dedupe of notices.once, and it was never cleared after the chain was emptied. It is gone: refused is heard on each refused frame.
    • Fixed: the walk uses nested checks instead of a chain of ternaries and a surface! assertion; the exported LinearRefusedBlending type is dropped, and callbacks take the core Blending.
    • Fixed: the instanced-pool test no longer builds a mesh only to borrow its geometry.
    • Skipped, later reversed by the CTO's decision: the backend answering the refusal. It is now BackendHostDraw.linearRefusal, with the line in backend/types.ts freed by splitting that interface out.
    • Skipped, later reversed by the CTO's decision: sharing one walk with sceneDraw's collect. That is now done (the single walk, below).
    • Skipped: Object3D.traverseVisible instead of the walk. It loses the early exit and would widen HostScene.
    • Skipped: wiring the notice in world.ts instead of worldSwitches. It puts world.ts over the 200-line cap.
    • Skipped: comparing the host constants directly instead of blendingOf, and declaring blending on GraphSurface. Both are micro-changes outside the diff.
    • Delta after cef7d0281 (the single walk): the real simplify skill (4 agents) made 4 fixes and skipped 4 findings.
      • Fixed: the refusal is read only from the see-through meshes the walk collected, not on every mesh.
      • Fixed: linearRefusalOf uses isInstancedNode.
      • Fixed: one session fixture (world/render/composeSession.fixture.ts) is shared by the refusal and walk tests.
      • Fixed: a doc line says the walk may come before onBeforeRender.
      • Skipped: flattening the host face, which would put pages.ts at 201 lines.
      • Skipped: moving the walk into render(), which would read a stale graph and walk held frames.
      • Skipped: keeping only one of the two walked resets; both are needed.
      • Skipped: merging the two depth loops, which would allocate a list per frame.
  • Correctness review: the real code-review skill (--fix) found no path where the draw binds a multiply or subtractive surface into the linear target unchecked. It checked onBeforeRender, instanced pools, blend copies, material arrays, surfaces switched mid-frame, the fallback and capture paths, and other drawHostGeometry backends. It made 1 fix and skipped 4 findings.
    • Fixed: noticeEffectRefusal built its message on every refused frame. It now builds nothing past the first.
    • Fixed (reviewer, from its finding 4): in a lit view, a transmissive multiply surface is still refused by the draw after the notice, so the notice no longer promises a whole frame; it says the chain comes back once no such surface is drawn.
    • Skipped, later reversed: the second walk. It is gone (the single walk).
    • Skipped: one shared visibility rule for the walk, collect and renderer.mesh. That is a refactor outside the diff; the tests pin each condition.
    • Delta after cef7d0281: the real code-review --fix skill found no runtime break. It made 2 fixes and skipped 3 findings.
      • Fixed: the walk-count test asserts that the chain ran on every frame.
      • Fixed: the GraphScene.onBeforeRender comment says the walk may come first.
      • Skipped: stale lists after an effects.begin that throws and a draw that follows with no render().
      • Skipped: the witnesses losing the old walk's refusal; the old walk found nothing there.
      • Skipped, by design: the refusal and the draw share one walk through the walked flag.
    • Re-review of cef7d0281..512bcd76a (the reviewer): the real code-review --fix skill checked the walk resets, walking before onBeforeRender, the refusal rule and the depth move. It made 1 fix and skipped 2 findings.
      • Checked: every compose path (frame, budget fallback, error fallback, capture, view capture) calls render() first, so walked is reset. Graph writes after render() come before the lazy walk, and nothing writes the graph between the refusal and the draw. Held frames return before passesOf. The one onBeforeRender hook zeroes no field the walk reads. Depths are taken over the same nodes, with the same camera.
      • Fixed: the walk tests did not prove the walk is lazy or that held frames skip it; a walk moved into render() passed them all. Two tests in composeWalk.test.ts now cover it, and the session fixture writes the graph between render() and the composition and holds frames.
      • Skipped, latent: a throw after the refusal's walk but before the draw's try, such as effects.begin, leaves walked set until the next render(). Every compose path calls render() first.
      • Skipped: the exact witness keeps its chain with a multiply surface, as the bench-witness line above says.
    • Skipped, by design: the chain's targets stay allocated while refused, as in a diagnostic view, so the chain comes back without reallocating.
  • Auditor list: Closes #349, and the diff delivers the split issue's one To-do item (the audit fix of feat(effects): world.effects chain with a physically based bloom (#349) #651). It follows the lead's path note, as refined by the CTO: the composer decides before it binds the target, reading the refusal from the scene draw's own walk, and no scene walk is added per frame. Every changed behaviour has a test that fails on develop, on real fixtures, waiting for the notice's delivery and never a fixed delay. Docs (SDK.md, ENGINE.md, the EffectChain docstring) and the 14 translations follow. CONTRIBUTING §Streaming, memory and shadows rule 8 holds: WebGL2 is degraded, never broken, with no hole. AGENTS.md rule 1 holds: every surface is still drawn, and the missing chain on WebGL2 is declared by the notice. WebGPU is unchanged. No new error code, no format change, no example added.

Lead verification

  • The audit of feat(effects): world.effects chain with a physically based bloom (#349) #651, with a pass in world.effects: a WebGL2 transparent surface in multiply or subtractive blending no longer throws mid-draw (webgl/cluster/materialBinding.ts:44): delivered in packages/sdk-browser/src/world/render/compose.ts (passesOf reads backend.linearRefusal?.() in O(1) before binding the chain), webgl/cluster/sceneDraw.ts (the draw's one walk per drawn image records the see-through meshes) and webgl/cluster/linearRefusal.ts (linearRefusalOf, the draw's own rule). The notice effects-refused-blending (world/diagnostic/worldNotices.ts noticeEffectRefusal) goes through the world's existing notices.once. The guard in materialBinding.ts now refuses through refuseCluster (CLUSTER_MATERIAL_UNSUPPORTED). Proved by:
    • composeRefusal.test.ts: 10 tests covering {multiply, subtractive} × {a pass added, a surface entering, a surface switched, a WebGL2 session opened}, an instanced mesh placed nowhere, and a transmissive surface in the unlit view. They fail on develop.
    • composeWalk.test.ts: "a chain on WebGL2 visits the graph no more than the draw does without one" (fails with a composer walk: 105 reads against 80), "a held frame walks nothing", and "a surface the engine writes after its render is read by the refusal and the draw".
  • The measurer's branch proof (fed8792 against develop f2371a2), four-ways-to-blend with a bloom:
    • WebGPU: frames keep drawing and the chain stays on.
    • WebGL2: develop fails the session with cannot draw subtractive blending (0 frames). The branch keeps drawing, says effects-refused-blending once, draws every surface without the chain (42 px against no bloom), and the bloom comes back when those surfaces are hidden.
    • Without a bloom, the branch sits within the page's own A/A spread on both backends.
  • The develop conflict (imports only, next to feat(fluids): one GPU particle pool, stepped by a WebGPU compute pass; WebGL2 refused by name until #759 (#420) #776's particle pool) was re-reviewed; merged with develop, backend/types.ts is at 199 lines.

Before merge:

  1. The whole promise: Post-processing chain: bloom, depth of field, motion blur, outline, LUT and custom passes after the resolve #349 holds only the audit fix since the CTO's split; the six effects are on The post-processing chain's remaining effects: depth of field, motion blur, outline, LUT, lens effects, custom pass #757.
  2. Tests that bite: every refusal and walk test fails on develop or on the previous composer walk. They use the engine's own scene draw, composer and notices, with no fixed delays.
  3. No image loss: every surface is drawn on every path. The missing bloom on WebGL2, while such a surface is drawn, is a named degrade (Streaming without holes: rules and objectives for geometry, memory and shadows #483 rule 8, accepted by the CTO), and its removal is a To-do on Merges of 24 Sept. agree: visible toggle, retired joints, format keys, blend parity, reuse #558. WebGPU is unchanged.
  4. Reuse: there is no second walk (the CTO's rule: the scene is never walked per frame); the refusal reads the draw's own walk. The notice uses notices.once, and the guard uses refuseCluster. linearRefusalOf and refusesLinear share one rule with coversLinear. BackendHostDraw is split out of types.ts, like BackendSceneUpdates.
  5. Docs: docs/SDK.md, docs/ENGINE.md, the EffectChain docstring and its 14 translations. This body describes this diff and closes Post-processing chain: bloom, depth of field, motion blur, outline, LUT and custom passes after the resolve #349.
  6. Measured first: not an optimisation. The walk count is pinned by a test.
  7. Path: the composer-side refusal (not the gate) and the single walk are written on Post-processing chain: bloom, depth of field, motion blur, outline, LUT and custom passes after the resolve #349 and accepted by the CTO. Labels: in review now, to measure at the hand-over.

Not proven / left out

  • The measurer's proof on a scene with a bloom and multiply and subtractive surfaces, for example four-ways-to-blend with world.effects.add(effect.bloom()), on both renderers:
    • WebGPU draws the multiply and subtractive surfaces with the chain, the bloom on, and says nothing.
    • WebGL2 draws every surface without the chain and says effects-refused-blending once on the diagnostic channel.
  • Latent: a scene.onBeforeRender hook that switches a surface's blending to multiply or subtractive after the check would still reach the guard (CLUSTER_MATERIAL_UNSUPPORTED); no hook does so today.
  • The six remaining effects moved to The post-processing chain's remaining effects: depth of field, motion blur, outline, LUT, lens effects, custom pass #757.

pasquelin and others added 17 commits September 26, 2026 04:41
…ile a multiply or subtractive surface is drawn (#349)
…s refusal to the draw and types its walk once (#349)
…sive surface included, and the notice dedupes alone (#349)
…ene draw's own walk, walking nothing itself (#349)
… walk met; one session fixture for the chain tests (#349)
…efore-render hook says the walk may come first (#349)
@pasquelin
pasquelin merged commit 2aff068 into develop Sep 26, 2026
7 checks passed
@pasquelin
pasquelin deleted the 349-effect-blend-gate branch September 26, 2026 06:57
@pasquelin pasquelin added the audited merged pull request re-read by the auditor label Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

audited merged pull request re-read by the auditor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant