246 lines
9.6 KiB
TypeScript
246 lines
9.6 KiB
TypeScript
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<ErrorCode, "GPU_LIGHT_BUDGET_EXCEEDED" | "GPU_SHADOW_BUDGET_EXCEEDED" | "GPU_TEXTURE_BUDGET_EXCEEDED">;
|
|
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<Record<PBRRenderBackend, PBRRenderBudget>> = 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<SceneSnapshotIR, "nodes" | "lights">,
|
|
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,
|
|
};
|
|
}
|