0.2.6-dev: impostor review follow-ups (#542) — a skeleton stays in the clip-bounds queue only while an actor uses it (the last one destroyed drops its pending clips, the clip in progress and its scratch skeleton; a later actor queues them again), a clip whose sampling throws is recorded as not measurable and drawn directly while it plays instead of counting as empty, and a frozen model's first update counts as posed only when it succeeded; DESIGN §31.6 updated; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-13 06:29:20 +08:00
1 parent 4c2fe9431d
commit 47004a3651
4 files changed
+197 -78

No files matched your search

+65 -58
View File
@@ -111,62 +111,69 @@ Regression covers normal two-, three- and four-human browser flows, the final tu
`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).
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. (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`). 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. (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 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; `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, 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=<chessId>,…`. Golden: unchanged.
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=<chessId>,…`.
Golden: unchanged.