diff --git a/frontend/src/canvas/TextureManager.ts b/frontend/src/canvas/TextureManager.ts index 09be85a..4072684 100644 --- a/frontend/src/canvas/TextureManager.ts +++ b/frontend/src/canvas/TextureManager.ts @@ -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(); @@ -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 { 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(); } diff --git a/frontend/src/canvas/sprites/ImageSprite.ts b/frontend/src/canvas/sprites/ImageSprite.ts index e232863..bb2c902 100644 --- a/frontend/src/canvas/sprites/ImageSprite.ts +++ b/frontend/src/canvas/sprites/ImageSprite.ts @@ -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) {