Files
ark-stronghold-protocol/public/js/render/impostor.js
T

206 lines
9.9 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…, slot boxes…]
// slot = shelf-packed rect (16 px grid) owned by one unit; erased (ERASE blend quad over exactly the slot) then
// redrawn when due. The skeleton is drawn inside the slot's own box — a container masked by the slot
// rectangle, which PIXI turns into a scissor rect (an axis-aligned rectangle mask: no stencil, no render-pass
// break) — so nothing a unit draws can land outside its slot: a pose wider than the unit's box (render/units.js
// _impBox) is cut at the slot's edge instead of painting into the neighbouring slots or the free space
// around them, where no eraser ever cleared it again (GitHub #542: 野鬃's flag, 浊心斯卡蒂's S3 flight).
// Every redraw therefore starts from an empty slot: its eraser clears all the slot can hold.
// 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. The scissor of the
// slot box also bounds those stencil clears to the slot. Cost of the clipping: one batch flush per drawn slot (each
// skeleton is its own draw call or calls; skeletons sharing an atlas texture no longer batch across slots), each with a
// vertex buffer of its own (render/app.js turns PIXI's BatchRenderer.canUploadSameBuffer off — PIXI's own setting on iOS:
// uploading the batch's one shared buffer again for every flush stalled Apple GPUs once the slots were clipped).
//
// 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 boxes = new P.Container();
batch.addChild(erasers, boxes);
const page = { kind, size, rt, batch, erasers, boxes, 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);
// the slot's own pass: its skeleton is drawn in `box`, clipped to the slot rectangle (a scissor rect)
const mask = new P.Graphics();
mask.beginFill(0xffffff, 1);
mask.drawRect(x, y, w, h);
mask.endFill();
const box = new P.Container();
box.addChild(mask);
box.mask = mask;
box.visible = false;
page.boxes.addChild(box);
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, box, mask, tex, freed: false };
}
/** Back to the parking lot: every skeleton queued in a slot's box (never destroyed here — the actors own them). */
_unqueue(slot) {
for (const b of [...slot.box.children]) if (b !== slot.mask) this.park(b);
slot.box.visible = 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 {
this._unqueue(slot); // a skeleton queued this frame (its unit left impostor mode) is not destroyed with the box
slot.box.mask = null;
slot.box.destroy({ children: true });
} 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 !== slot.box) slot.box.addChild(obj);
slot.box.visible = true;
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 box of page.boxes.children) {
if (!box.visible) continue;
box.visible = false;
for (const b of [...box.children]) if (b !== box.mask) this.park(b);
}
}
this.stats.drawn = drawn;
}
destroy() {
for (const page of this.pages) {
for (const box of page.boxes.children) for (const b of [...box.children]) if (b !== box.mask) box.removeChild(b);
try { page.batch.destroy({ children: true }); } catch { /* ignore */ }
try { page.rt.destroy(true); } catch { /* ignore */ }
}
this.pages = [];
try { this.parked.destroy({ children: false }); } catch { /* ignore */ }
}
}