- 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>
781 lines
39 KiB
TypeScript
781 lines
39 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
import type { MaterialIR, MaterialLinkIR, MaterialNodeIR } from "./scene-ir";
|
|
import type { ShaderGraphIR } from "./shader-graph";
|
|
|
|
export const SHADER_COMPILE_SCHEMA = 1 as const;
|
|
export const SHADER_COMPILE_TASK = "M10-07" as const;
|
|
export const SHADER_COMPILE_BACKEND = "WEBGL2_THREE_PHYSICAL" as const;
|
|
export type ShaderCompileBackend = typeof SHADER_COMPILE_BACKEND;
|
|
export type ShaderTextureColorSpace = "SRGB" | "NON_COLOR" | "LINEAR";
|
|
|
|
/**
|
|
* This is deliberately smaller than the Main writer allowlist. RGB and Value are
|
|
* constants used to feed the declared Math/Principled closure; no arbitrary GLSL
|
|
* or unknown Blender node can enter the browser material path.
|
|
*/
|
|
export const SHADER_COMPILE_ALLOWLIST = [
|
|
"RGB",
|
|
"VALUE",
|
|
"MIX",
|
|
"MATH",
|
|
"MAPPING",
|
|
"TEX_COORD",
|
|
"UV_MAP",
|
|
"SEPARATE_COLOR",
|
|
"OBJECT_INFO",
|
|
"IMAGE_TEXTURE",
|
|
"NORMAL_MAP",
|
|
"BUMP",
|
|
"DISPLACEMENT",
|
|
"PRINCIPLED",
|
|
"OUTPUT",
|
|
] as const;
|
|
|
|
export const SHADER_COMPILE_BUDGET = {
|
|
maxNodes: 128,
|
|
maxLinks: 512,
|
|
maxDepth: 64,
|
|
maxTextures: 16,
|
|
maxIdentifierBytes: 256,
|
|
maxNameBytes: 1_024,
|
|
} as const;
|
|
|
|
type CompileNodeType = typeof SHADER_COMPILE_ALLOWLIST[number];
|
|
type Issue = { code: ErrorCode; message: string; path?: string };
|
|
|
|
export interface ShaderCompiledMaterial {
|
|
baseColor: [number, number, number, number];
|
|
roughness: number;
|
|
metallic: number;
|
|
alpha: number;
|
|
ior: number;
|
|
specularIORLevel?: number;
|
|
transmissionWeight?: number;
|
|
coatWeight?: number;
|
|
coatRoughness?: number;
|
|
emissionStrength?: number;
|
|
emissionColor?: [number, number, number, number];
|
|
baseColorImageId?: string;
|
|
normalImageId?: string;
|
|
displacementImageId?: string;
|
|
displacementMidlevel?: number;
|
|
displacementScale?: number;
|
|
}
|
|
|
|
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];
|
|
rotation: number;
|
|
scale: [number, number];
|
|
};
|
|
}
|
|
|
|
export interface ShaderCompileReport {
|
|
schemaVersion: typeof SHADER_COMPILE_SCHEMA;
|
|
taskId: typeof SHADER_COMPILE_TASK;
|
|
backend: typeof SHADER_COMPILE_BACKEND;
|
|
status: "COMPILED" | "BLOCKED";
|
|
materialId: string;
|
|
graphHash: string | null;
|
|
compileKey: string | null;
|
|
nodeOrder: string[];
|
|
compiledNodeTypes: CompileNodeType[];
|
|
textureBindings: ShaderTextureBindingIR[];
|
|
instructions: Array<{ nodeId: string; type: CompileNodeType; operation?: string }>;
|
|
material?: ShaderCompiledMaterial;
|
|
issues: Issue[];
|
|
}
|
|
|
|
export interface ShaderCompileContext {
|
|
imageIds?: ReadonlySet<string>;
|
|
blockedImageIds?: ReadonlySet<string>;
|
|
rendererBackend?: string;
|
|
textureIdentities?: ReadonlyMap<string, {
|
|
assetId?: string;
|
|
sha256?: string;
|
|
colorSpace?: ShaderTextureColorSpace;
|
|
}>;
|
|
}
|
|
|
|
export interface ShaderCompileKeyInput {
|
|
graphHash: string | null;
|
|
rendererBackend: string;
|
|
textures: ReadonlyArray<{
|
|
imageId: string;
|
|
usage: ShaderTextureUsage;
|
|
assetId: string | null;
|
|
sha256: string | null;
|
|
colorSpace: ShaderTextureColorSpace;
|
|
}>;
|
|
}
|
|
|
|
export function compileShaderGraph(
|
|
graph: ShaderGraphIR,
|
|
baseMaterial?: MaterialIR,
|
|
context: ShaderCompileContext = {},
|
|
): ShaderCompileReport {
|
|
const socketName = new Map<string, string>();
|
|
for (const node of graph.nodes) {
|
|
for (const socket of node.sockets) socketName.set(`${node.id}:${socket.id}`, socket.name);
|
|
}
|
|
const nodes: MaterialNodeIR[] = graph.nodes.map((node) => {
|
|
const outputDefault = node.sockets.find((socket) => socket.direction === "OUTPUT")?.defaultValue;
|
|
const defaultValue = typeof outputDefault === "number" ? [outputDefault]
|
|
: Array.isArray(outputDefault) && outputDefault.every(finite) ? [...outputDefault] : undefined;
|
|
return {
|
|
id: node.id,
|
|
type: node.type === "MATERIAL_OUTPUT" ? "OUTPUT" : node.type as MaterialNodeIR["type"],
|
|
name: node.name,
|
|
imageId: node.imageId,
|
|
defaultValue,
|
|
properties: node.type === "MATH" ? {
|
|
operation: node.properties?.operation as "ADD" | "SUBTRACT" | "MULTIPLY" | "DIVIDE" | "MINIMUM" | "MAXIMUM" | undefined,
|
|
} : undefined,
|
|
};
|
|
});
|
|
const material: MaterialIR = {
|
|
id: graph.materialId,
|
|
name: baseMaterial?.name ?? graph.materialId,
|
|
baseColor: baseMaterial?.baseColor ?? [0.8, 0.8, 0.8, 1],
|
|
roughness: baseMaterial?.roughness ?? 0.5,
|
|
metallic: baseMaterial?.metallic ?? 0,
|
|
emissionColor: baseMaterial?.emissionColor ?? [0, 0, 0, 1],
|
|
alpha: baseMaterial?.alpha ?? 1,
|
|
ior: baseMaterial?.ior ?? 1.45,
|
|
specularIORLevel: baseMaterial?.specularIORLevel,
|
|
transmissionWeight: baseMaterial?.transmissionWeight,
|
|
coatWeight: baseMaterial?.coatWeight,
|
|
coatRoughness: baseMaterial?.coatRoughness,
|
|
emissionStrength: baseMaterial?.emissionStrength,
|
|
nodes,
|
|
links: graph.links.map((link) => ({
|
|
fromNodeId: link.fromNodeId,
|
|
fromSocket: socketName.get(`${link.fromNodeId}:${link.fromSocketId}`) ?? link.fromSocketId,
|
|
toNodeId: link.toNodeId,
|
|
toSocket: socketName.get(`${link.toNodeId}:${link.toSocketId}`) ?? link.toSocketId,
|
|
})),
|
|
};
|
|
return compileMaterialGraph(material, context);
|
|
}
|
|
|
|
function finite(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
}
|
|
|
|
function finiteColor(value: unknown): value is number[] {
|
|
return Array.isArray(value) && (value.length === 3 || value.length === 4) && value.every((item) => finite(item) && item >= 0 && item <= 1);
|
|
}
|
|
|
|
function normalizeSocket(value: string): string {
|
|
return value.toLowerCase().replace(/[ _-]/g, "");
|
|
}
|
|
|
|
function scalarSocket(value: string): boolean {
|
|
const normalized = normalizeSocket(value);
|
|
return normalized === "value" || normalized === "value001" || normalized === "a" || normalized === "b" || normalized === "factor";
|
|
}
|
|
|
|
function canonicalGraph(material: MaterialIR): string {
|
|
return JSON.stringify({
|
|
schemaVersion: 1,
|
|
materialId: material.id,
|
|
nodes: (material.nodes ?? []).map((node) => ({
|
|
id: node.id,
|
|
type: node.type,
|
|
name: node.name,
|
|
imageId: node.imageId ?? null,
|
|
defaultValue: node.defaultValue ?? null,
|
|
properties: node.properties ?? null,
|
|
...(node.sockets?{inputs:node.sockets.filter(socket=>socket.direction==='INPUT').map(socket=>({id:socket.id,value:socket.value??null}))}:{}),
|
|
})),
|
|
links: (material.links ?? []).map((link) => ({
|
|
fromNodeId: link.fromNodeId,
|
|
fromSocket: link.fromSocket,
|
|
toNodeId: link.toNodeId,
|
|
toSocket: link.toSocket,
|
|
})),
|
|
});
|
|
}
|
|
|
|
// A synchronous SHA-256 keeps viewport material creation deterministic without
|
|
// making every Three.js material allocation asynchronous. Native snapshots carry
|
|
// their own SHA-256; this is the fallback for protocol/unit fixtures.
|
|
function fallbackGraphHash(value: string): string {
|
|
const bytes = new TextEncoder().encode(value);
|
|
const words = new Uint32Array(64);
|
|
const constants = [
|
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
];
|
|
const state = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
|
const length = ((bytes.length + 9 + 63) >> 6) << 6;
|
|
const padded = new Uint8Array(length);
|
|
padded.set(bytes);
|
|
padded[bytes.length] = 0x80;
|
|
const bitLength = bytes.length * 8;
|
|
new DataView(padded.buffer).setUint32(length - 4, bitLength >>> 0, false);
|
|
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
for (let index = 0; index < 16; index++) words[index] = new DataView(padded.buffer, offset + index * 4, 4).getUint32(0, false);
|
|
for (let index = 16; index < 64; index++) {
|
|
const a = words[index - 15];
|
|
const b = words[index - 2];
|
|
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
|
|
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
|
|
words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
|
|
}
|
|
let [a, b, c, d, e, f, g, h] = state;
|
|
for (let index = 0; index < 64; index++) {
|
|
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
|
|
const choose = (e & f) ^ (~e & g);
|
|
const temp1 = (h + S1 + choose + constants[index] + words[index]) >>> 0;
|
|
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
|
|
const majority = (a & b) ^ (a & c) ^ (b & c);
|
|
const temp2 = (S0 + majority) >>> 0;
|
|
h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;
|
|
}
|
|
state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0; state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;
|
|
state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0; state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;
|
|
}
|
|
return Array.from(state, (word) => word.toString(16).padStart(8, "0")).join("");
|
|
}
|
|
|
|
function issue(code: ErrorCode, message: string, path?: string): Issue {
|
|
return { code, message, path };
|
|
}
|
|
|
|
export function createShaderCompileKey(input: ShaderCompileKeyInput): string {
|
|
const textures = [...input.textures].sort((left, right) =>
|
|
`${left.usage}:${left.imageId}`.localeCompare(`${right.usage}:${right.imageId}`));
|
|
return fallbackGraphHash(JSON.stringify({
|
|
schemaVersion: SHADER_COMPILE_SCHEMA,
|
|
graphHash: input.graphHash,
|
|
rendererBackend: input.rendererBackend,
|
|
textures,
|
|
}));
|
|
}
|
|
|
|
function linkKey(nodeId: string, socket: string): string {
|
|
return `${nodeId}:${normalizeSocket(socket)}`;
|
|
}
|
|
|
|
function sourceLink(incoming: ReadonlyMap<string, MaterialLinkIR>, nodeId: string, socket: string): MaterialLinkIR | undefined {
|
|
return incoming.get(linkKey(nodeId, socket));
|
|
}
|
|
|
|
function isScalarSource(node: MaterialNodeIR | undefined): boolean {
|
|
return node?.type === "VALUE" || node?.type === "MATH" || node?.type === "MIX" || node?.type === "SEPARATE_COLOR";
|
|
}
|
|
|
|
function targetScalarSocket(socket: string): boolean {
|
|
return new Set(["roughness", "metallic", "alpha", "ior", "speculariorlevel", "transmissionweight", "coatweight", "coatroughness", "emissionstrength"]).has(normalizeSocket(socket));
|
|
}
|
|
|
|
function boundedMaterialValue(name: string, value: number): boolean {
|
|
if (!finite(value)) return false;
|
|
if (["roughness", "metallic", "alpha", "speculariorlevel", "transmissionweight", "coatweight", "coatroughness"].includes(name)) return value >= 0 && value <= 1;
|
|
if (name === "ior") return value >= 1 && value <= 2.333;
|
|
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 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,
|
|
taskId: SHADER_COMPILE_TASK,
|
|
backend: SHADER_COMPILE_BACKEND,
|
|
status: "BLOCKED",
|
|
materialId: material.id,
|
|
graphHash,
|
|
nodeOrder: [],
|
|
compileKey: null,
|
|
compiledNodeTypes: [],
|
|
textureBindings: [],
|
|
instructions: [],
|
|
issues: [],
|
|
};
|
|
if (nodes.length === 0) return {
|
|
...base,
|
|
status: "COMPILED",
|
|
issues: [],
|
|
material: {
|
|
baseColor: material.baseColor,
|
|
roughness: material.roughness,
|
|
metallic: material.metallic,
|
|
alpha: material.alpha,
|
|
ior: material.ior,
|
|
specularIORLevel: material.specularIORLevel,
|
|
transmissionWeight: material.transmissionWeight,
|
|
coatWeight: material.coatWeight,
|
|
coatRoughness: material.coatRoughness,
|
|
emissionStrength: material.emissionStrength,
|
|
emissionColor: material.emissionColor,
|
|
},
|
|
};
|
|
if (nodes.length > SHADER_COMPILE_BUDGET.maxNodes) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compiler node budget", "nodes"));
|
|
if (links.length > SHADER_COMPILE_BUDGET.maxLinks) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compiler link budget", "links"));
|
|
if (base.issues.length > 0) return base;
|
|
const rendererBackend = context.rendererBackend ?? SHADER_COMPILE_BACKEND;
|
|
if (rendererBackend !== SHADER_COMPILE_BACKEND) {
|
|
base.issues.push(issue("CAPABILITY_MISSING", `Shader backend is not available: ${rendererBackend}`, "rendererBackend"));
|
|
return base;
|
|
}
|
|
if (material.shaderGraphHash !== undefined && !/^[0-9a-f]{64}$/.test(material.shaderGraphHash)) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader graph hash is not a lowercase SHA-256 digest", "shaderGraphHash"));
|
|
if (!finiteColor(material.baseColor) || !finiteColor(material.emissionColor)) base.issues.push(issue("SHADER_INVALID_GRAPH", "Material color defaults are invalid", "material"));
|
|
for (const [field, value] of [["roughness", material.roughness], ["metallic", material.metallic], ["alpha", material.alpha], ["ior", material.ior]] as const) {
|
|
if (!boundedMaterialValue(normalizeSocket(field), value)) base.issues.push(issue("SHADER_INVALID_GRAPH", `Material ${field} default is outside the compiler range`, field));
|
|
}
|
|
const byId = new Map<string, MaterialNodeIR>();
|
|
for (const [index, node] of nodes.entries()) {
|
|
if (new TextEncoder().encode(node.id).byteLength === 0 || new TextEncoder().encode(node.id).byteLength > SHADER_COMPILE_BUDGET.maxIdentifierBytes) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader node ID is empty or oversized", `nodes.${index}.id`));
|
|
if (new TextEncoder().encode(node.name).byteLength === 0 || new TextEncoder().encode(node.name).byteLength > SHADER_COMPILE_BUDGET.maxNameBytes) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader node name is empty or oversized", `nodes.${index}.name`));
|
|
if (byId.has(node.id)) base.issues.push(issue("SHADER_INVALID_GRAPH", `duplicate shader node ID: ${node.id}`, `nodes.${index}.id`));
|
|
byId.set(node.id, node);
|
|
if (!(SHADER_COMPILE_ALLOWLIST as readonly string[]).includes(node.type)) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `shader node ${node.type} is outside the M10-07 compiler allowlist`, `nodes.${index}.type`));
|
|
if (node.type === "MATH" && !["ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "MINIMUM", "MAXIMUM"].includes(node.properties?.operation ?? "")) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Math operation is outside the M10-07 compiler allowlist", `nodes.${index}.properties.operation`));
|
|
}
|
|
const incoming = new Map<string, MaterialLinkIR>();
|
|
const edges = new Map<string, string[]>();
|
|
for (const [index, link] of links.entries()) {
|
|
const from = byId.get(link.fromNodeId);
|
|
const to = byId.get(link.toNodeId);
|
|
if (!from || !to) {
|
|
base.issues.push(issue("SHADER_INVALID_GRAPH", "shader link references an unknown node", `links.${index}`));
|
|
continue;
|
|
}
|
|
const targetKey = linkKey(to.id, link.toSocket);
|
|
if (incoming.has(targetKey)) base.issues.push(issue("SHADER_INVALID_GRAPH", `shader input has more than one link: ${targetKey}`, `links.${index}`));
|
|
incoming.set(targetKey, link);
|
|
edges.set(from.id, [...(edges.get(from.id) ?? []), to.id]);
|
|
}
|
|
const state = new Map<string, 0 | 1 | 2>();
|
|
const order: string[] = [];
|
|
const visit = (id: string, depth: number): void => {
|
|
if (depth > SHADER_COMPILE_BUDGET.maxDepth) {
|
|
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compile depth", "links"));
|
|
return;
|
|
}
|
|
const current = state.get(id) ?? 0;
|
|
if (current === 2) return;
|
|
if (current === 1) {
|
|
base.issues.push(issue("SHADER_GRAPH_CYCLE", "Shader graph contains a cycle", "links"));
|
|
return;
|
|
}
|
|
state.set(id, 1);
|
|
for (const next of edges.get(id) ?? []) visit(next, depth + 1);
|
|
state.set(id, 2);
|
|
order.push(id);
|
|
};
|
|
for (const node of nodes) visit(node.id, 0);
|
|
base.nodeOrder = [...new Set(order.reverse())];
|
|
const outputs = nodes.filter((node) => node.type === "OUTPUT");
|
|
const principled = nodes.filter((node) => node.type === "PRINCIPLED");
|
|
if (outputs.length !== 1 || principled.length !== 1) base.issues.push(issue("SHADER_INVALID_GRAPH", "Compiled Shader graph requires exactly one Principled and one Output node", "nodes"));
|
|
const output = outputs[0];
|
|
const shader = principled[0];
|
|
if (output && shader && !sourceLink(incoming, output.id, "Surface")) base.issues.push(issue("SHADER_INVALID_GRAPH", "Material Output Surface is not connected", "links"));
|
|
const textures = new Map<string, ShaderTextureBindingIR>();
|
|
const compiled: ShaderCompiledMaterial = {
|
|
baseColor: material.baseColor,
|
|
roughness: material.roughness,
|
|
metallic: material.metallic,
|
|
alpha: material.alpha,
|
|
ior: material.ior,
|
|
specularIORLevel: material.specularIORLevel,
|
|
transmissionWeight: material.transmissionWeight,
|
|
coatWeight: material.coatWeight,
|
|
coatRoughness: material.coatRoughness,
|
|
emissionStrength: material.emissionStrength,
|
|
emissionColor: material.emissionColor,
|
|
};
|
|
const socketNumber = (node: MaterialNodeIR, names: string[]): number | undefined => {
|
|
for (const socket of node.sockets ?? []) {
|
|
if (socket.direction !== "INPUT") continue;
|
|
if (!names.includes(normalizeSocket(socket.id)) && !names.includes(normalizeSocket(socket.name))) continue;
|
|
if (finite(socket.value)) return socket.value;
|
|
}
|
|
return undefined;
|
|
};
|
|
const socketVector2 = (node: MaterialNodeIR, names: string[]): [number, number] | undefined => {
|
|
for (const socket of node.sockets ?? []) {
|
|
if (socket.direction !== "INPUT") continue;
|
|
if (!names.includes(normalizeSocket(socket.id)) && !names.includes(normalizeSocket(socket.name))) continue;
|
|
if (Array.isArray(socket.value) && socket.value.length >= 2 && finite(socket.value[0]) && finite(socket.value[1])) {
|
|
return [socket.value[0], socket.value[1]];
|
|
}
|
|
}
|
|
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;
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
};
|
|
const textureUv = (imageNode: MaterialNodeIR): { uvMap?: string; uvTransform?: ShaderTextureBindingIR["uvTransform"] } => {
|
|
const vectorLink = sourceLink(incoming, imageNode.id, "Vector");
|
|
if (!vectorLink) return {};
|
|
const source = byId.get(vectorLink.fromNodeId);
|
|
if (!source) return {};
|
|
if (source.type === "UV_MAP") return { uvMap: meshUvMap(source) };
|
|
if (source.type === "TEX_COORD") return {};
|
|
if (source.type !== "MAPPING") {
|
|
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Texture Vector must come from Mapping, UV Map, or Texture Coordinate", `nodes.${imageNode.id}.Vector`));
|
|
return {};
|
|
}
|
|
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;
|
|
if (uvNode?.type === "UV_MAP") uvMap = meshUvMap(uvNode);
|
|
else if (uvNode && uvNode.type !== "TEX_COORD") {
|
|
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Mapping Vector must come from UV Map or Texture Coordinate", `nodes.${source.id}.Vector`));
|
|
}
|
|
return { uvMap, uvTransform: { offset, rotation, scale } };
|
|
};
|
|
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`));
|
|
}
|
|
if (context.blockedImageIds?.has(imageNode.imageId)) {
|
|
base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`));
|
|
}
|
|
const uv = textureUv(imageNode);
|
|
const binding: ShaderTextureBindingIR = {
|
|
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 } : {}),
|
|
};
|
|
textures.set(`${usage}:${imageNode.imageId}`, binding);
|
|
if (usage === "BASE_COLOR") compiled.baseColorImageId = imageNode.imageId;
|
|
return binding;
|
|
};
|
|
const evaluated = new Map<string, number>();
|
|
const evaluating = new Set<string>();
|
|
const evalScalar = (nodeId: string, depth: number): number | undefined => {
|
|
if (depth > SHADER_COMPILE_BUDGET.maxDepth) return undefined;
|
|
const cached = evaluated.get(nodeId);
|
|
if (cached !== undefined) return cached;
|
|
if (evaluating.has(nodeId)) return undefined;
|
|
const node = byId.get(nodeId);
|
|
if (!node) return undefined;
|
|
evaluating.add(nodeId);
|
|
let value: number | undefined;
|
|
if (node.type === "VALUE") {
|
|
value = node.defaultValue?.length === 1 && finite(node.defaultValue[0]) ? node.defaultValue[0] : undefined;
|
|
}
|
|
else if (node.type === "MATH") {
|
|
const first = sourceLink(incoming, node.id, "Value") ?? sourceLink(incoming, node.id, "A");
|
|
const second = sourceLink(incoming, node.id, "Value_001") ?? sourceLink(incoming, node.id, "B");
|
|
const left = first ? evalScalar(first.fromNodeId, depth + 1) : socketNumber(node, ["value"]);
|
|
const right = second ? evalScalar(second.fromNodeId, depth + 1) : socketNumber(node, ["value001"]);
|
|
if (left !== undefined && right !== undefined) {
|
|
switch (node.properties?.operation) {
|
|
case "ADD": value = left + right; break;
|
|
case "SUBTRACT": value = left - right; break;
|
|
case "MULTIPLY": value = left * right; break;
|
|
case "DIVIDE": value = Math.abs(right) > 1e-12 ? left / right : undefined; break;
|
|
case "MINIMUM": value = Math.min(left, right); break;
|
|
case "MAXIMUM": value = Math.max(left, right); break;
|
|
}
|
|
}
|
|
}
|
|
else if (node.type === "MIX") {
|
|
const factorLink = sourceLink(incoming, node.id, "Factor");
|
|
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 ? evalScalar(aLink.fromNodeId, depth + 1) : socketNumber(node, ["a"]);
|
|
const right = bLink ? evalScalar(bLink.fromNodeId, depth + 1) : socketNumber(node, ["b"]);
|
|
if (factor !== undefined && left !== undefined && right !== undefined) {
|
|
const blend = String(node.properties?.blendType ?? "MIX");
|
|
if (blend === "MIX") value = left * (1 - factor) + right * factor;
|
|
else if (blend === "ADD") value = left + right * factor;
|
|
else if (blend === "MULTIPLY") value = left * (1 - factor + right * factor);
|
|
else base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `Mix blend ${blend} is not a compiled scalar constant`, `nodes.${node.id}`));
|
|
}
|
|
}
|
|
evaluating.delete(nodeId);
|
|
if (value !== undefined && finite(value)) evaluated.set(nodeId, value);
|
|
return value;
|
|
};
|
|
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;
|
|
if (node.type === "RGB" && finiteColor(node.defaultValue)) {
|
|
return [node.defaultValue[0], node.defaultValue[1], node.defaultValue[2], node.defaultValue[3] ?? 1];
|
|
}
|
|
if (node.type === "IMAGE_TEXTURE") {
|
|
registerTexture(node, usage);
|
|
return undefined;
|
|
}
|
|
if (node.type === "MIX") {
|
|
const factorLink = sourceLink(incoming, node.id, "Factor");
|
|
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, 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;
|
|
}
|
|
const blend = String(node.properties?.blendType ?? "MIX");
|
|
const mix = (a: number, b: number): number => blend === "MULTIPLY" ? a * b : blend === "ADD" ? Math.min(1, a + b) : a * (1 - factor) + b * factor;
|
|
return [mix(left[0], right[0]), mix(left[1], right[1]), mix(left[2], right[2]), mix(left[3], right[3])];
|
|
}
|
|
if (node.type === "SEPARATE_COLOR") {
|
|
const colorLink = sourceLink(incoming, node.id, "Color");
|
|
return colorLink ? evalColorLink(colorLink, depth + 1, usage) : undefined;
|
|
}
|
|
return 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, usage) : undefined;
|
|
if (!color) return undefined;
|
|
const channel = normalizeSocket(link.fromSocket);
|
|
return channel === "red" || channel === "r" ? color[0]
|
|
: channel === "green" || channel === "g" ? color[1]
|
|
: channel === "blue" || channel === "b" ? color[2]
|
|
: channel === "alpha" || channel === "a" ? color[3]
|
|
: undefined;
|
|
}
|
|
return evalScalar(link.fromNodeId, depth);
|
|
};
|
|
if (shader) {
|
|
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 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;
|
|
}
|
|
const colorLink = sourceLink(incoming, shader.id, "Base Color");
|
|
if (colorLink) {
|
|
const source = byId.get(colorLink.fromNodeId);
|
|
const color = evalColorLink(colorLink, 0);
|
|
if (color) compiled.baseColor = color;
|
|
else if (source?.type !== "IMAGE_TEXTURE" && source?.type !== "MIX") base.issues.push(issue("SHADER_INVALID_GRAPH", "Base Color must be an RGB constant, Mix constant, or Image Texture", "links.BaseColor"));
|
|
}
|
|
const normalLink = sourceLink(incoming, shader.id, "Normal");
|
|
if (normalLink) {
|
|
const normalNode = byId.get(normalLink.fromNodeId);
|
|
if (normalNode?.type === "NORMAL_MAP") {
|
|
const imageLink = sourceLink(incoming, normalNode.id, "Color");
|
|
const imageNode = imageLink ? byId.get(imageLink.fromNodeId) : undefined;
|
|
if (!imageLink || imageNode?.type !== "IMAGE_TEXTURE" || !imageNode.imageId) base.issues.push(issue("SHADER_INVALID_GRAPH", "Normal Map requires an Image Texture Color input", `nodes.${normalNode.id}`));
|
|
else {
|
|
const strengthLink = sourceLink(incoming, normalNode.id, "Strength");
|
|
const strength = strengthLink ? evalScalarLink(strengthLink, 0) : socketNumber(normalNode, ["strength"]);
|
|
registerTexture(imageNode, "NORMAL", strength);
|
|
compiled.normalImageId = imageNode.imageId;
|
|
}
|
|
}
|
|
else if (normalNode?.type === "BUMP") {
|
|
const heightLink = sourceLink(incoming, normalNode.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", "Bump requires an Image Texture Height input", `nodes.${normalNode.id}`));
|
|
else {
|
|
const strengthLink = sourceLink(incoming, normalNode.id, "Strength");
|
|
const strength = strengthLink ? evalScalarLink(strengthLink, 0) : socketNumber(normalNode, ["strength"]);
|
|
registerTexture(imageNode, "BUMP", strength);
|
|
}
|
|
}
|
|
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);
|
|
const to = byId.get(link.toNodeId);
|
|
if (!from || !to) return false;
|
|
const fromSocket = normalizeSocket(link.fromSocket);
|
|
const toSocket = normalizeSocket(link.toSocket);
|
|
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";
|
|
if (to.type === "BUMP") return from.type === "IMAGE_TEXTURE" && fromSocket === "color" && toSocket === "height";
|
|
if (to.type === "MATH") return isScalarSource(from) && scalarSocket(toSocket) && fromSocket === "value";
|
|
if (to.type === "IMAGE_TEXTURE") {
|
|
return toSocket === "vector" && (
|
|
(from.type === "MAPPING" && fromSocket === "vector") ||
|
|
(from.type === "UV_MAP" && (fromSocket === "uv" || fromSocket === "uvmap")) ||
|
|
(from.type === "TEX_COORD" && fromSocket === "uv")
|
|
);
|
|
}
|
|
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")
|
|
);
|
|
}
|
|
if (to.type === "MIX") {
|
|
if (toSocket === "factor") return isScalarSource(from);
|
|
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" || (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}`));
|
|
base.textureBindings = [...textures.values()];
|
|
const textureKeys = base.textureBindings.map((binding) => {
|
|
const identity = context.textureIdentities?.get(binding.imageId);
|
|
const sha256 = identity?.sha256 ?? null;
|
|
if (sha256 !== null && !/^[0-9a-f]{64}$/.test(sha256)) {
|
|
base.issues.push(issue("SHADER_INVALID_GRAPH", `texture SHA-256 is invalid: ${binding.imageId}`, `textures.${binding.imageId}.sha256`));
|
|
}
|
|
return {
|
|
imageId: binding.imageId,
|
|
usage: binding.usage,
|
|
assetId: identity?.assetId ?? null,
|
|
sha256,
|
|
colorSpace: identity?.colorSpace ?? (binding.usage === "BASE_COLOR" ? "SRGB" : "NON_COLOR"),
|
|
};
|
|
});
|
|
const parameterHash=nodes.some(node=>node.sockets)?fallbackGraphHash(JSON.stringify({graphHash,parameters:canonicalGraph(material)})):graphHash;
|
|
base.compileKey = createShaderCompileKey({ graphHash:parameterHash, rendererBackend, textures: textureKeys });
|
|
if (base.textureBindings.length > SHADER_COMPILE_BUDGET.maxTextures) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded texture binding budget", "nodes"));
|
|
base.compiledNodeTypes = [...new Set(nodes.map((node) => node.type).filter((type): type is CompileNodeType => (SHADER_COMPILE_ALLOWLIST as readonly string[]).includes(type)))];
|
|
base.instructions = base.nodeOrder.map((nodeId) => {
|
|
const node = byId.get(nodeId) as MaterialNodeIR;
|
|
return { nodeId, type: node.type as CompileNodeType, ...(node.type === "MATH" ? { operation: node.properties?.operation } : {}) };
|
|
});
|
|
if (base.issues.length > 0) return base;
|
|
return { ...base, status: "COMPILED", material: compiled };
|
|
}
|