Files
ark-stronghold-protocol/test/render/impostor-bounds.test.js
T

333 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});