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

+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, {