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
+164 -73
View File
@@ -626,6 +626,9 @@ export class Scene {
#graphBatchDirty = false;
#inhibits = 0;
#batchInhibits = 0;
/** Indirect draw buckets: pipeline + bind-set key → `draws.<id>`, and each bucket's row capacity. */
#buckets = new Map<string, number>();
#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<string, GraphBuffer>();
const textures = new Map<string, GraphTexture>();
const samplers = new Map<string, GraphSampler>();
@@ -1363,6 +1373,7 @@ export class Scene {
}
let previous = computeIds.length ? computeIds : ["cluster-lights"];
const bucketRows = new Map<number, number[][]>();
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<string, Bucket>();
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.<id>` 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<number, number[][]>) {
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();
}