import type { ErrorCode } from "./error"; import { MAX_GPU_TEXTURE_ASSETS, MAX_GPU_TEXTURE_BYTES, MAX_GPU_TEXTURE_DIMENSION, type GPUTextureAsset } from "./render-assets"; import type { SceneSnapshotIR } from "./scene-ir"; export const PBR_RENDER_BUDGET_SCHEMA = 1 as const; export type PBRRenderBackend = "THREE_WEBGL2" | "THREE_WEBGPU"; export interface PBRRenderBudget { schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA; backend: PBRRenderBackend; maxLights: number; reservedLights: number; maxShadowMaps: number; reservedShadowMaps: number; shadowMapDimension: number; maxShadowMapTexels: number; maxTextureAssets: number; maxTextureDimension: number; maxTexturePayloadBytes: number; maxTextureGPUBytes: number; } export interface PBRDeviceLimits { maxLights?: number; maxShadowMaps?: number; maxShadowMapDimension?: number; maxTextureAssets?: number; maxTextureDimension2D?: number; maxTexturePayloadBytes?: number; maxTextureGPUBytes?: number; } export interface PBRRenderBudgetIssue { code: Extract; message: string; resource: "LIGHT" | "SHADOW_MAP" | "TEXTURE"; } export interface PBRLightingBudgetReport { schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA; backend: PBRRenderBackend; status: "READY" | "BLOCKED"; budget: PBRRenderBudget; requestedLights: number; renderedLightNodeIds: string[]; droppedLightNodeIds: string[]; requestedShadowMaps: number; shadowLightNodeIds: string[]; shadowBlockedLightNodeIds: string[]; shadowMapTexels: number; issues: PBRRenderBudgetIssue[]; } export type PBRTextureBudgetAsset = Pick< GPUTextureAsset, "assetId" | "imageId" | "usage" | "tileNumber" | "width" | "height" | "byteLength" >; export interface PBRTextureBudgetReport { schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA; backend: PBRRenderBackend; status: "READY" | "BLOCKED"; budget: PBRRenderBudget; requestedAssets: number; payloadBytes: number; decodedGPUBytes: number; maxRequestedDimension: number; issues: PBRRenderBudgetIssue[]; } const mib = 1024 * 1024; export const PBR_RENDER_BUDGETS: Readonly> = Object.freeze({ THREE_WEBGL2: Object.freeze({ schemaVersion: PBR_RENDER_BUDGET_SCHEMA, backend: "THREE_WEBGL2", maxLights: 16, reservedLights: 2, maxShadowMaps: 4, reservedShadowMaps: 1, shadowMapDimension: 1024, maxShadowMapTexels: 4 * 1024 * 1024, maxTextureAssets: MAX_GPU_TEXTURE_ASSETS, maxTextureDimension: MAX_GPU_TEXTURE_DIMENSION, maxTexturePayloadBytes: 512 * mib, maxTextureGPUBytes: 512 * mib, }), THREE_WEBGPU: Object.freeze({ schemaVersion: PBR_RENDER_BUDGET_SCHEMA, backend: "THREE_WEBGPU", maxLights: 64, reservedLights: 2, maxShadowMaps: 8, reservedShadowMaps: 1, shadowMapDimension: 2048, maxShadowMapTexels: 8 * 2048 * 2048, maxTextureAssets: MAX_GPU_TEXTURE_ASSETS, maxTextureDimension: MAX_GPU_TEXTURE_DIMENSION, maxTexturePayloadBytes: 512 * mib, maxTextureGPUBytes: 1024 * mib, }), }); function positiveLimit(value: number | undefined, field: string): number | undefined { if (value === undefined) return undefined; if (!Number.isSafeInteger(value) || value < 1) { throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: ${field} must be a positive safe integer`); } return value; } function lower(product: number, device: number | undefined, field: string): number { return Math.min(product, positiveLimit(device, field) ?? product); } export function resolvePBRRenderBudget( backend: PBRRenderBackend, device: PBRDeviceLimits = {}, ): PBRRenderBudget { const product = PBR_RENDER_BUDGETS[backend]; if (!product) throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: unknown PBR render backend ${String(backend)}`); const maxLights = Math.max(product.reservedLights, lower(product.maxLights, device.maxLights, "maxLights")); const maxShadowMaps = Math.max(product.reservedShadowMaps, lower(product.maxShadowMaps, device.maxShadowMaps, "maxShadowMaps")); const shadowMapDimension = lower(product.shadowMapDimension, device.maxShadowMapDimension, "maxShadowMapDimension"); return { ...product, maxLights, maxShadowMaps, shadowMapDimension, maxShadowMapTexels: Math.min(product.maxShadowMapTexels, maxShadowMaps * shadowMapDimension * shadowMapDimension), maxTextureAssets: lower(product.maxTextureAssets, device.maxTextureAssets, "maxTextureAssets"), maxTextureDimension: lower(product.maxTextureDimension, device.maxTextureDimension2D, "maxTextureDimension2D"), maxTexturePayloadBytes: lower(product.maxTexturePayloadBytes, device.maxTexturePayloadBytes, "maxTexturePayloadBytes"), maxTextureGPUBytes: lower(product.maxTextureGPUBytes, device.maxTextureGPUBytes, "maxTextureGPUBytes"), }; } function shadowCapable(lightType: number, castsShadow: boolean | undefined): boolean { return castsShadow !== false && (lightType === 0 || lightType === 1 || lightType === 2); } export function planPBRLightingBudget( snapshot: Pick, backend: PBRRenderBackend = "THREE_WEBGL2", device: PBRDeviceLimits = {}, ): PBRLightingBudgetReport { const budget = resolvePBRRenderBudget(backend, device); const definitions = new Map(snapshot.lights.map((light) => [light.id, light])); const requested = snapshot.nodes.filter((node) => node.type === "LIGHT" && node.visible && node.dataId && definitions.has(node.dataId)); const lightSlots = Math.max(0, budget.maxLights - budget.reservedLights); const rendered = requested.slice(0, lightSlots); const dropped = requested.slice(lightSlots); const shadowRequested = rendered.filter((node) => shadowCapable(definitions.get(node.dataId!)!.lightType, definitions.get(node.dataId!)!.castsShadow)); const shadowSlots = Math.max(0, budget.maxShadowMaps - budget.reservedShadowMaps); const shadowLights = shadowRequested.slice(0, shadowSlots); const shadowBlocked = shadowRequested.slice(shadowSlots); const issues: PBRRenderBudgetIssue[] = []; if (dropped.length > 0) { issues.push({ code: "GPU_LIGHT_BUDGET_EXCEEDED", resource: "LIGHT", message: `${backend} requested ${requested.length + budget.reservedLights} total lights; limit is ${budget.maxLights}`, }); } if (shadowBlocked.length > 0) { issues.push({ code: "GPU_SHADOW_BUDGET_EXCEEDED", resource: "SHADOW_MAP", message: `${backend} requested ${shadowRequested.length + budget.reservedShadowMaps} shadow maps; limit is ${budget.maxShadowMaps}`, }); } return { schemaVersion: PBR_RENDER_BUDGET_SCHEMA, backend, status: issues.length === 0 ? "READY" : "BLOCKED", budget, requestedLights: requested.length, renderedLightNodeIds: rendered.map((node) => node.id), droppedLightNodeIds: dropped.map((node) => node.id), requestedShadowMaps: shadowRequested.length, shadowLightNodeIds: shadowLights.map((node) => node.id), shadowBlockedLightNodeIds: shadowBlocked.map((node) => node.id), shadowMapTexels: (shadowLights.length + budget.reservedShadowMaps) * budget.shadowMapDimension * budget.shadowMapDimension, issues, }; } function safeAdd(total: number, value: number, field: string): number { const result = total + value; if (!Number.isSafeInteger(result)) throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: ${field} overflows a safe integer`); return result; } function textureKey(asset: PBRTextureBudgetAsset): string { return `${asset.assetId}:${asset.imageId}:${asset.usage}:${asset.tileNumber ?? 0}`; } export function planPBRTextureBudget( assets: readonly PBRTextureBudgetAsset[], backend: PBRRenderBackend = "THREE_WEBGL2", device: PBRDeviceLimits = {}, ): PBRTextureBudgetReport { const budget = resolvePBRRenderBudget(backend, device); const unique = new Map(assets.map((asset) => [textureKey(asset), asset])); let payloadBytes = 0; let decodedGPUBytes = 0; let maxRequestedDimension = 0; let invalid = false; for (const asset of unique.values()) { if (!Number.isSafeInteger(asset.width) || !Number.isSafeInteger(asset.height) || !Number.isSafeInteger(asset.byteLength) || asset.width < 1 || asset.height < 1 || asset.byteLength < 1 || asset.byteLength > MAX_GPU_TEXTURE_BYTES) { invalid = true; continue; } maxRequestedDimension = Math.max(maxRequestedDimension, asset.width, asset.height); payloadBytes = safeAdd(payloadBytes, asset.byteLength, "texture payload bytes"); const pixels = asset.width * asset.height; if (!Number.isSafeInteger(pixels) || !Number.isSafeInteger(pixels * 4)) { invalid = true; continue; } decodedGPUBytes = safeAdd(decodedGPUBytes, pixels * 4, "decoded texture bytes"); } const issues: PBRRenderBudgetIssue[] = []; if (invalid || unique.size > budget.maxTextureAssets || maxRequestedDimension > budget.maxTextureDimension || payloadBytes > budget.maxTexturePayloadBytes || decodedGPUBytes > budget.maxTextureGPUBytes) { issues.push({ code: "GPU_TEXTURE_BUDGET_EXCEEDED", resource: "TEXTURE", message: `${backend} texture request ${unique.size} assets/${payloadBytes} payload bytes/${decodedGPUBytes} decoded bytes/${maxRequestedDimension}px exceeds ${budget.maxTextureAssets}/${budget.maxTexturePayloadBytes}/${budget.maxTextureGPUBytes}/${budget.maxTextureDimension}`, }); } return { schemaVersion: PBR_RENDER_BUDGET_SCHEMA, backend, status: issues.length === 0 ? "READY" : "BLOCKED", budget, requestedAssets: unique.size, payloadBytes, decodedGPUBytes, maxRequestedDimension, issues, }; }