diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index d96ccd0..4568a58 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -2020,6 +2020,13 @@ export class CoreNode extends EventEmitter { this.stage.untrackTimedNode(this); } + // Release this node's shader-value cache entry so the shader manager's idle + // cleanup can reclaim it. Skip the shared default shader (never per-node). + const shader = this.props.shader; + if (shader !== null && shader !== this.stage.defShaderNode) { + shader.detachNode(); + } + if (USE_RTT && this.rtt === true) { this.stage.renderer.removeRTTNode(this); } diff --git a/src/core/renderers/CoreShaderNode.test.ts b/src/core/renderers/CoreShaderNode.test.ts new file mode 100644 index 0000000..3dc3557 --- /dev/null +++ b/src/core/renderers/CoreShaderNode.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CoreShaderNode } from './CoreShaderNode.js'; +import type { Stage } from '../Stage.js'; +import type { CoreShaderType } from './CoreShaderNode.js'; + +const makeNode = (mutate: ReturnType) => { + const stage = { + shManager: { mutateShaderValueUsage: mutate }, + } as unknown as Stage; + return new CoreShaderNode( + 'test', + { time: undefined } as CoreShaderType, + stage, + ); +}; + +describe('CoreShaderNode.detachNode', () => { + it('releases the held value-cache key so idle cleanup can evict it', () => { + const mutate = vi.fn(); + const node = makeNode(mutate); + node.valueKey = 'color:1;node-width:10node-height:10'; + + node.detachNode(); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith( + 'color:1;node-width:10node-height:10', + -1, + ); + // Key is cleared so a double-detach can't decrement twice. + expect(node.valueKey).toBe(''); + }); + + it('is a no-op when no value key is held', () => { + const mutate = vi.fn(); + const node = makeNode(mutate); + + node.detachNode(); + + expect(mutate).not.toHaveBeenCalled(); + }); + + it('does not double-decrement on repeated detach', () => { + const mutate = vi.fn(); + const node = makeNode(mutate); + node.valueKey = 'k'; + + node.detachNode(); + node.detachNode(); + + expect(mutate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/renderers/CoreShaderNode.ts b/src/core/renderers/CoreShaderNode.ts index 991453e..0f3232d 100644 --- a/src/core/renderers/CoreShaderNode.ts +++ b/src/core/renderers/CoreShaderNode.ts @@ -96,6 +96,12 @@ export class CoreShaderNode> { protected node: CoreNode | null = null; readonly time: CoreShaderType['time'] = undefined; update: (() => void) | undefined = undefined; + /** + * The shader-value cache key currently held by this node (set by the + * subclass `update()` after the most recent successful value resolution). + * Tracked on the base so {@link detachNode} can release it on teardown. + */ + valueKey = ''; private _valueKeyCache = ''; private _valueKeyDirty = true; private _lastW = 0; @@ -162,6 +168,23 @@ export class CoreShaderNode> { this.node = node; } + /** + * Release this node's currently-held shader-value cache entry so the shader + * manager's idle `cleanup()` can reclaim it, and detach from the owning node. + * + * Called from {@link CoreNode.destroy}. Mirrors the `prevKey` release done on + * every value-key change in the subclass `update()`; without it a destroyed + * node's last value-cache entry stays pinned at usage >= 1 forever and can + * never be evicted. + */ + detachNode() { + if (this.valueKey.length > 0) { + this.stage.shManager.mutateShaderValueUsage(this.valueKey, -1); + this.valueKey = ''; + } + this.node = null; + } + createValueKey() { if ( this._valueKeyDirty === false && diff --git a/src/core/renderers/canvas/CanvasShaderNode.ts b/src/core/renderers/canvas/CanvasShaderNode.ts index 861213f..0ad2bb1 100644 --- a/src/core/renderers/canvas/CanvasShaderNode.ts +++ b/src/core/renderers/canvas/CanvasShaderNode.ts @@ -26,7 +26,6 @@ export class CanvasShaderNode< > extends CoreShaderNode { private updater: ((node: CoreNode, props?: Props) => void) | undefined = undefined; - private valueKey: string = ''; computed: Partial = {}; applySNR: boolean; render: CanvasShaderType['render']; diff --git a/src/core/renderers/webgl/WebGlShaderNode.ts b/src/core/renderers/webgl/WebGlShaderNode.ts index 534f3c5..78da5b9 100644 --- a/src/core/renderers/webgl/WebGlShaderNode.ts +++ b/src/core/renderers/webgl/WebGlShaderNode.ts @@ -53,7 +53,6 @@ export class WebGlShaderNode< readonly program: WebGlShaderProgram; private updater: ((node: CoreNode, props?: Props) => void) | undefined = undefined; - private valueKey: string = ''; uniforms: UniformCollection = { single: {}, vec2: {}, diff --git a/src/core/textures/SubTexture.test.ts b/src/core/textures/SubTexture.test.ts new file mode 100644 index 0000000..57d9c7d --- /dev/null +++ b/src/core/textures/SubTexture.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SubTexture } from './SubTexture.js'; +import { ImageTexture } from './ImageTexture.js'; +import type { CoreTextureManager } from '../CoreTextureManager.js'; + +const flushMicrotasks = () => Promise.resolve(); + +describe('SubTexture lifecycle', () => { + it('detaches its parent-texture listeners on destroy', async () => { + // 'initial' state means the constructor's microtask attaches listeners + // without synchronously firing any of the state handlers. + const parent = { + state: 'initial', + dimensions: null, + error: null, + on: vi.fn(), + off: vi.fn(), + }; + const txManager = { + maxRetryCount: 0, + platform: {}, + resolveParentTexture: () => parent, + } as unknown as CoreTextureManager; + + const parentImage = new ImageTexture(txManager, {} as never); + const sub = new SubTexture(txManager, { + texture: parentImage, + x: 0, + y: 0, + w: 10, + h: 10, + }); + + // Listeners are attached in a microtask after construction. + await flushMicrotasks(); + expect(parent.on).toHaveBeenCalledTimes(4); + + sub.destroy(); + + const offEvents = (parent.off.mock.calls as Array<[string]>) + .map((c) => c[0]) + .sort(); + expect(offEvents).toEqual(['failed', 'freed', 'loaded', 'loading']); + }); +}); diff --git a/src/core/textures/SubTexture.ts b/src/core/textures/SubTexture.ts index 66e1024..ec0ffbf 100644 --- a/src/core/textures/SubTexture.ts +++ b/src/core/textures/SubTexture.ts @@ -133,6 +133,19 @@ export class SubTexture extends Texture { this.parentTexture.setRenderableOwner(this.subtextureId, isRenderable); } + override destroy(): void { + // Detach from the parent texture's event emitter. The parent is typically a + // long-lived shared atlas (preventCleanup), so without this each destroyed + // SubTexture — and everything its handlers capture — would be retained by + // the parent's listener lists for the rest of the session. + const parentTx = this.parentTexture; + parentTx.off('loading', this.onParentTxLoading); + parentTx.off('loaded', this.onParentTxLoaded); + parentTx.off('failed', this.onParentTxFailed); + parentTx.off('freed', this.onParentTxFreed); + super.destroy(); + } + override async getTextureSource(): Promise { // Check if parent texture is loaded return new Promise((resolve, reject) => { diff --git a/src/core/textures/Texture.ts b/src/core/textures/Texture.ts index e2ba3d0..84a33ca 100644 --- a/src/core/textures/Texture.ts +++ b/src/core/textures/Texture.ts @@ -370,6 +370,10 @@ export abstract class Texture extends EventEmitter { // Always free texture data regardless of state this.freeTextureData(); + + // Drop any remaining subscribers so a texture destroyed while something + // still holds a listener does not retain it (and its captures). + this.removeAllListeners(); } /**