198 lines
8.8 KiB
TypeScript
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");
|
|
}
|