Files
refboard-ayon/frontend/src/canvas/sprites/ImageSprite.ts
T
Hiren Kangad 9716beee8a 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.
2026-03-10 11:12:21 +05:30

135 lines
4.1 KiB
TypeScript

import { Container, Sprite, Texture, Graphics } from "pixi.js";
import { TextureManager } from "../TextureManager";
/**
* A Container holding a shadow graphic + sprite with lazy texture loading.
* Uses a lightweight Graphics shadow instead of DropShadowFilter (GPU-heavy).
*
* Textures are loaded/unloaded by the viewport culling system (PixiCanvas).
* The constructor does NOT start loading — call loadTexture() when near viewport.
* The Sprite is NOT added to the display tree until a real texture is loaded,
* because PixiJS v8 crashes when rendering a Sprite with Texture.EMPTY.
*/
// Shadow defaults (resting state)
const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 };
// Shadow lifted state (during drag)
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
export class ImageSprite extends Container {
readonly assetKey: string;
private textures: TextureManager;
loaded = false;
private loading = false;
private placeholder: Graphics | null = null;
private _sprite: Sprite | null = null;
private _shadow: Graphics;
private _naturalWidth: number;
private _naturalHeight: number;
constructor(
assetKey: string,
w: number,
h: number,
textures: TextureManager,
) {
super();
this.assetKey = assetKey;
this.textures = textures;
this._naturalWidth = w;
this._naturalHeight = h;
// Shadow: simple dark rect behind the sprite (cheap, no GPU filter)
this._shadow = new Graphics();
this._drawShadow(SHADOW_REST);
this.addChild(this._shadow);
// Placeholder: dark rect shown until texture is loaded by culling system
const placeholder = new Graphics();
placeholder.rect(0, 0, w, h).fill(0x2a2a2a);
this.placeholder = placeholder;
this.addChild(placeholder);
// NOTE: Sprite is created lazily in loadTexture() — NOT added here.
}
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
this._shadow.clear();
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
}
/** Expand shadow for drag-lift effect. */
liftShadow(): void {
this._drawShadow(SHADOW_LIFT);
}
/** Restore shadow to resting state. */
dropShadow(): void {
this._drawShadow(SHADOW_REST);
}
/** The underlying sprite's texture (used by clipboard for resolution detection). */
get texture(): Texture {
return this._sprite?.texture ?? Texture.EMPTY;
}
/** Load the full-res texture. Called by viewport culling when near viewport. */
async loadTexture(): Promise<void> {
if (this.loaded || this.loading) return;
this.loading = true;
try {
const tex = await this.textures.load(this.assetKey);
if (this.destroyed) return;
// Create sprite with real texture and add to display tree
const sprite = new Sprite(tex);
sprite.width = this._naturalWidth;
sprite.height = this._naturalHeight;
this._sprite = sprite;
// Insert before placeholder (so placeholder is on top until removed)
this.addChild(sprite);
this.loaded = true;
// Remove placeholder after successful load
if (this.placeholder) {
this.removeChild(this.placeholder);
this.placeholder.destroy();
this.placeholder = null;
}
} catch (err) {
console.warn(
`[ImageSprite] Failed to load "${this.assetKey}":`,
err,
);
} finally {
this.loading = false;
}
}
/** Unload texture to free GPU memory. Called by viewport culling when far from viewport. */
unloadTexture(): void {
if (!this.loaded) return;
this.textures.release(this.assetKey);
// Remove sprite from display tree entirely — avoids PixiJS v8 render crash
if (this._sprite) {
this.removeChild(this._sprite);
this._sprite.destroy();
this._sprite = null;
}
this.loaded = false;
// Restore placeholder
if (!this.placeholder) {
const placeholder = new Graphics();
placeholder.rect(0, 0, this._naturalWidth, this._naturalHeight).fill(0x2a2a2a);
this.placeholder = placeholder;
this.addChild(placeholder);
}
}
}