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>
454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { Mesh } from "../src/Mesh";
|
|
import { FXAA } from "../src/PostProcesses/FXAA";
|
|
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
|
import { Texture } from "../src/materials/Texture";
|
|
import {
|
|
bucketDraws,
|
|
createScene,
|
|
detailedMesh,
|
|
forwardPasses,
|
|
latestGraph,
|
|
pbrPipelines,
|
|
renderPipeline,
|
|
testCore,
|
|
triangle,
|
|
} from "./scene-helpers";
|
|
|
|
if (typeof globalThis.ImageBitmap === "undefined")
|
|
Object.defineProperty(globalThis, "ImageBitmap", {
|
|
value: class ImageBitmap {},
|
|
});
|
|
|
|
test("empty scenes use neutral authored defaults without placeholder geometry", async () => {
|
|
const scene = await createScene();
|
|
const emptyPass = latestGraph(scene).passes.find(
|
|
(pass: any) => pass.id === "forward-empty",
|
|
);
|
|
|
|
assert.equal(emptyPass.draw.vertices, 0);
|
|
assert.deepEqual(emptyPass.color[0].clear, [0, 0, 0, 1]);
|
|
assert.deepEqual(Array.from(scene.array("sceneAccent").row(0)), [1, 1, 1, 1]);
|
|
});
|
|
|
|
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
|
|
const scene = await createScene();
|
|
const material = new PBRMaterial(scene);
|
|
await material.ready;
|
|
const mesh = detailedMesh(scene, material);
|
|
await mesh.ready;
|
|
|
|
const graph = latestGraph(scene);
|
|
const pipeline = pbrPipelines(graph)[0];
|
|
const forward = forwardPasses(graph)[0];
|
|
const present = renderPipeline(graph, "present");
|
|
const clusters = graph.pipelines.compute.find(
|
|
(candidate: any) => candidate.id === "cluster-lights",
|
|
);
|
|
|
|
assert.equal(pipeline.primitive.frontFace, "ccw");
|
|
assert.equal(
|
|
graph.passes.some((pass: any) => pass.id.includes("background")),
|
|
false,
|
|
);
|
|
assert.doesNotMatch(JSON.stringify(graph), /studio/iu);
|
|
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 1]);
|
|
assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u);
|
|
assert.match(pipeline.code, /materials\[input\.material \* 3u\]/u);
|
|
assert.doesNotMatch(pipeline.code, /defaultLight|studioEnvironment/u);
|
|
assert.doesNotMatch(pipeline.code, /0\.4, 0\.7, 0\.6/u);
|
|
assert.match(
|
|
pipeline.code,
|
|
/let ambient = vec3<f32>\(bitcast<f32>\(clusters\[1012\]\)/u,
|
|
);
|
|
assert.match(pipeline.code, /fn directContribution/u);
|
|
assert.match(pipeline.code, /fn directionalContribution/u);
|
|
assert.equal(
|
|
forward.bindings.find((binding: any) => binding.binding === 10)?.resource,
|
|
"directional-lights",
|
|
);
|
|
|
|
assert.match(clusters.code, /clusters\[11u \+ pointCount\] = i/u);
|
|
assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u);
|
|
assert.match(pipeline.code, /let pointCount = clusters\[10\]/u);
|
|
assert.match(pipeline.code, /pointCount <= 500u/u);
|
|
assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/u);
|
|
assert.match(
|
|
pipeline.code,
|
|
/let sourceRoughness = clamp\(roughness \+ max\(state\.z/u,
|
|
);
|
|
assert.match(pipeline.code, /let spotCount = clusters\[511\]/u);
|
|
assert.match(pipeline.code, /spotCount <= 500u/u);
|
|
});
|
|
|
|
test("materials with identical shader features share one pipeline", async () => {
|
|
const scene = await createScene();
|
|
const firstMaterial = new PBRMaterial(scene, { roughness: 0.2 });
|
|
const secondMaterial = new PBRMaterial(scene, { roughness: 0.8 });
|
|
await Promise.all([firstMaterial.ready, secondMaterial.ready]);
|
|
const firstMesh = detailedMesh(scene, firstMaterial);
|
|
const secondMesh = detailedMesh(scene, secondMaterial);
|
|
await Promise.all([firstMesh.ready, secondMesh.ready]);
|
|
|
|
const graph = latestGraph(scene);
|
|
const draws = bucketDraws(scene, graph);
|
|
assert.equal(pbrPipelines(graph).length, 1);
|
|
// One indirect bucket pass holds both meshes: same pipeline and bind set.
|
|
assert.equal(forwardPasses(graph).length, 1);
|
|
assert.deepEqual(
|
|
draws.map((draw) => draw.firstInstance & 0xffff),
|
|
[firstMesh.id, secondMesh.id],
|
|
);
|
|
assert.deepEqual(
|
|
draws.map((draw) => (draw.firstInstance >>> 16) - 1),
|
|
[firstMaterial.id, secondMaterial.id],
|
|
);
|
|
});
|
|
|
|
test("transmission renders directly into a composited layer with five taps", async () => {
|
|
const scene = await createScene();
|
|
const material = new PBRMaterial(scene, {
|
|
transmission: 0.9,
|
|
thickness: 0.25,
|
|
attenuationDistance: 2,
|
|
});
|
|
await material.ready;
|
|
const mesh = detailedMesh(scene, material);
|
|
await mesh.ready;
|
|
|
|
const graph = latestGraph(scene);
|
|
const pipeline = pbrPipelines(graph)[0];
|
|
const present = renderPipeline(graph, "present");
|
|
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
|
|
|
|
assert.equal(
|
|
graph.passes.some((pass: any) => pass.id === "scene-color-clear"),
|
|
true,
|
|
);
|
|
const sceneColorClear = graph.passes.find(
|
|
(pass: any) => pass.id === "scene-color-clear",
|
|
);
|
|
assert.equal(sceneColorClear.draw.vertices, 0);
|
|
assert.deepEqual(sceneColorClear.color[0].clear, [0, 0, 0, 1]);
|
|
assert.equal(
|
|
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
presentPass.bindings.some(
|
|
(binding: any) => binding.resource === "scene-color",
|
|
),
|
|
true,
|
|
);
|
|
assert.match(present.code, /textureSample\(scene, sourceSampler/u);
|
|
assert.equal(
|
|
pipeline.code.match(/textureSampleLevel\(sceneColor/g)?.length,
|
|
5,
|
|
);
|
|
assert.match(pipeline.code, /transmitted \*= pow/u);
|
|
});
|
|
|
|
test("clear color is explicit scene data used by empty and forward graphs", async () => {
|
|
const clearColor = [0.03, 0.06, 0.09, 1] as const;
|
|
const scene = await createScene({ clearColor });
|
|
const empty = latestGraph(scene).passes.find(
|
|
(pass: any) => pass.id === "forward-empty",
|
|
);
|
|
assert.deepEqual(empty.color[0].clear, clearColor);
|
|
|
|
const material = new PBRMaterial(scene);
|
|
await material.ready;
|
|
const mesh = detailedMesh(scene, material);
|
|
await mesh.ready;
|
|
|
|
const graph = latestGraph(scene);
|
|
assert.deepEqual(forwardPasses(graph)[0].color[0].clear, clearColor);
|
|
assert.deepEqual(
|
|
graph.passes.find((pass: any) => pass.id === "present").color[0].clear,
|
|
clearColor,
|
|
);
|
|
});
|
|
|
|
test("post-processing keeps the conservative transmission copy", async () => {
|
|
const scene = await createScene();
|
|
const material = new PBRMaterial(scene, { transmission: 1 });
|
|
await material.ready;
|
|
const mesh = detailedMesh(scene, material);
|
|
await mesh.ready;
|
|
const effect = new FXAA(scene);
|
|
await effect.ready;
|
|
|
|
const graph = latestGraph(scene);
|
|
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
|
|
assert.equal(
|
|
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
presentPass.bindings.some(
|
|
(binding: any) => binding.resource === "scene-color",
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
graph.passes.some((pass: any) => pass.pipeline.startsWith("post-fxaa-")),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("PBR generation eliminates inactive features and specializes active ones", async () => {
|
|
const scene = await createScene();
|
|
const textures = Array.from(
|
|
{ length: 13 },
|
|
() => new Texture(scene, { size: [1, 1, 1] }),
|
|
);
|
|
await Promise.all(textures.map((texture) => texture.ready));
|
|
const textureOptions = {
|
|
baseColorTexture: textures[0],
|
|
metallicRoughnessTexture: textures[1],
|
|
normalTexture: textures[2],
|
|
emissiveTexture: textures[3],
|
|
occlusionTexture: textures[4],
|
|
transmissionTexture: textures[5],
|
|
thicknessTexture: textures[6],
|
|
clearcoatTexture: textures[7],
|
|
iridescenceTexture: textures[8],
|
|
iridescenceThicknessTexture: textures[9],
|
|
anisotropyTexture: textures[10],
|
|
specularTexture: textures[11],
|
|
specularColorTexture: textures[12],
|
|
};
|
|
const active = new PBRMaterial(scene, {
|
|
...textureOptions,
|
|
transmission: 1,
|
|
thickness: 0.5,
|
|
attenuationDistance: 2,
|
|
clearcoat: 0.7,
|
|
sheenColor: [0.2, 0.3, 0.4],
|
|
iridescence: 0.8,
|
|
anisotropy: 0.6,
|
|
textureInfos: Array.from({ length: 13 }, (_, slot) => ({
|
|
texCoord: slot === 1 ? 1 : 0,
|
|
})),
|
|
});
|
|
const unlit = new PBRMaterial(scene, { ...textureOptions, flags: 8 });
|
|
await Promise.all([active.ready, unlit.ready]);
|
|
const activeMesh = detailedMesh(scene, active);
|
|
const unlitMesh = detailedMesh(scene, unlit);
|
|
await Promise.all([activeMesh.ready, unlitMesh.ready]);
|
|
|
|
const pipelines = pbrPipelines(latestGraph(scene));
|
|
const activeCode = pipelines.find((pipeline: any) =>
|
|
pipeline.code.includes("transmissionAmount"),
|
|
).code;
|
|
const unlitCode = pipelines.find((pipeline: any) =>
|
|
pipeline.code.includes("color = base.rgb + emissive"),
|
|
).code;
|
|
|
|
for (const binding of [
|
|
"baseTexture",
|
|
"materialTexture",
|
|
"normalTexture",
|
|
"emissiveTexture",
|
|
"occlusionTexture",
|
|
"transmissionTexture",
|
|
"thicknessTexture",
|
|
"clearcoatTexture",
|
|
"iridescenceTexture",
|
|
"iridescenceThicknessTexture",
|
|
"anisotropyTexture",
|
|
"specularTexture",
|
|
"specularColorTexture",
|
|
])
|
|
assert.match(activeCode, new RegExp(`var ${binding}: texture_2d`));
|
|
assert.match(
|
|
activeCode,
|
|
/textureSample\(baseTexture, materialSampler, input\.uv\)/u,
|
|
);
|
|
assert.match(
|
|
activeCode,
|
|
/textureSample\(materialTexture, materialSampler, input\.uv2\)/u,
|
|
);
|
|
assert.match(activeCode, /let anisotropyAmount/u);
|
|
assert.match(activeCode, /let iridescenceAmount/u);
|
|
assert.match(activeCode, /let clearcoatFresnel/u);
|
|
assert.match(activeCode, /let sheen =/u);
|
|
assert.match(unlitCode, /var baseTexture: texture_2d/u);
|
|
assert.match(unlitCode, /var emissiveTexture: texture_2d/u);
|
|
assert.doesNotMatch(unlitCode, /var normalTexture: texture_2d/u);
|
|
assert.doesNotMatch(unlitCode, /var clearcoatTexture: texture_2d/u);
|
|
assert.doesNotMatch(unlitCode, /let clearcoatFresnel/u);
|
|
});
|
|
|
|
test("alpha blending uses source-over and disables depth writes", async () => {
|
|
const scene = await createScene();
|
|
const material = new PBRMaterial(scene, { flags: 2 });
|
|
await material.ready;
|
|
const mesh = detailedMesh(scene, material);
|
|
await mesh.ready;
|
|
|
|
const pipeline = pbrPipelines(latestGraph(scene))[0];
|
|
assert.deepEqual(pipeline.fragment.targets[0].blend, {
|
|
color: {
|
|
srcFactor: "src-alpha",
|
|
dstFactor: "one-minus-src-alpha",
|
|
operation: "add",
|
|
},
|
|
alpha: {
|
|
srcFactor: "one",
|
|
dstFactor: "one-minus-src-alpha",
|
|
operation: "add",
|
|
},
|
|
});
|
|
assert.equal(pipeline.depthStencil.depth_write_enabled, false);
|
|
});
|
|
|
|
test("batchGraphUpdates compiles once for multiple mesh registrations", async () => {
|
|
const scene = await createScene();
|
|
const material = new PBRMaterial(scene);
|
|
await material.ready;
|
|
const before = testCore(scene).graphs.length;
|
|
|
|
await scene.batchGraphUpdates(async () => {
|
|
const first = new Mesh(scene, { material, vertexData: triangle });
|
|
const second = new Mesh(scene, { material, vertexData: triangle });
|
|
await Promise.all([first.ready, second.ready]);
|
|
});
|
|
|
|
assert.equal(testCore(scene).graphs.length - before, 1);
|
|
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 () => {
|
|
const scene = await createScene();
|
|
const source: any = await scene.getRenderGraph();
|
|
|
|
assert.equal(source.id, "scene");
|
|
assert.ok(
|
|
source.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
|
|
);
|
|
source.id = "mutated";
|
|
source.resources.buffers.length = 0;
|
|
|
|
const next: any = await scene.getRenderGraph();
|
|
assert.equal(next.id, "scene");
|
|
assert.ok(
|
|
next.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
|
|
);
|
|
});
|
|
|
|
test("serialized render graphs compile into explicitly selectable loadouts", async () => {
|
|
const scene = await createScene();
|
|
const core = testCore(scene);
|
|
const serialized = core.serializedGraphs.at(-1)!;
|
|
const before = core.graphs.length;
|
|
|
|
const loadoutId = await scene.compileRenderGraph(serialized);
|
|
await scene.setLoadout(loadoutId);
|
|
|
|
assert.equal(core.graphs.length, before + 1);
|
|
assert.equal(loadoutId, `loadout-${before + 1}`);
|
|
assert.equal(core.loadouts.at(-1), loadoutId);
|
|
});
|