Files
workinf_Blender_Wasm/web/protocol/render-assets.ts
2026-08-12 04:47:48 -04:00

198 lines
8.8 KiB
TypeScript

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<string> {
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<GPUTextureAsset> {
return {
schemaVersion: GPU_TEXTURE_PAYLOAD_SCHEMA,
...request,
byteLength: data.byteLength,
sha256: await sha256Hex(data),
data,
};
}
export async function validateGPUTextureAsset(asset: GPUTextureAsset): Promise<void> {
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<string, Set<GPUTextureUsage>> {
const usages = new Map<string, Set<GPUTextureUsage>>();
const add = (imageId: string, usage: GPUTextureUsage): void => {
const current = usages.get(imageId) ?? new Set<GPUTextureUsage>();
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<GPUTextureUsage>();
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<string, GPUTextureAssetRequest>();
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<number>();
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");
}