feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
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

- 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:
mes123456
2026-11-12 21:49:24 +08:00
parent a078648297
commit d5dd647881
276 changed files with 9632 additions and 314 deletions

View File

@@ -472,3 +472,52 @@ export async function createAssetCatalogManifestV2FromDesktop(value: unknown, re
const manifest = { schemaVersion: ASSET_CATALOG_SCHEMA, revision, catalogs, assets, libraries: [] };
return parseAssetCatalogManifestV2(manifest);
}
export interface AssetCatalogMergeResult {
manifest: AssetCatalogManifestV2IR;
conflicts: string[];
added: { catalogs: number; assets: number; libraries: number };
}
/** Merge an independently generated library into the existing global catalog. */
export async function mergeAssetCatalogManifests(existingValue: unknown, incomingValue: unknown): Promise<AssetCatalogMergeResult> {
const existing = await parseAssetCatalogManifestV2(existingValue);
const incoming = await parseAssetCatalogManifestV2(incomingValue);
const conflicts: string[] = [];
const mergeList = <T>(base: readonly T[], extra: readonly T[], key: (value: T) => string, label: string): T[] => {
const merged = new Map(base.map((value) => [key(value), value]));
for (const value of extra) {
const id = key(value);
const current = merged.get(id);
if (!current) {
merged.set(id, structuredClone(value) as T);
} else if (JSON.stringify(current) !== JSON.stringify(value)) {
conflicts.push(`${label}:${id}`);
}
}
return [...merged.values()];
};
const catalogs = mergeList(existing.catalogs, incoming.catalogs, (value) => value.catalogId, "catalog");
const assets = mergeList(existing.assets, incoming.assets, (value) => value.assetId, "asset");
const libraries = mergeList(existing.libraries, incoming.libraries, (value) => value.libraryId, "library");
const revision = Math.max(existing.revision, incoming.revision) + 1;
if (revision > Number.MAX_SAFE_INTEGER) {
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", "asset catalog revision overflow");
}
const manifest = await parseAssetCatalogManifestV2({
schemaVersion: ASSET_CATALOG_SCHEMA,
revision,
catalogs,
assets,
libraries,
});
return {
manifest,
conflicts,
added: {
catalogs: catalogs.length - existing.catalogs.length,
assets: assets.length - existing.assets.length,
libraries: libraries.length - existing.libraries.length,
},
};
}

View File

