Advance M8-M11 parity workflows
This commit is contained in:
245
web/protocol/render-budget.ts
Normal file
245
web/protocol/render-budget.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user