From da21c4cbb07e74869d1ba3db1830658bbafe06ed Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Sun, 5 Apr 2026 19:34:07 +0300 Subject: [PATCH] opt --- frontend/src/game/enemyVisuals.ts | 2 +- frontend/src/game/renderer.ts | 138 ++++++++++++++++++++++++++---- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/frontend/src/game/enemyVisuals.ts b/frontend/src/game/enemyVisuals.ts index 945d7e2..eb469bf 100644 --- a/frontend/src/game/enemyVisuals.ts +++ b/frontend/src/game/enemyVisuals.ts @@ -615,7 +615,7 @@ function tweakVisualForSlug(base: EnemyVisualConfig, slug: string): EnemyVisualC const h2 = (Math.imul(h, 0x9e3779b1) >>> 0) ^ slug.length; const bodyShape = BODY_SHAPE_ORDER[Math.abs(h) % BODY_SHAPE_ORDER.length] ?? base.bodyShape; const headShape = HEAD_SHAPE_ORDER[Math.abs(h2) % HEAD_SHAPE_ORDER.length] ?? base.headShape; - const sizeMul = 0.86 + ((h ^ h2) % 29) / 100; + const sizeMul = 1.5 + ((h ^ h2) % 29) / 100; return { ...base, bodyShape, diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index 991616c..7e29427 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -42,8 +42,18 @@ type SpritePoolEntry = { textureKey: string; worldX?: number; worldY?: number; + /** Set when sprite is hidden by pooling; cleared when shown again. */ + hiddenSinceMs?: number; }; +const SPRITE_HIDDEN_EVICT_MS = 3000; + +/** + * Max width×height (local px) for baking Graphics into a texture via {@link Graphics#cacheAsTexture}. + * Procedural ground fills the whole visible tile grid — caching would allocate a huge RenderTexture. + */ +const MAX_STATIC_GRAPHICS_CACHE_AREA = 2_500_000; + const TERRAIN_SEAM_BLEED_SCALE = 1.002; /** Convert world (tile) coordinates to screen (pixel) coordinates */ @@ -129,7 +139,7 @@ export class GameRenderer { | null = null; private _lastSpritesReady = false; - // Reusable Graphics objects (avoid GC in hot path) + // Reusable Graphics (avoid GC). Animated / full-screen paths are not texture-cached — see drawTowns / drawGround. private _groundGfx: Graphics | null = null; private _heroGfx: Graphics | null = null; /** Second adventurer silhouette during hero_meet (opponent at server stand position). */ @@ -372,12 +382,71 @@ export class GameRenderer { return entry; } - private _hideUnusedSprites(pool: Map, used: Set): void { + /** + * Hide pooled sprites not in `used`. After {@link SPRITE_HIDDEN_EVICT_MS} of being hidden, + * remove from the pool and recycle or destroy the sprite. + */ + private _hideUnusedSprites( + pool: Map, + used: Set, + nowMs: number, + freeList?: Sprite[], + maxFreeListSize?: number, + ): void { + const toRemove: string[] = []; for (const [key, entry] of pool) { - entry.sprite.visible = used.has(key); + if (used.has(key)) { + entry.sprite.visible = true; + entry.hiddenSinceMs = undefined; + continue; + } + entry.sprite.visible = false; + if (entry.hiddenSinceMs === undefined) { + entry.hiddenSinceMs = nowMs; + } else if (nowMs - entry.hiddenSinceMs >= SPRITE_HIDDEN_EVICT_MS) { + toRemove.push(key); + } + } + for (const key of toRemove) { + const entry = pool.get(key); + if (!entry) continue; + pool.delete(key); + const sprite = entry.sprite; + sprite.visible = false; + if ( + freeList !== undefined && + maxFreeListSize !== undefined && + freeList.length < maxFreeListSize + ) { + freeList.push(sprite); + } else { + sprite.destroy(); + } } } + /** Drop GPU cache before mutating vector paths (Pixi 8: prefer cacheAsTexture over legacy cacheAsBitmap). */ + private _releaseGraphicsTextureCache(gfx: Graphics | null): void { + if (!gfx?.isCachedAsTexture) return; + gfx.cacheAsTexture(false); + } + + /** + * Bake static vector layers to a single quad. Skips huge bounds (see {@link MAX_STATIC_GRAPHICS_CACHE_AREA}). + * Do not use for per-frame animated Graphics (hero, enemy, NPC, thought bubbles). + */ + private _cacheStaticGraphicsIfBounded(gfx: Graphics | null): void { + if (!gfx || gfx.isCachedAsTexture) return; + const b = gfx.getLocalBounds(); + const area = Math.max(0, b.width) * Math.max(0, b.height); + if (area < 16 || area > MAX_STATIC_GRAPHICS_CACHE_AREA) return; + gfx.cacheAsTexture({ + antialias: false, + resolution: 1, + scaleMode: 'linear', + }); + } + private _evictSpritesOutsideTileBounds( pool: Map, minX: number, @@ -601,6 +670,9 @@ export class GameRenderer { /** * Draw visible ground tiles and decorative objects. * Terrain is procedurally generated — the world is endless. + * + * Vector fallback spans the full visible tile grid; we do not `cacheAsTexture` on `_groundGfx` + * (bounds would create a very large RenderTexture). Prefer terrain/object sprites when loaded. */ drawGround(camera: Camera, screenWidth: number, screenHeight: number): void { const gfx = this._groundGfx; @@ -817,9 +889,22 @@ export class GameRenderer { } } + const hideSpritesNow = performance.now(); if (spritesReady) { - this._hideUnusedSprites(this._tileSpritePool, usedTileSprites); - this._hideUnusedSprites(this._objectSpritePool, usedObjectSprites); + this._hideUnusedSprites( + this._tileSpritePool, + usedTileSprites, + hideSpritesNow, + this._tileSpriteFreeList, + 2800, + ); + this._hideUnusedSprites( + this._objectSpritePool, + usedObjectSprites, + hideSpritesNow, + this._objectSpriteFreeList, + 1800, + ); const evictPaddingTiles = 8; this._evictSpritesOutsideTileBounds( this._tileSpritePool, @@ -840,8 +925,20 @@ export class GameRenderer { 1800, ); } else { - this._hideUnusedSprites(this._tileSpritePool, this._emptySpriteSet); - this._hideUnusedSprites(this._objectSpritePool, this._emptySpriteSet); + this._hideUnusedSprites( + this._tileSpritePool, + this._emptySpriteSet, + hideSpritesNow, + this._tileSpriteFreeList, + 2800, + ); + this._hideUnusedSprites( + this._objectSpritePool, + this._emptySpriteSet, + hideSpritesNow, + this._objectSpriteFreeList, + 1800, + ); } } @@ -1151,9 +1248,9 @@ export class GameRenderer { entry.sprite.y = cy; entry.sprite.roundPixels = true; const th = Math.max(1, tex.height || tex.width || 48); - const targetH = 52; + const targetH = 100; entry.sprite.scale.set(targetH / th); - entry.sprite.zIndex = cy + 100; + entry.sprite.zIndex = cy + 90; entry.sprite.visible = true; drawEnemyHpBarOnly(gfx, enemySlug, enemyArchetype, cx, cy, hp, maxHp); return; @@ -1727,6 +1824,8 @@ export class GameRenderer { this._lastTownDrawMs = now; this._lastTownCameraX = cx; this._lastTownCameraY = cy; + this._releaseGraphicsTextureCache(gfx); + this._releaseGraphicsTextureCache(iconGfx); gfx.clear(); iconGfx.clear(); @@ -1859,7 +1958,9 @@ export class GameRenderer { labelIdx++; } - this._hideUnusedSprites(this._buildingSpritePool, usedBuildingSprites); + this._hideUnusedSprites(this._buildingSpritePool, usedBuildingSprites, now); + this._cacheStaticGraphicsIfBounded(gfx); + this._cacheStaticGraphicsIfBounded(iconGfx); } /** @@ -1875,6 +1976,7 @@ export class GameRenderer { ): void { const gfx = this._npcGfx; if (!gfx) return; + // Per-frame idle sway + culling: keep vector path, no cacheAsTexture (would stale or rebuild every frame). gfx.clear(); for (const lbl of this._npcLabels) { @@ -2109,7 +2211,7 @@ export class GameRenderer { } } - this._hideUnusedSprites(this._npcSpritePool, usedNpcSprites); + this._hideUnusedSprites(this._npcSpritePool, usedNpcSprites, now); } /** @@ -2158,7 +2260,13 @@ export class GameRenderer { } } - this._hideUnusedSprites(this._townObjectSpritePool, usedTownObjects); + this._hideUnusedSprites( + this._townObjectSpritePool, + usedTownObjects, + performance.now(), + this._townObjectSpriteFreeList, + 1200, + ); } /** Clear NPC visuals when there are none to render */ @@ -2167,7 +2275,7 @@ export class GameRenderer { for (const lbl of this._npcLabels) { lbl.visible = false; } - this._hideUnusedSprites(this._npcSpritePool, this._emptySpriteSet); + this._hideUnusedSprites(this._npcSpritePool, this._emptySpriteSet, performance.now()); } /** @@ -2261,7 +2369,7 @@ export class GameRenderer { gfx.zIndex = cy + 100; } - this._hideUnusedSprites(this._nearbyHeroSpritePool, usedNearbySprites); + this._hideUnusedSprites(this._nearbyHeroSpritePool, usedNearbySprites, performance.now()); } /** @@ -2335,7 +2443,7 @@ export class GameRenderer { for (const lbl of this._nearbyHeroLabels) { lbl.visible = false; } - this._hideUnusedSprites(this._nearbyHeroSpritePool, this._emptySpriteSet); + this._hideUnusedSprites(this._nearbyHeroSpritePool, this._emptySpriteSet, performance.now()); } /** Sort entity layer by y-position for correct isometric depth */