Files
yawn/addons/handles/test/scene-graph.test.ts
T

612 lines
21 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 { ShaderMaterial } from "../src/materials/ShaderMaterial";
import { PointLight } from "../src/lights/PointLight";
import { SpotLight } from "../src/lights/SpotLight";
import { DirectionalLight } from "../src/lights/DirectionalLight";
import { ComputePass } from "../src/ComputePass";
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();
await Promise.all([
new PointLight(scene).ready,
new SpotLight(scene).ready,
new DirectionalLight(scene).ready,
]);
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);
});
test("packed ORM reads are shared only for the same texture and UV transform", async () => {
const scene = await createScene();
const orm = new Texture(scene, { size: [1, 1, 1] });
const other = new Texture(scene, { size: [1, 1, 1] });
await Promise.all([orm.ready, other.ready]);
for (const [occlusion, info, shared] of [
[orm, {}, true],
[other, {}, false],
[orm, { texCoord: 1 }, false],
[orm, { offset: [0.25, -0.1] }, false],
[orm, { scale: [2, 1] }, false],
[orm, { rotation: 0.4 }, false],
] as const) {
const material = new PBRMaterial(scene, {
metallicRoughnessTexture: orm,
occlusionTexture: occlusion,
textureInfos: [{}, {}, {}, {}, info],
});
await material.ready;
const mesh = await detailedMesh(scene, material).ready;
const graph = latestGraph(scene);
const pass = forwardPasses(graph).at(-1);
const code = renderPipeline(graph, pass.pipeline).code;
assert.equal(code.includes("mix(1.0, packedMaterial.r"), shared);
assert.equal(code.includes("textureSample(occlusionTexture"), !shared);
assert.equal(
pass.bindings.some((b: any) => b.group === 1 && b.binding === 5),
!shared,
);
await mesh.dispose();
}
});
test("light variants follow first/last light lifetimes and keep hot edits rebuild-free", async () => {
const scene = await createScene();
const material = await new PBRMaterial(scene).ready;
await detailedMesh(scene, material).ready;
const code = () => pbrPipelines(latestGraph(scene))[0].code;
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
const first = await new PointLight(scene, { intensity: 0 }).ready;
assert.match(code(), /fn pointContribution/);
assert.doesNotMatch(code(), /fn (spot|directional)Contribution/);
assert.equal(code().match(/let nDotV =/g)?.length, 1);
assert.match(code(), /let diffuseColor = base.rgb \* \(1.0 - metallic\)/);
const before = testCore(scene).graphs.length;
first.intensity = 3;
first.radius = 0.4;
const second = await new PointLight(scene).ready;
await first.dispose();
assert.equal(testCore(scene).graphs.length, before);
assert.match(code(), /fn pointContribution/);
const spot = await new SpotLight(scene).ready;
const directional = await new DirectionalLight(scene).ready;
assert.match(code(), /fn spotContribution/);
assert.match(code(), /fn directionalContribution/);
await second.dispose();
assert.doesNotMatch(code(), /fn pointContribution/);
await spot.dispose();
await directional.dispose();
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
});
test("direct canvas loadout preserves clear/depth and falls back for linear-color consumers", async () => {
const scene = await createScene({
directToCanvas: true,
clearColor: [0.02, 0.09, 0.3, 0.5],
});
const material = await new PBRMaterial(scene).ready;
const mesh = await detailedMesh(scene, material).ready;
const assertDirect = () => {
const graph = latestGraph(scene);
assert.equal(
graph.resources.textures.some((t: any) => t.id === "hdr"),
false,
);
assert.equal(
graph.passes.some((p: any) => p.id === "present"),
false,
);
const background = graph.passes.find(
(p: any) => p.id === "canvas-background",
);
assert.deepEqual(background.depth, { resource: "depth", clear: 1 });
assert.match(
renderPipeline(graph, "canvas-background").code,
/canvasOutput\(vec4<f32>\(0.02, 0.09, 0.3, 0.5\)\)/,
);
const forward = forwardPasses(graph)[0];
assert.deepEqual(forward.color, [{ resource: "canvas", load: "load" }]);
assert.deepEqual(forward.depth, { resource: "depth", load: "load" });
assert.equal(pbrPipelines(graph)[0].fragment.targets[0].format, "canvas");
assert.match(pbrPipelines(graph)[0].code, /unpack2x16float\(pack2x16float/);
};
const assertHdr = () =>
assert.ok(renderPipeline(latestGraph(scene), "present"));
assertDirect();
const effect = new FXAA(scene);
await effect.ready;
assertHdr();
await effect.dispose();
assertDirect();
for (const options of [{ flags: 2 }, { transmission: 0.5 }]) {
const special = await new PBRMaterial(scene, options).ready;
const extra = await detailedMesh(scene, special).ready;
assertHdr();
await extra.dispose();
assertDirect();
}
const compute = new ComputePass({
code: "@compute @workgroup_size(1) fn main() {}",
});
await scene.addComputePass(compute);
assertHdr();
assert.match(
pbrPipelines(latestGraph(scene))[0].code,
/fn directionalContribution/,
);
await scene.removeComputePass(compute.id);
assertDirect();
const custom = new ShaderMaterial(scene, { code: "custom WGSL" });
await custom.ready;
assertHdr();
await custom.dispose();
assertDirect();
const basic = await new Mesh(scene, {
vertexData: { positions: triangle.positions, indices: triangle.indices },
}).ready;
assertHdr();
await basic.dispose();
assertDirect();
const mask = await new PBRMaterial(scene, { flags: 1 }).ready;
const masked = await detailedMesh(scene, mask).ready;
assertDirect();
assert.match(
pbrPipelines(latestGraph(scene)).find((p: any) =>
p.code.includes("discard"),
).code,
/canvasOutput/,
);
await masked.dispose();
await mesh.dispose();
assertHdr();
const ldr = await createScene({ directToCanvas: true, hdr: false });
const ldrMaterial = await new PBRMaterial(ldr).ready;
await detailedMesh(ldr, ldrMaterial).ready;
assert.ok(renderPipeline(latestGraph(ldr), "present"));
});