Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup. Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
352 lines
12 KiB
TypeScript
352 lines
12 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 {
|
|
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 passes = forwardPasses(graph);
|
|
assert.equal(pbrPipelines(graph).length, 1);
|
|
assert.equal(passes.length, 2);
|
|
assert.deepEqual(
|
|
passes.map((pass: any) => pass.draw.firstInstance & 0xffff),
|
|
[firstMesh.id, secondMesh.id],
|
|
);
|
|
assert.deepEqual(
|
|
passes.map((pass: any) => (pass.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, 2);
|
|
});
|
|
|
|
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);
|
|
});
|