0.2.6-dev: impostor slots sized from every clip a unit plays and clipped to themselves (GitHub #542 by @ImPQlc: in a crowd 野鬃's idle flag, 浊心斯卡蒂's S3 flight and 隐现's pistol were cut, offset or left stray pixels) — spine.js measures each skeleton's clips once within a 2.5 ms frame budget, units.js impostorBox covers the setup pose and the played clips (a clip past 4× / 2× of that core is drawn directly while it plays, the slot kept), impostor.js draws each slot's skeleton under a scissor rect of the slot; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-13 03:30:48 +08:00
1 parent e825254850
commit c45d86f70c
8 files changed
+1153 -22

No files matched your search

+4 -1
View File
@@ -103,7 +103,8 @@
// Assault prep = the boss field).
// Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates.
// Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per
// frame before the main pass.
// frame before the main pass; each frame also measures queued Spine clips within a small time budget
// (spine.js stepClipBounds: the impostor boxes, GitHub #542).
// canPlace: `(uid, target, piece) => bool` (the game UI's form: target = {area:'board',row,col}|{area:'hand',idx})
// or `(piece, row, col, target) => bool` (functions declaring ≥ 3 parameters). Throwing ⇒ illegal.
// Piece shape (DESIGN §8.3): { uid, kind: 'chess'|'item'|'token', id, golden, tier, items: [{uid,id}], count, ownerUid }.
@@ -125,6 +126,7 @@ import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, s
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
import { loadBoardArt } from './boardArt.js';
import { ImpostorAtlas } from './impostor.js';
import { stepClipBounds } from './spine.js';
import { loadThree, loadBoardPack, loadScenePack, webgl2Available, boardArtListed } from './board3d/load.js';
import { BoardScene } from './board3d/scene.js';
import { unionAreas } from './board3d/layout.js';
@@ -1744,6 +1746,7 @@ export async function createFieldView(host, options = {}) {
if (leader && !leaderHidden) leader.view.update(dt, cam, clock);
tiles.update(dt);
fx.update(dt);
stepClipBounds();
impostors.flush();
} catch (err) {
if (!frame.warned) { frame.warned = true; console.error('[render] frame failed', err); }
+45 -10
View File
@@ -6,14 +6,22 @@
// 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
// 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.
// 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).
//
// const imp = new ImpostorAtlas(renderer)
// const slot = imp.alloc(w, h) // CSS px; null when full
@@ -49,9 +57,9 @@ export class ImpostorAtlas {
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 };
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;
@@ -97,9 +105,25 @@ export class ImpostorAtlas {
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, tex, freed: false };
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) {
@@ -107,6 +131,11 @@ export class ImpostorAtlas {
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);
@@ -135,7 +164,8 @@ export class ImpostorAtlas {
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);
if (obj.parent !== slot.box) slot.box.addChild(obj);
slot.box.visible = true;
page.queued++;
}
@@ -152,14 +182,19 @@ export class ImpostorAtlas {
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);
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) {
try { page.batch.destroy({ children: false }); } catch { /* ignore */ }
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 = [];
+178
View File
@@ -33,6 +33,8 @@
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
// redraws the model, only advances `clock`)
// clipBounds(names) skeleton-space bounds of the setup pose and of those clips (sampled once per
// skeleton data, see "clip bounds" below; render/units.js sizes the impostor box)
// Each attack plays its clip once, then the end clip (if any) and base — except the attacks of a skill
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
// attacks while it runs.
@@ -110,6 +112,161 @@ function slotCentre(slot, att, buf, out) {
return Number.isFinite(out[0]) && Number.isFinite(out[1]);
}
// ---- clip bounds (GitHub #542) ------------------------------------------------------------------------------------
// What a skeleton can draw, in skeleton space (pixi-spine's y-down world; rects are [x0, y0, x1, y1]): its setup pose and
// each clip, sampled once per skeleton data and shared by every actor built from it. render/units.js sizes a unit's
// impostor slot from the clips the unit plays — a slot sized from the first pose drawn (plus a margin) cut 野鬃's idle
// flag and 浊心斯卡蒂's head in her S3 flight, and 598 of the roster's 681 skeletons play a clip that leaves a slot sized
// that way from their idle's first frame. A clip is posed on a scratch skeleton of the same runtime (never an actor's
// own) CLIP_SAMPLE_HZ times a second, at most CLIP_SAMPLE_MAX + 1 times (a longer clip is sampled more sparsely); every
// slot that is drawn (alpha > 0) counts — a region with its whole attachment rectangle (the atlas trim only makes the
// drawn sprite smaller), a mesh with its world vertices. (Skeleton.getBounds() cannot be used: pixi-spine never computes
// the RegionAttachment offsets it reads, its sprites do not need them.) Requests are queued; `stepClipBounds(ms)`, called
// by render/app.js once per frame, samples within that time budget: the clips a unit plays take ≈ 9 ms per skeleton on
// the current roster (53 ms at most), so a model is measured a few frames after it loads, long before a crowd needs its
// impostor. [ASSUMED] the rate, the cap and the budget (a pose between two samples is covered by the box's padding,
// units.js IMP_BOX, and anything past it is cut by the atlas slot's scissor, never left behind).
export const CLIP_SAMPLE_HZ = 60;
export const CLIP_SAMPLE_MAX = 48;
export const CLIP_BOUNDS_BUDGET_MS = 2.5;
const ATT_REGION = 0, ATT_MESH = 2; // pixi-spine AttachmentType
const boundsOf = new WeakMap(); // skeleton data → { data, Skeleton, skel, setup, rects: Map<clip, rect|null>, todo, job, failed }
const boundsQueue = []; // records with clips still to sample, oldest request first
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
const emptyRect = () => [Infinity, Infinity, -Infinity, -Infinity];
const rectOk = (r) => !!r && Number.isFinite(r[0]) && Number.isFinite(r[1]) && Number.isFinite(r[2]) && Number.isFinite(r[3]) && r[0] <= r[2] && r[1] <= r[3];
/** Grow `r` by everything `skel` draws in its current pose (see "clip bounds"); `buf.v` is a scratch vertex buffer. */
function poseRect(skel, r, buf) {
const grow = (x, y) => { if (x < r[0]) r[0] = x; if (x > r[2]) r[2] = x; if (y < r[1]) r[1] = y; if (y > r[3]) r[3] = y; };
for (const slot of skel.drawOrder) {
const bone = slot.bone;
if (!bone || bone.active === false) continue;
const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment;
if (!att || (slot.color ? slot.color.a : 1) * (att.color ? att.color.a : 1) <= 0.004) continue;
if (att.type === ATT_MESH && typeof att.computeWorldVertices === 'function') {
const n = att.worldVerticesLength | 0;
if (n < 2) continue;
if (buf.v.length < n) buf.v = new Float32Array(n);
const v = buf.v;
att.computeWorldVertices(slot, 0, n, v, 0, 2);
for (let k = 0; k < n; k += 2) grow(v[k], v[k + 1]);
} else if (att.type === ATT_REGION && bone.matrix) {
// the attachment rectangle (width × height × scale, rotated, at x / y) through the bone (a PIXI matrix)
const m = bone.matrix;
const hw = (att.width * att.scaleX) / 2, hh = (att.height * att.scaleY) / 2;
const rad = (att.rotation * Math.PI) / 180, c = Math.cos(rad), s = Math.sin(rad);
for (let q = 0; q < 4; q++) {
const px = q === 0 || q === 3 ? -hw : hw, py = q < 2 ? -hh : hh;
const lx = px * c - py * s + att.x, ly = px * s + py * c + att.y;
grow(m.a * lx + m.c * ly + m.tx, m.b * lx + m.d * ly + m.ty);
}
}
}
return r;
}
/** The scratch skeleton of a bounds record (made on demand, dropped once its queued clips are sampled). */
function scratchSkeleton(rec) {
if (rec.skel) return rec.skel;
const Skeleton = rec.Skeleton;
if (typeof Skeleton !== 'function' || Skeleton === Object) throw new Error('no Spine runtime');
const skel = new Skeleton(rec.data);
if (!Array.isArray(skel.drawOrder) || typeof skel.setToSetupPose !== 'function' || typeof skel.updateWorldTransform !== 'function') throw new Error('no Spine runtime');
rec.skel = skel;
return skel;
}
/** The bounds record of skeleton data `data` (its setup pose measured at once); `failed` when it cannot be posed here. */
function boundsRecord(data, Skeleton) {
let rec = boundsOf.get(data);
if (rec) return rec;
rec = { data, Skeleton, skel: null, setup: null, rects: new Map(), todo: [], job: null, failed: false, buf: { v: new Float32Array(256) } };
try {
const skel = scratchSkeleton(rec);
skel.setToSetupPose();
skel.updateWorldTransform();
const r = poseRect(skel, emptyRect(), rec.buf);
rec.setup = rectOk(r) ? r : null;
} catch {
rec.failed = true;
rec.skel = null;
}
boundsOf.set(data, rec);
return rec;
}
/**
* Start sampling clip `name`: { name, anim, d, n, i, r } (posed at t = d·i/n for i = 0…n; `r` grows), or null when the
* skeleton has no such clip.
*/
function startClip(rec, name) {
const anim = typeof rec.data.findAnimation === 'function' ? rec.data.findAnimation(name) : null;
if (!anim || typeof anim.apply !== 'function') return null;
const d = Number.isFinite(anim.duration) && anim.duration > 0 ? anim.duration : 0;
const n = d > 0 ? Math.min(CLIP_SAMPLE_MAX, Math.max(1, Math.ceil(d * CLIP_SAMPLE_HZ))) : 0;
scratchSkeleton(rec).setToSetupPose();
return { name, anim, d, n, i: 0, r: emptyRect() };
}
/**
* Queue clips `names` of skeleton data `data` (pixi-spine runtime class `Skeleton`) for sampling — those neither sampled
* nor queued yet — and return its record (`setup`, `rects`, `failed`).
*/
export function requestClipBounds(data, Skeleton, names) {
const rec = boundsRecord(data, Skeleton);
if (rec.failed) return rec;
let added = false;
for (const n of names) {
if (typeof n === 'string' && !rec.rects.has(n) && !rec.todo.includes(n) && !(rec.job && rec.job.name === n)) { rec.todo.push(n); added = true; }
}
if (added && !boundsQueue.includes(rec)) boundsQueue.push(rec);
return rec;
}
/**
* Sample queued clips for up to `budgetMs` (render/app.js calls it once per frame): pose after pose, a clip resumed on the
* next call when the budget runs out inside it; at least one pose per call.
* @returns {number} clips finished
*/
export function stepClipBounds(budgetMs = CLIP_BOUNDS_BUDGET_MS) {
if (!boundsQueue.length) return 0;
const end = nowMs() + budgetMs;
let finished = 0, posed = 0;
while (boundsQueue.length) {
const rec = boundsQueue[0];
if (!rec.job) {
const name = rec.todo.shift();
if (name === undefined) { boundsQueue.shift(); rec.skel = null; continue; }
try { rec.job = startClip(rec, name); } catch { rec.job = null; }
if (!rec.job) { rec.rects.set(name, null); finished++; continue; }
}
const j = rec.job;
try {
const skel = scratchSkeleton(rec);
while (j.i <= j.n) {
const t = j.n ? (j.d * j.i) / j.n : 0;
j.anim.apply(skel, t, t, false, null, 1, 0, 0); // MixBlend.setup, MixDirection.mixIn: the clip's own pose at t
skel.updateWorldTransform();
poseRect(skel, j.r, rec.buf);
j.i++;
posed++;
if (nowMs() >= end) break;
}
} catch { j.i = j.n + 1; j.r = emptyRect(); }
if (j.i > j.n) {
rec.rects.set(j.name, rectOk(j.r) ? j.r : null);
rec.job = null;
finished++;
}
if (posed && nowMs() >= end) break;
}
return finished;
}
/** Clips still waiting to be sampled, the one in progress included (dev / tests). */
export function pendingClipBounds() { return boundsQueue.reduce((s, r) => s + r.todo.length + (r.job ? 1 : 0), 0); }
export class SpineActor {
/**
* @param {any} spineData PIXI.spine skeleton data
@@ -695,6 +852,27 @@ export class SpineActor {
this._clipHidden.clear();
}
/**
* Skeleton-space bounds of the setup pose and of clips `names` ("clip bounds" above; render/units.js _impBox): `{ setup,
* clips: Map<name, rect | null> }` once every clip is sampled; null while some still wait — they are queued here and
* render/app.js samples them (stepClipBounds); false when this runtime cannot be posed off-screen (a test double).
* @param {string[]} names
*/
clipBounds(names) {
const sp = this.spine;
const data = sp && sp.spineData;
if (!data) return false;
let rec = boundsOf.get(data);
if (!rec || names.some((n) => !rec.rects.has(n))) rec = requestClipBounds(data, sp.skeleton && sp.skeleton.constructor, names);
if (rec.failed) return false;
const clips = new Map();
for (const n of names) {
if (!rec.rects.has(n)) return null;
clips.set(n, rec.rects.get(n));
}
return { setup: rec.setup, clips };
}
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
get height() {
const b = this.entry.bounds;
+200 -10
View File
@@ -59,7 +59,11 @@
// animated or drawn at all (its clock catches up, ≤ 0.5 s, when it comes back); `opts.lod: 'idle'` (the enemy preview
// pen: idle loops only) animates through the impostor atlas every 3rd frame; under the view's adaptive load level
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
// every unit does.
// every unit does. An impostor's slot is sized from the setup pose and every clip the unit can play (`impostorBox`, GitHub
// #542: a slot sized from the first pose drawn cut 野鬃's idle flag and 浊心斯卡蒂's S3 flight, and what crossed its edge
// was left in the neighbouring slots); its skeleton's clips are measured once, a few frames after the model loads
// (spine.js clipBounds) — until then the unit is drawn directly —, and a clip too large for the box is drawn directly
// while it plays.
//
// Picking is by tile (render/pick.js, user playtest #4 item 1): views carry no hit shapes; `bounds()` is the drawn body's
// screen rect for tooltips and overlays (view.pieceScreenRect). A dragged (lifted) item plate is drawn centred on its
@@ -414,6 +418,86 @@ export const FORMS = Object.freeze({
enemy_1121_lifbos_2: PRISONER_COLOURED,
});
/**
* The clips a unit can play, clip name → true for a one-off clip (GitHub #542: its impostor box is sized from them,
* `impostorBox`): every role of its manifest entry (`anims`, with every skill's set — `skills` — and the Run cycle) and of
* its model forms (`forms` = FORMS[id]: their clip sets and their change, closing and leaving clips). One-off = the deploy
* clip, a skill's, stun's, move's or run's begin and end clips and a form's change, closing and leaving clips; everything
* else loops or is held (idle, attack — its begin and end come with every attack —, a skill's loop and idle, the move and
* run loops, the stun loop, the death clip a knocked-out operator lies in). A name with both kinds of role loops.
* @returns {Map<string, boolean>}
*/
export function playedClips(anims, forms = null) {
const out = new Map();
const add = (name, once) => { if (typeof name === 'string' && name) out.set(name, out.has(name) ? out.get(name) && once : once); };
const clip = (c, endsOnce) => {
if (!c || typeof c !== 'object') return;
add(c.loop, false); add(c.idle, false); add(c.begin, endsOnce); add(c.end, endsOnce);
};
const roles = (r) => {
if (!r || typeof r !== 'object') return;
add(r.idle, false); add(r.deploy, true); add(r.die, false);
clip(r.attack, false); clip(r.skill, true); clip(r.move, true); clip(r.run, true); clip(r.stun, true);
if (r.skills && typeof r.skills === 'object') for (const s of Object.values(r.skills)) clip(s, true);
};
roles(anims);
if (forms && typeof forms === 'object') {
for (const f of Object.values(forms)) {
if (!f || typeof f !== 'object') continue;
add(f.change, true); add(f.end, true); add(f.leave, true);
roles(f.roles);
}
}
return out;
}
/**
* Impostor box limits (GitHub #542, `impostorBox`): a clip enters a unit's box while the box stays within `steady` × the
* area of its setup pose ∪ idle clip (a looping or held clip), `transient` × for a one-off clip; `pad` × the box's longer
* side + `padMin` skeleton units is added on every side for the poses between two samples (spine.js CLIP_SAMPLE_HZ).
* [ASSUMED] the values: on the current roster they keep every looping / held clip of 675 of the 681 skeletons in the box
* (the 6 others hold a screen-wide skill or death effect) and leave out 319 of the 681 deploy clips (mostly the drop from
* above, up to 1700 units tall); the boxes add up to 1.86 × the area of boxes sized from the idle's first frame (the
* median skeleton's 1.39 ×).
*/
export const IMP_BOX = Object.freeze({ steady: 4, transient: 2, pad: 0.03, padMin: 8 });
/**
* A unit's impostor box (GitHub #542) in skeleton space, from rects [x0, y0, x1, y1] (y down; null = nothing drawn): the
* setup pose and the idle clip always (the core — never a box smaller than the setup pose); then each other clip the unit
* plays (`clips`: [name, rect, once], `playedClips`) in the order of the area it adds, taken while the box stays within
* IMP_BOX.steady × the core's area (IMP_BOX.transient × for a one-off clip). A clip that does not fit and is not inside
* the box anyway is `over`: its unit is drawn directly while it plays (units.js _updateImpostor), so a rare huge clip
* (a deploy drop from above, a screen-wide sweep) does not grow every refresh of the unit's slot. Padded by IMP_BOX.pad.
* Without a setup pose or idle clip that draws anything, the looping clips make the core. null when nothing is drawn.
* @returns {{ x0: number, y0: number, w: number, h: number, over: Set<string> } | null}
*/
export function impostorBox(setup, idle, clips = []) {
const ok = (r) => Array.isArray(r) && r.length === 4 && r.every(Number.isFinite) && r[0] <= r[2] && r[1] <= r[3];
const join = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
const area = (r) => Math.max(1, r[2] - r[0]) * Math.max(1, r[3] - r[1]);
let core = null;
for (const r of [setup, idle]) if (ok(r)) core = core ? join(core, r) : r.slice();
if (!core) for (const [, r, once] of clips) if (!once && ok(r)) core = core ? join(core, r) : r.slice();
if (!core) return null;
const limit = area(core);
const order = clips.filter(([, r]) => ok(r)).map(([name, r, once]) => ({ name, r, once, grow: area(join(core, r)) }))
.sort((a, b) => a.grow - b.grow || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
let box = core;
const left = [];
for (const c of order) {
const u = join(box, c.r);
if (area(u) <= (c.once ? IMP_BOX.transient : IMP_BOX.steady) * limit) box = u;
else left.push(c);
}
const over = new Set();
for (const c of left) if (c.r[0] < box[0] || c.r[1] < box[1] || c.r[2] > box[2] || c.r[3] > box[3]) over.add(c.name);
const pad = IMP_BOX.pad * Math.max(box[2] - box[0], box[3] - box[1]) + IMP_BOX.padMin;
return { x0: box[0] - pad, y0: box[1] - pad, w: box[2] - box[0] + 2 * pad, h: box[3] - box[1] + 2 * pad, over };
}
/** No clip is drawn past the box (a pen figure; the legacy box of a runtime that cannot be sampled). Never added to. */
const NO_CLIPS = new Set();
/**
* Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at
* height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change.
@@ -695,6 +779,9 @@ export class UnitView {
this.spineReady = true;
this.swapT = swap ? 1 : 0;
this.actor.spine.alpha = swap ? 1 : 0;
// measure the clips it can play now (sampled over the next frames, render/app.js stepClipBounds): its impostor box
// is ready before a crowd needs it (_impBox)
try { this.actor.clipBounds([...this._clipNames().keys()]); } catch { /* _impBox asks again */ }
// a mode the unit is already in (a model built or rebuilt after the change): its clip set, no change clip — a dead
// one lies in the form it died in (a 替身 knocked out: the sim resets the form at once, but the Front model it lies
// down with facing UP, §22.1, is built a frame later; a model rebuilt while it is down)
@@ -1271,16 +1358,13 @@ export class UnitView {
// a pose that cannot change (a knocked-out operator lying in its held Die pose, a frozen model) is neither re-posed
// nor redrawn — it would draw the same pixels again; the actor's clock still runs
const held = !clipFlip && this.actor.poseHeld();
if (interval > 0 && this.ctx.renderer) {
// (an impostor needs its box: while its model's clips are still being measured the unit is drawn directly)
if (interval > 0 && this.ctx.renderer && this._impBox()) {
if (clipFlip && this.imp) this.imp.dirty = true; // masks on / off: the slot is redrawn either way
this._updateImpostor(sc, flip, tint, animDt, interval, held);
} else {
if (this.imp) this._leaveImpostor();
this.actor.spine.alpha = this.swapT;
this.actor.spine.scale.set(sc * flip, sc * this.modelKY);
if (held) this.actor.clock += animDt;
else this.actor.update(animDt);
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
this._drawDirect(sc, flip, tint, animDt, held);
}
}
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
@@ -1649,9 +1733,58 @@ export class UnitView {
// impostor atlas (render/impostor.js) every `interval` frames (staggered per unit; every frame for clipped
// skeletons, whose stencil masks must stay out of the main pass) and shown as one sprite. Without a free atlas
// slot the unit keeps a private RenderTexture (same visuals, one extra framebuffer switch).
// The slot is sized from the unit's box (GitHub #542, `impostorBox`): its setup pose and every clip it can play, as
// measured once per skeleton (spine.js clipBounds) — no pose it plays is cut, and the atlas clips every slot to itself.
// A clip too large for the box (`over`) is drawn directly while it plays; the slot stays the unit's (_impBypass).
/** The impostor box ({ x0, y0, w, h, over } in skeleton units), null while the model's clips are still measured. */
_impBox() {
if (this._box) return this._box;
const a = this.actor;
const names = this._clipNames();
const b = typeof a.clipBounds === 'function' ? a.clipBounds([...names.keys()]) : false;
if (b === null) return null;
let box = null;
if (b) {
const idle = this._idleClip();
const rest = [];
for (const [name, once] of names) if (name !== idle) rest.push([name, b.clips.get(name) || null, once]);
box = impostorBox(b.setup, idle ? b.clips.get(idle) || null : null, rest);
}
this._box = box || this._legacyBox();
return this._box;
}
/** The idle clip of the model's own roles (the impostor box's core with the setup pose), else null. */
_idleClip() {
const a = this.actor, idle = a.entry && a.entry.anims ? a.entry.anims.idle : null;
return a.has(idle) ? idle : a.has('Idle') ? 'Idle' : null;
}
/**
* The clips this view's model can play that its skeleton has (`playedClips` of its entry and FORMS, the actor's
* fallbacks 'Idle' / 'Die' included), name → one-off; a pen figure (lod idle) plays its idle clip only.
*/
_clipNames() {
const a = this.actor;
if (this._clipsOf === a) return this._clips;
const anims = (a.entry && a.entry.anims) || {};
const idle = this._idleClip();
const out = new Map();
if (idle) out.set(idle, false);
if (!this.lodIdle) {
for (const [name, once] of playedClips(anims, FORMS[this.info.spine || this.info.defId] || null)) {
if (a.has(name)) out.set(name, out.has(name) ? out.get(name) && once : once);
}
if (!anims.die && a.has('Die')) out.set('Die', false);
}
this._clipsOf = a;
this._clips = out;
return out;
}
/** The box sized from the pose drawn now, for a runtime whose clips cannot be measured (the pre-#542 rule). */
_legacyBox() {
let b = null;
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
let x0 = -220, y0 = -420, x1 = 220, y1 = 40;
@@ -1660,8 +1793,52 @@ export class UnitView {
x0 = Math.min(b.x - padX, -140); x1 = Math.max(b.x + b.width + padX, 140);
y0 = Math.min(b.y - padY, -260); y1 = Math.max(b.y + b.height + padY * 0.4, 30);
}
this._box = { x0, y0, w: x1 - x0, h: y1 - y0 };
return this._box;
return { x0, y0, w: x1 - x0, h: y1 - y0, over: NO_CLIPS };
}
/** Is a clip outside the box (`over`) on screen — the track's clip or one it is still mixing out of? */
_impOver() {
const over = this._box && this._box.over;
if (!over || !over.size) return false;
const tracks = this.actor.spine && this.actor.spine.state ? this.actor.spine.state.tracks : null;
for (let e = tracks && tracks[0], d = 0; e && d < 4; e = e.mixingFrom, d++) if (e.animation && over.has(e.animation.name)) return true;
return false;
}
/**
* Draw the skeleton directly while a clip too large for the box plays (`on`), back to the impostor after it: the
* skeleton leaves the atlas' parking lot for the body and the impostor sprite hides; the slot stays allocated, and is
* redrawn on the way back.
*/
_impBypass(on) {
const imp = this.imp, sp = this.actor.spine, atlas = this.ctx.impostors || null;
if (!imp || !!imp.bypass === on) return;
imp.bypass = on;
if (on) {
if (atlas) atlas.unpark(sp);
sp.visible = true;
sp.position.set(0, 0);
this.body.addChild(sp);
imp.sprite.visible = false;
} else {
if (sp.parent === this.body) this.body.removeChild(sp);
if (atlas) atlas.park(sp);
imp.sprite.visible = true;
imp.dirty = true;
imp.acc = 0;
}
}
/** Draw the skeleton itself this frame (no impostor, or bypassing it): posed by `animDt` unless `held` / `posed`. */
_drawDirect(sc, flip, tint, animDt, held, posed = false) {
const sp = this.actor.spine;
sp.alpha = this.swapT;
sp.scale.set(sc * flip, sc * this.modelKY);
if (!posed) {
if (held) this.actor.clock += animDt;
else this.actor.update(animDt);
}
if (this._tint !== tint) { this._tint = tint; sp.tint = tint; }
}
_updateImpostor(sc, flip, tint, animDt, interval, held = false) {
@@ -1672,9 +1849,16 @@ export class UnitView {
if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine);
if (atlas) atlas.park(this.actor.spine);
this.body.addChild(sprite);
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 };
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0, bypass: false };
}
const imp = this.imp;
// a clip too large for the box plays: drawn directly until it is over (then redrawn into the slot)
if (this._impOver()) {
this._impBypass(true);
this._drawDirect(sc, flip, tint, animDt, held);
return;
}
if (imp.bypass) this._impBypass(false);
imp.acc += animDt;
const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0;
// the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval;
@@ -1691,6 +1875,12 @@ export class UnitView {
imp.acc = 0;
imp.dirty = false;
imp.last = frame;
// the update reached a clip too large for the box (a queued clip began): this frame already draws it directly
if (this._impOver()) {
this._impBypass(true);
this._drawDirect(sc, flip, tint, 0, held, true);
return;
}
this._renderImpostor(sc, atlas);
}
const k = imp.sc > 0 ? sc / imp.sc : 1;
+3 -1
View File
@@ -205,6 +205,7 @@ describe('impostor atlas allocator', () => {
globalThis.PIXI = {
Container: Obj,
Sprite: class extends Obj { constructor(t) { super(); this.texture = t; } },
Graphics: class extends Obj { beginFill() { return this; } drawRect(x, y, w, h) { this.rect = [x, y, w, h]; return this; } endFill() { return this; } },
Texture: class { constructor(b, f) { this.baseTexture = b; this.frame = f; } destroy() {} static WHITE = {} },
Rectangle: class { constructor(x, y, w, h) { Object.assign(this, { x, y, width: w, height: h }); } },
RenderTexture: { create: (o) => ({ ...o, baseTexture: { id: Math.random() }, framebuffer: { enableStencil() {} }, destroy() {} }) },
@@ -227,7 +228,7 @@ describe('impostor atlas allocator', () => {
const body = new Obj();
atlas.park(body);
atlas.draw(c, body, { a: 0.3, d: 0.3, tx: 40, ty: 90 });
assert.equal(body.parent, c.page.bodies);
assert.equal(body.parent, c.box, 'drawn inside its slot\'s box (clipped to the slot, GitHub #542)');
atlas.flush();
assert.deepEqual(rendered, [true]);
assert.equal(body.parent, atlas.parked);
@@ -254,6 +255,7 @@ describe('impostor atlas allocator', () => {
globalThis.PIXI = {
Container: Obj,
Sprite: class extends Obj { constructor(t) { super(); this.texture = t; } },
Graphics: class extends Obj { beginFill() { return this; } drawRect() { return this; } endFill() { return this; } },
Texture: class { constructor(b, f) { this.baseTexture = b; this.frame = f; } destroy() {} static WHITE = {} },
Rectangle: class { constructor(x, y, w, h) { Object.assign(this, { x, y, width: w, height: h }); } },
RenderTexture: { create: (o) => ({ ...o, baseTexture: {}, framebuffer: { enableStencil() {} }, destroy() {} }) },
+158
View File
@@ -0,0 +1,158 @@
// test/render/impostor-bounds-models.test.js — GitHub #542 on the three reported models (野鬃 char_496_wildmn, 浊心斯卡蒂
// char_1012_skadi2, 隐现 char_498_inside), read by @pixi-spine/runtime-3.8 and played by its real Spine and AnimationState
// inside the real SpineActor (headless, as test/render/eyemask.test.js): every pose a unit of them plays — each clip of
// its roles at 30 Hz, measured with Spine's own vertex math (RegionAttachment.computeWorldVertices with the region offsets
// pixi-spine never computes, MeshAttachment.computeWorldVertices) — lies inside the impostor box UnitView._impBox builds
// from the clip bounds of render/spine.js, except the clips the box leaves to a direct draw (`over`); the old rule (the
// pose shown first + a margin) cut 野鬃's idle flag and 浊心斯卡蒂's S3 flight when that pose was the setup pose.
// The skeletons are not in the repository (public/assets: `npm run assets`): without them these tests skip.
// Run: node --test test/render/impostor-bounds-models.test.js
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const require = createRequire(path.join(ROOT, 'package.json'));
const ASSETS = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const dirOf = (id) => path.join(ROOT, 'public', 'assets', 'spine', 'op', id, 'front');
const have = (id) => existsSync(path.join(dirOf(id), `${id}.skel`)) && existsSync(path.join(dirOf(id), `${id}.atlas`));
const MODELS = ['char_496_wildmn', 'char_1012_skadi2', 'char_498_inside'];
let spine, units, rt, base, core;
before(async () => {
class HeadlessCanvas { constructor() { this.width = 16; this.height = 16; } getContext(kind) { return kind === '2d' ? { fillStyle: '', fillRect() {}, clearRect() {}, drawImage() {} } : null; } }
globalThis.HTMLCanvasElement ??= HeadlessCanvas;
globalThis.document ??= { createElement: () => new HeadlessCanvas() };
rt = require('@pixi-spine/runtime-3.8');
base = require('@pixi-spine/base');
core = require('@pixi/core');
globalThis.PIXI = { spine: { Spine: rt.Spine } };
spine = await import('../../public/js/render/spine.js');
units = await import('../../public/js/render/units.js');
});
const cache = new Map();
function skeletonData(id) {
if (!cache.has(id)) {
const atlas = new base.TextureAtlas(readFileSync(path.join(dirOf(id), `${id}.atlas`), 'utf8'), (line, cb) => cb(new core.BaseTexture(null, { width: 1024, height: 1024 })));
cache.set(id, new rt.SkeletonBinary(new rt.AtlasAttachmentLoader(atlas)).readSkeletonData(new Uint8Array(readFileSync(path.join(dirOf(id), `${id}.skel`)))));
}
return cache.get(id);
}
const measured = new Set();
/**
* A real actor of `id` and the impostor box its battle view builds (UnitView._impBox on a stand-in view): the first actor
* of a skeleton waits for its clips to be sampled (stepClipBounds, once per frame); later ones share the result.
*/
function unit(id) {
const a = new spine.SpineActor(skeletonData(id), ASSETS.chars[id].spine.front);
const view = Object.assign(Object.create(units.UnitView.prototype), { actor: a, lodIdle: false, info: { spine: id, defId: id } });
const t0 = performance.now();
let box = view._impBox();
if (!measured.has(id)) assert.equal(box, null, 'measured over the next frames, not at once');
measured.add(id);
let frames = 0;
while (!box) { spine.stepClipBounds(); frames++; box = view._impBox(); }
return { a, view, box, frames, ms: performance.now() - t0 };
}
const V = new Float32Array(1 << 14);
/** The skeleton's drawn geometry now (Spine's own vertex math; slots drawn at alpha 0 skipped), as [x0, y0, x1, y1]. */
function drawn(skel) {
const r = [Infinity, Infinity, -Infinity, -Infinity];
for (const slot of skel.drawOrder) {
const att = slot.getAttachment();
if (!att || !slot.bone.active || slot.color.a * att.color.a <= 0.004) continue;
let n = 0;
if (att instanceof rt.RegionAttachment) {
if (!att.offsetDone) { att.updateOffset(); att.offsetDone = true; }
att.computeWorldVertices(slot.bone, V, 0, 2);
n = 8;
} else if (att instanceof rt.MeshAttachment) {
n = att.worldVerticesLength;
att.computeWorldVertices(slot, 0, n, V, 0, 2);
}
for (let k = 0; k < n; k += 2) { r[0] = Math.min(r[0], V[k]); r[2] = Math.max(r[2], V[k]); r[1] = Math.min(r[1], V[k + 1]); r[3] = Math.max(r[3], V[k + 1]); }
}
return r;
}
/** The pose `t` s into `clip`, played by the actor's own Spine (AnimationState, no mix), as drawn([x0, y0, x1, y1]). */
function drawnAt(a, clip, t) {
const sp = a.spine;
sp.state.clearTracks();
sp.skeleton.setToSetupPose();
const e = sp.state.setAnimation(0, clip, false);
e.mixDuration = 0;
sp.update(t);
return drawn(sp.skeleton);
}
const rectOf = (b) => [b.x0, b.y0, b.x0 + b.w, b.y0 + b.h];
const past = (r, p) => Math.max(r[0] - p[0], p[2] - r[2], r[1] - p[1], p[3] - r[3]);
/** The pre-#542 box: the bounds of the pose shown when the unit became an impostor + its margins. */
function oldBox(p) {
const w = p[2] - p[0], h = p[3] - p[1];
const padX = Math.max(60, w * 0.3), padY = Math.max(50, h * 0.22);
return [Math.min(p[0] - padX, -140), Math.min(p[1] - padY, -260), Math.max(p[2] + padX, 140), Math.max(p[3] + padY * 0.4, 30)];
}
/** What a freshly built model shows before its first update: the setup pose. */
function setupDrawn(a) {
const sp = a.spine;
sp.state.clearTracks();
sp.skeleton.setToSetupPose();
sp.skeleton.updateWorldTransform();
return drawn(sp.skeleton);
}
describe('the reported operators: every pose they play fits their impostor box', { skip: !MODELS.every(have) && 'the operator skeletons are not in public/assets (npm run assets)' }, () => {
test('野鬃 idling: the flag at 0.27 s of Idle is in the box — the old box from the setup pose cut it by 103 units', (t) => {
const { a, box, frames, ms } = unit('char_496_wildmn');
t.diagnostic(`measured in ${frames} frames (${ms.toFixed(1)} ms with the box)`);
const flag = drawnAt(a, 'Idle', 0.273);
assert.ok(flag[0] < -420, `the flag reaches x ${flag[0].toFixed(0)}`);
assert.ok(past(rectOf(box), flag) <= 0, `inside the box (${past(rectOf(box), flag).toFixed(1)})`);
const before = past(oldBox(setupDrawn(a)), flag);
assert.ok(before > 80, `the old box cut it by ${before.toFixed(0)} units`);
assert.ok(box.over.has('Start'), 'its deploy drop (1700 units tall) is drawn directly instead');
});
test('浊心斯卡蒂 S3 (Skill_3_Begin, the flight): her head at 3.5 s is in the box — the old box cut it', () => {
const { a, box } = unit('char_1012_skadi2');
const head = drawnAt(a, 'Skill_3_Begin', 3.527);
assert.ok(head[1] < -680, `the hat reaches y ${head[1].toFixed(0)}`);
assert.ok(past(rectOf(box), head) <= 0, 'inside the box');
assert.ok(past(oldBox(setupDrawn(a)), head) > 150, 'the old box cut it');
assert.ok(!box.over.has('Skill_3_Begin') && !box.over.has('Skill_3_Loop'), 'her skill plays as an impostor');
});
test('隐现 S2: the pistol with its silencer is in the box (her own box held it before too: the stray pixels came from neighbours)', () => {
const { a, box } = unit('char_498_inside');
for (const [clip, at] of [['Skill_2_Begin', 0.658], ['Skill_2_Loop', 0.333], ['Attack_Begin', 0.1]]) {
const p = drawnAt(a, clip, at);
assert.ok(past(rectOf(box), p) <= 0, `${clip} at ${at} inside the box`);
}
assert.equal(box.over.size, 0, 'no clip of hers is drawn directly');
});
test('all three: every clip they play, at 30 Hz, inside the box unless left to a direct draw', (t) => {
for (const id of MODELS) {
const { a, view, box } = unit(id);
const r = rectOf(box);
let poses = 0, worst = -Infinity, at = '';
for (const name of view._clipNames().keys()) {
if (box.over.has(name)) continue;
const d = a.dur(name);
for (let k = 0; k <= Math.ceil(d * 30); k++) {
const time = Math.min(d, k / 30);
const e = past(r, drawnAt(a, name, time));
poses++;
if (e > worst) { worst = e; at = `${name} ${time.toFixed(2)} s`; }
}
}
t.diagnostic(`${id}: ${poses} poses, closest to the edge ${(-worst).toFixed(1)} units inside (${at}); over: ${[...box.over].join(', ') || '—'}`);
assert.ok(worst <= 0, `${id}: ${at} is ${worst.toFixed(1)} units past the box`);
}
});
});
+233
View File
@@ -0,0 +1,233 @@
// test/render/impostor-bounds.browser.test.js — GitHub #542 (reported by @ImPQlc) in headless Chrome, real WebGL: a crowd
// drawn through the impostor atlas (public/dev/render-demo.html?scene=stress: 120 units, impostor interval ≥ 2) with the
// three reported operators in it (&ops=: 野鬃, 浊心斯卡蒂, 隐现), each held in its widest pose — 野鬃's flag at 0.27 s of
// Idle, 浊心斯卡蒂 at 3.5 s of Skill_3_Begin (the S3 flight), 隐现 at 0.33 s of Skill_2_Loop (the silenced pistol).
// Checks: every pose lies inside its unit's slot; after every page is cleared and every impostor redrawn once, no atlas
// pixel lies outside a live slot (render/impostor.js clips each slot's skeleton to the slot rectangle — a scissor rect);
// the scissor itself on a bare atlas page (a quad 9× its slot paints only the slot, a redraw clears the slot, a neighbour
// keeps its pixels); a clip too large for the box (野鬃's deploy drop) is drawn directly while it plays, then the impostor
// comes back. Pictures → test/e2e/out/impostor-542-after-*.png (each operator in the crowd, the atlas pages with the slot
// outlines). The same pictures of the code before #542 are made by checking out its parent and running this file.
//
// Opt-in (starts Chrome, needs the downloaded assets): RENDER_E2E=1 node --test test/render/impostor-bounds.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
const TAG = process.env.IMPOSTOR_SHOT_TAG || 'after'; // file name tag of the pictures ('before' on the parent commit)
// [unit id (render-demo &ops= places), chessId, label, clip, loop, seconds into it]
const OPS = [
[6, 'chess_char_1_19_a', 'wildmn-idle', 'Idle', true, 0.273],
[8, 'chess_char_6_04_a', 'skadi2-s3begin', 'Skill_3_Begin', false, 3.527],
[10, 'chess_char_1_01_a', 'inside-s2loop', 'Skill_2_Loop', true, 0.333],
];
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
describe('impostor boxes and slot clipping in headless Chrome (GitHub #542)', { skip }, () => {
let srv, browser, page;
const problems = [];
before(async () => {
const puppeteer = (await import('puppeteer-core')).default;
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
await srv.assetIndex?.whenReady();
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
page = await browser.newPage();
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.setViewport({ width: 1600, height: 900 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=stress&panel=0&ops=${OPS.map((o) => o[1]).join(',')}`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
assert.equal(await page.evaluate(() => window.__demo.error || null), null, 'demo boot');
// the crowd loads, its clips are measured, every unit becomes an impostor
await page.waitForFunction((ids) => {
const v = window.__demo.view;
if (v.stats().impostor < 2) return false;
for (const id of ids) { const u = v.debug.views.get(id); if (!u || !u.spineReady || !u.imp || u.imp.bypass) return false; }
return true;
}, { timeout: 40000, polling: 200 }, OPS.map((o) => o[0]));
await wait(2500); // the crowd attacks and casts for a while
await page.evaluate(() => window.__demo.pause());
await wait(300);
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('the three operators held in their widest poses: inside their slots; no atlas pixel outside a live slot', async () => {
await page.evaluate((ops) => {
const views = window.__demo.view.debug.views;
for (const [id, , , clip, loop, t] of ops) {
const v = views.get(id);
v.actor.mode = 'base';
v.actor._play(clip, loop, { timeScale: 0, mix: 0, start: t });
if (v.imp) v.imp.dirty = true;
}
}, OPS);
await wait(800);
// a clean slate: every page cleared, every impostor redrawn once — what lies outside the live slots afterwards was
// painted by these redraws (a freed slot's old pixels would count otherwise)
await page.evaluate(() => {
const view = window.__demo.view;
const R = view.debug.app.renderer;
for (const pg of view.debug.ctx.impostors.pages) R.render(new PIXI.Container(), { renderTexture: pg.rt, clear: true });
for (const v of view.debug.views.values()) if (v.imp) v.imp.dirty = true;
});
await wait(600);
const info = await page.evaluate((ops) => {
const view = window.__demo.view;
const atlas = view.debug.ctx.impostors;
const R = view.debug.app.renderer;
const res = atlas.res;
const units = ops.map(([id, , label, clip]) => {
const v = view.debug.views.get(id);
const b = v.actor.spine.getLocalBounds();
const s = v.imp && v.imp.slot, box = v._box, sc = v.imp ? v.imp.sc : 0, yK = v.modelKY || 1;
const track = v.actor.spine.state.tracks[0]?.animation?.name;
if (!s || !box) return { label, track, clip, slot: null };
const x0 = (b.x - box.x0) * sc, y0 = (b.y - box.y0) * sc * yK, x1 = x0 + b.width * sc, y1 = y0 + b.height * sc * yK;
return { label, track, clip, slot: [s.x, s.y, s.w, s.h], over: Math.max(-x0, -y0, x1 - s.w, y1 - s.h), sc };
});
const live = [];
for (const v of view.debug.views.values()) if (v.imp && v.imp.slot && !v.imp.slot.freed) live.push(v.imp.slot);
const pages = [], images = [];
for (const pg of atlas.pages) {
if (!pg.cleared) continue;
const px = R.extract.pixels(pg.rt);
const W = Math.round(pg.size * res);
const mine = live.filter((s) => s.page === pg);
const inSlot = new Uint8Array(W * W);
for (const s of mine) for (let y = Math.floor(s.y * res); y < Math.ceil((s.y + s.h) * res); y++) inSlot.fill(1, y * W + Math.floor(s.x * res), y * W + Math.ceil((s.x + s.w) * res));
let stray = 0;
for (let i = 0; i < W * W; i++) if (!inSlot[i] && px[i * 4 + 3] > 8) stray++;
pages.push({ index: pg.index, kind: pg.kind, slots: mine.length, stray });
// picture: the page over a dark checker, the live slots outlined, the three operators' slots in colour
const c = R.extract.canvas(pg.rt);
const o = document.createElement('canvas');
o.width = c.width; o.height = c.height;
const g = o.getContext('2d');
for (let y = 0; y < o.height; y += 16) for (let x = 0; x < o.width; x += 16) { g.fillStyle = ((x + y) / 16) % 2 ? '#2a2a2e' : '#222226'; g.fillRect(x, y, 16, 16); }
g.drawImage(c, 0, 0);
g.strokeStyle = 'rgba(255,255,255,0.35)';
for (const s of mine) g.strokeRect(s.x * res + 0.5, s.y * res + 0.5, s.w * res - 1, s.h * res - 1);
const colours = ['#ff4d4d', '#4dd2ff', '#ffd24d'];
ops.forEach(([id, , label], k) => {
const s = view.debug.views.get(id)?.imp?.slot;
if (!s || s.page !== pg) return;
g.strokeStyle = colours[k]; g.lineWidth = 2;
g.strokeRect(s.x * res + 1, s.y * res + 1, s.w * res - 2, s.h * res - 2);
g.fillStyle = colours[k]; g.font = '12px sans-serif'; g.fillText(label, s.x * res + 4, s.y * res + 14);
g.lineWidth = 1;
});
images.push({ index: pg.index, url: o.toDataURL('image/png') });
}
return { units, pages, images, screens: ops.map(([id]) => ({ ...view.debug.views.get(id).screen })) };
}, OPS);
for (const im of info.images) writeFileSync(path.join(OUT, `impostor-542-${TAG}-atlas-page${im.index}.png`), Buffer.from(im.url.split(',')[1], 'base64'));
// each operator alone on the field (the crowd keeps drawing into the atlas, hidden in the main pass; bars hidden)
await page.evaluate((ids) => {
const view = window.__demo.view;
for (const [id, v] of view.debug.views) if (!ids.includes(id)) { v.root.visible = false; v.shadow.visible = false; }
view.debug.ctx.layers.bars.visible = false;
}, OPS.map((o) => o[0]));
await wait(300);
const rect = await page.evaluate(() => { const r = document.querySelector('#field canvas').getBoundingClientRect(); return { left: r.left, top: r.top }; });
for (const [k, [, , label]] of OPS.entries()) {
const s = info.screens[k];
const w = s.s * 3.4, h = s.s * 3.6;
await page.screenshot({ path: path.join(OUT, `impostor-542-${TAG}-${label}.png`), clip: { x: Math.max(0, rect.left + s.x - w / 2), y: Math.max(0, rect.top + s.y - h * 0.84), width: w, height: h } });
}
await page.evaluate((ids) => {
const view = window.__demo.view;
for (const [id, v] of view.debug.views) if (!ids.includes(id)) { v.root.visible = true; v.shadow.visible = true; }
view.debug.ctx.layers.bars.visible = true;
}, OPS.map((o) => o[0]));
for (const u of info.units) {
assert.equal(u.track, u.clip, `${u.label}: holding ${u.clip}`);
assert.ok(u.slot, `${u.label}: drawn through the atlas`);
assert.ok(u.over <= 0.5, `${u.label}: the pose is ${u.over.toFixed(1)} px past its slot`);
}
for (const p of info.pages) assert.equal(p.stray, 0, `${p.kind} page ${p.index}: ${p.stray} px outside the live slots`);
assert.deepEqual(problems, []);
});
test('the scissor on a bare atlas page: a quad 9× its slot paints only the slot; a redraw clears it; a neighbour keeps its pixels', async () => {
const r = await page.evaluate(async () => {
const { ImpostorAtlas } = await import('/js/render/impostor.js');
const R = window.__demo.view.debug.app.renderer;
const atlas = new ImpostorAtlas(R);
try {
const a = atlas.alloc(64, 64), b = atlas.alloc(64, 64);
// 16 px white quads, scaled by the draw transform (atlas.draw sets the scale, as for a skeleton)
const quad = (tint) => { const s = new PIXI.Sprite(PIXI.Texture.WHITE); s.tint = tint; return s; };
const big = quad(0xff0000), small = quad(0x00ff00), nb = quad(0x0000ff);
for (const s of [big, small, nb]) atlas.park(s);
const read = () => {
const px = R.extract.pixels(a.page.rt), W = Math.round(a.page.size * atlas.res), k = atlas.res;
const at = (x, y) => { const i = (Math.floor(y * k) * W + Math.floor(x * k)) * 4; return [px[i], px[i + 1], px[i + 2], px[i + 3]]; };
let outside = 0;
for (let y = 0; y < Math.round(160 * k); y++) for (let x = 0; x < Math.round(256 * k); x++) {
const cx = x / k, cy = y / k;
const inA = cx >= a.x && cx < a.x + a.w && cy >= a.y && cy < a.y + a.h, inB = cx >= b.x && cx < b.x + b.w && cy >= b.y && cy < b.y + b.h;
if (!inA && !inB && px[(y * W + x) * 4 + 3] > 8) outside++;
}
return { outside, aCorner: at(a.x + 60, a.y + 60), aCentre: at(a.x + 8, a.y + 8), bCentre: at(b.x + 32, b.y + 32) };
};
// b: a blue square, drawn once; then a alone: a red quad 3× the slot each way, offset so that, unclipped, it would
// paint over b (not redrawn: its eraser stays off) and the free space right of and below the slots
atlas.draw(b, nb, { a: 4, d: 4, tx: 0, ty: 0 }); // 64 px: all of b
atlas.flush();
atlas.draw(a, big, { a: 12, d: 12, tx: -32, ty: -32 }); // 192 px from (−32, −32)
atlas.flush();
const first = read();
// a again with a 16 px green quad in its corner: the rest of the slot must be empty
atlas.draw(a, small, { a: 1, d: 1, tx: 0, ty: 0 });
atlas.flush();
const second = read();
return { first, second, a: [a.x, a.y, a.w, a.h], b: [b.x, b.y, b.w, b.h] };
} finally {
atlas.destroy();
}
});
assert.deepEqual(r.a.slice(2), [64, 64]);
assert.equal(r.first.outside, 0, 'nothing painted outside the slots');
assert.deepEqual(r.first.aCorner.slice(0, 1).concat(r.first.aCorner[3]), [255, 255], 'slot a filled red');
assert.deepEqual(r.first.bCentre, [0, 0, 255, 255], 'slot b keeps its blue: a\'s spill was cut at a\'s edge');
assert.equal(r.second.aCorner[3], 0, 'the redraw cleared the rest of slot a');
assert.deepEqual(r.second.aCentre, [0, 255, 0, 255], 'and drew the new quad');
assert.deepEqual(r.second.bCentre, [0, 0, 255, 255], 'b untouched');
assert.equal(r.second.outside, 0);
});
test('a clip too large for the box (野鬃\'s deploy drop) is drawn directly while it plays, then the impostor is back', async () => {
const r = await page.evaluate(async () => {
const v = window.__demo.view.debug.views.get(6);
const over = [...v._box.over];
v.actor._play('Idle', true, { mix: 0 });
v.onDeploy(); // the Start clip, as b.ev 'deploy'
await new Promise((ok) => setTimeout(ok, 120));
const during = { bypass: !!v.imp?.bypass, sprite: v.imp?.sprite.visible, inBody: v.actor.spine.parent === v.body, slot: !!v.imp?.slot };
await new Promise((ok) => setTimeout(ok, 1600)); // 1 s clip at the demo's 2× clock, then its mix out
const afterwards = { bypass: !!v.imp?.bypass, sprite: v.imp?.sprite.visible, track: v.actor.spine.state.tracks[0]?.animation?.name };
return { over, during, afterwards };
});
assert.ok(r.over.includes('Start'), `over: ${r.over}`);
assert.deepEqual(r.during, { bypass: true, sprite: false, inBody: true, slot: true }, 'drawn directly, its slot kept');
assert.equal(r.afterwards.bypass, false);
assert.equal(r.afterwards.sprite, true, 'the impostor again');
assert.deepEqual(problems, []);
});
});
+332
View File
@@ -0,0 +1,332 @@
// test/render/impostor-bounds.test.js — impostor boxes and slot clipping (GitHub #542, reported by @ImPQlc): on quality
// 高 with many units on the field (round 7 on), 野鬃's flag while idling, 浊心斯卡蒂's head in her S3 flight and 隐现's
// pistol were cut, offset or left stray pixels. A unit's impostor slot was sized from the pose it showed when it first
// became an impostor (plus a margin, render/units.js _impBox); a later pose past it was cut at the slot's edge and painted
// into the neighbouring slots, whose erasers covered only their own slot (render/impostor.js), so what fell outside every
// slot stayed and what fell into another unit's slot showed on that unit until it was redrawn.
// Now every clip a unit plays is measured once per skeleton (render/spine.js clipBounds; sampled within a frame budget by
// stepClipBounds), the box covers them (units.js impostorBox — a clip too large for it is drawn directly while it plays),
// and the atlas draws each slot's skeleton inside a box masked by the slot rectangle (a scissor rect).
// Synthetic Spine 3.8 skeletons (@pixi-spine/runtime-3.8) and the headless fake PIXI (test/render/fakepixi.js); the three
// reported operators through the real runtime: test/render/impostor-bounds-models.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const rt = createRequire(path.join(ROOT, 'package.json'))('@pixi-spine/runtime-3.8');
let fake, spine, units, ImpostorAtlas;
before(async () => {
fake = installFakePixi();
spine = await import('../../public/js/render/spine.js');
units = await import('../../public/js/render/units.js');
({ ImpostorAtlas } = await import('../../public/js/render/impostor.js'));
});
after(() => fake.restore());
/**
* A synthetic skeleton: a 100 × 200 body on the root (setup x −50…50, y −200…0 in pixi-spine's y-down world), a 50 × 50
* flag on the 'arm' bone at x 100 (75…125), a 'flash' slot that is empty in the setup pose, and a 'ghost' slot drawn at
* alpha 0. Clips: 'Wave' (the arm out to x + 300 at 0.5 s and back), 'Flash' (the flash shown 0.50–0.55 s only, at
* x 900), 'Ghost' (the ghost bone moved far out: never drawn), 'Still'.
*/
function skeleton() {
const data = new rt.SkeletonData();
const bone = (i, name, parent) => { const b = new rt.BoneData(i, name, parent); data.bones.push(b); return b; };
const root = bone(0, 'root', null), arm = bone(1, 'arm', root), ghostBone = bone(2, 'ghost', root);
const slot = (i, name, b, att) => { const s = new rt.SlotData(i, name, b); s.attachmentName = att; data.slots.push(s); return s; };
slot(0, 'body', root, 'body'); slot(1, 'flag', arm, 'flag'); slot(2, 'flash', root, null);
slot(3, 'ghost', ghostBone, 'ghost').color.a = 0;
const skin = new rt.Skin('default');
const region = (name, w, h, x, y) => { const a = new rt.RegionAttachment(name); a.width = w; a.height = h; a.x = x; a.y = y; return a; };
skin.setAttachment(0, 'body', region('body', 100, 200, 0, 100));
skin.setAttachment(1, 'flag', region('flag', 50, 50, 100, 0));
skin.setAttachment(2, 'flash', region('flash', 20, 20, 900, 0));
skin.setAttachment(3, 'ghost', region('ghost', 10, 10, 0, 0));
data.skins.push(skin);
data.defaultSkin = skin;
const wave = new rt.TranslateTimeline(3);
wave.boneIndex = 1;
wave.setFrame(0, 0, 0, 0); wave.setFrame(1, 0.5, 300, 0); wave.setFrame(2, 1, 0, 0);
const flash = new rt.AttachmentTimeline(3);
flash.slotIndex = 2;
flash.setFrame(0, 0, null); flash.setFrame(1, 0.5, 'flash'); flash.setFrame(2, 0.55, null);
const ghost = new rt.TranslateTimeline(2);
ghost.boneIndex = 2;
ghost.setFrame(0, 0, 5000, 0); ghost.setFrame(1, 1, 5000, 0);
data.animations.push(new rt.Animation('Wave', [wave], 1), new rt.Animation('Flash', [flash], 1),
new rt.Animation('Ghost', [ghost], 1), new rt.Animation('Still', [], 0));
return data;
}
const near = (a, b, msg) => { for (let i = 0; i < 4; i++) assert.ok(Math.abs(a[i] - b[i]) < 1e-3, `${msg}: [${a}] vs [${b}]`); };
/** Sample every queued clip now. */
const drain = () => { while (spine.pendingClipBounds()) spine.stepClipBounds(Infinity); spine.stepClipBounds(Infinity); };
describe('clip bounds (render/spine.js): what a skeleton draws, measured once per skeleton data', () => {
test('setup pose at once; a clip by every pose it samples — a short-lived attachment counts, an invisible slot does not', () => {
const data = skeleton();
const rec = spine.requestClipBounds(data, rt.Skeleton, ['Wave', 'Flash', 'Ghost', 'Still', 'Nope']);
assert.equal(rec.failed, false);
near(rec.setup, [-50, -200, 125, 25], 'setup: the body and the flag (the empty flash and the alpha-0 ghost do not count)');
drain();
near(rec.rects.get('Wave'), [-50, -200, 425, 25], 'Wave: the flag 300 units out at 0.5 s');
near(rec.rects.get('Flash'), [-50, -200, 910, 25], 'Flash: shown for 0.05 s of its 1 s, still measured (49 poses)');
near(rec.rects.get('Ghost'), [-50, -200, 125, 25], 'Ghost: a bone moved 5000 units out with an alpha-0 slot adds nothing');
near(rec.rects.get('Still'), [-50, -200, 125, 25], 'Still: a 0-s clip is its single pose');
assert.equal(rec.rects.get('Nope'), null, 'a clip the skeleton does not have');
assert.equal(rec.skel, null, 'the scratch skeleton is dropped once its clips are sampled');
// the same data again: nothing new to sample
spine.requestClipBounds(data, rt.Skeleton, ['Wave', 'Flash']);
assert.equal(spine.pendingClipBounds(), 0);
});
test('the queue samples pose by pose within the frame budget — a clip resumes on the next call —, oldest request first', () => {
const a = skeleton(), b = skeleton();
const recA = spine.requestClipBounds(a, rt.Skeleton, ['Wave', 'Still']);
spine.requestClipBounds(b, rt.Skeleton, ['Still']);
assert.equal(spine.pendingClipBounds(), 3);
// a spent budget still poses once per call: 'Wave' (1 s: 48 + 1 poses) takes 49 calls
let calls = 0, finished = 0;
while (!finished) { finished = spine.stepClipBounds(0); calls++; }
assert.equal(calls, spine.CLIP_SAMPLE_MAX + 1);
near(recA.rects.get('Wave'), [-50, -200, 425, 25], 'Wave across 49 calls');
assert.equal(spine.pendingClipBounds(), 2);
assert.equal(spine.stepClipBounds(0), 1, 'Still: one pose');
assert.equal(spine.stepClipBounds(0), 1, 'then the next skeleton\'s');
assert.equal(spine.pendingClipBounds(), 0);
assert.equal(spine.stepClipBounds(), 0, 'an empty queue costs nothing');
});
test('SpineActor.clipBounds: null while queued, then { setup, clips }; false for a runtime that cannot be posed', () => {
const data = skeleton();
const stand = { spine: { spineData: data, skeleton: new rt.Skeleton(data) } };
const clipBounds = spine.SpineActor.prototype.clipBounds;
assert.equal(clipBounds.call(stand, ['Wave', 'Still']), null, 'queued, not sampled yet');
drain();
const b = clipBounds.call(stand, ['Wave', 'Still']);
near(b.setup, [-50, -200, 125, 25], 'setup');
near(b.clips.get('Wave'), [-50, -200, 425, 25], 'Wave');
// the fake PIXI's Spine has a plain-object skeleton: no runtime to pose
const actor = new spine.SpineActor({ animations: [{ name: 'Idle' }] }, { anims: { idle: 'Idle' }, animations: { Idle: 1 } });
assert.equal(actor.clipBounds(['Idle']), false);
});
});
describe('impostor box (render/units.js impostorBox, playedClips)', () => {
test('playedClips: every role of the entry and of its forms; one-off = deploy, begin / end of skills, stun, move; a shared name loops', () => {
const anims = {
idle: 'Idle', deploy: 'Start', die: 'Die',
attack: { begin: 'Attack_Begin', loop: 'Attack_Loop', end: 'Attack_End' },
skill: { begin: 'Skill_2_Begin', loop: 'Skill_2_Loop', end: 'Skill_2_End', idle: 'Skill_2_Idle' },
skills: { 0: { begin: 'Skill_1_Begin', loop: 'Skill_1_Loop', end: null, idle: null }, 1: { begin: 'Skill_2_Begin', loop: 'Skill_2_Loop', end: 'Skill_2_End', idle: 'Skill_2_Idle' } },
move: { begin: 'Move_Begin', loop: 'Move_Loop', end: 'Move_End' }, run: { loop: 'Run_Loop' },
stun: { begin: 'Stun_Begin', loop: 'Stun', end: null }, attackDown: { loop: 'Attack_Down' },
};
const m = units.playedClips(anims, units.FORMS.char_4016_kazema);
const once = [...m].filter(([, o]) => o).map(([n]) => n).sort();
const loops = [...m].filter(([, o]) => !o).map(([n]) => n).sort();
assert.deepEqual(once, ['Move_Begin', 'Move_End', 'Skill_1_Begin', 'Skill_2_Begin', 'Skill_2_End', 'Start', 'Start_B', 'Stun_Begin']);
assert.deepEqual(loops, ['Attack_B', 'Attack_Begin', 'Attack_End', 'Attack_Loop', 'Die', 'Die_B', 'Idle', 'Idle_B', 'Move_Loop', 'Run_Loop', 'Skill_1_Loop', 'Skill_2_Idle', 'Skill_2_Loop', 'Stun']);
assert.ok(!m.has('Attack_Down'), 'attackDown is no clip the renderer plays');
// a name that is the deploy clip and the idle clip loops
assert.equal(units.playedClips({ idle: 'Idle', deploy: 'Idle' }).get('Idle'), false);
assert.equal(units.playedClips(null).size, 0);
});
const box = (b) => [b.x0, b.y0, b.x0 + b.w, b.y0 + b.h];
const inside = (outer, r) => outer[0] <= r[0] && outer[1] <= r[1] && outer[2] >= r[2] && outer[3] >= r[3];
test('the setup pose and the idle clip always; then each clip while the box stays within 4× (looping) / 2× (one-off) of that core', () => {
const setup = [-100, -400, 100, 0], idle = [-150, -380, 120, 10]; // core −150…120 × −400…10: 270 × 410
const clips = [
['Attack', [-150, -420, 400, 10], false], // 550 × 430 = 2.1 × the core: looping, taken
['Skill', [-300, -500, 120, 10], false], // with the attack 550 × 510 = 2.5 ×: taken
['Start', [-100, -1800, 100, 0], true], // a drop from above: 1810 tall, one-off, 9 ×: drawn directly
['Die', [-120, -200, 90, 5], false], // inside the box anyway
['Huge', [-3000, -400, 3000, 0], false], // a screen-wide effect: past 4 ×, left out
['Gone', null, true], // draws nothing
];
const b = units.impostorBox(setup, idle, clips);
const r = box(b);
assert.ok(inside(r, setup) && inside(r, idle), 'never smaller than the setup pose or the idle clip');
for (const n of ['Attack', 'Skill', 'Die']) assert.ok(inside(r, clips.find((c) => c[0] === n)[1]), `${n} inside the box`);
assert.deepEqual([...b.over].sort(), ['Huge', 'Start'], 'the clips drawn directly while they play');
// padding: 3 % of the longer side (700) + 8 units on every side
const pad = 0.03 * 700 + 8;
near(r, [-300 - pad, -500 - pad, 400 + pad, 10 + pad], 'padded box');
});
test('a one-off clip fits under the 2× limit, a looping one up to 4×; a clip inside the box is never left out', () => {
const core = [-100, -100, 100, 100]; // area 40000
const once = units.impostorBox(core, null, [['Begin', [-100, -260, 100, 100], true]]); // 1.8 ×
assert.equal(once.over.size, 0);
const tooBig = units.impostorBox(core, null, [['Begin', [-100, -340, 100, 100], true]]); // 2.2 ×
assert.deepEqual([...tooBig.over], ['Begin']);
const loop = units.impostorBox(core, null, [['Loop', [-100, -660, 100, 100], false]]); // 3.8 ×
assert.equal(loop.over.size, 0);
// ordered by the area they add: the small one first, then a big one-off that the big loop already covers
const both = units.impostorBox(core, null, [['Begin', [-100, -500, 100, 100], true], ['Loop', [-100, -660, 100, 100], false]]);
assert.equal(both.over.size, 0, 'Begin lies inside the box the loop made');
});
test('without a measurable setup pose or idle clip the looping clips make the core; nothing drawn → null', () => {
const b = units.impostorBox(null, null, [['Attack', [-10, -50, 30, 0], false], ['Start', [-10, -900, 10, 0], true]]);
assert.deepEqual([...b.over], ['Start']);
assert.ok(b.w > 40 && b.h > 50 && b.h < 100);
assert.equal(units.impostorBox(null, null, [['Start', null, true]]), null);
assert.equal(units.impostorBox([NaN, 0, 1, 1], null, []), null);
});
});
describe('impostor atlas (render/impostor.js): every slot is cleared and clipped to itself', () => {
/** An atlas on the fake PIXI with a renderer that records what each page render contained. */
function atlas() {
const renders = [];
const R = { resolution: 1, render: (obj, o) => renders.push({ clear: o.clear, boxes: obj.children[1].children.filter((b) => b.visible).length }) };
return { imp: new ImpostorAtlas(R), renders };
}
test('a slot owns an eraser and a box masked by exactly its rectangle (PIXI draws a rectangle mask as a scissor rect)', () => {
const { imp } = atlas();
const drawn = [];
const drawRect = fake.P.Graphics.prototype.drawRect;
fake.P.Graphics.prototype.drawRect = function (x, y, w, h) { drawn.push(this); this.rect = [x, y, w, h]; return this; };
try {
const a = imp.alloc(100, 110), b = imp.alloc(60, 40);
for (const s of [a, b]) {
assert.deepEqual(s.mask.rect, [s.x, s.y, s.w, s.h], 'mask = the slot');
assert.equal(s.box.mask, s.mask);
assert.equal(s.mask.parent, s.box, 'the mask lives in the box (its transform follows the page)');
assert.deepEqual([s.eraser.x, s.eraser.y, s.eraser.width, s.eraser.height], [s.x, s.y, s.w, s.h], 'the eraser clears all of it');
assert.equal(s.box.visible, false, 'nothing drawn until queued');
}
assert.equal(drawn.length, 2);
} finally { fake.P.Graphics.prototype.drawRect = drawRect; }
});
test('a queued skeleton is drawn inside its slot box, then parked; freeing a slot never destroys a skeleton queued in it', () => {
const { imp, renders } = atlas();
const s = imp.alloc(100, 110), t = imp.alloc(100, 110);
const body = new fake.P.Container(), other = new fake.P.Container();
imp.park(body); imp.park(other);
imp.draw(s, body, { a: 0.3, d: 0.3, tx: 40, ty: 90 });
assert.equal(body.parent, s.box);
assert.ok(s.box.visible && s.eraser.visible && !t.box.visible);
assert.deepEqual([body.x, body.y], [s.x + 40, s.y + 90]);
imp.flush();
assert.deepEqual(renders, [{ clear: true, boxes: 1 }], 'one render of the page: the queued slot only');
assert.equal(body.parent, imp.parked);
assert.ok(!s.box.visible && !s.eraser.visible && !body.visible);
imp.draw(t, other, { a: 1, d: 1, tx: 0, ty: 0 });
imp.free(t); // its unit left impostor mode before the flush
assert.ok(!other.destroyed && other.parent === imp.parked, 'the skeleton went back to the lot');
assert.ok(t.box.destroyed, 'the box and its mask are gone');
imp.flush();
assert.deepEqual(renders.at(-1), { clear: false, boxes: 0 }, 'later passes do not clear the page');
});
});
describe('UnitView impostor mode (render/units.js)', () => {
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const ENTRY = { skel: '/s/op.skel', atlas: '/s/op.atlas', textures: ['/s/op.png'], anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { begin: null, loop: 'Attack', end: null } }, animations: { Idle: 1, Start: 1, Die: 0.8, Attack: 1 } };
const DATA = { animations: [{ name: 'Idle' }, { name: 'Start' }, { name: 'Die' }, { name: 'Attack' }] };
const MEASURED = {
setup: [-60, -200, 60, 0],
clips: new Map([['Idle', [-70, -210, 70, 0]], ['Attack', [-70, -230, 180, 0]], ['Start', [-70, -1800, 70, 0]], ['Die', [-120, -150, 90, 0]]]),
};
/** An operator in a crowd (impostor interval 2) with a recording fake atlas; `bounds()` = what clipBounds answers. */
async function operator(bounds) {
let frame = 0;
const log = { alloc: 0, free: 0, draw: 0 };
const atlas = {
alloc: (w, h, o) => { log.alloc++; return { w, h, tex: new fake.P.Texture(), clip: !!o?.clip }; },
free() { log.free++; }, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() { log.draw++; },
};
const ctx = fakeViewCtx(fake.P, {
cam, frameNo: () => frame, impostors: atlas, impostorInterval: () => 2,
renderer: { resolution: 1, render() {} },
assets: { picture: () => null, image: async () => null, spineEntry: () => ENTRY, spine: { acquire: async () => DATA, release() {} } },
});
const v = new units.UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT' });
await tick(); await tick();
assert.ok(v.spineReady, 'spine ready');
v.actor.clipBounds = (names) => {
const b = bounds();
if (b) for (const n of names) assert.ok(b.clips.has(n), `${n} measured`);
return b;
};
const step = (n = 1) => { for (let i = 0; i < n; i++) { v.update(1 / 60, cam(), frame / 60); frame++; } };
return { v, step, log };
}
test('while the model\'s clips are still measured the unit is drawn directly; then an impostor sized from them', async () => {
let ready = null;
const { v, step, log } = await operator(() => ready);
step(4);
assert.ok(!v.imp, 'no impostor without a box');
assert.equal(log.draw, 0);
assert.equal(v.actor.spine.parent, v.body, 'drawn directly');
ready = MEASURED;
step(2);
assert.ok(v.imp && log.draw >= 1, 'an impostor once measured');
const b = v._box, r = [b.x0, b.y0, b.x0 + b.w, b.y0 + b.h];
for (const n of ['Idle', 'Attack', 'Die']) {
const c = MEASURED.clips.get(n);
assert.ok(r[0] <= c[0] && r[1] <= c[1] && r[2] >= c[2] && r[3] >= c[3], `${n} inside the box`);
}
assert.deepEqual([...b.over], ['Start'], 'the deploy drop is drawn directly while it plays');
});
test('a clip too large for the box is drawn directly while it plays — the slot stays the unit\'s — and redrawn into it after', async () => {
const { v, step, log } = await operator(() => MEASURED);
step(4);
const sprite = v.imp.sprite;
assert.ok(v.imp && sprite.visible);
const c0 = { ...log };
v.onDeploy(); // b.ev 'deploy': the Start clip
step(1);
assert.equal(v.imp.bypass, true);
assert.equal(sprite.visible, false, 'the impostor sprite hides');
assert.equal(v.actor.spine.parent, v.body, 'the skeleton is drawn in the body');
assert.equal(v.actor.spine.visible, true);
step(30);
assert.equal(log.draw, c0.draw, 'no atlas redraw while it plays');
assert.equal(log.free, c0.free, 'its slot is kept');
step(40); // the 1-s clip is over: back to the idle
assert.equal(v.imp.bypass, false);
assert.ok(sprite.visible && v.actor.spine.parent !== v.body, 'the impostor again');
assert.ok(log.draw > c0.draw, 'redrawn into its slot');
assert.equal(log.alloc, c0.alloc, 'the same slot');
});
test('a runtime whose clips cannot be measured keeps the box from the pose shown (the pre-#542 rule)', async () => {
const { v, step } = await operator(() => false);
step(3);
assert.ok(v.imp, 'impostor');
assert.deepEqual([v._box.x0, v._box.y0, v._box.w, v._box.h], [-140, -260, 280, 290], 'fake getLocalBounds (−50, −100, 100 × 100) + the old margins');
assert.equal(v._box.over.size, 0);
});
test('a pen figure (lod idle) needs its idle clip only', async () => {
let asked = null;
const ctx = fakeViewCtx(fake.P, {
cam, frameNo: () => 0, impostors: { alloc: (w, h) => ({ w, h, tex: new fake.P.Texture(), clip: false }), free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {} },
renderer: { resolution: 1, render() {} },
assets: { picture: () => null, image: async () => null, spineEntry: () => ENTRY, spine: { acquire: async () => DATA, release() {} } },
});
const v = new units.UnitView(ctx, { id: 'e:0', side: 'enemy', kind: 'enemy', defId: 'enemy_x', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' });
await tick(); await tick();
v.actor.clipBounds = (names) => { asked = names; return MEASURED; };
v.update(1 / 60, cam(), 0);
assert.deepEqual(asked, ['Idle']);
assert.ok(v.imp && v._box.over.size === 0);
});
});