handles: indirect draw buckets for opaque and mask meshes

Opaque/mask draws sharing a pipeline and bind set become rows of one
persistent `draws.<id>` 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 <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-09-18 07:14:37 +00:00
co-authored by heaust
parent a4f3eeddd6
commit bc957cf40d
4 changed files with 300 additions and 81 deletions
+107 -5
View File
@@ -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 () => {