0.2.6-dev: impostor boxes without the setup-pose floor (#542) — a model frozen before its first update (built while stunned without a stun clip, or dead without a death or idle clip) shows its clip's first frame instead of its setup pose, the box's core is the idle clip (looping clips up to 5× it, one-off clips 2×), and PIXI gets a vertex buffer per batch flush (the per-slot scissor's flushes stalled Apple GPUs: 42–58 → 60 fps for 120 units at 1920 × 1080); golden: unchanged
This commit is contained in:
1 parent
d7cb8770da
commit
c973c1a1ad
6 files changed
+216
-96
No files matched your search
@@ -192,6 +192,12 @@ export async function createFieldView(host, options = {}) {
|
||||
let view = null;
|
||||
let onAbort;
|
||||
try {
|
||||
// a vertex buffer of its own for every batch flush of a frame (PIXI's own setting on iOS) instead of uploading the one
|
||||
// shared buffer again: the impostor atlas flushes once per redrawn slot (render/impostor.js: each slot is clipped by its
|
||||
// own scissor rect, GitHub #542), and with those flushes the re-uploads stalled Apple GPUs — on an Apple M4 (Chrome,
|
||||
// ANGLE Metal) a 120-unit crowd at 1920 × 1080 ran at 42–58 fps instead of 60 (DPR 1 and 2, by the machine's load);
|
||||
// a buffer per flush costs ≈ 0.3 ms of render CPU there
|
||||
if (P.BatchRenderer && 'canUploadSameBuffer' in P.BatchRenderer) P.BatchRenderer.canUploadSameBuffer = false;
|
||||
const app = new P.Application({
|
||||
// MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill
|
||||
// transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself)
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
// 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. 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).
|
||||
// skeleton is its own draw call or calls; skeletons sharing an atlas texture no longer batch across slots), each with a
|
||||
// vertex buffer of its own (render/app.js turns PIXI's BatchRenderer.canUploadSameBuffer off — PIXI's own setting on iOS:
|
||||
// uploading the batch's one shared buffer again for every flush stalled Apple GPUs once the slots were clipped).
|
||||
//
|
||||
// const imp = new ImpostorAtlas(renderer)
|
||||
// const slot = imp.alloc(w, h) // CSS px; null when full
|
||||
|
||||
+25
-16
@@ -30,11 +30,14 @@
|
||||
// (no attack cuts the change clip short); `end` = { clip, in, roles? }: a
|
||||
// closing clip timed to end `in` s from now (a 重生's last clip ends with the
|
||||
// 重生), landing in `roles`
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything); a
|
||||
// model frozen before it was ever posed (built while stunned without a stun
|
||||
// clip, or dead without a death or idle clip) shows its clip's first frame,
|
||||
// never its setup pose (GitHub #542: the impostor box covers the clips only)
|
||||
// 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)
|
||||
// clipBounds(names) skeleton-space bounds of those clips (sampled once per skeleton data, see
|
||||
// "clip bounds" below; render/units.js sizes the impostor box from them)
|
||||
// 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.
|
||||
@@ -113,8 +116,9 @@ function slotCentre(slot, att, buf, out) {
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
// What a skeleton can draw, in skeleton space (pixi-spine's y-down world; rects are [x0, y0, x1, y1]): each clip, sampled
|
||||
// once per skeleton data and shared by every actor built from it (the setup pose is never shown: SpineActor.update poses
|
||||
// even a model frozen before its first update at its clip's first frame). 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
|
||||
@@ -130,7 +134,7 @@ 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 boundsOf = new WeakMap(); // skeleton data → { data, Skeleton, skel, 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];
|
||||
@@ -177,17 +181,15 @@ function scratchSkeleton(rec) {
|
||||
return skel;
|
||||
}
|
||||
|
||||
/** The bounds record of skeleton data `data` (its setup pose measured at once); `failed` when it cannot be posed here. */
|
||||
/** The bounds record of skeleton data `data`; `failed` when this runtime cannot be posed here (tried at once). */
|
||||
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) } };
|
||||
rec = { data, Skeleton, skel: 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;
|
||||
@@ -211,7 +213,7 @@ function startClip(rec, name) {
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
* nor queued yet — and return its record (`rects`, `failed`).
|
||||
*/
|
||||
export function requestClipBounds(data, Skeleton, names) {
|
||||
const rec = boundsRecord(data, Skeleton);
|
||||
@@ -319,6 +321,7 @@ export class SpineActor {
|
||||
this.clipPerAttack = false;
|
||||
this.wound = false; // wound up for the coming attack (windUp → attack)
|
||||
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
|
||||
this._posed = false; // the skeleton was ever posed (a model frozen before that shows its clip's first frame)
|
||||
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
|
||||
this._play(this._idleName(), true);
|
||||
}
|
||||
@@ -428,6 +431,7 @@ export class SpineActor {
|
||||
}
|
||||
// Apply the new attachments even when normal updates are frozen.
|
||||
this.spine.update(0);
|
||||
this._posed = true;
|
||||
}
|
||||
|
||||
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
|
||||
@@ -795,8 +799,13 @@ export class SpineActor {
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
if (!this.frozen) {
|
||||
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
|
||||
// frozen (a stun without a stun clip, a death without a death or idle clip): the pose holds — but a model frozen before
|
||||
// it was ever posed (built while its unit is stunned or dead) is posed once at the current clip's first frame, never
|
||||
// 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;
|
||||
}
|
||||
if (this.clipped && !this.clipOn) this._eyeMaskFallback();
|
||||
this._applied = true;
|
||||
@@ -853,8 +862,8 @@ export class SpineActor {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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).
|
||||
* @param {string[]} names
|
||||
*/
|
||||
@@ -870,7 +879,7 @@ export class SpineActor {
|
||||
if (!rec.rects.has(n)) return null;
|
||||
clips.set(n, rec.rects.get(n));
|
||||
}
|
||||
return { setup: rec.setup, clips };
|
||||
return clips;
|
||||
}
|
||||
|
||||
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
|
||||
|
||||
+39
-33
@@ -59,11 +59,10 @@
|
||||
// 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. 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.
|
||||
// every unit does. An impostor's slot is sized from 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
|
||||
@@ -453,31 +452,32 @@ export function playedClips(anims, forms = null) {
|
||||
|
||||
/**
|
||||
* 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 ×).
|
||||
* area of its 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 668 of the 681 skeletons in the box
|
||||
* (the 13 others hold a screen-wide skill, attack or death effect: 帕拉斯, 诗怀雅's attack …; at 4 × the attack or skill
|
||||
* loops of 39 would be drawn directly whenever they play) and leave out 355 of the 681 deploy clips (mostly the drop from
|
||||
* above, up to 1700 units tall); the boxes add up to 1.26 × the area of boxes sized from the idle's first frame (the
|
||||
* median skeleton's 1.13 ×).
|
||||
*/
|
||||
export const IMP_BOX = Object.freeze({ steady: 4, transient: 2, pad: 0.03, padMin: 8 });
|
||||
export const IMP_BOX = Object.freeze({ steady: 5, 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.
|
||||
* A unit's impostor box (GitHub #542) in skeleton space, from rects [x0, y0, x1, y1] (y down; null = nothing drawn): its
|
||||
* idle clip always (the core); 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. 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.
|
||||
* @returns {{ x0: number, y0: number, w: number, h: number, over: Set<string> } | null}
|
||||
*/
|
||||
export function impostorBox(setup, idle, clips = []) {
|
||||
export function impostorBox(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();
|
||||
let core = ok(idle) ? idle.slice() : null;
|
||||
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);
|
||||
@@ -1733,37 +1733,43 @@ 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 slot is sized from the unit's box (GitHub #542, `impostorBox`): 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;
|
||||
const rects = typeof a.clipBounds === 'function' ? a.clipBounds([...names.keys()]) : false;
|
||||
if (rects === null) return null;
|
||||
let box = null;
|
||||
if (b) {
|
||||
if (rects) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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. */
|
||||
/**
|
||||
* The idle clip of the model's own roles (the impostor box's core), as SpineActor picks it: the role's, else 'Idle',
|
||||
* else the skeleton's first clip; null for a skeleton without clips.
|
||||
*/
|
||||
_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;
|
||||
if (a.has(idle)) return idle;
|
||||
if (a.has('Idle')) return 'Idle';
|
||||
const first = a.names && a.names.values().next().value;
|
||||
return typeof first === 'string' ? first : 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.
|
||||
* fallbacks — its idle clip, 'Die' — included), name → one-off; a pen figure (lod idle) plays its idle clip only.
|
||||
*/
|
||||
_clipNames() {
|
||||
const a = this.actor;
|
||||
|
||||
Reference in new issue
Block a user