- generate a desktop-Blender PBR asset library and seed catalog v2 into OPFS - add React asset browser, material preview sphere and Web PBR render panel - extend shader graph/compiler to the finite 14-node set and native properties - add image colour-space editing, scene-camera still rendering and asset repair - add focused E2E/unit coverage and implementation status notes Co-Authored-By: Claude Code <noreply@anthropic.com>
271 lines
13 KiB
JavaScript
271 lines
13 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import test from "node:test";
|
|
import ts from "typescript";
|
|
|
|
const root = path.resolve(import.meta.dirname, "../../..");
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "shader-compiler-unit-"));
|
|
const sourcePath = path.join(root, "web/protocol/shader-compiler.ts");
|
|
const outputPath = path.join(temporary, "shader-compiler.mjs");
|
|
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
|
fileName: sourcePath,
|
|
reportDiagnostics: true,
|
|
});
|
|
assert.deepEqual(transpiled.diagnostics, []);
|
|
fs.writeFileSync(outputPath, transpiled.outputText);
|
|
const compiler = await import(pathToFileURL(outputPath));
|
|
|
|
function material(overrides = {}) {
|
|
return {
|
|
id: "material:ShaderUnit",
|
|
name: "ShaderUnit",
|
|
baseColor: [0.2, 0.3, 0.4, 1],
|
|
roughness: 0.5,
|
|
metallic: 0.1,
|
|
emissionColor: [0, 0, 0, 1],
|
|
alpha: 1,
|
|
ior: 1.45,
|
|
shaderGraphHash: "a".repeat(64),
|
|
nodes: [
|
|
{ id: "rgb", type: "RGB", name: "RGB", defaultValue: [0.1, 0.2, 0.3, 1] },
|
|
{ id: "value-a", type: "VALUE", name: "A", defaultValue: [0.2] },
|
|
{ id: "value-b", type: "VALUE", name: "B", defaultValue: [0.22] },
|
|
{ id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } },
|
|
{ id: "image", type: "IMAGE_TEXTURE", name: "Image", imageId: "image:Normal" },
|
|
{ id: "normal", type: "NORMAL_MAP", name: "Normal" },
|
|
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
|
{ id: "output", type: "OUTPUT", name: "Output" },
|
|
],
|
|
links: [
|
|
{ fromNodeId: "rgb", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" },
|
|
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
|
{ fromNodeId: "value-b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
|
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
|
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" },
|
|
{ fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
|
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("M10-07 compiles the declared Principled/Image/Normal/Math closure", () => {
|
|
const report = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]) });
|
|
assert.equal(report.status, "COMPILED");
|
|
assert.equal(report.taskId, "M10-07");
|
|
assert.equal(report.backend, "WEBGL2_THREE_PHYSICAL");
|
|
assert.equal(report.material.baseColor[2], 0.3);
|
|
assert.ok(Math.abs(report.material.roughness - 0.42) < 1e-8);
|
|
assert.deepEqual(report.textureBindings, [{ imageId: "image:Normal", usage: "NORMAL" }]);
|
|
assert.match(report.graphHash, /^[0-9a-f]{64}$/);
|
|
assert.ok(report.instructions.some((instruction) => instruction.operation === "ADD"));
|
|
assert.ok(report.nodeOrder.indexOf("value-a") < report.nodeOrder.indexOf("math"));
|
|
assert.ok(report.nodeOrder.indexOf("math") < report.nodeOrder.indexOf("principled"));
|
|
assert.ok(report.nodeOrder.indexOf("principled") < report.nodeOrder.indexOf("output"));
|
|
});
|
|
|
|
test("M10-07 blocks unknown nodes and preserves the graph metadata", () => {
|
|
const source = material();
|
|
source.nodes.push({ id: "mix", type: "UNSUPPORTED", name: "ShaderNodeMix" });
|
|
const report = compiler.compileMaterialGraph(source);
|
|
assert.equal(report.status, "BLOCKED");
|
|
assert.equal(report.issues[0].code, "SHADER_NODE_UNSUPPORTED");
|
|
assert.equal(source.nodes.at(-1).type, "UNSUPPORTED");
|
|
});
|
|
|
|
test("M10-07 rejects cycles, duplicate links, and missing image resources", () => {
|
|
const cyclic = material({
|
|
links: [
|
|
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
|
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
|
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
|
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
|
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" },
|
|
{ fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
|
],
|
|
});
|
|
const cyclicReport = compiler.compileMaterialGraph(cyclic);
|
|
assert.equal(cyclicReport.status, "BLOCKED");
|
|
assert.ok(cyclicReport.issues.some((issue) => issue.code === "SHADER_GRAPH_CYCLE"));
|
|
const missingReport = compiler.compileMaterialGraph(material(), { imageIds: new Set() });
|
|
assert.equal(missingReport.status, "BLOCKED");
|
|
assert.ok(missingReport.issues.some((issue) => issue.code === "SHADER_EXTERNAL_RESOURCE_MISSING"));
|
|
});
|
|
|
|
test("M10-07 keeps the fallback graph fingerprint deterministic", () => {
|
|
const source = material();
|
|
delete source.shaderGraphHash;
|
|
const first = compiler.compileMaterialGraph(source);
|
|
const second = compiler.compileMaterialGraph(source);
|
|
assert.equal(first.graphHash, second.graphHash);
|
|
assert.match(first.graphHash, /^[0-9a-f]{64}$/);
|
|
const canonical = JSON.stringify({
|
|
schemaVersion: 1,
|
|
materialId: source.id,
|
|
nodes: source.nodes.map((node) => ({
|
|
id: node.id,
|
|
type: node.type,
|
|
name: node.name,
|
|
imageId: node.imageId ?? null,
|
|
defaultValue: node.defaultValue ?? null,
|
|
properties: node.properties ?? null,
|
|
})),
|
|
links: source.links.map((link) => ({
|
|
fromNodeId: link.fromNodeId,
|
|
fromSocket: link.fromSocket,
|
|
toNodeId: link.toNodeId,
|
|
toSocket: link.toSocket,
|
|
})),
|
|
});
|
|
assert.equal(first.graphHash, createHash("sha256").update(canonical).digest("hex"));
|
|
});
|
|
|
|
test("M10-07 fails closed before oversized topology or forged graph hashes are compiled", () => {
|
|
const oversized = material({
|
|
nodes: Array.from({ length: compiler.SHADER_COMPILE_BUDGET.maxNodes + 1 }, (_value, index) => ({
|
|
id: `value:${index}`,
|
|
type: "VALUE",
|
|
name: `Value ${index}`,
|
|
defaultValue: [0],
|
|
})),
|
|
links: [],
|
|
});
|
|
const oversizedReport = compiler.compileMaterialGraph(oversized);
|
|
assert.equal(oversizedReport.status, "BLOCKED");
|
|
assert.equal(oversizedReport.issues[0].code, "SHADER_NODE_UNSUPPORTED");
|
|
const forged = compiler.compileMaterialGraph(material({ shaderGraphHash: "A".repeat(64) }));
|
|
assert.equal(forged.status, "BLOCKED");
|
|
assert.equal(forged.issues[0].code, "SHADER_INVALID_GRAPH");
|
|
});
|
|
|
|
test("M10-08 binds graph, texture identity, color space and backend into compileKey", () => {
|
|
const textureIdentities = new Map([["image:Normal", {
|
|
assetId: "asset:normal-v1",
|
|
sha256: "b".repeat(64),
|
|
colorSpace: "NON_COLOR",
|
|
}]]);
|
|
const first = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities });
|
|
const second = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities: new Map([["image:Normal", { ...textureIdentities.get("image:Normal"), sha256: "c".repeat(64) }]]) });
|
|
const linear = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities: new Map([["image:Normal", { ...textureIdentities.get("image:Normal"), colorSpace: "LINEAR" }]]) });
|
|
assert.equal(first.status, "COMPILED");
|
|
assert.match(first.compileKey, /^[0-9a-f]{64}$/);
|
|
assert.notEqual(first.compileKey, second.compileKey);
|
|
assert.notEqual(first.compileKey, linear.compileKey);
|
|
assert.notEqual(
|
|
first.compileKey,
|
|
compiler.createShaderCompileKey({
|
|
graphHash: first.graphHash,
|
|
rendererBackend: "WEBGPU",
|
|
textures: [{ imageId: "image:Normal", usage: "NORMAL", assetId: "asset:normal-v1", sha256: "b".repeat(64), colorSpace: "NON_COLOR" }],
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("M10-08 rejects an unknown renderer backend and malformed texture identity", () => {
|
|
const backend = compiler.compileMaterialGraph(material(), { rendererBackend: "WEBGPU" });
|
|
assert.equal(backend.status, "BLOCKED");
|
|
assert.equal(backend.issues[0].code, "CAPABILITY_MISSING");
|
|
const malformed = compiler.compileMaterialGraph(material(), {
|
|
imageIds: new Set(["image:Normal"]),
|
|
textureIdentities: new Map([["image:Normal", { sha256: "not-a-digest" }]]),
|
|
});
|
|
assert.equal(malformed.status, "BLOCKED");
|
|
assert.equal(malformed.issues.at(-1).code, "SHADER_INVALID_GRAPH");
|
|
});
|
|
|
|
test("M10-07 compiles Mix constants into the Principled base color", () => {
|
|
const report = compiler.compileMaterialGraph(material({
|
|
nodes: [
|
|
{ id: "color-a", type: "RGB", name: "A", defaultValue: [1, 0, 0, 1] },
|
|
{ id: "color-b", type: "RGB", name: "B", defaultValue: [0, 0, 1, 1] },
|
|
{ id: "factor", type: "VALUE", name: "Factor", defaultValue: [0.5] },
|
|
{ id: "mix", type: "MIX", name: "Mix", properties: { dataType: "RGBA", blendType: "MIX" } },
|
|
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
|
{ id: "output", type: "OUTPUT", name: "Output" },
|
|
],
|
|
links: [
|
|
{ fromNodeId: "factor", fromSocket: "Value", toNodeId: "mix", toSocket: "Factor" },
|
|
{ fromNodeId: "color-a", fromSocket: "Color", toNodeId: "mix", toSocket: "A" },
|
|
{ fromNodeId: "color-b", fromSocket: "Color", toNodeId: "mix", toSocket: "B" },
|
|
{ fromNodeId: "mix", fromSocket: "Result", toNodeId: "principled", toSocket: "Base Color" },
|
|
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
|
],
|
|
}));
|
|
assert.equal(report.status, "COMPILED");
|
|
assert.deepEqual(report.material.baseColor.map((value) => Number(value.toFixed(4))), [0.5, 0, 0.5, 1]);
|
|
});
|
|
|
|
test("M10-07 compiles Separate Color constants into scalar inputs", () => {
|
|
const report = compiler.compileMaterialGraph(material({
|
|
nodes: [
|
|
{ id: "color", type: "RGB", name: "Color", defaultValue: [0.2, 0.4, 0.6, 1] },
|
|
{ id: "separate", type: "SEPARATE_COLOR", name: "Separate", properties: { mode: "RGB" } },
|
|
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
|
{ id: "output", type: "OUTPUT", name: "Output" },
|
|
],
|
|
links: [
|
|
{ fromNodeId: "color", fromSocket: "Color", toNodeId: "separate", toSocket: "Color" },
|
|
{ fromNodeId: "separate", fromSocket: "Red", toNodeId: "principled", toSocket: "Roughness" },
|
|
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
|
],
|
|
}));
|
|
assert.equal(report.status, "COMPILED");
|
|
assert.equal(report.material.roughness, 0.2);
|
|
});
|
|
|
|
test("M10-07 compiles Mapping -> Image -> Bump with a UV transform binding", () => {
|
|
const report = compiler.compileMaterialGraph(material({
|
|
nodes: [
|
|
{ id: "uv", type: "UV_MAP", name: "UV", properties: { uvMap: "UVMap" } },
|
|
{ id: "mapping", type: "MAPPING", name: "Mapping", properties: { vectorType: "POINT" }, sockets: [
|
|
{ id: "Location", name: "Location", direction: "INPUT", dataType: "VECTOR", value: [0.1, 0.2, 0] },
|
|
{ id: "Scale", name: "Scale", direction: "INPUT", dataType: "VECTOR", value: [2, 3, 1] },
|
|
{ id: "Rotation", name: "Rotation", direction: "INPUT", dataType: "VECTOR", value: [0.5, 0, 0] },
|
|
] },
|
|
{ id: "bump-image", type: "IMAGE_TEXTURE", name: "Bump Image", imageId: "image:Bump" },
|
|
{ id: "bump", type: "BUMP", name: "Bump" },
|
|
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
|
{ id: "output", type: "OUTPUT", name: "Output" },
|
|
],
|
|
links: [
|
|
{ fromNodeId: "uv", fromSocket: "UV", toNodeId: "mapping", toSocket: "Vector" },
|
|
{ fromNodeId: "mapping", fromSocket: "Vector", toNodeId: "bump-image", toSocket: "Vector" },
|
|
{ fromNodeId: "bump-image", fromSocket: "Color", toNodeId: "bump", toSocket: "Height" },
|
|
{ fromNodeId: "bump", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
|
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
|
],
|
|
}), { imageIds: new Set(["image:Bump"]) });
|
|
assert.equal(report.status, "COMPILED");
|
|
assert.deepEqual(report.textureBindings, [{
|
|
imageId: "image:Bump",
|
|
usage: "BUMP",
|
|
uvMap: "UVMap",
|
|
uvTransform: { offset: [0.1, 0.2], rotation: 0.5, scale: [2, 3] },
|
|
}]);
|
|
});
|
|
|
|
test("M10-07 keeps texture-fed Mix blocked with a visible issue", () => {
|
|
const report = compiler.compileMaterialGraph(material({
|
|
nodes: [
|
|
{ id: "image", type: "IMAGE_TEXTURE", name: "Image", imageId: "image:Color" },
|
|
{ id: "color", type: "RGB", name: "Color", defaultValue: [0, 0, 0, 1] },
|
|
{ id: "mix", type: "MIX", name: "Mix", properties: { dataType: "RGBA", blendType: "MIX" } },
|
|
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
|
{ id: "output", type: "OUTPUT", name: "Output" },
|
|
],
|
|
links: [
|
|
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "mix", toSocket: "A" },
|
|
{ fromNodeId: "color", fromSocket: "Color", toNodeId: "mix", toSocket: "B" },
|
|
{ fromNodeId: "mix", fromSocket: "Result", toNodeId: "principled", toSocket: "Base Color" },
|
|
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
|
],
|
|
}), { imageIds: new Set(["image:Color"]) });
|
|
assert.equal(report.status, "BLOCKED");
|
|
assert.ok(report.issues.some((entry) => entry.message.includes("Mix color inputs")));
|
|
});
|