0.2.6-dev: impostor boxes without the setup-pose floor (#542) — a model frozen before its first update (built while stunned without a stun clip, or dead without a death or idle clip) shows its clip's first frame instead of its setup pose, the box's core is the idle clip (looping clips up to 5× it, one-off clips 2×), and PIXI gets a vertex buffer per batch flush (the per-slot scissor's flushes stalled Apple GPUs: 42–58 → 60 fps for 120 units at 1920 × 1080); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-13 05:26:04 +08:00
1 parent d7cb8770da
commit c973c1a1ad
6 files changed
+216 -96

No files matched your search

+6
View File
@@ -192,6 +192,12 @@ export async function createFieldView(host, options = {}) {
let view = null;
let onAbort;
try {
// a vertex buffer of its own for every batch flush of a frame (PIXI's own setting on iOS) instead of uploading the one
// shared buffer again: the impostor atlas flushes once per redrawn slot (render/impostor.js: each slot is clipped by its
// own scissor rect, GitHub #542), and with those flushes the re-uploads stalled Apple GPUs — on an Apple M4 (Chrome,
// ANGLE Metal) a 120-unit crowd at 1920 × 1080 ran at 42–58 fps instead of 60 (DPR 1 and 2, by the machine's load);
// a buffer per flush costs ≈ 0.3 ms of render CPU there
if (P.BatchRenderer && 'canUploadSameBuffer' in P.BatchRenderer) P.BatchRenderer.canUploadSameBuffer = false;
const app = new P.Application({
// MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill
// transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself)
+3 -1
View File
@@ -21,7 +21,9 @@
// 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).
// 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
+25 -16
View File
@@ -30,11 +30,14 @@
// (no attack cuts the change clip short); `end` = { clip, in, roles? }: a
// closing clip timed to end `in` s from now (a 重生's last clip ends with the
// 重生), landing in `roles`
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything); a
// model frozen before it was ever posed (built while stunned without a stun
// clip, or dead without a death or idle clip) shows its clip's first frame,
// never its setup pose (GitHub #542: the impostor box covers the clips only)
// 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)
// clipBounds(names) skeleton-space bounds of those clips (sampled once per skeleton data, see
// "clip bounds" below; render/units.js sizes the impostor box from them)
// 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.
@@ -113,8 +116,9 @@ function slotCentre(slot, att, buf, out) {
}
// ---- 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
// What a skeleton can draw, in skeleton space (pixi-spine's y-down world; rects are [x0, y0, x1, y1]): each clip, sampled
// once per skeleton data and shared by every actor built from it (the setup pose is never shown: SpineActor.update poses
// even a model frozen before its first update at its clip's first frame). 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
@@ -130,7 +134,7 @@ 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 boundsOf = new WeakMap(); // skeleton data → { data, Skeleton, skel, 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];
@@ -177,17 +181,15 @@ function scratchSkeleton(rec) {
return skel;
}
/** The bounds record of skeleton data `data` (its setup pose measured at once); `failed` when it cannot be posed here. */
/** The bounds record of skeleton data `data`; `failed` when this runtime cannot be posed here (tried at once). */
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) } };
rec = { data, Skeleton, skel: 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;
@@ -211,7 +213,7 @@ function startClip(rec, name) {
/**
* 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`).
* nor queued yet — and return its record (`rects`, `failed`).
*/
export function requestClipBounds(data, Skeleton, names) {
const rec = boundsRecord(data, Skeleton);
@@ -319,6 +321,7 @@ export class SpineActor {
this.clipPerAttack = false;
this.wound = false; // wound up for the coming attack (windUp → attack)
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
this._posed = false; // the skeleton was ever posed (a model frozen before that shows its clip's first frame)
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
this._play(this._idleName(), true);
}
@@ -428,6 +431,7 @@ export class SpineActor {
}
// Apply the new attachments even when normal updates are frozen.
this.spine.update(0);
this._posed = true;
}
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
@@ -795,8 +799,13 @@ export class SpineActor {
break;
default: break;
}
if (!this.frozen) {
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
// frozen (a stun without a stun clip, a death without a death or idle clip): the pose holds — but a model frozen before
// it was ever posed (built while its unit is stunned or dead) is posed once at the current clip's first frame, never
// left in its setup pose, which no clip shows and the impostor box does not cover (GitHub #542: 隐现's Back model
// stands 900 units to the right of its feet in the setup pose)
if (!this.frozen || !this._posed) {
try { this.spine.update(this.frozen ? 0 : dt); } catch { /* a broken skeleton must not stop the frame */ }
this._posed = true;
}
if (this.clipped && !this.clipOn) this._eyeMaskFallback();
this._applied = true;
@@ -853,8 +862,8 @@ export class SpineActor {
}
/**
* 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
* Skeleton-space bounds of clips `names` ("clip bounds" above; render/units.js _impBox): a Map name → rect (null: draws
* nothing / no such clip) 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
*/
@@ -870,7 +879,7 @@ export class SpineActor {
if (!rec.rects.has(n)) return null;
clips.set(n, rec.rects.get(n));
}
return { setup: rec.setup, clips };
return clips;
}
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
+39 -33
View File
@@ -59,11 +59,10 @@
// 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. 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.
// every unit does. An impostor's slot is sized from 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
@@ -453,31 +452,32 @@ export function playedClips(anims, forms = null) {
/**
* 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 ×).
* area of its 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 668 of the 681 skeletons in the box
* (the 13 others hold a screen-wide skill, attack or death effect: 帕拉斯, 诗怀雅's attack …; at 4 × the attack or skill
* loops of 39 would be drawn directly whenever they play) and leave out 355 of the 681 deploy clips (mostly the drop from
* above, up to 1700 units tall); the boxes add up to 1.26 × the area of boxes sized from the idle's first frame (the
* median skeleton's 1.13 ×).
*/
export const IMP_BOX = Object.freeze({ steady: 4, transient: 2, pad: 0.03, padMin: 8 });
export const IMP_BOX = Object.freeze({ steady: 5, 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.
* A unit's impostor box (GitHub #542) in skeleton space, from rects [x0, y0, x1, y1] (y down; null = nothing drawn): its
* idle clip always (the core); 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. The setup pose needs no room: no model shows it (spine.js
* SpineActor.update poses one frozen before its first update at its clip's first frame). Without an 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 = []) {
export function impostorBox(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();
let core = ok(idle) ? idle.slice() : null;
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);
@@ -1733,37 +1733,43 @@ 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 slot is sized from the unit's box (GitHub #542, `impostorBox`): 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;
const rects = typeof a.clipBounds === 'function' ? a.clipBounds([...names.keys()]) : false;
if (rects === null) return null;
let box = null;
if (b) {
if (rects) {
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);
for (const [name, once] of names) if (name !== idle) rest.push([name, rects.get(name) || null, once]);
box = impostorBox(idle ? rects.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. */
/**
* The idle clip of the model's own roles (the impostor box's core), as SpineActor picks it: the role's, else 'Idle',
* else the skeleton's first clip; null for a skeleton without clips.
*/
_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;
if (a.has(idle)) return idle;
if (a.has('Idle')) return 'Idle';
const first = a.names && a.names.values().next().value;
return typeof first === 'string' ? first : 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.
* fallbacks — its idle clip, 'Die' — included), name → one-off; a pen figure (lod idle) plays its idle clip only.
*/
_clipNames() {
const a = this.actor;
+35 -13
View File
@@ -4,7 +4,9 @@
// 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.
// pose shown first + a margin) cut 野鬃's idle flag and 浊心斯卡蒂's S3 flight when that pose was the setup pose. The box
// leaves the setup pose out: a model built while stunned (no stun clip) shows its clip's first frame instead — 隐现's
// Back model, whose setup pose stands 900 units right of its feet.
// 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
@@ -18,8 +20,8 @@ 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 dirOf = (id, side = 'front') => path.join(ROOT, 'public', 'assets', 'spine', 'op', id, side);
const have = (id, side = 'front') => existsSync(path.join(dirOf(id, side), `${id}.skel`)) && existsSync(path.join(dirOf(id, side), `${id}.atlas`));
const MODELS = ['char_496_wildmn', 'char_1012_skadi2', 'char_498_inside'];
let spine, units, rt, base, core;
@@ -36,25 +38,27 @@ before(async () => {
});
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`)))));
function skeletonData(id, side = 'front') {
const key = `${id}:${side}`;
if (!cache.has(key)) {
const dir = dirOf(id, side);
const atlas = new base.TextureAtlas(readFileSync(path.join(dir, `${id}.atlas`), 'utf8'), (line, cb) => cb(new core.BaseTexture(null, { width: 1024, height: 1024 })));
cache.set(key, new rt.SkeletonBinary(new rt.AtlasAttachmentLoader(atlas)).readSkeletonData(new Uint8Array(readFileSync(path.join(dir, `${id}.skel`)))));
}
return cache.get(id);
return cache.get(key);
}
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);
function unit(id, side = 'front') {
const a = new spine.SpineActor(skeletonData(id, side), ASSETS.chars[id].spine[side]);
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);
if (!measured.has(`${id}:${side}`)) assert.equal(box, null, 'measured over the next frames, not at once');
measured.add(`${id}:${side}`);
let frames = 0;
while (!box) { spine.stepClipBounds(); frames++; box = view._impBox(); }
return { a, view, box, frames, ms: performance.now() - t0 };
@@ -106,7 +110,7 @@ function setupDrawn(a) {
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)' }, () => {
describe('the reported operators: every pose they play fits their impostor box', { skip: !(MODELS.every((id) => have(id)) && have('char_498_inside', 'back')) && '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)`);
@@ -155,4 +159,22 @@ describe('the reported operators: every pose they play fits their impostor box',
assert.ok(worst <= 0, `${id}: ${at} is ${worst.toFixed(1)} units past the box`);
}
});
test('隐现\'s Back model built while stunned (no stun clip): its Idle\'s first frame, inside the box — not its setup pose', () => {
const { box } = unit('char_498_inside', 'back');
const r = rectOf(box);
const setup = setupDrawn(unit('char_498_inside', 'back').a);
assert.ok(setup[0] > 700, `the setup pose stands right of its feet (x ${setup[0].toFixed(0)}…${setup[2].toFixed(0)})`);
assert.ok(past(r, setup) > 500, 'and lies outside the box: no model shows it');
const first = drawnAt(unit('char_498_inside', 'back').a, 'Idle', 0);
const { a } = unit('char_498_inside', 'back'); // a model built for a stunned unit, before any update
a.setBase('stun');
assert.equal(a.frozen, true, 'no stun clip: frozen');
a.update(1 / 60);
const shown = drawn(a.spine.skeleton);
for (let i = 0; i < 4; i++) assert.ok(Math.abs(shown[i] - first[i]) < 0.5, `the Idle clip's first frame (${shown.map(Math.round)} vs ${first.map(Math.round)})`);
assert.ok(past(r, shown) <= 0, 'inside the box');
a.update(1 / 60);
assert.deepEqual(drawn(a.spine.skeleton), shown, 'and held while stunned');
});
});
+108 -33
View File
@@ -6,7 +6,8 @@
// 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).
// and the atlas draws each slot's skeleton inside a box masked by the slot rectangle (a scissor rect). The setup pose is
// never shown (a model frozen before its first update is posed at its clip's first frame), so the box leaves it out.
// 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.
@@ -69,16 +70,16 @@ const near = (a, b, msg) => { for (let i = 0; i < 4; i++) assert.ok(Math.abs(a[i
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', () => {
test('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)');
assert.equal(rec.setup, undefined, 'no setup pose: no model shows it');
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');
near(rec.rects.get('Still'), [-50, -200, 125, 25], 'Still: a 0-s clip is its single pose (the body and the flag)');
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
@@ -103,21 +104,65 @@ describe('clip bounds (render/spine.js): what a skeleton draws, measured once pe
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', () => {
test('SpineActor.clipBounds: null while queued, then a Map clip → rect; 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');
assert.deepEqual([...b.keys()], ['Wave', 'Still']);
near(b.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('a model frozen before its first update shows its clip\'s first frame, never its setup pose (render/spine.js)', () => {
const ENTRY = { anims: { idle: 'Idle', die: 'Die', stun: null }, animations: { Idle: 1, Die: 0.8 } };
/** An actor on the fake PIXI whose skeleton updates are recorded (the dt of each pixi-spine update). */
function actor(data, entry) {
const a = new spine.SpineActor(data, entry);
const posed = [];
a.spine.update = (dt) => posed.push(dt);
return { a, posed };
}
test('stunned without a stun clip before any update: posed once at t = 0 of its clip, then the pose holds', () => {
const { a, posed } = actor({ animations: [{ name: 'Idle' }, { name: 'Die' }] }, ENTRY);
a.setBase('stun');
assert.equal(a.frozen, true);
a.update(0.1);
assert.deepEqual(posed, [0]);
assert.equal(a.spine.state.tracks[0].animation.name, 'Idle');
a.update(0.1); a.update(0.1);
assert.deepEqual(posed, [0], 'held');
assert.equal(a.poseHeld(), true);
a.setBase('idle'); // the stun ends: animated again
a.update(0.1);
assert.deepEqual(posed, [0, 0.1]);
});
test('stunned after it was posed: the pose it had holds (unchanged)', () => {
const { a, posed } = actor({ animations: [{ name: 'Idle' }, { name: 'Die' }] }, ENTRY);
a.update(0.2);
a.setBase('stun');
a.update(0.1); a.update(0.1);
assert.deepEqual(posed, [0.2]);
});
test('dead without a death or idle clip: frozen, and posed once at t = 0 of the clip it had (its first)', () => {
const { a, posed } = actor({ animations: [{ name: 'Attack' }, { name: 'Move' }] }, { anims: { attack: { begin: null, loop: 'Attack', end: null } }, animations: { Attack: 1, Move: 1 } });
assert.equal(a.die(), 0, 'no death clip');
assert.equal(a.frozen, true, 'no idle clip either: frozen');
a.update(0.5);
a.update(0.5);
assert.deepEqual(posed, [0]);
assert.equal(a.spine.state.tracks[0].animation.name, 'Attack');
});
});
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 = {
@@ -142,19 +187,19 @@ describe('impostor box (render/units.js impostorBox, playedClips)', () => {
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
test('the idle clip always; then each clip while the box stays within 5× (looping) / 2× (one-off) of that core', () => {
const idle = [-150, -380, 120, 10]; // the core: 270 × 390
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
['Attack', [-150, -420, 400, 10], false], // 550 × 430 = 2.2 × the core: looping, taken
['Skill', [-300, -500, 120, 10], false], // with the attack 700 × 510 = 3.4 ×: taken
['Start', [-100, -1800, 100, 0], true], // a drop from above: 1810 tall, one-off, 12 ×: 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
['Huge', [-3000, -400, 3000, 0], false], // a screen-wide effect: past 5 ×, left out
['Gone', null, true], // draws nothing
];
const b = units.impostorBox(setup, idle, clips);
const b = units.impostorBox(idle, clips);
const r = box(b);
assert.ok(inside(r, setup) && inside(r, idle), 'never smaller than the setup pose or the idle clip');
assert.ok(inside(r, idle), 'never smaller than 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
@@ -162,25 +207,27 @@ describe('impostor box (render/units.js impostorBox, playedClips)', () => {
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', () => {
test('a one-off clip fits under the 2× limit, a looping one up to 5×; 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 ×
const once = units.impostorBox(core, [['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 ×
const tooBig = units.impostorBox(core, [['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 ×
const loop = units.impostorBox(core, [['Loop', [-100, -800, 100, 100], false]]); // 4.5 ×
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]]);
const loopTooBig = units.impostorBox(core, [['Loop', [-100, -1000, 100, 100], false]]); // 5.5 ×
assert.deepEqual([...loopTooBig.over], ['Loop']);
// ordered by the area they add: Begin first (left out at 3 ×), then the loop, whose box covers Begin after all
const both = units.impostorBox(core, [['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]]);
test('without an idle clip that draws anything the looping clips make the core; nothing drawn → null', () => {
const b = units.impostorBox(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);
assert.equal(units.impostorBox(null, [['Start', null, true]]), null);
assert.equal(units.impostorBox([NaN, 0, 1, 1], []), null);
});
});
@@ -237,13 +284,13 @@ describe('UnitView impostor mode (render/units.js)', () => {
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]]]),
};
const MEASURED = 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) {
/**
* An operator in a crowd (impostor interval 2) with a recording fake atlas; `bounds()` = what clipBounds answers;
* `flags` = its unit flags while the model loads (UF.STUNNED: the model is built for a stunned unit).
*/
async function operator(bounds, flags = 0) {
let frame = 0;
const log = { alloc: 0, free: 0, draw: 0 };
const atlas = {
@@ -256,11 +303,12 @@ describe('UnitView impostor mode (render/units.js)', () => {
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' });
v.flags = flags;
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`);
if (b) for (const n of names) assert.ok(b.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++; } };
@@ -279,7 +327,7 @@ describe('UnitView impostor mode (render/units.js)', () => {
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);
const c = MEASURED.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');
@@ -315,6 +363,33 @@ describe('UnitView impostor mode (render/units.js)', () => {
assert.equal(v._box.over.size, 0);
});
test('built while stunned without a stun clip: posed once at its idle clip\'s first frame — the box\'s core —, then held', async () => {
const { v, step, log } = await operator(() => MEASURED, 2); // UF.STUNNED
assert.equal(v.actor.frozen, true, 'no stun clip: the pose freezes');
const posed = [];
v.actor.spine.update = (dt) => posed.push(dt);
step(6);
assert.deepEqual(posed, [0], 'one pose at t = 0 of the clip on its track, never the setup pose');
assert.equal(v.actor.spine.state.tracks[0].animation.name, 'Idle');
assert.ok(v.imp && log.draw === 1, 'drawn into its slot once, then held');
const b = v._box, c = MEASURED.get('Idle');
assert.ok(b.x0 <= c[0] && b.y0 <= c[1] && b.x0 + b.w >= c[2] && b.y0 + b.h >= c[3], 'that frame lies inside the box');
});
test('a skeleton without an Idle clip: the clip SpineActor idles on instead (its first) is the box\'s core', async () => {
const entry = { ...ENTRY, anims: { attack: { begin: null, loop: 'Attack', end: null } }, animations: { Attack: 1, Move: 1 } };
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 () => ({ animations: [{ name: 'Attack' }, { name: 'Move' }] }), release() {} } },
});
const v = new units.UnitView(ctx, { id: 2, side: 'ally', kind: 'chess', defId: 'char_y', tier: 1, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT' });
await tick(); await tick();
assert.equal(v.actor.spine.state.tracks[0].animation.name, 'Attack', 'SpineActor idles on the first clip');
assert.equal(v._idleClip(), 'Attack');
assert.deepEqual([...v._clipNames()], [['Attack', false]]);
});
test('a pen figure (lod idle) needs its idle clip only', async () => {
let asked = null;
const ctx = fakeViewCtx(fake.P, {