Files
workinf_Blender_Wasm/web/protocol/render-budget.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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,
};
}