import type { CapabilityGateResult } from "./capability-gates"; import { blockedGate, capabilityIssue, readyGate } from "./capability-gates"; import type { ErrorCode } from "./error"; import type { ImageIR, MaterialIR, SceneSnapshotIR } from "./scene-ir"; export const GPU_TEXTURE_PAYLOAD_SCHEMA = 1 as const; export const MAX_GPU_TEXTURE_BYTES = 64 * 1024 * 1024; 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" | "EMISSIVE" | "DATA" | "ENVIRONMENT" | "UDIM_TILE"; export type GPUTextureColorSpace = "SRGB" | "NON_COLOR" | "LINEAR"; export interface GPUTextureAssetRequest { assetId: string; imageId: string; mimeType: string; width: number; height: number; usage: GPUTextureUsage; colorSpace: GPUTextureColorSpace; tileNumber?: number; } export interface GPUTextureAsset extends GPUTextureAssetRequest { schemaVersion: typeof GPU_TEXTURE_PAYLOAD_SCHEMA; sha256: string; byteLength: number; data: ArrayBuffer; } export interface UDIMTileManifestEntry { number: number; u: number; v: number; assetId: string; width: number; height: number; packed: boolean; } export interface UDIMManifest { schemaVersion: 1; imageId: string; tiles: UDIMTileManifestEntry[]; } export class RenderAssetValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(message); this.name = "RenderAssetValidationError"; this.code = code; } } function imageMimeType(value: string | undefined): string { return (value ?? "application/octet-stream").toLowerCase(); } function isRasterMimeType(value: string): boolean { return value === "image/png" || value === "image/jpeg" || value === "image/webp"; } export async function sha256Hex(data: ArrayBuffer): Promise { const digest = await crypto.subtle.digest("SHA-256", data); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } export async function createGPUTextureAsset( request: GPUTextureAssetRequest, data: ArrayBuffer, ): Promise { return { schemaVersion: GPU_TEXTURE_PAYLOAD_SCHEMA, ...request, byteLength: data.byteLength, sha256: await sha256Hex(data), data, }; } export async function validateGPUTextureAsset(asset: GPUTextureAsset): Promise { if (asset.schemaVersion !== GPU_TEXTURE_PAYLOAD_SCHEMA || !asset.assetId || !asset.imageId || !isRasterMimeType(imageMimeType(asset.mimeType))) { throw new RenderAssetValidationError("GPU_TEXTURE_INVALID", `Invalid GPU texture metadata for ${asset.assetId || "unknown asset"}`); } if (!Number.isInteger(asset.width) || !Number.isInteger(asset.height) || asset.width < 1 || asset.height < 1 || asset.width > MAX_GPU_TEXTURE_DIMENSION || asset.height > MAX_GPU_TEXTURE_DIMENSION) { throw new RenderAssetValidationError("GPU_TEXTURE_BUDGET_EXCEEDED", `Texture dimensions exceed the PBR-007 limit: ${asset.width}x${asset.height}`); } if (asset.byteLength !== asset.data.byteLength || asset.byteLength < 1 || asset.byteLength > MAX_GPU_TEXTURE_BYTES) { throw new RenderAssetValidationError("GPU_TEXTURE_BUDGET_EXCEEDED", `Texture payload exceeds the PBR-007 limit: ${asset.byteLength} bytes`); } if (!/^[a-f0-9]{64}$/.test(asset.sha256) || await sha256Hex(asset.data) !== asset.sha256) { throw new RenderAssetValidationError("GPU_TEXTURE_HASH_MISMATCH", `Texture payload checksum mismatch: ${asset.assetId}`); } if (asset.tileNumber !== undefined && (!Number.isInteger(asset.tileNumber) || asset.tileNumber < 1001 || asset.tileNumber > 1999)) { throw new RenderAssetValidationError("UDIM_MANIFEST_INVALID", `Invalid UDIM tile number: ${asset.tileNumber}`); } } function materialUsages(materials: MaterialIR[]): Map> { const usages = new Map>(); const add = (imageId: string, usage: GPUTextureUsage): void => { const current = usages.get(imageId) ?? new Set(); current.add(usage); usages.set(imageId, current); }; for (const material of materials) { if (material.normalImageId) add(material.normalImageId, "NORMAL"); for (const imageId of material.imageIds ?? []) 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"); } } return usages; } function requestForImage(image: ImageIR, usage: GPUTextureUsage): GPUTextureAssetRequest[] { const colorSpace: GPUTextureColorSpace = usage === "BASE_COLOR" || usage === "EMISSIVE" ? "SRGB" : usage === "ENVIRONMENT" ? "LINEAR" : "NON_COLOR"; if (image.tiles?.length) { return image.tiles.filter((tile) => tile.packed).map((tile) => ({ assetId: tile.assetId, imageId: image.id, mimeType: imageMimeType(tile.mimeType), width: tile.width, height: tile.height, usage: "UDIM_TILE", colorSpace, tileNumber: tile.number, })); } if (!image.packed) return []; return [{ assetId: image.assetId, imageId: image.id, mimeType: imageMimeType(image.mimeType), width: image.width ?? 0, height: image.height ?? 0, usage, colorSpace, }]; } export function collectGPUTextureAssetRequests(snapshot: SceneSnapshotIR): GPUTextureAssetRequest[] { const usages = materialUsages(snapshot.materials); for (const world of snapshot.worlds) { if (world.environmentImageId) { const current = usages.get(world.environmentImageId) ?? new Set(); current.add("ENVIRONMENT"); usages.set(world.environmentImageId, current); } } const requests = snapshot.images.flatMap((image) => [...(usages.get(image.id) ?? [])].flatMap((usage) => requestForImage(image, usage))); const unique = new Map(); for (const request of requests) unique.set(`${request.assetId}:${request.usage}:${request.colorSpace}`, request); if (unique.size > MAX_GPU_TEXTURE_ASSETS) throw new RenderAssetValidationError("GPU_TEXTURE_BUDGET_EXCEEDED", `Scene requests ${unique.size} textures; limit is ${MAX_GPU_TEXTURE_ASSETS}`); return [...unique.values()]; } 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`); } const seen = new Set(); const tiles = image.tiles.map((tile) => { if (!Number.isInteger(tile.number) || tile.number < 1001 || tile.number > 1999 || seen.has(tile.number) || tile.width < 1 || tile.height < 1) { throw new RenderAssetValidationError("UDIM_MANIFEST_INVALID", `Invalid or duplicate UDIM tile ${tile.number}`); } seen.add(tile.number); const offset = tile.number - 1001; return { number: tile.number, u: offset % 10, v: Math.floor(offset / 10), assetId: tile.assetId, width: tile.width, height: tile.height, packed: tile.packed }; }).sort((a, b) => a.number - b.number); return { schemaVersion: 1, imageId: image.id, tiles }; } export function gateUDIMImage(image: ImageIR, multiTileRendererAvailable = false): CapabilityGateResult { try { const manifest = createUDIMManifest(image); const missing = manifest.tiles.filter((tile) => !tile.packed); if (missing.length) return blockedGate("PBR-008", "UDIM", [capabilityIssue("UDIM_TILE_MISSING", `UDIM tiles are not packed: ${missing.map((tile) => tile.number).join(", ")}`, "tiles")]); if (manifest.tiles.length > 1 && !multiTileRendererAvailable) return blockedGate("PBR-008", "UDIM", [capabilityIssue("UDIM_MULTI_TILE_UNAVAILABLE", "Multi-tile UDIM sampling is not available in the current renderer", "tiles")]); return readyGate("PBR-008", "UDIM"); } catch (error) { const issue = error as RenderAssetValidationError; return blockedGate("PBR-008", "UDIM", [capabilityIssue(issue.code ?? "UDIM_MANIFEST_INVALID", issue.message)]); } } export function gateEnvironmentImage(image: ImageIR | undefined): CapabilityGateResult { if (!image) return blockedGate("PBR-009", "HDRI_IBL", [capabilityIssue("IBL_ENVIRONMENT_MISSING", "World environment image is missing")]); if (!image.packed) return blockedGate("PBR-009", "HDRI_IBL", [capabilityIssue("IBL_ENVIRONMENT_MISSING", "World environment image is not packed", "environmentImageId")]); if (!isRasterMimeType(imageMimeType(image.mimeType))) return blockedGate("PBR-009", "HDRI_IBL", [capabilityIssue("IBL_FORMAT_UNSUPPORTED", `Environment format is not decoded by the Web renderer: ${image.mimeType ?? "unknown"}`, "mimeType")]); return readyGate("PBR-009", "HDRI_IBL"); }