diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a21e9b..46d8f28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 最后一个还没选策略的玩家不再看到可点的「跳过」按钮:已经选完的玩家仍显示在顺序里,但不算可以让过去的人(PR #546,感谢 @Xinglan233)。 - 每回合开始时,自己棋盘上的干员按部署顺序一个接一个重播入场动画并播放落地音效(维护者按官方实机确认);买到干员、干员上场或换到另一格时也会播放一次,同一次落地只响一声,音量跟随「音效」(PR #416,感谢 @Cromemadnd)。 - 设置新增「弹药数量」,是可选项、默认关闭(官方界面没有这个数字):打开后弹药类技能的黄色弹药条下方显示剩余弹药 / 总弹药数(如 13/14),格子密到画不出分隔线时也能读出(PR #552,感谢 @IceCodeNew)。 +- 场上单位多时(画面质量「高」下通常在第 7 回合以后)模型改用缓存画面绘制,此前野鬃待机时的旗帜、浊心斯卡蒂三技能飞起时的头部会被裁掉,超出的部分还会画进相邻单位的缓存,在隐现的枪口等处留下错位的残影。现在每个模型按它会播放的全部动作预留缓存大小,每个单位只画在自己的区域内;少数特别大的动作(多为部署时从天而降)播放期间直接绘制;单位在眩晕中才加载完成的模型也不再显示初始姿势(#542,感谢 @ImPQlc)。 ### 工具与开发 - Windows 下场景素材测试的仓库路径不再重复盘符(PR #547,感谢 @Xinglan233)。 diff --git a/docs/design/client.md b/docs/design/client.md index 45f9f0c8..9b1fa611 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -44,7 +44,7 @@ view.resize() ; view.destroy() Asset URLs (0.2.6, host bandwidth layer 1): the client takes every art, model and audio URL from `data/assets.json` and `data/local-assets.json` and builds none itself, so the content-hash segment the server puts in front (`/assets/~/…`, docs/ASSETS.md "The committed manifest and the served manifests") needs nothing on this side: `validSpine` still sees `.skel`, pixi-spine finds the atlas and its pages under the same segment (a model shares one hash), audio is fetched as `/media/~/…`. The two URLs the client derives from another file's — the 3D board's `tiles.json` beside the atlas and the token death sound beside `charDie` — are served `no-cache` (the board) or drop the segment (`shared/assetPaths.js unhashedUrl`, the sound). -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`; with 设置「弹药数量」 on, its count `left/magazine` under it, §31.9), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`; with 设置「弹药数量」 on, its count `left/magazine` under it, §31.9), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; an impostor's slot is sized from every clip the unit can play — measured once per skeleton, a few frames after the model loads (`render/spine.js clipBounds`, sampled within a 2.5 ms frame budget; until then the unit is drawn directly); no model shows its setup pose (one frozen before its first update — built while stunned without a stun clip — shows its clip's first frame) —, a clip too large for that box (`render/units.js impostorBox`: mostly a deploy drop from above) is drawn directly while it plays, and the atlas draws every slot clipped to its own rectangle (a scissor rect; every batch flush gets its own vertex buffer, which Apple GPUs need), so no pose is cut and nothing is left in another unit's slot (GitHub #542, §31.6); the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- diff --git a/docs/history/0.2.6.md b/docs/history/0.2.6.md index 12c977d9..2308a216 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -105,6 +105,79 @@ counts order entries absent from `draft.picks`, alongside the existing turn and `skipBand` already rejects the final player with `BAD_TARGET / nobody to pass to`; its rule remains unchanged. Regression covers normal two-, three- and four-human browser flows, the final turn, and exhausted quota. +### 31.6 Impostor bounds and slot clearing (#542) + +`render/spine.js clipBounds / stepClipBounds / SpineActor.update`, `render/units.js impostorBox / _impBox / _impBypass`, +`render/impostor.js`, `render/app.js`. + +- **Seen** (GitHub #542 by @ImPQlc; quality 高, usually from round 7 on): 野鬃's flag cut, offset and leaving stray pixels + while she idles; 浊心斯卡蒂's head cut in her S3 flight (Skill_3_Begin); 隐现's pistol muzzle cut, offset and leaving stray + pixels during her skill. +- **Cause** (reproduced in headless Chrome: the dev demo's stress crowd, 120 units at impostor interval 4, the three + held in their widest poses): a unit's impostor slot was sized once from the pose it showed when it first became an + impostor, plus a margin (`_impBox`: the pose's bounds + 30 % / 22 %). A later pose past that box was drawn past the + slot's edge: cut in the unit's own sprite and painted into whatever lay beside the slot on the atlas page. A page is + cleared on its first flush only and each eraser covers its own slot, so a spill into free space stayed, and a spill + into another unit's slot showed on that unit until it was redrawn in a frame without the spill. 浊心斯卡蒂's flight reaches + y −704 (skeleton units) against a box top near −516 (54–57 px past her slot in 5 of 5 runs); 野鬃's idle flag reaches x + −434, 103 units past a box sized from her setup pose (0–47 px past her slot over 5 runs: it depended on that first + pose); after one clean redraw of every impostor the atlas pages held 0–1,405 pixels outside every live slot. 隐现's own + poses fit her box (the silencer reaches x 197, her box 225): her muzzle sits at her slot's edge, where a neighbour's + spill lands. Of the roster's 681 skeletons, 598 play a clip that leaves a box sized that way from their idle's first + frame. No clipping attachment is involved (`spine.js _eyeMaskFallback`, #177, unchanged). +- **Now**: (1) every clip a unit can play (its manifest roles with every skill's set and the Run cycle, its `FORMS` clip + sets and change clips) is measured once per skeleton data on a scratch skeleton: 60 poses a second, at most 49 per + clip; a region by its attachment rectangle, a mesh by its vertices; slots drawn at alpha 0 skipped. It is queued when + the model loads and sampled within 2.5 ms per frame (`stepClipBounds`, called by `render/app.js`), ≈ 9 ms per skeleton + on the roster (53 ms at most), shared by every unit of that skeleton; a unit is drawn directly until its box is ready. + A skeleton stays queued only while an actor uses it: when the last one is destroyed (a model swap, a battlefield torn + down) its pending clips and its scratch skeleton are dropped, so nothing holds a skeleton the Spine store let go or is + measured ahead of the next scene's models, and a later actor queues again what was never measured. (2) The box is the + idle clip, then each other clip in the order of the area it adds while the box stays within 5 × the idle clip's area + (a looping or held clip) or 2 × (a one-off clip: the deploy clip, a skill's, stun's or move's begin and end, a form's + change), padded by 3 % + 8 units. A clip that does not fit (`over`: 355 of the roster's 681 deploy clips — the drop + from above, up to 1,700 units tall — and the looping or held clips of 13 skeletons with screen-wide effects, among + them 帕拉斯's skills and 诗怀雅's attack) is drawn directly while it plays; the unit keeps its slot (`_impBypass`). So is a + clip whose sampling threw (recorded as not measurable, never as drawing nothing — the scissor would cut it). The box + is computed once per unit: no bounds work per frame. The setup pose needs no room: a model frozen before its first + update — built while its unit is stunned and it has no stun clip (202 of the 210 operators have none), or dead without + a death or idle clip — showed its setup pose, which no clip shows (隐现's Back model stands 900 units right of its feet + in it); `SpineActor.update` now poses it once at its clip's first frame (again on the next frame if that update + threw). (3) The atlas draws each slot's skeleton in a container masked by the slot rectangle, which PIXI draws as a + scissor rect (no stencil, no render-pass break): nothing paints outside a slot, and the slot's eraser clears all that + an earlier pose can have left in it. Each redrawn slot is a batch flush of its own, and PIXI uploaded its one batch + vertex buffer again for every flush, which stalls Apple GPUs (Chrome, ANGLE Metal, Apple M4: the 120-unit crowd at + 1920 × 1080 ran at 42–58 fps instead of 60, by the machine's load), so `render/app.js` turns PIXI's + `BatchRenderer.canUploadSameBuffer` off: a buffer per flush, PIXI's own setting on iOS. Switched back and forth within + one page it gave 60 fps against 42 (DPR 2) and 50 (DPR 1) with the shared buffer — turning it off only around the + atlas pass gave 55 — for ≈ 0.3 ms more render CPU per frame; under load it also steadied the 0.2.5 renderer (51 → 59 + fps at DPR 2). +- **Cost**: slot sizes grow with the boxes. On the roster they add up to 1.26 × the old area (the median skeleton 1.13 + ×). The 120-unit stress crowd at 1600 × 900 (DPR 1) uses 5.5 M px of slots instead of 4.4 M (two of the three atlas + pages, no private render target). At DPR 2 a page holds a quarter of the CSS pixels: the crowd at 1280 × 720 keeps + 98–108 of its units in the atlas instead of 118–120, at 1920 × 1080 59–61 instead of 58–59 (its private render targets + 4.4 M px instead of 2.8 M); at 844 × 390, DPR 3, all 120 stay in two pages. 60 fps in every case, before and after + (runs on a quiet machine). A unit that finds no slot keeps a private render target, as before; the atlas keeps its ≤ 3 + + 2 pages. CPU: `tools/perfbench.mjs` (844 × 390, DPR 2, quality medium / low, where those battles draw through the + atlas; four battles at a 4 × CPU throttle, two interleaved runs before and after): the view's JS 4.69 → 5.04 ms per + frame, the impostor-redraw stage +21 % (+0.12 ms per frame), all busy JS +0.4 %, 60 fps in every run. +- [ASSUMED]: the growth limits (5 × / 2 ×), the 3 % + 8 padding, the sampling rate and the 2.5 ms budget. A pose between + two samples can still pass the padded box for a frame (42 of the 681 skeletons by a denser 60 Hz check, all in long + clips sampled sparser than that); the scissor cuts it and leaves nothing behind. +- Tests: `test/render/impostor-bounds.test.js` (synthetic Spine 3.8 skeletons: clip bounds, an attachment shown for 0.05 + s, an alpha-0 slot, the budgeted queue, a skeleton leaving the queue with its last actor, a clip whose sampling + throws; a model frozen before its first update — stunned without a stun clip, dead without a death or idle clip — + posed once at its clip's first frame, again after an update that threw; `impostorBox` / `playedClips`; every atlas + slot's clip box and eraser; UnitView: drawn directly until measured, built while stunned, a skeleton without an Idle + clip, the over-clip bypass keeping its slot, an unmeasurable attack or idle clip drawn directly, the legacy fallback, + a pen figure), `test/render/impostor-bounds-models.test.js` (the three operators through the real runtime: every pose + of every clip they play, at 30 Hz, inside their box; the old box cut 野鬃 by 103 units and 浊心斯卡蒂 by 188; 隐现's Back model + built while stunned shows its Idle's first frame inside the box, its setup pose lies outside it; skips without the + assets), `test/render/impostor-bounds.browser.test.js` (`RENDER_E2E=1`: the held stress crowd — inside their slots, no + atlas pixel outside a live slot; the scissor on a bare page; 野鬃's deploy drawn directly), + `test/render/boardart.test.js` (the allocator's test doubles). Dev: `render-demo.html?scene=stress&ops=,…`. + Golden: unchanged. + ### 31.7 Official scene light gains (#549) GitHub #549 (@xcdoge, confirmed by other players): the official battlefield scenes of 0.2.5 read much darker than the diff --git a/public/dev/render-demo.js b/public/dev/render-demo.js index 693d950a..4e625df8 100644 --- a/public/dev/render-demo.js +++ b/public/dev/render-demo.js @@ -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||stress|numbers &stage= &t= &speed= &paused=1 &panel=0 &quality=high|medium|low // &pen= (prep: that recording's round preview — m.private.nextEnemies — in the enemy preview pen) +// &ops=,… (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=,… : 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++) { diff --git a/public/js/render/app.js b/public/js/render/app.js index d405b45f..d496a514 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -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); } diff --git a/public/js/render/impostor.js b/public/js/render/impostor.js index 6c9f56ae..9c024ada 100644 --- a/public/js/render/impostor.js +++ b/public/js/render/impostor.js @@ -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 = []; diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 1ef36256..c04e1bda 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -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, 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; } diff --git a/public/js/render/units.js b/public/js/render/units.js index b398d5da..29956416 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -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} + */ +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 } | 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; diff --git a/test/render/boardart.test.js b/test/render/boardart.test.js index a4530dcd..3a032245 100644 --- a/test/render/boardart.test.js +++ b/test/render/boardart.test.js @@ -205,6 +205,7 @@ describe('impostor atlas allocator', () => { globalThis.PIXI = { Container: Obj, Sprite: class extends Obj { constructor(t) { super(); this.texture = t; } }, + Graphics: class extends Obj { beginFill() { return this; } drawRect(x, y, w, h) { this.rect = [x, y, w, h]; return this; } endFill() { return this; } }, Texture: class { constructor(b, f) { this.baseTexture = b; this.frame = f; } destroy() {} static WHITE = {} }, Rectangle: class { constructor(x, y, w, h) { Object.assign(this, { x, y, width: w, height: h }); } }, RenderTexture: { create: (o) => ({ ...o, baseTexture: { id: Math.random() }, framebuffer: { enableStencil() {} }, destroy() {} }) }, @@ -227,7 +228,7 @@ describe('impostor atlas allocator', () => { const body = new Obj(); atlas.park(body); atlas.draw(c, body, { a: 0.3, d: 0.3, tx: 40, ty: 90 }); - assert.equal(body.parent, c.page.bodies); + assert.equal(body.parent, c.box, 'drawn inside its slot\'s box (clipped to the slot, GitHub #542)'); atlas.flush(); assert.deepEqual(rendered, [true]); assert.equal(body.parent, atlas.parked); @@ -254,6 +255,7 @@ describe('impostor atlas allocator', () => { globalThis.PIXI = { Container: Obj, Sprite: class extends Obj { constructor(t) { super(); this.texture = t; } }, + Graphics: class extends Obj { beginFill() { return this; } drawRect() { return this; } endFill() { return this; } }, Texture: class { constructor(b, f) { this.baseTexture = b; this.frame = f; } destroy() {} static WHITE = {} }, Rectangle: class { constructor(x, y, w, h) { Object.assign(this, { x, y, width: w, height: h }); } }, RenderTexture: { create: (o) => ({ ...o, baseTexture: {}, framebuffer: { enableStencil() {} }, destroy() {} }) }, diff --git a/test/render/impostor-bounds-models.test.js b/test/render/impostor-bounds-models.test.js new file mode 100644 index 00000000..3bf68fa4 --- /dev/null +++ b/test/render/impostor-bounds-models.test.js @@ -0,0 +1,180 @@ +// test/render/impostor-bounds-models.test.js — GitHub #542 on the three reported models (野鬃 char_496_wildmn, 浊心斯卡蒂 +// char_1012_skadi2, 隐现 char_498_inside), read by @pixi-spine/runtime-3.8 and played by its real Spine and AnimationState +// inside the real SpineActor (headless, as test/render/eyemask.test.js): every pose a unit of them plays — each clip of +// its roles at 30 Hz, measured with Spine's own vertex math (RegionAttachment.computeWorldVertices with the region offsets +// pixi-spine never computes, MeshAttachment.computeWorldVertices) — lies inside the impostor box UnitView._impBox builds +// from the clip bounds of render/spine.js, except the clips the box leaves to a direct draw (`over`); the old rule (the +// pose shown first + a margin) cut 野鬃's idle flag and 浊心斯卡蒂's S3 flight when that pose was the setup pose. The box +// leaves the setup pose out: a model built while stunned (no stun clip) shows its clip's first frame instead — 隐现's +// Back model, whose setup pose stands 900 units right of its feet. +// The skeletons are not in the repository (public/assets: `npm run assets`): without them these tests skip. +// Run: node --test test/render/impostor-bounds-models.test.js + +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const require = createRequire(path.join(ROOT, 'package.json')); +const ASSETS = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); +const dirOf = (id, side = 'front') => path.join(ROOT, 'public', 'assets', 'spine', 'op', id, side); +const have = (id, side = 'front') => existsSync(path.join(dirOf(id, side), `${id}.skel`)) && existsSync(path.join(dirOf(id, side), `${id}.atlas`)); +const MODELS = ['char_496_wildmn', 'char_1012_skadi2', 'char_498_inside']; + +let spine, units, rt, base, core; +before(async () => { + class HeadlessCanvas { constructor() { this.width = 16; this.height = 16; } getContext(kind) { return kind === '2d' ? { fillStyle: '', fillRect() {}, clearRect() {}, drawImage() {} } : null; } } + globalThis.HTMLCanvasElement ??= HeadlessCanvas; + globalThis.document ??= { createElement: () => new HeadlessCanvas() }; + rt = require('@pixi-spine/runtime-3.8'); + base = require('@pixi-spine/base'); + core = require('@pixi/core'); + globalThis.PIXI = { spine: { Spine: rt.Spine } }; + spine = await import('../../public/js/render/spine.js'); + units = await import('../../public/js/render/units.js'); +}); + +const cache = new Map(); +function skeletonData(id, side = 'front') { + const key = `${id}:${side}`; + if (!cache.has(key)) { + const dir = dirOf(id, side); + const atlas = new base.TextureAtlas(readFileSync(path.join(dir, `${id}.atlas`), 'utf8'), (line, cb) => cb(new core.BaseTexture(null, { width: 1024, height: 1024 }))); + cache.set(key, new rt.SkeletonBinary(new rt.AtlasAttachmentLoader(atlas)).readSkeletonData(new Uint8Array(readFileSync(path.join(dir, `${id}.skel`))))); + } + return cache.get(key); +} +const measured = new Set(); +/** + * A real actor of `id` and the impostor box its battle view builds (UnitView._impBox on a stand-in view): the first actor + * of a skeleton waits for its clips to be sampled (stepClipBounds, once per frame); later ones share the result. + */ +function unit(id, side = 'front') { + const a = new spine.SpineActor(skeletonData(id, side), ASSETS.chars[id].spine[side]); + const view = Object.assign(Object.create(units.UnitView.prototype), { actor: a, lodIdle: false, info: { spine: id, defId: id } }); + const t0 = performance.now(); + let box = view._impBox(); + if (!measured.has(`${id}:${side}`)) assert.equal(box, null, 'measured over the next frames, not at once'); + measured.add(`${id}:${side}`); + let frames = 0; + while (!box) { spine.stepClipBounds(); frames++; box = view._impBox(); } + return { a, view, box, frames, ms: performance.now() - t0 }; +} +const V = new Float32Array(1 << 14); +/** The skeleton's drawn geometry now (Spine's own vertex math; slots drawn at alpha 0 skipped), as [x0, y0, x1, y1]. */ +function drawn(skel) { + const r = [Infinity, Infinity, -Infinity, -Infinity]; + for (const slot of skel.drawOrder) { + const att = slot.getAttachment(); + if (!att || !slot.bone.active || slot.color.a * att.color.a <= 0.004) continue; + let n = 0; + if (att instanceof rt.RegionAttachment) { + if (!att.offsetDone) { att.updateOffset(); att.offsetDone = true; } + att.computeWorldVertices(slot.bone, V, 0, 2); + n = 8; + } else if (att instanceof rt.MeshAttachment) { + n = att.worldVerticesLength; + att.computeWorldVertices(slot, 0, n, V, 0, 2); + } + for (let k = 0; k < n; k += 2) { r[0] = Math.min(r[0], V[k]); r[2] = Math.max(r[2], V[k]); r[1] = Math.min(r[1], V[k + 1]); r[3] = Math.max(r[3], V[k + 1]); } + } + return r; +} +/** The pose `t` s into `clip`, played by the actor's own Spine (AnimationState, no mix), as drawn([x0, y0, x1, y1]). */ +function drawnAt(a, clip, t) { + const sp = a.spine; + sp.state.clearTracks(); + sp.skeleton.setToSetupPose(); + const e = sp.state.setAnimation(0, clip, false); + e.mixDuration = 0; + sp.update(t); + return drawn(sp.skeleton); +} +const rectOf = (b) => [b.x0, b.y0, b.x0 + b.w, b.y0 + b.h]; +const past = (r, p) => Math.max(r[0] - p[0], p[2] - r[2], r[1] - p[1], p[3] - r[3]); +/** The pre-#542 box: the bounds of the pose shown when the unit became an impostor + its margins. */ +function oldBox(p) { + const w = p[2] - p[0], h = p[3] - p[1]; + const padX = Math.max(60, w * 0.3), padY = Math.max(50, h * 0.22); + return [Math.min(p[0] - padX, -140), Math.min(p[1] - padY, -260), Math.max(p[2] + padX, 140), Math.max(p[3] + padY * 0.4, 30)]; +} +/** What a freshly built model shows before its first update: the setup pose. */ +function setupDrawn(a) { + const sp = a.spine; + sp.state.clearTracks(); + sp.skeleton.setToSetupPose(); + sp.skeleton.updateWorldTransform(); + return drawn(sp.skeleton); +} + +describe('the reported operators: every pose they play fits their impostor box', { skip: !(MODELS.every((id) => have(id)) && have('char_498_inside', 'back')) && 'the operator skeletons are not in public/assets (npm run assets)' }, () => { + test('野鬃 idling: the flag at 0.27 s of Idle is in the box — the old box from the setup pose cut it by 103 units', (t) => { + const { a, box, frames, ms } = unit('char_496_wildmn'); + t.diagnostic(`measured in ${frames} frames (${ms.toFixed(1)} ms with the box)`); + const flag = drawnAt(a, 'Idle', 0.273); + assert.ok(flag[0] < -420, `the flag reaches x ${flag[0].toFixed(0)}`); + assert.ok(past(rectOf(box), flag) <= 0, `inside the box (${past(rectOf(box), flag).toFixed(1)})`); + const before = past(oldBox(setupDrawn(a)), flag); + assert.ok(before > 80, `the old box cut it by ${before.toFixed(0)} units`); + assert.ok(box.over.has('Start'), 'its deploy drop (1700 units tall) is drawn directly instead'); + }); + + test('浊心斯卡蒂 S3 (Skill_3_Begin, the flight): her head at 3.5 s is in the box — the old box cut it', () => { + const { a, box } = unit('char_1012_skadi2'); + const head = drawnAt(a, 'Skill_3_Begin', 3.527); + assert.ok(head[1] < -680, `the hat reaches y ${head[1].toFixed(0)}`); + assert.ok(past(rectOf(box), head) <= 0, 'inside the box'); + assert.ok(past(oldBox(setupDrawn(a)), head) > 150, 'the old box cut it'); + assert.ok(!box.over.has('Skill_3_Begin') && !box.over.has('Skill_3_Loop'), 'her skill plays as an impostor'); + }); + + test('隐现 S2: the pistol with its silencer is in the box (her own box held it before too: the stray pixels came from neighbours)', () => { + const { a, box } = unit('char_498_inside'); + for (const [clip, at] of [['Skill_2_Begin', 0.658], ['Skill_2_Loop', 0.333], ['Attack_Begin', 0.1]]) { + const p = drawnAt(a, clip, at); + assert.ok(past(rectOf(box), p) <= 0, `${clip} at ${at} inside the box`); + } + assert.equal(box.over.size, 0, 'no clip of hers is drawn directly'); + }); + + test('all three: every clip they play, at 30 Hz, inside the box unless left to a direct draw', (t) => { + for (const id of MODELS) { + const { a, view, box } = unit(id); + const r = rectOf(box); + let poses = 0, worst = -Infinity, at = ''; + for (const name of view._clipNames().keys()) { + if (box.over.has(name)) continue; + const d = a.dur(name); + for (let k = 0; k <= Math.ceil(d * 30); k++) { + const time = Math.min(d, k / 30); + const e = past(r, drawnAt(a, name, time)); + poses++; + if (e > worst) { worst = e; at = `${name} ${time.toFixed(2)} s`; } + } + } + t.diagnostic(`${id}: ${poses} poses, closest to the edge ${(-worst).toFixed(1)} units inside (${at}); over: ${[...box.over].join(', ') || '—'}`); + assert.ok(worst <= 0, `${id}: ${at} is ${worst.toFixed(1)} units past the box`); + } + }); + + test('隐现\'s Back model built while stunned (no stun clip): its Idle\'s first frame, inside the box — not its setup pose', () => { + const { box } = unit('char_498_inside', 'back'); + const r = rectOf(box); + const setup = setupDrawn(unit('char_498_inside', 'back').a); + assert.ok(setup[0] > 700, `the setup pose stands right of its feet (x ${setup[0].toFixed(0)}…${setup[2].toFixed(0)})`); + assert.ok(past(r, setup) > 500, 'and lies outside the box: no model shows it'); + const first = drawnAt(unit('char_498_inside', 'back').a, 'Idle', 0); + const { a } = unit('char_498_inside', 'back'); // a model built for a stunned unit, before any update + a.setBase('stun'); + assert.equal(a.frozen, true, 'no stun clip: frozen'); + a.update(1 / 60); + const shown = drawn(a.spine.skeleton); + for (let i = 0; i < 4; i++) assert.ok(Math.abs(shown[i] - first[i]) < 0.5, `the Idle clip's first frame (${shown.map(Math.round)} vs ${first.map(Math.round)})`); + assert.ok(past(r, shown) <= 0, 'inside the box'); + a.update(1 / 60); + assert.deepEqual(drawn(a.spine.skeleton), shown, 'and held while stunned'); + }); +}); diff --git a/test/render/impostor-bounds.browser.test.js b/test/render/impostor-bounds.browser.test.js new file mode 100644 index 00000000..41c30be1 --- /dev/null +++ b/test/render/impostor-bounds.browser.test.js @@ -0,0 +1,233 @@ +// test/render/impostor-bounds.browser.test.js — GitHub #542 (reported by @ImPQlc) in headless Chrome, real WebGL: a crowd +// drawn through the impostor atlas (public/dev/render-demo.html?scene=stress: 120 units, impostor interval ≥ 2) with the +// three reported operators in it (&ops=: 野鬃, 浊心斯卡蒂, 隐现), each held in its widest pose — 野鬃's flag at 0.27 s of +// Idle, 浊心斯卡蒂 at 3.5 s of Skill_3_Begin (the S3 flight), 隐现 at 0.33 s of Skill_2_Loop (the silenced pistol). +// Checks: every pose lies inside its unit's slot; after every page is cleared and every impostor redrawn once, no atlas +// pixel lies outside a live slot (render/impostor.js clips each slot's skeleton to the slot rectangle — a scissor rect); +// the scissor itself on a bare atlas page (a quad 9× its slot paints only the slot, a redraw clears the slot, a neighbour +// keeps its pixels); a clip too large for the box (野鬃's deploy drop) is drawn directly while it plays, then the impostor +// comes back. Pictures → test/e2e/out/impostor-542-after-*.png (each operator in the crowd, the atlas pages with the slot +// outlines). The same pictures of the code before #542 are made by checking out its parent and running this file. +// +// Opt-in (starts Chrome, needs the downloaded assets): RENDER_E2E=1 node --test test/render/impostor-bounds.browser.test.js +// Chrome path: $CHROME_PATH or the macOS default. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const OUT = path.join(ROOT, 'test/e2e/out'); +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets')); +const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)'; +const TAG = process.env.IMPOSTOR_SHOT_TAG || 'after'; // file name tag of the pictures ('before' on the parent commit) + +// [unit id (render-demo &ops= places), chessId, label, clip, loop, seconds into it] +const OPS = [ + [6, 'chess_char_1_19_a', 'wildmn-idle', 'Idle', true, 0.273], + [8, 'chess_char_6_04_a', 'skadi2-s3begin', 'Skill_3_Begin', false, 3.527], + [10, 'chess_char_1_01_a', 'inside-s2loop', 'Skill_2_Loop', true, 0.333], +]; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +describe('impostor boxes and slot clipping in headless Chrome (GitHub #542)', { skip }, () => { + let srv, browser, page; + const problems = []; + before(async () => { + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../../server/index.js'); + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + await srv.assetIndex?.whenReady(); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] }); + mkdirSync(OUT, { recursive: true }); + page = await browser.newPage(); + page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); }); + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); }); + await page.setViewport({ width: 1600, height: 900 }); + await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=stress&panel=0&ops=${OPS.map((o) => o[1]).join(',')}`); + await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 }); + assert.equal(await page.evaluate(() => window.__demo.error || null), null, 'demo boot'); + // the crowd loads, its clips are measured, every unit becomes an impostor + await page.waitForFunction((ids) => { + const v = window.__demo.view; + if (v.stats().impostor < 2) return false; + for (const id of ids) { const u = v.debug.views.get(id); if (!u || !u.spineReady || !u.imp || u.imp.bypass) return false; } + return true; + }, { timeout: 40000, polling: 200 }, OPS.map((o) => o[0])); + await wait(2500); // the crowd attacks and casts for a while + await page.evaluate(() => window.__demo.pause()); + await wait(300); + }); + after(async () => { + await browser?.close(); + await srv?.close(); + }); + + test('the three operators held in their widest poses: inside their slots; no atlas pixel outside a live slot', async () => { + await page.evaluate((ops) => { + const views = window.__demo.view.debug.views; + for (const [id, , , clip, loop, t] of ops) { + const v = views.get(id); + v.actor.mode = 'base'; + v.actor._play(clip, loop, { timeScale: 0, mix: 0, start: t }); + if (v.imp) v.imp.dirty = true; + } + }, OPS); + await wait(800); + // a clean slate: every page cleared, every impostor redrawn once — what lies outside the live slots afterwards was + // painted by these redraws (a freed slot's old pixels would count otherwise) + await page.evaluate(() => { + const view = window.__demo.view; + const R = view.debug.app.renderer; + for (const pg of view.debug.ctx.impostors.pages) R.render(new PIXI.Container(), { renderTexture: pg.rt, clear: true }); + for (const v of view.debug.views.values()) if (v.imp) v.imp.dirty = true; + }); + await wait(600); + const info = await page.evaluate((ops) => { + const view = window.__demo.view; + const atlas = view.debug.ctx.impostors; + const R = view.debug.app.renderer; + const res = atlas.res; + const units = ops.map(([id, , label, clip]) => { + const v = view.debug.views.get(id); + const b = v.actor.spine.getLocalBounds(); + const s = v.imp && v.imp.slot, box = v._box, sc = v.imp ? v.imp.sc : 0, yK = v.modelKY || 1; + const track = v.actor.spine.state.tracks[0]?.animation?.name; + if (!s || !box) return { label, track, clip, slot: null }; + const x0 = (b.x - box.x0) * sc, y0 = (b.y - box.y0) * sc * yK, x1 = x0 + b.width * sc, y1 = y0 + b.height * sc * yK; + return { label, track, clip, slot: [s.x, s.y, s.w, s.h], over: Math.max(-x0, -y0, x1 - s.w, y1 - s.h), sc }; + }); + const live = []; + for (const v of view.debug.views.values()) if (v.imp && v.imp.slot && !v.imp.slot.freed) live.push(v.imp.slot); + const pages = [], images = []; + for (const pg of atlas.pages) { + if (!pg.cleared) continue; + const px = R.extract.pixels(pg.rt); + const W = Math.round(pg.size * res); + const mine = live.filter((s) => s.page === pg); + const inSlot = new Uint8Array(W * W); + for (const s of mine) for (let y = Math.floor(s.y * res); y < Math.ceil((s.y + s.h) * res); y++) inSlot.fill(1, y * W + Math.floor(s.x * res), y * W + Math.ceil((s.x + s.w) * res)); + let stray = 0; + for (let i = 0; i < W * W; i++) if (!inSlot[i] && px[i * 4 + 3] > 8) stray++; + pages.push({ index: pg.index, kind: pg.kind, slots: mine.length, stray }); + // picture: the page over a dark checker, the live slots outlined, the three operators' slots in colour + const c = R.extract.canvas(pg.rt); + const o = document.createElement('canvas'); + o.width = c.width; o.height = c.height; + const g = o.getContext('2d'); + for (let y = 0; y < o.height; y += 16) for (let x = 0; x < o.width; x += 16) { g.fillStyle = ((x + y) / 16) % 2 ? '#2a2a2e' : '#222226'; g.fillRect(x, y, 16, 16); } + g.drawImage(c, 0, 0); + g.strokeStyle = 'rgba(255,255,255,0.35)'; + for (const s of mine) g.strokeRect(s.x * res + 0.5, s.y * res + 0.5, s.w * res - 1, s.h * res - 1); + const colours = ['#ff4d4d', '#4dd2ff', '#ffd24d']; + ops.forEach(([id, , label], k) => { + const s = view.debug.views.get(id)?.imp?.slot; + if (!s || s.page !== pg) return; + g.strokeStyle = colours[k]; g.lineWidth = 2; + g.strokeRect(s.x * res + 1, s.y * res + 1, s.w * res - 2, s.h * res - 2); + g.fillStyle = colours[k]; g.font = '12px sans-serif'; g.fillText(label, s.x * res + 4, s.y * res + 14); + g.lineWidth = 1; + }); + images.push({ index: pg.index, url: o.toDataURL('image/png') }); + } + return { units, pages, images, screens: ops.map(([id]) => ({ ...view.debug.views.get(id).screen })) }; + }, OPS); + for (const im of info.images) writeFileSync(path.join(OUT, `impostor-542-${TAG}-atlas-page${im.index}.png`), Buffer.from(im.url.split(',')[1], 'base64')); + // each operator alone on the field (the crowd keeps drawing into the atlas, hidden in the main pass; bars hidden) + await page.evaluate((ids) => { + const view = window.__demo.view; + for (const [id, v] of view.debug.views) if (!ids.includes(id)) { v.root.visible = false; v.shadow.visible = false; } + view.debug.ctx.layers.bars.visible = false; + }, OPS.map((o) => o[0])); + await wait(300); + const rect = await page.evaluate(() => { const r = document.querySelector('#field canvas').getBoundingClientRect(); return { left: r.left, top: r.top }; }); + for (const [k, [, , label]] of OPS.entries()) { + const s = info.screens[k]; + const w = s.s * 3.4, h = s.s * 3.6; + await page.screenshot({ path: path.join(OUT, `impostor-542-${TAG}-${label}.png`), clip: { x: Math.max(0, rect.left + s.x - w / 2), y: Math.max(0, rect.top + s.y - h * 0.84), width: w, height: h } }); + } + await page.evaluate((ids) => { + const view = window.__demo.view; + for (const [id, v] of view.debug.views) if (!ids.includes(id)) { v.root.visible = true; v.shadow.visible = true; } + view.debug.ctx.layers.bars.visible = true; + }, OPS.map((o) => o[0])); + for (const u of info.units) { + assert.equal(u.track, u.clip, `${u.label}: holding ${u.clip}`); + assert.ok(u.slot, `${u.label}: drawn through the atlas`); + assert.ok(u.over <= 0.5, `${u.label}: the pose is ${u.over.toFixed(1)} px past its slot`); + } + for (const p of info.pages) assert.equal(p.stray, 0, `${p.kind} page ${p.index}: ${p.stray} px outside the live slots`); + assert.deepEqual(problems, []); + }); + + test('the scissor on a bare atlas page: a quad 9× its slot paints only the slot; a redraw clears it; a neighbour keeps its pixels', async () => { + const r = await page.evaluate(async () => { + const { ImpostorAtlas } = await import('/js/render/impostor.js'); + const R = window.__demo.view.debug.app.renderer; + const atlas = new ImpostorAtlas(R); + try { + const a = atlas.alloc(64, 64), b = atlas.alloc(64, 64); + // 16 px white quads, scaled by the draw transform (atlas.draw sets the scale, as for a skeleton) + const quad = (tint) => { const s = new PIXI.Sprite(PIXI.Texture.WHITE); s.tint = tint; return s; }; + const big = quad(0xff0000), small = quad(0x00ff00), nb = quad(0x0000ff); + for (const s of [big, small, nb]) atlas.park(s); + const read = () => { + const px = R.extract.pixels(a.page.rt), W = Math.round(a.page.size * atlas.res), k = atlas.res; + const at = (x, y) => { const i = (Math.floor(y * k) * W + Math.floor(x * k)) * 4; return [px[i], px[i + 1], px[i + 2], px[i + 3]]; }; + let outside = 0; + for (let y = 0; y < Math.round(160 * k); y++) for (let x = 0; x < Math.round(256 * k); x++) { + const cx = x / k, cy = y / k; + const inA = cx >= a.x && cx < a.x + a.w && cy >= a.y && cy < a.y + a.h, inB = cx >= b.x && cx < b.x + b.w && cy >= b.y && cy < b.y + b.h; + if (!inA && !inB && px[(y * W + x) * 4 + 3] > 8) outside++; + } + return { outside, aCorner: at(a.x + 60, a.y + 60), aCentre: at(a.x + 8, a.y + 8), bCentre: at(b.x + 32, b.y + 32) }; + }; + // b: a blue square, drawn once; then a alone: a red quad 3× the slot each way, offset so that, unclipped, it would + // paint over b (not redrawn: its eraser stays off) and the free space right of and below the slots + atlas.draw(b, nb, { a: 4, d: 4, tx: 0, ty: 0 }); // 64 px: all of b + atlas.flush(); + atlas.draw(a, big, { a: 12, d: 12, tx: -32, ty: -32 }); // 192 px from (−32, −32) + atlas.flush(); + const first = read(); + // a again with a 16 px green quad in its corner: the rest of the slot must be empty + atlas.draw(a, small, { a: 1, d: 1, tx: 0, ty: 0 }); + atlas.flush(); + const second = read(); + return { first, second, a: [a.x, a.y, a.w, a.h], b: [b.x, b.y, b.w, b.h] }; + } finally { + atlas.destroy(); + } + }); + assert.deepEqual(r.a.slice(2), [64, 64]); + assert.equal(r.first.outside, 0, 'nothing painted outside the slots'); + assert.deepEqual(r.first.aCorner.slice(0, 1).concat(r.first.aCorner[3]), [255, 255], 'slot a filled red'); + assert.deepEqual(r.first.bCentre, [0, 0, 255, 255], 'slot b keeps its blue: a\'s spill was cut at a\'s edge'); + assert.equal(r.second.aCorner[3], 0, 'the redraw cleared the rest of slot a'); + assert.deepEqual(r.second.aCentre, [0, 255, 0, 255], 'and drew the new quad'); + assert.deepEqual(r.second.bCentre, [0, 0, 255, 255], 'b untouched'); + assert.equal(r.second.outside, 0); + }); + + test('a clip too large for the box (野鬃\'s deploy drop) is drawn directly while it plays, then the impostor is back', async () => { + const r = await page.evaluate(async () => { + const v = window.__demo.view.debug.views.get(6); + const over = [...v._box.over]; + v.actor._play('Idle', true, { mix: 0 }); + v.onDeploy(); // the Start clip, as b.ev 'deploy' + await new Promise((ok) => setTimeout(ok, 120)); + const during = { bypass: !!v.imp?.bypass, sprite: v.imp?.sprite.visible, inBody: v.actor.spine.parent === v.body, slot: !!v.imp?.slot }; + await new Promise((ok) => setTimeout(ok, 1600)); // 1 s clip at the demo's 2× clock, then its mix out + const afterwards = { bypass: !!v.imp?.bypass, sprite: v.imp?.sprite.visible, track: v.actor.spine.state.tracks[0]?.animation?.name }; + return { over, during, afterwards }; + }); + assert.ok(r.over.includes('Start'), `over: ${r.over}`); + assert.deepEqual(r.during, { bypass: true, sprite: false, inBody: true, slot: true }, 'drawn directly, its slot kept'); + assert.equal(r.afterwards.bypass, false); + assert.equal(r.afterwards.sprite, true, 'the impostor again'); + assert.deepEqual(problems, []); + }); +}); diff --git a/test/render/impostor-bounds.test.js b/test/render/impostor-bounds.test.js new file mode 100644 index 00000000..39bd53e8 --- /dev/null +++ b/test/render/impostor-bounds.test.js @@ -0,0 +1,477 @@ +// test/render/impostor-bounds.test.js — impostor boxes and slot clipping (GitHub #542, reported by @ImPQlc): on quality +// 高 with many units on the field (round 7 on), 野鬃's flag while idling, 浊心斯卡蒂's head in her S3 flight and 隐现's +// pistol were cut, offset or left stray pixels. A unit's impostor slot was sized from the pose it showed when it first +// became an impostor (plus a margin, render/units.js _impBox); a later pose past it was cut at the slot's edge and painted +// into the neighbouring slots, whose erasers covered only their own slot (render/impostor.js), so what fell outside every +// slot stayed and what fell into another unit's slot showed on that unit until it was redrawn. +// Now every clip a unit plays is measured once per skeleton (render/spine.js clipBounds; sampled within a frame budget by +// stepClipBounds), the box covers them (units.js impostorBox — a clip too large for it is drawn directly while it plays), +// and the atlas draws each slot's skeleton inside a box masked by the slot rectangle (a scissor rect). The setup pose is +// never shown (a model frozen before its first update is posed at its clip's first frame), so the box leaves it out. +// Synthetic Spine 3.8 skeletons (@pixi-spine/runtime-3.8) and the headless fake PIXI (test/render/fakepixi.js); the three +// reported operators through the real runtime: test/render/impostor-bounds-models.test.js. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const rt = createRequire(path.join(ROOT, 'package.json'))('@pixi-spine/runtime-3.8'); + +let fake, spine, units, ImpostorAtlas; +before(async () => { + fake = installFakePixi(); + spine = await import('../../public/js/render/spine.js'); + units = await import('../../public/js/render/units.js'); + ({ ImpostorAtlas } = await import('../../public/js/render/impostor.js')); +}); +after(() => fake.restore()); + +/** + * A synthetic skeleton: a 100 × 200 body on the root (setup x −50…50, y −200…0 in pixi-spine's y-down world), a 50 × 50 + * flag on the 'arm' bone at x 100 (75…125), a 'flash' slot that is empty in the setup pose, and a 'ghost' slot drawn at + * alpha 0. Clips: 'Wave' (the arm out to x + 300 at 0.5 s and back), 'Flash' (the flash shown 0.50–0.55 s only, at + * x 900), 'Ghost' (the ghost bone moved far out: never drawn), 'Still'. + */ +function skeleton() { + const data = new rt.SkeletonData(); + const bone = (i, name, parent) => { const b = new rt.BoneData(i, name, parent); data.bones.push(b); return b; }; + const root = bone(0, 'root', null), arm = bone(1, 'arm', root), ghostBone = bone(2, 'ghost', root); + const slot = (i, name, b, att) => { const s = new rt.SlotData(i, name, b); s.attachmentName = att; data.slots.push(s); return s; }; + slot(0, 'body', root, 'body'); slot(1, 'flag', arm, 'flag'); slot(2, 'flash', root, null); + slot(3, 'ghost', ghostBone, 'ghost').color.a = 0; + const skin = new rt.Skin('default'); + const region = (name, w, h, x, y) => { const a = new rt.RegionAttachment(name); a.width = w; a.height = h; a.x = x; a.y = y; return a; }; + skin.setAttachment(0, 'body', region('body', 100, 200, 0, 100)); + skin.setAttachment(1, 'flag', region('flag', 50, 50, 100, 0)); + skin.setAttachment(2, 'flash', region('flash', 20, 20, 900, 0)); + skin.setAttachment(3, 'ghost', region('ghost', 10, 10, 0, 0)); + data.skins.push(skin); + data.defaultSkin = skin; + const wave = new rt.TranslateTimeline(3); + wave.boneIndex = 1; + wave.setFrame(0, 0, 0, 0); wave.setFrame(1, 0.5, 300, 0); wave.setFrame(2, 1, 0, 0); + const flash = new rt.AttachmentTimeline(3); + flash.slotIndex = 2; + flash.setFrame(0, 0, null); flash.setFrame(1, 0.5, 'flash'); flash.setFrame(2, 0.55, null); + const ghost = new rt.TranslateTimeline(2); + ghost.boneIndex = 2; + ghost.setFrame(0, 0, 5000, 0); ghost.setFrame(1, 1, 5000, 0); + data.animations.push(new rt.Animation('Wave', [wave], 1), new rt.Animation('Flash', [flash], 1), + new rt.Animation('Ghost', [ghost], 1), new rt.Animation('Still', [], 0)); + return data; +} +const near = (a, b, msg) => { for (let i = 0; i < 4; i++) assert.ok(Math.abs(a[i] - b[i]) < 1e-3, `${msg}: [${a}] vs [${b}]`); }; +/** Sample every queued clip now. */ +const drain = () => { while (spine.pendingClipBounds()) spine.stepClipBounds(Infinity); spine.stepClipBounds(Infinity); }; + +describe('clip bounds (render/spine.js): what a skeleton draws, measured once per skeleton data', () => { + test('a clip by every pose it samples — a short-lived attachment counts, an invisible slot does not', () => { + const data = skeleton(); + const rec = spine.requestClipBounds(data, rt.Skeleton, ['Wave', 'Flash', 'Ghost', 'Still', 'Nope']); + assert.equal(rec.failed, false); + assert.equal(rec.setup, undefined, 'no setup pose: no model shows it'); + drain(); + near(rec.rects.get('Wave'), [-50, -200, 425, 25], 'Wave: the flag 300 units out at 0.5 s'); + near(rec.rects.get('Flash'), [-50, -200, 910, 25], 'Flash: shown for 0.05 s of its 1 s, still measured (49 poses)'); + near(rec.rects.get('Ghost'), [-50, -200, 125, 25], 'Ghost: a bone moved 5000 units out with an alpha-0 slot adds nothing'); + near(rec.rects.get('Still'), [-50, -200, 125, 25], 'Still: a 0-s clip is its single pose (the body and the flag)'); + assert.equal(rec.rects.get('Nope'), null, 'a clip the skeleton does not have'); + assert.equal(rec.skel, null, 'the scratch skeleton is dropped once its clips are sampled'); + // the same data again: nothing new to sample + spine.requestClipBounds(data, rt.Skeleton, ['Wave', 'Flash']); + assert.equal(spine.pendingClipBounds(), 0); + }); + + test('the queue samples pose by pose within the frame budget — a clip resumes on the next call —, oldest request first', () => { + const a = skeleton(), b = skeleton(); + const recA = spine.requestClipBounds(a, rt.Skeleton, ['Wave', 'Still']); + spine.requestClipBounds(b, rt.Skeleton, ['Still']); + assert.equal(spine.pendingClipBounds(), 3); + // a spent budget still poses once per call: 'Wave' (1 s: 48 + 1 poses) takes 49 calls + let calls = 0, finished = 0; + while (!finished) { finished = spine.stepClipBounds(0); calls++; } + assert.equal(calls, spine.CLIP_SAMPLE_MAX + 1); + near(recA.rects.get('Wave'), [-50, -200, 425, 25], 'Wave across 49 calls'); + assert.equal(spine.pendingClipBounds(), 2); + assert.equal(spine.stepClipBounds(0), 1, 'Still: one pose'); + assert.equal(spine.stepClipBounds(0), 1, 'then the next skeleton\'s'); + assert.equal(spine.pendingClipBounds(), 0); + assert.equal(spine.stepClipBounds(), 0, 'an empty queue costs nothing'); + }); + + test('a record leaves the queue when its last actor is destroyed — pending clips, the clip in progress and its scratch skeleton —; a later actor queues them again', () => { + const data = skeleton(); + /** A SpineActor stand-in on the real runtime skeleton (clipBounds / destroy are the real methods). */ + const actor = () => Object.assign(Object.create(spine.SpineActor.prototype), { spine: { spineData: data, skeleton: new rt.Skeleton(data), destroy() {} } }); + const a = actor(), b = actor(); + assert.equal(a.clipBounds(['Wave', 'Flash']), null, 'queued'); + assert.equal(b.clipBounds(['Wave', 'Flash']), null); + spine.stepClipBounds(0); // one pose of Wave: a clip in progress + const rec = spine.requestClipBounds(data, rt.Skeleton, []); + assert.ok(rec.job && rec.skel, 'sampling Wave on its scratch skeleton'); + a.destroy(); + assert.equal(spine.pendingClipBounds(), 2, 'b still uses the skeleton: still queued'); + b.destroy(); // e.g. the battlefield torn down with its units + assert.equal(spine.pendingClipBounds(), 0, 'nothing left to sample'); + assert.ok(rec.job === null && rec.todo.length === 0 && rec.skel === null, 'no clip in progress, no todo, no scratch skeleton held'); + const c = actor(); // the same skeleton in the next scene + assert.equal(c.clipBounds(['Wave', 'Flash']), null, 'queued again (no todo left behind to block it)'); + assert.equal(spine.pendingClipBounds(), 2); + drain(); + near(c.clipBounds(['Wave', 'Flash']).get('Wave'), [-50, -200, 425, 25], 'measured'); + c.destroy(); + }); + + test('a clip whose sampling throws is recorded as not measurable (false), never as drawing nothing', () => { + const data = skeleton(); + const wave = data.findAnimation('Wave'); + const apply = wave.apply.bind(wave); + wave.apply = (skel, last, t, ...rest) => { if (t > 0.5) throw new Error('a broken timeline'); return apply(skel, last, t, ...rest); }; + const rec = spine.requestClipBounds(data, rt.Skeleton, ['Wave', 'Still']); + drain(); + assert.equal(rec.rects.get('Wave'), false, 'not measurable'); + near(rec.rects.get('Still'), [-50, -200, 125, 25], 'the other clip is measured'); + assert.equal(rec.rects.get('Nope'), undefined); + const b = units.impostorBox([-50, -200, 125, 25], [['Wave', false, false], ['Still', rec.rects.get('Still'), false]]); + assert.deepEqual([...b.over], ['Wave'], 'drawn directly while it plays'); + }); + + test('SpineActor.clipBounds: null while queued, then a Map clip → rect; false for a runtime that cannot be posed', () => { + const data = skeleton(); + const stand = { spine: { spineData: data, skeleton: new rt.Skeleton(data) } }; + const clipBounds = spine.SpineActor.prototype.clipBounds; + assert.equal(clipBounds.call(stand, ['Wave', 'Still']), null, 'queued, not sampled yet'); + drain(); + const b = clipBounds.call(stand, ['Wave', 'Still']); + assert.deepEqual([...b.keys()], ['Wave', 'Still']); + near(b.get('Wave'), [-50, -200, 425, 25], 'Wave'); + // the fake PIXI's Spine has a plain-object skeleton: no runtime to pose + const actor = new spine.SpineActor({ animations: [{ name: 'Idle' }] }, { anims: { idle: 'Idle' }, animations: { Idle: 1 } }); + assert.equal(actor.clipBounds(['Idle']), false); + }); +}); + +describe('a model frozen before its first update shows its clip\'s first frame, never its setup pose (render/spine.js)', () => { + const ENTRY = { anims: { idle: 'Idle', die: 'Die', stun: null }, animations: { Idle: 1, Die: 0.8 } }; + /** An actor on the fake PIXI whose skeleton updates are recorded (the dt of each pixi-spine update). */ + function actor(data, entry) { + const a = new spine.SpineActor(data, entry); + const posed = []; + a.spine.update = (dt) => posed.push(dt); + return { a, posed }; + } + + test('stunned without a stun clip before any update: posed once at t = 0 of its clip, then the pose holds', () => { + const { a, posed } = actor({ animations: [{ name: 'Idle' }, { name: 'Die' }] }, ENTRY); + a.setBase('stun'); + assert.equal(a.frozen, true); + a.update(0.1); + assert.deepEqual(posed, [0]); + assert.equal(a.spine.state.tracks[0].animation.name, 'Idle'); + a.update(0.1); a.update(0.1); + assert.deepEqual(posed, [0], 'held'); + assert.equal(a.poseHeld(), true); + a.setBase('idle'); // the stun ends: animated again + a.update(0.1); + assert.deepEqual(posed, [0, 0.1]); + }); + + test('a first update that throws is tried again: the frozen model is posed once an update succeeds', () => { + const { a, posed } = actor({ animations: [{ name: 'Idle' }, { name: 'Die' }] }, ENTRY); + let broken = true; + a.spine.update = (dt) => { posed.push(dt); if (broken) { broken = false; throw new Error('a broken skeleton'); } }; + a.setBase('stun'); + a.update(0.1); // throws inside pixi-spine (caught): not posed yet + a.update(0.1); // tried again at the clip's first frame: posed + a.update(0.1); // held + assert.deepEqual(posed, [0, 0]); + }); + + test('stunned after it was posed: the pose it had holds (unchanged)', () => { + const { a, posed } = actor({ animations: [{ name: 'Idle' }, { name: 'Die' }] }, ENTRY); + a.update(0.2); + a.setBase('stun'); + a.update(0.1); a.update(0.1); + assert.deepEqual(posed, [0.2]); + }); + + test('dead without a death or idle clip: frozen, and posed once at t = 0 of the clip it had (its first)', () => { + const { a, posed } = actor({ animations: [{ name: 'Attack' }, { name: 'Move' }] }, { anims: { attack: { begin: null, loop: 'Attack', end: null } }, animations: { Attack: 1, Move: 1 } }); + assert.equal(a.die(), 0, 'no death clip'); + assert.equal(a.frozen, true, 'no idle clip either: frozen'); + a.update(0.5); + a.update(0.5); + assert.deepEqual(posed, [0]); + assert.equal(a.spine.state.tracks[0].animation.name, 'Attack'); + }); +}); + +describe('impostor box (render/units.js impostorBox, playedClips)', () => { + test('playedClips: every role of the entry and of its forms; one-off = deploy, begin / end of skills, stun, move; a shared name loops', () => { + const anims = { + idle: 'Idle', deploy: 'Start', die: 'Die', + attack: { begin: 'Attack_Begin', loop: 'Attack_Loop', end: 'Attack_End' }, + skill: { begin: 'Skill_2_Begin', loop: 'Skill_2_Loop', end: 'Skill_2_End', idle: 'Skill_2_Idle' }, + skills: { 0: { begin: 'Skill_1_Begin', loop: 'Skill_1_Loop', end: null, idle: null }, 1: { begin: 'Skill_2_Begin', loop: 'Skill_2_Loop', end: 'Skill_2_End', idle: 'Skill_2_Idle' } }, + move: { begin: 'Move_Begin', loop: 'Move_Loop', end: 'Move_End' }, run: { loop: 'Run_Loop' }, + stun: { begin: 'Stun_Begin', loop: 'Stun', end: null }, attackDown: { loop: 'Attack_Down' }, + }; + const m = units.playedClips(anims, units.FORMS.char_4016_kazema); + const once = [...m].filter(([, o]) => o).map(([n]) => n).sort(); + const loops = [...m].filter(([, o]) => !o).map(([n]) => n).sort(); + assert.deepEqual(once, ['Move_Begin', 'Move_End', 'Skill_1_Begin', 'Skill_2_Begin', 'Skill_2_End', 'Start', 'Start_B', 'Stun_Begin']); + assert.deepEqual(loops, ['Attack_B', 'Attack_Begin', 'Attack_End', 'Attack_Loop', 'Die', 'Die_B', 'Idle', 'Idle_B', 'Move_Loop', 'Run_Loop', 'Skill_1_Loop', 'Skill_2_Idle', 'Skill_2_Loop', 'Stun']); + assert.ok(!m.has('Attack_Down'), 'attackDown is no clip the renderer plays'); + // a name that is the deploy clip and the idle clip loops + assert.equal(units.playedClips({ idle: 'Idle', deploy: 'Idle' }).get('Idle'), false); + assert.equal(units.playedClips(null).size, 0); + }); + + const box = (b) => [b.x0, b.y0, b.x0 + b.w, b.y0 + b.h]; + const inside = (outer, r) => outer[0] <= r[0] && outer[1] <= r[1] && outer[2] >= r[2] && outer[3] >= r[3]; + + test('the idle clip always; then each clip while the box stays within 5× (looping) / 2× (one-off) of that core', () => { + const idle = [-150, -380, 120, 10]; // the core: 270 × 390 + const clips = [ + ['Attack', [-150, -420, 400, 10], false], // 550 × 430 = 2.2 × the core: looping, taken + ['Skill', [-300, -500, 120, 10], false], // with the attack 700 × 510 = 3.4 ×: taken + ['Start', [-100, -1800, 100, 0], true], // a drop from above: 1810 tall, one-off, 12 ×: drawn directly + ['Die', [-120, -200, 90, 5], false], // inside the box anyway + ['Huge', [-3000, -400, 3000, 0], false], // a screen-wide effect: past 5 ×, left out + ['Gone', null, true], // draws nothing + ]; + const b = units.impostorBox(idle, clips); + const r = box(b); + assert.ok(inside(r, idle), 'never smaller than the idle clip'); + for (const n of ['Attack', 'Skill', 'Die']) assert.ok(inside(r, clips.find((c) => c[0] === n)[1]), `${n} inside the box`); + assert.deepEqual([...b.over].sort(), ['Huge', 'Start'], 'the clips drawn directly while they play'); + // padding: 3 % of the longer side (700) + 8 units on every side + const pad = 0.03 * 700 + 8; + near(r, [-300 - pad, -500 - pad, 400 + pad, 10 + pad], 'padded box'); + }); + + test('a one-off clip fits under the 2× limit, a looping one up to 5×; a clip inside the box is never left out', () => { + const core = [-100, -100, 100, 100]; // area 40000 + const once = units.impostorBox(core, [['Begin', [-100, -260, 100, 100], true]]); // 1.8 × + assert.equal(once.over.size, 0); + const tooBig = units.impostorBox(core, [['Begin', [-100, -340, 100, 100], true]]); // 2.2 × + assert.deepEqual([...tooBig.over], ['Begin']); + const loop = units.impostorBox(core, [['Loop', [-100, -800, 100, 100], false]]); // 4.5 × + assert.equal(loop.over.size, 0); + const loopTooBig = units.impostorBox(core, [['Loop', [-100, -1000, 100, 100], false]]); // 5.5 × + assert.deepEqual([...loopTooBig.over], ['Loop']); + // ordered by the area they add: Begin first (left out at 3 ×), then the loop, whose box covers Begin after all + const both = units.impostorBox(core, [['Begin', [-100, -500, 100, 100], true], ['Loop', [-100, -660, 100, 100], false]]); + assert.equal(both.over.size, 0, 'Begin lies inside the box the loop made'); + }); + + test('without an idle clip that draws anything the looping clips make the core; nothing drawn → null', () => { + const b = units.impostorBox(null, [['Attack', [-10, -50, 30, 0], false], ['Start', [-10, -900, 10, 0], true]]); + assert.deepEqual([...b.over], ['Start']); + assert.ok(b.w > 40 && b.h > 50 && b.h < 100); + assert.equal(units.impostorBox(null, [['Start', null, true]]), null); + assert.equal(units.impostorBox([NaN, 0, 1, 1], []), null); + }); +}); + +describe('impostor atlas (render/impostor.js): every slot is cleared and clipped to itself', () => { + /** An atlas on the fake PIXI with a renderer that records what each page render contained. */ + function atlas() { + const renders = []; + const R = { resolution: 1, render: (obj, o) => renders.push({ clear: o.clear, boxes: obj.children[1].children.filter((b) => b.visible).length }) }; + return { imp: new ImpostorAtlas(R), renders }; + } + + test('a slot owns an eraser and a box masked by exactly its rectangle (PIXI draws a rectangle mask as a scissor rect)', () => { + const { imp } = atlas(); + const drawn = []; + const drawRect = fake.P.Graphics.prototype.drawRect; + fake.P.Graphics.prototype.drawRect = function (x, y, w, h) { drawn.push(this); this.rect = [x, y, w, h]; return this; }; + try { + const a = imp.alloc(100, 110), b = imp.alloc(60, 40); + for (const s of [a, b]) { + assert.deepEqual(s.mask.rect, [s.x, s.y, s.w, s.h], 'mask = the slot'); + assert.equal(s.box.mask, s.mask); + assert.equal(s.mask.parent, s.box, 'the mask lives in the box (its transform follows the page)'); + assert.deepEqual([s.eraser.x, s.eraser.y, s.eraser.width, s.eraser.height], [s.x, s.y, s.w, s.h], 'the eraser clears all of it'); + assert.equal(s.box.visible, false, 'nothing drawn until queued'); + } + assert.equal(drawn.length, 2); + } finally { fake.P.Graphics.prototype.drawRect = drawRect; } + }); + + test('a queued skeleton is drawn inside its slot box, then parked; freeing a slot never destroys a skeleton queued in it', () => { + const { imp, renders } = atlas(); + const s = imp.alloc(100, 110), t = imp.alloc(100, 110); + const body = new fake.P.Container(), other = new fake.P.Container(); + imp.park(body); imp.park(other); + imp.draw(s, body, { a: 0.3, d: 0.3, tx: 40, ty: 90 }); + assert.equal(body.parent, s.box); + assert.ok(s.box.visible && s.eraser.visible && !t.box.visible); + assert.deepEqual([body.x, body.y], [s.x + 40, s.y + 90]); + imp.flush(); + assert.deepEqual(renders, [{ clear: true, boxes: 1 }], 'one render of the page: the queued slot only'); + assert.equal(body.parent, imp.parked); + assert.ok(!s.box.visible && !s.eraser.visible && !body.visible); + imp.draw(t, other, { a: 1, d: 1, tx: 0, ty: 0 }); + imp.free(t); // its unit left impostor mode before the flush + assert.ok(!other.destroyed && other.parent === imp.parked, 'the skeleton went back to the lot'); + assert.ok(t.box.destroyed, 'the box and its mask are gone'); + imp.flush(); + assert.deepEqual(renders.at(-1), { clear: false, boxes: 0 }, 'later passes do not clear the page'); + }); +}); + +describe('UnitView impostor mode (render/units.js)', () => { + const tick = () => new Promise((r) => setImmediate(r)); + const cam = () => presetCamera('normal', { width: 1280, height: 720 }); + const ENTRY = { skel: '/s/op.skel', atlas: '/s/op.atlas', textures: ['/s/op.png'], anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { begin: null, loop: 'Attack', end: null } }, animations: { Idle: 1, Start: 1, Die: 0.8, Attack: 1 } }; + const DATA = { animations: [{ name: 'Idle' }, { name: 'Start' }, { name: 'Die' }, { name: 'Attack' }] }; + const MEASURED = new Map([['Idle', [-70, -210, 70, 0]], ['Attack', [-70, -230, 180, 0]], ['Start', [-70, -1800, 70, 0]], ['Die', [-120, -150, 90, 0]]]); + + /** + * An operator in a crowd (impostor interval 2) with a recording fake atlas; `bounds()` = what clipBounds answers; + * `flags` = its unit flags while the model loads (UF.STUNNED: the model is built for a stunned unit). + */ + async function operator(bounds, flags = 0) { + let frame = 0; + const log = { alloc: 0, free: 0, draw: 0 }; + const atlas = { + alloc: (w, h, o) => { log.alloc++; return { w, h, tex: new fake.P.Texture(), clip: !!o?.clip }; }, + free() { log.free++; }, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() { log.draw++; }, + }; + const ctx = fakeViewCtx(fake.P, { + cam, frameNo: () => frame, impostors: atlas, impostorInterval: () => 2, + renderer: { resolution: 1, render() {} }, + assets: { picture: () => null, image: async () => null, spineEntry: () => ENTRY, spine: { acquire: async () => DATA, release() {} } }, + }); + const v = new units.UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT' }); + v.flags = flags; + await tick(); await tick(); + assert.ok(v.spineReady, 'spine ready'); + v.actor.clipBounds = (names) => { + const b = bounds(); + if (b) for (const n of names) assert.ok(b.has(n), `${n} measured`); + return b; + }; + const step = (n = 1) => { for (let i = 0; i < n; i++) { v.update(1 / 60, cam(), frame / 60); frame++; } }; + return { v, step, log }; + } + + test('while the model\'s clips are still measured the unit is drawn directly; then an impostor sized from them', async () => { + let ready = null; + const { v, step, log } = await operator(() => ready); + step(4); + assert.ok(!v.imp, 'no impostor without a box'); + assert.equal(log.draw, 0); + assert.equal(v.actor.spine.parent, v.body, 'drawn directly'); + ready = MEASURED; + step(2); + assert.ok(v.imp && log.draw >= 1, 'an impostor once measured'); + const b = v._box, r = [b.x0, b.y0, b.x0 + b.w, b.y0 + b.h]; + for (const n of ['Idle', 'Attack', 'Die']) { + const c = MEASURED.get(n); + assert.ok(r[0] <= c[0] && r[1] <= c[1] && r[2] >= c[2] && r[3] >= c[3], `${n} inside the box`); + } + assert.deepEqual([...b.over], ['Start'], 'the deploy drop is drawn directly while it plays'); + }); + + test('a clip too large for the box is drawn directly while it plays — the slot stays the unit\'s — and redrawn into it after', async () => { + const { v, step, log } = await operator(() => MEASURED); + step(4); + const sprite = v.imp.sprite; + assert.ok(v.imp && sprite.visible); + const c0 = { ...log }; + v.onDeploy(); // b.ev 'deploy': the Start clip + step(1); + assert.equal(v.imp.bypass, true); + assert.equal(sprite.visible, false, 'the impostor sprite hides'); + assert.equal(v.actor.spine.parent, v.body, 'the skeleton is drawn in the body'); + assert.equal(v.actor.spine.visible, true); + step(30); + assert.equal(log.draw, c0.draw, 'no atlas redraw while it plays'); + assert.equal(log.free, c0.free, 'its slot is kept'); + step(40); // the 1-s clip is over: back to the idle + assert.equal(v.imp.bypass, false); + assert.ok(sprite.visible && v.actor.spine.parent !== v.body, 'the impostor again'); + assert.ok(log.draw > c0.draw, 'redrawn into its slot'); + assert.equal(log.alloc, c0.alloc, 'the same slot'); + }); + + test('a clip whose sampling threw is drawn directly while it plays — the attack, or even the idle clip —, never cut by the slot', async () => { + const broken = new Map(MEASURED).set('Attack', false); + const { v, step, log } = await operator(() => broken); + step(3); + assert.ok(v.imp && v._box.over.has('Attack')); + const draws = log.draw; + v.actor._play('Attack', false, { mix: 0 }); + step(2); + assert.equal(v.imp.bypass, true, 'the attack is drawn directly'); + assert.equal(log.draw, draws, 'not into the slot'); + const idleBroken = new Map(MEASURED).set('Idle', false); + const w = (await operator(() => idleBroken)).v; + w.update(1 / 60, cam(), 0); + assert.ok(w._box.over.has('Idle'), 'an unmeasurable idle clip: drawn directly while it idles'); + const b = w._box, c = MEASURED.get('Attack'); + assert.ok(b.x0 <= c[0] && b.x0 + b.w >= c[2], 'the box built from the clips that were measured'); + const none = new Map([...MEASURED.keys()].map((n) => [n, false])); + const z = (await operator(() => none)).v; + z.update(1 / 60, cam(), 0); + assert.deepEqual([...z._box.over].sort(), ['Attack', 'Die', 'Idle', 'Start'], 'nothing measurable: every clip drawn directly'); + }); + + test('a runtime whose clips cannot be measured keeps the box from the pose shown (the pre-#542 rule)', async () => { + const { v, step } = await operator(() => false); + step(3); + assert.ok(v.imp, 'impostor'); + assert.deepEqual([v._box.x0, v._box.y0, v._box.w, v._box.h], [-140, -260, 280, 290], 'fake getLocalBounds (−50, −100, 100 × 100) + the old margins'); + assert.equal(v._box.over.size, 0); + }); + + test('built while stunned without a stun clip: posed once at its idle clip\'s first frame — the box\'s core —, then held', async () => { + const { v, step, log } = await operator(() => MEASURED, 2); // UF.STUNNED + assert.equal(v.actor.frozen, true, 'no stun clip: the pose freezes'); + const posed = []; + v.actor.spine.update = (dt) => posed.push(dt); + step(6); + assert.deepEqual(posed, [0], 'one pose at t = 0 of the clip on its track, never the setup pose'); + assert.equal(v.actor.spine.state.tracks[0].animation.name, 'Idle'); + assert.ok(v.imp && log.draw === 1, 'drawn into its slot once, then held'); + const b = v._box, c = MEASURED.get('Idle'); + assert.ok(b.x0 <= c[0] && b.y0 <= c[1] && b.x0 + b.w >= c[2] && b.y0 + b.h >= c[3], 'that frame lies inside the box'); + }); + + test('a skeleton without an Idle clip: the clip SpineActor idles on instead (its first) is the box\'s core', async () => { + const entry = { ...ENTRY, anims: { attack: { begin: null, loop: 'Attack', end: null } }, animations: { Attack: 1, Move: 1 } }; + const ctx = fakeViewCtx(fake.P, { + cam, frameNo: () => 0, impostors: { alloc: (w, h) => ({ w, h, tex: new fake.P.Texture(), clip: false }), free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {} }, + renderer: { resolution: 1, render() {} }, + assets: { picture: () => null, image: async () => null, spineEntry: () => entry, spine: { acquire: async () => ({ animations: [{ name: 'Attack' }, { name: 'Move' }] }), release() {} } }, + }); + const v = new units.UnitView(ctx, { id: 2, side: 'ally', kind: 'chess', defId: 'char_y', tier: 1, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT' }); + await tick(); await tick(); + assert.equal(v.actor.spine.state.tracks[0].animation.name, 'Attack', 'SpineActor idles on the first clip'); + assert.equal(v._idleClip(), 'Attack'); + assert.deepEqual([...v._clipNames()], [['Attack', false]]); + }); + + test('a pen figure (lod idle) needs its idle clip only', async () => { + let asked = null; + const ctx = fakeViewCtx(fake.P, { + cam, frameNo: () => 0, impostors: { alloc: (w, h) => ({ w, h, tex: new fake.P.Texture(), clip: false }), free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {} }, + renderer: { resolution: 1, render() {} }, + assets: { picture: () => null, image: async () => null, spineEntry: () => ENTRY, spine: { acquire: async () => DATA, release() {} } }, + }); + const v = new units.UnitView(ctx, { id: 'e:0', side: 'enemy', kind: 'enemy', defId: 'enemy_x', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' }); + await tick(); await tick(); + v.actor.clipBounds = (names) => { asked = names; return MEASURED; }; + v.update(1 / 60, cam(), 0); + assert.deepEqual(asked, ['Idle']); + assert.ok(v.imp && v._box.over.size === 0); + }); +});