Files
yawn/addons/fxnode_rg/test/compiler.test.ts
T

416 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
renderGraphComposition,
renderGraphNodeGroups,
renderGraphPresets,
} from "../src/catalog";
import {
buildFxNodeRenderGraph,
compileFxNodeRenderGraph,
encodeRenderGraph,
} from "../src/compiler";
import {
isPhysicalRenderGraph,
materializeRenderGraph,
reconstructRenderGraph,
} from "../src/physical";
import { bindFxNodeHeadless } from "../../../vendor/fxnode/src/headless-runtime";
import { compileFxNodeComposition } from "../../../vendor/fxnode/src/composition/compile";
const baseGraph = {
id: "scene",
resources: {
buffers: [
{ id: "node-positions", array: "nodePositions", usage: ["storage"] },
{ id: "node-rotors", array: "nodeRotors", usage: ["storage"] },
{ id: "node-scales", array: "nodeScales", usage: ["storage"] },
{ id: "mesh-info", array: "meshInfo", usage: ["storage"] },
{ id: "camera-matrices", array: "cameraMatrices", usage: ["storage"] },
{ id: "geometry-1-positions", array: "positions", usage: ["vertex"] },
{ id: "geometry-1-normals", array: "normals", usage: ["vertex"] },
],
textures: [
{
id: "hdr",
format: "rgba16float",
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
},
{
id: "depth",
format: "depth24plus",
size: ["canvas", "canvas", 1],
usage: ["render"],
},
],
samplers: [{ id: "linear", descriptor: { magFilter: "linear" } }],
},
pipelines: {
compute: [],
render: [
{
id: "forward-pbr-0",
code: "original pbr shader",
vertex: {
entry: "vertex",
buffers: [
{
arrayStride: 16,
attributes: [
{ format: "float32x3", offset: 0, shaderLocation: 0 },
],
},
{
arrayStride: 16,
attributes: [
{ format: "float32x3", offset: 0, shaderLocation: 1 },
],
},
{
arrayStride: 16,
attributes: [
{ format: "float32x2", offset: 0, shaderLocation: 2 },
],
},
],
},
fragment: {
entry: "fragment",
targets: [{ format: "rgba16float" }],
},
depthStencil: {
format: "depth24plus",
depth_write_enabled: true,
depth_compare: "less",
},
},
{
id: "present",
code: "present shader",
vertex: { entry: "vertex", buffers: [] },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
},
],
},
passes: [
{
id: "forward-1--1",
type: "render",
pipeline: "forward-pbr-0",
bindings: [{ group: 0, binding: 0, resource: "unrelated" }],
color: [{ resource: "hdr", clear: [0, 0, 0, 1] }],
depth: { resource: "depth", clear: 1 },
vertexBuffers: [
{ slot: 0, resource: "geometry-1-positions" },
{ slot: 1, resource: "geometry-1-normals" },
{ slot: 2, resource: "geometry-1-uvs" },
],
draw: { vertices: 3, instances: 1 },
},
{
id: "present",
type: "render",
pipeline: "present",
after: ["forward-1--1"],
bindings: [
{ group: 0, binding: 0, resource: "hdr" },
{ group: 0, binding: 1, resource: "linear" },
],
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }],
draw: { vertices: 3 },
},
],
};
test("catalog exposes grouped, materially rich render graph nodes", () => {
assert.equal(renderGraphNodeGroups.length, 9);
assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 32);
for (const item of renderGraphNodeGroups.flatMap((group) => group.items))
assert.ok(renderGraphComposition.nodes[item.typeId], item.typeId);
assert.doesNotThrow(() => compileFxNodeComposition(renderGraphComposition));
});
test("prepared intent expands into the complete physical render graph", () => {
const standard = renderGraphPresets.find((preset) => preset.id === "beauty")!;
const concrete = buildFxNodeRenderGraph(standard.layout, baseGraph);
const layout = materializeRenderGraph(concrete, "beauty");
assert.equal(isPhysicalRenderGraph(layout), true);
assert.ok(layout.nodes.length >= 14);
for (const typeId of [
"yawn.rg.physical.frame-in",
"yawn.rg.physical.frame-out",
"yawn.rg.physical.buffer",
"yawn.rg.physical.texture",
"yawn.rg.physical.sampler",
"yawn.rg.physical.render-pipeline",
"yawn.rg.physical.render-pass",
]) {
assert.ok(
layout.nodes.some((node) => node.typeId === typeId),
typeId,
);
}
assert.ok(
layout.links.some((link) => link.extensions.relation === "pipeline"),
);
assert.ok(
layout.links.some((link) => link.extensions.relation === "binding"),
);
assert.ok(layout.links.some((link) => link.extensions.relation === "color"));
assert.ok(layout.links.some((link) => link.extensions.relation === "depth"));
assert.ok(layout.links.some((link) => link.extensions.relation === "vertex"));
assert.deepEqual(reconstructRenderGraph(layout), concrete);
});
test("physical pipeline shader edits compile into the S-expression", () => {
const concrete = buildFxNodeRenderGraph(
renderGraphPresets[0]!.layout,
baseGraph,
);
const layout = structuredClone(materializeRenderGraph(concrete, "beauty"));
const pipeline = layout.nodes.find(
(node) =>
node.typeId === "yawn.rg.physical.render-pipeline" &&
node.label.includes("forward-pbr-0"),
)!;
pipeline.parameters.code = {
kind: "string",
value:
"@fragment fn edited_shader() -> @location(0) vec4<f32> { return vec4<f32>(0.25); }",
};
const serialized = compileFxNodeRenderGraph(layout, baseGraph);
assert.match(serialized, /edited_shader/u);
assert.doesNotMatch(serialized, /original pbr shader/u);
});
test("Color Grade exposes Blender-style grading modes and migrates version 1 nodes", () => {
const definition = renderGraphComposition.nodes["yawn.rg.post.color-grade"];
assert.equal(definition.version, 2);
assert.deepEqual(
definition.ui
.filter((row) => row.kind === "widget" && row.widget === "grading-wheels")
.map((row) => row.bindings.map((binding) => binding.title)),
[
["Lift", "Gamma", "Gain"],
["Offset", "Power", "Slope"],
],
);
assert.deepEqual(definition.parameters.mode.enum, [
"Lift/Gamma/Gain",
"Offset/Power/Slope",
"White Point",
]);
const legacy = structuredClone(
renderGraphPresets.find((preset) => preset.id === "cinematic")!.layout,
) as any;
legacy.catalogVersion = 1;
const grade = legacy.nodes.find((node: any) => node.id === "grade");
grade.typeVersion = 1;
grade.parameters = Object.fromEntries(
["exposure", "saturation", "tint", "tintAmount"].map((key) => [
key,
grade.parameters[key],
]),
);
const runtime = bindFxNodeHeadless(
compileFxNodeComposition(renderGraphComposition),
);
const decoded = runtime.decodeGraphDocument(legacy);
assert.equal(decoded.ok, true);
if (!decoded.ok) return;
const migrated = decoded.value.nodes.grade!;
assert.equal(migrated.known, true);
assert.equal(migrated.typeVersion, 2);
assert.equal(migrated.parameters.mode.value, "Lift/Gamma/Gain");
assert.equal(migrated.parameters.contrast.value, 1);
assert.equal(migrated.parameters.outputTemperature.value, 6500);
});
test("prepared loadouts are connected to one Present output", () => {
assert.deepEqual(
renderGraphPresets.map((preset) => preset.id),
["beauty", "normals", "depth", "clay", "cinematic", "blueprint"],
);
assert.equal(renderGraphPresets[0]!.name, "Standard");
for (const preset of renderGraphPresets) {
assert.equal(
preset.layout.nodes.filter(
(node) => node.typeId === "yawn.rg.output.present",
).length,
1,
);
assert.equal(preset.layout.links.length, preset.layout.nodes.length - 1);
const left = Math.min(
...preset.layout.nodes.map((node) => node.position.x),
);
const right = Math.max(
...preset.layout.nodes.map((node) => node.position.x + node.size.x),
);
assert.ok(right - left <= 650, `${preset.name} is too wide for the editor`);
}
});
test("wire encoder is stable and rejects unsupported numbers", () => {
assert.equal(
encodeRenderGraph({ z: [true, "x"], a: 1 }),
'(yawn-graph 1 (object (field "a" 1) (field "z" (array true "x"))))',
);
assert.throws(() => encodeRenderGraph({ value: Number.NaN }), /finite/u);
});
test("normals loadout replaces scene forward shading without mutating the base", () => {
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
const graph = buildFxNodeRenderGraph(normals.layout, baseGraph) as any;
const serialized = encodeRenderGraph(graph);
assert.match(serialized, /scene-fxnode-normals/u);
assert.match(serialized, /rg-normals-detailed/u);
assert.match(serialized, /normalize\(input\.normal\) \* 0\.5 \+ 0\.5/u);
assert.match(serialized, /camera-matrices/u);
assert.deepEqual(
graph.passes.map((pass: any) => pass.id),
["forward-1--1"],
);
assert.deepEqual(graph.passes[0].color, [
{ resource: "canvas", clear: [0, 0, 0, 1] },
]);
assert.deepEqual(
graph.pipelines.render.map((pipeline: any) => pipeline.id),
["rg-normals-detailed"],
);
assert.deepEqual(
graph.resources.textures.map((texture: any) => texture.id),
["depth"],
);
assert.deepEqual(graph.resources.samplers, []);
assert.equal(baseGraph.passes[0].pipeline, "forward-pbr-0");
assert.equal(baseGraph.pipelines.render.length, 2);
});
test("debug loadouts remove detached clustered lighting work", () => {
const graph = structuredClone(baseGraph) as any;
graph.resources.buffers.push(
{ id: "point-lights", array: "pointLights", usage: ["storage"] },
{ id: "clusters", array: "clusters", usage: ["storage"] },
);
graph.pipelines.compute.push({
id: "cluster-lights",
code: "@compute @workgroup_size(1) fn main() {}",
entry: "main",
});
graph.passes.unshift({
id: "cluster-lights",
type: "compute",
pipeline: "cluster-lights",
bindings: [
{ group: 0, binding: 0, resource: "point-lights" },
{ group: 0, binding: 1, resource: "clusters" },
],
dispatch: [1, 1, 1],
});
graph.passes[1].after = ["cluster-lights"];
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
const result = buildFxNodeRenderGraph(normals.layout, graph) as any;
assert.equal(
result.passes.some((pass: any) => pass.type === "compute"),
false,
);
assert.deepEqual(result.pipelines.compute, []);
assert.equal(
result.resources.buffers.some((buffer: any) =>
["point-lights", "clusters"].includes(buffer.id),
),
false,
);
});
test("depth loadout uses position-only geometry and renders to canvas", () => {
const depth = renderGraphPresets.find((preset) => preset.id === "depth")!;
const graph = buildFxNodeRenderGraph(depth.layout, baseGraph) as any;
assert.deepEqual(
graph.pipelines.render.map((pipeline: any) => pipeline.id),
["rg-depth-basic"],
);
assert.match(graph.pipelines.render[0].code, /0\.2040/u);
assert.deepEqual(graph.pipelines.render[0].vertex.buffers.length, 1);
assert.deepEqual(graph.passes[0].vertexBuffers, [
{ slot: 0, resource: "geometry-1-positions" },
]);
assert.deepEqual(graph.passes[0].color, [
{ resource: "canvas", clear: [0, 0, 0, 1] },
]);
assert.equal(
graph.resources.buffers.some(
(buffer: any) => buffer.id === "geometry-1-normals",
),
false,
);
});
test("cinematic loadout inserts ordered sampled post passes", () => {
const cinematic = renderGraphPresets.find(
(preset) => preset.id === "cinematic",
)!;
const graph = buildFxNodeRenderGraph(cinematic.layout, baseGraph) as any;
const serialized = encodeRenderGraph(graph);
assert.match(serialized, /rg-effect-pass-0-bloom/u);
assert.match(serialized, /rg-effect-pass-1-grade/u);
assert.match(serialized, /rg-effect-pass-2-vignette/u);
assert.doesNotMatch(serialized, /\(field "id" "rg-effect-2-vignette"\)/u);
assert.doesNotMatch(serialized, /\(field "id" "present"\)/u);
assert.deepEqual(
graph.passes.find((pass: any) => pass.id === "rg-effect-pass-2-vignette")
.color,
[{ resource: "canvas", clear: [0, 0, 0, 1] }],
);
assert.match(serialized, /let liftBalance = vec3<f32>/u);
assert.match(serialized, /let gammaBalance = vec3<f32>/u);
assert.match(serialized, /let gainBalance = vec3<f32>/u);
assert.match(serialized, /color = \(color - vec3<f32>\(0\.18000\)\)/u);
});
test("Color Grade compiles offset and white-point modes into pixel math", () => {
const loadout = structuredClone(
renderGraphPresets.find((preset) => preset.id === "cinematic")!.layout,
) as any;
const grade = loadout.nodes.find((node: any) => node.id === "grade");
grade.parameters.mode = { kind: "string", value: "Offset/Power/Slope" };
grade.parameters.offset = { kind: "number", value: 0.08 };
grade.parameters.power = { kind: "number", value: 1.12 };
grade.parameters.slope = { kind: "number", value: 0.94 };
let serialized = compileFxNodeRenderGraph(loadout, baseGraph);
assert.match(serialized, /let offsetBalance = vec3<f32>/u);
assert.match(serialized, /let powerBalance = vec3<f32>/u);
assert.match(serialized, /color \* slopeBalance \+ offsetBalance/u);
grade.parameters.mode = { kind: "string", value: "White Point" };
grade.parameters.inputTemperature = { kind: "number", value: 7200 };
grade.parameters.outputTemperature = { kind: "number", value: 4800 };
grade.parameters.outputTint = { kind: "number", value: -0.12 };
serialized = compileFxNodeRenderGraph(loadout, baseGraph);
assert.match(serialized, /let whitePointAdaptation = vec3<f32>/u);
assert.match(serialized, /color \*= whitePointAdaptation/u);
});
test("blueprint loadout converts normals into blue paper and linework", () => {
const blueprint = renderGraphPresets.find(
(preset) => preset.id === "blueprint",
)!;
const serialized = compileFxNodeRenderGraph(blueprint.layout, baseGraph);
assert.match(serialized, /scene-fxnode-normals-edges/u);
assert.match(serialized, /vec3<f32>\(0\.008, 0\.045, 0\.11\)/u);
assert.match(serialized, /vec3<f32>\(0\.68, 0\.94, 1\.0\)/u);
assert.match(serialized, /fract\(input\.uv \* vec2<f32>\(24\.0, 16\.0\)\)/u);
assert.match(serialized, /, line\), 1\.0\)/u);
});
test("compiler rejects a disconnected output", () => {
const disconnected = structuredClone(renderGraphPresets[0]!.layout);
disconnected.links = [];
assert.throws(
() => compileFxNodeRenderGraph(disconnected, baseGraph),
/not connected to Scene/u,
);
});