fix(refboard): reference-counted textures prevent shared source crash

When duplicated images share the same assetKey, Assets.unload()
was destroying the shared texture source — crashing all sprites
using it (null addressModeU/alphaMode in WebGL renderer).

TextureManager now tracks refCount per asset. Only calls
Assets.unload() when the last reference is released. ImageSprite
calls release() instead of unload() on texture teardown.
This commit is contained in:
Hiren Kangad
2026-03-10 11:12:21 +05:30
parent 1ffb7e9d41
commit 9716beee8a
2 changed files with 36 additions and 12 deletions
+35 -11
View File
@@ -2,15 +2,20 @@ import { Texture, Assets } from "pixi.js";
interface TextureEntry {
texture: Texture;
url: string; // needed for Assets.unload()
url: string;
refCount: number;
}
/**
* TextureManager — GPU texture cache.
* TextureManager — GPU texture cache with reference counting.
*
* No internal LRU eviction — the viewport culling system (PixiCanvas)
* manages loading/unloading based on proximity. This avoids destroying
* textures that sprites still reference (which crashes PixiJS v8).
* Multiple ImageSprites can share the same assetKey (duplicated images).
* Assets.unload() destroys the shared texture source, so we must only
* call it when the LAST reference is released. Otherwise PixiJS v8
* crashes with null source errors (alphaMode, addressModeU).
*
* The viewport culling system (PixiCanvas) calls load/release to
* manage which textures consume GPU memory.
*/
export class TextureManager {
private cache = new Map<string, TextureEntry>();
@@ -24,12 +29,13 @@ export class TextureManager {
}
/**
* Load a texture for the given asset.
* Load a texture for the given asset. Increments ref count.
* Returns cached texture if available, otherwise fetches and caches.
*/
async load(assetKey: string): Promise<Texture> {
const existing = this.cache.get(assetKey);
if (existing) {
existing.refCount++;
return existing.texture;
}
@@ -43,15 +49,21 @@ export class TextureManager {
return Texture.EMPTY;
}
this.cache.set(assetKey, { texture, url });
this.cache.set(assetKey, { texture, url, refCount: 1 });
return texture;
}
/** Remove a specific texture from the cache via Assets.unload(). */
unload(assetKey: string): void {
/**
* Release a reference to a texture. Only actually unloads from GPU
* when the last reference is released.
*/
release(assetKey: string): void {
const entry = this.cache.get(assetKey);
if (!entry) return;
entry.refCount--;
if (entry.refCount > 0) return; // other sprites still using it
this.cache.delete(assetKey);
try {
Assets.unload(entry.url);
@@ -60,10 +72,22 @@ export class TextureManager {
}
}
/** Force unload (used by clear). */
unload(assetKey: string): void {
const entry = this.cache.get(assetKey);
if (!entry) return;
this.cache.delete(assetKey);
try {
Assets.unload(entry.url);
} catch {
// ignore
}
}
/** Unload all cached textures. */
clear(): void {
for (const [key] of this.cache) {
this.unload(key);
for (const [, entry] of this.cache) {
try { Assets.unload(entry.url); } catch { /* ignore */ }
}
this.cache.clear();
}
+1 -1
View File
@@ -113,7 +113,7 @@ export class ImageSprite extends Container {
unloadTexture(): void {
if (!this.loaded) return;
this.textures.unload(this.assetKey);
this.textures.release(this.assetKey);
// Remove sprite from display tree entirely — avoids PixiJS v8 render crash
if (this._sprite) {