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; + }); +}