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

+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
// 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;
}
+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
* 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? */