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", "MATH", "IMAGE_TEXTURE", "NORMAL_MAP", "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; } 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: Array<{ imageId: string; usage: "BASE_COLOR" | "NORMAL" }>; instructions: Array<{ nodeId: string; type: CompileNodeType; operation?: string }>; material?: ShaderCompiledMaterial; issues: Issue[]; } export interface ShaderCompileContext { imageIds?: ReadonlySet; blockedImageIds?: ReadonlySet; rendererBackend?: string; textureIdentities?: ReadonlyMap; } export interface ShaderCompileKeyInput { graphHash: string | null; rendererBackend: string; textures: ReadonlyArray<{ imageId: string; usage: "BASE_COLOR" | "NORMAL"; assetId: string | null; sha256: string | null; colorSpace: ShaderTextureColorSpace; }>; } export function compileShaderGraph( graph: ShaderGraphIR, baseMaterial?: MaterialIR, context: ShaderCompileContext = {}, ): ShaderCompileReport { const socketName = new Map(); 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"; } 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, })), 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, 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"; } 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; } export function compileMaterialGraph(material: MaterialIR, context: ShaderCompileContext = {}): ShaderCompileReport { const nodes = material.nodes ?? []; const links = material.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(); 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`)); if (node.type !== "MATH" && node.properties && Object.keys(node.properties).length > 0) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `properties are not compiled for ${node.type}`, `nodes.${index}.properties`)); } const incoming = new Map(); const edges = new Map(); 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(); 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(); 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 evaluated = new Map(); const evaluating = new Set(); 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) : undefined; const right = second ? evalScalar(second.fromNodeId, depth + 1) : undefined; 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; } } } evaluating.delete(nodeId); if (value !== undefined && finite(value)) evaluated.set(nodeId, value); return value; }; const evalColor = (link: MaterialLinkIR): [number, number, number, number] | undefined => { const node = byId.get(link.fromNodeId); 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" && node.imageId) { if (context.imageIds && !context.imageIds.has(node.imageId)) { base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${node.imageId}`, `nodes.${node.id}.imageId`)); } if (context.blockedImageIds?.has(node.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${node.imageId}`, `nodes.${node.id}.imageId`)); textures.set(`BASE_COLOR:${node.imageId}`, { imageId: node.imageId, usage: "BASE_COLOR" }); compiled.baseColorImageId = node.imageId; return undefined; } return undefined; }; 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 value = evalScalar(link.fromNodeId, 0); 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)[field] = value; } const colorLink = sourceLink(incoming, shader.id, "Base Color"); if (colorLink) { const color = evalColor(colorLink); if (color) compiled.baseColor = color; else if (byId.get(colorLink.fromNodeId)?.type !== "IMAGE_TEXTURE") base.issues.push(issue("SHADER_INVALID_GRAPH", "Base Color must be an RGB 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") base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Principled Normal must be driven by the declared Normal Map node", "links.Normal")); else { 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 { 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`)); textures.set(`NORMAL:${imageNode.imageId}`, { imageId: imageNode.imageId, usage: "NORMAL" }); compiled.normalImageId = imageNode.imageId; } } } } 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") return from.type === "PRINCIPLED" && fromSocket === "bsdf" && toSocket === "surface"; if (to.type === "PRINCIPLED") { if (toSocket === "basecolor") return (from.type === "RGB" && fromSocket === "color") || (from.type === "IMAGE_TEXTURE" && fromSocket === "color"); if (toSocket === "normal") return from.type === "NORMAL_MAP" && fromSocket === "normal"; 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 === "MATH") return isScalarSource(from) && scalarSocket(toSocket) && fromSocket === "value"; 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"), }; }); base.compileKey = createShaderCompileKey({ graphHash, 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 }; }