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:
1 parent
e825254850
commit
c45d86f70c
8 files changed
+1153
-22
No files matched your search
@@ -103,7 +103,8 @@
|
||||
// Assault prep = the boss field).
|
||||
// Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates.
|
||||
// Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per
|
||||
// frame before the main pass.
|
||||
// frame before the main pass; each frame also measures queued Spine clips within a small time budget
|
||||
// (spine.js stepClipBounds: the impostor boxes, GitHub #542).
|
||||
// canPlace: `(uid, target, piece) => bool` (the game UI's form: target = {area:'board',row,col}|{area:'hand',idx})
|
||||
// or `(piece, row, col, target) => bool` (functions declaring ≥ 3 parameters). Throwing ⇒ illegal.
|
||||
// Piece shape (DESIGN §8.3): { uid, kind: 'chess'|'item'|'token', id, golden, tier, items: [{uid,id}], count, ownerUid }.
|
||||
@@ -125,6 +126,7 @@ import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, s
|
||||
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
|
||||
import { loadBoardArt } from './boardArt.js';
|
||||
import { ImpostorAtlas } from './impostor.js';
|
||||
import { stepClipBounds } from './spine.js';
|
||||
import { loadThree, loadBoardPack, loadScenePack, webgl2Available, boardArtListed } from './board3d/load.js';
|
||||
import { BoardScene } from './board3d/scene.js';
|
||||
import { unionAreas } from './board3d/layout.js';
|
||||
@@ -1744,6 +1746,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (leader && !leaderHidden) leader.view.update(dt, cam, clock);
|
||||
tiles.update(dt);
|
||||
fx.update(dt);
|
||||
stepClipBounds();
|
||||
impostors.flush();
|
||||
} catch (err) {
|
||||
if (!frame.warned) { frame.warned = true; console.error('[render] frame failed', err); }
|
||||
|
||||
@@ -6,14 +6,22 @@
|
||||
// load/store on tiled GPUs. Instead of one RenderTexture per unit (one framebuffer switch each), every due
|
||||
// impostor of a frame is drawn into slots of a few shared atlas pages in ONE render call per page, before the
|
||||
// main pass starts:
|
||||
// page = RenderTexture (2048² physical px) + a batch container [erasers…, skeletons…]
|
||||
// slot = shelf-packed rect (16 px grid) owned by one unit; erased (ERASE blend quad) then redrawn when due
|
||||
// page = RenderTexture (2048² physical px) + a batch container [erasers…, slot boxes…]
|
||||
// slot = shelf-packed rect (16 px grid) owned by one unit; erased (ERASE blend quad over exactly the slot) then
|
||||
// redrawn when due. The skeleton is drawn inside the slot's own box — a container masked by the slot
|
||||
// rectangle, which PIXI turns into a scissor rect (an axis-aligned rectangle mask: no stencil, no render-pass
|
||||
// break) — so nothing a unit draws can land outside its slot: a pose wider than the unit's box (render/units.js
|
||||
// _impBox) is cut at the slot's edge instead of painting into the neighbouring slots or the free space
|
||||
// around them, where no eraser ever cleared it again (GitHub #542: 野鬃's flag, 浊心斯卡蒂's S3 flight).
|
||||
// Every redraw therefore starts from an empty slot: its eraser clears all the slot can hold.
|
||||
// Pages are created on demand (≤ MAX_PAGES); when every page is full `alloc` returns null and the unit renders
|
||||
// directly (still correct, just not batched). A shelf whose last slot is freed is emptied for reuse, and empty shelves
|
||||
// at the top of a page are given back (a page whose slots are all freed — every battle / prep rebuild — starts over):
|
||||
// without that, zooming a crowd in and out (the enemy pen camera) left pages full of shelves of stale sizes and the
|
||||
// next battle's units fell back to one private render target each. Clipped skeletons get slots on small separate "clip" pages: their
|
||||
// stencil clears break the page's render pass, and the cost of a break scales with the page size.
|
||||
// stencil clears break the page's render pass, and the cost of a break scales with the page size. The scissor of the
|
||||
// slot box also bounds those stencil clears to the slot. Cost of the clipping: one batch flush per drawn slot (each
|
||||
// skeleton is its own draw call or calls; skeletons sharing an atlas texture no longer batch across slots).
|
||||
//
|
||||
// const imp = new ImpostorAtlas(renderer)
|
||||
// const slot = imp.alloc(w, h) // CSS px; null when full
|
||||
@@ -49,9 +57,9 @@ export class ImpostorAtlas {
|
||||
try { rt.framebuffer.enableStencil(); } catch { /* masks fall back to scissor */ }
|
||||
const batch = new P.Container();
|
||||
const erasers = new P.Container();
|
||||
const bodies = new P.Container();
|
||||
batch.addChild(erasers, bodies);
|
||||
const page = { kind, size, rt, batch, erasers, bodies, shelves: [], nextY: 0, queued: 0, cleared: false, index: this.pages.length };
|
||||
const boxes = new P.Container();
|
||||
batch.addChild(erasers, boxes);
|
||||
const page = { kind, size, rt, batch, erasers, boxes, shelves: [], nextY: 0, queued: 0, cleared: false, index: this.pages.length };
|
||||
this.pages.push(page);
|
||||
this.stats.pages = this.pages.length;
|
||||
return page;
|
||||
@@ -97,9 +105,25 @@ export class ImpostorAtlas {
|
||||
eraser.width = w; eraser.height = h;
|
||||
eraser.visible = false;
|
||||
page.erasers.addChild(eraser);
|
||||
// the slot's own pass: its skeleton is drawn in `box`, clipped to the slot rectangle (a scissor rect)
|
||||
const mask = new P.Graphics();
|
||||
mask.beginFill(0xffffff, 1);
|
||||
mask.drawRect(x, y, w, h);
|
||||
mask.endFill();
|
||||
const box = new P.Container();
|
||||
box.addChild(mask);
|
||||
box.mask = mask;
|
||||
box.visible = false;
|
||||
page.boxes.addChild(box);
|
||||
const tex = new P.Texture(page.rt.baseTexture, new P.Rectangle(x, y, w, h));
|
||||
sh.live = (sh.live || 0) + 1;
|
||||
return { page, shelf: sh, clip: page.kind === 'clip', x, y, w, h, eraser, tex, freed: false };
|
||||
return { page, shelf: sh, clip: page.kind === 'clip', x, y, w, h, eraser, box, mask, tex, freed: false };
|
||||
}
|
||||
|
||||
/** Back to the parking lot: every skeleton queued in a slot's box (never destroyed here — the actors own them). */
|
||||
_unqueue(slot) {
|
||||
for (const b of [...slot.box.children]) if (b !== slot.mask) this.park(b);
|
||||
slot.box.visible = false;
|
||||
}
|
||||
|
||||
free(slot) {
|
||||
@@ -107,6 +131,11 @@ export class ImpostorAtlas {
|
||||
slot.freed = true;
|
||||
this.stats.slots = Math.max(0, this.stats.slots - 1);
|
||||
try { slot.eraser.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
this._unqueue(slot); // a skeleton queued this frame (its unit left impostor mode) is not destroyed with the box
|
||||
slot.box.mask = null;
|
||||
slot.box.destroy({ children: true });
|
||||
} catch { /* ignore */ }
|
||||
try { slot.tex.destroy(false); } catch { /* ignore */ }
|
||||
const page = slot.page;
|
||||
const sh = slot.shelf && page.shelves.includes(slot.shelf) ? slot.shelf : page.shelves.find((s) => s.y === slot.y);
|
||||
@@ -135,7 +164,8 @@ export class ImpostorAtlas {
|
||||
obj.position.set(slot.x + m.tx, slot.y + m.ty);
|
||||
obj.scale.set(m.a, m.d);
|
||||
obj.visible = true;
|
||||
if (obj.parent !== page.bodies) page.bodies.addChild(obj);
|
||||
if (obj.parent !== slot.box) slot.box.addChild(obj);
|
||||
slot.box.visible = true;
|
||||
page.queued++;
|
||||
}
|
||||
|
||||
@@ -152,14 +182,19 @@ export class ImpostorAtlas {
|
||||
page.queued = 0;
|
||||
for (const e of page.erasers.children) e.visible = false;
|
||||
// back to the parking lot until due again
|
||||
for (const b of [...page.bodies.children]) this.park(b);
|
||||
for (const box of page.boxes.children) {
|
||||
if (!box.visible) continue;
|
||||
box.visible = false;
|
||||
for (const b of [...box.children]) if (b !== box.mask) this.park(b);
|
||||
}
|
||||
}
|
||||
this.stats.drawn = drawn;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const page of this.pages) {
|
||||
try { page.batch.destroy({ children: false }); } catch { /* ignore */ }
|
||||
for (const box of page.boxes.children) for (const b of [...box.children]) if (b !== box.mask) box.removeChild(b);
|
||||
try { page.batch.destroy({ children: true }); } catch { /* ignore */ }
|
||||
try { page.rt.destroy(true); } catch { /* ignore */ }
|
||||
}
|
||||
this.pages = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
+200
-10
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user