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>
This commit is contained in:
@@ -26,6 +26,7 @@ export const SHADER_COMPILE_ALLOWLIST = [
|
||||
"IMAGE_TEXTURE",
|
||||
"NORMAL_MAP",
|
||||
"BUMP",
|
||||
"DISPLACEMENT",
|
||||
"PRINCIPLED",
|
||||
"OUTPUT",
|
||||
] as const;
|
||||
@@ -56,13 +57,18 @@ export interface ShaderCompiledMaterial {
|
||||
emissionColor?: [number, number, number, number];
|
||||
baseColorImageId?: string;
|
||||
normalImageId?: string;
|
||||
displacementImageId?: string;
|
||||
displacementMidlevel?: number;
|
||||
displacementScale?: number;
|
||||
}
|
||||
|
||||
export type ShaderTextureUsage = "BASE_COLOR" | "NORMAL" | "BUMP";
|
||||
export type ShaderTextureUsage = "BASE_COLOR" | "NORMAL" | "BUMP" | "ROUGHNESS" | "METALLIC" | "ALPHA" | "HEIGHT";
|
||||
export interface ShaderTextureBindingIR {
|
||||
imageId: string;
|
||||
usage: ShaderTextureUsage;
|
||||
strength?: number;
|
||||
midlevel?: number;
|
||||
scale?: number;
|
||||
uvMap?: string;
|
||||
uvTransform?: {
|
||||
offset: [number, number];
|
||||
@@ -284,9 +290,43 @@ function boundedMaterialValue(name: string, value: number): boolean {
|
||||
return value >= 0 && value <= 1_000_000;
|
||||
}
|
||||
|
||||
function normalizeShaderGraph(
|
||||
nodes: readonly MaterialNodeIR[],
|
||||
links: readonly MaterialLinkIR[],
|
||||
): { nodes: MaterialNodeIR[]; links: MaterialLinkIR[] } {
|
||||
if (!nodes.some((node) => node.type === "REROUTE" || node.type === "FRAME")) {
|
||||
return { nodes: [...nodes], links: [...links] };
|
||||
}
|
||||
const removed = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (node.type === "FRAME") removed.add(node.id);
|
||||
}
|
||||
const reroutes = new Set(nodes.filter((node) => node.type === "REROUTE").map((node) => node.id));
|
||||
let normalizedLinks = links.filter((link) => !removed.has(link.fromNodeId) && !removed.has(link.toNodeId));
|
||||
const resolve = (nodeId: string, fromSocket: string, depth = 0): { nodeId: string; socket: string } | undefined => {
|
||||
if (depth > SHADER_COMPILE_BUDGET.maxDepth) return undefined;
|
||||
const node = nodes.find((candidate) => candidate.id === nodeId);
|
||||
if (!node) return undefined;
|
||||
if (node.type !== "REROUTE") return { nodeId: node.id, socket: fromSocket };
|
||||
const input = normalizedLinks.find((link) => link.toNodeId === nodeId);
|
||||
if (!input) return undefined;
|
||||
return resolve(input.fromNodeId, input.fromSocket, depth + 1);
|
||||
};
|
||||
normalizedLinks = normalizedLinks.map((link) => {
|
||||
if (!reroutes.has(link.fromNodeId)) return link;
|
||||
const upstream = resolve(link.fromNodeId, link.fromSocket);
|
||||
return upstream ? { ...link, fromNodeId: upstream.nodeId, fromSocket: upstream.socket } : link;
|
||||
}).filter((link) => !reroutes.has(link.fromNodeId) && !reroutes.has(link.toNodeId));
|
||||
return {
|
||||
nodes: nodes.filter((node) => !removed.has(node.id) && !reroutes.has(node.id)),
|
||||
links: normalizedLinks,
|
||||
};
|
||||
}
|
||||
|
||||
export function compileMaterialGraph(material: MaterialIR, context: ShaderCompileContext = {}): ShaderCompileReport {
|
||||
const nodes = material.nodes ?? [];
|
||||
const links = material.links ?? [];
|
||||
const normalized = normalizeShaderGraph(material.nodes ?? [], material.links ?? []);
|
||||
const nodes = normalized.nodes;
|
||||
const links = normalized.links;
|
||||
const graphHash = material.shaderGraphHash ?? (nodes.length > 0 ? fallbackGraphHash(canonicalGraph(material)) : null);
|
||||
const base: ShaderCompileReport = {
|
||||
schemaVersion: SHADER_COMPILE_SCHEMA,
|
||||
@@ -414,6 +454,17 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const linkedScalar = (node: MaterialNodeIR, names: string[]): number | undefined => {
|
||||
for (const name of names) {
|
||||
const link = sourceLink(incoming, node.id, name);
|
||||
if (!link) continue;
|
||||
const source = byId.get(link.fromNodeId);
|
||||
if (source?.type === "VALUE" && source.defaultValue?.length === 1 && finite(source.defaultValue[0])) {
|
||||
return source.defaultValue[0];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const meshUvMap = (node: MaterialNodeIR): string | undefined => {
|
||||
const properties = node.properties as Record<string, unknown> | undefined;
|
||||
const value = properties?.uvMap ?? properties?.uv_map;
|
||||
@@ -430,9 +481,12 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Texture Vector must come from Mapping, UV Map, or Texture Coordinate", `nodes.${imageNode.id}.Vector`));
|
||||
return {};
|
||||
}
|
||||
const offset = socketVector2(source, ["location"]) ?? [0, 0];
|
||||
const scale = socketVector2(source, ["scale"]) ?? [1, 1];
|
||||
const rotation = socketVector2(source, ["rotation"])?.[0] ?? 0;
|
||||
const linkedOffset = linkedScalar(source, ["location"]);
|
||||
const linkedScale = linkedScalar(source, ["scale"]);
|
||||
const linkedRotation = linkedScalar(source, ["rotation"]);
|
||||
const offset: [number, number] = linkedOffset !== undefined ? [linkedOffset, linkedOffset] : socketVector2(source, ["location"]) ?? [0, 0];
|
||||
const scale: [number, number] = linkedScale !== undefined ? [linkedScale, linkedScale] : socketVector2(source, ["scale"]) ?? [1, 1];
|
||||
const rotation = linkedRotation ?? socketVector2(source, ["rotation"])?.[0] ?? 0;
|
||||
const uvLink = sourceLink(incoming, source.id, "Vector");
|
||||
const uvNode = uvLink ? byId.get(uvLink.fromNodeId) : undefined;
|
||||
let uvMap: string | undefined;
|
||||
@@ -442,7 +496,12 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
}
|
||||
return { uvMap, uvTransform: { offset, rotation, scale } };
|
||||
};
|
||||
const registerTexture = (imageNode: MaterialNodeIR, usage: ShaderTextureUsage, strength?: number): ShaderTextureBindingIR | undefined => {
|
||||
const registerTexture = (
|
||||
imageNode: MaterialNodeIR,
|
||||
usage: ShaderTextureUsage,
|
||||
strength?: number,
|
||||
options: { scale?: number; midlevel?: number } = {},
|
||||
): ShaderTextureBindingIR | undefined => {
|
||||
if (!imageNode.imageId) return undefined;
|
||||
if (context.imageIds && !context.imageIds.has(imageNode.imageId)) {
|
||||
base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`));
|
||||
@@ -455,6 +514,8 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
imageId: imageNode.imageId,
|
||||
usage,
|
||||
...(strength !== undefined ? { strength } : {}),
|
||||
...(options.scale !== undefined ? { scale: options.scale } : {}),
|
||||
...(options.midlevel !== undefined ? { midlevel: options.midlevel } : {}),
|
||||
...(uv.uvMap ? { uvMap: uv.uvMap } : {}),
|
||||
...(uv.uvTransform ? { uvTransform: uv.uvTransform } : {}),
|
||||
};
|
||||
@@ -511,7 +572,7 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
if (value !== undefined && finite(value)) evaluated.set(nodeId, value);
|
||||
return value;
|
||||
};
|
||||
const evalColorLink = (link: MaterialLinkIR, depth: number): [number, number, number, number] | undefined => {
|
||||
const evalColorLink = (link: MaterialLinkIR, depth: number, usage: ShaderTextureUsage = "BASE_COLOR"): [number, number, number, number] | undefined => {
|
||||
if (depth > SHADER_COMPILE_BUDGET.maxDepth) return undefined;
|
||||
const node = byId.get(link.fromNodeId);
|
||||
if (!node) return undefined;
|
||||
@@ -519,7 +580,7 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
return [node.defaultValue[0], node.defaultValue[1], node.defaultValue[2], node.defaultValue[3] ?? 1];
|
||||
}
|
||||
if (node.type === "IMAGE_TEXTURE") {
|
||||
registerTexture(node, "BASE_COLOR");
|
||||
registerTexture(node, usage);
|
||||
return undefined;
|
||||
}
|
||||
if (node.type === "MIX") {
|
||||
@@ -527,8 +588,8 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
const aLink = sourceLink(incoming, node.id, "A");
|
||||
const bLink = sourceLink(incoming, node.id, "B");
|
||||
const factor = factorLink ? evalScalar(factorLink.fromNodeId, depth + 1) : socketNumber(node, ["factor"]);
|
||||
const left = aLink ? evalColorLink(aLink, depth + 1) : undefined;
|
||||
const right = bLink ? evalColorLink(bLink, depth + 1) : undefined;
|
||||
const left = aLink ? evalColorLink(aLink, depth + 1, usage) : undefined;
|
||||
const right = bLink ? evalColorLink(bLink, depth + 1, usage) : undefined;
|
||||
if (factor === undefined || !left || !right) {
|
||||
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Mix color inputs must be bounded constants", `nodes.${node.id}`));
|
||||
return undefined;
|
||||
@@ -539,15 +600,15 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
}
|
||||
if (node.type === "SEPARATE_COLOR") {
|
||||
const colorLink = sourceLink(incoming, node.id, "Color");
|
||||
return colorLink ? evalColorLink(colorLink, depth + 1) : undefined;
|
||||
return colorLink ? evalColorLink(colorLink, depth + 1, usage) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const evalScalarLink = (link: MaterialLinkIR, depth: number): number | undefined => {
|
||||
const evalScalarLink = (link: MaterialLinkIR, depth: number, usage: ShaderTextureUsage = "BASE_COLOR"): number | undefined => {
|
||||
const node = byId.get(link.fromNodeId);
|
||||
if (node?.type === "SEPARATE_COLOR") {
|
||||
const colorLink = sourceLink(incoming, node.id, "Color");
|
||||
const color = colorLink ? evalColorLink(colorLink, depth + 1) : undefined;
|
||||
const color = colorLink ? evalColorLink(colorLink, depth + 1, usage) : undefined;
|
||||
if (!color) return undefined;
|
||||
const channel = normalizeSocket(link.fromSocket);
|
||||
return channel === "red" || channel === "r" ? color[0]
|
||||
@@ -562,7 +623,19 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
for (const [socket, field] of [["Roughness", "roughness"], ["Metallic", "metallic"], ["Alpha", "alpha"], ["IOR", "ior"], ["Specular IOR Level", "specularIORLevel"], ["Transmission Weight", "transmissionWeight"], ["Coat Weight", "coatWeight"], ["Coat Roughness", "coatRoughness"], ["Emission Strength", "emissionStrength"]] as const) {
|
||||
const link = sourceLink(incoming, shader.id, socket);
|
||||
if (!link) continue;
|
||||
const value = evalScalarLink(link, 0);
|
||||
const usage: ShaderTextureUsage | undefined = field === "roughness" ? "ROUGHNESS"
|
||||
: field === "metallic" ? "METALLIC"
|
||||
: field === "alpha" ? "ALPHA" : undefined;
|
||||
const source = byId.get(link.fromNodeId);
|
||||
const colorLink = source?.type === "SEPARATE_COLOR" ? sourceLink(incoming, source.id, "Color") : undefined;
|
||||
const imageNode = source?.type === "IMAGE_TEXTURE" ? source
|
||||
: colorLink ? byId.get(colorLink.fromNodeId) : undefined;
|
||||
if (imageNode?.type === "IMAGE_TEXTURE" && usage) {
|
||||
registerTexture(imageNode, usage);
|
||||
(compiled as unknown as Record<string, unknown>)[field] = 1;
|
||||
continue;
|
||||
}
|
||||
const value = evalScalarLink(link, 0, usage ?? "BASE_COLOR");
|
||||
const normalized = normalizeSocket(field);
|
||||
if (value === undefined || !boundedMaterialValue(normalized, value)) base.issues.push(issue("SHADER_INVALID_GRAPH", `${socket} input is not a finite bounded constant`, `links.${socket}`));
|
||||
else (compiled as unknown as Record<string, unknown>)[field] = value;
|
||||
@@ -600,6 +673,33 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
}
|
||||
else base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Principled Normal must be driven by Normal Map or Bump", "links.Normal"));
|
||||
}
|
||||
const displacementLink = output ? sourceLink(incoming, output.id, "Displacement") : undefined;
|
||||
if (displacementLink) {
|
||||
const displacementNode = byId.get(displacementLink.fromNodeId);
|
||||
if (displacementNode?.type === "DISPLACEMENT") {
|
||||
const heightLink = sourceLink(incoming, displacementNode.id, "Height");
|
||||
const imageNode = heightLink ? byId.get(heightLink.fromNodeId) : undefined;
|
||||
if (!heightLink || imageNode?.type !== "IMAGE_TEXTURE" || !imageNode.imageId) {
|
||||
base.issues.push(issue("SHADER_INVALID_GRAPH", "Displacement requires an Image Texture Height input", `nodes.${displacementNode.id}`));
|
||||
} else {
|
||||
const scaleLink = sourceLink(incoming, displacementNode.id, "Scale");
|
||||
const midlevelLink = sourceLink(incoming, displacementNode.id, "Midlevel");
|
||||
const scale = (scaleLink ? evalScalarLink(scaleLink, 0) : socketNumber(displacementNode, ["scale"])) ?? 0.01;
|
||||
const midlevel = (midlevelLink ? evalScalarLink(midlevelLink, 0) : socketNumber(displacementNode, ["midlevel"])) ?? 0.5;
|
||||
if (scale === undefined || !Number.isFinite(scale) || scale < 0 ||
|
||||
midlevel === undefined || !Number.isFinite(midlevel) || midlevel < 0 || midlevel > 1) {
|
||||
base.issues.push(issue("SHADER_INVALID_GRAPH", "Displacement Scale/Midlevel are invalid", `nodes.${displacementNode.id}`));
|
||||
} else {
|
||||
registerTexture(imageNode, "HEIGHT", undefined, { scale, midlevel });
|
||||
compiled.displacementImageId = imageNode.imageId;
|
||||
compiled.displacementScale = scale;
|
||||
compiled.displacementMidlevel = midlevel;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Material Output Displacement must be driven by a Displacement node", "links.Displacement"));
|
||||
}
|
||||
}
|
||||
}
|
||||
const supportedLink = (link: MaterialLinkIR): boolean => {
|
||||
const from = byId.get(link.fromNodeId);
|
||||
@@ -607,12 +707,21 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
if (!from || !to) return false;
|
||||
const fromSocket = normalizeSocket(link.fromSocket);
|
||||
const toSocket = normalizeSocket(link.toSocket);
|
||||
if (to.type === "OUTPUT") return from.type === "PRINCIPLED" && fromSocket === "bsdf" && toSocket === "surface";
|
||||
if (to.type === "OUTPUT") {
|
||||
if (from.type === "PRINCIPLED" && fromSocket === "bsdf" && toSocket === "surface") return true;
|
||||
return from.type === "DISPLACEMENT" && fromSocket === "displacement" && toSocket === "displacement";
|
||||
}
|
||||
if (to.type === "DISPLACEMENT") {
|
||||
if (toSocket === "height") return from.type === "IMAGE_TEXTURE" && fromSocket === "color";
|
||||
if (toSocket === "scale" || toSocket === "midlevel") return from.type === "VALUE" || from.type === "MATH";
|
||||
return false;
|
||||
}
|
||||
if (to.type === "PRINCIPLED") {
|
||||
if (toSocket === "basecolor") {
|
||||
return (from.type === "RGB" || from.type === "MIX" || from.type === "IMAGE_TEXTURE" || from.type === "SEPARATE_COLOR") && (fromSocket === "color" || fromSocket === "result");
|
||||
}
|
||||
if (toSocket === "normal") return (from.type === "NORMAL_MAP" || from.type === "BUMP") && fromSocket === "normal";
|
||||
if (targetScalarSocket(toSocket) && from.type === "IMAGE_TEXTURE") return fromSocket === "color";
|
||||
return targetScalarSocket(toSocket) && isScalarSource(from) && (from.type !== "MATH" || fromSocket === "value");
|
||||
}
|
||||
if (to.type === "NORMAL_MAP") return from.type === "IMAGE_TEXTURE" && fromSocket === "color" && toSocket === "color";
|
||||
@@ -626,6 +735,9 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
);
|
||||
}
|
||||
if (to.type === "MAPPING") {
|
||||
if (toSocket === "scale" || toSocket === "rotation" || toSocket === "location") {
|
||||
return from.type === "VALUE" || from.type === "MATH";
|
||||
}
|
||||
return toSocket === "vector" && (
|
||||
(from.type === "UV_MAP" && (fromSocket === "uv" || fromSocket === "uvmap")) ||
|
||||
(from.type === "TEX_COORD" && fromSocket === "uv")
|
||||
@@ -636,7 +748,7 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
|
||||
if (toSocket === "a" || toSocket === "b") return isScalarSource(from) || from.type === "RGB" || from.type === "IMAGE_TEXTURE";
|
||||
return false;
|
||||
}
|
||||
if (to.type === "SEPARATE_COLOR") return toSocket === "color" && from.type === "RGB";
|
||||
if (to.type === "SEPARATE_COLOR") return toSocket === "color" && (from.type === "RGB" || (from.type === "IMAGE_TEXTURE" && fromSocket === "color"));
|
||||
return false;
|
||||
};
|
||||
for (const [index, link] of links.entries()) if (!supportedLink(link)) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `Shader link is outside the M10-07 compiled closure: ${link.fromNodeId}.${link.fromSocket} -> ${link.toNodeId}.${link.toSocket}`, `links.${index}`));
|
||||
|
||||
Reference in New Issue
Block a user