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:
1 parent
4c2fe9431d
commit
47004a3651
4 files changed
+197
-78
No files matched your search
+65
-58
@@ -111,62 +111,69 @@ Regression covers normal two-, three- and four-human browser flows, the final tu
|
||||
`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
|
||||
while she idles; 浊心斯卡蒂's head cut in her S3 flight (Skill_3_Begin); 隐现's pistol muzzle cut, offset and leaving
|
||||
stray pixels during her skill.
|
||||
- **Cause** (reproduced in headless Chrome: the dev demo's stress crowd, 120 units at impostor interval 4, the three held
|
||||
in their widest poses): a unit's impostor slot was sized once from the pose it showed when it first became an impostor,
|
||||
plus a margin (`_impBox`: the pose's bounds + 30 % / 22 %). A later pose past that box was drawn past the 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 cleared on its
|
||||
first flush only and each eraser covers its own slot, so a spill into free space stayed, and a spill into another unit's
|
||||
slot showed on that unit until it was redrawn in a frame without the spill. 浊心斯卡蒂's flight reaches 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 −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 pose); after one clean
|
||||
redraw of every impostor the atlas pages held 0–1,405 pixels outside every live slot. 隐现's own poses fit her box (the
|
||||
silencer reaches x 197, her box 225): her muzzle sits at her slot's edge, where a neighbour's spill lands. Of the
|
||||
roster's 681 skeletons, 598 play a clip that leaves a box sized that way from their idle's first frame. No clipping
|
||||
attachment is involved (`spine.js _eyeMaskFallback`, #177, unchanged).
|
||||
while she idles; 浊心斯卡蒂's head cut in her S3 flight (Skill_3_Begin); 隐现's pistol muzzle cut, offset and leaving stray
|
||||
pixels during her skill.
|
||||
- **Cause** (reproduced in headless Chrome: the dev demo's stress crowd, 120 units at impostor interval 4, the three
|
||||
held in their widest poses): a unit's impostor slot was sized once from the pose it showed when it first became an
|
||||
impostor, plus a margin (`_impBox`: the pose's bounds + 30 % / 22 %). A later pose past that box was drawn past the
|
||||
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
|
||||
cleared on its first flush only and each eraser covers its own slot, so a spill into free space stayed, and a spill
|
||||
into another unit's slot showed on that unit until it was redrawn in a frame without the spill. 浊心斯卡蒂's flight reaches
|
||||
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
|
||||
−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
|
||||
pose); after one clean redraw of every impostor the atlas pages held 0–1,405 pixels outside every live slot. 隐现's own
|
||||
poses fit her box (the silencer reaches x 197, her box 225): her muzzle sits at her slot's edge, where a neighbour's
|
||||
spill lands. Of the roster's 681 skeletons, 598 play a clip that leaves a box sized that way from their idle's first
|
||||
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
|
||||
sets and change clips) is measured once per skeleton data on a scratch skeleton: 60 poses a second, at most 49 per clip;
|
||||
a region by its attachment rectangle, a mesh by its vertices; slots drawn at alpha 0 skipped. It is queued when the model
|
||||
loads and sampled within 2.5 ms per frame (`stepClipBounds`, called by `render/app.js`), ≈ 9 ms per skeleton on the
|
||||
roster (53 ms at most), shared by every unit of that skeleton; a unit is drawn directly until its box is ready. (2) The
|
||||
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
|
||||
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
|
||||
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
|
||||
from above, up to 1,700 units tall — and the looping or held clips of 13 skeletons with screen-wide effects, among them
|
||||
帕拉斯's skills and 诗怀雅's attack) is drawn directly while it plays; the unit keeps its slot (`_impBypass`). The box is
|
||||
computed once per unit: no bounds work per frame. The setup pose needs no room: a model frozen before its first update —
|
||||
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
|
||||
idle clip — showed its setup pose, which no clip shows (隐现's Back model stands 900 units right of its feet in it);
|
||||
`SpineActor.update` now poses it once at its clip's first frame. (3) The atlas draws each slot's skeleton in a container
|
||||
masked by the slot rectangle, which PIXI draws as a scissor rect (no stencil, no render-pass break): nothing paints
|
||||
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
|
||||
flush of its own, and PIXI uploaded its one batch vertex buffer again for every flush, which stalls Apple GPUs (Chrome,
|
||||
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
|
||||
`render/app.js` turns PIXI's `BatchRenderer.canUploadSameBuffer` off: a buffer per flush, PIXI's own setting on iOS.
|
||||
Switched back and forth within one page it gave 60 fps against 42 (DPR 2) and 50 (DPR 1) with the shared buffer —
|
||||
turning it off only around the 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 fps at DPR 2).
|
||||
- **Cost**: slot sizes grow with the boxes. On the roster they add up to 1.26 × the old area (the median skeleton
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
quiet machine). A unit that finds no slot keeps a private render target, as before; the atlas keeps its ≤ 3 + 2 pages.
|
||||
CPU: `tools/perfbench.mjs` (844 × 390, DPR 2, quality medium / low, where those battles draw through the atlas; four
|
||||
battles at a 4 × CPU throttle, two interleaved runs before and after): the view's JS 4.69 → 5.04 ms per frame, the
|
||||
impostor-redraw stage +21 % (+0.12 ms per frame), all busy JS +0.4 %, 60 fps in every run.
|
||||
- [ASSUMED]: the growth limits (5 × / 2 ×), the 3 % + 8 padding, the sampling rate and the 2.5 ms budget. A pose between 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 clips
|
||||
sampled sparser than that); the scissor cuts it and leaves nothing behind.
|
||||
- Tests: `test/render/impostor-bounds.test.js` (synthetic Spine 3.8 skeletons: clip bounds, an attachment shown for
|
||||
0.05 s, an alpha-0 slot, the budgeted queue; a model frozen before its first update — stunned without a stun clip, dead
|
||||
without a death or idle clip — posed once at its clip's first frame; `impostorBox` / `playedClips`; every atlas slot's
|
||||
clip box and eraser; UnitView: drawn directly until measured, built while stunned, a skeleton without an Idle clip, the
|
||||
over-clip bypass keeping its slot, 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.
|
||||
sets and change clips) is measured once per skeleton data on a scratch skeleton: 60 poses a second, at most 49 per
|
||||
clip; a region by its attachment rectangle, a mesh by its vertices; slots drawn at alpha 0 skipped. It is queued when
|
||||
the model loads and sampled within 2.5 ms per frame (`stepClipBounds`, called by `render/app.js`), ≈ 9 ms per skeleton
|
||||
on the roster (53 ms at most), shared by every unit of that skeleton; a unit is drawn directly until its box is ready.
|
||||
A skeleton stays queued only while an actor uses it: when the last one is destroyed (a model swap, a battlefield torn
|
||||
down) its pending clips and its scratch skeleton are dropped, so nothing holds a skeleton the Spine store let go or is
|
||||
measured ahead of the next scene's models, and a later actor queues again what was never measured. (2) The 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 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 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
|
||||
from above, up to 1,700 units tall — and the looping or held clips of 13 skeletons with screen-wide effects, among
|
||||
them 帕拉斯's skills and 诗怀雅's attack) is drawn directly while it plays; the unit keeps its slot (`_impBypass`). So is a
|
||||
clip whose sampling threw (recorded as not measurable, never as drawing nothing — the scissor would cut it). The box
|
||||
is computed once per unit: no bounds work per frame. The setup pose needs no room: a model frozen before its first
|
||||
update — 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 idle clip — showed its setup pose, which no clip shows (隐现's Back model stands 900 units right of its feet
|
||||
in it); `SpineActor.update` now poses it once at its clip's first frame (again on the next frame if that update
|
||||
threw). (3) The atlas draws each slot's skeleton in a container masked by the slot rectangle, which PIXI draws as a
|
||||
scissor rect (no stencil, no render-pass break): nothing paints 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 flush of its own, and PIXI uploaded its one batch
|
||||
vertex buffer again for every flush, which stalls Apple GPUs (Chrome, 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 `render/app.js` turns PIXI's
|
||||
`BatchRenderer.canUploadSameBuffer` off: a buffer per flush, PIXI's own setting on iOS. Switched back and forth within
|
||||
one page it gave 60 fps against 42 (DPR 2) and 50 (DPR 1) with the shared buffer — turning it off only around the
|
||||
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
|
||||
fps at DPR 2).
|
||||
- **Cost**: slot sizes grow with the boxes. On the roster they add up to 1.26 × the old area (the median skeleton 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
|
||||
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 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 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 quiet machine). A unit that finds no slot keeps a private render target, as before; the atlas keeps its ≤ 3
|
||||
+ 2 pages. CPU: `tools/perfbench.mjs` (844 × 390, DPR 2, quality medium / low, where those battles draw through the
|
||||
atlas; four battles at a 4 × CPU throttle, two interleaved runs before and after): the view's JS 4.69 → 5.04 ms per
|
||||
frame, the impostor-redraw stage +21 % (+0.12 ms per frame), all busy JS +0.4 %, 60 fps in every run.
|
||||
- [ASSUMED]: the growth limits (5 × / 2 ×), the 3 % + 8 padding, the sampling rate and the 2.5 ms budget. A pose between
|
||||
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
|
||||
clips sampled sparser than that); the scissor cuts it and leaves nothing behind.
|
||||
- Tests: `test/render/impostor-bounds.test.js` (synthetic Spine 3.8 skeletons: clip bounds, an attachment shown for 0.05
|
||||
s, an alpha-0 slot, the budgeted queue, a skeleton leaving the queue with its last actor, a clip whose sampling
|
||||
throws; a model frozen before its first update — stunned without a stun clip, dead without a death or idle clip —
|
||||
posed once at its clip's first frame, again after an update that threw; `impostorBox` / `playedClips`; every atlas
|
||||
slot's clip box and eraser; UnitView: drawn directly until measured, built while stunned, a skeleton without an Idle
|
||||
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
@@ -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
|
||||
// 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,
|
||||
// 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_MAX = 48;
|
||||
export const CLIP_BOUNDS_BUDGET_MS = 2.5;
|
||||
const ATT_REGION = 0, ATT_MESH = 2; // pixi-spine AttachmentType
|
||||
const boundsOf = new WeakMap(); // skeleton data → { data, Skeleton, skel, 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 nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
||||
const emptyRect = () => [Infinity, Infinity, -Infinity, -Infinity];
|
||||
@@ -185,7 +189,7 @@ function scratchSkeleton(rec) {
|
||||
function boundsRecord(data, Skeleton) {
|
||||
let rec = boundsOf.get(data);
|
||||
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 {
|
||||
const skel = scratchSkeleton(rec);
|
||||
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 (added && !boundsQueue.includes(rec)) boundsQueue.push(rec);
|
||||
else if (!boundsQueue.includes(rec)) rec.skel = null; // nothing to sample: no scratch skeleton kept
|
||||
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
|
||||
* 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) {
|
||||
const name = rec.todo.shift();
|
||||
if (name === undefined) { boundsQueue.shift(); rec.skel = null; continue; }
|
||||
try { rec.job = startClip(rec, name); } catch { rec.job = null; }
|
||||
if (!rec.job) { rec.rects.set(name, null); finished++; continue; }
|
||||
let broken = false;
|
||||
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;
|
||||
try {
|
||||
@@ -255,9 +277,10 @@ export function stepClipBounds(budgetMs = CLIP_BOUNDS_BUDGET_MS) {
|
||||
posed++;
|
||||
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) {
|
||||
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;
|
||||
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
|
||||
// stands 900 units to the right of its feet in the setup pose)
|
||||
if (!this.frozen || !this._posed) {
|
||||
try { this.spine.update(this.frozen ? 0 : dt); } catch { /* a broken skeleton must not stop the frame */ }
|
||||
this._posed = true;
|
||||
try {
|
||||
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();
|
||||
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
|
||||
* nothing / no such clip) once every clip is sampled; null while some still wait — they are queued here and
|
||||
* render/app.js samples them (stepClipBounds); false when this runtime cannot be posed off-screen (a test double).
|
||||
* nothing / no such clip; false: its sampling threw, not measurable) once every clip is sampled; null while some still
|
||||
* wait — they are queued here and render/app.js samples them (stepClipBounds); false when this runtime cannot be posed
|
||||
* off-screen (a test double). The actor counts as a user of its skeleton's record until it is destroyed.
|
||||
* @param {string[]} names
|
||||
*/
|
||||
clipBounds(names) {
|
||||
@@ -874,6 +900,11 @@ export class SpineActor {
|
||||
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.failed) return false;
|
||||
if (this._bounds !== rec) {
|
||||
if (this._bounds) useBounds(this._bounds, -1);
|
||||
this._bounds = rec;
|
||||
useBounds(rec, 1);
|
||||
}
|
||||
const clips = new Map();
|
||||
for (const n of names) {
|
||||
if (!rec.rects.has(n)) return null;
|
||||
@@ -890,6 +921,7 @@ export class SpineActor {
|
||||
}
|
||||
|
||||
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 */ }
|
||||
this.spine = null;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* plays (units.js _updateImpostor), so a rare huge clip (a deploy drop from above, a screen-wide sweep) does not grow every
|
||||
* refresh of the unit's slot. Padded by IMP_BOX.pad. The setup pose needs no room: no model shows it (spine.js
|
||||
* SpineActor.update poses one frozen before its first update at its clip's first frame). Without an idle clip that draws
|
||||
* anything, the looping clips make the core. null when nothing is drawn.
|
||||
* SpineActor.update poses one frozen before its first update at its clip's first frame). A clip whose rect is `false`
|
||||
* (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}
|
||||
*/
|
||||
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;
|
||||
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);
|
||||
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 };
|
||||
@@ -1745,13 +1746,19 @@ export class UnitView {
|
||||
const rects = typeof a.clipBounds === 'function' ? a.clipBounds([...names.keys()]) : false;
|
||||
if (rects === null) return null;
|
||||
let box = null;
|
||||
const unmeasured = new Set(); // clips whose sampling threw (false): drawn directly while they play
|
||||
if (rects) {
|
||||
const idle = this._idleClip();
|
||||
const rect = (name) => (rects.has(name) ? rects.get(name) : null);
|
||||
const rest = [];
|
||||
for (const [name, once] of names) if (name !== idle) rest.push([name, rects.get(name) || null, once]);
|
||||
box = impostorBox(idle ? rects.get(idle) || null : null, rest);
|
||||
for (const [name, once] of names) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1789,8 +1796,11 @@ export class UnitView {
|
||||
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;
|
||||
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
|
||||
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);
|
||||
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? */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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) } };
|
||||
@@ -144,6 +181,17 @@ describe('a model frozen before its first update shows its clip\'s first frame,
|
||||
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);
|
||||
@@ -355,6 +403,28 @@ describe('UnitView impostor mode (render/units.js)', () => {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user