Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
53 changes: 53 additions & 0 deletions src/core/renderers/CoreShaderNode.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>) => {
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);
});
});
23 changes: 23 additions & 0 deletions src/core/renderers/CoreShaderNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export class CoreShaderNode<Props extends object = Record<string, unknown>> {
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;
Expand Down Expand Up @@ -162,6 +168,23 @@ export class CoreShaderNode<Props extends object = Record<string, unknown>> {
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 &&
Expand Down
1 change: 0 additions & 1 deletion src/core/renderers/canvas/CanvasShaderNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export class CanvasShaderNode<
> extends CoreShaderNode<Props> {
private updater: ((node: CoreNode, props?: Props) => void) | undefined =
undefined;
private valueKey: string = '';
computed: Partial<Computed> = {};
applySNR: boolean;
render: CanvasShaderType<Props>['render'];
Expand Down
1 change: 0 additions & 1 deletion src/core/renderers/webgl/WebGlShaderNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
45 changes: 45 additions & 0 deletions src/core/textures/SubTexture.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
13 changes: 13 additions & 0 deletions src/core/textures/SubTexture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextureData> {
// Check if parent texture is loaded
return new Promise((resolve, reject) => {
Expand Down
4 changes: 4 additions & 0 deletions src/core/textures/Texture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
Loading