180 lines
8.4 KiB
JavaScript
180 lines
8.4 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");
|
|
});
|