export type SceneResourceKind = | "geometry" | "material" | "texture" | "target" | "pass" | "renderer" | "other"; export interface DisposableResource { dispose(): void; } export interface DisconnectableResource { disconnect(): void; } export interface SceneResourceSnapshot { disposed: boolean; owned: Readonly>; listenerCount: number; observerCount: number; timerCount: number; rafCount: 0 | 1; } export interface GpuTextureAllocation { id: string; width: number; height: number; bytesPerPixel: number; layers: number; mipmapped: boolean; } export interface GpuRenderTargetAllocation { id: string; width: number; height: number; bytesPerPixel: number; attachments: number; samples: number; } export interface GpuRenderTargetReporter { gpuRenderTargets(): readonly GpuRenderTargetAllocation[]; } export interface SceneGpuBudgetSnapshot { tier: "low" | "high"; textures: readonly GpuTextureAllocation[]; geometryBytes: number; renderTargets: readonly GpuRenderTargetAllocation[]; } const resourceKinds: readonly SceneResourceKind[] = [ "geometry", "material", "texture", "target", "pass", "renderer", "other", ]; export class SceneResourceBag { private disposed = false; private readonly resources = new Map(); private readonly listeners = new Set<() => void>(); private readonly observers = new Set(); private readonly timers = new Map void>(); private raf: { id: number; cancel: (id: number) => void } | null = null; own(kind: SceneResourceKind, resource: T): T { if (this.disposed) { resource.dispose(); return resource; } if (!this.resources.has(resource)) this.resources.set(resource, kind); return resource; } listen( target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: AddEventListenerOptions | boolean, ): () => void { if (this.disposed) return () => undefined; target.addEventListener(type, listener, options); let active = true; const remove = (): void => { if (!active) return; active = false; target.removeEventListener(type, listener, options); this.listeners.delete(remove); }; this.listeners.add(remove); return remove; } observe(observer: T): T { if (this.disposed) observer.disconnect(); else this.observers.add(observer); return observer; } timer(id: number, clear: (id: number) => void): number { if (this.disposed) clear(id); else this.timers.set(id, clear); return id; } releaseTimer(id: number): void { this.timers.delete(id); } animationFrame(id: number, cancel: (id: number) => void): number { if (this.disposed) { cancel(id); return id; } if (this.raf) this.raf.cancel(this.raf.id); this.raf = { id, cancel }; return id; } releaseAnimationFrame(id: number): void { if (this.raf?.id === id) this.raf = null; } cancelAnimationFrame(): void { if (!this.raf) return; const current = this.raf; this.raf = null; current.cancel(current.id); } dispose(): void { if (this.disposed) return; this.disposed = true; this.cancelAnimationFrame(); for (const [id, clear] of this.timers) clear(id); this.timers.clear(); for (const remove of [...this.listeners]) remove(); this.listeners.clear(); for (const observer of this.observers) observer.disconnect(); this.observers.clear(); // Texture, target and material disposal needs the renderer's allocation // records. Disposing the renderer first clears those records before the // resources can release their WebGL handles during a quality change. for (const [resource, kind] of this.resources) { if (kind !== "renderer") resource.dispose(); } for (const [resource, kind] of this.resources) { if (kind === "renderer") resource.dispose(); } this.resources.clear(); } snapshot(): SceneResourceSnapshot { const owned = Object.fromEntries( resourceKinds.map((kind) => [kind, 0]), ) as Record; for (const kind of this.resources.values()) owned[kind] += 1; return { disposed: this.disposed, owned, listenerCount: this.listeners.size, observerCount: this.observers.size, timerCount: this.timers.size, rafCount: this.raf ? 1 : 0, }; } gpuBudgetSnapshot(input: { tier: "low" | "high"; defaultFramebuffer: Omit; }): SceneGpuBudgetSnapshot { let geometryBytes = 0; const seenBuffers = new Set(); const textures: GpuTextureAllocation[] = []; const renderTargets: GpuRenderTargetAllocation[] = [ { id: "default-framebuffer", ...input.defaultFramebuffer }, ]; let textureIndex = 0; let targetIndex = 0; for (const [resource, kind] of this.resources) { const reporter = resource as DisposableResource & Partial; if (typeof reporter.gpuRenderTargets === "function") { for (const allocation of reporter.gpuRenderTargets()) { validateRenderTargetAllocation(allocation); renderTargets.push({ ...allocation }); } } if (kind === "geometry") { const geometry = resource as DisposableResource & { attributes?: Record< string, { array?: ArrayBufferView; data?: { array?: ArrayBufferView } } >; index?: { array?: ArrayBufferView } | null; }; const arrays = [ ...Object.values(geometry.attributes ?? {}).map( (attribute) => attribute.array ?? attribute.data?.array, ), geometry.index?.array, ].filter((array): array is ArrayBufferView => array !== undefined); for (const array of arrays) { if (seenBuffers.has(array.buffer)) continue; seenBuffers.add(array.buffer); geometryBytes += array.byteLength; } } if (kind === "texture") { const texture = resource as DisposableResource & { image?: { width?: number; height?: number; data?: ArrayBufferView }; generateMipmaps?: boolean; }; const width = Number(texture.image?.width); const height = Number(texture.image?.height); if ( !Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0 ) { throw new Error( "Owned production texture is missing measurable dimensions.", ); } const dataBytes = texture.image?.data?.byteLength; const bytesPerPixel = dataBytes === undefined ? 4 : dataBytes / (width * height); if (!Number.isInteger(bytesPerPixel) || bytesPerPixel <= 0) throw new Error( "Owned production texture has an unsupported pixel layout.", ); textures.push({ id: `owned-texture-${textureIndex}`, width, height, bytesPerPixel, layers: 1, mipmapped: texture.generateMipmaps !== false, }); textureIndex += 1; } if (kind === "target") { const target = resource as DisposableResource & { width?: number; height?: number; samples?: number; depthBuffer?: boolean; }; const width = Number(target.width); const height = Number(target.height); if ( !Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0 ) { throw new Error( "Owned production render target is missing measurable dimensions.", ); } renderTargets.push({ id: `owned-render-target-${targetIndex}`, width, height, bytesPerPixel: 4, attachments: target.depthBuffer === false ? 1 : 2, samples: Math.max(1, Number(target.samples) || 1), }); targetIndex += 1; } } return { tier: input.tier, textures, geometryBytes, renderTargets }; } } function validateRenderTargetAllocation( allocation: GpuRenderTargetAllocation, ): void { if ( allocation.id.length === 0 || !Number.isInteger(allocation.width) || allocation.width <= 0 || !Number.isInteger(allocation.height) || allocation.height <= 0 || !Number.isInteger(allocation.bytesPerPixel) || allocation.bytesPerPixel <= 0 || !Number.isInteger(allocation.attachments) || allocation.attachments <= 0 || !Number.isInteger(allocation.samples) || allocation.samples <= 0 ) { throw new Error( "Owned post-processing render target has an invalid allocation description.", ); } }