Files
ark-stronghold-protocol/public/js/render/impostor.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

169 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// render/impostor.js — shared render-target atlas for Spine impostors (render/units.js).
//
// Crowded fields render skeletons through impostors (a sprite showing a cached image of the skeleton, refreshed
// every few frames), and skeletons with clipping attachments always do: pixi-spine implements clipping with
// stencil masks, and every mask inside the main (MSAA) pass costs a render-pass break with a full-screen
// load/store on tiled GPUs. Instead of one RenderTexture per unit (one framebuffer switch each), every due
// impostor of a frame is drawn into slots of a few shared atlas pages in ONE render call per page, before the
// main pass starts:
// page = RenderTexture (2048² physical px) + a batch container [erasers…, skeletons…]
// slot = shelf-packed rect (16 px grid) owned by one unit; erased (ERASE blend quad) then redrawn when due
// Pages are created on demand (≤ MAX_PAGES); when every page is full `alloc` returns null and the unit renders
// directly (still correct, just not batched). A shelf whose last slot is freed is emptied for reuse, and empty shelves
// at the top of a page are given back (a page whose slots are all freed — every battle / prep rebuild — starts over):
// without that, zooming a crowd in and out (the enemy pen camera) left pages full of shelves of stale sizes and the
// next battle's units fell back to one private render target each. Clipped skeletons get slots on small separate "clip" pages: their
// stencil clears break the page's render pass, and the cost of a break scales with the page size.
//
// const imp = new ImpostorAtlas(renderer)
// const slot = imp.alloc(w, h) // CSS px; null when full
// imp.draw(slot, displayObject, matrixLike { a, d, tx, ty }) // queue for this frame (object must be parked here)
// imp.park(displayObject) / imp.unpark(displayObject) // keep skeletons of impostor units in the batch
// imp.flush() // once per frame, after every unit update, before the main render
// imp.free(slot) ; imp.destroy()
const MAX_PAGES = { main: 3, clip: 2 };
const PAGE_PX = { main: 2048, clip: 1024 };
const GRID = 16;
export class ImpostorAtlas {
/** @param {any} renderer PIXI renderer */
constructor(renderer) {
this.P = globalThis.PIXI;
this.R = renderer;
this.res = Math.max(1, Math.min(2, renderer.resolution || 1));
this.pages = [];
this.parked = new this.P.Container(); // skeletons of impostor units (never rendered directly)
this.parked.visible = false;
this.stats = { pages: 0, slots: 0, drawn: 0, full: 0 };
}
/** CSS px per side of a page of `kind`. */
sizeOf(kind) { return Math.floor(PAGE_PX[kind] / this.res); }
_page(kind) {
if (this.pages.filter((p) => p.kind === kind).length >= MAX_PAGES[kind]) return null;
const P = this.P;
const size = this.sizeOf(kind);
const rt = P.RenderTexture.create({ width: size, height: size, resolution: this.res });
try { rt.framebuffer.enableStencil(); } catch { /* masks fall back to scissor */ }
const batch = new P.Container();
const erasers = new P.Container();
const bodies = new P.Container();
batch.addChild(erasers, bodies);
const page = { kind, size, rt, batch, erasers, bodies, shelves: [], nextY: 0, queued: 0, cleared: false, index: this.pages.length };
this.pages.push(page);
this.stats.pages = this.pages.length;
return page;
}
/** Allocate a slot of at least w×h CSS px (rounded up to the grid); null when every page is full. */
alloc(w, h, { clip = false } = {}) {
const kind = clip ? 'clip' : 'main';
const W = Math.max(GRID, Math.ceil(w / GRID) * GRID), H = Math.max(GRID, Math.ceil(h / GRID) * GRID);
if (W > this.sizeOf(kind) || H > this.sizeOf(kind)) return null;
const pages = this.pages.filter((p) => p.kind === kind);
for (let i = 0; i <= pages.length; i++) {
const page = pages[i] || this._page(kind);
if (!page) break;
const s = this._allocIn(page, W, H);
if (s) { this.stats.slots++; return s; }
}
this.stats.full++;
return null;
}
_allocIn(page, W, H) {
// reuse a freed slot of a fitting size first
for (const sh of page.shelves) {
if (sh.h < H || sh.h > H * 1.5) continue;
const k = sh.free.findIndex((f) => f.w >= W && f.w <= W * 1.5);
if (k >= 0) { const f = sh.free.splice(k, 1)[0]; return this._slot(page, sh, f.x, f.w); }
if (sh.x + W <= page.size) { const x = sh.x; sh.x += W; return this._slot(page, sh, x, W); }
}
if (page.nextY + H > page.size) return null;
const sh = { y: page.nextY, h: H, x: W, free: [], live: 0 };
page.nextY += H;
page.shelves.push(sh);
return this._slot(page, sh, 0, W);
}
_slot(page, sh, x, w) {
const P = this.P;
const y = sh.y, h = sh.h;
const eraser = new P.Sprite(P.Texture.WHITE);
eraser.blendMode = P.BLEND_MODES.ERASE;
eraser.position.set(x, y);
eraser.width = w; eraser.height = h;
eraser.visible = false;
page.erasers.addChild(eraser);
const tex = new P.Texture(page.rt.baseTexture, new P.Rectangle(x, y, w, h));
sh.live = (sh.live || 0) + 1;
return { page, shelf: sh, clip: page.kind === 'clip', x, y, w, h, eraser, tex, freed: false };
}
free(slot) {
if (!slot || slot.freed) return;
slot.freed = true;
this.stats.slots = Math.max(0, this.stats.slots - 1);
try { slot.eraser.destroy(); } catch { /* ignore */ }
try { slot.tex.destroy(false); } catch { /* ignore */ }
const page = slot.page;
const sh = slot.shelf && page.shelves.includes(slot.shelf) ? slot.shelf : page.shelves.find((s) => s.y === slot.y);
if (!sh) return;
sh.live = Math.max(0, (sh.live || 0) - 1);
if (sh.live > 0) { sh.free.push({ x: slot.x, w: slot.w }); return; }
// the shelf's last slot: empty it; empty shelves at the top of the page are given back
sh.x = 0;
sh.free.length = 0;
const L = page.shelves;
while (L.length && L[L.length - 1].live === 0) {
const top = L.pop();
page.nextY = top.y;
}
}
/** Keep a skeleton here while its unit is an impostor (hidden unless drawn this frame). */
park(obj) { if (obj && obj.parent !== this.parked) this.parked.addChild(obj); obj.visible = false; }
unpark(obj) { if (obj && obj.parent === this.parked) this.parked.removeChild(obj); if (obj) obj.visible = true; }
/** Queue `obj` to be drawn into `slot` this frame with the local transform (scale a/d, offset tx/ty in the slot). */
draw(slot, obj, m) {
if (!slot || slot.freed || !obj) return;
const page = slot.page;
slot.eraser.visible = true;
obj.position.set(slot.x + m.tx, slot.y + m.ty);
obj.scale.set(m.a, m.d);
obj.visible = true;
if (obj.parent !== page.bodies) page.bodies.addChild(obj);
page.queued++;
}
/** Render every queued slot: one render call per page with queued work. */
flush() {
let drawn = 0;
for (const page of this.pages) {
if (!page.queued) continue;
try {
this.R.render(page.batch, { renderTexture: page.rt, clear: !page.cleared, skipUpdateTransform: false });
page.cleared = true;
} catch { /* lost context etc. */ }
drawn += page.queued;
page.queued = 0;
for (const e of page.erasers.children) e.visible = false;
// back to the parking lot until due again
for (const b of [...page.bodies.children]) this.park(b);
}
this.stats.drawn = drawn;
}
destroy() {
for (const page of this.pages) {
try { page.batch.destroy({ children: false }); } catch { /* ignore */ }
try { page.rt.destroy(true); } catch { /* ignore */ }
}
this.pages = [];
try { this.parked.destroy({ children: false }); } catch { /* ignore */ }
}
}