0.2.6-dev: merge claude/r37-impostor — GitHub #542: impostor boxes from each skeleton's measured clip bounds (idle core, played clips within limits, the rest drawn directly while they play), every slot drawn inside its own scissor, frozen models posed at their clip's first frame, PIXI's pooled batch buffers (no Apple GPU stall from the per-slot flushes); golden: unchanged
# Conflicts: # CHANGELOG.md # docs/design/client.md # docs/history/0.2.6.md
This commit is contained in:
commit
a1f0d1bb6e
12 files changed
+1469
-27
No files matched your search
@@ -9,6 +9,7 @@
|
||||
// 'lock' it plays 蕾缪安's S3 as the sim does (a lock every 0.5 s, then one shell every 0.3 s with its bombard)
|
||||
// Query: ?scene=prep|<recording>|stress|numbers &stage=<stageId> &t=<seconds> &speed=<x> &paused=1 &panel=0 &quality=high|medium|low
|
||||
// &pen=<recording> (prep: that recording's round preview — m.private.nextEnemies — in the enemy preview pen)
|
||||
// &ops=<chessId>,… (stress: up to 6 given chess in the crowd's front row, ids 6, 8, 10, …)
|
||||
// &fa=L|R (prep on that half of the Final Assault boss field, view.setCamera('bossPrep', { side }))
|
||||
// &board=2d|3d (the official 3D board scene, DESIGN §15; default 3D when the local art + WebGL2 are available)
|
||||
// &cam=prep|normal|unite|boss|pen|bossPrep [&side=L|R &half=1 &shop=0] (official configBlackBoard framings)
|
||||
@@ -366,12 +367,16 @@ function stressScene(view, stageId) {
|
||||
const rect = { r0: 9, r1: 12, c0: 0, c1: 20 };
|
||||
const chess = data.list('chess').filter((c) => c.visible && !c.isGolden);
|
||||
const enemies = data.list('enemies').filter((e) => assets.spineEntry(e.key) && e.rank !== 'BOSS');
|
||||
// &ops=<chessId>,… : these chess take every other place of the front row from col 7 (ids 6, 8, 10, …) — given models
|
||||
// inside a crowded, impostor-rendered field, clear of the screen edge (GitHub #542: impostor-bounds.browser.test.js)
|
||||
const ops = (q.get('ops') || '').split(',').map((s) => data.lookup('chess', s.trim())).filter(Boolean);
|
||||
const opAt = (i) => (i >= 5 && i % 2 === 1 && i < 17 ? ops[(i - 5) / 2] : null);
|
||||
let seed = 7;
|
||||
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
|
||||
const units = [];
|
||||
let id = 1;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const c = chess[(i * 7) % chess.length];
|
||||
const c = opAt(i) || chess[(i * 7) % chess.length];
|
||||
units.push({ id: id++, kind: 'op', side: 'ally', defId: c.chessId, name: c.name, tier: c.tier, golden: i % 6 === 0, spine: c.assets?.spine, avatar: c.assets?.avatar, x: 2 + (i % 17), y: 9 + ((i / 17) | 0) % 4, facing: 1, maxHp: 3000 });
|
||||
}
|
||||
for (let i = 0; i < 80; i++) {
|
||||
|
||||
+10
-1
@@ -112,7 +112,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 }.
|
||||
@@ -134,6 +135,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';
|
||||
@@ -199,6 +201,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)
|
||||
@@ -1820,6 +1828,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,24 @@
|
||||
// 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), 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
|
||||
@@ -49,9 +59,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 +107,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 +133,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 +166,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 +184,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 = [];
|
||||
|
||||
+222
-3
@@ -30,9 +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 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.
|
||||
@@ -110,6 +115,183 @@ 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]): 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
|
||||
// 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). 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|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];
|
||||
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`; `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, rects: new Map(), todo: [], job: null, users: 0, failed: false, buf: { v: new Float32Array(256) } };
|
||||
try {
|
||||
const skel = scratchSkeleton(rec);
|
||||
skel.setToSetupPose();
|
||||
skel.updateWorldTransform();
|
||||
} 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 (`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);
|
||||
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.
|
||||
* @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; }
|
||||
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 {
|
||||
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.broken = true; }
|
||||
if (j.i > j.n) {
|
||||
// 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++;
|
||||
}
|
||||
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
|
||||
@@ -162,6 +344,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);
|
||||
}
|
||||
@@ -271,6 +454,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'). */
|
||||
@@ -638,8 +822,15 @@ 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);
|
||||
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;
|
||||
@@ -695,6 +886,33 @@ export class SpineActor {
|
||||
this._clipHidden.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton-space bounds of clips `names` ("clip bounds" above; render/units.js _impBox): a Map name → rect (null: draws
|
||||
* 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) {
|
||||
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;
|
||||
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;
|
||||
clips.set(n, rec.rects.get(n));
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
|
||||
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
|
||||
get height() {
|
||||
const b = this.entry.bounds;
|
||||
@@ -703,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;
|
||||
}
|
||||
|
||||
+216
-10
@@ -68,7 +68,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.
|
||||
// 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
|
||||
@@ -428,6 +431,88 @@ 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 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: 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): 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). 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 = []) {
|
||||
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 = 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);
|
||||
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(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 };
|
||||
}
|
||||
/** 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.
|
||||
@@ -710,6 +795,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)
|
||||
@@ -1325,16 +1413,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)
|
||||
@@ -1728,9 +1813,73 @@ 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`): 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 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 (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(unmeasured);
|
||||
return this._box;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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 — its idle clip, '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); `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;
|
||||
@@ -1739,8 +1888,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: 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? */
|
||||
_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) {
|
||||
@@ -1751,9 +1944,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;
|
||||
@@ -1770,6 +1970,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