diff --git a/docs/history/0.2.6.md b/docs/history/0.2.6.md index 21d9f60f..65bd4f10 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -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=,…`. 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=,…`. + Golden: unchanged. diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 0ba3ba47..c04e1bda 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -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, todo, job, failed } +const boundsOf = new WeakMap(); // skeleton data → { data, Skeleton, skel, rects: Map, 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; } diff --git a/public/js/render/units.js b/public/js/render/units.js index 441cb677..3fbdc232 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -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 } | 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? */ diff --git a/test/render/impostor-bounds.test.js b/test/render/impostor-bounds.test.js index ecbf5525..39bd53e8 100644 --- a/test/render/impostor-bounds.test.js +++ b/test/render/impostor-bounds.test.js @@ -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);