Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
604e1c0
fix(lighting): WebGL2 draws a frame whole without the effect chain wh…
pasquelin Sep 26, 2026
f3d5d93
test(lighting): the WebGL2 composition fixture scene holds its childr…
pasquelin Sep 26, 2026
5480dba
refactor(lighting): the linear refusal leaves a transmissive surface'…
pasquelin Sep 26, 2026
c239f96
fix(lighting): an instanced mesh placed nowhere keeps no effect chain…
pasquelin Sep 26, 2026
ed281c1
test(lighting): one drawn-triangle fixture for the linear output and …
pasquelin Sep 26, 2026
c49c436
refactor(lighting): the engine answers the linear refusal, a transmis…
pasquelin Sep 26, 2026
7be9a24
fix(lighting): the refusal notice builds nothing past the first frame…
pasquelin Sep 26, 2026
3cb733e
Merge remote-tracking branch 'origin/develop' into 349-effect-blend-gate
pasquelin Sep 26, 2026
cef7d02
refactor(lighting): the composer keeps the linear refusal walk, the b…
pasquelin Sep 26, 2026
cc9fc8c
refactor(lighting): the composer reads the linear refusal from the sc…
pasquelin Sep 26, 2026
7cdec91
refactor(lighting): the refusal reads only the see-through meshes the…
pasquelin Sep 26, 2026
b37121a
test(lighting): the walk count holds only while the chain runs; the b…
pasquelin Sep 26, 2026
512bcd7
test(lighting): the session fixture keeps its half-float answer to it…
pasquelin Sep 26, 2026
b31c277
test(lighting): the walk is read after the engine writes its graph, a…
pasquelin Sep 26, 2026
fed8792
Merge remote-tracking branch 'origin/develop' into 349-effect-blend-gate
pasquelin Sep 26, 2026
dccff05
Merge remote-tracking branch 'origin/develop' into 349-effect-blend-gate
pasquelin Sep 26, 2026
6d10972
Merge branch 'develop' into 349-effect-blend-gate
pasquelin Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/ENGINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,16 @@ 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 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.
- **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
Expand Down
4 changes: 4 additions & 0 deletions docs/SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,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 });
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-browser/src/backend/autonomous/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export const autonomousPagesBackend: BackendFactory = (context) => {
hostDraw.render(camera);
if (ready) frame(camera);
},
drawHostGeometry: hostDraw.drawHostGeometry,
...hostDraw.host,
...instances,
...autonomousPlacements({
...tables,
Expand Down
16 changes: 16 additions & 0 deletions packages/sdk-browser/src/backend/hostDraw.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 3 additions & 6 deletions packages/sdk-browser/src/backend/types.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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<void>;
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. */
Expand Down
11 changes: 11 additions & 0 deletions packages/sdk-browser/src/host/graph/graph.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk-browser/src/host/graph/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {};
Expand Down
21 changes: 5 additions & 16 deletions packages/sdk-browser/src/webgl/cluster/linearOutput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,26 @@
// 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) {
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 {
Expand Down
29 changes: 29 additions & 0 deletions packages/sdk-browser/src/webgl/cluster/linearRefusal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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';

/**
* 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 === 'multiply' || mode === 'subtractive';

/**
* 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: { 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;
}
11 changes: 6 additions & 5 deletions packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions packages/sdk-browser/src/webgl/cluster/materialBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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 { refuseCluster } from './refusal.ts';
import type { Matrix3UniformCache } from './uniforms.ts';
import type { WebglClusterMaterialUniforms } from './materialUniforms.ts';

Expand All @@ -33,16 +35,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)) refuseCluster(`the WebGL2 effect chain cannot draw ${mode} blending`);
return mode === 'none';
}

Expand Down
4 changes: 2 additions & 2 deletions packages/sdk-browser/src/webgl/cluster/mips.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -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');
Expand Down
15 changes: 9 additions & 6 deletions packages/sdk-browser/src/webgl/cluster/sceneDraw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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/,
);
});
Expand Down Expand Up @@ -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')!;
Expand All @@ -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),
);
Expand Down Expand Up @@ -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]);
Expand Down
Loading
Loading