@@ -9,7 +9,7 @@ export const MAX_GPU_TEXTURE_DIMENSION = 16_384;
export const MAX_GPU_TEXTURE_ASSETS = 256;
export const MAX_UDIM_TILES = 64;
export type GPUTextureUsage = "BASE_COLOR" | "NORMAL" | "METALLIC" | "ROUGHNESS" | "EMISSIVE" | "ALPHA" | "BUMP" | "DATA" | "ENVIRONMENT" | "UDIM_TILE";
export type GPUTextureUsage = "BASE_COLOR" | "NORMAL" | "METALLIC" | "ROUGHNESS" | "EMISSIVE" | "ALPHA" | "BUMP" | "HEIGHT" | "DATA" | "ENVIRONMENT" | "UDIM_TILE";
export type GPUTextureColorSpace = "SRGB" | "NON_COLOR" | "LINEAR";
export interface GPUTextureAssetRequest {
@@ -108,22 +108,35 @@ function materialUsages(materials: MaterialIR[]): Map<string, Set<GPUTextureUsag
usages.set(imageId, current);
};
for (const material of materials) {
if (material.normalImageId) add(material.normalImageId, "NORMAL");
for (const binding of material.textureBindings ?? []) add(binding.imageId, binding.usage === "EMISSION" ? "EMISSIVE" : binding.usage);
const bound = new Set<string>();
if (material.normalImageId) {
bound.add(material.normalImageId);
add(material.normalImageId, "NORMAL");
}
for (const binding of material.textureBindings ?? []) {
bound.add(binding.imageId);
add(binding.imageId, binding.usage === "EMISSION" ? "EMISSIVE" : binding.usage);
}
for (const imageId of material.imageIds ?? []) {
if (!material.textureBindings?.some((binding) => binding.imageId === imageId)) add(imageId, imageId === material.normalImageId ? "NORMAL" : "BASE_COLOR");
if (bound.has(imageId)) continue;
add(imageId, imageId === material.normalImageId ? "NORMAL" : "BASE_COLOR");
}
for (const node of material.nodes ?? []) {
if (node.type === "IMAGE_TEXTURE" && node.imageId) add(node.imageId, node.imageId === material.normalImageId ? "NORMAL" : "BASE_COLOR");
if (node.type === "IMAGE_TEXTURE" && node.imageId && !bound.has(node.imageId)) {
add(node.imageId, node.imageId === material.normalImageId ? "NORMAL" : "BASE_COLOR");
}
}
}
return usages;
}
function requestForImage(image: ImageIR, usage: GPUTextureUsage): GPUTextureAssetRequest[] {
const colorSpace: GPUTextureColorSpace = image.colorSpace ?? (usage === "BASE_COLOR" || usage === "EMISSIVE" ? "SRGB" : usage === "ENVIRONMENT" ? "LINEAR" : "NON_COLOR");
if (image.tiles?.length) {
return image.tiles.filter((tile) => tile.packed).map((tile) => ({
const colorSpace: GPUTextureColorSpace = usage === "HEIGHT"
? "NON_COLOR"
: image.colorSpace ?? (usage === "BASE_COLOR" || usage === "EMISSIVE" ? "SRGB" : usage === "ENVIRONMENT" ? "LINEAR" : "NON_COLOR");
const tiles = image.tiles;
if (tiles && tiles.length > 1) {
return tiles.filter((tile) => tile.packed).map((tile) => ({
assetId: tile.assetId,
imageId: image.id,
mimeType: imageMimeType(tile.mimeType),
@@ -134,6 +147,19 @@ function requestForImage(image: ImageIR, usage: GPUTextureUsage): GPUTextureAsse
tileNumber: tile.number,
}));
}
if (tiles && tiles.length === 1) {
const tile = tiles[0];
if (!tile.packed) return [];
return [{
assetId: tile.assetId,
imageId: image.id,
mimeType: imageMimeType(tile.mimeType),
width: image.width || tile.width,
height: image.height || tile.height,
usage,
colorSpace,
}];
}
if (!image.packed) return [];
return [{
assetId: image.assetId,
@@ -162,6 +188,26 @@ export function collectGPUTextureAssetRequests(snapshot: SceneSnapshotIR): GPUTe
return [...unique.values()];
}
/**
* Number of GPU texture assets the packed-asset fallback will produce. This
* includes images that have no MaterialTextureBinding yet (the App adds a
* BASE_COLOR/ENVIRONMENT fallback for them) so readiness cannot release early.
*/
export function expectedGPUTextureAssetCount(snapshot: SceneSnapshotIR): number {
const requests = collectGPUTextureAssetRequests(snapshot);
const represented = new Set(requests.map((request) => request.assetId));
let count = requests.length;
for (const image of snapshot.images) {
if (image.tiles?.length) {
for (const tile of image.tiles) if (tile.packed && !represented.has(tile.assetId)) count += 1;
}
else if (image.packed && !represented.has(image.assetId)) {
count += 1;
}
}
return count;
}
export function createUDIMManifest(image: ImageIR): UDIMManifest {
if (image.sourceKind !== "TILED" || !image.tiles?.length || image.tiles.length > MAX_UDIM_TILES) {
throw new RenderAssetValidationError("UDIM_MANIFEST_INVALID", `Image ${image.id} does not have a bounded UDIM tile set`);

View File

@@ -233,7 +233,7 @@ export interface MeshSummaryIR {
export interface MaterialNodeIR {
id: string;
type: "RGB" | "VALUE" | "MATH" | "MIX" | "MAPPING" | "TEX_COORD" | "UV_MAP" | "BUMP" | "SEPARATE_COLOR" | "OBJECT_INFO" | "PRINCIPLED" | "IMAGE_TEXTURE" | "NORMAL_MAP" | "OUTPUT" | "UNSUPPORTED";
type: "RGB" | "VALUE" | "MATH" | "MIX" | "MAPPING" | "TEX_COORD" | "UV_MAP" | "BUMP" | "SEPARATE_COLOR" | "OBJECT_INFO" | "PRINCIPLED" | "IMAGE_TEXTURE" | "NORMAL_MAP" | "OUTPUT" | "REROUTE" | "FRAME" | "DISPLACEMENT" | "UNSUPPORTED";
name: string;
location?: [number, number];
sockets?: Array<{id:string;name:string;direction:'INPUT'|'OUTPUT';dataType:'VALUE'|'VECTOR'|'COLOR'|'BOOLEAN'|'SHADER'|'UNSUPPORTED';value?:number|boolean|number[];linked:boolean;integer?:boolean;available?:boolean;hidden?:boolean}>;
@@ -263,10 +263,12 @@ export interface MaterialLinkIR {
export interface MaterialTextureBindingIR {
imageId: string;
usage: "BASE_COLOR" | "METALLIC" | "ROUGHNESS" | "EMISSION" | "ALPHA" | "NORMAL" | "BUMP";
usage: "BASE_COLOR" | "METALLIC" | "ROUGHNESS" | "EMISSION" | "ALPHA" | "NORMAL" | "BUMP" | "HEIGHT";
socket?: string;
evaluated?: boolean;
strength?: number;
midlevel?: number;
scale?: number;
uvMap?: string;
}
@@ -286,6 +288,9 @@ export interface MaterialIR {
coatRoughness?: number;
emissionStrength?: number;
normalImageId?: string | null;
displacementImageId?: string | null;
displacementMidlevel?: number;
displacementScale?: number;
imageIds?: string[];
textureBindings?: MaterialTextureBindingIR[];
warnings?: string[];
@@ -1712,6 +1717,9 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
throw new Error(`SceneIR.materials[${index}].emissionStrength is outside the supported range`);
}
if (material.normalImageId !== undefined && material.normalImageId !== null) requireString(material.normalImageId, `materials[${index}].normalImageId`);
if (material.displacementImageId !== undefined && material.displacementImageId !== null) requireString(material.displacementImageId, `materials[${index}].displacementImageId`);
if (material.displacementMidlevel !== undefined) requireNumber(material.displacementMidlevel, `materials[${index}].displacementMidlevel`);
if (material.displacementScale !== undefined) requireNumber(material.displacementScale, `materials[${index}].displacementScale`);
if (material.imageIds !== undefined && (!Array.isArray(material.imageIds) || material.imageIds.some((item) => typeof item !== "string"))) {
throw new Error(`SceneIR.materials[${index}].imageIds must contain strings`);
}
@@ -1720,11 +1728,13 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
for (const [bindingIndex, binding] of material.textureBindings.entries()) {
if (!isRecord(binding)) throw new Error(`SceneIR.materials[${index}].textureBindings[${bindingIndex}] must be an object`);
requireString(binding.imageId, `materials[${index}].textureBindings[${bindingIndex}].imageId`);
if (!["BASE_COLOR", "METALLIC", "ROUGHNESS", "EMISSION", "ALPHA", "NORMAL", "BUMP"].includes(binding.usage as string)) throw new Error(`SceneIR.materials[${index}].textureBindings[${bindingIndex}].usage is invalid`);
if (!["BASE_COLOR", "METALLIC", "ROUGHNESS", "EMISSION", "ALPHA", "NORMAL", "BUMP", "HEIGHT"].includes(binding.usage as string)) throw new Error(`SceneIR.materials[${index}].textureBindings[${bindingIndex}].usage is invalid`);
if (binding.socket !== undefined) requireString(binding.socket, `materials[${index}].textureBindings[${bindingIndex}].socket`);
if (binding.uvMap !== undefined) requireString(binding.uvMap, `materials[${index}].textureBindings[${bindingIndex}].uvMap`);
if (binding.evaluated !== undefined && typeof binding.evaluated !== "boolean") throw new Error(`SceneIR.materials[${index}].textureBindings[${bindingIndex}].evaluated is invalid`);
if (binding.strength !== undefined) requireNumber(binding.strength, `materials[${index}].textureBindings[${bindingIndex}].strength`);
if (binding.midlevel !== undefined) requireNumber(binding.midlevel, `materials[${index}].textureBindings[${bindingIndex}].midlevel`);
if (binding.scale !== undefined) requireNumber(binding.scale, `materials[${index}].textureBindings[${bindingIndex}].scale`);
}
}
if (material.warnings !== undefined && (!Array.isArray(material.warnings) || material.warnings.some((item) => typeof item !== "string"))) {
@@ -1736,7 +1746,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
if (!isRecord(node)) throw new Error(`SceneIR.materials[${index}].nodes[${nodeIndex}] must be an object`);
requireString(node.id, `SceneIR.materials[${index}].nodes[${nodeIndex}].id`);
requireString(node.name, `SceneIR.materials[${index}].nodes[${nodeIndex}].name`);
if (!["RGB", "VALUE", "MATH", "MIX", "MAPPING", "TEX_COORD", "UV_MAP", "BUMP", "SEPARATE_COLOR", "OBJECT_INFO", "PRINCIPLED", "IMAGE_TEXTURE", "NORMAL_MAP", "OUTPUT", "UNSUPPORTED"].includes(node.type as string)) throw new Error(`SceneIR.materials[${index}].nodes[${nodeIndex}].type is invalid`);
if (!["RGB", "VALUE", "MATH", "MIX", "MAPPING", "TEX_COORD", "UV_MAP", "BUMP", "SEPARATE_COLOR", "OBJECT_INFO", "PRINCIPLED", "IMAGE_TEXTURE", "NORMAL_MAP", "OUTPUT", "REROUTE", "FRAME", "DISPLACEMENT", "UNSUPPORTED"].includes(node.type as string)) throw new Error(`SceneIR.materials[${index}].nodes[${nodeIndex}].type is invalid`);
if (node.imageId !== undefined && node.imageId !== null) requireString(node.imageId, `SceneIR.materials[${index}].nodes[${nodeIndex}].imageId`);
if (node.defaultValue !== undefined && (!Array.isArray(node.defaultValue) || node.defaultValue.length === 0 || node.defaultValue.length > 4 || node.defaultValue.some((value) => typeof value !== "number" || !Number.isFinite(value)))) throw new Error(`SceneIR.materials[${index}].nodes[${nodeIndex}].defaultValue is invalid`);
if (node.properties !== undefined) {

View File

@@ -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}`));

View File

@@ -297,6 +297,7 @@ export interface StorageRequest {
| { type: "listAssets"; projectId: string }
| { type: "getAssetCatalog" }
| { type: "seedAssetCatalog"; projectId: string; manifest: unknown; library: ArrayBuffer; previews: Array<{ sha256: string; data: ArrayBuffer }>; seedId: string; repair?: boolean }
| { type: "mergeAssetCatalogManifest"; projectId: string; manifest: unknown }
| { type: "readAssetCatalogPreview"; projectId: string; sha256: string }
| { type: "commitAssetCatalogPreview"; projectId: string; expectedRevision: number; identity: unknown; data: ArrayBuffer }
| { type: "commitTexturePaintTile"; commit: TexturePaintTileCommitIR }