From 604e1c02f492e3cdf373f9b6ae0cbfb2c0f88b66 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:41:08 +0200 Subject: [PATCH 01/13] fix(lighting): WebGL2 draws a frame whole without the effect chain while a multiply or subtractive surface is drawn (#349) --- docs/ENGINE.md | 9 +- docs/SDK.md | 4 + .../src/webgl/cluster/linearRefusal.ts | 38 +++++ .../src/webgl/cluster/materialBinding.ts | 11 +- packages/sdk-browser/src/world/core/world.ts | 2 +- .../src/world/core/worldSwitches.test.ts | 10 ++ .../src/world/core/worldSwitches.ts | 5 +- .../src/world/diagnostic/worldNotices.ts | 16 ++ .../sdk-browser/src/world/render/compose.ts | 27 +++- .../src/world/render/composeRefusal.test.ts | 145 ++++++++++++++++++ .../sdk-browser/src/world/render/hostState.ts | 1 + .../sdk-browser/src/world/session/options.ts | 5 + packages/sdk-core/src/world/effect/chain.ts | 4 +- site/content/reference/api.ar.json | 2 +- site/content/reference/api.de.json | 2 +- site/content/reference/api.es.json | 2 +- site/content/reference/api.fr.json | 2 +- site/content/reference/api.hi.json | 2 +- site/content/reference/api.id.json | 2 +- site/content/reference/api.it.json | 2 +- site/content/reference/api.ja.json | 2 +- site/content/reference/api.ko.json | 2 +- site/content/reference/api.pt.json | 2 +- site/content/reference/api.ru.json | 2 +- site/content/reference/api.tr.json | 2 +- site/content/reference/api.vi.json | 2 +- site/content/reference/api.zh.json | 2 +- 27 files changed, 272 insertions(+), 33 deletions(-) create mode 100644 packages/sdk-browser/src/webgl/cluster/linearRefusal.ts create mode 100644 packages/sdk-browser/src/world/render/composeRefusal.test.ts diff --git a/docs/ENGINE.md b/docs/ENGINE.md index a83846604e..33666acd2b 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -198,9 +198,12 @@ revision and asks for a frame. drawn before the chain existed. Its second output marks, one byte a pixel, the coverage of the surfaces whose material skips the curve (`toneMapped: false`); the output program leaves that share as drawn. Coverage past one is read as light (`effects/webglOutput.ts`). With a chain, a - `none`-blended surface covers as an opaque one, and multiply and subtractive surfaces are - refused (`coversLinear`, `webgl/cluster/materialBinding.ts`). A context that cannot render half - floats draws without the chain. + `none`-blended surface covers as an opaque one. Multiply and subtractive filter the background, + which the linear target does not hold: before it binds the target, the composer walks the scene as + the draw does (`webgl/cluster/linearRefusal.ts`), and a frame that draws a transparent surface in + either mode is drawn whole without the chain, never stopped mid-draw; `ComposedChain.refused` + hears the mode when the refusal starts, and the world says `effects-refused-blending` once + (`noticeEffectRefusal`). A context that cannot render half floats draws without the chain. - **Kinds**: each renderer holds one table from pass kind to implementation (`WEBGPU_KINDS`, `WEBGL_KINDS`); a new built-in or the custom pass is one entry. The kinds of a chain share its two pass targets; each holds its own resources besides, sized for the passes of its kind — the diff --git a/docs/SDK.md b/docs/SDK.md index afde042690..fd0e5f8e6b 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -386,6 +386,10 @@ physically based glow on the linear image, before tone mapping, energy-conservin spread at every level, in texels of that level. `world.effects.add(pass, index?)`, `remove(pass)` and `clear()` change the chain; a setting written on a pass shows at the next frame. An empty chain costs nothing, and a still image with a chain is post-processed once, then held. +On WebGL2, a frame that draws a transparent surface blending in `multiply` or `subtractive` is drawn +whole without the chain — its linear target cannot hold those modes; WebGPU draws both —, and the +world's diagnostic channel says `effects-refused-blending` once; the chain comes back once no such +surface is drawn. ```js const glow = effect.bloom({ intensity: 0.08 }); diff --git a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts new file mode 100644 index 0000000000..9dde192206 --- /dev/null +++ b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts @@ -0,0 +1,38 @@ +import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; +import type { HostScene } from '../../host/resources.ts'; +import { isDrawnNode } from '../../host/graph/kinds.ts'; +import { blendingOf } from '../../scene/materialBlending.ts'; +import { firstMaterial } from '../../scene/materialSide.ts'; + +/** The modes the effect chain's linear target cannot hold (`refusesLinear`). */ +export type LinearRefusedBlending = Extract; + +/** + * Whether a transparent surface in `mode` cannot be drawn into the effect chain's linear target, + * whose alpha is coverage over transparent black (`../../effects/webglOutput.ts`): multiply and + * subtractive filter what the display target holds, the background included, which that target + * does not hold. WebGPU composes them with the chain; WebGL2 draws such a frame without it. + */ +export const refusesLinear = (mode: Blending | undefined): mode is LinearRefusedBlending => + mode === 'multiply' || mode === 'subtractive'; + +type Walked = { readonly visible?: boolean; readonly children?: readonly object[] }; + +function refusalUnder(nodes: readonly object[]): LinearRefusedBlending | undefined { + for (const node of nodes) { + if (!(node as Walked).visible) continue; + const surface = isDrawnNode(node) ? firstMaterial(node.material) : undefined; + const mode = surface?.visible && surface.transparent ? blendingOf(surface.blending) : undefined; + if (refusesLinear(mode)) return mode; + const below = refusalUnder((node as Walked).children ?? []); + if (below) return below; + } +} + +/** + * The mode of the first surface the scene draw would draw (`sceneDraw.ts`: a visible mesh under + * visible parents, its surface visible) that the linear target cannot hold, or `undefined`. Read + * before the chain binds its target, on a frame the composer draws: the frame is then drawn + * without the chain, never stopped in the middle of its draw. + */ +export const linearRefusal = (scene: HostScene) => refusalUnder(scene.children); diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts index 8e936a431e..70495529dc 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts @@ -8,6 +8,7 @@ import type { ClusterDrawMesh } from '../../cluster/batchMesh.ts'; import type { Side } from '../../../../sdk-core/src/index.ts'; import type { WebglClusterTextures } from './textures.ts'; import { drawnModeOf, type WebglClusterState } from './state.ts'; +import { refusesLinear } from './linearRefusal.ts'; import type { Matrix3UniformCache } from './uniforms.ts'; import type { WebglClusterMaterialUniforms } from './materialUniforms.ts'; @@ -33,16 +34,14 @@ type Binding = { /** * Whether a surface drawn into the effect chain's linear target, whose alpha is coverage * (`../../effects/webglOutput.ts`), covers its pixel whatever its alpha: an opaque one, and a - * transparent one that replaces what is behind it (`none`), as the display path shows it. - * Multiply and subtractive filter what the display target holds, the background included, which - * the linear target does not hold: they are refused by name, never drawn as another mode, as - * every mode the display path refuses (`drawnBlending`). + * transparent one that replaces what is behind it (`none`), as the display path shows it. A mode + * the target cannot hold (`refusesLinear`) never reaches here: the composer draws such a frame + * without the chain (`linearRefusal`); a caller that skipped that read is refused by name. */ function coversLinear(material: Material) { if (!material.transparent) return true; const mode = drawnModeOf(material); - if (mode === 'multiply' || mode === 'subtractive') - throw new Error(`the WebGL2 effect chain cannot draw ${mode} blending`); + if (refusesLinear(mode)) throw new Error(`the WebGL2 effect chain cannot draw ${mode} blending`); return mode === 'none'; } diff --git a/packages/sdk-browser/src/world/core/world.ts b/packages/sdk-browser/src/world/core/world.ts index 295f8628c7..35a9da4a44 100644 --- a/packages/sdk-browser/src/world/core/world.ts +++ b/packages/sdk-browser/src/world/core/world.ts @@ -41,7 +41,7 @@ export function createWorld(target: WorldTarget, options: WorldOptions = {}) { const scene = new Scene(worldModelLoader(device.ready, options.signal, () => device.renderer)); const invalidate = () => runtime.invalidate(); const diagnostic = worldDiagnostic(() => runtime.explorer); - const switches = worldSwitches(options, () => runtime, device, invalidate); + const switches = worldSwitches(options, () => runtime, device, invalidate, diagnostic.notices); const runtime = createWorldRuntime({ canvas, ready: () => device.pending, diff --git a/packages/sdk-browser/src/world/core/worldSwitches.test.ts b/packages/sdk-browser/src/world/core/worldSwitches.test.ts index d53918ab7a..e39b119eab 100644 --- a/packages/sdk-browser/src/world/core/worldSwitches.test.ts +++ b/packages/sdk-browser/src/world/core/worldSwitches.test.ts @@ -5,6 +5,9 @@ import { sessionOptions } from './worldOptions.ts'; import { worldSwitches } from './worldSwitches.ts'; import { effect } from '../../../../sdk-core/src/world/effect/index.ts'; +/** The world's notices, where nothing here is said. */ +const silent = { once() {} }; + /** An open session that records the switches written into it. */ function session(draws = true) { const written: boolean[] = []; @@ -30,6 +33,7 @@ test('temporal antialiasing is given to the session and switched in place', () = () => runtime, device, () => void invalidated++, + silent, ); assert.equal(sessionOptions(options, switches.held).temporalAntialiasing, false); assert.equal(switches.temporalAntialiasing, false, 'before a session: what the page asked'); @@ -49,6 +53,7 @@ test('temporal antialiasing reads false on WebGL2 and as the session draws it', () => runtime, { renderer: 'webgl2' }, () => {}, + silent, ); assert.equal(switches.held.temporalAntialiasing, true, 'on by default'); assert.equal(switches.temporalAntialiasing, false, 'WebGL2 has none'); @@ -61,12 +66,14 @@ test('temporal antialiasing reads false on WebGL2 and as the session draws it', test('the effect chain is given to every session, and a change of it asks for a frame', () => { let renewed = 0, invalidated = 0; + const said: string[] = []; const runtime = { explorer: null as MeasuredWorld | null, renew: () => void renewed++ }; const switches = worldSwitches( {}, () => runtime, { renderer: 'webgpu' }, () => void invalidated++, + { once: (kind) => void said.push(kind) }, ); const chain = switches.held.effects; assert.equal(sessionOptions({}, switches.held).effects, chain); @@ -74,4 +81,7 @@ test('the effect chain is given to every session, and a change of it asks for a (chain.passes[0] as ReturnType).radius = 2; assert.equal(sessionOptions({}, switches.held).effects, chain, 'the same chain on reopen'); assert.deepEqual([invalidated, renewed], [2, 0]); + // A WebGL2 frame drawn without the chain is said on the world's own channel. + sessionOptions({}, switches.held).effectsRefused!('multiply'); + assert.deepEqual(said, ['effects-refused-blending']); }); diff --git a/packages/sdk-browser/src/world/core/worldSwitches.ts b/packages/sdk-browser/src/world/core/worldSwitches.ts index 2501633aaa..c559263cdd 100644 --- a/packages/sdk-browser/src/world/core/worldSwitches.ts +++ b/packages/sdk-browser/src/world/core/worldSwitches.ts @@ -3,6 +3,7 @@ import type { WorldRenderer } from '../capability/worldReady.ts'; import type { WorldOptions } from './worldOptions.ts'; import { EffectChain } from '../../../../sdk-core/src/world/effect/chain.ts'; import { createGuideSet, type Guides } from '../../guides/guideSet.ts'; +import { noticeEffectRefusal, type WorldNotices } from '../diagnostic/worldNotices.ts'; /** What of the world's runtime the switches reach: its open session, and its reopening. */ interface SwitchedRuntime { @@ -16,19 +17,21 @@ interface SwitchedRuntime { * the open one in place, the session reopened only where it cannot take one. Temporal * antialiasing reads back what the open session draws; before one opens, what the page asked * (`world.temporalAntialiasing`). The chain is shared by reference: a session reads it at every - * frame. + * frame, and says on the world's `notices` a frame it drew without it (`noticeEffectRefusal`). */ export function worldSwitches( options: WorldOptions, runtime: () => SwitchedRuntime, device: { readonly renderer: WorldRenderer | null }, invalidate: () => void, + notices: Pick, ) { const held = { bounce: false, temporalAntialiasing: options.temporalAntialiasing !== false, // One chain for the world's life: every session draws it, a change asks for a frame. effects: new EffectChain(invalidate), + effectsRefused: noticeEffectRefusal(notices), guides: createGuideSet(invalidate), }; return { diff --git a/packages/sdk-browser/src/world/diagnostic/worldNotices.ts b/packages/sdk-browser/src/world/diagnostic/worldNotices.ts index b8bca12abf..c6334bf0dd 100644 --- a/packages/sdk-browser/src/world/diagnostic/worldNotices.ts +++ b/packages/sdk-browser/src/world/diagnostic/worldNotices.ts @@ -1,6 +1,7 @@ import { createDiagnosticChannel } from '../../diagnostic/channel.ts'; import type { BackendDiagnostic } from '../../backend/types.ts'; import { effectTargetExcess, type BudgetCanvas } from '../../residency/memoryBudget.ts'; +import type { LinearRefusedBlending } from '../../webgl/cluster/linearRefusal.ts'; /** The page channels open now (`diagnostic.createChannel`): every world notice reaches each. */ const listeners = new Set<(notice: BackendDiagnostic) => void>(); @@ -96,3 +97,18 @@ export function noticeEffectBudget( ); }; } + +/** + * The WebGL2 composer's word (`effectsRefused`) that a frame was drawn whole without the effect + * chain, a transparent surface drawn blending in `blending`, which the chain's linear target + * cannot hold (`linearRefusal`): said once per world, as `effects-refused-blending`. WebGPU draws + * both and never says it. + */ +export const noticeEffectRefusal = + (notices: Pick) => (blending: LinearRefusedBlending) => + notices.once( + 'effects-refused-blending', + `effect chain not drawn on WebGL2: a transparent surface blends in ${blending}, which ` + + `the chain cannot hold; the frame is drawn whole without it until no such surface is drawn`, + { blending }, + ); diff --git a/packages/sdk-browser/src/world/render/compose.ts b/packages/sdk-browser/src/world/render/compose.ts index be17a41ded..39eb49e770 100644 --- a/packages/sdk-browser/src/world/render/compose.ts +++ b/packages/sdk-browser/src/world/render/compose.ts @@ -17,12 +17,18 @@ import { type WebglRenderTarget, } from '../../webgl/core/renderTarget.ts'; import { createWebglEffects, type WebglEffectOutput } from '../../effects/webglEffects.ts'; +import { linearRefusal, type LinearRefusedBlending } from '../../webgl/cluster/linearRefusal.ts'; const NONE: readonly EffectPass[] = []; /** The world's effect chain as the composer draws it: `shown` is false in a diagnostic view, - * which shows the engine's image as it is. */ -export type ComposedChain = { chain: EffectChain; shown: () => boolean }; + * which shows the engine's image as it is; `refused` hears the mode of a surface that keeps the + * chain off a frame (`linearRefusal`), each time one starts to. */ +export type ComposedChain = { + chain: EffectChain; + shown: () => boolean; + refused?: (blending: LinearRefusedBlending) => void; +}; /** * Composes one engine's frame on the host surface or on a render target — the one place that @@ -61,7 +67,8 @@ export function createFrameComposer( toneCurve: 0, background: [0, 0, 0], }; - let keptRevision = 0; + let keptRevision = 0, + refusal: LinearRefusedBlending | undefined; /** The engine's background, sRGB-encoded like everything the destinations store. */ const encode = (background: SceneColour) => { const { r, g, b } = background?.isColor ? background : { r: 0, g: 0, b: 0 }; @@ -81,13 +88,19 @@ export function createFrameComposer( gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); }; /** The passes this frame draws: none without a chain, in a diagnostic view, on a destination - * that takes the engine's image alone, or on a context that cannot hold the targets. */ - const passesOf = (wanted: boolean) => { + * that takes the engine's image alone, on a context that cannot hold the targets, or while a + * surface drawn blends in a mode the chain's target cannot hold — every surface is still drawn, + * the chain comes back once none does. */ + const passesOf = (backend: RenderBackend, wanted: boolean) => { if (!composed) return NONE; const passes = composed.chain.stage('before-tone-mapping'); // An emptied chain gives its targets back; one kept aside for a capture keeps them. if (!passes.length) effects!.release(); - return passes.length && wanted && composed.shown() && effects!.supported() ? passes : NONE; + if (!passes.length || !wanted || !composed.shown() || !effects!.supported()) return NONE; + const refused = linearRefusal(backend.scene); + if (refused && refused !== refusal) composed.refused?.(refused); + refusal = refused; + return refused ? NONE : passes; }; /** * `reuse` is false where the kept frame is not this engine's: a fallback takes over the image @@ -123,7 +136,7 @@ export function createFrameComposer( // the chain (P4). A target thus holds what the page would show. output.toneMapped = backend.sceneLit?.() !== false; output.toneMapping = backend.sceneToneMapping?.() ?? DEFAULT_TONE_MAPPING; - const passes = passesOf(chained); + const passes = passesOf(backend, chained); const linear = passes.length ? effects!.begin(passes, width, height) : null; output.linear = !!linear; output.framebuffer = (linear ?? target)?.framebuffer ?? null; diff --git a/packages/sdk-browser/src/world/render/composeRefusal.test.ts b/packages/sdk-browser/src/world/render/composeRefusal.test.ts new file mode 100644 index 0000000000..92d9e69f33 --- /dev/null +++ b/packages/sdk-browser/src/world/render/composeRefusal.test.ts @@ -0,0 +1,145 @@ +// #349, audit of #651: on WebGL2 the effect chain's linear target cannot hold a transparent surface +// that blends in multiply or subtractive. Whichever comes first — the pass, the surface, the +// switch of its mode, or a WebGL2 session opened on a world that holds both — the frame keeps +// drawing every surface, without the chain, and the world says so once; the chain comes back +// once no such surface is drawn. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as G from '../../host/graph/graph.fixture.ts'; +import type { RenderBackend } from '../../backend/types.ts'; +import { EffectChain } from '../../../../sdk-core/src/world/effect/chain.ts'; +import { effect } from '../../../../sdk-core/src/world/effect/index.ts'; +import { Geometry } from '../../../../sdk-core/src/world/geometry/geometry.ts'; +import { BufferAttribute } from '../../../../sdk-core/src/world/buffer/attribute.ts'; +import { GraphScene } from '../../host/graph/scene.ts'; +import { GraphMesh } from '../../host/graph/mesh.ts'; +import { GraphSurface } from '../../host/graph/surface.ts'; +import { + HOST_BLENDING_MULTIPLY, + HOST_BLENDING_NORMAL, + HOST_BLENDING_SUBTRACTIVE, +} from '../../host/surfaceConstants.ts'; +import { createSceneDraw } from '../../webgl/cluster/sceneDraw.ts'; +import { createTestContext } from '../../webgl/core/testContext.fixture.ts'; +import { + createWorldNotices, + listenWorldNotices, + noticeEffectRefusal, +} from '../diagnostic/worldNotices.ts'; +import { createFrameComposer } from './compose.ts'; + +const camera = G.perspectiveCamera(); +const halfFloats = { + getExtension: (name: string) => (name === 'EXT_color_buffer_float' ? {} : null), +}; + +/** A drawn mesh of three corners in `surface`. */ +function mesh(surface: GraphSurface) { + const geometry = new Geometry().setIndex(new BufferAttribute(new Uint32Array(3), 1)); + geometry.setAttribute('position', new BufferAttribute(new Float32Array(9), 3)); + geometry.setAttribute('normal', new BufferAttribute(new Float32Array(9), 3)); + const made = new GraphMesh(geometry, surface); + made.frustumCulled = false; + return made; +} +const blended = (blending: number) => + new GraphSurface('standard', { transparent: true, opacity: 0.5, blending }); + +/** A WebGL2 session drawing `scene` with the world's `chain`, its refusals said on a world's + * notices; `frame` draws one and returns whether the chain ran and what the scene submitted. */ +function session(scene: GraphScene, chain: EffectChain) { + const context = createTestContext({ answers: halfFloats }); + const draw = createSceneDraw(context.gl, scene); + const backend = { id: 'engine', scene, ...draw } as unknown as RenderBackend; + const notices = createWorldNotices(); + const refused = noticeEffectRefusal(notices); + const compose = createFrameComposer(context.gl, camera, { + effects: { chain, shown: () => true, refused }, + }); + return { + frame() { + const passes = context.of('drawArrays').length, + submitted = context.of('drawElements').length; + draw.render(camera); + compose(backend, null); + return { + chained: context.of('drawArrays').length > passes, + submitted: context.of('drawElements').length - submitted, + }; + }, + close: notices.close, + }; +} + +/** The kinds of every world notice said while `run` draws `view`, once delivered. */ +async function heard(view: ReturnType, run: () => void) { + const said: string[] = []; + const stop = listenWorldNotices((notice) => void said.push(notice.phase)); + run(); + await new Promise(setImmediate); + view.close(); + stop(); + return said; +} + +const MODES = [ + ['multiply', HOST_BLENDING_MULTIPLY], + ['subtractive', HOST_BLENDING_SUBTRACTIVE], +] as const; + +for (const [name, blending] of MODES) { + test(`a pass added while a ${name} surface is drawn: every frame drawn, said once`, async () => { + const scene = new GraphScene().add(mesh(new GraphSurface('standard')), mesh(blended(blending))); + const chain = new EffectChain(); + const view = session(scene, chain); + const said = await heard(view, () => { + assert.deepEqual(view.frame(), { chained: false, submitted: 2 }); + chain.add(effect.bloom()); + assert.deepEqual(view.frame(), { chained: false, submitted: 2 }, 'no throw, no hole'); + assert.deepEqual(view.frame(), { chained: false, submitted: 2 }); + }); + assert.deepEqual(said, ['effects-refused-blending']); + }); + + test(`a ${name} surface entering a world with a pass: drawn, said once`, async () => { + const scene = new GraphScene().add(mesh(new GraphSurface('standard'))); + const view = session(scene, new EffectChain().add(effect.bloom())); + const glass = mesh(blended(blending)); + const said = await heard(view, () => { + assert.deepEqual(view.frame(), { chained: true, submitted: 1 }); + scene.add(glass); + assert.deepEqual(view.frame(), { chained: false, submitted: 2 }); + assert.deepEqual(view.frame(), { chained: false, submitted: 2 }); + // The chain comes back once no such surface is drawn. + glass.visible = false; + assert.deepEqual(view.frame(), { chained: true, submitted: 1 }); + }); + assert.deepEqual(said, ['effects-refused-blending']); + }); + + test(`a surface switched to ${name} under a pass: drawn, said once`, async () => { + const surface = blended(HOST_BLENDING_NORMAL); + const view = session( + new GraphScene().add(mesh(surface)), + new EffectChain().add(effect.bloom()), + ); + const said = await heard(view, () => { + assert.deepEqual(view.frame(), { chained: true, submitted: 1 }); + surface.blending = blending; + assert.deepEqual(view.frame(), { chained: false, submitted: 1 }); + assert.deepEqual(view.frame(), { chained: false, submitted: 1 }); + }); + assert.deepEqual(said, ['effects-refused-blending']); + }); + + test(`a WebGL2 session opened on a world with a pass and a ${name} surface`, async () => { + // The world falling back to WebGL2 opens its session on what it already holds. + const scene = new GraphScene().add(mesh(blended(blending))); + const view = session(scene, new EffectChain().add(effect.bloom())); + const said = await heard(view, () => { + assert.deepEqual(view.frame(), { chained: false, submitted: 1 }, 'its first frame drawn'); + assert.deepEqual(view.frame(), { chained: false, submitted: 1 }); + }); + assert.deepEqual(said, ['effects-refused-blending']); + }); +} diff --git a/packages/sdk-browser/src/world/render/hostState.ts b/packages/sdk-browser/src/world/render/hostState.ts index cfbc6eeff8..d9aa61064b 100644 --- a/packages/sdk-browser/src/world/render/hostState.ts +++ b/packages/sdk-browser/src/world/render/hostState.ts @@ -89,6 +89,7 @@ export function createExplorerHostState( effects: options.effects && { chain: options.effects, shown: () => state.diagnostic === 'beauty', + refused: options.effectsRefused, }, guides: options.guides, }), diff --git a/packages/sdk-browser/src/world/session/options.ts b/packages/sdk-browser/src/world/session/options.ts index 490ab9c90c..5f4a43ec7e 100644 --- a/packages/sdk-browser/src/world/session/options.ts +++ b/packages/sdk-browser/src/world/session/options.ts @@ -115,6 +115,11 @@ export interface MeasuredWorldOptions { temporalAntialiasing?: boolean; /** The world's effect chain, drawn after temporal antialiasing (`world.effects`). */ effects?: import('../../../../sdk-core/src/world/effect/chain.ts').EffectChain; + /** Hears the mode of a surface that keeps WebGL2 from drawing `effects` on a frame, each time + * one starts to: that frame is drawn whole without the chain (`world/render/compose.ts`). */ + effectsRefused?: ( + blending: import('../../webgl/cluster/linearRefusal.ts').LinearRefusedBlending, + ) => void; /** Whether the prepared scene reads the source images. `'cache'`, the default: an image whose * mip chain the cache carries is neither fetched nor decoded — the engine reads the baked * levels, which it does whatever this option says. `'host'`: the scene reads and decodes diff --git a/packages/sdk-core/src/world/effect/chain.ts b/packages/sdk-core/src/world/effect/chain.ts index b918733407..f3275d8c33 100644 --- a/packages/sdk-core/src/world/effect/chain.ts +++ b/packages/sdk-core/src/world/effect/chain.ts @@ -28,7 +28,9 @@ export abstract class EffectPass { * `world.effects`: the ordered passes drawn over the image after temporal antialiasing and before * it reaches the canvas. The passes that run before tone mapping read the linear radiance, in the * order they were added; those that run after read the display image, in theirs. An empty chain - * costs nothing: no pass, no copy, no target. + * costs nothing: no pass, no copy, no target. On WebGL2, a frame that draws a transparent surface + * blending in `multiply` or `subtractive` is drawn whole without the chain, and the world's + * diagnostic channel says `effects-refused-blending` once; WebGPU draws both. */ export class EffectChain { private readonly list: EffectPass[] = []; diff --git a/site/content/reference/api.ar.json b/site/content/reference/api.ar.json index 6115f690e9..8f860e87a0 100644 --- a/site/content/reference/api.ar.json +++ b/site/content/reference/api.ar.json @@ -880,7 +880,7 @@ }, "EffectChain": { "summary": "`world.effects`: التمريرات المرتّبة المرسومة فوق الصورة بعد التنعيم الزمني وقبل وصولها إلى الـ canvas.", - "description": "تقرأ التمريرات التي قبل تعيين الدرجات اللونية الإشعاعية الخطية بترتيب إضافتها؛ وتقرأ التي بعده الصورة المعروضة بترتيبها. السلسلة الفارغة لا تكلّف شيئًا: لا تمريرة، ولا نسخة، ولا هدف.", + "description": "تقرأ التمريرات التي قبل تعيين الدرجات اللونية الإشعاعية الخطية بترتيب إضافتها؛ وتقرأ التي بعده الصورة المعروضة بترتيبها. السلسلة الفارغة لا تكلّف شيئًا: لا تمريرة، ولا نسخة، ولا هدف. على WebGL2، يُرسم الإطار الذي يرسم سطحًا شفافًا بمزج `multiply` أو `subtractive` كاملًا من دون السلسلة، وتقول قناة تشخيص العالم `effects-refused-blending` مرة واحدة؛ أما WebGPU فيرسم الاثنين.", "members": { "revision": "يعدّ التغييرات: الصورة المرسومة عند مراجعة أخرى قديمة.", "passes": "التمريرات، بترتيب تنفيذها.", diff --git a/site/content/reference/api.de.json b/site/content/reference/api.de.json index c26c216e43..7788daae84 100644 --- a/site/content/reference/api.de.json +++ b/site/content/reference/api.de.json @@ -1678,7 +1678,7 @@ }, "EffectChain": { "summary": "`world.effects`: die geordneten Durchgänge, die nach dem temporalen Antialiasing über das Bild gezeichnet werden, bevor es den Canvas erreicht.", - "description": "Die Durchgänge vor dem Tone Mapping lesen die lineare Strahldichte, in der Reihenfolge, in der sie hinzugefügt wurden; die danach lesen das angezeigte Bild, in ihrer. Eine leere Kette kostet nichts: kein Durchgang, keine Kopie, kein Ziel.", + "description": "Die Durchgänge vor dem Tone Mapping lesen die lineare Strahldichte, in der Reihenfolge, in der sie hinzugefügt wurden; die danach lesen das angezeigte Bild, in ihrer. Eine leere Kette kostet nichts: kein Durchgang, keine Kopie, kein Ziel. Auf WebGL2 wird ein Bild, das eine transparente Oberfläche mit `multiply`- oder `subtractive`-Überblendung zeichnet, vollständig ohne die Kette gezeichnet, und der Diagnosekanal der Welt meldet einmal `effects-refused-blending`; WebGPU zeichnet beides.", "members": { "revision": "Zählt die Änderungen: Ein Bild, das bei einer anderen Revision gezeichnet wurde, ist veraltet.", "passes": "Die Durchgänge, in der Reihenfolge, in der sie laufen.", diff --git a/site/content/reference/api.es.json b/site/content/reference/api.es.json index 378dc82969..ddf9a97395 100644 --- a/site/content/reference/api.es.json +++ b/site/content/reference/api.es.json @@ -1678,7 +1678,7 @@ }, "EffectChain": { "summary": "`world.effects`: las pasadas ordenadas que se dibujan sobre la imagen tras el antialiasing temporal y antes de que llegue al canvas.", - "description": "Las pasadas anteriores al tone mapping leen la radiancia lineal, en el orden en que se añadieron; las posteriores leen la imagen mostrada, en el suyo. Una cadena vacía no cuesta nada: ninguna pasada, ninguna copia, ningún destino.", + "description": "Las pasadas anteriores al tone mapping leen la radiancia lineal, en el orden en que se añadieron; las posteriores leen la imagen mostrada, en el suyo. Una cadena vacía no cuesta nada: ninguna pasada, ninguna copia, ningún destino. En WebGL2, un fotograma que dibuja una superficie transparente con fusión `multiply` o `subtractive` se dibuja entero sin la cadena, y el canal de diagnóstico del mundo dice `effects-refused-blending` una vez; WebGPU dibuja ambos.", "members": { "revision": "Cuenta los cambios: una imagen dibujada en otra revisión está desfasada.", "passes": "Las pasadas, en el orden en que se ejecutan.", diff --git a/site/content/reference/api.fr.json b/site/content/reference/api.fr.json index 2fbb00f649..039d9fc17c 100644 --- a/site/content/reference/api.fr.json +++ b/site/content/reference/api.fr.json @@ -1678,7 +1678,7 @@ }, "EffectChain": { "summary": "`world.effects` : les passes ordonnées dessinées sur l’image après l’antialiasing temporel et avant qu’elle n’atteigne le canvas.", - "description": "Les passes qui agissent avant le tone mapping lisent la radiance linéaire, dans l’ordre où elles ont été ajoutées ; celles qui agissent après lisent l’image affichée, dans le leur. Une chaîne vide ne coûte rien : aucune passe, aucune copie, aucune cible.", + "description": "Les passes qui agissent avant le tone mapping lisent la radiance linéaire, dans l’ordre où elles ont été ajoutées ; celles qui agissent après lisent l’image affichée, dans le leur. Une chaîne vide ne coûte rien : aucune passe, aucune copie, aucune cible. Sur WebGL2, une image qui dessine une surface transparente en mélange `multiply` ou `subtractive` est dessinée entière sans la chaîne, et le canal de diagnostic du monde dit `effects-refused-blending` une fois ; WebGPU dessine les deux.", "members": { "revision": "Compte les changements : une image dessinée à une autre révision est périmée.", "passes": "Les passes, dans l’ordre où elles s’exécutent.", diff --git a/site/content/reference/api.hi.json b/site/content/reference/api.hi.json index 1f4971c23b..2e967ceb21 100644 --- a/site/content/reference/api.hi.json +++ b/site/content/reference/api.hi.json @@ -880,7 +880,7 @@ }, "EffectChain": { "summary": "`world.effects`: टेम्पोरल एंटीएलियासिंग के बाद और छवि के canvas तक पहुँचने से पहले छवि पर बनाए जाने वाले क्रमबद्ध पास।", - "description": "टोन मैपिंग से पहले वाले पास रैखिक रेडियंस को जोड़े जाने के क्रम में पढ़ते हैं; बाद वाले दिखाई गई छवि को अपने क्रम में पढ़ते हैं। खाली चेन की कोई लागत नहीं: कोई पास नहीं, कोई प्रति नहीं, कोई लक्ष्य नहीं।", + "description": "टोन मैपिंग से पहले वाले पास रैखिक रेडियंस को जोड़े जाने के क्रम में पढ़ते हैं; बाद वाले दिखाई गई छवि को अपने क्रम में पढ़ते हैं। खाली चेन की कोई लागत नहीं: कोई पास नहीं, कोई प्रति नहीं, कोई लक्ष्य नहीं। WebGL2 पर, `multiply` या `subtractive` ब्लेंडिंग वाली पारदर्शी सतह खींचने वाला फ़्रेम चेन के बिना पूरा खींचा जाता है, और दुनिया का निदान चैनल एक बार `effects-refused-blending` कहता है; WebGPU दोनों खींचता है।", "members": { "revision": "बदलाव गिनता है: किसी दूसरे संशोधन पर बनी छवि पुरानी है।", "passes": "पास, उसी क्रम में जिसमें वे चलते हैं।", diff --git a/site/content/reference/api.id.json b/site/content/reference/api.id.json index 645438d4db..a5b3c5d5f0 100644 --- a/site/content/reference/api.id.json +++ b/site/content/reference/api.id.json @@ -880,7 +880,7 @@ }, "EffectChain": { "summary": "`world.effects`: pass berurutan yang digambar di atas gambar setelah antialiasing temporal dan sebelum gambar mencapai canvas.", - "description": "Pass sebelum tone mapping membaca radiansi linear, sesuai urutan penambahannya; pass sesudahnya membaca gambar yang ditampilkan, sesuai urutannya. Rantai kosong tidak memakan apa pun: tanpa pass, tanpa salinan, tanpa target.", + "description": "Pass sebelum tone mapping membaca radiansi linear, sesuai urutan penambahannya; pass sesudahnya membaca gambar yang ditampilkan, sesuai urutannya. Rantai kosong tidak memakan apa pun: tanpa pass, tanpa salinan, tanpa target. Di WebGL2, frame yang menggambar permukaan transparan dengan blending `multiply` atau `subtractive` digambar utuh tanpa rantai, dan kanal diagnostik dunia menyebut `effects-refused-blending` sekali; WebGPU menggambar keduanya.", "members": { "revision": "Menghitung perubahan: gambar yang digambar pada revisi lain sudah usang.", "passes": "Pass-pass, sesuai urutan jalannya.", diff --git a/site/content/reference/api.it.json b/site/content/reference/api.it.json index b9d6ff1c5e..dcb0c96472 100644 --- a/site/content/reference/api.it.json +++ b/site/content/reference/api.it.json @@ -1678,7 +1678,7 @@ }, "EffectChain": { "summary": "`world.effects`: le passate ordinate disegnate sull’immagine dopo l’antialiasing temporale e prima che raggiunga il canvas.", - "description": "Le passate prima del tone mapping leggono la radianza lineare, nell’ordine in cui sono state aggiunte; quelle dopo leggono l’immagine mostrata, nel loro. Una catena vuota non costa nulla: nessuna passata, nessuna copia, nessun target.", + "description": "Le passate prima del tone mapping leggono la radianza lineare, nell’ordine in cui sono state aggiunte; quelle dopo leggono l’immagine mostrata, nel loro. Una catena vuota non costa nulla: nessuna passata, nessuna copia, nessun target. Su WebGL2, un fotogramma che disegna una superficie trasparente con fusione `multiply` o `subtractive` viene disegnato intero senza la catena, e il canale diagnostico del mondo segnala `effects-refused-blending` una volta; WebGPU disegna entrambi.", "members": { "revision": "Conta le modifiche: un’immagine disegnata a un’altra revisione è superata.", "passes": "Le passate, nell’ordine in cui vengono eseguite.", diff --git a/site/content/reference/api.ja.json b/site/content/reference/api.ja.json index 8757ac84d4..084f978472 100644 --- a/site/content/reference/api.ja.json +++ b/site/content/reference/api.ja.json @@ -873,7 +873,7 @@ }, "EffectChain": { "summary": "`world.effects`:テンポラルアンチエイリアスの後、画像が canvas に届く前に画像の上に描かれる、順序付きのパス。", - "description": "トーンマッピング前のパスは追加された順に線形放射輝度を読み、後のパスは表示画像を自分たちの順に読みます。空のチェーンはコストがかかりません。パスもコピーもターゲットもありません。", + "description": "トーンマッピング前のパスは追加された順に線形放射輝度を読み、後のパスは表示画像を自分たちの順に読みます。空のチェーンはコストがかかりません。パスもコピーもターゲットもありません。WebGL2 では、`multiply` または `subtractive` でブレンドする透明な面を描くフレームはチェーンなしで全体が描かれ、ワールドの診断チャネルが `effects-refused-blending` を一度伝えます。WebGPU は両方を描きます。", "members": { "revision": "変更を数えます。別のリビジョンで描かれた画像は古くなっています。", "passes": "実行される順のパス。", diff --git a/site/content/reference/api.ko.json b/site/content/reference/api.ko.json index 3aac9ed033..3e05d9f26b 100644 --- a/site/content/reference/api.ko.json +++ b/site/content/reference/api.ko.json @@ -873,7 +873,7 @@ }, "EffectChain": { "summary": "`world.effects`: 시간적 안티앨리어싱 후, 이미지가 canvas에 닿기 전에 이미지 위에 그려지는 순서 있는 패스.", - "description": "톤 매핑 전의 패스는 추가된 순서대로 선형 복사휘도를 읽고, 후의 패스는 표시 이미지를 자기 순서대로 읽습니다. 빈 체인은 비용이 없습니다. 패스도, 복사도, 타깃도 없습니다.", + "description": "톤 매핑 전의 패스는 추가된 순서대로 선형 복사휘도를 읽고, 후의 패스는 표시 이미지를 자기 순서대로 읽습니다. 빈 체인은 비용이 없습니다. 패스도, 복사도, 타깃도 없습니다. WebGL2에서는 `multiply` 또는 `subtractive`로 블렌딩하는 투명 표면을 그리는 프레임이 체인 없이 전부 그려지고, 월드의 진단 채널이 `effects-refused-blending`을 한 번 알립니다. WebGPU는 둘 다 그립니다.", "members": { "revision": "변경을 셉니다. 다른 리비전에서 그린 이미지는 오래된 것입니다.", "passes": "실행되는 순서의 패스.", diff --git a/site/content/reference/api.pt.json b/site/content/reference/api.pt.json index e9cf8fdc37..87f1e80c40 100644 --- a/site/content/reference/api.pt.json +++ b/site/content/reference/api.pt.json @@ -873,7 +873,7 @@ }, "EffectChain": { "summary": "`world.effects`: os passes ordenados desenhados sobre a imagem depois do antialiasing temporal e antes de ela chegar ao canvas.", - "description": "Os passes antes do tone mapping leem a radiância linear, na ordem em que foram adicionados; os depois leem a imagem exibida, na deles. Uma cadeia vazia não custa nada: nenhum passe, nenhuma cópia, nenhum alvo.", + "description": "Os passes antes do tone mapping leem a radiância linear, na ordem em que foram adicionados; os depois leem a imagem exibida, na deles. Uma cadeia vazia não custa nada: nenhum passe, nenhuma cópia, nenhum alvo. No WebGL2, um quadro que desenha uma superfície transparente com mistura `multiply` ou `subtractive` é desenhado inteiro sem a cadeia, e o canal de diagnóstico do mundo diz `effects-refused-blending` uma vez; o WebGPU desenha ambos.", "members": { "revision": "Conta as mudanças: uma imagem desenhada em outra revisão está desatualizada.", "passes": "Os passes, na ordem em que rodam.", diff --git a/site/content/reference/api.ru.json b/site/content/reference/api.ru.json index 5dfb593a85..51d0fb2e1c 100644 --- a/site/content/reference/api.ru.json +++ b/site/content/reference/api.ru.json @@ -873,7 +873,7 @@ }, "EffectChain": { "summary": "`world.effects`: упорядоченные проходы, рисуемые поверх изображения после временного сглаживания и до того, как оно попадёт на canvas.", - "description": "Проходы до тонального отображения читают линейную яркость в порядке добавления; проходы после читают отображаемое изображение в своём порядке. Пустая цепочка ничего не стоит: ни прохода, ни копии, ни цели.", + "description": "Проходы до тонального отображения читают линейную яркость в порядке добавления; проходы после читают отображаемое изображение в своём порядке. Пустая цепочка ничего не стоит: ни прохода, ни копии, ни цели. В WebGL2 кадр, в котором рисуется прозрачная поверхность со смешиванием `multiply` или `subtractive`, рисуется целиком без цепочки, и диагностический канал мира один раз сообщает `effects-refused-blending`; WebGPU рисует и то и другое.", "members": { "revision": "Считает изменения: изображение, нарисованное при другой ревизии, устарело.", "passes": "Проходы в порядке выполнения.", diff --git a/site/content/reference/api.tr.json b/site/content/reference/api.tr.json index d5f05eef7f..89e2f74f98 100644 --- a/site/content/reference/api.tr.json +++ b/site/content/reference/api.tr.json @@ -873,7 +873,7 @@ }, "EffectChain": { "summary": "`world.effects`: zamansal kenar yumuşatmadan sonra ve görüntü canvas’a ulaşmadan önce görüntünün üzerine çizilen sıralı geçişler.", - "description": "Ton eşlemeden önceki geçişler doğrusal ışımayı eklendikleri sırayla okur; sonrakiler gösterilen görüntüyü kendi sıralarıyla okur. Boş bir zincir hiçbir şeye mal olmaz: geçiş yok, kopya yok, hedef yok.", + "description": "Ton eşlemeden önceki geçişler doğrusal ışımayı eklendikleri sırayla okur; sonrakiler gösterilen görüntüyü kendi sıralarıyla okur. Boş bir zincir hiçbir şeye mal olmaz: geçiş yok, kopya yok, hedef yok. WebGL2'de, `multiply` veya `subtractive` karıştırmalı saydam bir yüzey çizen kare zincir olmadan eksiksiz çizilir ve dünyanın tanılama kanalı bir kez `effects-refused-blending` der; WebGPU ikisini de çizer.", "members": { "revision": "Değişiklikleri sayar: başka bir revizyonda çizilmiş görüntü eskimiştir.", "passes": "Geçişler, çalıştıkları sırayla.", diff --git a/site/content/reference/api.vi.json b/site/content/reference/api.vi.json index 404004309f..6938482e41 100644 --- a/site/content/reference/api.vi.json +++ b/site/content/reference/api.vi.json @@ -880,7 +880,7 @@ }, "EffectChain": { "summary": "`world.effects`: các pass có thứ tự được vẽ lên ảnh sau khử răng cưa theo thời gian và trước khi ảnh tới canvas.", - "description": "Các pass trước tone mapping đọc độ rọi tuyến tính, theo thứ tự được thêm vào; các pass sau đọc ảnh hiển thị, theo thứ tự của chúng. Một chuỗi rỗng không tốn gì: không pass, không bản sao, không đích.", + "description": "Các pass trước tone mapping đọc độ rọi tuyến tính, theo thứ tự được thêm vào; các pass sau đọc ảnh hiển thị, theo thứ tự của chúng. Một chuỗi rỗng không tốn gì: không pass, không bản sao, không đích. Trên WebGL2, một khung hình vẽ bề mặt trong suốt hòa trộn `multiply` hoặc `subtractive` được vẽ trọn vẹn không có chuỗi, và kênh chẩn đoán của thế giới báo `effects-refused-blending` một lần; WebGPU vẽ cả hai.", "members": { "revision": "Đếm các thay đổi: ảnh được vẽ ở một phiên bản khác là đã cũ.", "passes": "Các pass, theo thứ tự chúng chạy.", diff --git a/site/content/reference/api.zh.json b/site/content/reference/api.zh.json index 59d3ffa7b0..39ba12f770 100644 --- a/site/content/reference/api.zh.json +++ b/site/content/reference/api.zh.json @@ -873,7 +873,7 @@ }, "EffectChain": { "summary": "`world.effects`:在时间抗锯齿之后、图像到达 canvas 之前绘制在图像上的有序通道。", - "description": "色调映射之前的通道按添加顺序读取线性辐射亮度;之后的通道按它们的顺序读取显示图像。空链不花任何代价:没有通道、没有复制、没有目标。", + "description": "色调映射之前的通道按添加顺序读取线性辐射亮度;之后的通道按它们的顺序读取显示图像。空链不花任何代价:没有通道、没有复制、没有目标。在 WebGL2 上,绘制以 `multiply` 或 `subtractive` 混合的透明表面的帧会在没有链的情况下完整绘制,世界的诊断通道会报告一次 `effects-refused-blending`;WebGPU 两者都绘制。", "members": { "revision": "统计变更:在另一个修订号下绘制的图像已经过时。", "passes": "按运行顺序排列的通道。", From f3d5d93a71acdff0af39a9458ef342768f864cd1 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:43:37 +0200 Subject: [PATCH 02/13] test(lighting): the WebGL2 composition fixture scene holds its children (#349) --- packages/sdk-browser/src/world/render/composeEffects.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-browser/src/world/render/composeEffects.test.ts b/packages/sdk-browser/src/world/render/composeEffects.test.ts index 9c9fb9688e..f29e585f15 100644 --- a/packages/sdk-browser/src/world/render/composeEffects.test.ts +++ b/packages/sdk-browser/src/world/render/composeEffects.test.ts @@ -22,7 +22,7 @@ function engine(held = false) { const outputs: HostDrawOutput[] = []; const backend = { id: 'engine', - scene: { background: { isColor: true, r: 0.5, g: 0, b: 1 } }, + scene: { background: { isColor: true, r: 0.5, g: 0, b: 1 }, children: [] }, frameHeld: held, drawHostGeometry: (_camera: unknown, output: HostDrawOutput) => outputs.push({ ...output }), } as unknown as RenderBackend; From 5480dba9218e9f8faeaf83624e75340d057a7c16 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:47:01 +0200 Subject: [PATCH 03/13] refactor(lighting): the linear refusal leaves a transmissive surface's refusal to the draw and types its walk once (#349) --- .../src/webgl/cluster/linearRefusal.ts | 26 ++++++++++++------- .../src/world/diagnostic/worldNotices.ts | 17 ++++++------ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts index 9dde192206..4e833f510e 100644 --- a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts +++ b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts @@ -1,7 +1,8 @@ import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; import type { HostScene } from '../../host/resources.ts'; import { isDrawnNode } from '../../host/graph/kinds.ts'; -import { blendingOf } from '../../scene/materialBlending.ts'; +import { blendingOf, blendingRefusal } from '../../scene/materialBlending.ts'; +import { isTransmissive } from '../../visibility/shader/material.ts'; import { firstMaterial } from '../../scene/materialSide.ts'; /** The modes the effect chain's linear target cannot hold (`refusesLinear`). */ @@ -16,15 +17,20 @@ export type LinearRefusedBlending = Extract mode === 'multiply' || mode === 'subtractive'; -type Walked = { readonly visible?: boolean; readonly children?: readonly object[] }; +type Walked = { readonly visible?: boolean; readonly children?: readonly Walked[] }; -function refusalUnder(nodes: readonly object[]): LinearRefusedBlending | undefined { +function refusalUnder(nodes: readonly Walked[]): LinearRefusedBlending | undefined { for (const node of nodes) { - if (!(node as Walked).visible) continue; + if (!node.visible) continue; const surface = isDrawnNode(node) ? firstMaterial(node.material) : undefined; - const mode = surface?.visible && surface.transparent ? blendingOf(surface.blending) : undefined; - if (refusesLinear(mode)) return mode; - const below = refusalUnder((node as Walked).children ?? []); + const mode = + surface?.visible && surface.transparent + ? blendingOf(surface.blending as number | undefined) + : undefined; + // A mode no path draws for this surface is the draw's own refusal (`blendingRefusal`), named + // there: only the modes every path draws but the linear target cannot hold are read here. + if (refusesLinear(mode) && !blendingRefusal(mode, isTransmissive(surface!))) return mode; + const below = refusalUnder(node.children ?? []); if (below) return below; } } @@ -33,6 +39,8 @@ function refusalUnder(nodes: readonly object[]): LinearRefusedBlending | undefin * The mode of the first surface the scene draw would draw (`sceneDraw.ts`: a visible mesh under * visible parents, its surface visible) that the linear target cannot hold, or `undefined`. Read * before the chain binds its target, on a frame the composer draws: the frame is then drawn - * without the chain, never stopped in the middle of its draw. + * without the chain, never stopped in the middle of its draw. It reads the scene, not what the + * camera culls: the chain does not blink on and off as such a surface enters and leaves the view. */ -export const linearRefusal = (scene: HostScene) => refusalUnder(scene.children); +export const linearRefusal = (scene: HostScene) => + refusalUnder(scene.children as readonly Walked[]); diff --git a/packages/sdk-browser/src/world/diagnostic/worldNotices.ts b/packages/sdk-browser/src/world/diagnostic/worldNotices.ts index c6334bf0dd..a31dac3797 100644 --- a/packages/sdk-browser/src/world/diagnostic/worldNotices.ts +++ b/packages/sdk-browser/src/world/diagnostic/worldNotices.ts @@ -1,7 +1,7 @@ import { createDiagnosticChannel } from '../../diagnostic/channel.ts'; import type { BackendDiagnostic } from '../../backend/types.ts'; import { effectTargetExcess, type BudgetCanvas } from '../../residency/memoryBudget.ts'; -import type { LinearRefusedBlending } from '../../webgl/cluster/linearRefusal.ts'; +import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; /** The page channels open now (`diagnostic.createChannel`): every world notice reaches each. */ const listeners = new Set<(notice: BackendDiagnostic) => void>(); @@ -104,11 +104,10 @@ export function noticeEffectBudget( * cannot hold (`linearRefusal`): said once per world, as `effects-refused-blending`. WebGPU draws * both and never says it. */ -export const noticeEffectRefusal = - (notices: Pick) => (blending: LinearRefusedBlending) => - notices.once( - 'effects-refused-blending', - `effect chain not drawn on WebGL2: a transparent surface blends in ${blending}, which ` + - `the chain cannot hold; the frame is drawn whole without it until no such surface is drawn`, - { blending }, - ); +export const noticeEffectRefusal = (notices: Pick) => (blending: Blending) => + notices.once( + 'effects-refused-blending', + `effect chain not drawn on WebGL2: a transparent surface blends in ${blending}, which ` + + `the chain cannot hold; the frame is drawn whole without it until no such surface is drawn`, + { blending }, + ); From c239f963d2549e34f7cfa3563102b3bec00721cd Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:53:37 +0200 Subject: [PATCH 04/13] fix(lighting): an instanced mesh placed nowhere keeps no effect chain off (#349) --- .../src/webgl/cluster/linearRefusal.ts | 8 +++++--- .../src/world/render/composeRefusal.test.ts | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts index 4e833f510e..7ad98c71b2 100644 --- a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts +++ b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts @@ -1,6 +1,6 @@ import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; import type { HostScene } from '../../host/resources.ts'; -import { isDrawnNode } from '../../host/graph/kinds.ts'; +import { isDrawnNode, isInstancedNode } from '../../host/graph/kinds.ts'; import { blendingOf, blendingRefusal } from '../../scene/materialBlending.ts'; import { isTransmissive } from '../../visibility/shader/material.ts'; import { firstMaterial } from '../../scene/materialSide.ts'; @@ -22,7 +22,9 @@ type Walked = { readonly visible?: boolean; readonly children?: readonly Walked[ function refusalUnder(nodes: readonly Walked[]): LinearRefusedBlending | undefined { for (const node of nodes) { if (!node.visible) continue; - const surface = isDrawnNode(node) ? firstMaterial(node.material) : undefined; + // An instanced mesh placed nowhere submits nothing (`renderer.ts`): it keeps no chain off. + const drawn = isDrawnNode(node) && !(isInstancedNode(node) && !node.count); + const surface = drawn ? firstMaterial(node.material) : undefined; const mode = surface?.visible && surface.transparent ? blendingOf(surface.blending as number | undefined) @@ -37,7 +39,7 @@ function refusalUnder(nodes: readonly Walked[]): LinearRefusedBlending | undefin /** * The mode of the first surface the scene draw would draw (`sceneDraw.ts`: a visible mesh under - * visible parents, its surface visible) that the linear target cannot hold, or `undefined`. Read + * visible parents, its surface visible, an instanced one placed at least once) that the linear target cannot hold, or `undefined`. Read * before the chain binds its target, on a frame the composer draws: the frame is then drawn * without the chain, never stopped in the middle of its draw. It reads the scene, not what the * camera culls: the chain does not blink on and off as such a surface enters and leaves the view. diff --git a/packages/sdk-browser/src/world/render/composeRefusal.test.ts b/packages/sdk-browser/src/world/render/composeRefusal.test.ts index 92d9e69f33..1b9d2ea5bc 100644 --- a/packages/sdk-browser/src/world/render/composeRefusal.test.ts +++ b/packages/sdk-browser/src/world/render/composeRefusal.test.ts @@ -12,7 +12,7 @@ import { effect } from '../../../../sdk-core/src/world/effect/index.ts'; import { Geometry } from '../../../../sdk-core/src/world/geometry/geometry.ts'; import { BufferAttribute } from '../../../../sdk-core/src/world/buffer/attribute.ts'; import { GraphScene } from '../../host/graph/scene.ts'; -import { GraphMesh } from '../../host/graph/mesh.ts'; +import { GraphInstancedMesh, GraphMesh } from '../../host/graph/mesh.ts'; import { GraphSurface } from '../../host/graph/surface.ts'; import { HOST_BLENDING_MULTIPLY, @@ -143,3 +143,19 @@ for (const [name, blending] of MODES) { assert.deepEqual(said, ['effects-refused-blending']); }); } + +test('a multiply instanced mesh placed nowhere keeps the chain on until it is placed', async () => { + const surface = blended(HOST_BLENDING_MULTIPLY), + placed = mesh(surface), + pool = new GraphInstancedMesh(placed.geometry, surface, 1); + pool.count = 0; + pool.frustumCulled = false; + const scene = new GraphScene().add(mesh(new GraphSurface('standard')), pool); + const view = session(scene, new EffectChain().add(effect.bloom())); + const said = await heard(view, () => { + assert.equal(view.frame().chained, true, 'nothing submitted, nothing refused'); + pool.count = 1; + assert.equal(view.frame().chained, false); + }); + assert.deepEqual(said, ['effects-refused-blending']); +}); From ed281c1879ea0af75ce7b061d6f13696fff1c5e9 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:57:24 +0200 Subject: [PATCH 05/13] test(lighting): one drawn-triangle fixture for the linear output and refusal tests (#349) --- .../src/host/graph/graph.fixture.ts | 11 +++++++ .../src/webgl/cluster/linearOutput.test.ts | 19 +++--------- .../src/world/render/composeRefusal.test.ts | 30 +++++++------------ 3 files changed, 26 insertions(+), 34 deletions(-) diff --git a/packages/sdk-browser/src/host/graph/graph.fixture.ts b/packages/sdk-browser/src/host/graph/graph.fixture.ts index 815066e5d1..dd36103603 100644 --- a/packages/sdk-browser/src/host/graph/graph.fixture.ts +++ b/packages/sdk-browser/src/host/graph/graph.fixture.ts @@ -64,6 +64,17 @@ export const mesh = ( material: GraphSurface | GraphSurface[] = new GraphSurface('basic'), ) => new GraphMesh(geometry, material); +/** A drawn triangle — three indices, positions and normals — in `surface`, never culled: what a + * draw test submits once per pass. */ +export function triangleMesh(surface: GraphSurface) { + const geometry = new Geometry().setIndex(new BufferAttribute(new Uint32Array(3), 1)); + geometry.setAttribute('position', new BufferAttribute(new Float32Array(9), 3)); + geometry.setAttribute('normal', new BufferAttribute(new Float32Array(9), 3)); + const made = new GraphMesh(geometry, surface); + made.frustumCulled = false; + return made; +} + /** A surface of each family a scene declares. */ export const basicSurface = (parameters?: SurfaceParameters) => surface('basic', parameters); export const standardSurface = (parameters?: SurfaceParameters) => surface('standard', parameters); diff --git a/packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts b/packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts index 54a4bbedae..b847d051e7 100644 --- a/packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts @@ -5,31 +5,20 @@ // surfaces whose material skips the curve are marked so the chain's output skips it too. import test from 'node:test'; import assert from 'node:assert/strict'; +import * as G from '../../host/graph/graph.fixture.ts'; import { createSceneDraw } from './sceneDraw.ts'; import { createTestContext } from '../core/testContext.fixture.ts'; import { createHostDrawCamera, type HostCamera } from '../../camera/world.ts'; import { GraphScene } from '../../host/graph/scene.ts'; -import { GraphMesh } from '../../host/graph/mesh.ts'; -import { BufferAttribute } from '../../../../sdk-core/src/world/buffer/attribute.ts'; import { GraphSurface } from '../../host/graph/surface.ts'; import { CLUSTER_FRAGMENT, CLUSTER_LINEAR_FRAGMENT, CLUSTER_VERTEX } from './shaders.ts'; -import { Geometry } from '../../../../sdk-core/src/world/geometry/geometry.ts'; - -function mesh(surface: GraphSurface) { - const geometry = new Geometry().setIndex(new BufferAttribute(new Uint32Array(3), 1)); - geometry.setAttribute('position', new BufferAttribute(new Float32Array(9), 3)); - geometry.setAttribute('normal', new BufferAttribute(new Float32Array(9), 3)); - const made = new GraphMesh(geometry, surface); - made.frustumCulled = false; - return made; -} /** A standard surface, a transparent one and one the curve skips, drawn once per `linear`. */ function draw(...linear: boolean[]) { const scene = new GraphScene(); - scene.add(mesh(new GraphSurface('standard'))); - scene.add(mesh(new GraphSurface('standard', { transparent: true, opacity: 0.5 }))); - scene.add(mesh(new GraphSurface('standard', { toneMapped: false }))); + scene.add(G.triangleMesh(new GraphSurface('standard'))); + scene.add(G.triangleMesh(new GraphSurface('standard', { transparent: true, opacity: 0.5 }))); + scene.add(G.triangleMesh(new GraphSurface('standard', { toneMapped: false }))); const context = createTestContext(); const sceneDraw = createSceneDraw(context.gl, scene); for (const each of linear) { diff --git a/packages/sdk-browser/src/world/render/composeRefusal.test.ts b/packages/sdk-browser/src/world/render/composeRefusal.test.ts index 1b9d2ea5bc..2e2797066e 100644 --- a/packages/sdk-browser/src/world/render/composeRefusal.test.ts +++ b/packages/sdk-browser/src/world/render/composeRefusal.test.ts @@ -9,10 +9,8 @@ import * as G from '../../host/graph/graph.fixture.ts'; import type { RenderBackend } from '../../backend/types.ts'; import { EffectChain } from '../../../../sdk-core/src/world/effect/chain.ts'; import { effect } from '../../../../sdk-core/src/world/effect/index.ts'; -import { Geometry } from '../../../../sdk-core/src/world/geometry/geometry.ts'; -import { BufferAttribute } from '../../../../sdk-core/src/world/buffer/attribute.ts'; import { GraphScene } from '../../host/graph/scene.ts'; -import { GraphInstancedMesh, GraphMesh } from '../../host/graph/mesh.ts'; +import { GraphInstancedMesh } from '../../host/graph/mesh.ts'; import { GraphSurface } from '../../host/graph/surface.ts'; import { HOST_BLENDING_MULTIPLY, @@ -33,15 +31,6 @@ const halfFloats = { getExtension: (name: string) => (name === 'EXT_color_buffer_float' ? {} : null), }; -/** A drawn mesh of three corners in `surface`. */ -function mesh(surface: GraphSurface) { - const geometry = new Geometry().setIndex(new BufferAttribute(new Uint32Array(3), 1)); - geometry.setAttribute('position', new BufferAttribute(new Float32Array(9), 3)); - geometry.setAttribute('normal', new BufferAttribute(new Float32Array(9), 3)); - const made = new GraphMesh(geometry, surface); - made.frustumCulled = false; - return made; -} const blended = (blending: number) => new GraphSurface('standard', { transparent: true, opacity: 0.5, blending }); @@ -89,7 +78,10 @@ const MODES = [ for (const [name, blending] of MODES) { test(`a pass added while a ${name} surface is drawn: every frame drawn, said once`, async () => { - const scene = new GraphScene().add(mesh(new GraphSurface('standard')), mesh(blended(blending))); + const scene = new GraphScene().add( + G.triangleMesh(new GraphSurface('standard')), + G.triangleMesh(blended(blending)), + ); const chain = new EffectChain(); const view = session(scene, chain); const said = await heard(view, () => { @@ -102,9 +94,9 @@ for (const [name, blending] of MODES) { }); test(`a ${name} surface entering a world with a pass: drawn, said once`, async () => { - const scene = new GraphScene().add(mesh(new GraphSurface('standard'))); + const scene = new GraphScene().add(G.triangleMesh(new GraphSurface('standard'))); const view = session(scene, new EffectChain().add(effect.bloom())); - const glass = mesh(blended(blending)); + const glass = G.triangleMesh(blended(blending)); const said = await heard(view, () => { assert.deepEqual(view.frame(), { chained: true, submitted: 1 }); scene.add(glass); @@ -120,7 +112,7 @@ for (const [name, blending] of MODES) { test(`a surface switched to ${name} under a pass: drawn, said once`, async () => { const surface = blended(HOST_BLENDING_NORMAL); const view = session( - new GraphScene().add(mesh(surface)), + new GraphScene().add(G.triangleMesh(surface)), new EffectChain().add(effect.bloom()), ); const said = await heard(view, () => { @@ -134,7 +126,7 @@ for (const [name, blending] of MODES) { test(`a WebGL2 session opened on a world with a pass and a ${name} surface`, async () => { // The world falling back to WebGL2 opens its session on what it already holds. - const scene = new GraphScene().add(mesh(blended(blending))); + const scene = new GraphScene().add(G.triangleMesh(blended(blending))); const view = session(scene, new EffectChain().add(effect.bloom())); const said = await heard(view, () => { assert.deepEqual(view.frame(), { chained: false, submitted: 1 }, 'its first frame drawn'); @@ -146,11 +138,11 @@ for (const [name, blending] of MODES) { test('a multiply instanced mesh placed nowhere keeps the chain on until it is placed', async () => { const surface = blended(HOST_BLENDING_MULTIPLY), - placed = mesh(surface), + placed = G.triangleMesh(surface), pool = new GraphInstancedMesh(placed.geometry, surface, 1); pool.count = 0; pool.frustumCulled = false; - const scene = new GraphScene().add(mesh(new GraphSurface('standard')), pool); + const scene = new GraphScene().add(G.triangleMesh(new GraphSurface('standard')), pool); const view = session(scene, new EffectChain().add(effect.bloom())); const said = await heard(view, () => { assert.equal(view.frame().chained, true, 'nothing submitted, nothing refused'); From c49c436d3991ca108c14068c9d7e0e126a85776a Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 05:10:08 +0200 Subject: [PATCH 06/13] refactor(lighting): the engine answers the linear refusal, a transmissive surface included, and the notice dedupes alone (#349) --- docs/ENGINE.md | 12 +++-- .../src/backend/autonomous/pages.ts | 1 + packages/sdk-browser/src/backend/types.ts | 5 ++ .../src/webgl/cluster/linearRefusal.ts | 48 +++++++++---------- .../src/webgl/cluster/materialBinding.test.ts | 11 +++-- .../src/webgl/cluster/materialBinding.ts | 3 +- .../src/webgl/cluster/sceneDraw.ts | 3 ++ .../sdk-browser/src/world/render/compose.ts | 24 +++++----- .../src/world/render/composeEffects.test.ts | 2 +- .../src/world/render/composeRefusal.test.ts | 23 ++++++++- .../sdk-browser/src/world/session/options.ts | 8 ++-- 11 files changed, 83 insertions(+), 57 deletions(-) diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 33666acd2b..93f05ae526 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -199,11 +199,13 @@ revision and asks for a frame. surfaces whose material skips the curve (`toneMapped: false`); the output program leaves that share as drawn. Coverage past one is read as light (`effects/webglOutput.ts`). With a chain, a `none`-blended surface covers as an opaque one. Multiply and subtractive filter the background, - which the linear target does not hold: before it binds the target, the composer walks the scene as - the draw does (`webgl/cluster/linearRefusal.ts`), and a frame that draws a transparent surface in - either mode is drawn whole without the chain, never stopped mid-draw; `ComposedChain.refused` - hears the mode when the refusal starts, and the world says `effects-refused-blending` once - (`noticeEffectRefusal`). A context that cannot render half floats draws without the chain. + which the linear target does not hold: before it binds the target, the composer asks the engine + (`RenderBackend.linearRefusal`, a walk of the scene as the draw reads it, + `webgl/cluster/linearRefusal.ts`), and a frame that draws a transparent surface in either mode, + transmissive or not, is drawn whole without the chain, never stopped mid-draw; + `ComposedChain.refused` hears the mode on each such frame, and the world says + `effects-refused-blending` once (`noticeEffectRefusal`). A context that cannot render half floats + draws without the chain. - **Kinds**: each renderer holds one table from pass kind to implementation (`WEBGPU_KINDS`, `WEBGL_KINDS`); a new built-in or the custom pass is one entry. The kinds of a chain share its two pass targets; each holds its own resources besides, sized for the passes of its kind — the diff --git a/packages/sdk-browser/src/backend/autonomous/pages.ts b/packages/sdk-browser/src/backend/autonomous/pages.ts index 61f8df3922..b1ac960770 100644 --- a/packages/sdk-browser/src/backend/autonomous/pages.ts +++ b/packages/sdk-browser/src/backend/autonomous/pages.ts @@ -148,6 +148,7 @@ export const autonomousPagesBackend: BackendFactory = (context) => { if (ready) frame(camera); }, drawHostGeometry: hostDraw.drawHostGeometry, + linearRefusal: hostDraw.linearRefusal, ...instances, ...autonomousPlacements({ ...tables, diff --git a/packages/sdk-browser/src/backend/types.ts b/packages/sdk-browser/src/backend/types.ts index af6ab13958..0d802580d0 100644 --- a/packages/sdk-browser/src/backend/types.ts +++ b/packages/sdk-browser/src/backend/types.ts @@ -10,6 +10,7 @@ import type { } from '../../../sdk-core/src/index.ts'; import type { BackendDrawCounters, BackendMetrics } from '../diagnostic/metricKeys.ts'; import type { SceneToneMapping } from '../../../sdk-core/src/scene/core/environment.ts'; +import type { Blending } from '../../../sdk-core/src/world/constants/index.ts'; import type { MemoryBudgets, MemoryBudgetsReport } from '../residency/pools.ts'; import type { CpuStepSummary } from '../stage/cpuProfile.ts'; import type { BackendDiagnostic, DiagnosticDetail } from '../diagnostic/types.ts'; @@ -49,6 +50,10 @@ export interface RenderBackend extends BackendSceneUpdates { * scene a witness holds — into the framebuffer the host has bound and cleared, `output` * naming it and its display chain. Absent from an engine that presents its own surface. */ drawHostGeometry?(camera: HostDrawCamera, output: HostDrawOutput): void; + /** The blending of a surface its next `drawHostGeometry` draws that a linear output cannot + * hold, or `undefined`; read before the effect chain binds its target, which such a frame is + * drawn whole without. Absent from an engine whose linear draw holds every mode. */ + linearRefusal?(): Blending | undefined; readonly overBudget: boolean; /** True when the last rendered frame was held: nothing was reselected or rebuilt, and the * attached scene IS this frame. Read per frame; absent from an engine that holds nothing. */ diff --git a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts index 7ad98c71b2..53ff88e51a 100644 --- a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts +++ b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts @@ -1,48 +1,46 @@ import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; import type { HostScene } from '../../host/resources.ts'; import { isDrawnNode, isInstancedNode } from '../../host/graph/kinds.ts'; -import { blendingOf, blendingRefusal } from '../../scene/materialBlending.ts'; -import { isTransmissive } from '../../visibility/shader/material.ts'; +import { blendingOf } from '../../scene/materialBlending.ts'; import { firstMaterial } from '../../scene/materialSide.ts'; -/** The modes the effect chain's linear target cannot hold (`refusesLinear`). */ -export type LinearRefusedBlending = Extract; - /** * Whether a transparent surface in `mode` cannot be drawn into the effect chain's linear target, * whose alpha is coverage over transparent black (`../../effects/webglOutput.ts`): multiply and * subtractive filter what the display target holds, the background included, which that target * does not hold. WebGPU composes them with the chain; WebGL2 draws such a frame without it. */ -export const refusesLinear = (mode: Blending | undefined): mode is LinearRefusedBlending => +export const refusesLinear = (mode: Blending | undefined) => mode === 'multiply' || mode === 'subtractive'; -type Walked = { readonly visible?: boolean; readonly children?: readonly Walked[] }; +/** A node as the walk reads it: a published graph's nodes are objects of any shape. */ +type Walked = { readonly visible?: boolean; readonly children?: readonly object[] }; +const LEAF: readonly object[] = []; -function refusalUnder(nodes: readonly Walked[]): LinearRefusedBlending | undefined { - for (const node of nodes) { +function refusalUnder(nodes: readonly object[]): Blending | undefined { + for (const node of nodes as readonly Walked[]) { if (!node.visible) continue; // An instanced mesh placed nowhere submits nothing (`renderer.ts`): it keeps no chain off. - const drawn = isDrawnNode(node) && !(isInstancedNode(node) && !node.count); - const surface = drawn ? firstMaterial(node.material) : undefined; - const mode = - surface?.visible && surface.transparent - ? blendingOf(surface.blending as number | undefined) - : undefined; - // A mode no path draws for this surface is the draw's own refusal (`blendingRefusal`), named - // there: only the modes every path draws but the linear target cannot hold are read here. - if (refusesLinear(mode) && !blendingRefusal(mode, isTransmissive(surface!))) return mode; - const below = refusalUnder(node.children ?? []); + if (isDrawnNode(node) && !(isInstancedNode(node) && !node.count)) { + const surface = firstMaterial(node.material); + if (surface?.visible && surface.transparent) { + // Transmissive or not: a view may zero the transmission before the draw + // (`../../lighting/unlitAlbedo.ts`), which then binds the surface in this mode. + const mode = blendingOf(surface.blending as number | undefined); + if (refusesLinear(mode)) return mode; + } + } + const below = refusalUnder(node.children ?? LEAF); if (below) return below; } } /** * The mode of the first surface the scene draw would draw (`sceneDraw.ts`: a visible mesh under - * visible parents, its surface visible, an instanced one placed at least once) that the linear target cannot hold, or `undefined`. Read - * before the chain binds its target, on a frame the composer draws: the frame is then drawn - * without the chain, never stopped in the middle of its draw. It reads the scene, not what the - * camera culls: the chain does not blink on and off as such a surface enters and leaves the view. + * visible parents, its surface visible and transparent, an instanced one placed at least once) + * that the linear target cannot hold (`refusesLinear`), or `undefined`. Read before the chain + * binds its target, the frame is then drawn whole without the chain, never stopped mid-draw. It + * reads the scene, not what the camera culls: the chain does not blink as such a surface enters + * and leaves the view. */ -export const linearRefusal = (scene: HostScene) => - refusalUnder(scene.children as readonly Walked[]); +export const linearRefusal = (scene: HostScene) => refusalUnder(scene.children); diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts index 224a738f59..55c830ee29 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts @@ -122,15 +122,16 @@ test('Into the effect chain, a surface covers its pixel as the display path show assert.equal(flagOf(transparent(HOST_BLENDING_NONE), 'covering', true), 1, 'none'); assert.equal(flagOf(transparent(HOST_BLENDING_NORMAL), 'covering', true), 0, 'normal'); assert.equal(flagOf(transparent(HOST_BLENDING_ADDITIVE), 'covering', true), 0, 'additive'); - // Multiply and subtractive filter the background the linear target does not hold: refused. + // Multiply and subtractive filter the background the linear target does not hold: refused + // by the path's named refusal, for a caller that skipped `linearRefusal`. for (const [blending, mode] of [ [HOST_BLENDING_MULTIPLY, 'multiply'], [HOST_BLENDING_SUBTRACTIVE, 'subtractive'], ] as const) { - assert.throws( - () => flagOf(transparent(blending), 'covering', true), - new Error(`the WebGL2 effect chain cannot draw ${mode} blending`), - ); + assert.throws(() => flagOf(transparent(blending), 'covering', true), { + code: 'CLUSTER_MATERIAL_UNSUPPORTED', + details: { reason: `the WebGL2 effect chain cannot draw ${mode} blending` }, + }); assert.equal(flagOf(transparent(blending), 'covering'), undefined, 'drawn without a chain'); } // A mode no path draws is refused here as by the display path, never drawn uncovered. diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts index 70495529dc..46c7f2dcf7 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts @@ -9,6 +9,7 @@ import type { Side } from '../../../../sdk-core/src/index.ts'; import type { WebglClusterTextures } from './textures.ts'; import { drawnModeOf, type WebglClusterState } from './state.ts'; import { refusesLinear } from './linearRefusal.ts'; +import { refuseCluster } from './refusal.ts'; import type { Matrix3UniformCache } from './uniforms.ts'; import type { WebglClusterMaterialUniforms } from './materialUniforms.ts'; @@ -41,7 +42,7 @@ type Binding = { function coversLinear(material: Material) { if (!material.transparent) return true; const mode = drawnModeOf(material); - if (refusesLinear(mode)) throw new Error(`the WebGL2 effect chain cannot draw ${mode} blending`); + if (refusesLinear(mode)) refuseCluster(`the WebGL2 effect chain cannot draw ${mode} blending`); return mode === 'none'; } diff --git a/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts b/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts index 161a873bfc..81ea6fe3d1 100644 --- a/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts +++ b/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts @@ -12,6 +12,7 @@ import type { WebglClusterScene } from './lights.ts'; import type { SceneCopy } from './copyCulling.ts'; import { WebglClusterOwner } from './owner.ts'; import { depthOf } from './meshDepth.ts'; +import { linearRefusal } from './linearRefusal.ts'; import { meshes } from '../../scene/meshes.ts'; import { DEFAULT_PIXEL_RATIO } from '../../backend/common.ts'; @@ -140,6 +141,8 @@ export function createSceneDraw( } counters.triangles = owner.submittedTriangles; }, + /** A surface this graph draws that the linear output cannot hold (`linearRefusal`). */ + linearRefusal: () => linearRefusal(display), counters: () => (opened ? counters : null), dispose() { owner?.dispose(); diff --git a/packages/sdk-browser/src/world/render/compose.ts b/packages/sdk-browser/src/world/render/compose.ts index 39eb49e770..17fcaebd1e 100644 --- a/packages/sdk-browser/src/world/render/compose.ts +++ b/packages/sdk-browser/src/world/render/compose.ts @@ -17,17 +17,17 @@ import { type WebglRenderTarget, } from '../../webgl/core/renderTarget.ts'; import { createWebglEffects, type WebglEffectOutput } from '../../effects/webglEffects.ts'; -import { linearRefusal, type LinearRefusedBlending } from '../../webgl/cluster/linearRefusal.ts'; +import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; const NONE: readonly EffectPass[] = []; /** The world's effect chain as the composer draws it: `shown` is false in a diagnostic view, - * which shows the engine's image as it is; `refused` hears the mode of a surface that keeps the - * chain off a frame (`linearRefusal`), each time one starts to. */ + * which shows the engine's image as it is; `refused` hears, on each frame it keeps the chain + * off, the mode `RenderBackend.linearRefusal` names. */ export type ComposedChain = { chain: EffectChain; shown: () => boolean; - refused?: (blending: LinearRefusedBlending) => void; + refused?: (blending: Blending) => void; }; /** @@ -67,8 +67,7 @@ export function createFrameComposer( toneCurve: 0, background: [0, 0, 0], }; - let keptRevision = 0, - refusal: LinearRefusedBlending | undefined; + let keptRevision = 0; /** The engine's background, sRGB-encoded like everything the destinations store. */ const encode = (background: SceneColour) => { const { r, g, b } = background?.isColor ? background : { r: 0, g: 0, b: 0 }; @@ -88,19 +87,18 @@ export function createFrameComposer( gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); }; /** The passes this frame draws: none without a chain, in a diagnostic view, on a destination - * that takes the engine's image alone, on a context that cannot hold the targets, or while a - * surface drawn blends in a mode the chain's target cannot hold — every surface is still drawn, - * the chain comes back once none does. */ + * that takes the engine's image alone, on a context that cannot hold the targets, or on a frame + * the engine's linear draw cannot hold (`linearRefusal`) — every surface is still drawn. */ const passesOf = (backend: RenderBackend, wanted: boolean) => { if (!composed) return NONE; const passes = composed.chain.stage('before-tone-mapping'); // An emptied chain gives its targets back; one kept aside for a capture keeps them. if (!passes.length) effects!.release(); if (!passes.length || !wanted || !composed.shown() || !effects!.supported()) return NONE; - const refused = linearRefusal(backend.scene); - if (refused && refused !== refusal) composed.refused?.(refused); - refusal = refused; - return refused ? NONE : passes; + const refused = backend.linearRefusal?.(); + if (!refused) return passes; + composed.refused?.(refused); + return NONE; }; /** * `reuse` is false where the kept frame is not this engine's: a fallback takes over the image diff --git a/packages/sdk-browser/src/world/render/composeEffects.test.ts b/packages/sdk-browser/src/world/render/composeEffects.test.ts index f29e585f15..9c9fb9688e 100644 --- a/packages/sdk-browser/src/world/render/composeEffects.test.ts +++ b/packages/sdk-browser/src/world/render/composeEffects.test.ts @@ -22,7 +22,7 @@ function engine(held = false) { const outputs: HostDrawOutput[] = []; const backend = { id: 'engine', - scene: { background: { isColor: true, r: 0.5, g: 0, b: 1 }, children: [] }, + scene: { background: { isColor: true, r: 0.5, g: 0, b: 1 } }, frameHeld: held, drawHostGeometry: (_camera: unknown, output: HostDrawOutput) => outputs.push({ ...output }), } as unknown as RenderBackend; diff --git a/packages/sdk-browser/src/world/render/composeRefusal.test.ts b/packages/sdk-browser/src/world/render/composeRefusal.test.ts index 2e2797066e..8c83e17752 100644 --- a/packages/sdk-browser/src/world/render/composeRefusal.test.ts +++ b/packages/sdk-browser/src/world/render/composeRefusal.test.ts @@ -18,6 +18,7 @@ import { HOST_BLENDING_SUBTRACTIVE, } from '../../host/surfaceConstants.ts'; import { createSceneDraw } from '../../webgl/cluster/sceneDraw.ts'; +import { createUnlitAlbedo } from '../../lighting/unlitAlbedo.ts'; import { createTestContext } from '../../webgl/core/testContext.fixture.ts'; import { createWorldNotices, @@ -138,8 +139,7 @@ for (const [name, blending] of MODES) { test('a multiply instanced mesh placed nowhere keeps the chain on until it is placed', async () => { const surface = blended(HOST_BLENDING_MULTIPLY), - placed = G.triangleMesh(surface), - pool = new GraphInstancedMesh(placed.geometry, surface, 1); + pool = new GraphInstancedMesh(G.triangleMesh(surface).geometry, surface, 1); pool.count = 0; pool.frustumCulled = false; const scene = new GraphScene().add(G.triangleMesh(new GraphSurface('standard')), pool); @@ -151,3 +151,22 @@ test('a multiply instanced mesh placed nowhere keeps the chain on until it is pl }); assert.deepEqual(said, ['effects-refused-blending']); }); + +test('a transmissive multiply surface in the unlit view, which zeroes its transmission', async () => { + // The view zeroes the transmission before the draw reads the surface: the draw then binds it + // in multiply, so the chain is off on that frame whatever the surface declares. + const glass = new GraphSurface('physical', { + transparent: true, + opacity: 0.5, + transmission: 1, + blending: HOST_BLENDING_MULTIPLY, + }); + const scene = new GraphScene().add(G.triangleMesh(glass)); + createUnlitAlbedo(scene).setEnabled(true); + const view = session(scene, new EffectChain().add(effect.bloom())); + const said = await heard(view, () => { + assert.deepEqual(view.frame(), { chained: false, submitted: 1 }, 'no throw, no hole'); + }); + assert.equal(glass.transmission, 1, 'the view gives the surface back'); + assert.deepEqual(said, ['effects-refused-blending']); +}); diff --git a/packages/sdk-browser/src/world/session/options.ts b/packages/sdk-browser/src/world/session/options.ts index 5f4a43ec7e..79916d753b 100644 --- a/packages/sdk-browser/src/world/session/options.ts +++ b/packages/sdk-browser/src/world/session/options.ts @@ -115,11 +115,9 @@ export interface MeasuredWorldOptions { temporalAntialiasing?: boolean; /** The world's effect chain, drawn after temporal antialiasing (`world.effects`). */ effects?: import('../../../../sdk-core/src/world/effect/chain.ts').EffectChain; - /** Hears the mode of a surface that keeps WebGL2 from drawing `effects` on a frame, each time - * one starts to: that frame is drawn whole without the chain (`world/render/compose.ts`). */ - effectsRefused?: ( - blending: import('../../webgl/cluster/linearRefusal.ts').LinearRefusedBlending, - ) => void; + /** Hears the mode of a surface that keeps WebGL2 from drawing `effects` on a frame, drawn + * whole without the chain (`ComposedChain.refused`). */ + effectsRefused?: import('../render/compose.ts').ComposedChain['refused']; /** Whether the prepared scene reads the source images. `'cache'`, the default: an image whose * mip chain the cache carries is neither fetched nor decoded — the engine reads the baked * levels, which it does whatever this option says. `'host'`: the scene reads and decodes From 7be9a24ba17d9b4dcdfb483e536377573dc07c0e Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 05:16:28 +0200 Subject: [PATCH 07/13] fix(lighting): the refusal notice builds nothing past the first frame and promises only the chain's return (#349) --- .../src/world/diagnostic/worldNotices.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/sdk-browser/src/world/diagnostic/worldNotices.ts b/packages/sdk-browser/src/world/diagnostic/worldNotices.ts index a31dac3797..f9599c8cd1 100644 --- a/packages/sdk-browser/src/world/diagnostic/worldNotices.ts +++ b/packages/sdk-browser/src/world/diagnostic/worldNotices.ts @@ -99,15 +99,21 @@ export function noticeEffectBudget( } /** - * The WebGL2 composer's word (`effectsRefused`) that a frame was drawn whole without the effect - * chain, a transparent surface drawn blending in `blending`, which the chain's linear target + * The WebGL2 composer's word (`effectsRefused`) that a frame was drawn without the effect chain, + * a transparent surface drawn blending in `blending`, which the chain's linear target * cannot hold (`linearRefusal`): said once per world, as `effects-refused-blending`. WebGPU draws - * both and never says it. + * both and never says it. Heard on every refused frame: past the first, it builds nothing. */ -export const noticeEffectRefusal = (notices: Pick) => (blending: Blending) => - notices.once( - 'effects-refused-blending', - `effect chain not drawn on WebGL2: a transparent surface blends in ${blending}, which ` + - `the chain cannot hold; the frame is drawn whole without it until no such surface is drawn`, - { blending }, - ); +export function noticeEffectRefusal(notices: Pick) { + let said = false; + return (blending: Blending) => { + if (said) return; + said = true; + notices.once( + 'effects-refused-blending', + `effect chain not drawn on WebGL2: a transparent surface blends in ${blending}, which ` + + `the chain cannot hold; the chain comes back once no such surface is drawn`, + { blending }, + ); + }; +} From cef7d0281114b617b67147d8fdef7d07aa39c1cb Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 05:18:04 +0200 Subject: [PATCH 08/13] refactor(lighting): the composer keeps the linear refusal walk, the backend files at their line cap (#349) --- docs/ENGINE.md | 7 +++---- packages/sdk-browser/src/backend/autonomous/pages.ts | 1 - packages/sdk-browser/src/backend/types.ts | 5 ----- packages/sdk-browser/src/webgl/cluster/sceneDraw.ts | 3 --- packages/sdk-browser/src/world/render/compose.ts | 10 ++++++---- .../src/world/render/composeEffects.test.ts | 2 +- 6 files changed, 10 insertions(+), 18 deletions(-) diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 93f05ae526..e9912e8e02 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -199,10 +199,9 @@ revision and asks for a frame. surfaces whose material skips the curve (`toneMapped: false`); the output program leaves that share as drawn. Coverage past one is read as light (`effects/webglOutput.ts`). With a chain, a `none`-blended surface covers as an opaque one. Multiply and subtractive filter the background, - which the linear target does not hold: before it binds the target, the composer asks the engine - (`RenderBackend.linearRefusal`, a walk of the scene as the draw reads it, - `webgl/cluster/linearRefusal.ts`), and a frame that draws a transparent surface in either mode, - transmissive or not, is drawn whole without the chain, never stopped mid-draw; + which the linear target does not hold: before it binds the target, the composer walks the scene as + the draw reads it (`webgl/cluster/linearRefusal.ts`), and a frame that draws a transparent surface + in either mode, transmissive or not, is drawn whole without the chain, never stopped mid-draw; `ComposedChain.refused` hears the mode on each such frame, and the world says `effects-refused-blending` once (`noticeEffectRefusal`). A context that cannot render half floats draws without the chain. diff --git a/packages/sdk-browser/src/backend/autonomous/pages.ts b/packages/sdk-browser/src/backend/autonomous/pages.ts index b1ac960770..61f8df3922 100644 --- a/packages/sdk-browser/src/backend/autonomous/pages.ts +++ b/packages/sdk-browser/src/backend/autonomous/pages.ts @@ -148,7 +148,6 @@ export const autonomousPagesBackend: BackendFactory = (context) => { if (ready) frame(camera); }, drawHostGeometry: hostDraw.drawHostGeometry, - linearRefusal: hostDraw.linearRefusal, ...instances, ...autonomousPlacements({ ...tables, diff --git a/packages/sdk-browser/src/backend/types.ts b/packages/sdk-browser/src/backend/types.ts index 0d802580d0..af6ab13958 100644 --- a/packages/sdk-browser/src/backend/types.ts +++ b/packages/sdk-browser/src/backend/types.ts @@ -10,7 +10,6 @@ import type { } from '../../../sdk-core/src/index.ts'; import type { BackendDrawCounters, BackendMetrics } from '../diagnostic/metricKeys.ts'; import type { SceneToneMapping } from '../../../sdk-core/src/scene/core/environment.ts'; -import type { Blending } from '../../../sdk-core/src/world/constants/index.ts'; import type { MemoryBudgets, MemoryBudgetsReport } from '../residency/pools.ts'; import type { CpuStepSummary } from '../stage/cpuProfile.ts'; import type { BackendDiagnostic, DiagnosticDetail } from '../diagnostic/types.ts'; @@ -50,10 +49,6 @@ export interface RenderBackend extends BackendSceneUpdates { * scene a witness holds — into the framebuffer the host has bound and cleared, `output` * naming it and its display chain. Absent from an engine that presents its own surface. */ drawHostGeometry?(camera: HostDrawCamera, output: HostDrawOutput): void; - /** The blending of a surface its next `drawHostGeometry` draws that a linear output cannot - * hold, or `undefined`; read before the effect chain binds its target, which such a frame is - * drawn whole without. Absent from an engine whose linear draw holds every mode. */ - linearRefusal?(): Blending | undefined; readonly overBudget: boolean; /** True when the last rendered frame was held: nothing was reselected or rebuilt, and the * attached scene IS this frame. Read per frame; absent from an engine that holds nothing. */ diff --git a/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts b/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts index 81ea6fe3d1..161a873bfc 100644 --- a/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts +++ b/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts @@ -12,7 +12,6 @@ import type { WebglClusterScene } from './lights.ts'; import type { SceneCopy } from './copyCulling.ts'; import { WebglClusterOwner } from './owner.ts'; import { depthOf } from './meshDepth.ts'; -import { linearRefusal } from './linearRefusal.ts'; import { meshes } from '../../scene/meshes.ts'; import { DEFAULT_PIXEL_RATIO } from '../../backend/common.ts'; @@ -141,8 +140,6 @@ export function createSceneDraw( } counters.triangles = owner.submittedTriangles; }, - /** A surface this graph draws that the linear output cannot hold (`linearRefusal`). */ - linearRefusal: () => linearRefusal(display), counters: () => (opened ? counters : null), dispose() { owner?.dispose(); diff --git a/packages/sdk-browser/src/world/render/compose.ts b/packages/sdk-browser/src/world/render/compose.ts index a0b8d625c6..89ac0e50fa 100644 --- a/packages/sdk-browser/src/world/render/compose.ts +++ b/packages/sdk-browser/src/world/render/compose.ts @@ -17,13 +17,15 @@ import { type WebglRenderTarget, } from '../../webgl/core/renderTarget.ts'; import { createWebglEffects, type WebglEffectOutput } from '../../effects/webglEffects.ts'; +import { linearRefusal } from '../../webgl/cluster/linearRefusal.ts'; +import type { HostScene } from '../../host/resources.ts'; import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; const NONE: readonly EffectPass[] = []; /** The world's effect chain as the composer draws it: `shown` is false in a diagnostic view, * which shows the engine's image as it is; `refused` hears, on each frame it keeps the chain - * off, the mode `RenderBackend.linearRefusal` names. */ + * off, the mode `linearRefusal` names. */ export type ComposedChain = { chain: EffectChain; shown: () => boolean; @@ -92,13 +94,13 @@ export function createFrameComposer( /** The passes this frame draws: none without a chain, in a diagnostic view, on a destination * that takes the engine's image alone, on a context that cannot hold the targets, or on a frame * the engine's linear draw cannot hold (`linearRefusal`) — every surface is still drawn. */ - const passesOf = (backend: RenderBackend, wanted: boolean) => { + const passesOf = (scene: HostScene, wanted: boolean) => { if (!composed) return NONE; const passes = composed.chain.stage('before-tone-mapping'); // An emptied chain gives its targets back; one kept aside for a capture keeps them. if (!passes.length) effects!.release(); if (!passes.length || !wanted || !composed.shown() || !effects!.supported()) return NONE; - const refused = backend.linearRefusal?.(); + const refused = linearRefusal(scene); if (!refused) return passes; composed.refused?.(refused); return NONE; @@ -137,7 +139,7 @@ export function createFrameComposer( // the chain (P4). A target thus holds what the page would show. output.toneMapped = backend.sceneLit?.() !== false; output.toneMapping = backend.sceneToneMapping?.() ?? DEFAULT_TONE_MAPPING; - const passes = passesOf(backend, chained); + const passes = passesOf(backend.scene, chained); const linear = passes.length ? effects!.begin(passes, width, height) : null; output.linear = !!linear; output.framebuffer = (linear ?? target)?.framebuffer ?? null; diff --git a/packages/sdk-browser/src/world/render/composeEffects.test.ts b/packages/sdk-browser/src/world/render/composeEffects.test.ts index 24540b1b88..f8a1a4a5ab 100644 --- a/packages/sdk-browser/src/world/render/composeEffects.test.ts +++ b/packages/sdk-browser/src/world/render/composeEffects.test.ts @@ -22,7 +22,7 @@ function engine(held = false) { const outputs: HostDrawOutput[] = []; const backend = { id: 'engine', - scene: { background: { isColor: true, r: 0.5, g: 0, b: 1 } }, + scene: { background: { isColor: true, r: 0.5, g: 0, b: 1 }, children: [] }, frameHeld: held, drawHostGeometry: (_camera: unknown, output: HostDrawOutput) => outputs.push({ ...output }), } as unknown as RenderBackend; From cc9fc8c19268c0353f7f9b3917196f0ad5bd7c83 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 06:48:08 +0200 Subject: [PATCH 09/13] refactor(lighting): the composer reads the linear refusal from the scene draw's own walk, walking nothing itself (#349) --- docs/ENGINE.md | 9 ++- .../src/backend/autonomous/pages.ts | 2 +- packages/sdk-browser/src/backend/hostDraw.ts | 16 +++++ packages/sdk-browser/src/backend/types.ts | 9 +-- .../src/webgl/cluster/linearOutput.test.ts | 2 +- .../src/webgl/cluster/linearRefusal.ts | 44 ++++--------- .../src/webgl/cluster/mips.test.ts | 4 +- .../src/webgl/cluster/sceneDraw.test.ts | 15 +++-- .../src/webgl/cluster/sceneDraw.ts | 48 ++++++++++---- .../sdk-browser/src/world/render/compose.ts | 13 ++-- .../src/world/render/composeRefusal.test.ts | 2 +- .../src/world/render/composeWalk.test.ts | 65 +++++++++++++++++++ 12 files changed, 161 insertions(+), 68 deletions(-) create mode 100644 packages/sdk-browser/src/backend/hostDraw.ts create mode 100644 packages/sdk-browser/src/world/render/composeWalk.test.ts diff --git a/docs/ENGINE.md b/docs/ENGINE.md index e9912e8e02..9a3df6ce8c 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -199,9 +199,12 @@ revision and asks for a frame. surfaces whose material skips the curve (`toneMapped: false`); the output program leaves that share as drawn. Coverage past one is read as light (`effects/webglOutput.ts`). With a chain, a `none`-blended surface covers as an opaque one. Multiply and subtractive filter the background, - which the linear target does not hold: before it binds the target, the composer walks the scene as - the draw reads it (`webgl/cluster/linearRefusal.ts`), and a frame that draws a transparent surface - in either mode, transmissive or not, is drawn whole without the chain, never stopped mid-draw; + which the linear target does not hold: before it binds the target, the composer asks the engine + (`BackendHostDraw.linearRefusal`), whose scene draw answers from its own walk of the graph — the + one walk per drawn image it already made, now at the first of that question and the draw, never + on a held frame (`webgl/cluster/sceneDraw.ts`, `linearRefusal.ts`). A frame that draws a + transparent surface in either mode, transmissive or not, is drawn whole without the chain, never + stopped mid-draw; `ComposedChain.refused` hears the mode on each such frame, and the world says `effects-refused-blending` once (`noticeEffectRefusal`). A context that cannot render half floats draws without the chain. diff --git a/packages/sdk-browser/src/backend/autonomous/pages.ts b/packages/sdk-browser/src/backend/autonomous/pages.ts index 61f8df3922..6d1025b174 100644 --- a/packages/sdk-browser/src/backend/autonomous/pages.ts +++ b/packages/sdk-browser/src/backend/autonomous/pages.ts @@ -147,7 +147,7 @@ export const autonomousPagesBackend: BackendFactory = (context) => { hostDraw.render(camera); if (ready) frame(camera); }, - drawHostGeometry: hostDraw.drawHostGeometry, + ...hostDraw.host, ...instances, ...autonomousPlacements({ ...tables, diff --git a/packages/sdk-browser/src/backend/hostDraw.ts b/packages/sdk-browser/src/backend/hostDraw.ts new file mode 100644 index 0000000000..0afc495f67 --- /dev/null +++ b/packages/sdk-browser/src/backend/hostDraw.ts @@ -0,0 +1,16 @@ +import type { Blending } from '../../../sdk-core/src/world/constants/index.ts'; +import type { HostDrawCamera } from '../camera/world.ts'; +import type { HostDrawOutput } from '../webgl/core/renderTarget.ts'; + +/** What an engine that draws on the host surface lets the host composer ask of its image. */ +export interface BackendHostDraw { + /** Draws the engine's whole image — paged clusters, diagnostic pages, scene copies, or the + * scene a witness holds — into the framebuffer the host has bound and cleared, `output` + * naming it and its display chain. Absent from an engine that presents its own surface. */ + drawHostGeometry?(camera: HostDrawCamera, output: HostDrawOutput): void; + /** The blending of a surface the next `drawHostGeometry` draws that the effect chain's linear + * output cannot hold (`../webgl/cluster/linearRefusal.ts`), or `undefined`: read by the + * composer before it binds that output, from the draw's own walk of its graph. Absent from an + * engine whose draw has no such surface. */ + linearRefusal?(): Blending | undefined; +} diff --git a/packages/sdk-browser/src/backend/types.ts b/packages/sdk-browser/src/backend/types.ts index af6ab13958..75dec24915 100644 --- a/packages/sdk-browser/src/backend/types.ts +++ b/packages/sdk-browser/src/backend/types.ts @@ -1,5 +1,5 @@ import type { HostDiagnosticFactory, HostScene, HostTexture } from '../host/resources.ts'; -import type { HostCamera, HostDrawCamera } from '../camera/world.ts'; +import type { HostCamera } from '../camera/world.ts'; import type { HostDrawOutput } from '../webgl/core/renderTarget.ts'; import type { BackendCapabilities, @@ -15,10 +15,11 @@ import type { CpuStepSummary } from '../stage/cpuProfile.ts'; import type { BackendDiagnostic, DiagnosticDetail } from '../diagnostic/types.ts'; import type { PlacementRows } from '../placement/rows.ts'; import type { BackendSceneUpdates } from '../placement/backendSceneUpdates.ts'; +import type { BackendHostDraw } from './hostDraw.ts'; import type { Object3D } from '../../../sdk-core/src/world/object/object3d.ts'; export type { BackendCapabilities, BackendDiagnostic, DiagnosticDetail, HostDrawOutput }; type ViewSize = { width: number; height: number }; -export interface RenderBackend extends BackendSceneUpdates { +export interface RenderBackend extends BackendSceneUpdates, BackendHostDraw { id: string; capabilities: BackendCapabilities; setDiagnostic?(mode: DiagnosticMode): void; @@ -45,10 +46,6 @@ export interface RenderBackend extends BackendSceneUpdates { signal?: AbortSignal; // Aborted by its dispose or its session's: `prepare` then fails as cancelled. prepare(): Promise; render(camera: HostCamera): void; - /** Draws the engine's whole image — paged clusters, diagnostic pages, scene copies, or the - * scene a witness holds — into the framebuffer the host has bound and cleared, `output` - * naming it and its display chain. Absent from an engine that presents its own surface. */ - drawHostGeometry?(camera: HostDrawCamera, output: HostDrawOutput): void; readonly overBudget: boolean; /** True when the last rendered frame was held: nothing was reselected or rebuilt, and the * attached scene IS this frame. Read per frame; absent from an engine that holds nothing. */ diff --git a/packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts b/packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts index b847d051e7..3f722e58c3 100644 --- a/packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts @@ -24,7 +24,7 @@ function draw(...linear: boolean[]) { for (const each of linear) { sceneDraw.render({} as HostCamera); const output = { toneMapped: true, framebuffer: null, width: 8, height: 4, linear: each }; - sceneDraw.drawHostGeometry(createHostDrawCamera(), output); + sceneDraw.host.drawHostGeometry(createHostDrawCamera(), output); } const uniforms = context.calls.filter(({ name }) => name.startsWith('uniform')); return { diff --git a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts index 53ff88e51a..cb988df333 100644 --- a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts +++ b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts @@ -1,6 +1,5 @@ import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; -import type { HostScene } from '../../host/resources.ts'; -import { isDrawnNode, isInstancedNode } from '../../host/graph/kinds.ts'; +import type { HostMaterials } from '../../host/resources.ts'; import { blendingOf } from '../../scene/materialBlending.ts'; import { firstMaterial } from '../../scene/materialSide.ts'; @@ -13,34 +12,19 @@ import { firstMaterial } from '../../scene/materialSide.ts'; export const refusesLinear = (mode: Blending | undefined) => mode === 'multiply' || mode === 'subtractive'; -/** A node as the walk reads it: a published graph's nodes are objects of any shape. */ -type Walked = { readonly visible?: boolean; readonly children?: readonly object[] }; -const LEAF: readonly object[] = []; - -function refusalUnder(nodes: readonly object[]): Blending | undefined { - for (const node of nodes as readonly Walked[]) { - if (!node.visible) continue; - // An instanced mesh placed nowhere submits nothing (`renderer.ts`): it keeps no chain off. - if (isDrawnNode(node) && !(isInstancedNode(node) && !node.count)) { - const surface = firstMaterial(node.material); - if (surface?.visible && surface.transparent) { - // Transmissive or not: a view may zero the transmission before the draw - // (`../../lighting/unlitAlbedo.ts`), which then binds the surface in this mode. - const mode = blendingOf(surface.blending as number | undefined); - if (refusesLinear(mode)) return mode; - } - } - const below = refusalUnder(node.children ?? LEAF); - if (below) return below; - } -} +/** A drawn mesh as the scene draw's walk meets it (`sceneDraw.ts`, `collect`). */ +type Met = { readonly kind?: string; readonly count?: number; readonly material?: HostMaterials }; /** - * The mode of the first surface the scene draw would draw (`sceneDraw.ts`: a visible mesh under - * visible parents, its surface visible and transparent, an instanced one placed at least once) - * that the linear target cannot hold (`refusesLinear`), or `undefined`. Read before the chain - * binds its target, the frame is then drawn whole without the chain, never stopped mid-draw. It - * reads the scene, not what the camera culls: the chain does not blink as such a surface enters - * and leaves the view. + * The mode of a visible drawn mesh that keeps the chain off a frame, or `undefined`: its surface + * visible and transparent in a mode `refusesLinear` names, an instanced one placed at least once + * — one placed nowhere submits nothing (`renderer.ts`). Transmissive or not: a view may zero the + * transmission before the draw (`../../lighting/unlitAlbedo.ts`), which then binds it in this mode. */ -export const linearRefusal = (scene: HostScene) => refusalUnder(scene.children); +export function linearRefusalOf(mesh: Met): Blending | undefined { + if (mesh.kind === 'instancedMesh' && !mesh.count) return; + const surface = mesh.material && firstMaterial(mesh.material); + if (!surface?.visible || !surface.transparent) return; + const mode = blendingOf(surface.blending as number | undefined); + return refusesLinear(mode) ? mode : undefined; +} diff --git a/packages/sdk-browser/src/webgl/cluster/mips.test.ts b/packages/sdk-browser/src/webgl/cluster/mips.test.ts index 22d9f52ccb..1f91a1a610 100644 --- a/packages/sdk-browser/src/webgl/cluster/mips.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/mips.test.ts @@ -111,7 +111,7 @@ test('a still scene files each surface once across frames, a hidden opaque one i const draw = createSceneDraw(gl.gl, scene); for (let frame = 0; frame < 3; frame++) { draw.render({} as HostCamera); - draw.drawHostGeometry(createHostDrawCamera(), output); + draw.host.drawHostGeometry(createHostDrawCamera(), output); } const filed = read.mock.calls.filter((call) => call.result).length; assert.deepEqual([filed, follow.mock.callCount()], [2, 3]); @@ -129,7 +129,7 @@ test('a world texel map is uploaded as stored, with its box chain', () => { const gl = context(); const draw = createSceneDraw(gl.gl, scene); draw.render({} as HostCamera); - draw.drawHostGeometry(createHostDrawCamera(), output); + draw.host.drawHostGeometry(createHostDrawCamera(), output); draw.dispose(); const uploaded = gl.of('texImage2D').map((args) => (args[8] as ArrayBufferView | null)?.buffer); assert.ok(uploaded.includes(pixels.buffer), 'uploaded as the bytes it holds'); diff --git a/packages/sdk-browser/src/webgl/cluster/sceneDraw.test.ts b/packages/sdk-browser/src/webgl/cluster/sceneDraw.test.ts index d8496f1fbd..f015535406 100644 --- a/packages/sdk-browser/src/webgl/cluster/sceneDraw.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/sceneDraw.test.ts @@ -29,9 +29,12 @@ function drawn(scene: GraphScene) { const context = createTestContext(); const draw = createSceneDraw(context.gl, scene); assert.equal(draw.counters(), null, 'no count before the first frame'); - assert.throws(() => draw.drawHostGeometry(createHostDrawCamera(), OUTPUT), /Draw before render/); + assert.throws( + () => draw.host.drawHostGeometry(createHostDrawCamera(), OUTPUT), + /Draw before render/, + ); draw.render({} as HostCamera); - draw.drawHostGeometry(createHostDrawCamera(), OUTPUT); + draw.host.drawHostGeometry(createHostDrawCamera(), OUTPUT); return { context, draw }; } @@ -70,7 +73,7 @@ test('without a context the draw is refused by name', () => { const draw = createSceneDraw(undefined, new GraphScene()); draw.render({} as HostCamera); assert.throws( - () => draw.drawHostGeometry(createHostDrawCamera(), OUTPUT), + () => draw.host.drawHostGeometry(createHostDrawCamera(), OUTPUT), /HOST_SURFACE_MISSING/, ); }); @@ -107,7 +110,7 @@ test('a mesh under a translated and rotated group draws where the reference draw draw = createSceneDraw(context.gl, scene), camera = new GraphCamera({ fov: 60, aspect: 1, near: 0.1, far: 100 }); draw.render({} as HostCamera); - draw.drawHostGeometry(readHostDrawCamera(createHostDrawCamera(), camera), OUTPUT); + draw.host.drawHostGeometry(readHostDrawCamera(createHostDrawCamera(), camera), OUTPUT); const uploaded = context .of('uniformMatrix4fv') .find((args) => (args[0] as { uniform: string }).uniform === 'modelViewMatrix')!; @@ -131,7 +134,7 @@ test('a transmissive copy draws over the backdrop the opaque meshes were drawn i scene.add(mesh(6, 0), glass); const draw = createSceneDraw(context.gl, scene, [glass]); draw.render({} as HostCamera); - draw.drawHostGeometry(createHostDrawCamera(), OUTPUT); + draw.host.drawHostGeometry(createHostDrawCamera(), OUTPUT); const submitted = context.calls.filter((call) => ['drawElements', 'bindFramebuffer', 'uniform1i'].includes(call.name), ); @@ -175,7 +178,7 @@ test('a line surface draws with its CSS width, the host pixel ratio and its dash for (const frame of [2, 1.5]) { ratio = frame; draw.render({} as HostCamera); - draw.drawHostGeometry(createHostDrawCamera(), OUTPUT); + draw.host.drawHostGeometry(createHostDrawCamera(), OUTPUT); } assert.deepEqual(uniform('pixelRatio'), [2, 1.5], 'each frame reads the ratio'); assert.deepEqual(uniform('lineWidth'), [3]); diff --git a/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts b/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts index 161a873bfc..a1caefcc6d 100644 --- a/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts +++ b/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts @@ -14,6 +14,9 @@ import { WebglClusterOwner } from './owner.ts'; import { depthOf } from './meshDepth.ts'; import { meshes } from '../../scene/meshes.ts'; import { DEFAULT_PIXEL_RATIO } from '../../backend/common.ts'; +import type { BackendHostDraw } from '../../backend/hostDraw.ts'; +import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; +import { linearRefusalOf } from './linearRefusal.ts'; /** The scene the owner reads for its lights and background, its world matrices resolved * before the read. */ @@ -47,7 +50,9 @@ const NO_BATCHES: readonly never[] = []; * * `render(camera)` opens the frame: it zeroes the counters, so that a frame * the composer held — nothing drawn — publishes nothing, never the previous draw; `counters()` is - * `null` before the first frame. Without a context (a session that never draws on the host + * `null` before the first frame. The graph is walked once per drawn image, at the first of + * `host.linearRefusal` and `host.drawHostGeometry`: never on a held frame, and never in `render`, + * which runs before the engine's frame writes the graph (`../../backend/autonomous/pages.ts`). Without a context (a session that never draws on the host * surface) the draw is refused by name. `pixelRatio`, read each frame, scales a line's CSS-pixel * width to the image's pixels. */ @@ -67,7 +72,9 @@ export function createSceneDraw( const opaque: WholeMesh[] = [], seeThrough: DrawnNode[] = []; let owner: WebglClusterOwner | undefined, - opened = false; + opened = false, + walked = false, + refused: Blending | undefined; // The projection times the view, and each drawn mesh's depth, read once a frame. const screen = new Float64Array(16), depths = new Map(); @@ -79,10 +86,20 @@ export function createSceneDraw( if (copied.has(node) || firstMaterial(node.material!)?.transparent) seeThrough.push(node as DrawnNode); else opaque.push(node as WholeMesh); - depths.set(node, depthOf(node, screen)); + refused ??= linearRefusalOf(node); } for (const child of node.children) collect(child); }; + /** The image's one walk of the graph: its world matrices, then what it draws, sorted later. */ + const walk = () => { + if (walked) return; + walked = true; + scene.updateMatrixWorld(); + opaque.length = seeThrough.length = 0; + refused = undefined; + followCopies(); + for (const child of scene.children) collect(child); + }; // Opaque meshes of one order are grouped by surface, numbered as first met, as the reference // groups them by the surfaces it numbers as it meets them — a run of one surface binds it once // —, then drawn from the nearest; a tie is broken by the node's number, as the reference's is. @@ -101,10 +118,10 @@ export function createSceneDraw( a.serial - b.serial; const backToFront = (a: DrawnNode, b: DrawnNode) => a.renderOrder - b.renderOrder || depth(b) - depth(a) || a.serial - b.serial; - return { - render(_camera: HostCamera) { - counters.triangles = 0; - opened = true; + const host: Required = { + linearRefusal() { + walk(); + return refused; }, drawHostGeometry(drawCamera: HostDrawCamera, output: HostDrawOutput) { if (!gl) throw new Error('HOST_SURFACE_MISSING'); @@ -115,12 +132,11 @@ export function createSceneDraw( owner.pixelRatio = pixelRatio(); scene.onBeforeRender?.(); try { - scene.updateMatrixWorld(); - opaque.length = seeThrough.length = 0; + walk(); depths.clear(); - followCopies(); multiplyMatrix4Typed(screen, drawCamera.projection, drawCamera.view); - for (const child of scene.children) collect(child); + for (const node of opaque as DrawnNode[]) depths.set(node, depthOf(node, screen)); + for (const node of seeThrough) depths.set(node, depthOf(node, screen)); (opaque as DrawnNode[]).sort(frontToBack); seeThrough.sort(backToFront); // A linear output is the effect chain's: its own program, which leaves the curve and the @@ -136,10 +152,20 @@ export function createSceneDraw( output.linear, ); } finally { + // A second draw of the same image — a capture — walks again, as every draw did. + walked = false; scene.onAfterRender?.(); } counters.triangles = owner.submittedTriangles; }, + }; + return { + render(_camera: HostCamera) { + counters.triangles = 0; + opened = true; + walked = false; + }, + host, counters: () => (opened ? counters : null), dispose() { owner?.dispose(); diff --git a/packages/sdk-browser/src/world/render/compose.ts b/packages/sdk-browser/src/world/render/compose.ts index 89ac0e50fa..2b755a4a0b 100644 --- a/packages/sdk-browser/src/world/render/compose.ts +++ b/packages/sdk-browser/src/world/render/compose.ts @@ -17,15 +17,13 @@ import { type WebglRenderTarget, } from '../../webgl/core/renderTarget.ts'; import { createWebglEffects, type WebglEffectOutput } from '../../effects/webglEffects.ts'; -import { linearRefusal } from '../../webgl/cluster/linearRefusal.ts'; -import type { HostScene } from '../../host/resources.ts'; import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; const NONE: readonly EffectPass[] = []; /** The world's effect chain as the composer draws it: `shown` is false in a diagnostic view, * which shows the engine's image as it is; `refused` hears, on each frame it keeps the chain - * off, the mode `linearRefusal` names. */ + * off, the mode the engine's `linearRefusal` names. */ export type ComposedChain = { chain: EffectChain; shown: () => boolean; @@ -93,14 +91,15 @@ export function createFrameComposer( }; /** The passes this frame draws: none without a chain, in a diagnostic view, on a destination * that takes the engine's image alone, on a context that cannot hold the targets, or on a frame - * the engine's linear draw cannot hold (`linearRefusal`) — every surface is still drawn. */ - const passesOf = (scene: HostScene, wanted: boolean) => { + * the engine's linear draw cannot hold (`linearRefusal`, read from the draw's own walk of its + * graph, which the draw then reuses) — every surface is still drawn. */ + const passesOf = (backend: RenderBackend, wanted: boolean) => { if (!composed) return NONE; const passes = composed.chain.stage('before-tone-mapping'); // An emptied chain gives its targets back; one kept aside for a capture keeps them. if (!passes.length) effects!.release(); if (!passes.length || !wanted || !composed.shown() || !effects!.supported()) return NONE; - const refused = linearRefusal(scene); + const refused = backend.linearRefusal?.(); if (!refused) return passes; composed.refused?.(refused); return NONE; @@ -139,7 +138,7 @@ export function createFrameComposer( // the chain (P4). A target thus holds what the page would show. output.toneMapped = backend.sceneLit?.() !== false; output.toneMapping = backend.sceneToneMapping?.() ?? DEFAULT_TONE_MAPPING; - const passes = passesOf(backend.scene, chained); + const passes = passesOf(backend, chained); const linear = passes.length ? effects!.begin(passes, width, height) : null; output.linear = !!linear; output.framebuffer = (linear ?? target)?.framebuffer ?? null; diff --git a/packages/sdk-browser/src/world/render/composeRefusal.test.ts b/packages/sdk-browser/src/world/render/composeRefusal.test.ts index 8c83e17752..9deba15276 100644 --- a/packages/sdk-browser/src/world/render/composeRefusal.test.ts +++ b/packages/sdk-browser/src/world/render/composeRefusal.test.ts @@ -40,7 +40,7 @@ const blended = (blending: number) => function session(scene: GraphScene, chain: EffectChain) { const context = createTestContext({ answers: halfFloats }); const draw = createSceneDraw(context.gl, scene); - const backend = { id: 'engine', scene, ...draw } as unknown as RenderBackend; + const backend = { id: 'engine', scene, ...draw, ...draw.host } as unknown as RenderBackend; const notices = createWorldNotices(); const refused = noticeEffectRefusal(notices); const compose = createFrameComposer(context.gl, camera, { diff --git a/packages/sdk-browser/src/world/render/composeWalk.test.ts b/packages/sdk-browser/src/world/render/composeWalk.test.ts new file mode 100644 index 0000000000..a4466d48ed --- /dev/null +++ b/packages/sdk-browser/src/world/render/composeWalk.test.ts @@ -0,0 +1,65 @@ +// #349: the effect chain's refusal of a multiply or subtractive surface on WebGL2 is read from the +// draw's own walk of its graph. A frame with a chain visits every node exactly as often as a frame +// without one: the composer walks nothing, and the draw walks once per image, as on develop. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as G from '../../host/graph/graph.fixture.ts'; +import type { RenderBackend } from '../../backend/types.ts'; +import { EffectChain } from '../../../../sdk-core/src/world/effect/chain.ts'; +import { effect } from '../../../../sdk-core/src/world/effect/index.ts'; +import { Group } from '../../../../sdk-core/src/world/object/object3d.ts'; +import { GraphScene } from '../../host/graph/scene.ts'; +import { GraphSurface } from '../../host/graph/surface.ts'; +import { createSceneDraw } from '../../webgl/cluster/sceneDraw.ts'; +import { createTestContext } from '../../webgl/core/testContext.fixture.ts'; +import { createFrameComposer } from './compose.ts'; + +const FRAMES = 5; +const halfFloats = { + getExtension: (name: string) => (name === 'EXT_color_buffer_float' ? {} : null), +}; + +/** A graph of nested groups and meshes whose every read of a node's children is counted. */ +function countedScene() { + const scene = new GraphScene(); + const group = new Group(); + group.add(G.triangleMesh(new GraphSurface('standard'))); + group.add(G.triangleMesh(new GraphSurface('standard', { transparent: true, opacity: 0.5 }))); + scene.add(group, G.triangleMesh(new GraphSurface('standard'))); + const visits = { count: 0 }; + scene.traverse((node) => { + const children = node.children; + Object.defineProperty(node, 'children', { + get: () => (visits.count++, children), + }); + }); + return { scene, visits }; +} + +/** The children reads of `FRAMES` drawn frames, the chain holding `passes`: nothing holds a + * frame here (`frameHeld` is absent), so each composes and draws. */ +function visitsOver(passes: number) { + const { scene, visits } = countedScene(); + const chain = new EffectChain(); + for (let i = 0; i < passes; i++) chain.add(effect.bloom()); + const context = createTestContext({ answers: halfFloats }); + const draw = createSceneDraw(context.gl, scene); + const backend = { id: 'engine', scene, ...draw, ...draw.host } as unknown as RenderBackend; + const compose = createFrameComposer(context.gl, G.perspectiveCamera(), { + effects: { chain, shown: () => true }, + }); + const drawn = context.of('drawElements').length; + visits.count = 0; + for (let frame = 0; frame < FRAMES; frame++) { + draw.render(G.perspectiveCamera()); + compose(backend, null); + } + assert.equal(context.of('drawElements').length - drawn, 3 * FRAMES, 'every frame drawn'); + return visits.count; +} + +test('a chain on WebGL2 visits the graph no more than the draw does without one', () => { + const without = visitsOver(0); + assert.ok(without > 0, 'the draw walks its graph'); + assert.equal(visitsOver(1), without, 'the composer walks nothing of its own'); +}); From 7cdec910ecb14163ec316e44cd8ae5244fa926f7 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 06:51:16 +0200 Subject: [PATCH 10/13] refactor(lighting): the refusal reads only the see-through meshes the walk met; one session fixture for the chain tests (#349) --- .../src/webgl/cluster/linearRefusal.ts | 19 +++--- .../src/webgl/cluster/sceneDraw.ts | 16 ++--- .../src/world/render/composeRefusal.test.ts | 52 +--------------- .../world/render/composeSession.fixture.ts | 57 ++++++++++++++++++ .../src/world/render/composeWalk.test.ts | 60 +++++++------------ 5 files changed, 96 insertions(+), 108 deletions(-) create mode 100644 packages/sdk-browser/src/world/render/composeSession.fixture.ts diff --git a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts index cb988df333..9842f7bcdb 100644 --- a/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts +++ b/packages/sdk-browser/src/webgl/cluster/linearRefusal.ts @@ -1,5 +1,6 @@ import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; import type { HostMaterials } from '../../host/resources.ts'; +import { isInstancedNode } from '../../host/graph/kinds.ts'; import { blendingOf } from '../../scene/materialBlending.ts'; import { firstMaterial } from '../../scene/materialSide.ts'; @@ -12,18 +13,16 @@ import { firstMaterial } from '../../scene/materialSide.ts'; export const refusesLinear = (mode: Blending | undefined) => mode === 'multiply' || mode === 'subtractive'; -/** A drawn mesh as the scene draw's walk meets it (`sceneDraw.ts`, `collect`). */ -type Met = { readonly kind?: string; readonly count?: number; readonly material?: HostMaterials }; - /** - * The mode of a visible drawn mesh that keeps the chain off a frame, or `undefined`: its surface - * visible and transparent in a mode `refusesLinear` names, an instanced one placed at least once - * — one placed nowhere submits nothing (`renderer.ts`). Transmissive or not: a view may zero the - * transmission before the draw (`../../lighting/unlitAlbedo.ts`), which then binds it in this mode. + * The mode of a visible see-through mesh the scene draw's walk met (`sceneDraw.ts`) that keeps the + * chain off a frame, or `undefined`: its surface visible and transparent in a mode `refusesLinear` + * names, an instanced one placed at least once — one placed nowhere submits nothing + * (`renderer.ts`). Transmissive or not: a view may zero the transmission before the draw + * (`../../lighting/unlitAlbedo.ts`), which then binds it in this mode. */ -export function linearRefusalOf(mesh: Met): Blending | undefined { - if (mesh.kind === 'instancedMesh' && !mesh.count) return; - const surface = mesh.material && firstMaterial(mesh.material); +export function linearRefusalOf(mesh: { readonly material?: HostMaterials }): Blending | undefined { + if (!mesh.material || (isInstancedNode(mesh) && !mesh.count)) return; + const surface = firstMaterial(mesh.material); if (!surface?.visible || !surface.transparent) return; const mode = blendingOf(surface.blending as number | undefined); return refusesLinear(mode) ? mode : undefined; diff --git a/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts b/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts index a1caefcc6d..88acf684a0 100644 --- a/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts +++ b/packages/sdk-browser/src/webgl/cluster/sceneDraw.ts @@ -15,7 +15,6 @@ import { depthOf } from './meshDepth.ts'; import { meshes } from '../../scene/meshes.ts'; import { DEFAULT_PIXEL_RATIO } from '../../backend/common.ts'; import type { BackendHostDraw } from '../../backend/hostDraw.ts'; -import type { Blending } from '../../../../sdk-core/src/world/constants/index.ts'; import { linearRefusalOf } from './linearRefusal.ts'; /** The scene the owner reads for its lights and background, its world matrices resolved @@ -52,7 +51,9 @@ const NO_BATCHES: readonly never[] = []; * the composer held — nothing drawn — publishes nothing, never the previous draw; `counters()` is * `null` before the first frame. The graph is walked once per drawn image, at the first of * `host.linearRefusal` and `host.drawHostGeometry`: never on a held frame, and never in `render`, - * which runs before the engine's frame writes the graph (`../../backend/autonomous/pages.ts`). Without a context (a session that never draws on the host + * which runs before the engine's frame writes the graph (`../../backend/autonomous/pages.ts`). Asked + * first, it walks before `onBeforeRender`, whose one hook (`../../lighting/unlitAlbedo.ts`) writes + * no field the walk reads. Without a context (a session that never draws on the host * surface) the draw is refused by name. `pixelRatio`, read each frame, scales a line's CSS-pixel * width to the image's pixels. */ @@ -73,8 +74,7 @@ export function createSceneDraw( seeThrough: DrawnNode[] = []; let owner: WebglClusterOwner | undefined, opened = false, - walked = false, - refused: Blending | undefined; + walked = false; // The projection times the view, and each drawn mesh's depth, read once a frame. const screen = new Float64Array(16), depths = new Map(); @@ -86,7 +86,6 @@ export function createSceneDraw( if (copied.has(node) || firstMaterial(node.material!)?.transparent) seeThrough.push(node as DrawnNode); else opaque.push(node as WholeMesh); - refused ??= linearRefusalOf(node); } for (const child of node.children) collect(child); }; @@ -96,7 +95,6 @@ export function createSceneDraw( walked = true; scene.updateMatrixWorld(); opaque.length = seeThrough.length = 0; - refused = undefined; followCopies(); for (const child of scene.children) collect(child); }; @@ -119,9 +117,13 @@ export function createSceneDraw( const backToFront = (a: DrawnNode, b: DrawnNode) => a.renderOrder - b.renderOrder || depth(b) - depth(a) || a.serial - b.serial; const host: Required = { + // Only a see-through mesh can refuse: the walk's list of them, still in graph order. linearRefusal() { walk(); - return refused; + for (const node of seeThrough) { + const mode = linearRefusalOf(node); + if (mode) return mode; + } }, drawHostGeometry(drawCamera: HostDrawCamera, output: HostDrawOutput) { if (!gl) throw new Error('HOST_SURFACE_MISSING'); diff --git a/packages/sdk-browser/src/world/render/composeRefusal.test.ts b/packages/sdk-browser/src/world/render/composeRefusal.test.ts index 9deba15276..66b385df38 100644 --- a/packages/sdk-browser/src/world/render/composeRefusal.test.ts +++ b/packages/sdk-browser/src/world/render/composeRefusal.test.ts @@ -6,7 +6,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as G from '../../host/graph/graph.fixture.ts'; -import type { RenderBackend } from '../../backend/types.ts'; import { EffectChain } from '../../../../sdk-core/src/world/effect/chain.ts'; import { effect } from '../../../../sdk-core/src/world/effect/index.ts'; import { GraphScene } from '../../host/graph/scene.ts'; @@ -17,61 +16,12 @@ import { HOST_BLENDING_NORMAL, HOST_BLENDING_SUBTRACTIVE, } from '../../host/surfaceConstants.ts'; -import { createSceneDraw } from '../../webgl/cluster/sceneDraw.ts'; import { createUnlitAlbedo } from '../../lighting/unlitAlbedo.ts'; -import { createTestContext } from '../../webgl/core/testContext.fixture.ts'; -import { - createWorldNotices, - listenWorldNotices, - noticeEffectRefusal, -} from '../diagnostic/worldNotices.ts'; -import { createFrameComposer } from './compose.ts'; - -const camera = G.perspectiveCamera(); -const halfFloats = { - getExtension: (name: string) => (name === 'EXT_color_buffer_float' ? {} : null), -}; +import { heard, session } from './composeSession.fixture.ts'; const blended = (blending: number) => new GraphSurface('standard', { transparent: true, opacity: 0.5, blending }); -/** A WebGL2 session drawing `scene` with the world's `chain`, its refusals said on a world's - * notices; `frame` draws one and returns whether the chain ran and what the scene submitted. */ -function session(scene: GraphScene, chain: EffectChain) { - const context = createTestContext({ answers: halfFloats }); - const draw = createSceneDraw(context.gl, scene); - const backend = { id: 'engine', scene, ...draw, ...draw.host } as unknown as RenderBackend; - const notices = createWorldNotices(); - const refused = noticeEffectRefusal(notices); - const compose = createFrameComposer(context.gl, camera, { - effects: { chain, shown: () => true, refused }, - }); - return { - frame() { - const passes = context.of('drawArrays').length, - submitted = context.of('drawElements').length; - draw.render(camera); - compose(backend, null); - return { - chained: context.of('drawArrays').length > passes, - submitted: context.of('drawElements').length - submitted, - }; - }, - close: notices.close, - }; -} - -/** The kinds of every world notice said while `run` draws `view`, once delivered. */ -async function heard(view: ReturnType, run: () => void) { - const said: string[] = []; - const stop = listenWorldNotices((notice) => void said.push(notice.phase)); - run(); - await new Promise(setImmediate); - view.close(); - stop(); - return said; -} - const MODES = [ ['multiply', HOST_BLENDING_MULTIPLY], ['subtractive', HOST_BLENDING_SUBTRACTIVE], diff --git a/packages/sdk-browser/src/world/render/composeSession.fixture.ts b/packages/sdk-browser/src/world/render/composeSession.fixture.ts new file mode 100644 index 0000000000..4f950db2a6 --- /dev/null +++ b/packages/sdk-browser/src/world/render/composeSession.fixture.ts @@ -0,0 +1,57 @@ +// A WebGL2 session of the effect chain's tests (#349): the engine's scene draw and the composer on +// a recorded context that renders half floats, the world's refusal notices, and what they said. +import * as G from '../../host/graph/graph.fixture.ts'; +import type { RenderBackend } from '../../backend/types.ts'; +import type { EffectChain } from '../../../../sdk-core/src/world/effect/chain.ts'; +import type { GraphScene } from '../../host/graph/scene.ts'; +import { createSceneDraw } from '../../webgl/cluster/sceneDraw.ts'; +import { createTestContext } from '../../webgl/core/testContext.fixture.ts'; +import { + createWorldNotices, + listenWorldNotices, + noticeEffectRefusal, +} from '../diagnostic/worldNotices.ts'; +import { createFrameComposer } from './compose.ts'; + +const camera = G.perspectiveCamera(); +/** A context that renders half floats, as every desktop WebGL2 does. */ +export const HALF_FLOATS = { + getExtension: (name: string) => (name === 'EXT_color_buffer_float' ? {} : null), +}; + +/** A WebGL2 session drawing `scene` with the world's `chain`, its refusals said on a world's + * notices; `frame` draws one and returns whether the chain ran and what the scene submitted. */ +export function session(scene: GraphScene, chain: EffectChain) { + const context = createTestContext({ answers: HALF_FLOATS }); + const draw = createSceneDraw(context.gl, scene); + const backend = { id: 'engine', scene, ...draw, ...draw.host } as unknown as RenderBackend; + const notices = createWorldNotices(); + const refused = noticeEffectRefusal(notices); + const compose = createFrameComposer(context.gl, camera, { + effects: { chain, shown: () => true, refused }, + }); + return { + frame() { + const passes = context.of('drawArrays').length, + submitted = context.of('drawElements').length; + draw.render(camera); + compose(backend, null); + return { + chained: context.of('drawArrays').length > passes, + submitted: context.of('drawElements').length - submitted, + }; + }, + close: notices.close, + }; +} + +/** The kinds of every world notice said while `run` draws `view`, once delivered. */ +export async function heard(view: ReturnType, run: () => void) { + const said: string[] = []; + const stop = listenWorldNotices((notice) => void said.push(notice.phase)); + run(); + await new Promise(setImmediate); + view.close(); + stop(); + return said; +} diff --git a/packages/sdk-browser/src/world/render/composeWalk.test.ts b/packages/sdk-browser/src/world/render/composeWalk.test.ts index a4466d48ed..0d727f4362 100644 --- a/packages/sdk-browser/src/world/render/composeWalk.test.ts +++ b/packages/sdk-browser/src/world/render/composeWalk.test.ts @@ -4,62 +4,42 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as G from '../../host/graph/graph.fixture.ts'; -import type { RenderBackend } from '../../backend/types.ts'; import { EffectChain } from '../../../../sdk-core/src/world/effect/chain.ts'; import { effect } from '../../../../sdk-core/src/world/effect/index.ts'; import { Group } from '../../../../sdk-core/src/world/object/object3d.ts'; import { GraphScene } from '../../host/graph/scene.ts'; import { GraphSurface } from '../../host/graph/surface.ts'; -import { createSceneDraw } from '../../webgl/cluster/sceneDraw.ts'; -import { createTestContext } from '../../webgl/core/testContext.fixture.ts'; -import { createFrameComposer } from './compose.ts'; +import { session } from './composeSession.fixture.ts'; const FRAMES = 5; -const halfFloats = { - getExtension: (name: string) => (name === 'EXT_color_buffer_float' ? {} : null), -}; -/** A graph of nested groups and meshes whose every read of a node's children is counted. */ -function countedScene() { - const scene = new GraphScene(); - const group = new Group(); +/** The reads of every node's children over `FRAMES` frames of a nested graph, drawn with `chain`: + * nothing holds a frame here (the draw has no `frameHeld`), so each composes and draws. */ +function visitsOver(chain: EffectChain) { + const scene = new GraphScene(), + group = new Group(); group.add(G.triangleMesh(new GraphSurface('standard'))); group.add(G.triangleMesh(new GraphSurface('standard', { transparent: true, opacity: 0.5 }))); scene.add(group, G.triangleMesh(new GraphSurface('standard'))); - const visits = { count: 0 }; + let visits = 0; scene.traverse((node) => { const children = node.children; - Object.defineProperty(node, 'children', { - get: () => (visits.count++, children), - }); + Object.defineProperty(node, 'children', { get: () => (visits++, children) }); }); - return { scene, visits }; -} - -/** The children reads of `FRAMES` drawn frames, the chain holding `passes`: nothing holds a - * frame here (`frameHeld` is absent), so each composes and draws. */ -function visitsOver(passes: number) { - const { scene, visits } = countedScene(); - const chain = new EffectChain(); - for (let i = 0; i < passes; i++) chain.add(effect.bloom()); - const context = createTestContext({ answers: halfFloats }); - const draw = createSceneDraw(context.gl, scene); - const backend = { id: 'engine', scene, ...draw, ...draw.host } as unknown as RenderBackend; - const compose = createFrameComposer(context.gl, G.perspectiveCamera(), { - effects: { chain, shown: () => true }, - }); - const drawn = context.of('drawElements').length; - visits.count = 0; - for (let frame = 0; frame < FRAMES; frame++) { - draw.render(G.perspectiveCamera()); - compose(backend, null); - } - assert.equal(context.of('drawElements').length - drawn, 3 * FRAMES, 'every frame drawn'); - return visits.count; + const view = session(scene, chain); + visits = 0; + for (let frame = 0; frame < FRAMES; frame++) + assert.equal(view.frame().submitted, 3, 'every frame drawn'); + view.close(); + return visits; } test('a chain on WebGL2 visits the graph no more than the draw does without one', () => { - const without = visitsOver(0); + const without = visitsOver(new EffectChain()); assert.ok(without > 0, 'the draw walks its graph'); - assert.equal(visitsOver(1), without, 'the composer walks nothing of its own'); + assert.equal( + visitsOver(new EffectChain().add(effect.bloom())), + without, + 'the composer walks nothing', + ); }); From b37121ad878bc3d5cda91acc09712dfe27193c52 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 06:54:12 +0200 Subject: [PATCH 11/13] test(lighting): the walk count holds only while the chain runs; the before-render hook says the walk may come first (#349) --- packages/sdk-browser/src/host/graph/scene.ts | 3 ++- packages/sdk-browser/src/world/render/composeWalk.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/sdk-browser/src/host/graph/scene.ts b/packages/sdk-browser/src/host/graph/scene.ts index 150c502157..71386de0bb 100644 --- a/packages/sdk-browser/src/host/graph/scene.ts +++ b/packages/sdk-browser/src/host/graph/scene.ts @@ -13,7 +13,8 @@ export class GraphScene extends GraphNode { background: Color | null = null; /** The contract's fog over every drawn surface (`../../lighting/contractLights.ts`); `null`, none. */ fog: SceneFog | null = null; - /** Called by the draw before it reads the graph. */ + /** Called by the draw before it binds the graph's surfaces; its walk of the graph may come + * first (`../../webgl/cluster/sceneDraw.ts`). */ onBeforeRender = () => {}; /** Called by the draw once it is done. */ onAfterRender = () => {}; diff --git a/packages/sdk-browser/src/world/render/composeWalk.test.ts b/packages/sdk-browser/src/world/render/composeWalk.test.ts index 0d727f4362..9ba258a030 100644 --- a/packages/sdk-browser/src/world/render/composeWalk.test.ts +++ b/packages/sdk-browser/src/world/render/composeWalk.test.ts @@ -15,7 +15,7 @@ const FRAMES = 5; /** The reads of every node's children over `FRAMES` frames of a nested graph, drawn with `chain`: * nothing holds a frame here (the draw has no `frameHeld`), so each composes and draws. */ -function visitsOver(chain: EffectChain) { +function visitsOver(chain: EffectChain, chained: boolean) { const scene = new GraphScene(), group = new Group(); group.add(G.triangleMesh(new GraphSurface('standard'))); @@ -29,16 +29,16 @@ function visitsOver(chain: EffectChain) { const view = session(scene, chain); visits = 0; for (let frame = 0; frame < FRAMES; frame++) - assert.equal(view.frame().submitted, 3, 'every frame drawn'); + assert.deepEqual(view.frame(), { chained, submitted: 3 }, 'every frame drawn'); view.close(); return visits; } test('a chain on WebGL2 visits the graph no more than the draw does without one', () => { - const without = visitsOver(new EffectChain()); + const without = visitsOver(new EffectChain(), false); assert.ok(without > 0, 'the draw walks its graph'); assert.equal( - visitsOver(new EffectChain().add(effect.bloom())), + visitsOver(new EffectChain().add(effect.bloom()), true), without, 'the composer walks nothing', ); From 512bcd76abd8f33d798a9ce4c2ad7ffb7b7e97fb Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 06:58:05 +0200 Subject: [PATCH 12/13] test(lighting): the session fixture keeps its half-float answer to itself (#349) --- packages/sdk-browser/src/world/render/composeSession.fixture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-browser/src/world/render/composeSession.fixture.ts b/packages/sdk-browser/src/world/render/composeSession.fixture.ts index 4f950db2a6..67a0d8aa91 100644 --- a/packages/sdk-browser/src/world/render/composeSession.fixture.ts +++ b/packages/sdk-browser/src/world/render/composeSession.fixture.ts @@ -15,7 +15,7 @@ import { createFrameComposer } from './compose.ts'; const camera = G.perspectiveCamera(); /** A context that renders half floats, as every desktop WebGL2 does. */ -export const HALF_FLOATS = { +const HALF_FLOATS = { getExtension: (name: string) => (name === 'EXT_color_buffer_float' ? {} : null), }; From b31c2777b30ceacfa828dc33e48ca0ef40c7b0e8 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 07:09:10 +0200 Subject: [PATCH 13/13] test(lighting): the walk is read after the engine writes its graph, and a held frame walks nothing (#349) --- .../world/render/composeSession.fixture.ts | 14 +++-- .../src/world/render/composeWalk.test.ts | 52 +++++++++++++++++-- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/sdk-browser/src/world/render/composeSession.fixture.ts b/packages/sdk-browser/src/world/render/composeSession.fixture.ts index 67a0d8aa91..3299643110 100644 --- a/packages/sdk-browser/src/world/render/composeSession.fixture.ts +++ b/packages/sdk-browser/src/world/render/composeSession.fixture.ts @@ -20,27 +20,33 @@ const HALF_FLOATS = { }; /** A WebGL2 session drawing `scene` with the world's `chain`, its refusals said on a world's - * notices; `frame` draws one and returns whether the chain ran and what the scene submitted. */ + * notices; `frame` draws one and returns whether the chain ran and what the scene submitted. + * `between` writes the graph after the engine's `render`, as the engine's own frame does + * (`../../backend/autonomous/pages.ts`); `hold` says whether the engine holds its frames. */ export function session(scene: GraphScene, chain: EffectChain) { const context = createTestContext({ answers: HALF_FLOATS }); const draw = createSceneDraw(context.gl, scene); - const backend = { id: 'engine', scene, ...draw, ...draw.host } as unknown as RenderBackend; + const backend = { id: 'engine', scene, frameHeld: false, ...draw, ...draw.host }; const notices = createWorldNotices(); const refused = noticeEffectRefusal(notices); const compose = createFrameComposer(context.gl, camera, { effects: { chain, shown: () => true, refused }, }); return { - frame() { + frame(between?: () => void) { const passes = context.of('drawArrays').length, submitted = context.of('drawElements').length; draw.render(camera); - compose(backend, null); + between?.(); + compose(backend as unknown as RenderBackend, null); return { chained: context.of('drawArrays').length > passes, submitted: context.of('drawElements').length - submitted, }; }, + hold(held: boolean) { + backend.frameHeld = held; + }, close: notices.close, }; } diff --git a/packages/sdk-browser/src/world/render/composeWalk.test.ts b/packages/sdk-browser/src/world/render/composeWalk.test.ts index 9ba258a030..20a3162791 100644 --- a/packages/sdk-browser/src/world/render/composeWalk.test.ts +++ b/packages/sdk-browser/src/world/render/composeWalk.test.ts @@ -9,13 +9,13 @@ import { effect } from '../../../../sdk-core/src/world/effect/index.ts'; import { Group } from '../../../../sdk-core/src/world/object/object3d.ts'; import { GraphScene } from '../../host/graph/scene.ts'; import { GraphSurface } from '../../host/graph/surface.ts'; +import { HOST_BLENDING_MULTIPLY } from '../../host/surfaceConstants.ts'; import { session } from './composeSession.fixture.ts'; const FRAMES = 5; -/** The reads of every node's children over `FRAMES` frames of a nested graph, drawn with `chain`: - * nothing holds a frame here (the draw has no `frameHeld`), so each composes and draws. */ -function visitsOver(chain: EffectChain, chained: boolean) { +/** A nested graph drawn with `chain`, each read of a node's children counted in `visits()`. */ +function counted(chain: EffectChain) { const scene = new GraphScene(), group = new Group(); group.add(G.triangleMesh(new GraphSurface('standard'))); @@ -27,11 +27,17 @@ function visitsOver(chain: EffectChain, chained: boolean) { Object.defineProperty(node, 'children', { get: () => (visits++, children) }); }); const view = session(scene, chain); - visits = 0; + return { view, visits: () => visits, reset: () => void (visits = 0) }; +} + +/** The reads of every node's children over `FRAMES` frames, none held, each composed and drawn. */ +function visitsOver(chain: EffectChain, chained: boolean) { + const { view, visits, reset } = counted(chain); + reset(); for (let frame = 0; frame < FRAMES; frame++) assert.deepEqual(view.frame(), { chained, submitted: 3 }, 'every frame drawn'); view.close(); - return visits; + return visits(); } test('a chain on WebGL2 visits the graph no more than the draw does without one', () => { @@ -43,3 +49,39 @@ test('a chain on WebGL2 visits the graph no more than the draw does without one' 'the composer walks nothing', ); }); + +test('a held frame walks nothing, with or without a chain', () => { + for (const chain of [new EffectChain(), new EffectChain().add(effect.bloom())]) { + const { view, visits, reset } = counted(chain); + view.frame(); + view.hold(true); + reset(); + for (let frame = 0; frame < FRAMES; frame++) + assert.deepEqual(view.frame(), { chained: false, submitted: 0 }, 'the kept image put back'); + assert.equal(visits(), 0); + view.close(); + } +}); + +test('a surface the engine writes after its render is read by the refusal and the draw', () => { + const scene = new GraphScene().add(G.triangleMesh(new GraphSurface('standard'))); + const view = session(scene, new EffectChain().add(effect.bloom())); + const glass = G.triangleMesh( + new GraphSurface('standard', { + transparent: true, + opacity: 0.5, + blending: HOST_BLENDING_MULTIPLY, + }), + ); + assert.deepEqual(view.frame(), { chained: true, submitted: 1 }); + // Written between `render` and the composition, as the engine's frame writes its graph. + assert.deepEqual( + view.frame(() => scene.add(glass)), + { chained: false, submitted: 2 }, + ); + assert.deepEqual( + view.frame(() => (glass.visible = false)), + { chained: true, submitted: 1 }, + ); + view.close(); +});