Files
workinf_Blender_Wasm/web/tests/unit/shader-compiler.test.mjs
mes123456 d5dd647881
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
- Add 11:07 Chinese-narrated lesson video, VTT, poster and 13 chapter screenshots.
- Regenerate 20-render WebGL2 matrix and 15 operation captures on RTX 4060.
- Fix WASM staging for non-ASCII import filenames (猴头.glb); rebuild web_engine.
- Harden lesson video recording pipeline; fix stale lesson-link E2E selector.
- Add RE PBR derived libraries, lesson page, evidence and status report.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-12 21:49:24 +08:00

336 lines
17 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")));
});
test("RE 5000PBR graph collapses REROUTE, ignores FRAME, keeps DISPLACEMENT/HEIGHT and applies Value-driven Mapping", () => {
const report = compiler.compileMaterialGraph(material({
nodes: [
{ id: "uv", type: "TEX_COORD", name: "UV" },
{ id: "mapping", type: "MAPPING", name: "Mapping" },
{ id: "reroute", type: "REROUTE", name: "Reroute" },
{ id: "frame", type: "FRAME", name: "Frame" },
{ id: "value", type: "VALUE", name: "Value", defaultValue: [2] },
{ id: "base", type: "IMAGE_TEXTURE", name: "Base", imageId: "image:Base" },
{ id: "rough", type: "IMAGE_TEXTURE", name: "Rough", imageId: "image:Rough" },
{ id: "height", type: "IMAGE_TEXTURE", name: "Height", imageId: "image:Height" },
{ id: "displacement", type: "DISPLACEMENT", name: "Displacement" },
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
{ id: "output", type: "OUTPUT", name: "Output" },
],
links: [
{ fromNodeId: "uv", fromSocket: "UV", toNodeId: "mapping", toSocket: "Vector" },
{ fromNodeId: "value", fromSocket: "Value", toNodeId: "mapping", toSocket: "Scale" },
{ fromNodeId: "mapping", fromSocket: "Vector", toNodeId: "reroute", toSocket: "Input" },
{ fromNodeId: "reroute", fromSocket: "Output", toNodeId: "base", toSocket: "Vector" },
{ fromNodeId: "reroute", fromSocket: "Output", toNodeId: "rough", toSocket: "Vector" },
{ fromNodeId: "reroute", fromSocket: "Output", toNodeId: "height", toSocket: "Vector" },
{ fromNodeId: "base", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" },
{ fromNodeId: "rough", fromSocket: "Color", toNodeId: "principled", toSocket: "Roughness" },
{ fromNodeId: "height", fromSocket: "Color", toNodeId: "displacement", toSocket: "Height" },
{ fromNodeId: "displacement", fromSocket: "Displacement", toNodeId: "output", toSocket: "Displacement" },
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
],
}), { imageIds: new Set(["image:Base", "image:Rough", "image:Height"]) });
assert.equal(report.status, "COMPILED");
assert.ok(report.compiledNodeTypes.every((type) => !["REROUTE", "FRAME"].includes(type)));
assert.ok(report.compiledNodeTypes.includes("DISPLACEMENT"));
const base = report.textureBindings.find((binding) => binding.usage === "BASE_COLOR");
assert.deepEqual(base.uvTransform?.scale, [2, 2]);
assert.ok(report.textureBindings.some((binding) => binding.usage === "ROUGHNESS"));
const height = report.textureBindings.find((binding) => binding.usage === "HEIGHT");
assert.equal(height.imageId, "image:Height");
assert.equal(height.scale, 0.01);
assert.equal(height.midlevel, 0.5);
assert.deepEqual(height.uvTransform?.scale, [2, 2]);
assert.equal(report.material.displacementImageId, "image:Height");
assert.equal(report.material.displacementScale, 0.01);
assert.equal(report.material.displacementMidlevel, 0.5);
assert.equal(report.material.roughness, 1);
});
test("RE 5000PBR roughness/metallic can be read through Separate Color", () => {
const report = compiler.compileMaterialGraph(material({
nodes: [
{ id: "image", type: "IMAGE_TEXTURE", name: "Mask", imageId: "image:Mask" },
{ id: "separate", type: "SEPARATE_COLOR", name: "Separate" },
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
{ id: "output", type: "OUTPUT", name: "Output" },
],
links: [
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "separate", toSocket: "Color" },
{ fromNodeId: "separate", fromSocket: "Blue", toNodeId: "principled", toSocket: "Metallic" },
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
],
}), { imageIds: new Set(["image:Mask"]) });
assert.equal(report.status, "COMPILED");
assert.ok(report.textureBindings.some((binding) => binding.usage === "METALLIC" && binding.imageId === "image:Mask"));
assert.equal(report.material.metallic, 1);
});