0.2.6-dev: merge claude/r37-impostor — GitHub #542: impostor boxes from each skeleton's measured clip bounds (idle core, played clips within limits, the rest drawn directly while they play), every slot drawn inside its own scissor, frozen models posed at their clip's first frame, PIXI's pooled batch buffers (no Apple GPU stall from the per-slot flushes); golden: unchanged
# Conflicts: # CHANGELOG.md # docs/design/client.md # docs/history/0.2.6.md
This commit is contained in:
commit
a1f0d1bb6e
12 files changed
+1469
-27
No files matched your search
@@ -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() {} }) },
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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 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
|
||||
|
||||
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, 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;
|
||||
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, 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(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, 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}:${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 };
|
||||
}
|
||||
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((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)`);
|
||||
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`);
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
// 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). 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.
|
||||
|
||||
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('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);
|
||||
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 (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
|
||||
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('a record leaves the queue when its last actor is destroyed — pending clips, the clip in progress and its scratch skeleton —; a later actor queues them again', () => {
|
||||
const data = skeleton();
|
||||
/** A SpineActor stand-in on the real runtime skeleton (clipBounds / destroy are the real methods). */
|
||||
const actor = () => Object.assign(Object.create(spine.SpineActor.prototype), { spine: { spineData: data, skeleton: new rt.Skeleton(data), destroy() {} } });
|
||||
const a = actor(), b = actor();
|
||||
assert.equal(a.clipBounds(['Wave', 'Flash']), null, 'queued');
|
||||
assert.equal(b.clipBounds(['Wave', 'Flash']), null);
|
||||
spine.stepClipBounds(0); // one pose of Wave: a clip in progress
|
||||
const rec = spine.requestClipBounds(data, rt.Skeleton, []);
|
||||
assert.ok(rec.job && rec.skel, 'sampling Wave on its scratch skeleton');
|
||||
a.destroy();
|
||||
assert.equal(spine.pendingClipBounds(), 2, 'b still uses the skeleton: still queued');
|
||||
b.destroy(); // e.g. the battlefield torn down with its units
|
||||
assert.equal(spine.pendingClipBounds(), 0, 'nothing left to sample');
|
||||
assert.ok(rec.job === null && rec.todo.length === 0 && rec.skel === null, 'no clip in progress, no todo, no scratch skeleton held');
|
||||
const c = actor(); // the same skeleton in the next scene
|
||||
assert.equal(c.clipBounds(['Wave', 'Flash']), null, 'queued again (no todo left behind to block it)');
|
||||
assert.equal(spine.pendingClipBounds(), 2);
|
||||
drain();
|
||||
near(c.clipBounds(['Wave', 'Flash']).get('Wave'), [-50, -200, 425, 25], 'measured');
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
test('a clip whose sampling throws is recorded as not measurable (false), never as drawing nothing', () => {
|
||||
const data = skeleton();
|
||||
const wave = data.findAnimation('Wave');
|
||||
const apply = wave.apply.bind(wave);
|
||||
wave.apply = (skel, last, t, ...rest) => { if (t > 0.5) throw new Error('a broken timeline'); return apply(skel, last, t, ...rest); };
|
||||
const rec = spine.requestClipBounds(data, rt.Skeleton, ['Wave', 'Still']);
|
||||
drain();
|
||||
assert.equal(rec.rects.get('Wave'), false, 'not measurable');
|
||||
near(rec.rects.get('Still'), [-50, -200, 125, 25], 'the other clip is measured');
|
||||
assert.equal(rec.rects.get('Nope'), undefined);
|
||||
const b = units.impostorBox([-50, -200, 125, 25], [['Wave', false, false], ['Still', rec.rects.get('Still'), false]]);
|
||||
assert.deepEqual([...b.over], ['Wave'], 'drawn directly while it plays');
|
||||
});
|
||||
|
||||
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']);
|
||||
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('a first update that throws is tried again: the frozen model is posed once an update succeeds', () => {
|
||||
const { a, posed } = actor({ animations: [{ name: 'Idle' }, { name: 'Die' }] }, ENTRY);
|
||||
let broken = true;
|
||||
a.spine.update = (dt) => { posed.push(dt); if (broken) { broken = false; throw new Error('a broken skeleton'); } };
|
||||
a.setBase('stun');
|
||||
a.update(0.1); // throws inside pixi-spine (caught): not posed yet
|
||||
a.update(0.1); // tried again at the clip's first frame: posed
|
||||
a.update(0.1); // held
|
||||
assert.deepEqual(posed, [0, 0]);
|
||||
});
|
||||
|
||||
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 = {
|
||||
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 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.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 5 ×, left out
|
||||
['Gone', null, true], // draws nothing
|
||||
];
|
||||
const b = units.impostorBox(idle, clips);
|
||||
const r = box(b);
|
||||
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
|
||||
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 5×; a clip inside the box is never left out', () => {
|
||||
const core = [-100, -100, 100, 100]; // area 40000
|
||||
const once = units.impostorBox(core, [['Begin', [-100, -260, 100, 100], true]]); // 1.8 ×
|
||||
assert.equal(once.over.size, 0);
|
||||
const tooBig = units.impostorBox(core, [['Begin', [-100, -340, 100, 100], true]]); // 2.2 ×
|
||||
assert.deepEqual([...tooBig.over], ['Begin']);
|
||||
const loop = units.impostorBox(core, [['Loop', [-100, -800, 100, 100], false]]); // 4.5 ×
|
||||
assert.equal(loop.over.size, 0);
|
||||
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 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, [['Start', null, true]]), null);
|
||||
assert.equal(units.impostorBox([NaN, 0, 1, 1], []), 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 = 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;
|
||||
* `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 = {
|
||||
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' });
|
||||
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.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.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 clip whose sampling threw is drawn directly while it plays — the attack, or even the idle clip —, never cut by the slot', async () => {
|
||||
const broken = new Map(MEASURED).set('Attack', false);
|
||||
const { v, step, log } = await operator(() => broken);
|
||||
step(3);
|
||||
assert.ok(v.imp && v._box.over.has('Attack'));
|
||||
const draws = log.draw;
|
||||
v.actor._play('Attack', false, { mix: 0 });
|
||||
step(2);
|
||||
assert.equal(v.imp.bypass, true, 'the attack is drawn directly');
|
||||
assert.equal(log.draw, draws, 'not into the slot');
|
||||
const idleBroken = new Map(MEASURED).set('Idle', false);
|
||||
const w = (await operator(() => idleBroken)).v;
|
||||
w.update(1 / 60, cam(), 0);
|
||||
assert.ok(w._box.over.has('Idle'), 'an unmeasurable idle clip: drawn directly while it idles');
|
||||
const b = w._box, c = MEASURED.get('Attack');
|
||||
assert.ok(b.x0 <= c[0] && b.x0 + b.w >= c[2], 'the box built from the clips that were measured');
|
||||
const none = new Map([...MEASURED.keys()].map((n) => [n, false]));
|
||||
const z = (await operator(() => none)).v;
|
||||
z.update(1 / 60, cam(), 0);
|
||||
assert.deepEqual([...z._box.over].sort(), ['Attack', 'Die', 'Idle', 'Start'], 'nothing measurable: every clip drawn directly');
|
||||
});
|
||||
|
||||
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('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, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user