export const DEVICE_BUDGET_SCHEMA_VERSION = 1 as const; export type DeviceBudgetTier = "CONSERVATIVE" | "BALANCED" | "HIGH"; export interface DeviceBudgetObservation { schemaVersion: typeof DEVICE_BUDGET_SCHEMA_VERSION; identitySha256: string; webgl2: { status: "PASS" | "BLOCKED"; renderer?: string; vendor?: string }; webgpu: { status: "PASS" | "BLOCKED"; device?: string; description?: string; isFallbackAdapter?: boolean }; hardwareConcurrency: number | null; deviceMemory: number | null; } export interface DeviceBudgetLimits { maxTextureGPUBytes: number; maxTexturePayloadBytes: number; maxTextureDimension: number; maxLights: number; maxShadowMaps: number; } export interface DeviceBudgetSelection { schemaVersion: typeof DEVICE_BUDGET_SCHEMA_VERSION; identitySha256: string; tier: DeviceBudgetTier; reason: "MISSING_GPU" | "UNTRUSTED_ADAPTER" | "WEBGPU_UNAVAILABLE" | "BALANCED_CAPABILITY" | "HIGH_CAPABILITY"; limits: DeviceBudgetLimits; } const mib = 1024 * 1024; const LIMITS: Readonly> = Object.freeze({ CONSERVATIVE: Object.freeze({ maxTextureGPUBytes: 256 * mib, maxTexturePayloadBytes: 256 * mib, maxTextureDimension: 8192, maxLights: 8, maxShadowMaps: 2 }), BALANCED: Object.freeze({ maxTextureGPUBytes: 512 * mib, maxTexturePayloadBytes: 512 * mib, maxTextureDimension: 16384, maxLights: 16, maxShadowMaps: 4 }), HIGH: Object.freeze({ maxTextureGPUBytes: 1024 * mib, maxTexturePayloadBytes: 512 * mib, maxTextureDimension: 16384, maxLights: 64, maxShadowMaps: 8 }), }); function validHash(value: unknown): value is string { return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value); } function validPositive(value: number | null): value is number { return value !== null && Number.isSafeInteger(value) && value > 0; } export function selectDeviceBudget(observation: DeviceBudgetObservation): DeviceBudgetSelection { if (!observation || observation.schemaVersion !== DEVICE_BUDGET_SCHEMA_VERSION || !validHash(observation.identitySha256)) { throw new Error("DEVICE_BUDGET_IDENTITY_INVALID"); } const conservative = (reason: DeviceBudgetSelection["reason"]): DeviceBudgetSelection => ({ schemaVersion: DEVICE_BUDGET_SCHEMA_VERSION, identitySha256: observation.identitySha256, tier: "CONSERVATIVE", reason, limits: LIMITS.CONSERVATIVE, }); if (observation.webgl2.status !== "PASS") return conservative("MISSING_GPU"); const renderer = `${observation.webgl2.renderer ?? ""} ${observation.webgpu.description ?? ""}`.toLowerCase(); if (!renderer || renderer.includes("swiftshader") || renderer.includes("unknown") || observation.webgpu.isFallbackAdapter) return conservative("UNTRUSTED_ADAPTER"); if (observation.webgpu.status !== "PASS") return conservative("WEBGPU_UNAVAILABLE"); if (!validPositive(observation.hardwareConcurrency) || !validPositive(observation.deviceMemory)) return conservative("UNTRUSTED_ADAPTER"); const tier: DeviceBudgetTier = observation.hardwareConcurrency >= 8 && observation.deviceMemory >= 8 ? "HIGH" : "BALANCED"; return { schemaVersion: DEVICE_BUDGET_SCHEMA_VERSION, identitySha256: observation.identitySha256, tier, reason: tier === "HIGH" ? "HIGH_CAPABILITY" : "BALANCED_CAPABILITY", limits: LIMITS[tier] }; } export function deviceBudgetLimits(tier: DeviceBudgetTier): DeviceBudgetLimits { const limits = LIMITS[tier]; if (!limits) throw new Error("DEVICE_BUDGET_TIER_INVALID"); return limits; }