WebGL2: a multiply or subtractive surface under an effect pass draws the frame without the chain, named, never a throw (#349) - #780
Merged
Conversation
…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)
… and promises only the chain's return (#349)
…ackend files at their line cap (#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)
…nd a held frame walks nothing (#349)
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 #349
What changed
This is the audit fix of #651. On WebGL2, with a pass in
world.effects, a transparent surface inmultiplyorsubtractiveblending threwthe WebGL2 effect chain cannot draw … blendingin the middle of the draw, which stopped the frame.Design choice. The combination is refused for the frame, never thrown mid-draw.
diagnostic.createChannel().observe) reportseffects-refused-blendingonce per world, with{ blending }. The chain comes back by itself once no such surface is drawn.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).world.effects.add. It would miss the other three orders of events, and it would refuse a pass that WebGPU draws.Where it is.
webgl/cluster/sceneDraw.tswalks the display graph once per drawn image (updateMatrixWorld+collect), at the first ofhost.linearRefusal()andhost.drawHostGeometry, and the draw reuses what the walk collected.linearRefusal()then scans only the see-through meshes that walk collected, withlinearRefusalOf(webgl/cluster/linearRefusal.ts): visible mesh under visible parents, visible transparent surface, an instanced mesh placed at least once, blending thatrefusesLinearnames.lighting/unlitAlbedo.ts) zeroes its transmission inonBeforeRender, after the check, and the draw then binds it in that mode.render(), whichbackend/autonomous/pages.tscalls before the engine's frame writes the graph and which also runs on held frames, nor on a held frame.world/render/compose.ts(passesOf) readsbackend.linearRefusal?.()in O(1) beforeeffects.begin.ComposedChain.refusedhears the mode on each refused frame.drawHostGeometryandlinearRefusalformBackendHostDraw(backend/hostDraw.ts), whichRenderBackendextends, split out ofbackend/types.tsasBackendSceneUpdatesis.pages.tsspreads the scene draw'shostface.world/diagnostic/worldNotices.tsnoticeEffectRefusalsayseffects-refused-blendingthroughnotices.once, the world's existing notice channel, likeeffect-targets-over-budget; past its first word it builds nothing. It is wired throughworldSwitches(its newnoticesparameter), the session optioneffectsRefused, andhostState.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 sharesrefusesLinearwith it.docs/SDK.md(effects paragraph),docs/ENGINE.md(WebGL2 bullet), and theEffectChaindocstring. That docstring feeds the API reference; its sentence is added to the 14 translations insite/content/reference/api.*.json.Cost: no walk added.
collectof 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.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 ameasure ko.Proof
packages/sdk-browser/src/world/render/composeRefusal.test.tshas 10 behaviour tests on the WebGL2 path. They use the realcreateSceneDraw,GraphScene, surfaces, composer and world notices, with no fixed delay.effects-refused-blendingis said once. The chain comes back when the surface is hidden.Autonomous WebGL2 refused: the WebGL2 effect chain cannot draw multiply blending.world/render/composeWalk.test.tscounts every read of a node'schildrenover 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. Atcef7d0281, where the composer walked the scene, it fails: 105 reads against 80.composeWalk.test.tsalso proves the walk is lazy. A multiply surface the engine writes betweenrender()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'schildren. Both tests fail if the walk is moved intorender().compose.ts, all 10 refusal tests fail withthe WebGL2 effect chain cannot draw multiply blending(subtractive likewise).webgl/cluster/materialBinding.test.ts: the guard refuses withCLUSTER_MATERIAL_UNSUPPORTED.world/core/worldSwitches.test.ts: the session options'effectsRefusedsayseffects-refused-blendingon the world's notices.pnpm run check:changedpasses.pnpm run test:changed: 2090 pass, 0 fail (after the merge oforigin/developatfed87923a).pnpm run validate --group quickpasses.pnpm run validate --group typescriptpasses.node scripts/check-pr-size.ts: 477 hand-written lines added (limit 600).Local review before push
simplifyskill (4 agents: reuse, simplification, efficiency, altitude) found 5 things to fix and 6 to skip.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.coversLinearthrows the path's named refusal (refuseCluster), not a plainError.refusalstate repeated the dedupe ofnotices.once, and it was never cleared after the chain was emptied. It is gone:refusedis heard on each refused frame.surface!assertion; the exportedLinearRefusedBlendingtype is dropped, and callbacks take the coreBlending.BackendHostDraw.linearRefusal, with the line inbackend/types.tsfreed by splitting that interface out.sceneDraw'scollect. That is now done (the single walk, below).Object3D.traverseVisibleinstead of the walk. It loses the early exit and would widenHostScene.world.tsinstead ofworldSwitches. It putsworld.tsover the 200-line cap.blendingOf, and declaringblendingonGraphSurface. Both are micro-changes outside the diff.cef7d0281(the single walk): the realsimplifyskill (4 agents) made 4 fixes and skipped 4 findings.linearRefusalOfusesisInstancedNode.world/render/composeSession.fixture.ts) is shared by the refusal and walk tests.onBeforeRender.hostface, which would putpages.tsat 201 lines.render(), which would read a stale graph and walk held frames.walkedresets; both are needed.code-reviewskill (--fix) found no path where the draw binds a multiply or subtractive surface into the linear target unchecked. It checkedonBeforeRender, instanced pools, blend copies, material arrays, surfaces switched mid-frame, the fallback and capture paths, and otherdrawHostGeometrybackends. It made 1 fix and skipped 4 findings.noticeEffectRefusalbuilt its message on every refused frame. It now builds nothing past the first.collectandrenderer.mesh. That is a refactor outside the diff; the tests pin each condition.cef7d0281: the realcode-review --fixskill found no runtime break. It made 2 fixes and skipped 3 findings.GraphScene.onBeforeRendercomment says the walk may come first.effects.beginthat throws and a draw that follows with norender().walkedflag.cef7d0281..512bcd76a(the reviewer): the realcode-review --fixskill checked the walk resets, walking beforeonBeforeRender, the refusal rule and the depth move. It made 1 fix and skipped 2 findings.render()first, sowalkedis reset. Graph writes afterrender()come before the lazy walk, and nothing writes the graph between the refusal and the draw. Held frames return beforepassesOf. The oneonBeforeRenderhook zeroes no field the walk reads. Depths are taken over the same nodes, with the same camera.render()passed them all. Two tests incomposeWalk.test.tsnow cover it, and the session fixture writes the graph betweenrender()and the composition and holds frames.try, such aseffects.begin, leaveswalkedset until the nextrender(). Every compose path callsrender()first.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, theEffectChaindocstring) 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
world.effects: a WebGL2 transparent surface inmultiplyorsubtractiveblending no longer throws mid-draw (webgl/cluster/materialBinding.ts:44): delivered inpackages/sdk-browser/src/world/render/compose.ts(passesOfreadsbackend.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) andwebgl/cluster/linearRefusal.ts(linearRefusalOf, the draw's own rule). The noticeeffects-refused-blending(world/diagnostic/worldNotices.tsnoticeEffectRefusal) goes through the world's existingnotices.once. The guard inmaterialBinding.tsnow refuses throughrefuseCluster(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".four-ways-to-blendwith a bloom:cannot draw subtractive blending(0 frames). The branch keeps drawing, sayseffects-refused-blendingonce, draws every surface without the chain (42 px against no bloom), and the bloom comes back when those surfaces are hidden.backend/types.tsis at 199 lines.Before merge:
notices.once, and the guard usesrefuseCluster.linearRefusalOfandrefusesLinearshare one rule withcoversLinear.BackendHostDrawis split out oftypes.ts, likeBackendSceneUpdates.docs/SDK.md,docs/ENGINE.md, theEffectChaindocstring 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.in reviewnow,to measureat the hand-over.Not proven / left out
four-ways-to-blendwithworld.effects.add(effect.bloom()), on both renderers:effects-refused-blendingonce on the diagnostic channel.scene.onBeforeRenderhook 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.