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

+200 -10
View File
@@ -59,7 +59,11 @@
// animated or drawn at all (its clock catches up, ≤ 0.5 s, when it comes back); `opts.lod: 'idle'` (the enemy preview
// pen: idle loops only) animates through the impostor atlas every 3rd frame; under the view's adaptive load level
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
// every unit does.
// every unit does. An impostor's slot is sized from the setup pose and every clip the unit can play (`impostorBox`, GitHub
// #542: a slot sized from the first pose drawn cut 野鬃's idle flag and 浊心斯卡蒂's S3 flight, and what crossed its edge
// was left in the neighbouring slots); its skeleton's clips are measured once, a few frames after the model loads
// (spine.js clipBounds) — until then the unit is drawn directly —, and a clip too large for the box is drawn directly
// while it plays.
//
// Picking is by tile (render/pick.js, user playtest #4 item 1): views carry no hit shapes; `bounds()` is the drawn body's
// screen rect for tooltips and overlays (view.pieceScreenRect). A dragged (lifted) item plate is drawn centred on its
@@ -414,6 +418,86 @@ export const FORMS = Object.freeze({
enemy_1121_lifbos_2: PRISONER_COLOURED,
});
/**
* The clips a unit can play, clip name → true for a one-off clip (GitHub #542: its impostor box is sized from them,
* `impostorBox`): every role of its manifest entry (`anims`, with every skill's set — `skills` — and the Run cycle) and of
* its model forms (`forms` = FORMS[id]: their clip sets and their change, closing and leaving clips). One-off = the deploy
* clip, a skill's, stun's, move's or run's begin and end clips and a form's change, closing and leaving clips; everything
* else loops or is held (idle, attack — its begin and end come with every attack —, a skill's loop and idle, the move and
* run loops, the stun loop, the death clip a knocked-out operator lies in). A name with both kinds of role loops.
* @returns {Map<string, boolean>}
*/
export function playedClips(anims, forms = null) {
const out = new Map();
const add = (name, once) => { if (typeof name === 'string' && name) out.set(name, out.has(name) ? out.get(name) && once : once); };
const clip = (c, endsOnce) => {
if (!c || typeof c !== 'object') return;
add(c.loop, false); add(c.idle, false); add(c.begin, endsOnce); add(c.end, endsOnce);
};
const roles = (r) => {
if (!r || typeof r !== 'object') return;
add(r.idle, false); add(r.deploy, true); add(r.die, false);
clip(r.attack, false); clip(r.skill, true); clip(r.move, true); clip(r.run, true); clip(r.stun, true);
if (r.skills && typeof r.skills === 'object') for (const s of Object.values(r.skills)) clip(s, true);
};
roles(anims);
if (forms && typeof forms === 'object') {
for (const f of Object.values(forms)) {
if (!f || typeof f !== 'object') continue;
add(f.change, true); add(f.end, true); add(f.leave, true);
roles(f.roles);
}
}
return out;
}
/**
* Impostor box limits (GitHub #542, `impostorBox`): a clip enters a unit's box while the box stays within `steady` × the
* area of its setup pose ∪ idle clip (a looping or held clip), `transient` × for a one-off clip; `pad` × the box's longer
* side + `padMin` skeleton units is added on every side for the poses between two samples (spine.js CLIP_SAMPLE_HZ).
* [ASSUMED] the values: on the current roster they keep every looping / held clip of 675 of the 681 skeletons in the box
* (the 6 others hold a screen-wide skill or death effect) and leave out 319 of the 681 deploy clips (mostly the drop from
* above, up to 1700 units tall); the boxes add up to 1.86 × the area of boxes sized from the idle's first frame (the
* median skeleton's 1.39 ×).
*/
export const IMP_BOX = Object.freeze({ steady: 4, transient: 2, pad: 0.03, padMin: 8 });
/**
* A unit's impostor box (GitHub #542) in skeleton space, from rects [x0, y0, x1, y1] (y down; null = nothing drawn): the
* setup pose and the idle clip always (the core — never a box smaller than the setup pose); then each other clip the unit
* plays (`clips`: [name, rect, once], `playedClips`) in the order of the area it adds, taken while the box stays within
* IMP_BOX.steady × the core's area (IMP_BOX.transient × for a 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.
* Without a setup pose or 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(setup, idle, clips = []) {
const ok = (r) => Array.isArray(r) && r.length === 4 && r.every(Number.isFinite) && r[0] <= r[2] && r[1] <= r[3];
const join = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
const area = (r) => Math.max(1, r[2] - r[0]) * Math.max(1, r[3] - r[1]);
let core = null;
for (const r of [setup, idle]) if (ok(r)) core = core ? join(core, r) : r.slice();
if (!core) for (const [, r, once] of clips) if (!once && ok(r)) core = core ? join(core, r) : r.slice();
if (!core) return null;
const limit = area(core);
const order = clips.filter(([, r]) => ok(r)).map(([name, r, once]) => ({ name, r, once, grow: area(join(core, r)) }))
.sort((a, b) => a.grow - b.grow || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
let box = core;
const left = [];
for (const c of order) {
const u = join(box, c.r);
if (area(u) <= (c.once ? IMP_BOX.transient : IMP_BOX.steady) * limit) box = u;
else left.push(c);
}
const over = new Set();
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 };
}
/** No clip is drawn past the box (a pen figure; the legacy box of a runtime that cannot be sampled). Never added to. */
const NO_CLIPS = new Set();
/**
* Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at
* height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change.
@@ -695,6 +779,9 @@ export class UnitView {
this.spineReady = true;
this.swapT = swap ? 1 : 0;
this.actor.spine.alpha = swap ? 1 : 0;
// measure the clips it can play now (sampled over the next frames, render/app.js stepClipBounds): its impostor box
// is ready before a crowd needs it (_impBox)
try { this.actor.clipBounds([...this._clipNames().keys()]); } catch { /* _impBox asks again */ }
// a mode the unit is already in (a model built or rebuilt after the change): its clip set, no change clip — a dead
// one lies in the form it died in (a 替身 knocked out: the sim resets the form at once, but the Front model it lies
// down with facing UP, §22.1, is built a frame later; a model rebuilt while it is down)
@@ -1271,16 +1358,13 @@ export class UnitView {
// a pose that cannot change (a knocked-out operator lying in its held Die pose, a frozen model) is neither re-posed
// nor redrawn — it would draw the same pixels again; the actor's clock still runs
const held = !clipFlip && this.actor.poseHeld();
if (interval > 0 && this.ctx.renderer) {
// (an impostor needs its box: while its model's clips are still being measured the unit is drawn directly)
if (interval > 0 && this.ctx.renderer && this._impBox()) {
if (clipFlip && this.imp) this.imp.dirty = true; // masks on / off: the slot is redrawn either way
this._updateImpostor(sc, flip, tint, animDt, interval, held);
} else {
if (this.imp) this._leaveImpostor();
this.actor.spine.alpha = this.swapT;
this.actor.spine.scale.set(sc * flip, sc * this.modelKY);
if (held) this.actor.clock += animDt;
else this.actor.update(animDt);
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
this._drawDirect(sc, flip, tint, animDt, held);
}
}
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
@@ -1649,9 +1733,58 @@ export class UnitView {
// impostor atlas (render/impostor.js) every `interval` frames (staggered per unit; every frame for clipped
// skeletons, whose stencil masks must stay out of the main pass) and shown as one sprite. Without a free atlas
// slot the unit keeps a private RenderTexture (same visuals, one extra framebuffer switch).
// The slot is sized from the unit's box (GitHub #542, `impostorBox`): its setup pose and every clip it can play, as
// measured once per skeleton (spine.js clipBounds) — no pose it plays is cut, and the atlas clips every slot to itself.
// A clip too large for the box (`over`) is drawn directly while it plays; the slot stays the unit's (_impBypass).
/** The impostor box ({ x0, y0, w, h, over } in skeleton units), null while the model's clips are still measured. */
_impBox() {
if (this._box) return this._box;
const a = this.actor;
const names = this._clipNames();
const b = typeof a.clipBounds === 'function' ? a.clipBounds([...names.keys()]) : false;
if (b === null) return null;
let box = null;
if (b) {
const idle = this._idleClip();
const rest = [];
for (const [name, once] of names) if (name !== idle) rest.push([name, b.clips.get(name) || null, once]);
box = impostorBox(b.setup, idle ? b.clips.get(idle) || null : null, rest);
}
this._box = box || this._legacyBox();
return this._box;
}
/** The idle clip of the model's own roles (the impostor box's core with the setup pose), else null. */
_idleClip() {
const a = this.actor, idle = a.entry && a.entry.anims ? a.entry.anims.idle : null;
return a.has(idle) ? idle : a.has('Idle') ? 'Idle' : null;
}
/**
* The clips this view's model can play that its skeleton has (`playedClips` of its entry and FORMS, the actor's
* fallbacks 'Idle' / 'Die' included), name → one-off; a pen figure (lod idle) plays its idle clip only.
*/
_clipNames() {
const a = this.actor;
if (this._clipsOf === a) return this._clips;
const anims = (a.entry && a.entry.anims) || {};
const idle = this._idleClip();
const out = new Map();
if (idle) out.set(idle, false);
if (!this.lodIdle) {
for (const [name, once] of playedClips(anims, FORMS[this.info.spine || this.info.defId] || null)) {
if (a.has(name)) out.set(name, out.has(name) ? out.get(name) && once : once);
}
if (!anims.die && a.has('Die')) out.set('Die', false);
}
this._clipsOf = a;
this._clips = out;
return out;
}
/** The box sized from the pose drawn now, for a runtime whose clips cannot be measured (the pre-#542 rule). */
_legacyBox() {
let b = null;
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
let x0 = -220, y0 = -420, x1 = 220, y1 = 40;
@@ -1660,8 +1793,52 @@ 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);
}
this._box = { x0, y0, w: x1 - x0, h: y1 - y0 };
return this._box;
return { x0, y0, w: x1 - x0, h: y1 - y0, over: NO_CLIPS };
}
/** Is a clip outside the box (`over`) on screen — the track's clip or one it is still mixing out of? */
_impOver() {
const over = this._box && this._box.over;
if (!over || !over.size) return false;
const tracks = this.actor.spine && this.actor.spine.state ? this.actor.spine.state.tracks : null;
for (let e = tracks && tracks[0], d = 0; e && d < 4; e = e.mixingFrom, d++) if (e.animation && over.has(e.animation.name)) return true;
return false;
}
/**
* Draw the skeleton directly while a clip too large for the box plays (`on`), back to the impostor after it: the
* skeleton leaves the atlas' parking lot for the body and the impostor sprite hides; the slot stays allocated, and is
* redrawn on the way back.
*/
_impBypass(on) {
const imp = this.imp, sp = this.actor.spine, atlas = this.ctx.impostors || null;
if (!imp || !!imp.bypass === on) return;
imp.bypass = on;
if (on) {
if (atlas) atlas.unpark(sp);
sp.visible = true;
sp.position.set(0, 0);
this.body.addChild(sp);
imp.sprite.visible = false;
} else {
if (sp.parent === this.body) this.body.removeChild(sp);
if (atlas) atlas.park(sp);
imp.sprite.visible = true;
imp.dirty = true;
imp.acc = 0;
}
}
/** Draw the skeleton itself this frame (no impostor, or bypassing it): posed by `animDt` unless `held` / `posed`. */
_drawDirect(sc, flip, tint, animDt, held, posed = false) {
const sp = this.actor.spine;
sp.alpha = this.swapT;
sp.scale.set(sc * flip, sc * this.modelKY);
if (!posed) {
if (held) this.actor.clock += animDt;
else this.actor.update(animDt);
}
if (this._tint !== tint) { this._tint = tint; sp.tint = tint; }
}
_updateImpostor(sc, flip, tint, animDt, interval, held = false) {
@@ -1672,9 +1849,16 @@ export class UnitView {
if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine);
if (atlas) atlas.park(this.actor.spine);
this.body.addChild(sprite);
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 };
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0, bypass: false };
}
const imp = this.imp;
// a clip too large for the box plays: drawn directly until it is over (then redrawn into the slot)
if (this._impOver()) {
this._impBypass(true);
this._drawDirect(sc, flip, tint, animDt, held);
return;
}
if (imp.bypass) this._impBypass(false);
imp.acc += animDt;
const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0;
// the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval;
@@ -1691,6 +1875,12 @@ export class UnitView {
imp.acc = 0;
imp.dirty = false;
imp.last = frame;
// the update reached a clip too large for the box (a queued clip began): this frame already draws it directly
if (this._impOver()) {
this._impBypass(true);
this._drawDirect(sc, flip, tint, 0, held, true);
return;
}
this._renderImpostor(sc, atlas);
}
const k = imp.sc > 0 ? sc / imp.sc : 1;