0.2.6-dev: impostor review follow-ups (#542) — a skeleton stays in the clip-bounds queue only while an actor uses it (the last one destroyed drops its pending clips, the clip in progress and its scratch skeleton; a later actor queues them again), a clip whose sampling throws is recorded as not measurable and drawn directly while it plays instead of counting as empty, and a frozen model's first update counts as posed only when it succeeded; DESIGN §31.6 updated; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-13 06:29:20 +08:00
1 parent 4c2fe9431d
commit 47004a3651
4 files changed
+197 -78

No files matched your search

+65 -58
View File
@@ -111,62 +111,69 @@ Regression covers normal two-, three- and four-human browser flows, the final tu
`render/impostor.js`, `render/app.js`. `render/impostor.js`, `render/app.js`.
- **Seen** (GitHub #542 by @ImPQlc; quality 高, usually from round 7 on): 野鬃's flag cut, offset and leaving stray pixels - **Seen** (GitHub #542 by @ImPQlc; quality 高, usually from round 7 on): 野鬃's flag cut, offset and leaving stray pixels
while she idles; 浊心斯卡蒂's head cut in her S3 flight (Skill_3_Begin); 隐现's pistol muzzle cut, offset and leaving while she idles; 浊心斯卡蒂's head cut in her S3 flight (Skill_3_Begin); 隐现's pistol muzzle cut, offset and leaving stray
stray pixels during her skill. pixels during her skill.
- **Cause** (reproduced in headless Chrome: the dev demo's stress crowd, 120 units at impostor interval 4, the three held - **Cause** (reproduced in headless Chrome: the dev demo's stress crowd, 120 units at impostor interval 4, the three
in their widest poses): a unit's impostor slot was sized once from the pose it showed when it first became an impostor, held in their widest poses): a unit's impostor slot was sized once from the pose it showed when it first became an
plus a margin (`_impBox`: the pose's bounds + 30 % / 22 %). A later pose past that box was drawn past the slot's edge: impostor, plus a margin (`_impBox`: the pose's bounds + 30 % / 22 %). A later pose past that box was drawn past the
cut in the unit's own sprite and painted into whatever lay beside the slot on the atlas page. A page is cleared on its slot's edge: cut in the unit's own sprite and painted into whatever lay beside the slot on the atlas page. A page is
first flush only and each eraser covers its own slot, so a spill into free space stayed, and a spill into another unit's cleared on its first flush only and each eraser covers its own slot, so a spill into free space stayed, and a spill
slot showed on that unit until it was redrawn in a frame without the spill. 浊心斯卡蒂's flight reaches y −704 (skeleton into another unit's slot showed on that unit until it was redrawn in a frame without the spill. 浊心斯卡蒂's flight reaches
units) against a box top near −516 (54–57 px past her slot in 5 of 5 runs); 野鬃's idle flag reaches x −434, 103 units y −704 (skeleton units) against a box top near −516 (54–57 px past her slot in 5 of 5 runs); 野鬃's idle flag reaches x
past a box sized from her setup pose (0–47 px past her slot over 5 runs: it depended on that first pose); after one clean −434, 103 units past a box sized from her setup pose (0–47 px past her slot over 5 runs: it depended on that first
redraw of every impostor the atlas pages held 0–1,405 pixels outside every live slot. 隐现's own poses fit her box (the pose); after one clean redraw of every impostor the atlas pages held 0–1,405 pixels outside every live slot. 隐现's own
silencer reaches x 197, her box 225): her muzzle sits at her slot's edge, where a neighbour's spill lands. Of the poses fit her box (the silencer reaches x 197, her box 225): her muzzle sits at her slot's edge, where a neighbour's
roster's 681 skeletons, 598 play a clip that leaves a box sized that way from their idle's first frame. No clipping spill lands. Of the roster's 681 skeletons, 598 play a clip that leaves a box sized that way from their idle's first
attachment is involved (`spine.js _eyeMaskFallback`, #177, unchanged). frame. No clipping attachment is involved (`spine.js _eyeMaskFallback`, #177, unchanged).
- **Now**: (1) every clip a unit can play (its manifest roles with every skill's set and the Run cycle, its `FORMS` clip - **Now**: (1) every clip a unit can play (its manifest roles with every skill's set and the Run cycle, its `FORMS` clip
sets and change clips) is measured once per skeleton data on a scratch skeleton: 60 poses a second, at most 49 per clip; sets and change clips) is measured once per skeleton data on a scratch skeleton: 60 poses a second, at most 49 per
a region by its attachment rectangle, a mesh by its vertices; slots drawn at alpha 0 skipped. It is queued when the model clip; a region by its attachment rectangle, a mesh by its vertices; slots drawn at alpha 0 skipped. It is queued when
loads and sampled within 2.5 ms per frame (`stepClipBounds`, called by `render/app.js`), ≈ 9 ms per skeleton on the the model loads and sampled within 2.5 ms per frame (`stepClipBounds`, called by `render/app.js`), ≈ 9 ms per skeleton
roster (53 ms at most), shared by every unit of that skeleton; a unit is drawn directly until its box is ready. (2) The on the roster (53 ms at most), shared by every unit of that skeleton; a unit is drawn directly until its box is ready.
box is the idle clip, then each other clip in the order of the area it adds while the box stays within 5 × the idle A skeleton stays queued only while an actor uses it: when the last one is destroyed (a model swap, a battlefield torn
clip's area (a looping or held clip) or 2 × (a one-off clip: the deploy clip, a skill's, stun's or move's begin and end, a down) its pending clips and its scratch skeleton are dropped, so nothing holds a skeleton the Spine store let go or is
form's change), padded by 3 % + 8 units. A clip that does not fit (`over`: 355 of the roster's 681 deploy clips — the drop measured ahead of the next scene's models, and a later actor queues again what was never measured. (2) The box is the
from above, up to 1,700 units tall — and the looping or held clips of 13 skeletons with screen-wide effects, among them idle clip, then each other clip in the order of the area it adds while the box stays within 5 × the idle clip's area
帕拉斯's skills and 诗怀雅's attack) is drawn directly while it plays; the unit keeps its slot (`_impBypass`). The box is (a looping or held clip) or 2 × (a one-off clip: the deploy clip, a skill's, stun's or move's begin and end, a form's
computed once per unit: no bounds work per frame. The setup pose needs no room: a model frozen before its first update — change), padded by 3 % + 8 units. A clip that does not fit (`over`: 355 of the roster's 681 deploy clips — the drop
built while its unit is stunned and it has no stun clip (202 of the 210 operators have none), or dead without a death or from above, up to 1,700 units tall — and the looping or held clips of 13 skeletons with screen-wide effects, among
idle clip — showed its setup pose, which no clip shows (隐现's Back model stands 900 units right of its feet in it); them 帕拉斯's skills and 诗怀雅's attack) is drawn directly while it plays; the unit keeps its slot (`_impBypass`). So is a
`SpineActor.update` now poses it once at its clip's first frame. (3) The atlas draws each slot's skeleton in a container clip whose sampling threw (recorded as not measurable, never as drawing nothing — the scissor would cut it). The box
masked by the slot rectangle, which PIXI draws as a scissor rect (no stencil, no render-pass break): nothing paints is computed once per unit: no bounds work per frame. The setup pose needs no room: a model frozen before its first
outside a slot, and the slot's eraser clears all that an earlier pose can have left in it. Each redrawn slot is a batch update — built while its unit is stunned and it has no stun clip (202 of the 210 operators have none), or dead without
flush of its own, and PIXI uploaded its one batch vertex buffer again for every flush, which stalls Apple GPUs (Chrome, a death or idle clip — showed its setup pose, which no clip shows (隐现's Back model stands 900 units right of its feet
ANGLE Metal, Apple M4: the 120-unit crowd at 1920 × 1080 ran at 42–58 fps instead of 60, by the machine's load), so in it); `SpineActor.update` now poses it once at its clip's first frame (again on the next frame if that update
`render/app.js` turns PIXI's `BatchRenderer.canUploadSameBuffer` off: a buffer per flush, PIXI's own setting on iOS. threw). (3) The atlas draws each slot's skeleton in a container masked by the slot rectangle, which PIXI draws as a
Switched back and forth within one page it gave 60 fps against 42 (DPR 2) and 50 (DPR 1) with the shared buffer — scissor rect (no stencil, no render-pass break): nothing paints outside a slot, and the slot's eraser clears all that
turning it off only around the atlas pass gave 55 — for ≈ 0.3 ms more render CPU per frame; under load it also steadied an earlier pose can have left in it. Each redrawn slot is a batch flush of its own, and PIXI uploaded its one batch
the 0.2.5 renderer (51 → 59 fps at DPR 2). vertex buffer again for every flush, which stalls Apple GPUs (Chrome, ANGLE Metal, Apple M4: the 120-unit crowd at
- **Cost**: slot sizes grow with the boxes. On the roster they add up to 1.26 × the old area (the median skeleton 1920 × 1080 ran at 42–58 fps instead of 60, by the machine's load), so `render/app.js` turns PIXI's
1.13 ×). The 120-unit stress crowd at 1600 × 900 (DPR 1) uses 5.5 M px of slots instead of 4.4 M (two of the three atlas `BatchRenderer.canUploadSameBuffer` off: a buffer per flush, PIXI's own setting on iOS. Switched back and forth within
pages, no private render target). At DPR 2 a page holds a quarter of the CSS pixels: the crowd at 1280 × 720 keeps 98–108 one page it gave 60 fps against 42 (DPR 2) and 50 (DPR 1) with the shared buffer — turning it off only around the
of its units in the atlas instead of 118–120, at 1920 × 1080 59–61 instead of 58–59 (its private render targets 4.4 M px atlas pass gave 55 — for ≈ 0.3 ms more render CPU per frame; under load it also steadied the 0.2.5 renderer (51 → 59
instead of 2.8 M); at 844 × 390, DPR 3, all 120 stay in two pages. 60 fps in every case, before and after (runs on a fps at DPR 2).
quiet machine). A unit that finds no slot keeps a private render target, as before; the atlas keeps its ≤ 3 + 2 pages. - **Cost**: slot sizes grow with the boxes. On the roster they add up to 1.26 × the old area (the median skeleton 1.13
CPU: `tools/perfbench.mjs` (844 × 390, DPR 2, quality medium / low, where those battles draw through the atlas; four ×). The 120-unit stress crowd at 1600 × 900 (DPR 1) uses 5.5 M px of slots instead of 4.4 M (two of the three atlas
battles at a 4 × CPU throttle, two interleaved runs before and after): the view's JS 4.69 → 5.04 ms per frame, the pages, no private render target). At DPR 2 a page holds a quarter of the CSS pixels: the crowd at 1280 × 720 keeps
impostor-redraw stage +21 % (+0.12 ms per frame), all busy JS +0.4 %, 60 fps in every run. 98–108 of its units in the atlas instead of 118–120, at 1920 × 1080 59–61 instead of 58–59 (its private render targets
- [ASSUMED]: the growth limits (5 × / 2 ×), the 3 % + 8 padding, the sampling rate and the 2.5 ms budget. A pose between two 4.4 M px instead of 2.8 M); at 844 × 390, DPR 3, all 120 stay in two pages. 60 fps in every case, before and after
samples can still pass the padded box for a frame (42 of the 681 skeletons by a denser 60 Hz check, all in long clips (runs on a quiet machine). A unit that finds no slot keeps a private render target, as before; the atlas keeps its ≤ 3
sampled sparser than that); the scissor cuts it and leaves nothing behind. + 2 pages. CPU: `tools/perfbench.mjs` (844 × 390, DPR 2, quality medium / low, where those battles draw through the
- Tests: `test/render/impostor-bounds.test.js` (synthetic Spine 3.8 skeletons: clip bounds, an attachment shown for atlas; four battles at a 4 × CPU throttle, two interleaved runs before and after): the view's JS 4.69 → 5.04 ms per
0.05 s, an alpha-0 slot, the budgeted queue; a model frozen before its first update — stunned without a stun clip, dead frame, the impostor-redraw stage +21 % (+0.12 ms per frame), all busy JS +0.4 %, 60 fps in every run.
without a death or idle clip — posed once at its clip's first frame; `impostorBox` / `playedClips`; every atlas slot's - [ASSUMED]: the growth limits (5 × / 2 ×), the 3 % + 8 padding, the sampling rate and the 2.5 ms budget. A pose between
clip box and eraser; UnitView: drawn directly until measured, built while stunned, a skeleton without an Idle clip, the two samples can still pass the padded box for a frame (42 of the 681 skeletons by a denser 60 Hz check, all in long
over-clip bypass keeping its slot, the legacy fallback, a pen figure), `test/render/impostor-bounds-models.test.js` (the clips sampled sparser than that); the scissor cuts it and leaves nothing behind.
three operators through the real runtime: every pose of every clip they play, at 30 Hz, inside their box; the old box - Tests: `test/render/impostor-bounds.test.js` (synthetic Spine 3.8 skeletons: clip bounds, an attachment shown for 0.05
cut 野鬃 by 103 units and 浊心斯卡蒂 by 188; 隐现's Back model built while stunned shows its Idle's first frame inside s, an alpha-0 slot, the budgeted queue, a skeleton leaving the queue with its last actor, a clip whose sampling
the box, its setup pose lies outside it; skips without the assets), `test/render/impostor-bounds.browser.test.js` throws; a model frozen before its first update — stunned without a stun clip, dead without a death or idle clip —
(`RENDER_E2E=1`: the held stress crowd — inside their slots, no atlas pixel outside a live slot; the scissor on a bare posed once at its clip's first frame, again after an update that threw; `impostorBox` / `playedClips`; every atlas
page; 野鬃's deploy drawn directly), `test/render/boardart.test.js` (the allocator's test doubles). Dev: slot's clip box and eraser; UnitView: drawn directly until measured, built while stunned, a skeleton without an Idle
`render-demo.html?scene=stress&ops=<chessId>,…`. Golden: unchanged. clip, the over-clip bypass keeping its slot, an unmeasurable attack or idle clip drawn directly, the legacy fallback,
a pen figure), `test/render/impostor-bounds-models.test.js` (the three operators through the real runtime: every pose
of every clip they play, at 30 Hz, inside their box; the old box cut 野鬃 by 103 units and 浊心斯卡蒂 by 188; 隐现's Back model
built while stunned shows its Idle's first frame inside the box, its setup pose lies outside it; skips without the
assets), `test/render/impostor-bounds.browser.test.js` (`RENDER_E2E=1`: the held stress crowd — inside their slots, no
atlas pixel outside a live slot; the scissor on a bare page; 野鬃's deploy drawn directly),
`test/render/boardart.test.js` (the allocator's test doubles). Dev: `render-demo.html?scene=stress&ops=<chessId>,…`.
Golden: unchanged.
+43 -11
View File
@@ -129,12 +129,16 @@ function slotCentre(slot, att, buf, out) {
// by render/app.js once per frame, samples within that time budget: the clips a unit plays take ≈ 9 ms per skeleton on // by render/app.js once per frame, samples within that time budget: the clips a unit plays take ≈ 9 ms per skeleton on
// the current roster (53 ms at most), so a model is measured a few frames after it loads, long before a crowd needs its // the current roster (53 ms at most), so a model is measured a few frames after it loads, long before a crowd needs its
// impostor. [ASSUMED] the rate, the cap and the budget (a pose between two samples is covered by the box's padding, // impostor. [ASSUMED] the rate, the cap and the budget (a pose between two samples is covered by the box's padding,
// units.js IMP_BOX, and anything past it is cut by the atlas slot's scissor, never left behind). // units.js IMP_BOX, and anything past it is cut by the atlas slot's scissor, never left behind). A record is queued only
// while an actor uses its skeleton data: when the last one is destroyed (a model swap, a battlefield torn down) its pending
// clips and its scratch skeleton are dropped — nothing holds a skeleton the Spine store let go, and nothing stale is
// measured ahead of the next scene's models —, and a later actor queues what was never measured again. A clip whose
// sampling throws is recorded as not measurable (false): units.js draws it directly while it plays, never as empty.
export const CLIP_SAMPLE_HZ = 60; export const CLIP_SAMPLE_HZ = 60;
export const CLIP_SAMPLE_MAX = 48; export const CLIP_SAMPLE_MAX = 48;
export const CLIP_BOUNDS_BUDGET_MS = 2.5; export const CLIP_BOUNDS_BUDGET_MS = 2.5;
const ATT_REGION = 0, ATT_MESH = 2; // pixi-spine AttachmentType const ATT_REGION = 0, ATT_MESH = 2; // pixi-spine AttachmentType
const boundsOf = new WeakMap(); // skeleton data → { data, Skeleton, skel, rects: Map<clip, rect|null>, todo, job, failed } const boundsOf = new WeakMap(); // skeleton data → { data, Skeleton, skel, rects: Map<clip, rect|null|false>, todo, job, users, failed }
const boundsQueue = []; // records with clips still to sample, oldest request first const boundsQueue = []; // records with clips still to sample, oldest request first
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now()); const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
const emptyRect = () => [Infinity, Infinity, -Infinity, -Infinity]; const emptyRect = () => [Infinity, Infinity, -Infinity, -Infinity];
@@ -185,7 +189,7 @@ function scratchSkeleton(rec) {
function boundsRecord(data, Skeleton) { function boundsRecord(data, Skeleton) {
let rec = boundsOf.get(data); let rec = boundsOf.get(data);
if (rec) return rec; if (rec) return rec;
rec = { data, Skeleton, skel: null, rects: new Map(), todo: [], job: null, failed: false, buf: { v: new Float32Array(256) } }; rec = { data, Skeleton, skel: null, rects: new Map(), todo: [], job: null, users: 0, failed: false, buf: { v: new Float32Array(256) } };
try { try {
const skel = scratchSkeleton(rec); const skel = scratchSkeleton(rec);
skel.setToSetupPose(); skel.setToSetupPose();
@@ -223,9 +227,25 @@ export function requestClipBounds(data, Skeleton, names) {
if (typeof n === 'string' && !rec.rects.has(n) && !rec.todo.includes(n) && !(rec.job && rec.job.name === n)) { rec.todo.push(n); added = true; } if (typeof n === 'string' && !rec.rects.has(n) && !rec.todo.includes(n) && !(rec.job && rec.job.name === n)) { rec.todo.push(n); added = true; }
} }
if (added && !boundsQueue.includes(rec)) boundsQueue.push(rec); if (added && !boundsQueue.includes(rec)) boundsQueue.push(rec);
else if (!boundsQueue.includes(rec)) rec.skel = null; // nothing to sample: no scratch skeleton kept
return rec; return rec;
} }
/**
* One actor more (+1, SpineActor.clipBounds) or less (−1, SpineActor.destroy) measures record `rec`; with none left, the
* record leaves the queue with its pending clips (todo and the clip in progress) and its scratch skeleton — a later actor
* queues what was never measured again (a todo left behind would read as queued and never be measured).
*/
function useBounds(rec, delta) {
rec.users = Math.max(0, rec.users + delta);
if (rec.users > 0) return;
const i = boundsQueue.indexOf(rec);
if (i >= 0) boundsQueue.splice(i, 1);
rec.todo.length = 0;
rec.job = null;
rec.skel = null;
}
/** /**
* Sample queued clips for up to `budgetMs` (render/app.js calls it once per frame): pose after pose, a clip resumed on the * Sample queued clips for up to `budgetMs` (render/app.js calls it once per frame): pose after pose, a clip resumed on the
* next call when the budget runs out inside it; at least one pose per call. * next call when the budget runs out inside it; at least one pose per call.
@@ -240,8 +260,10 @@ export function stepClipBounds(budgetMs = CLIP_BOUNDS_BUDGET_MS) {
if (!rec.job) { if (!rec.job) {
const name = rec.todo.shift(); const name = rec.todo.shift();
if (name === undefined) { boundsQueue.shift(); rec.skel = null; continue; } if (name === undefined) { boundsQueue.shift(); rec.skel = null; continue; }
try { rec.job = startClip(rec, name); } catch { rec.job = null; } let broken = false;
if (!rec.job) { rec.rects.set(name, null); finished++; continue; } try { rec.job = startClip(rec, name); } catch { rec.job = null; broken = true; }
// no such clip: it never plays (null); a clip that could not be set up: not measurable (false)
if (!rec.job) { rec.rects.set(name, broken ? false : null); finished++; continue; }
} }
const j = rec.job; const j = rec.job;
try { try {
@@ -255,9 +277,10 @@ export function stepClipBounds(budgetMs = CLIP_BOUNDS_BUDGET_MS) {
posed++; posed++;
if (nowMs() >= end) break; if (nowMs() >= end) break;
} }
} catch { j.i = j.n + 1; j.r = emptyRect(); } } catch { j.i = j.n + 1; j.broken = true; }
if (j.i > j.n) { if (j.i > j.n) {
rec.rects.set(j.name, rectOk(j.r) ? j.r : null); // a pose that threw: the clip is not measurable (false) — drawn directly while it plays, not taken as empty
rec.rects.set(j.name, j.broken ? false : rectOk(j.r) ? j.r : null);
rec.job = null; rec.job = null;
finished++; finished++;
} }
@@ -804,8 +827,10 @@ export class SpineActor {
// left in its setup pose, which no clip shows and the impostor box does not cover (GitHub #542: 隐现's Back model // left in its setup pose, which no clip shows and the impostor box does not cover (GitHub #542: 隐现's Back model
// stands 900 units to the right of its feet in the setup pose) // stands 900 units to the right of its feet in the setup pose)
if (!this.frozen || !this._posed) { if (!this.frozen || !this._posed) {
try { this.spine.update(this.frozen ? 0 : dt); } catch { /* a broken skeleton must not stop the frame */ } try {
this._posed = true; this.spine.update(this.frozen ? 0 : dt);
this._posed = true; // (a first update that threw is tried again: no setup pose left on screen)
} catch { /* a broken skeleton must not stop the frame */ }
} }
if (this.clipped && !this.clipOn) this._eyeMaskFallback(); if (this.clipped && !this.clipOn) this._eyeMaskFallback();
this._applied = true; this._applied = true;
@@ -863,8 +888,9 @@ export class SpineActor {
/** /**
* Skeleton-space bounds of clips `names` ("clip bounds" above; render/units.js _impBox): a Map name → rect (null: draws * Skeleton-space bounds of clips `names` ("clip bounds" above; render/units.js _impBox): a Map name → rect (null: draws
* nothing / no such clip) once every clip is sampled; null while some still wait — they are queued here and * nothing / no such clip; false: its sampling threw, not measurable) once every clip is sampled; null while some still
* render/app.js samples them (stepClipBounds); false when this runtime cannot be posed off-screen (a test double). * wait — they are queued here and render/app.js samples them (stepClipBounds); false when this runtime cannot be posed
* off-screen (a test double). The actor counts as a user of its skeleton's record until it is destroyed.
* @param {string[]} names * @param {string[]} names
*/ */
clipBounds(names) { clipBounds(names) {
@@ -874,6 +900,11 @@ export class SpineActor {
let rec = boundsOf.get(data); let rec = boundsOf.get(data);
if (!rec || names.some((n) => !rec.rects.has(n))) rec = requestClipBounds(data, sp.skeleton && sp.skeleton.constructor, names); if (!rec || names.some((n) => !rec.rects.has(n))) rec = requestClipBounds(data, sp.skeleton && sp.skeleton.constructor, names);
if (rec.failed) return false; if (rec.failed) return false;
if (this._bounds !== rec) {
if (this._bounds) useBounds(this._bounds, -1);
this._bounds = rec;
useBounds(rec, 1);
}
const clips = new Map(); const clips = new Map();
for (const n of names) { for (const n of names) {
if (!rec.rects.has(n)) return null; if (!rec.rects.has(n)) return null;
@@ -890,6 +921,7 @@ export class SpineActor {
} }
destroy() { destroy() {
if (this._bounds) { useBounds(this._bounds, -1); this._bounds = null; } // the last user gone: its queue entry goes
try { this.spine.destroy({ children: true, texture: false, baseTexture: false }); } catch { /* ignore */ } try { this.spine.destroy({ children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
this.spine = null; this.spine = null;
} }
+19 -9
View File
@@ -469,8 +469,9 @@ export const IMP_BOX = Object.freeze({ steady: 5, transient: 2, pad: 0.03, padMi
* one-off clip). A clip that does not fit and is not inside the box anyway is `over`: its unit is drawn directly while it * one-off clip). A clip that does not fit and is not inside the box anyway is `over`: its unit is drawn directly while it
* plays (units.js _updateImpostor), so a rare huge clip (a deploy drop from above, a screen-wide sweep) does not grow every * plays (units.js _updateImpostor), so a rare huge clip (a deploy drop from above, a screen-wide sweep) does not grow every
* refresh of the unit's slot. Padded by IMP_BOX.pad. The setup pose needs no room: no model shows it (spine.js * refresh of the unit's slot. Padded by IMP_BOX.pad. The setup pose needs no room: no model shows it (spine.js
* SpineActor.update poses one frozen before its first update at its clip's first frame). Without an idle clip that draws * SpineActor.update poses one frozen before its first update at its clip's first frame). A clip whose rect is `false`
* anything, the looping clips make the core. null when nothing is drawn. * (its sampling threw, spine.js clipBounds) is `over` as well — drawn directly, never taken as drawing nothing. Without an
* idle clip that draws anything, the looping clips make the core. null when nothing is drawn.
* @returns {{ x0: number, y0: number, w: number, h: number, over: Set<string> } | null} * @returns {{ x0: number, y0: number, w: number, h: number, over: Set<string> } | null}
*/ */
export function impostorBox(idle, clips = []) { export function impostorBox(idle, clips = []) {
@@ -490,7 +491,7 @@ export function impostorBox(idle, clips = []) {
if (area(u) <= (c.once ? IMP_BOX.transient : IMP_BOX.steady) * limit) box = u; if (area(u) <= (c.once ? IMP_BOX.transient : IMP_BOX.steady) * limit) box = u;
else left.push(c); else left.push(c);
} }
const over = new Set(); const over = new Set(clips.filter(([, r]) => r === false).map(([name]) => name));
for (const c of left) if (c.r[0] < box[0] || c.r[1] < box[1] || c.r[2] > box[2] || c.r[3] > box[3]) over.add(c.name); for (const c of left) if (c.r[0] < box[0] || c.r[1] < box[1] || c.r[2] > box[2] || c.r[3] > box[3]) over.add(c.name);
const pad = IMP_BOX.pad * Math.max(box[2] - box[0], box[3] - box[1]) + IMP_BOX.padMin; const pad = IMP_BOX.pad * Math.max(box[2] - box[0], box[3] - box[1]) + IMP_BOX.padMin;
return { x0: box[0] - pad, y0: box[1] - pad, w: box[2] - box[0] + 2 * pad, h: box[3] - box[1] + 2 * pad, over }; return { x0: box[0] - pad, y0: box[1] - pad, w: box[2] - box[0] + 2 * pad, h: box[3] - box[1] + 2 * pad, over };
@@ -1745,13 +1746,19 @@ export class UnitView {
const rects = typeof a.clipBounds === 'function' ? a.clipBounds([...names.keys()]) : false; const rects = typeof a.clipBounds === 'function' ? a.clipBounds([...names.keys()]) : false;
if (rects === null) return null; if (rects === null) return null;
let box = null; let box = null;
const unmeasured = new Set(); // clips whose sampling threw (false): drawn directly while they play
if (rects) { if (rects) {
const idle = this._idleClip(); const idle = this._idleClip();
const rect = (name) => (rects.has(name) ? rects.get(name) : null);
const rest = []; const rest = [];
for (const [name, once] of names) if (name !== idle) rest.push([name, rects.get(name) || null, once]); for (const [name, once] of names) {
box = impostorBox(idle ? rects.get(idle) || null : null, rest); if (rect(name) === false) unmeasured.add(name);
if (name !== idle) rest.push([name, rect(name), once]);
}
box = impostorBox(idle && rect(idle) !== false ? rect(idle) : null, rest);
if (box && idle && unmeasured.has(idle)) box.over.add(idle);
} }
this._box = box || this._legacyBox(); this._box = box || this._legacyBox(unmeasured);
return this._box; return this._box;
} }
@@ -1789,8 +1796,11 @@ export class UnitView {
return out; return out;
} }
/** The box sized from the pose drawn now, for a runtime whose clips cannot be measured (the pre-#542 rule). */ /**
_legacyBox() { * The box sized from the pose drawn now, for a runtime whose clips cannot be measured (the pre-#542 rule); `over` =
* clips that could not be measured (drawn directly while they play).
*/
_legacyBox(over = NO_CLIPS) {
let b = null; let b = null;
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; } try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
let x0 = -220, y0 = -420, x1 = 220, y1 = 40; let x0 = -220, y0 = -420, x1 = 220, y1 = 40;
@@ -1799,7 +1809,7 @@ export class UnitView {
x0 = Math.min(b.x - padX, -140); x1 = Math.max(b.x + b.width + padX, 140); x0 = Math.min(b.x - padX, -140); x1 = Math.max(b.x + b.width + padX, 140);
y0 = Math.min(b.y - padY, -260); y1 = Math.max(b.y + b.height + padY * 0.4, 30); y0 = Math.min(b.y - padY, -260); y1 = Math.max(b.y + b.height + padY * 0.4, 30);
} }
return { x0, y0, w: x1 - x0, h: y1 - y0, over: NO_CLIPS }; return { x0, y0, w: x1 - x0, h: y1 - y0, over: over && over.size ? over : NO_CLIPS };
} }
/** Is a clip outside the box (`over`) on screen — the track's clip or one it is still mixing out of? */ /** Is a clip outside the box (`over`) on screen — the track's clip or one it is still mixing out of? */
+70
View File
@@ -104,6 +104,43 @@ describe('clip bounds (render/spine.js): what a skeleton draws, measured once pe
assert.equal(spine.stepClipBounds(), 0, 'an empty queue costs nothing'); 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', () => { test('SpineActor.clipBounds: null while queued, then a Map clip → rect; false for a runtime that cannot be posed', () => {
const data = skeleton(); const data = skeleton();
const stand = { spine: { spineData: data, skeleton: new rt.Skeleton(data) } }; const stand = { spine: { spineData: data, skeleton: new rt.Skeleton(data) } };
@@ -144,6 +181,17 @@ describe('a model frozen before its first update shows its clip\'s first frame,
assert.deepEqual(posed, [0, 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)', () => { test('stunned after it was posed: the pose it had holds (unchanged)', () => {
const { a, posed } = actor({ animations: [{ name: 'Idle' }, { name: 'Die' }] }, ENTRY); const { a, posed } = actor({ animations: [{ name: 'Idle' }, { name: 'Die' }] }, ENTRY);
a.update(0.2); a.update(0.2);
@@ -355,6 +403,28 @@ describe('UnitView impostor mode (render/units.js)', () => {
assert.equal(log.alloc, c0.alloc, 'the same 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 () => { 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); const { v, step } = await operator(() => false);
step(3); step(3);