70 lines
3.4 KiB
TypeScript
70 lines
3.4 KiB
TypeScript
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<Record<DeviceBudgetTier, DeviceBudgetLimits>> = 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;
|
|
}
|