Dispose scene resources before renderer allocation records are cleared and release the bloom high-pass material. Avoid unused bloom depth buffers and the final screen pass swap, keeping the same visual output and quality. Batch sign-stage layout reads before applying existing masks. Validated approved assets, types, client and SSR builds, prerendered content, artifacts and transfer budgets. Checked 32 matching transition renders, 66 browser interactions and repeated quality-change resource cleanup.
306 lines
8.8 KiB
TypeScript
306 lines
8.8 KiB
TypeScript
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<Record<SceneResourceKind, number>>;
|
|
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<DisposableResource, SceneResourceKind>();
|
|
private readonly listeners = new Set<() => void>();
|
|
private readonly observers = new Set<DisconnectableResource>();
|
|
private readonly timers = new Map<number, (id: number) => void>();
|
|
private raf: { id: number; cancel: (id: number) => void } | null = null;
|
|
|
|
own<T extends DisposableResource>(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<T extends DisconnectableResource>(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<SceneResourceKind, number>;
|
|
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<GpuRenderTargetAllocation, "id">;
|
|
}): SceneGpuBudgetSnapshot {
|
|
let geometryBytes = 0;
|
|
const seenBuffers = new Set<ArrayBufferLike>();
|
|
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<GpuRenderTargetReporter>;
|
|
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.",
|
|
);
|
|
}
|
|
}
|