0.2.6-dev: impostor slots sized from every clip a unit plays and clipped to themselves (GitHub #542 by @ImPQlc: in a crowd 野鬃's idle flag, 浊心斯卡蒂's S3 flight and 隐现's pistol were cut, offset or left stray pixels) — spine.js measures each skeleton's clips once within a 2.5 ms frame budget, units.js impostorBox covers the setup pose and the played clips (a clip past 4× / 2× of that core is drawn directly while it plays, the slot kept), impostor.js draws each slot's skeleton under a scissor rect of the slot; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-13 03:30:48 +08:00
1 parent e825254850
commit c45d86f70c
8 files changed
+1153 -22

No files matched your search

+178
View File
@@ -33,6 +33,8 @@
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
// redraws the model, only advances `clock`)
// clipBounds(names) skeleton-space bounds of the setup pose and of those clips (sampled once per
// skeleton data, see "clip bounds" below; render/units.js sizes the impostor box)
// Each attack plays its clip once, then the end clip (if any) and base — except the attacks of a skill
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
// attacks while it runs.
@@ -110,6 +112,161 @@ function slotCentre(slot, att, buf, out) {
return Number.isFinite(out[0]) && Number.isFinite(out[1]);
}
// ---- clip bounds (GitHub #542) ------------------------------------------------------------------------------------
// What a skeleton can draw, in skeleton space (pixi-spine's y-down world; rects are [x0, y0, x1, y1]): its setup pose and
// each clip, sampled once per skeleton data and shared by every actor built from it. render/units.js sizes a unit's
// impostor slot from the clips the unit plays — a slot sized from the first pose drawn (plus a margin) cut 野鬃's idle
// flag and 浊心斯卡蒂's head in her S3 flight, and 598 of the roster's 681 skeletons play a clip that leaves a slot sized
// that way from their idle's first frame. A clip is posed on a scratch skeleton of the same runtime (never an actor's
// own) CLIP_SAMPLE_HZ times a second, at most CLIP_SAMPLE_MAX + 1 times (a longer clip is sampled more sparsely); every
// slot that is drawn (alpha > 0) counts — a region with its whole attachment rectangle (the atlas trim only makes the
// drawn sprite smaller), a mesh with its world vertices. (Skeleton.getBounds() cannot be used: pixi-spine never computes
// the RegionAttachment offsets it reads, its sprites do not need them.) Requests are queued; `stepClipBounds(ms)`, called
// 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).
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, setup, rects: Map<clip, rect|null>, todo, job, 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];
const rectOk = (r) => !!r && Number.isFinite(r[0]) && Number.isFinite(r[1]) && Number.isFinite(r[2]) && Number.isFinite(r[3]) && r[0] <= r[2] && r[1] <= r[3];
/** Grow `r` by everything `skel` draws in its current pose (see "clip bounds"); `buf.v` is a scratch vertex buffer. */
function poseRect(skel, r, buf) {
const grow = (x, y) => { if (x < r[0]) r[0] = x; if (x > r[2]) r[2] = x; if (y < r[1]) r[1] = y; if (y > r[3]) r[3] = y; };
for (const slot of skel.drawOrder) {
const bone = slot.bone;
if (!bone || bone.active === false) continue;
const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment;
if (!att || (slot.color ? slot.color.a : 1) * (att.color ? att.color.a : 1) <= 0.004) continue;
if (att.type === ATT_MESH && typeof att.computeWorldVertices === 'function') {
const n = att.worldVerticesLength | 0;
if (n < 2) continue;
if (buf.v.length < n) buf.v = new Float32Array(n);
const v = buf.v;
att.computeWorldVertices(slot, 0, n, v, 0, 2);
for (let k = 0; k < n; k += 2) grow(v[k], v[k + 1]);
} else if (att.type === ATT_REGION && bone.matrix) {
// the attachment rectangle (width × height × scale, rotated, at x / y) through the bone (a PIXI matrix)
const m = bone.matrix;
const hw = (att.width * att.scaleX) / 2, hh = (att.height * att.scaleY) / 2;
const rad = (att.rotation * Math.PI) / 180, c = Math.cos(rad), s = Math.sin(rad);
for (let q = 0; q < 4; q++) {
const px = q === 0 || q === 3 ? -hw : hw, py = q < 2 ? -hh : hh;
const lx = px * c - py * s + att.x, ly = px * s + py * c + att.y;
grow(m.a * lx + m.c * ly + m.tx, m.b * lx + m.d * ly + m.ty);
}
}
}
return r;
}
/** The scratch skeleton of a bounds record (made on demand, dropped once its queued clips are sampled). */
function scratchSkeleton(rec) {
if (rec.skel) return rec.skel;
const Skeleton = rec.Skeleton;
if (typeof Skeleton !== 'function' || Skeleton === Object) throw new Error('no Spine runtime');
const skel = new Skeleton(rec.data);
if (!Array.isArray(skel.drawOrder) || typeof skel.setToSetupPose !== 'function' || typeof skel.updateWorldTransform !== 'function') throw new Error('no Spine runtime');
rec.skel = skel;
return skel;
}
/** The bounds record of skeleton data `data` (its setup pose measured at once); `failed` when it cannot be posed here. */
function boundsRecord(data, Skeleton) {
let rec = boundsOf.get(data);
if (rec) return rec;
rec = { data, Skeleton, skel: null, setup: null, rects: new Map(), todo: [], job: null, failed: false, buf: { v: new Float32Array(256) } };
try {
const skel = scratchSkeleton(rec);
skel.setToSetupPose();
skel.updateWorldTransform();
const r = poseRect(skel, emptyRect(), rec.buf);
rec.setup = rectOk(r) ? r : null;
} catch {
rec.failed = true;
rec.skel = null;
}
boundsOf.set(data, rec);
return rec;
}
/**
* Start sampling clip `name`: { name, anim, d, n, i, r } (posed at t = d·i/n for i = 0…n; `r` grows), or null when the
* skeleton has no such clip.
*/
function startClip(rec, name) {
const anim = typeof rec.data.findAnimation === 'function' ? rec.data.findAnimation(name) : null;
if (!anim || typeof anim.apply !== 'function') return null;
const d = Number.isFinite(anim.duration) && anim.duration > 0 ? anim.duration : 0;
const n = d > 0 ? Math.min(CLIP_SAMPLE_MAX, Math.max(1, Math.ceil(d * CLIP_SAMPLE_HZ))) : 0;
scratchSkeleton(rec).setToSetupPose();
return { name, anim, d, n, i: 0, r: emptyRect() };
}
/**
* Queue clips `names` of skeleton data `data` (pixi-spine runtime class `Skeleton`) for sampling — those neither sampled
* nor queued yet — and return its record (`setup`, `rects`, `failed`).
*/
export function requestClipBounds(data, Skeleton, names) {
const rec = boundsRecord(data, Skeleton);
if (rec.failed) return rec;
let added = false;
for (const n of 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);
return rec;
}
/**
* 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.
* @returns {number} clips finished
*/
export function stepClipBounds(budgetMs = CLIP_BOUNDS_BUDGET_MS) {
if (!boundsQueue.length) return 0;
const end = nowMs() + budgetMs;
let finished = 0, posed = 0;
while (boundsQueue.length) {
const rec = boundsQueue[0];
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; }
}
const j = rec.job;
try {
const skel = scratchSkeleton(rec);
while (j.i <= j.n) {
const t = j.n ? (j.d * j.i) / j.n : 0;
j.anim.apply(skel, t, t, false, null, 1, 0, 0); // MixBlend.setup, MixDirection.mixIn: the clip's own pose at t
skel.updateWorldTransform();
poseRect(skel, j.r, rec.buf);
j.i++;
posed++;
if (nowMs() >= end) break;
}
} catch { j.i = j.n + 1; j.r = emptyRect(); }
if (j.i > j.n) {
rec.rects.set(j.name, rectOk(j.r) ? j.r : null);
rec.job = null;
finished++;
}
if (posed && nowMs() >= end) break;
}
return finished;
}
/** Clips still waiting to be sampled, the one in progress included (dev / tests). */
export function pendingClipBounds() { return boundsQueue.reduce((s, r) => s + r.todo.length + (r.job ? 1 : 0), 0); }
export class SpineActor {
/**
* @param {any} spineData PIXI.spine skeleton data
@@ -695,6 +852,27 @@ export class SpineActor {
this._clipHidden.clear();
}
/**
* Skeleton-space bounds of the setup pose and of clips `names` ("clip bounds" above; render/units.js _impBox): `{ setup,
* clips: Map<name, rect | null> }` 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).
* @param {string[]} names
*/
clipBounds(names) {
const sp = this.spine;
const data = sp && sp.spineData;
if (!data) return false;
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;
const clips = new Map();
for (const n of names) {
if (!rec.rects.has(n)) return null;
clips.set(n, rec.rects.get(n));
}
return { setup: rec.setup, clips };
}
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
get height() {
const b = this.entry.bounds;