From bc957cf40ddad143b4d254c39594c5422e71e006 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 18 Sep 2026 07:14:37 +0000 Subject: [PATCH] handles: indirect draw buckets for opaque and mask meshes Opaque/mask draws sharing a pipeline and bind set become rows of one persistent `draws.` indirect array (32-byte indexed commands, geometric capacity, inactive tail zeroed) drawn by a single graph pass, so the graph holds one pass per bucket instead of one per mesh. Transmission and blend draws stay direct and ordered after the buckets. Scene requires the indirect-first-instance feature. Compiles re-assert the bundle block when they start, since an earlier switch in the queue clears the one set at enqueue time. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b2dd-db32-712d-99ce-12ce4a86d979 Co-authored-by: Heaust Azure --- addons/handles/src/Scene.ts | 237 ++++++++++++++++-------- addons/handles/test/handles.test.ts | 6 +- addons/handles/test/scene-graph.test.ts | 112 ++++++++++- addons/handles/test/scene-helpers.ts | 26 +++ 4 files changed, 300 insertions(+), 81 deletions(-) diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts index 8bb0bb0..497cd89 100644 --- a/addons/handles/src/Scene.ts +++ b/addons/handles/src/Scene.ts @@ -626,6 +626,9 @@ export class Scene { #graphBatchDirty = false; #inhibits = 0; #batchInhibits = 0; + /** Indirect draw buckets: pipeline + bind-set key → `draws.`, and each bucket's row capacity. */ + #buckets = new Map(); + #bucketCapacity: number[] = []; #writeBatchDepth = 0; #writeBatchDirty = false; #writeBatchBundleDirty = false; @@ -660,6 +663,10 @@ export class Scene { async #initialize(fps?: number) { await this.core.ready; + if (!this.core.features.has("indirect-first-instance")) + throw new Error( + "INDIRECT_FIRST_INSTANCE: required by Scene draw buckets", + ); this.#signals = this.core.array("signals").row(0); for (const [name, stride, format] of rows) await this.core.createRows({ name, rows: 1, stride, format }); @@ -1202,6 +1209,9 @@ export class Scene { } async #compileRenderGraph() { + // Re-assert the bundle block now that this compile is running: an earlier switch in the + // queue cleared the one set when it was enqueued, and bucket rows are mutated below. + this.markDirty(true); const buffers = new Map(); const textures = new Map(); const samplers = new Map(); @@ -1363,6 +1373,7 @@ export class Scene { } let previous = computeIds.length ? computeIds : ["cluster-lights"]; + const bucketRows = new Map(); if (!forwardDraws.length) { renderPipelines.push({ id: "empty-forward", @@ -1428,6 +1439,48 @@ export class Scene { let firstDepth = true; let sceneColorReady = false; let forwardTarget = compositeSceneColor ? "scene-color" : "hdr"; + const attachments = () => { + const color = firstColor + ? { clear: [...this.clearColor] } + : { load: "load" }; + const depth = firstDepth ? { clear: 1 } : { load: "load" }; + firstColor = false; + firstDepth = false; + return { + color: [{ resource: forwardTarget, ...color }], + depth: { resource: "depth", ...depth }, + vertexBuffers: [{ slot: 0, resource: "vertices" }], + indexBuffer: { resource: "indices", format: "uint32" as const }, + }; + }; + // Opaque and mask draws are rows of a persistent indirect bucket per pipeline + bind set, + // so the graph holds one pass per bucket and membership is a row write. + type Bucket = { pipeline: string; bindings: object[]; rows: number[][] }; + const buckets = new Map(); + const flushBuckets = () => { + for (const [key, bucket] of buckets) { + const id = this.#buckets.get(key) ?? this.#buckets.size; + this.#buckets.set(key, id); + bucketRows.set(id, bucket.rows); + addBuffer({ + id: `draws-${id}`, + array: `draws.${id}`, + usage: ["indirect"], + sync: "manual", + }); + passes.push({ + id: `forward-bucket-${id}`, + type: "render", + pipeline: bucket.pipeline, + after: previous, + bindings: bucket.bindings, + ...attachments(), + indirect: { resource: `draws-${id}` }, + }); + previous = [`forward-bucket-${id}`]; + } + buckets.clear(); + }; for (const draw of forwardDraws) { const { mesh, geometry, material } = draw; const id = `forward-${mesh.id}-${draw.firstIndex}`; @@ -1466,6 +1519,7 @@ export class Scene { if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3); const textureInfo = this.array("materialTextureInfo").row(material); let clearCompositeLayer = false; + if (transmitted || (flags & 3) === 2) flushBuckets(); if (transmitted && !sceneColorReady) { sceneColorReady = true; if (compositeSceneColor) { @@ -1566,82 +1620,88 @@ export class Scene { const samplerTexture = materialTextures.find( (value, lane) => value !== undefined && (mask & (1 << lane)) !== 0, ); + const bindings = [ + ...[ + "clusters", + "accent", + "node-positions", + "node-rotors", + "node-scales", + "mesh-info", + "materials", + "camera-matrices", + ].map((resource, binding) => ({ + group: 0, + binding, + resource, + })), + ...(detailed + ? [ + { group: 0, binding: 8, resource: "point-lights" }, + { group: 0, binding: 9, resource: "spot-lights" }, + { + group: 0, + binding: 10, + resource: "directional-lights", + }, + ] + : []), + ...(detailed && mask + ? [ + { + group: 1, + binding: 0, + resource: samplerTexture?.sampler + ? `${samplerTexture.id}-sampler` + : "material-linear", + }, + ...materialTextures.flatMap((value, lane) => + value && (mask & (1 << lane)) !== 0 + ? [ + { + group: 1, + binding: lane + 1, + resource: value.id, + }, + ] + : [], + ), + ] + : []), + ...(detailed && transmitted + ? [ + { group: 2, binding: 0, resource: "scene-color" }, + { group: 2, binding: 1, resource: "linear" }, + ] + : []), + ]; + if (!transmitted && (flags & 3) !== 2) { + const key = `${pipelineKey}\n${JSON.stringify(bindings)}`; + const bucket = + buckets.get(key) ?? + buckets.set(key, { pipeline, bindings, rows: [] }).get(key)!; + bucket.rows.push([ + draw.indices, + 1, + draw.firstIndex, + geometry.vertexRow, + instance, + mesh.id, + material, + 0, + ]); + continue; + } + const target = attachments(); + if (clearCompositeLayer) + target.color[0] = { resource: forwardTarget, clear: [0, 0, 0, 0] }; passes.push({ id, type: "render", pipeline, after: previous, - bindings: [ - ...[ - "clusters", - "accent", - "node-positions", - "node-rotors", - "node-scales", - "mesh-info", - "materials", - "camera-matrices", - ].map((resource, binding) => ({ - group: 0, - binding, - resource, - })), - ...(detailed - ? [ - { group: 0, binding: 8, resource: "point-lights" }, - { group: 0, binding: 9, resource: "spot-lights" }, - { - group: 0, - binding: 10, - resource: "directional-lights", - }, - ] - : []), - ...(detailed && mask - ? [ - { - group: 1, - binding: 0, - resource: samplerTexture?.sampler - ? `${samplerTexture.id}-sampler` - : "material-linear", - }, - ...materialTextures.flatMap((value, lane) => - value && (mask & (1 << lane)) !== 0 - ? [ - { - group: 1, - binding: lane + 1, - resource: value.id, - }, - ] - : [], - ), - ] - : []), - ...(detailed && transmitted - ? [ - { group: 2, binding: 0, resource: "scene-color" }, - { group: 2, binding: 1, resource: "linear" }, - ] - : []), - ], - color: [ - { - resource: forwardTarget, - ...(clearCompositeLayer - ? { clear: [0, 0, 0, 0] } - : firstColor - ? { clear: [...this.clearColor] } - : { load: "load" }), - }, - ], - depth: { - resource: "depth", - ...(firstDepth ? { clear: 1 } : { load: "load" }), - }, - vertexBuffers: [{ slot: 0, resource: "vertices" }], - indexBuffer: { resource: "indices", format: "uint32" }, + bindings, + ...target, draw: { indices: draw.indices, firstIndex: draw.firstIndex, @@ -1650,10 +1710,9 @@ export class Scene { firstInstance: instance, }, }); - firstColor = false; - firstDepth = false; previous = [id]; } + flushBuckets(); } for (const material of this.#shaders.values()) { @@ -1752,10 +1811,42 @@ export class Scene { passes, }; this.#renderGraph = structuredClone(graph); + await this.#writeBuckets(bucketRows); const id = await this.core.compileGraph(serialize(graph)); await this.core.switchLoadout(id); } + /** + * Sizes each `draws.` bucket to hold its commands (geometric growth), writes them as + * indexed indirect rows, zeroes the inactive remainder, and uploads the whole bucket. + */ + async #writeBuckets(bucketRows: Map) { + const growth = [...bucketRows].flatMap(([id, commands]) => { + const current = this.#bucketCapacity[id] ?? 0; + return commands.length > current + ? [{ id, rows: Math.max(commands.length, current * 2) }] + : []; + }); + if (growth.length) { + await this.core.createRowsBatch( + growth.map(({ id, rows }) => ({ + name: `draws.${id}`, + rows, + stride: 32, + format: "u32", + })), + ); + for (const { id, rows } of growth) this.#bucketCapacity[id] = rows; + } + for (const [id, commands] of bucketRows) { + const array = this.core.array(`draws.${id}`); + const view = array.view as Uint32Array; + view.fill(0, commands.length * 8); + commands.forEach((command, row) => view.set(command, row * 8)); + await this.core.syncRows(`draws.${id}`, 0, array.rows); + } + } + dispose() { this.core.dispose(); } diff --git a/addons/handles/test/handles.test.ts b/addons/handles/test/handles.test.ts index 89baf0c..51c4468 100644 --- a/addons/handles/test/handles.test.ts +++ b/addons/handles/test/handles.test.ts @@ -7,8 +7,8 @@ import { PointLight } from "../src/lights/PointLight"; import { PBRMaterial } from "../src/materials/PBRMaterial"; import type { Texture } from "../src/materials/Texture"; import { + bucketDraws, createScene, - forwardPasses, latestGraph, testCore, triangle, @@ -236,9 +236,9 @@ test("geometry spans validate before mutating, move intact, and inhibit renderin [1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1], ); close(scene.array("indices").row(moved.indexRow), [0, 1, 2, 2]); - const draws = forwardPasses(latestGraph(scene)).map((pass: any) => pass.draw); + const draws = bucketDraws(scene, latestGraph(scene)); assert.deepEqual( - draws.map((draw: any) => [draw.baseVertex, draw.firstIndex, draw.indices]), + draws.map((draw) => [draw.baseVertex, draw.firstIndex, draw.indices]), [ [moved.vertexRow, moved.indexRow * 4, 6], [before.second.vertexRow, before.second.indexRow * 4, 3], diff --git a/addons/handles/test/scene-graph.test.ts b/addons/handles/test/scene-graph.test.ts index 4b5bf1b..0575192 100644 --- a/addons/handles/test/scene-graph.test.ts +++ b/addons/handles/test/scene-graph.test.ts @@ -5,6 +5,7 @@ import { FXAA } from "../src/PostProcesses/FXAA"; import { PBRMaterial } from "../src/materials/PBRMaterial"; import { Texture } from "../src/materials/Texture"; import { + bucketDraws, createScene, detailedMesh, forwardPasses, @@ -91,15 +92,16 @@ test("materials with identical shader features share one pipeline", async () => await Promise.all([firstMesh.ready, secondMesh.ready]); const graph = latestGraph(scene); - const passes = forwardPasses(graph); + const draws = bucketDraws(scene, graph); assert.equal(pbrPipelines(graph).length, 1); - assert.equal(passes.length, 2); + // One indirect bucket pass holds both meshes: same pipeline and bind set. + assert.equal(forwardPasses(graph).length, 1); assert.deepEqual( - passes.map((pass: any) => pass.draw.firstInstance & 0xffff), + draws.map((draw) => draw.firstInstance & 0xffff), [firstMesh.id, secondMesh.id], ); assert.deepEqual( - passes.map((pass: any) => (pass.draw.firstInstance >>> 16) - 1), + draws.map((draw) => (draw.firstInstance >>> 16) - 1), [firstMaterial.id, secondMaterial.id], ); }); @@ -315,7 +317,107 @@ test("batchGraphUpdates compiles once for multiple mesh registrations", async () }); assert.equal(testCore(scene).graphs.length - before, 1); - assert.equal(forwardPasses(latestGraph(scene)).length, 2); + assert.equal(forwardPasses(latestGraph(scene)).length, 1); + assert.equal(bucketDraws(scene, latestGraph(scene)).length, 2); +}); + +test("draw buckets persist per bind set, zero their inactive tail, and stay blocked while compiling", async () => { + const scene = await createScene(); + const core = testCore(scene); + const plain = new PBRMaterial(scene); + const texture = new Texture(scene, { size: [1, 1, 1] }); + await texture.ready; + const textured = new PBRMaterial(scene, { baseColorTexture: texture }); + await Promise.all([plain.ready, textured.ready]); + const meshes: Mesh[] = []; + for (let index = 0; index < 3; index++) + meshes.push(await detailedMesh(scene, plain).ready); + + // Three plain meshes fill one bucket; the array grew geometrically from 1 → 2 → 4 rows. + let graph = latestGraph(scene); + const bucket = graph.resources.buffers.find((buffer: any) => + buffer.id.startsWith("draws-"), + ); + assert.deepEqual( + { usage: bucket.usage, sync: bucket.sync }, + { usage: ["indirect"], sync: "manual" }, + ); + assert.equal(core.array(bucket.array).rows, 4); + assert.equal(bucketDraws(scene, graph).length, 3); + assert.equal(forwardPasses(graph).length, 1); + + // Moving one mesh to a textured material creates a second bucket; the first keeps two rows + // and its freed tail is zero (instanceCount 0) so the GPU skips it. + meshes[1].material = textured; + await meshes[1].ready; + await scene.updateRenderGraph(); + graph = latestGraph(scene); + assert.equal(forwardPasses(graph).length, 2); + const draws = bucketDraws(scene, graph); + assert.deepEqual( + draws.map((draw) => [draw.pass, draw.firstInstance & 0xffff]), + [ + ["forward-bucket-0", meshes[0].id], + ["forward-bucket-0", meshes[2].id], + ["forward-bucket-1", meshes[1].id], + ], + ); + assert.deepEqual( + [...core.array(bucket.array).view.subarray(16, 32)], + Array(16).fill(0), + ); + + // Back to plain: the second bucket vanishes from the graph, the first regains its row. + meshes[1].material = plain; + await meshes[1].ready; + await scene.updateRenderGraph(); + graph = latestGraph(scene); + assert.equal(forwardPasses(graph).length, 1); + assert.equal(bucketDraws(scene, graph).length, 3); + + // A compile that starts after an earlier switch cleared signal 6 re-asserts it before + // touching bucket rows, so no frame renders the old graph with new bucket contents. + const signals = core.array("signals").row(0); + const switchLoadout = core.switchLoadout.bind(core); + let observed = -1; + core.switchLoadout = async (id) => { + await switchLoadout(id); + signals[6] = 0; + core.switchLoadout = async (next) => { + observed = signals[6]; + await switchLoadout(next); + }; + }; + await Promise.all([scene.updateRenderGraph(), scene.updateRenderGraph()]); + assert.equal(observed, 1); + core.switchLoadout = switchLoadout; +}); + +test("opaque buckets render into the composited layer ahead of transmission", async () => { + const scene = await createScene(); + const opaque = new PBRMaterial(scene); + const glass = new PBRMaterial(scene, { transmission: 0.9 }); + await Promise.all([opaque.ready, glass.ready]); + const solid = detailedMesh(scene, opaque); + const window = detailedMesh(scene, glass); + await Promise.all([solid.ready, window.ready]); + + const passes = forwardPasses(latestGraph(scene)); + assert.deepEqual( + passes.map((pass: any) => [ + pass.id, + pass.color[0].resource, + Boolean(pass.indirect), + ]), + [ + ["forward-bucket-0", "scene-color", true], + [ + `forward-${window.id}-${scene.geometry(window.geometryId).indexRow * 4}`, + "hdr", + false, + ], + ], + ); }); test("render graph snapshots expose isolated scene-owned declarations", async () => { diff --git a/addons/handles/test/scene-helpers.ts b/addons/handles/test/scene-helpers.ts index 897452d..24166d2 100644 --- a/addons/handles/test/scene-helpers.ts +++ b/addons/handles/test/scene-helpers.ts @@ -53,3 +53,29 @@ export function detailedMesh( ) { return new Mesh(scene, { material, vertexData }); } + +/** Decodes the active indirect rows of every forward bucket pass, in pass order. */ +export function bucketDraws(scene: Scene, graph: any) { + return forwardPasses(graph) + .filter((pass: any) => pass.indirect) + .flatMap((pass: any) => { + const buffer = graph.resources.buffers.find( + (buffer: any) => buffer.id === pass.indirect.resource, + ); + const view = scene.core.array(buffer.array).view; + const draws = []; + for (let row = 0; row * 8 < view.length; row++) { + const [indices, instances, firstIndex, baseVertex, firstInstance] = + view.subarray(row * 8, row * 8 + 5); + if (instances) + draws.push({ + pass: pass.id, + indices, + firstIndex, + baseVertex, + firstInstance, + }); + } + return draws; + }); +}