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.
135 lines
4.1 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|