@rig-c/play 1.16.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,14 +8,15 @@
8
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-38BDF8.svg?style=flat-square" alt="license" /></a>
9
9
  </p>
10
10
 
11
- Plays the rigs [rigc](https://github.com/rig-c/core) builds — the compiled model
12
- document `rigc build` writes, `skeleton.model.json` — as **plain DOM**: one
13
- absolutely-positioned `<img>` per rigid slot, posed with a single CSS `matrix()` write
14
- per frame, deforming meshes on small per-part canvases; or as **one canvas** per player.
15
- `rig-play` and `spine-html` are the same package under the names it shipped as before.
16
- The toolchain lives under one organisation: the compiler [rig-c/core](https://github.com/rig-c/core)
17
- (npm `rig-c`), the part generator [rig-c/parts](https://github.com/rig-c/parts)
18
- (npm `@rig-c/parts`), and this player, [rig-c/play](https://github.com/rig-c/play).
11
+ `@rig-c/play` plays the rigs [rigc](https://github.com/rig-c/core) builds — the
12
+ compiled model document `rigc build` writes, `skeleton.model.json` — as **plain
13
+ DOM**: one absolutely-positioned `<img>` per rigid slot, posed with a single CSS
14
+ `matrix()` write per frame, and deforming meshes on small per-part canvases; or
15
+ as **one canvas** per player. It poses through `rig-c/core`, rigc's own posing
16
+ core, bundled into the package, and links nothing from Esoteric Software.
17
+ `rig-play` and `spine-html` are the same package under the names it shipped as
18
+ before; the compiler is [rig-c/core](https://github.com/rig-c/core) (npm `rig-c`)
19
+ and the part generator [rig-c/parts](https://github.com/rig-c/parts).
19
20
 
20
21
  ## Why
21
22
 
@@ -95,164 +96,8 @@ mounts independent player instances):
95
96
  | 20 small players | canvas | `walk-packed` `walk` | cell | 59.99 | 0.69 | 0.54 | —/20 | 600 | 20 | 0.015 | —/1 |
96
97
  | 20 small players | official | `walk-packed` `walk` | cell | 60.00 | 0.13 | 0.24 | 16/20 | 480 | 16 | 0.006 | 16/20 |
97
98
 
98
- Where each side wins and loses on that table:
99
-
100
- - **High-count meshed grids (`36` and `64 meshed rigs animating`) — `official` wins**: batching
101
- all 64 skeletons into one WebGL canvas holds `59.99 fps` (`5,632 triangles` in `1 draw call`),
102
- while `canvas` mode drops to `53.99 fps` (per-player `drawImage` blits from the shared WebGL
103
- context) and `dom` mode drops to `48.28 fps` at 36 rigs and `29.07 fps` at 64 rigs (per-part
104
- mesh canvas blits + DOM compositing). Up to 16 animating meshed rigs all three hold `60 fps`.
105
- - **Held poses (`1`, `16`, `64 held poses`) — `dom` and `canvas` skip rasterization**: both
106
- modes reuse their rasters (`0 triangles`, `0 draw calls`), while `official` redraws
107
- `88` / `1,408` / `5,632 triangles` every frame.
108
- - **Rigid-heavy grids (`64 rigid rigs animating`) — `dom` and batched `official` hold `60 fps`**:
109
- `dom` updates `<img>` CSS transforms (`0 triangles`, `59.89 fps`), whereas `canvas` pays 64
110
- per-player `drawImage` blits (`42.00 fps`).
111
- - **Many independent players on one page (`20 small players`) — `dom` and `canvas` draw all 20;
112
- `official` loses 4 to the browser's 16-context WebGL cap**: both `@rig-c/play` modes share at
113
- most one WebGL context across the page, while mounting 20 independent `spine-webgl` players
114
- evicts 4 contexts (`16/20 drawn players`, `16/20 live contexts`).
115
-
116
- **The element mode's mesh backend is `'webgl'` by default since 1.9 (#109)**, read off
117
- macOS Safari on 2026-10-13 (1.8.2, gallery rig `look-packed`: 4 meshes, 464
118
- triangles, `sweep`; the demo's stats line after a 30 s settle; the Mac was also
119
- serving the builds, so absolute fps is low and only the comparison is the point —
120
- readings verbatim on #25):
121
-
122
- | macOS Safari | 1 rig | 10 rigs (4,640 triangles) |
123
- | --- | --: | --: |
124
- | canvas2d mesh backend | 21–26 fps | 2–3 fps |
125
- | webgl mesh backend | 60 fps | 58 fps |
126
-
127
- Nothing is claimed here for mobile browsers: no phone or tablet has been measured
128
- on the 1.x line.
129
-
130
- The numbers below are older: measured on the 0.x line, on the spineboy exports it then played (PoC, Apple silicon —
131
- headless and on-device numbers are labeled, they do **not** substitute for each other).
132
- The renderer tiers they describe carried over; they have not been re-measured since the
133
- player moved to rigc's model (#60):
134
-
135
- - Rigid only (spineboy-ess), headless Chromium: 10 skeletons / 180 slot images at
136
- **~0.03 ms skeleton math + ~0.22 ms DOM writes per frame** — about 1.5% of a
137
- 60 fps frame budget. On-device Safari: 10 rigid skeletons hold **60 fps**.
138
- - With meshes (spineboy-pro), headless Chromium: 1 skeleton = **~0.05 ms + ~0.5 ms
139
- render** (8 mesh canvases, 323 triangles); 10 running skeletons = **~3.5 ms/frame**
140
- total, smooth.
141
- - Dirty-skip: 10 *static* skeletons (pose held) = **~0.4 ms/frame** headless and
142
- **~0.6 ms** on-device Safari — unchanged meshes reuse their raster, so idle or
143
- held parts cost nothing anywhere.
144
- - Real Safari (on-device), canvas2d mesh backend: in-callback JS stays **~4–5
145
- ms/frame** for 10 running meshed skeletons — but the rAF rate tells the real
146
- story: **meshed ×1 = 37 fps, meshed ×10 = 3–4 fps** (rigid-only ×10 = 60 fps).
147
- The cost lives outside the frame callback, in the compositor/GPU process:
148
- Safari antialiases canvas2d **clip paths**, so every triangle pays for an AA
149
- mask. Chromium doesn't antialias clips and stays smooth. This is what the
150
- optional WebGL mesh backend (below) removes.
151
- - Real Safari (on-device), **webgl mesh backend**: meshed ×1 holds **60 fps**
152
- (vs 37 on canvas2d), and the meshed ×10 stress scene jumps **3–4 → 45 fps** —
153
- 80 mesh canvases / 3230 triangles redrawn every frame (0 reused, the worst
154
- case), in-callback JS ~5.8 ms at dpr=1. The per-triangle clip-AA tax is gone;
155
- the remaining gap to 60 is the blit/compositing cost of a 10-skeleton stress
156
- scene, not a per-triangle cost.
157
- - Headless-WebKit numbers are a **software rasterizer** and measured up to 28×
158
- off real Safari in both directions — useful for visual regression only, never
159
- as Safari perf evidence.
160
-
161
- ## Status
162
-
163
- **Production.** The questions this started as a PoC to answer — how far does the
164
- DOM actually go, and how cheap is it — are answered with on-device numbers: the
165
- practical scenario (a character or two, mostly holding pose, a few parts
166
- deforming) holds **60 fps on every engine measured**, and the 10-skeleton
167
- stress scene holds 45 fps on the weakest one (Safari, webgl backend). Ships
168
- with a self-contained test suite (backend visual parity + invariants) and CI.
169
-
170
- **Two render modes.** `mode: 'dom'` (the default, everything below) draws a rig
171
- as elements. `mode: 'canvas'` draws the whole frame into **one `<canvas>`** per
172
- player: every slot as textured triangles from its atlas page through the same
173
- shared WebGL context the mesh tier's `webgl` backend uses, into one rect, then
174
- one `drawImage` onto the player's canvas. Tint and dark (two-colour) tint are
175
- vertex colours, blend modes are batch state, clips are cut on the CPU by
176
- `rig-c/core`'s clipper — no `clip-path`, no SVG filter, no per-slot element.
177
- `RigPlayer` swaps between the two in place (`player.mode = 'canvas'`). The
178
- canvas mode is held against the DOM tier pixel for pixel on the parity suite's
179
- frozen poses (bad pixels ≤ 1.6% of drawn content, measured, macOS Chromium and
180
- WebKit); which mode is faster where is **not measured on a device yet**, so the
181
- default stays `dom`.
182
-
183
- - ✅ Region attachments (rigid parts): exact affine mapping, draw-order via `z-index`,
184
- attachment swaps, alpha
185
- - ✅ Atlas unpacking at load time (90°-packed regions restored to the orientation
186
- the region's UVs name, held against hand-written UVs on a rigc build whose region
187
- is packed turned),
188
- so the rigid per-frame path never touches a canvas — a region that covers its whole
189
- page is passed through uncut, and `revokeRegions()` frees the rest when a skeleton
190
- is unloaded
191
- - ✅ Mesh attachments (deform tier): small per-part canvases sized to the mesh's world
192
- bounds, interleaved with the rigid `<img>` slots in one stacking context — the DOM
193
- handles the bones, a rasterizer handles the warps
194
- - ✅ Blend modes via `mix-blend-mode` (additive = `plus-lighter`)
195
- - ✅ Crack-free mesh seams on the canvas2d backend: canvas2d antialiases each
196
- triangle's clip edge, so two triangles sharing an edge each leave it partly
197
- transparent and the background shows through as a hairline — plainest over a
198
- light background behind light art (#104). Each edge of a triangle's clip
199
- polygon is moved 0.5px outward along its own normal so neighbours overlap —
200
- the texture is continuous across shared edges, so the overlap is invisible
201
- (`?expand=0` shows the cracks for comparison)
202
- - ✅ RGB tinting (skeleton × slot × attachment color) via an SVG `feColorMatrix`
203
- reference filter per element — an exact channel multiply that works identically on
204
- `<img>` and `<canvas>` without touching the raster (needs reference-filter support,
205
- Safari 15+; verified pixel-level on Chromium and WebKit). Dark/two-color tint is
206
- not expressible this way and stays out of scope
207
- - ✅ DPR-aware mesh canvas backing store: `renderer.pixelRatio` (defaults to
208
- `devicePixelRatio`; if you scale the root element, fold that scale in)
209
- - ✅ Dirty-skip: a mesh whose canvas-space vertices match the last raster reuses it;
210
- whole-pixel translations allow at most half a Float32 ULP at the current world
211
- coordinate, strictly less than half a pixel. CSS still tracks the translation, while
212
- stationary axes stay exact and the comparison anchor changes only on a redraw.
213
- 10 frozen spineboys: WebKit ~141 → ~0.5 ms/frame
214
- - ✅ Clipping attachments as element-level CSS `clip-path`: spine-core's semantics
215
- (one active clip at a time, applied from the clip's slot through its end slot
216
- inclusive), with the polygon expressed in each element's own local frame — the
217
- inverse of the `<img>` matrix for rigid slots, the canvas translate for mesh
218
- slots. Covers **part masks** (a clip with a real end slot, like the figure test
219
- rig's `mask` animation) and **whole-skeleton clips** (an end slot that never
220
- arrives, which takes a fast path: one `clip-path` on the root instead of one per
221
- element). **Inverse clips** are supported too: the region they keep has a hole
222
- in it, so it needs two rings, and `polygon()` carries only one — a box and a
223
- polygon listed inside one `polygon()` become a single self-intersecting ring
224
- whose even-odd fill leaves a wedge along the seam between them (measured, on a
225
- corner of spineboy's boot). So an inverse clip is written as a two-subpath
226
- `path(evenodd, …)` whose outer ring is the element's own box. The whole feature
227
- is element-level, so both mesh backends see the same thing and neither raster
228
- path knows clipping exists; `renderer.clipping = false`
229
- restores the old counted-and-skipped behaviour. Concave polygons go to CSS as
230
- authored — the convex decomposition spine's CPU clipper performs exists to feed a
231
- triangle rasterizer and has no job here. Two things this does not do: Spine's
232
- clipper convexifies an *inverse* polygon (convex hull) where CSS clips it as
233
- authored, so the two agree exactly when that polygon is convex — which is the
234
- shape `inverse` is meant for; and the cost of many simultaneous `clip-path`s on
235
- real-device Safari is **not measured** (the headless numbers that exist are not
236
- Safari evidence — see Measured above)
237
- - ✅ Safari mesh cost root-caused by on-device triangulation (two corrections deep):
238
- the early "~15× slower per-triangle path" was a **headless-WebKit artifact**
239
- (software rasterization), and the follow-up "on par with Chromium" held only for
240
- in-callback JS time. Real-Safari rAF rates — rigid ×10 = 60 fps, meshed ×1 =
241
- 37 fps, meshed ×10 = 3–4 fps with JS flat at ~4–5 ms — put the real cost in the
242
- GPU process: Safari antialiases canvas2d **clip paths**, so the per-triangle clip
243
- mapping pays a per-triangle AA-mask tax (the same AA that caused the seam cracks)
244
- - ✅ WebGL blit backend for the mesh tier, the default since 1.9 (#109;
245
- `renderer.meshBackend = 'canvas2d'` keeps the 2d raster). All dirty meshes are shelf-packed into
246
- **one shared offscreen WebGL context** (module-level — browsers cap contexts at
247
- ~16), drawn as textured triangles with premultiplied alpha, then rect-blitted
248
- onto the same per-part canvases with an unclipped `drawImage` — cheap on Safari.
249
- Everything element-level is unchanged: z-index interleave, tint filter,
250
- `mix-blend-mode`, dirty-skip, grow-only backing. GL rasterizes shared triangle
251
- edges seamlessly, so this path needs no crack overdraw. Falls back to canvas2d
252
- when WebGL is unavailable or the context is lost. Backend parity verified by
253
- headless Chromium+WebKit screenshot diffs (glow / clipping / tint scenes;
254
- sub-pixel edge differences only). On-device Safari, meshed ×10 with every mesh
255
- redrawn per frame: **3–4 fps (canvas2d) → 45 fps (webgl)**
99
+ Where each side wins and loses on that table, the Safari mesh-backend readings
100
+ and the 0.x numbers are in [docs/BENCHMARK.md](docs/BENCHMARK.md).
256
101
 
257
102
  ## Install
258
103
 
@@ -328,966 +173,27 @@ tells the renderer about the scale, and plays the first animation:
328
173
  ```
329
174
 
330
175
  `syncPixelRatio()` is what makes the scale cheap: without it the mesh canvases
331
- raster at the device pixel ratio of the *unscaled* rig, `1 / scale` times more
332
- pixels than the screen shows. Call it again after anything that changes the
176
+ raster at the device pixel ratio of the *unscaled* rig, `1 / scale²` times more
177
+ pixels than the screen shows (ideally 16× at `scale = 0.25`, before backing-store rounding and slack). Call it again after anything that changes the
333
178
  scale. `tests/readme-page.spec.ts` runs this block as written, against the
334
179
  package built from this tree.
335
180
 
336
- ### Wind, gravity and dragging the rig
337
-
338
- A rig's physics links answer the world the page sets (#121). `environment()`
339
- gives them spine-core's skeleton-level wind and gravity — x right, **y down**,
340
- the runtime's axes; each constraint's authored `wind` / `gravity` is its
341
- response, so a link stating `wind: 0` feels none — and `moved()` tells them the
342
- page moved or turned the rig, in the skeleton's world (y up), so a dragged rig
343
- trails its cloth. Both apply from the next step, at any time. Added to the
344
- minimal page above:
345
-
346
- ```ts
347
- // A wind slider, -1 (left) to 1 (right). The runtime starts at wind [1, 0], so set calm first.
348
- const wind = stage.appendChild(Object.assign(document.createElement('input'),
349
- { type: 'range', min: '-1', max: '1', step: '0.01', value: '0' }));
350
- player.environment({ wind: [0, 0] });
351
- wind.addEventListener('input', () => player.environment({ wind: [Number(wind.value), 0] }));
352
-
353
- // Drag the rig: move the root, and tell the physics how far, in world units with y up.
354
- let held = null; // the pointer's position while a drag is held
355
- stage.addEventListener('pointerdown', (e) => { if (e.target !== wind) held = { x: e.clientX, y: e.clientY }; });
356
- addEventListener('pointerup', () => { held = null; });
357
- stage.addEventListener('pointermove', (e) => {
358
- if (!held) return;
359
- const dx = e.clientX - held.x, dy = e.clientY - held.y;
360
- held = { x: e.clientX, y: e.clientY };
361
- root.style.left = `${parseFloat(root.style.left) + dx}px`;
362
- root.style.top = `${parseFloat(root.style.top) + dy}px`;
363
- player.moved({ x: dx / scale, y: -dy / scale });
364
- });
365
- ```
366
-
367
- The block is plain JavaScript, so it runs inside the page as written.
368
- `environment(null)` returns to the runtime's start values, and `environment()`
369
- reads what stands. The state is the player's, not a track's: every track and
370
- layer answers the same world, and a `seek` replays under the environment as it
371
- stands — no history is kept. A rig with no physics ignores both.
372
- `tests/readme-page.spec.ts` runs this block in the minimal page.
373
-
374
- The demo's `wind.html` is the same thing with controls: wind and gravity
375
- sliders, a drag on the stage, and the rig named by `?model=` (the `cloth` test
376
- rig by default; `bun run dev`, then `/wind.html?model=rigs/<name>/skeleton.model.json`).
377
- `tests/wind-page.spec.ts` holds it.
378
-
379
- The player owns one track: `play(name, { loop })` opens an animation at its first
380
- pose, `frame(dt)` steps it (or `advance(dt)` then `draw()`, to time the two
381
- apart), `pause()` / `resume()` hold and release it, `timeScale` scales every
382
- step, and `seek(t)` moves it to a time. A seek is "reopen, then step to the
383
- time" in `seekStep` increments (1/60 s by default) — never a jump, because a
384
- physics rig has history and its pose at a time depends on the steps that
385
- reached it. The physics integrates on rig-c's render clock (`physicsClockStep`:
386
- each step and 2⁻³⁰ of it more), so stepping at a fixed rate — `frame(1/60)`, a
387
- capture, a test — shows no single-frame spring jump where a frame ends on a
388
- physics step; the animation, the events and `time` stay on the steps (#103). `on('event', …)` receives the event keys each step crosses, in the
389
- runtime's order across a loop's wrap; `on('complete', …)` fires once a step
390
- completes a cycle. `skin` (a constructor option) poses under a named skin;
391
- without it the rig is posed the way a fresh Spine skeleton is, with no skin
392
- set. `player.color` multiplies a whole-rig colour into every slot's tint.
393
- `player.renderer` is the `DomRenderer` underneath, whose knobs and counters
394
- are below.
395
-
396
- **What it refuses, by name.** A Spine export (`.json`, `.skel`) is refused
397
- before anything is fetched: this package reads compiled models and nothing
398
- else. A document whose `spec` is not one `rig-c/core` reads is refused with the
399
- core's own sentence plus "build it with `rigc build`", and so is a
400
- `rigc-compiled/1` document, which predates the `pages` section.
401
-
402
- `@rig-c/play` is a third-party renderer and is not affiliated with or endorsed by
403
- Esoteric Software.
404
-
405
- ### Drawing your own frames
406
-
407
- `RigPlayer` is the convenient path; the renderer underneath takes a plain
408
- record, so anything that can produce one can draw through it:
409
-
410
- ```ts
411
- import { DomRenderer, type PosedFrame } from '@rig-c/play';
412
-
413
- const renderer = new DomRenderer(rootElement, assets.regionImages, assets.pageImages);
414
- renderer.render(frame); // frame: PosedFrame
415
- ```
416
-
417
- ```ts
418
- PosedFrame { order: PosedSlot[] } // the slots that draw, in draw order
419
- PosedSlot = { slot; z; blend: 'Normal'|'Additive'|'Multiply'|'Screen';
420
- tint: [r, g, b, a]; dark?: [r, g, b];
421
- clip?: { polygon: number[]; inverse: boolean; convex: boolean } } // world space, Y up
422
- & ( { kind: 'region'; region: string; corners: number[] /* BL, UL, UR, BR */;
423
- page: string; pma: boolean; uvs: number[] /* page UVs at the corners */ }
424
- | { kind: 'mesh'; page: string; pma: boolean; vertices: number[];
425
- uvs: number[]; triangles: number[]; sequence: number } )
426
- ```
427
-
428
- `player.posedFrame()` returns the record the player would draw. Two things the
429
- renderer reads off it by identity rather than by value: every slot a clip
430
- covers carries the **same** `clip` object (one that covers every slot of the
431
- frame takes the one-`clip-path`-on-the-root path), and a mesh's `uvs` and
432
- `triangles` are the same arrays from frame to frame for the same texture
433
- mapping (a new array re-rasters the mesh).
434
-
435
- ### Reading the pose
436
-
437
- Two reads off the pose the player holds — the last `frame`/`advance`/`seek`,
438
- or the setup pose before `play` — for framing a stage around a rig:
439
-
440
- ```ts
441
- const head = player.bone('head'); // { x, y, a, b, c, d, rotationX, rotationY, scaleX, scaleY, active }
442
- const box = player.bounds(); // { x, y, width, height } | null — world units, Y up
443
- ```
444
-
445
- `bone` is the core's world transform for that bone (`rig-c/core`'s `RawBone`,
446
- unchanged), and an unknown name is refused by name. `bounds` is the box of
447
- every region's four corners and every mesh's vertices in the frame record the
448
- renderer takes; a slot under a clip counts only inside the clip polygon's own
449
- box, an inverse clip removes nothing, and bounding boxes, paths and points do
450
- not draw and do not count. Both cost nothing until called and read the same
451
- numbers in `dom` and `canvas` mode.
452
-
453
- `readSetupMesh` reads a named mesh from loaded assets before or during playback,
454
- including an attachment the setup pose does not select:
455
-
456
- ```ts
457
- import { readSetupMesh } from '@rig-c/play';
458
-
459
- const mesh = readSetupMesh(assets, { slot: 'flag', attachment: 'alternate' });
460
- // mesh: { slot, attachment, skin, vertices, triangles, hull }
461
- // vertices: flat world x,y pairs, Y up; hull counts vertices, or is null.
462
- ```
463
-
464
- Names are exact slot names and attachment table keys, not display names or atlas
465
- regions. Omitted or `null` skin means no skin set, as for a fresh player. A named
466
- `skin` uses that skin's activation lists with default-table fallback; the returned
467
- `skin` names the supplying table. `skin: 'all'` uses all activation lists and
468
- refuses differing definitions rather than guessing a merge order. Linked meshes
469
- retain the requested identity and borrow source topology/bindings; unweighted
470
- vertices use the queried slot's bone. Inactive bones retain core's zero matrix.
471
- The result and its arrays are detached and mutable. Missing slot/attachment/skin,
472
- non-mesh and ambiguous targets throw core's named `CoreInputError` messages;
473
- setup/non-finite refusals propagate unchanged. The read creates no player and
474
- changes no document, existing player, time, generation or rendered frame.
475
-
476
- **Setup-slider boundary (core 2.66.0):** selected meshes include setup-slider
477
- deform, exactly as core's setup pose does. Unselected meshes use stored geometry
478
- through the same setup constraint/reset-physics matrices, without switching the
479
- attachment or borrowing another selection's deform. Core measured **four of six
480
- nonselected official-runtime comparisons differing by up to
481
- `2.9999999999999982` world units (about 3.0)**: spine-core 4.3.13's
482
- `computeWorldVertices` borrows the selected slot's deform, including for a linked
483
- mesh with `timelines: false`. The other two nonselected calls and all three
484
- selected reads agree exactly. This wrapper passes core's output through; it does
485
- not correct or round it. Stored-only formula comparisons therefore have a
486
- different domain from selected geometry with setup deform. See
487
- [core AUTHORING §14, including S1538_09](https://github.com/rig-c/core/blob/v2.66.0/docs/AUTHORING.md#14-read-a-named-mesh-at-setup).
488
-
489
- ### Drawing some slots only
490
-
491
- `slots` is a draw filter — an allow list of slot names or a predicate — settable up
492
- front and on a live player. The pose is the whole rig's whatever the filter:
493
- bones, constraints, physics, `bone()`, `adjust`, events and tracks see no filter;
494
- only the draw does, in both modes, and `bounds()` reads the drawn slots. A
495
- clipping slot the filter leaves out does not clip. An allow list naming a slot
496
- the document does not have is refused by name.
497
-
498
- ```ts
499
- const hand = new RigPlayer(frontRoot, assets, { slots: ['hand_f', 'arm_f_lo'] });
500
- player.slots = (slot) => !slot.startsWith('weapon');
501
- ```
502
-
503
- Two players over one `ModelAssets` share the load but each poses the rig. To
504
- pose it once and draw it twice — a hand drawn again in front of another
505
- character — give a second renderer this player's pose under another filter:
506
-
507
- ```ts
508
- const front = new DomRenderer(frontRoot, assets.regionImages, assets.pageImages);
509
- // each frame, after player.frame(dt):
510
- front.render(player.posedFrame(['hand_f', 'arm_f_lo']));
511
- ```
512
-
513
- ### Attachment sets (`sets.json`)
514
-
515
- A build from `@rig-c/parts` (or authored alongside a rig) may ship a `sets.json`
516
- sidecar beside `skeleton.model.json` declaring named attachment sets — an
517
- expression, a viseme, a side view — under spec `"rig-parts-sets/1"` or
518
- `"spine-parts-sets/1"`:
519
-
520
- ```json
521
- {
522
- "spec": "rig-parts-sets/1",
523
- "sets": {
524
- "smile": {
525
- "slots": { "face": "face_smile", "prop": "prop_star" },
526
- "hides": []
527
- },
528
- "wink": {
529
- "slots": { "face": "face_wink" },
530
- "hides": ["prop"]
531
- }
532
- }
533
- }
534
- ```
535
-
536
- `loadModel` probes `sets.json` in the model document's directory by default
537
- (treating a 404 as "no sets"; pass `setsUrl: null` to skip the probe, or a URL
538
- string to require one at another path) and validates every slot and attachment
539
- name against the model document. `player.sets()` (and `assets.sets`) returns the
540
- declared `readonly RigSet[]` (`{ name, slots, hides }`), and `player.setSet(name)`
541
- switches the active set at runtime (#134):
542
-
543
- ```ts
544
- player.sets(); // [{ name: 'smile', slots: { ... }, hides: [] }, ...]
545
- player.setSet('smile'); // covered slots take the set's attachments; <slot>_still and hides are hidden
546
- player.set; // 'smile'
547
- player.setSet(null); // restore base / track attachments
548
- ```
549
-
550
- The active set is applied immediately to the held pose (including the setup pose
551
- before `play`) and after track evaluation on every subsequent `advance`, so a
552
- track key on a covered slot is overridden while the set is active and restored
553
- on the next frame when cleared with `setSet(null)`. Uncovered slots keep their
554
- track attachments, mesh deforms and sequence frames untouched; when no set is
555
- active (`null`), track evaluation runs with zero extra work and produces
556
- byte-identical poses. An unknown set name throws by name; on a build without
557
- `sets.json`, `sets()` is empty (`[]`) and `setSet` throws.
558
-
559
- **Several sets at once** (#168): sets that touch disjoint slots can be active
560
- together — a mouth viseme driven by audio beside an arm gesture driven by
561
- input, without building every combination as its own set:
562
-
563
- ```ts
564
- player.setSets(['viseme_o', 'gesture_chin']); // exactly these, together
565
- player.removeSet('viseme_o'); // the gesture stays
566
- player.addSet('viseme_a'); // beside the active ones; already active = no change
567
- player.activeSets; // ['viseme_a', 'gesture_chin'] in sets.json order
568
- player.set; // null: several are active (one active → its name)
569
- player.setSets([]); // restore base / track attachments
570
- ```
571
-
572
- A set *touches* every slot it covers, every slot it `hides` and the
573
- `<base>_still` companion it hides. Two active sets touching the same slot are
574
- refused by name — `sets "viseme_o" and "talk_point" both touch slot "mouth"` —
575
- and nothing changes; precedence is never guessed. Disjoint sets compose as the
576
- union of each alone, whatever the order they were named or added in, and every
577
- rule above (applied to the held pose, after track evaluation, uncovered slots
578
- untouched) holds for the composition. `setSet(name)` keeps meaning *exactly this
579
- one* (`setSets([name])`) and `setSet(null)` is `setSets([])`; an unknown or
580
- repeated name throws by name. `slotIdentity()` reports the list as `sets`
581
- beside `set`.
582
-
583
- **Drawn bones** (#170): a set from `@rig-c/parts` that replaces a limb
584
- (a *gesture* subset set) carries `drawn` — the world rotation, in degrees
585
- (CCW, Y up), each of its bones was painted at — and its attachments are
586
- placed in the frame of the bones posed there:
587
-
588
- ```json
589
- "wave": {
590
- "slots": { "arm_upper": "arm_upper_wave", "arm_lower": "arm_lower_wave" },
591
- "drawn": { "shoulder": 50, "elbow": 95 }
592
- }
593
- ```
594
-
595
- While such a set is active its bones are posed at those angles, the way
596
- `rig-parts check --set` poses them: in document order, each drawn bone's
597
- local rotation is the drawn angle less its world rotation at local 0 under
598
- its already-posed parents (no constraint applied), computed once when the set
599
- is activated. On a track that local rotation replaces the clip's rotation
600
- of that bone on every pose, before `adjust` (so `adjust` still has the last
601
- word); every other bone keeps playing. Activating or clearing a drawn set
602
- poses the held pose again at the same time. Two active sets drawing the same
603
- bone are refused by name, as two sets touching one slot are. A `drawn` naming
604
- a bone the document does not have is refused when `sets.json` is loaded.
605
-
606
- **Physics under a drawn bone** (#173). A physics chain hung from a bone a set
607
- re-poses — a sleeve under a raised arm — reads the jump to the drawn angle as
608
- motion: the constraint remembers where its bone was and pointed, so the
609
- switch kicks it (on the test rig `drape`, a 110° raise throws the sleeve's
610
- tip about 297 units off the arm in the switch itself, against an idle
611
- frame-to-frame range of 1.5). Every set call takes an option for the chains
612
- under the bones the switch re-poses:
613
-
614
- ```ts
615
- player.setSets(['raise'], { physics: 'carry' }); // also setSet, addSet, removeSet
616
- ```
617
-
618
- - `'carry'` moves each affected constraint's remembered state by the rigid
619
- turn of its deepest re-posed ancestor, so the chain keeps its pose relative
620
- to the arm and goes on swinging from there (the switch itself moves the
621
- sleeve 0 relative to the arm; gravity then re-settles it in the turned pose,
622
- which is physics and not the switch).
623
- - `'reset'` restarts each affected constraint at the new pose, as a physics
624
- `reset` key does: the chain snaps straight along its bones (17 units on the
625
- same raise) and settles from there.
626
- - Absent, nothing changes: today's behaviour, frame for frame. This stays the
627
- default.
628
-
629
- `'carry'` is recommended: on `drape` it moves the sleeve 0 relative to the arm
630
- at the switch and 0 at the deactivation, where `'reset'` moves it 17 units at
631
- the switch and 118 at the deactivation (no option: 297 and 298).
632
-
633
- A constraint is affected when its bone is a bone whose drawn rotation the call
634
- adds, removes or changes, or a descendant of one; every other chain is not
635
- touched. The option does nothing before `play` (the setup pose carries no
636
- physics state) and nothing when no physics hangs under a re-posed bone. Three limits:
637
-
638
- - Carry moves the positions and direction the constraint remembers; a
639
- constraint with `x`/`y` keeps its translation offsets along the world axes,
640
- unturned.
641
- - A drawn bone nested under a carried chain reads its new world from the
642
- chain's state before the carry.
643
- - A later `seek` replays from the start with the sets then active, as it
644
- always has, so it does not reproduce a carried switch.
645
-
646
- `sets.json` keys are read strictly. Besides `slots`, `hides` and `drawn`, a
647
- set may carry `drawnLength`, `registration` and `threshold`, which parts
648
- writes and which place nothing under `check --set`: play reads past them by
649
- name and gives them no meaning. Any other key, on a set or on the document
650
- (which takes `spec`, `sets` and `threshold`), is refused by name rather than
651
- ignored.
652
-
653
- To switch one driver's set while another stays — a new viseme while a gesture
654
- holds — name the new list in one call (`setSets(['viseme_a', 'gesture_chin'])`)
655
- or `removeSet` then `addSet`; the first re-poses once, the second twice.
656
-
657
- ### Slot identity snapshot (`slotIdentity()`)
658
-
659
- To inspect which attachment key, owning skin and visibility state each slot
660
- holds on the current frame — for a parts explorer or review overlay — without
661
- `seek`, `repose`, or restarting physics (#144):
662
-
663
- ```ts
664
- const snap = player.slotIdentity();
665
- // {
666
- // generation: number, // matches player.generation and player.posedFrame().generation
667
- // set: string | null, // the one active set (player.set); null when none or several
668
- // sets: readonly string[], // every active set (player.activeSets), sets.json order
669
- // skin: string | null, // active skin name (player.skin ?? null)
670
- // slots: readonly [ // every slot in posed draw order (z = 0 .. slots.length - 1)
671
- // {
672
- // slot: string,
673
- // z: number,
674
- // attachment: string | null, // resolved attachment key when drawn, null when not drawn
675
- // skin: string | null, // skin owning the resolved attachment ('default' or named skin), or null
676
- // reason: 'hidden' | 'no-attachment' | 'timeline-null' | 'set-hide' | null,
677
- // },
678
- // ...
679
- // ],
680
- // }
681
- ```
682
-
683
- Every slot in the posed draw order (`z = 0 .. slots.length - 1`, hidden and null
684
- ones included) appears in `snap.slots`:
685
-
686
- - Drawn slots (`reason: null`) appear in the same `z` order as
687
- `player.posedFrame().order`, naming the resolved attachment key (`attachment`)
688
- and the skin that owns it (`skin`).
689
- - Non-drawn slots report `attachment: null`, `skin: null`, and why they are
690
- absent from `posedFrame().order`:
691
- - `'set-hide'` — hidden by an active attachment set (`set.hides` or a
692
- `<base>_still` companion of a slot the set covers or hides).
693
- - `'timeline-null'` — cleared by an active attachment timeline key (`null`).
694
- - `'no-attachment'` — no attachment in the setup pose or under the active skin
695
- (or a non-drawable attachment kind such as clipping/boundingbox/path/point).
696
- - `'hidden'` — filtered out by `player.slots` or on an inactive bone under the
697
- current skin.
698
- - `snap.generation` ticks monotonically with `player.generation` and
699
- `player.posedFrame().generation` whenever the held pose, active set, active
700
- skin or slot filter changes. The returned snapshot is frozen and cached per
701
- generation, so reading it repeatedly on a held pose is allocation-free and
702
- mutates no pixel or physics state.
703
-
704
- ### Several tracks
705
-
706
- A base on track 0, a layer over it, a one-shot played once and mixed back out —
707
- rig-c's live tracks (`openTracks`), whose mixing is the Spine runtime's
708
- `AnimationState` held at tolerance 0:
709
-
710
- ```ts
711
- player.play('idle'); // track 0, as always
712
- player.play('smile', { track: 1, loop: false, mix: 0.2 }); // held at its end, mixed in over 0.2 s
713
- player.play('wave', { track: 2, loop: false, mix: 0.1 }); // a one-shot…
714
- player.queueClear(2, { mix: 0.1 }); // …mixed back out over its last 0.1 s
715
- player.queue('idle_b', { track: 0, mix: 0.3 }); // after the current idle cycle completes
716
- player.clear(1, { mix: 0.2 }); // the layer gone, the base showing through
717
- player.entry(2); // { animation, loop, trackTime, animationTime, duration, alpha, additive } | null
718
- player.play('blink', { track: 3, alpha: 0.6 }); // the same clip at a weight…
719
- player.setAlpha(3, 0.85); // …changed while it plays
720
- player.play('breath', { track: 4, additive: true }); // adds to what the tracks below posed
721
- ```
722
-
723
- **Weight and additive layers** (rig-c 2.39.0, the runtime's `TrackEntry.alpha` and
724
- `additive`): `alpha` (0..1, default 1) multiplies every timeline the entry applies;
725
- `setAlpha(track, alpha)` changes the current entry's weight from the next pose and
726
- re-poses the held pose. It does not touch events — an entry fires at any alpha, 0
727
- included, and one mixing out fires none — or attachments: the current entry keeps
728
- its attachments at any alpha, one mixing out keeps none. `additive: true`, fixed
729
- when the entry is set or queued, adds the entry's keyed bone and deform values to
730
- what the tracks below posed instead of replacing them, so a layer keying the same
731
- bone channels as an idle does not cover its motion; a rotation is added as it
732
- stands, with no shortest-angle wrap, and an additive entry mixing out fades with its
733
- alpha and is never held. Colour, attachment and event timelines apply as on a
734
- replacing entry. A weight below 1 or an additive entry composes, as a mix does.
735
-
736
- `play(name)` keeps meaning what it meant: a plain play on track 0 runs on rig-c's
737
- single live track, which poses everything the core poses. The first call that
738
- composes — a `mix`, a `track` above 0, a `queue`, a `clear` — moves the player onto
739
- the tracks, carrying the single track over by **replay** (its animation set, pose 0
740
- taken, stepped to its time in `seekStep` increments — one pose per step played, so
741
- on a stage that idled a while that is one long frame; the core offers no hand-over
742
- of a live track's state). To never pay it, stand the player on the tracks from the
743
- start: `new RigPlayer(root, assets, { tracks: true })`. Pick the single track for a
744
- rig whose animations key what the tracks do not mix (below); pick the tracks for
745
- layering. Either way, after the first `mix`, `track` above 0, `queue` or `clear`,
746
- `seek` and `skin` are refused by name, since the player cannot replay a composition
747
- to a time — step it forward instead.
748
- Every entry mixes from the setup pose each step, so `adjust` writes still last one
749
- pose. **What the tracks do not mix**, in rig-c's words at `play`/`queue`: an
750
- animation keying a constraint (ik, transform, path, physics, slider), a sequence,
751
- the draw order, or a path's deform is refused when it is set on the tracks — it
752
- plays on the single track only. `'event'` carries `track`; a one-shot that is
753
- mixing out fires nothing. `'complete'` is per track, the player's rule: a looping
754
- entry each cycle, a held one when it reaches its duration, and an entry that is
755
- replaced — by `play`, or by the queued entry that starts at its completion less the
756
- mix — completes no more than it fires.
757
-
758
- ### Adjusting bones
759
-
760
- A per-pose write to bone locals — a breath's scale, a look's rotation, a mouth
761
- driven while the body holds still:
762
-
763
- ```ts
764
- const player = new RigPlayer(root, assets, {
765
- adjust: (bones) => { bones.bone('mouth').scaleY = 1 + openness; },
766
- });
767
- player.adjust = null; // or set it later; setting it re-poses the held pose at once
768
- ```
769
-
770
- The function runs once per pose, after the animation's timelines and before the
771
- world transforms, constraints and physics — `rig-c/core`'s `openTrack({ adjust })`,
772
- which rig-c holds to the Spine runtime at tolerance 0. **A write lasts one
773
- pose.** The core poses every step from the setup pose, so a value you want held
774
- is written again each pose and nothing compounds; a 0.8 caller that multiplied a
775
- channel on a skeleton it kept between frames writes the value it wants, every
776
- pose. It runs on every pose the animation is applied to, the first one after
777
- `play` included, and `seek` and a `mode` change keep it.
778
-
779
- A paused player and a zero step re-pose nothing, so a change the function would
780
- make is not seen until the next step — unless you ask: setting `player.adjust`
781
- re-poses the held pose at the same track time, and `player.repose()` does the
782
- same for a function whose own state changed (the mouth above), without stepping,
783
- firing events or drawing. A non-finite value, a bone the document does not have,
784
- or an error the function throws refuses that step in the core's words, and the
785
- track is reopened by the next `play` or `seek`.
786
-
787
- The callback also receives a `BoneAdjustContext` from the core:
788
-
789
- ```ts
790
- player.adjust = (bones, ctx) => {
791
- bones.bone('head').rotation = 12 * (ctx.trackTime ?? 0);
792
- };
793
- ```
794
-
795
- `ctx.trackTime` is the **current track-0 entry's unwrapped seconds**, starting at
796
- 0 for each new entry. Loops do not wrap it, and a nonlooping entry keeps counting
797
- past its duration. `ctx.animation` names that entry. Without a track-0 entry both
798
- are null; an explicit empty entry has **numeric trackTime and null animation**.
799
- Upper tracks supply no fallback. `ctx.globalTime` is the core walk's running sum
800
- of steps in seconds, so it can differ from a new entry's time. Neither clock is
801
- wall time, wrapped animation time, nor the physics clock's small lead. Unary
802
- callbacks remain compatible. The hook runs once after composition, including a
803
- crossfade, rather than once for each contributing entry.
804
-
805
- Seek evaluates its replay times (`seekStep` plus any final remainder), independent
806
- of the currently displayed player time. Exact cache hits may reuse a pose without
807
- calling the hook. A continuous walk and seek are bit-identical for an **unchanged
808
- patch and matched history**: the same animation, environment, adjustment, reset
809
- and step partition. Closed-over callback state still needs `repose()` or the
810
- adjust setter to invalidate cached poses. Pause, zero advance and renderer mode
811
- changes add no evaluations; explicit `repose()` keeps the same context time.
812
-
813
- ### Live physics parameters
814
-
815
- ```ts
816
- player.play('hang');
817
- player.setPhysicsParameters('link_1_phys', { damping: 0.4, wind: 8 });
818
- player.readPhysicsParameters('link_1_phys'); // { override, effective }
819
- player.clearPhysicsParameters('link_1_phys');
820
- ```
821
-
822
- `setPhysicsParameters(name, patch)` forwards a per-player patch of `strength`,
823
- `damping`, `inertia`, `wind`, `gravity`, and `mix` to the core. Omitted fields keep
824
- their standing override. Values are runtime scalars with no clamping, conversion,
825
- new clock or solver. The core applies them **after authored/keyed values and
826
- sliders, before the integrator**. Unknown constraints, a non-physics constraint,
827
- unknown fields and non-finite/non-number values are refused atomically by name.
828
- These methods require a live track: before `play`/`queue`, set, read and clear
829
- refuse with `no live track` instead of creating one. The model and other players
830
- sharing its assets are untouched.
831
-
832
- `PhysicsParameterRead` contains detached readonly snapshots: `override` is the
833
- pending patch or null; `effective` is the six values from the last active
834
- evaluation, or null until evaluated. An inactive constraint can retain its last
835
- active values within that live core instance. Setting or clearing changes no
836
- pose, time or physics state, and **does not evaluate**: effective keeps the last
837
- values until a normal evaluation (or an explicitly requested `repose()`). Clear
838
- removes the patch; authored keys resume at the next evaluation. It preserves
839
- integrated history and does not instantly undo the motion caused by old values.
840
-
841
- The player retains the **current standing patch** across new play, seek, skin
842
- replay and promotion to composed tracks. Like `environment()`, it records no
843
- historical changes. **After a mid-run patch change, a backward seek recomputes the
844
- past interval with the patch standing now**, under the current environment; it
845
- does not reproduce the old trajectory across that change. New single tracks and
846
- new composed tracks first evaluate their ordinary authored first pose, then
847
- receive the standing patch as pending, with no extra adjustment callback. At
848
- seek time 0, effective can therefore still be the initial authored value while
849
- override already reports the patch. The next normal core evaluation uses it.
850
- Changing entries on existing composed tracks follows that core instance's
851
- lifetime. Existing seek/skin restrictions after composition remain unchanged.
852
-
853
- Set/clear invalidate seek caches without re-posing. Seek snapshots retain the
854
- public effective read alongside their held pose, so backward and exact cache
855
- hits report that pose's evaluation rather than the frontier's latest evaluation.
856
- Readback itself never advances, evaluates or resets physics.
857
-
858
- ### One load, several players
859
-
860
- One `loadModel` backs any number of players: the regions are cut once, and
861
- every player draws from the one `regionImages` map. A player owns none of it —
862
- disposing a player leaves the bitmaps alive for the others — so there is exactly
863
- one `assets.dispose()`, after the last player is gone:
864
-
865
- ```ts
866
- const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
867
- const a = new RigPlayer(rootA, assets);
868
- const b = new RigPlayer(rootB, assets, { skin: 'winter' });
869
-
870
- a.dispose();
871
- b.dispose();
872
- assets.dispose();
873
- ```
874
-
875
- A document is one rig. Two rigs whose builds share page images load as two
876
- documents; the images are fetched by URL, so a page both name at the same URL
877
- is loaded once by the browser — but each load cuts its own regions and owns its
878
- own blob URLs.
879
-
880
- ### Loading it yourself
881
-
882
- `loadModel` is a convenience over two calls the package exports: read the
883
- document, load its pages, and cut the regions with `unpackRegions`, which takes
884
- the document's `pages` section — `{ name, width, height, pma, regions: [{ name,
885
- x, y, width, height, degrees }] }` per page — and a map of page images keyed by
886
- those names. Use it when the page images do not come from a URL (`resolvePage`
887
- on `loadModel` covers the ones that come from another URL), and pair it with
888
- `revokeRegions` on unload.
889
-
890
- A region the packer stored turned (`degrees: 90`) is cut upright again, and
891
- which way round that is comes from the UVs a runtime assigns such a region,
892
- not from the cut: its corners carry `(u2, v2), (u, v2), (u, v), (u2, v)` in the
893
- order BL, UL, UR, BR, so the artwork's top-left corner sits at the packed rect's
894
- bottom-left and unpacking it is a clockwise turn. Every release up to 0.7.0
895
- turned it the other way and drew rotated rigid parts 180° round (#49).
896
-
897
- `unpackRegions` mints one blob URL per region; `revokeRegions` is its
898
- counterpart. It only frees URLs `unpackRegions` created, so a map you built
899
- yourself and page images reused by the whole-page pass-through survive it — and
900
- calling it twice is a no-op. Load once for the page's lifetime and you can
901
- ignore it; load and unload repeatedly without it and you leak an atlas per
902
- cycle.
903
-
904
- ### Loading for the canvas mode only, and `data:` regions
905
-
906
- The region cut makes the rigid tier's bitmaps, which only the DOM mode draws.
907
- `loadModel({ regions })` says what to do about it:
908
-
909
- - `'blob'` (default) — `blob:` URLs the load owns; `assets.dispose()` frees them.
910
- - `'data'` — `data:` URLs instead, for a content policy or a snapshot that does
911
- not carry `blob:`. The same PNG bytes, inline; nothing is minted, so there is
912
- nothing to free, and the base64 lives as long as the map does.
913
- - `'none'` — no cut: the pages load, `assets.regionImages` is empty, and the
914
- load is for the canvas mode. A DOM-mode player opened on it — `new RigPlayer`
915
- without `mode: 'canvas'`, or `player.mode = 'dom'` — is refused by name, and
916
- a refused mode change leaves the player as it was.
917
-
918
- `assets.regions` records the choice. `unpackRegions` takes the same thing as
919
- `{ encoding: 'blob' | 'data' }`.
920
-
921
- ### Pages that ship at another resolution
922
-
923
- A page image may ship at a resolution its document does not declare — a
924
- half-resolution texture build, or an @2x variant, with the page's `width` and
925
- `height` left as the build wrote them. Nothing here needs a flag for that. A
926
- region's bounds are read **relative to the declared page size** and scaled onto
927
- the image's natural size, which is how the page UVs the mesh tier samples with
928
- are derived too (`u = x / page.width`), so both tiers land on the same pixels at
929
- any resolution.
930
-
931
- Each unpacked bitmap comes out at the **native resolution of the pixels it was
932
- cut from** — half-resolution pages cost a quarter of the cut pixels, and
933
- nothing is upscaled back — while `RegionImage.width`/`height` stay in **atlas
934
- units**. Those two numbers are the `<img>` layout box and the denominator of
935
- its CSS matrix, so the rig poses identically and the browser scales the
936
- smaller bitmap into the same box, the way a GPU samples a smaller texture
937
- through the same UVs. Ship one build and swap the images per device if you
938
- like (`resolvePage`).
939
-
940
- ### Premultiplied pages (`pma`)
941
-
942
- A page's `pma` flag in the document's `pages` section — the build copies it from
943
- the atlas's `pma: true` line — says its RGB is already multiplied by its alpha,
944
- and nothing is asked of you: each tier is handed the page in the convention it
945
- actually consumes. The `webgl` mesh backend uploads the texels unconverted (its
946
- blend already expects premultiplied source — lossless). The DOM and canvas2d
947
- tiers cannot: an `<img>` and `drawImage` composite straight alpha by definition,
948
- so they read a straight-alpha derivation of the page —
949
- `rgb = round(rgb * 255 / a)`, computed once and shared by the region cuts and
950
- the mesh raster. Without it every semi-transparent texel is multiplied by its
951
- alpha a second time and draws darker than it was authored: soft edges, soft
952
- shadows, glows.
953
-
954
- What it costs: **one page-sized canvas per premultiplied page image**
955
- (width × height × 4 bytes — 1 MiB for a 1024×256 page, 16 MiB for a 2048×2048
956
- one), alive as long as the page image is and released with it; the cache is
957
- weak, so there is nothing to free by hand. A page without the flag derives
958
- nothing. The division is 8-bit and starts from a canvas read, which has itself
959
- quantized a premultiplied texel, so a very transparent texel can land a few
960
- levels off — exact at alpha 0 and 255, within one level of 255 above alpha 128,
961
- and a little more below that, by an amount that belongs to the browser's canvas
962
- rather than to this package. It is invisible wherever the texel is: a texel at
963
- alpha 11 is ~4% opaque. The `webgl` backend, having no such step, is exact.
964
-
965
- One consequence worth knowing if your build is one part per page: a whole-page
966
- region on a premultiplied page is **cut** rather than handed through, because
967
- the page's own URL holds premultiplied pixels (see below).
968
-
969
- ### What unloading frees
970
-
971
- `revokeRegions()` — and `assets.dispose()`, which calls it — frees the blob URLs
972
- `unpackRegions` minted, and nothing else (a `regions: 'data'` or `'none'` load
973
- minted none, so it frees nothing). The page images are held by
974
- `assets.pageImages` and by every player drawing from them, so a page is
975
- released once you drop the assets and every player is disposed. With the
976
- `webgl` mesh backend the shared blitter also uploads each page image on first
977
- use, but that GL texture is not the module's forever: every renderer drawing
978
- the page holds a reference to it, `player.dispose()` hands those back, and the
979
- texture is deleted once the last renderer using that page is disposed — a later
980
- frame that needs the page uploads it again. The cache is keyed weakly by the
981
- page image, so a renderer dropped without `dispose()` costs GPU memory until
982
- the context is lost, but never keeps the image itself alive.
983
-
984
- A rig with meshes samples its page bitmaps every frame, so their decoded form
985
- stays in use: a floor on the order of `page width × page height × 4` bytes per
986
- page. A rigid-only rig draws nothing from the pages after `unpackRegions`, and
987
- what a merely reachable, undrawn image costs is up to the browser, not
988
- measurable from script.
989
-
990
- Unload in this order:
991
-
992
- 1. `player.dispose()`, for every player using the assets.
993
- 2. `assets.dispose()` (or `revokeRegions(regionImages)`).
994
- 3. Drop your references to the assets.
995
-
996
- ### One part per page
997
-
998
- `rigc build` writes one page per part by default — each part PNG is its own
999
- page, its one region covering it — and `rigc build --pack` arranges the parts
1000
- onto shared pages instead. Both play here, unchanged:
1001
-
1002
- - Regions that cover their whole page are handed straight through by
1003
- `unpackRegions` instead of being cut and re-encoded — at any image resolution,
1004
- since covering the page is a statement about the declared size. Load cost for
1005
- this shape is just the image loads. The exception is a page marked `pma`: its
1006
- URL holds premultiplied pixels, which an `<img>` would composite as straight
1007
- alpha, so those regions are cut from the straight-alpha derivation like any
1008
- other (and the blob is revoked by `revokeRegions` like any other).
1009
- - A packed build cuts every region from its page once, at load, and its meshes
1010
- sample the shared page through page UVs.
1011
-
1012
- ### Runtime knobs and what they cost
1013
-
1014
- - `player.mode` / `new RigPlayer(root, assets, { mode })` — `'dom'` (default)
1015
- or `'canvas'`. Changing it disposes the current renderer (elements removed,
1016
- GPU pages handed back) and creates the other over the same root and assets;
1017
- `pixelRatio` and `clipping` carry over and the held pose is redrawn at once.
1018
- `player.renderer` is then a `CanvasRenderer`, whose knobs are `pixelRatio`,
1019
- `syncPixelRatio()`, `clipping`, `viewport` and `backend`. What each mode costs, as counters:
1020
- - **dom** — one element per drawn slot, one style write per changed slot,
1021
- and the mesh tier's rasters (the knobs below).
1022
- - **canvas** — one canvas element per player and, per frame that changed,
1023
- one pass over every slot: `drawCalls` (one per run of one page under one
1024
- blend), `blendFlushes` (runs a blend change ended), `trianglesDrawn`,
1025
- `blits` (one), or `framesReused` (one) when the record did not change and
1026
- nothing is drawn. It never creates a WebGL context of its own, so any number
1027
- of players stays under the browser's ~16-context cap (20 players, one
1028
- context — `tests/canvas-mode.spec.ts`). The canvas covers a rect of the
1029
- root's coordinates that grows (never shrinks) to what the frames draw —
1030
- `canvasReallocCount` settles at zero — or a fixed `renderer.viewport` (world
1031
- units, Y up) when you set one. Blends are composed inside that canvas, which
1032
- starts transparent: an additive slot adds onto the slots under it and is
1033
- then composited onto the page normally, where the DOM tier's
1034
- `plus-lighter` adds onto the page itself. Where WebGL is unavailable or the
1035
- context is lost, the frame is drawn with canvas2d on the same canvas so it
1036
- never draws nothing — **alpha tint only, RGB and dark tint unsupported
1037
- there** — and that fallback is **not the measured path** (`backendActive`
1038
- says which ran).
1039
-
1040
- - `renderer.pixelRatio` — mesh-canvas backing pixels per CSS pixel (defaults to
1041
- `devicePixelRatio`; if you scale the root element, fold that scale in so the
1042
- raster matches the screen: `devicePixelRatio * rootScale`). **Writing it
1043
- reallocates every mesh canvas backing store on the next frame**, and each
1044
- reallocation recreates a GPU surface — the cost that took real Safari to ~3 fps
1045
- when it happened per frame. Each canvas is then sized from what the new ratio
1046
- needs, in both directions: lowering the ratio gives the backing pixels back
1047
- (the backing is grow-only *within* a ratio, not across a change of one). Set
1048
- it when a layout settles, never per frame:
1049
- debounce resize drags and quantize the value instead of tracking it
1050
- continuously. `renderer.canvasReallocCount` is the check — it must fall back to
1051
- zero within a second or two.
1052
- - `renderer.syncPixelRatio()` — measures the root's effective on-screen scale
1053
- and sets `pixelRatio` to `devicePixelRatio × scale`, returning the ratio now
1054
- in effect. It appends a hidden 100 px box to the root, reads its box once and
1055
- removes it (the root itself is usually 0×0), so it is **one forced layout per
1056
- call** — which is exactly why the renderer never calls it for you: there is no
1057
- per-frame layout read anywhere in this library. Call it when a zoom or a
1058
- layout *settles* (gesture end, debounced resize), not during the drag. A
1059
- change under 0.1% is ignored, so layout jitter cannot churn GPU surfaces, and
1060
- a root that is not laid out (a `display: none` ancestor) leaves the ratio
1061
- alone. Under an ancestor rotation the measured box is inflated and the ratio
1062
- errs high — oversampling costs pixels, undersampling costs picture.
1063
- - `renderer.meshBackingPixels` — allocated mesh-canvas backing pixels
1064
- (Σ width × height), computed on demand. This is what `pixelRatio` moves
1065
- quadratically, and the cheapest way to see an oversampling stage; the demo
1066
- prints it in the stats line as `backing N Mpx`.
1067
- - `renderer.meshBackend` — `'webgl'` (default since 1.9) or `'canvas2d'`; same
1068
- output, and on macOS Safari webgl holds 58–60 fps where canvas2d reads 2–26
1069
- (see Measured above). Falls back to canvas2d automatically when WebGL is
1070
- unavailable or the shared context is lost; `meshBackendActive` says which drew. Switching re-rasters every
1071
- mesh once (no reallocation), so it is fine to expose as a user setting.
1072
- - `renderer.triangleExpand` — how far (canvas px) each triangle edge's clip is
1073
- moved outward to close antialiased mesh seams (default 0.5). Also re-rasters every mesh once when changed.
1074
- - `renderer.meshSourceRect` — what a canvas2d mesh triangle is drawn from:
1075
- `'page'` (the whole page) or `'triangle'` (a sub-rect covering its clip). Both
1076
- draw the same picture; the default is `'triangle'` on Linux WebKit, which
1077
- garbles whole-page draws, and `'page'` elsewhere, because macOS Safari ran the
1078
- sub-rect 12–15% slower (#25). Also on `CanvasRenderer` for its canvas2d
1079
- fallback. Re-rasters every mesh once when changed.
1080
- - `renderer.clipping` — apply the clips the frame carries (default `true`).
1081
- Which slots a clip covers is decided where the frame is made — the player's
1082
- adapter walks the draw order the way the runtime's draw loop does (one clip in
1083
- force at a time, a second one met while one is in force ignored, the end slot
1084
- inside its own clip). What the renderer writes is one CSS `clip-path` per
1085
- element a clip covers, in that
1086
- element's own local frame; nothing reaches the raster backends, and a clip is
1087
- not part of the mesh dirty signature, so a mesh that held still keeps reusing
1088
- its raster under a moving clip. **Writes happen on change only**: each
1089
- clip-path is cached exactly as `transform` is, coordinates are quantized to
1090
- 1/1000 of a local unit so float jitter cannot defeat that cache, and a static
1091
- polygon over a static pose therefore costs **zero style writes per frame after
1092
- the first** — `renderer.clipWriteCount` is the check, alongside
1093
- `clipCount` (clips applied) and `clipSkipCount` (clips in the frame not
1094
- applied: switched off, or a degenerate polygon; a clip the runtime itself
1095
- ignores never reaches the frame). **The whole-skeleton fast path**: when one
1096
- clip covers every slot of the frame, one `clip-path` goes on the root instead
1097
- of one per element. The root is *your* element, so its inline `clip-path` is **borrowed,
1098
- not taken** — saved on the first write and put back verbatim when the clip
1099
- stops covering the frame, when `clipping` goes `false`, and by `dispose()`.
1100
- Per-element clip-paths and the root clip-path are never both in force for the
1101
- same clip. Two things to know about that path: the polygon is written in the
1102
- root's **border box** frame, which is where absolutely-positioned slot elements
1103
- start too *unless the root has a CSS border* (a border would offset the
1104
- whole-skeleton clip by its width — keep borders off the render root, which is
1105
- the normal shape for a 0×0 origin element); and an inverse clip never takes it,
1106
- because its CSS form needs an outer ring around a box and the root has none.
1107
- One further consequence of that path: a `clip-path` other than `none` makes an
1108
- element a stacking context (CSS Masking), so a whole-skeleton clip isolates
1109
- `mix-blend-mode` slots from backdrops *outside* the root — which a root
1110
- carrying a `transform` (the usual pan/zoom stage) already does. Per-element
1111
- clips do not change blending, since a blended slot is its own stacking context
1112
- either way. Setting `clipping = false` removes every clip-path this renderer
1113
- wrote.
1114
-
1115
- **A zoomable stage.** The mesh tier rasters at `world × pixelRatio` in the
1116
- root's own coordinates, and it cannot see a CSS transform above the root — so
1117
- the most natural pan/zoom stage, `transform: scale(zoom)` on an ancestor, makes
1118
- it oversample by `1/zoom²` in backing pixels until the zoom is folded in. The
1119
- picture stays correct throughout, which is what makes this easy to ship: only
1120
- the allocation and the frame rate move. Call `syncPixelRatio()` when the zoom
1121
- settles, or set `pixelRatio = devicePixelRatio * zoom` yourself. Measured
1122
- on-device (Chromium, dpr 2, stage under `scale(0.25)`, ~2.2 M CSS px on screen):
1123
-
1124
- | scene | `pixelRatio` | `meshBackingPixels` | fps |
1125
- | --- | --- | --- | --- |
1126
- | 57 meshes | `devicePixelRatio` (2) | 139.1 Mpx | 14–21 |
1127
- | 57 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 61 |
1128
- | 92 meshes | `devicePixelRatio` (2) | 141.1 Mpx | 36–39 |
1129
- | 92 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 60–61 |
1130
-
1131
- ## Coming from 1.8: meshes draw through WebGL by default
1132
-
1133
- From 1.9 the element mode rasterizes its meshes through the one shared WebGL
1134
- context unless told otherwise (`DomRenderer.meshBackend` defaults to `'webgl'`,
1135
- #109). The picture is the same within the parity suite's bounds, the API is
1136
- unchanged, and canvas2d is still drawn wherever WebGL is unavailable or the
1137
- context is lost. What a caller can notice:
1138
-
1139
- - The page holds one more WebGL context (one for every mesh of every player, not
1140
- one per player). A page already near the browser's cap of about 16 live
1141
- contexts can lose its oldest one when this one is created.
1142
- - `meshBackendActive` reads `'webgl'` where it read `'canvas2d'`.
1143
- - Frame cost moves from the page to the GPU blit (see *Measured*).
1144
-
1145
- To keep 1.8's raster, set it back, up front or on a live player:
1146
-
1147
- ```ts
1148
- const player = new RigPlayer(root, assets, { meshBackend: 'canvas2d' });
1149
- // or, on a renderer you hold:
1150
- renderer.meshBackend = 'canvas2d';
1151
- ```
1152
-
1153
- The canvas mode was already webgl by default and does not change.
1154
-
1155
- ## Coming from spine-html 0.8
1156
-
1157
- This is not a drop-in upgrade. 0.8 drew a Spine export (`.json` or `.skel`, plus its
1158
- `.atlas`) that `@esotericsoftware/spine-core` parsed and posed: the caller built the
1159
- `Skeleton` and the `AnimationState`, advanced them each frame, and handed the skeleton to
1160
- `renderer.render(skeleton)`. 1.0 loads one file, the `skeleton.model.json` that
1161
- `rigc build` writes, poses it through `rig-c/core` (bundled into the package, with no peer
1162
- dependency), and `RigPlayer` owns the track, so the caller's frame is `player.frame(dt)`.
1163
- A Spine export is refused by name before anything is fetched: the data has to be a rigc
1164
- build, and this package declares no loader for anything else.
1165
-
1166
- What did not move: a positioned root element is still the rig's origin (Y up) with its
1167
- layout and scaling left to the caller, the DOM tier keeps its knobs under a new class
1168
- name, and unloading is still elements first, bitmaps second.
1169
-
1170
- ### 0.8 → 1.0
1171
-
1172
- | 0.8.0 (`spine-html`) | 1.x (`@rig-c/play`, formerly `rig-play`) | Note |
1173
- | --- | --- | --- |
1174
- | `npm i spine-html @esotericsoftware/spine-core` | `npm i @rig-c/play`, and `npm i -D rig-c` for the build tool | 0.8 declared spine-core as a peer dependency (`>=4.0.0 <4.4.0`). 1.0.0 declares no dependencies and no peer dependencies. |
1175
- | `loadSkeletonAssets(options)` | `loadModel(options)` | One URL instead of two. The `.atlas` and `skeleton.json` a build writes beside the document are not fetched. |
1176
- | `LoadSkeletonAssetsOptions.atlasUrl`, `skeletonUrl` | `LoadModelOptions.modelUrl` | |
1177
- | `resolvePage(pageName, atlasUrl)` | `resolvePage(pageName, modelUrl)` | The default now resolves a page name against the model URL's directory. |
1178
- | `crossOrigin`, `fetch` | `crossOrigin`, `fetch` | Unchanged. |
1179
- | `scale` (`LoadSkeletonAssetsOptions`, `LoadSkeletonJsonOptions`) | none | See below. |
1180
- | `SkeletonAssets` | `ModelAssets` | |
1181
- | `SkeletonAssets.data` | `ModelAssets.document`, `animations`, `skins`, `stage`, `spec` | `document` is opaque (`ModelDocument`); the player poses it. What a caller used to read off the skeleton data is stated as `animations` (name and duration), `skins` (names) and `stage` (the stage the build declared, or null). |
1182
- | `SkeletonAssets.atlas` | `ModelAssets.pageImages` | The page images, keyed by page name. There is no atlas object; where each region sits is in the document's `pages` section. |
1183
- | `SkeletonAssets.regionImages` | `ModelAssets.regionImages` | Same `Map<string, RegionImage>`. |
1184
- | `SkeletonAssets.dispose()`, `AtlasAssets.dispose()` | `ModelAssets.dispose()` | Same contract: frees the blob URLs the load minted, idempotent, called after the players are disposed. |
1185
- | `loadAtlasAssets` + `loadSkeletonJson` (one atlas, several skeletons) | none | One `loadModel` backs any number of `RigPlayer`s of the same rig. Two rigs are two loads, and each cuts its own regions. See below. |
1186
- | `loadSkeletonBinary` from `spine-html/binary` | none | 1.0.0 has one entry point. A `.skel` URL is refused by name. |
1187
- | `unpackRegions(atlas, pageImages)` | `unpackRegions(pages, pageImages)` | The first argument is the document's `pages` section (`readonly PageLayout[]`) instead of a `TextureAtlas`. |
1188
- | `revokeRegions(images)` | `revokeRegions(images)` | Unchanged. |
1189
- | `RegionImage` | `RegionImage` | Unchanged (`url`, `width`, `height`, in atlas units). |
1190
- | `DomTexture` | none | Not exported; there is no atlas page to attach a texture to. |
1191
- | `new Skeleton(assets.data)` and `new AnimationState(new AnimationStateData(assets.data))` | `new RigPlayer(root, assets, options?)` | The player holds the pose and the track. |
1192
- | `state.setAnimation(0, 'walk', true)` | `player.play('walk', { loop: true })` | `loop` defaults to true. `play` opens the animation at its first pose with the physics reset there. `player.current` names what is playing. |
1193
- | `skeleton.findSlot('x').attachment = null` to hide a slot, or a second skeleton with slots emptied | `RigPlayerOptions.slots`, `player.slots`, `player.posedFrame(slots)` | A draw filter since 1.6.0 — the pose is the whole rig's; a clipping slot left out does not clip. See *Drawing some slots only*. |
1194
- | `state.setAnimation(1, 'smile', false)` with a mix, `addAnimation`, `setEmptyAnimation`, `addEmptyAnimation`, `getCurrent(track)`, `entry.alpha`, `entry.additive` | `player.play(name, { track, loop, mix, alpha, additive })`, `player.queue(name, { track, loop, mix, delay, alpha, additive })`, `player.clear(track, { mix })`, `player.queueClear(track, { mix, delay })`, `player.setAlpha(track, alpha)`, `player.entry(track)` | Since 1.5.0, rig-c's live tracks: the runtime's mixing at tolerance 0. Constraint, sequence, draw-order and path-deform timelines are not mixed and are refused on the tracks. See *Several tracks*. |
1195
- | `state.update(delta)`, `state.apply(skeleton)`, `skeleton.update(delta)`, `skeleton.updateWorldTransform(Physics.update)`, `renderer.render(skeleton)` | `player.frame(dt)` | Or `player.advance(dt)` then `player.draw()`, to time the two apart. |
1196
- | time scale (the caller's own, on the spine-core side) | `player.timeScale` | Multiplies every step. 0 holds the pose without re-posing it; a negative value is not stepped. |
1197
- | holding a pose (the caller stopped advancing) | `player.pause()`, `player.resume()` | `frame` keeps drawing the held pose. |
1198
- | moving to a time (the caller's own, on the spine-core side) | `player.seek(time)`, `RigPlayerOptions.seekStep`, `player.time` | A seek reopens the animation and steps forward in `seekStep` increments (1/60 s by default). Events and completes crossed on the way are not fired. |
1199
- | events (the caller's own listener on the spine-core side) | `player.on('event', listener)`, `player.on('complete', listener)` | `'event'` hands a `RigEvent` (`name`, `time`, `int`, `float`, `string`, `track`); `'complete'` hands `{ animation, track }`. `on` returns a function that removes the listener. |
1200
- | skin (set by the caller on its `Skeleton`) | `RigPlayerOptions.skin`, `player.skin` | A constructor option, and settable on a live player since 1.4.0: the pose re-resolves under the new skin and the track keeps its animation, loop and time (reopened and stepped there, as `seek` does). The names are in `ModelAssets.skins`. Without it the rig is posed with no skin set. |
1201
- | `SpineHtmlRenderer` | `DomRenderer`, reached as `player.renderer` | In `mode: 'dom'`, the default. `mode: 'canvas'` is new and makes `player.renderer` a `CanvasRenderer`. |
1202
- | `new SpineHtmlRenderer(root, regionImages)` | `new DomRenderer(root, regionImages, pages?)` | Only for drawing your own frames; `RigPlayer` constructs its renderer. `pages` is the page-image map the mesh tier samples (`ModelAssets.pageImages`). A hand-built `regionImages` map is still accepted. |
1203
- | `renderer.render(skeleton)` | `renderer.render(frame)` | Takes a `PosedFrame`, not a skeleton. `player.posedFrame()` returns the one the player would draw. |
1204
- | `renderer.pixelRatio` | `player.renderer.pixelRatio` | On both renderers. Carried over when `player.mode` changes. |
1205
- | `renderer.syncPixelRatio()` | `player.renderer.syncPixelRatio()` | On both renderers, the same meaning and the same deadband (since 1.3.0; before it, DOM mode only). |
1206
- | `renderer.meshBackingPixels` | `DomRenderer.meshBackingPixels` | DOM mode only. |
1207
- | `renderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | `DomRenderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | Same values; the default is `'webgl'` since 1.9 (`'canvas2d'` before). Also settable up front as `RigPlayerOptions.meshBackend`. |
1208
- | `renderer.triangleExpand` | `DomRenderer.triangleExpand` | Same default (0.5). |
1209
- | `renderer.clipping` | `player.renderer.clipping` | On both renderers. Carried over when `player.mode` changes. |
1210
- | `clipCount`, `clipSkipCount`, `clipWriteCount` | same names on `DomRenderer` | `clipSkipCount` counts less than it did: a second clip met while one is in force, and a clip on an inactive bone, no longer reach the frame, so they are not counted. |
1211
- | `meshCount`, `meshReuseCount`, `canvasReallocCount`, `triangleCount` | same names on `DomRenderer` | |
1212
- | tint: skeleton × slot × attachment colour | `player.color` × slot × attachment | `player.color` is `[r, g, b, a]` and takes the place of the skeleton's colour. The product arrives as `tint` on each `PosedSlot`. |
1213
- | dark (two-colour) tint: unsupported | `DomRenderer`: unsupported. `CanvasRenderer`: drawn | Carried as `dark` on a `PosedSlot`. The canvas mode's canvas2d fallback draws alpha only. |
1214
- | `renderer.dispose()` | `player.dispose()` | Then `assets.dispose()`, as before. `DomRenderer.dispose()` and `CanvasRenderer.dispose()` exist for a renderer you constructed yourself. |
1215
- | `skeleton.findBone('head').worldX`, `.worldY`, `.a` … `.d` | `player.bone('head')` | `{ x, y, a, b, c, d, rotationX, rotationY, scaleX, scaleY, active }`, the core's world transform for the pose the player holds (the setup pose before `play`). An unknown name is refused by name. Since 1.1.0. |
1216
- | `bone.scaleY = …` on a `Skeleton` between `state.apply` and `updateWorldTransform` | `RigPlayerOptions.adjust`, `player.adjust`, `player.repose()` | A function `(bones, ctx)` called once per pose with the pose's bone locals and current-entry context (unary callbacks remain valid) (`bones.bone('mouth').scaleY = v`). A write lasts one pose: the core poses every step from the setup pose, so write the value you want, every pose — nothing compounds. See *Adjusting bones*. Since 1.2.0. |
1217
- | `skeleton.getBounds(offset, size)` | `player.bounds()` | `{ x, y, width, height }` or null, world units, Y up: the box of every drawn region's corners and mesh's vertices, a slot under a clip counted inside the clip's own box. `ModelAssets.stage` remains the declared framing, not this box. Since 1.1.0. |
1218
- | live named physics constraint tuning | `player.setPhysicsParameters(name, patch)`, `readPhysicsParameters(name)`, `clearPhysicsParameters(name)` | Six scalar fields, per-player pending patch plus last evaluated effective values. Clear resumes authored values without resetting history. Replay uses the current standing patch, with no historical journal. See *Live physics parameters*. |
1219
- | named setup mesh geometry | `readSetupMesh(assets, { slot, attachment, skin? })` | Detached full-double world x/y (Y up), triangles and hull for any named mesh/linked mesh, including non-setup selections; no player/time mutation. Selected geometry includes setup-slider deform; unselected geometry is stored geometry through unchanged setup matrices. See the measured official-runtime distinction under *Reading the pose*. |
1220
-
1221
- ### No counterpart in 1.0.0
1222
-
1223
- Things a 0.8 caller could do because it held the spine-core objects, or through a 0.8
1224
- export, for which 1.0.0 declares nothing:
1225
-
1226
- - **A Spine export as input.** `loadSkeletonJson`, `loadSkeletonBinary`, `loadAtlasAssets`,
1227
- the `spine-html/binary` entry and `DomTexture` have no counterpart, and neither does
1228
- choosing which spine-core generation reads the data. `loadModel` reads a compiled model
1229
- document and nothing else.
1230
- - **Scaling the skeleton as it is read.** `LoadModelOptions` declares no `scale`. Scaling
1231
- the root element is still the caller's.
1232
- - **One unpacked atlas shared by different skeletons.** A document is one rig, and each
1233
- `loadModel` cuts its own regions and owns its own blob URLs. Sharing is per rig: one
1234
- load, several players.
1235
- - **Changing one slot from code** (its attachment, its colour). `RigPlayer` declares only
1236
- the whole-rig `color`. The frame record is a plain object, so a caller that draws its
1237
- own frames through `DomRenderer.render` or `CanvasRenderer.render` decides what is in
1238
- it.
1239
- - **Choosing how physics is stepped each frame.** 0.8's loop passed `Physics.update`
1240
- itself; 1.0.0 declares no physics option. `play` and `seek` reset physics at the
1241
- animation's first pose and every step advances it.
1242
- - **Track callbacks other than the two declared.** `on` accepts `'event'` and
1243
- `'complete'`.
1244
-
1245
- Knobs on `DomRenderer` that `CanvasRenderer` does not declare:
1246
-
1247
- - `meshBackend` and `meshBackendActive`. The canvas mode has its own pair, `backend` and
1248
- `backendActive` (type `CanvasBackend`, `'webgl'` by default).
1249
- - `triangleExpand`, `meshBackingPixels`, `clipWriteCount`, `meshCount`, `meshReuseCount`,
1250
- `triangleCount`. The canvas mode's counters are `drawCalls`, `blendFlushes`,
1251
- `trianglesDrawn`, `framesReused` and `blits`.
1252
-
1253
- Both declare `pixelRatio`, `clipping`, `clipCount`, `clipSkipCount`, `canvasReallocCount`,
1254
- `render` and `dispose`. Only `pixelRatio` and `clipping` carry over when `player.mode`
1255
- changes.
1256
-
1257
- ### Before and after
1258
-
1259
- 0.8.0:
1260
-
1261
- ```ts
1262
- import { Skeleton, AnimationState, AnimationStateData, Physics } from '@esotericsoftware/spine-core';
1263
- import { loadSkeletonAssets, SpineHtmlRenderer } from 'spine-html';
1264
- const assets = await loadSkeletonAssets({ atlasUrl: '/hero/hero.atlas', skeletonUrl: '/hero/hero.json' });
1265
- const skeleton = new Skeleton(assets.data);
1266
- const state = new AnimationState(new AnimationStateData(assets.data));
1267
- state.setAnimation(0, 'walk', true);
1268
- const renderer = new SpineHtmlRenderer(rootElement, assets.regionImages);
1269
- function frame(delta: number) {
1270
- state.update(delta);
1271
- state.apply(skeleton);
1272
- skeleton.update(delta);
1273
- skeleton.updateWorldTransform(Physics.update);
1274
- renderer.render(skeleton);
1275
- }
1276
- renderer.dispose(); assets.dispose(); // on unload
1277
- ```
1278
-
1279
- 1.0.0:
1280
-
1281
- ```ts
1282
- import { loadModel, RigPlayer } from '@rig-c/play';
1283
- const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
1284
- const player = new RigPlayer(rootElement, assets);
1285
- player.play('walk', { loop: true });
1286
- function frame(delta: number) {
1287
- player.frame(delta);
1288
- }
1289
- player.dispose(); assets.dispose(); // on unload
1290
- ```
181
+ ## Where to read next
182
+
183
+ - [docs/REFERENCE.md](docs/REFERENCE.md) — what the player does once it is on
184
+ the page: what ships and what is measured (*Status*), then every API section
185
+ from wind, gravity and dragging to the runtime knobs and what they cost.
186
+ - [docs/MIGRATION.md](docs/MIGRATION.md) — coming from 1.8 (meshes draw through
187
+ WebGL by default since 1.9) and from `spine-html` 0.8 (the 0.8 → 1.0 table,
188
+ what has no counterpart, before and after).
189
+ - [docs/TESTING.md](docs/TESTING.md) — how the suite holds the picture: the two
190
+ render modes' parity, the clipping oracle, the loading harness, and the
191
+ independent official raster oracle.
192
+ - [docs/BENCHMARK.md](docs/BENCHMARK.md) — the readings behind *Measured*, and
193
+ the bench that produces them: scenes, framing, and what a headless number is
194
+ not evidence of.
195
+ - [CHANGELOG.md](CHANGELOG.md) — what each version changed;
196
+ [RELEASING.md](RELEASING.md) — how a version is cut.
1291
197
 
1292
198
  ## Demo (this repository)
1293
199
 
@@ -1313,187 +219,19 @@ names the active backend), `?clipping=0` turns clips back off (they are then
1313
219
  counted and skipped), and `?time=1.2` seeks every instance to the same pose for
1314
220
  deterministic captures.
1315
221
 
1316
- ## Tests
1317
-
1318
- ```bash
1319
- bun run test # builds + serves the demo, then runs chromium + webkit
1320
- ```
1321
-
1322
- Playwright drives rigc-built gallery and procedural test rigs through the
1323
- production `RigPlayer`; its pose comes from `rig-c/core`. CI runs the same
1324
- suite on Ubuntu with Chromium and WebKit. Internal parity captures the same
1325
- deterministic pose in two modes/backends within one engine. An independent
1326
- development oracle also reads the paired Spine export through the official
1327
- WebGL runtime. No snapshot goldens are committed. Residue limits are measured
1328
- per engine, case and renderer; an A/B design alone does not make a measured
1329
- budget portable to another platform. Pose discrepancies remain rigc issues;
1330
- the independent oracle checks the resulting raster.
1331
-
1332
- **Internal picture parity** checks the two render modes drawing the same picture: every
1333
- scene captured in the DOM mode and in the canvas mode and diffed — bad pixels at
1334
- most 1.5% of drawn content on the test-rig scenes (measured floors 0.007–0.524%,
1335
- macOS Chromium; each scene's own feature dropped from the canvas mode — tint,
1336
- additive blend, clipping, a mesh — scores 4.2–77%) and 2.5% on the gallery
1337
- scenes (floors 0.585–1.553%, Chromium and WebKit). The DOM tier's two mesh
1338
- backends get the same A/B (`?backend=canvas2d` against `?backend=webgl`, at most
1339
- 0.5%). Hairline seams are guarded by a deterministic canary (`?expand=0` must
1340
- change the canvas2d raster), the canvas2d source sub-rect by another (expand 32
1341
- against 8, forced to the sub-rect), the two source rects by an A/B that must
1342
- match exactly, and counter tests pin the dirty-skip / grow-only-backing /
1343
- clip-counter invariants plus the region corner order (BL, UL, UR, BR) and the
1344
- turned-region UVs, read off rig-c's pose against hand-written numbers.
1345
-
1346
- Clipping gets its own oracle (`tests/clipping.spec.ts`), because a clip-path
1347
- that went through the wrong transform still parses and still counts as applied.
1348
- A clipped capture is compared against the **unclipped** capture of the same
1349
- slots masked in **screen space** by the world polygon — built once through the
1350
- stage transform, never through an element's local frame, so the check cannot
1351
- agree with the renderer by sharing its mistake. What it asserts is occupancy,
1352
- not pixel values: outside a 3 px band around the polygon's outline (where the
1353
- browser's clip-path antialiasing and the canvas `ctx.clip()` the oracle uses
1354
- legitimately differ), **no artwork may survive where the polygon excludes it
1355
- and none may go missing where it does not** — both counts absolute, no budget.
1356
- Pixel values are logged but not asserted, because a clipped element is drawn
1357
- through a mask and its silhouettes come out a shade different all over the
1358
- picture; a control capture, clipped by a polygon that removes nothing, pins
1359
- that down by coming back byte-identical to the unclipped one.
1360
-
1361
- The loading path is not observable in a rendered frame, so it gets its own
1362
- page (`tests/harness.html`, a second build entry) that exposes the library to
1363
- the specs directly. Its oracle is the browser: a revoked object URL stops
1364
- resolving, so blob ownership — every unpacked URL freed, nothing the caller
1365
- owns touched, nothing stranded by a failed load — is asserted rather than
1366
- assumed.
1367
-
1368
- ### Independent official raster oracle
1369
-
1370
- `tests/oracle.spec.ts` compares real DOM WebGL, DOM canvas2d and whole-canvas
1371
- output with `@esotericsoftware/spine-webgl` 4.3.13. The official reader parses
1372
- the same build's Spine JSON, atlas and original PNGs independently; it never
1373
- receives a `PosedFrame` or the player's alpha-corrected textures. JSON SHA-256
1374
- binds the export to the compiled model, and generation/page dimensions fail
1375
- closed. Both posers receive the same fixed-step history, crop, origin, scale
1376
- and DPR 1; world geometry must agree exactly before raster acceptance.
1377
-
1378
- Captures include the actual images, element canvases, CSS transforms, tint
1379
- and clipping. Transparent RGB remains diagnostic, except for additional narrow
1380
- raw checks on the whole-canvas PMA case. Acceptance compares those
1381
- same captured bytes on black and white backgrounds with the shared pixel
1382
- differ, and separately constrains alpha coverage, sum, unequal-pixel count
1383
- and maximum. `tests/oracleLimits.ts` stores measured engine/case/mode floors:
1384
- zero stays exact; nonzero counts receive 10% plus two units, and nonzero byte
1385
- maxima one byte. macOS and Linux have separately measured tables; other
1386
- platforms are refused as uncalibrated. Tests never regenerate limits from the
1387
- image under test.
1388
-
1389
- Cases cover geometry, tint, clipping, draw order with multiply/screen,
1390
- rotated/named regions, additive glow and an original-PMA twin. Named UV and
1391
- rotation coverage uses native scale: measured half-scale checker sampling
1392
- has additional residue, so arbitrary-scale pixel identity is not claimed.
1393
- The shared #37 negative control serves false PMA flags only in the player's
1394
- model response, leaving original files and the official reader unchanged.
1395
- Each renderer must first pass its healthy capture, then exceed the same
1396
- healthy black-composite budget with the altered response. This runs on both
1397
- CI browser projects. Original PMA PNG bytes are encoded before either reader
1398
- sees them.
1399
-
1400
- The reference uses `antialias:false`, `preserveDrawingBuffer:true` and the
1401
- official `pmaAdditiveBatching:false` option to retain additive coverage in
1402
- transparent captures. Initialization finishes model/region extraction before
1403
- official page uploads, avoiding a measured concurrent WebKit initialization
1404
- artifact. Use the required nova-run guard and a unique `TEST_PORT` for a
1405
- focused `bun run test tests/oracle.spec.ts --workers=1`; full-suite acceptance
1406
- remains CI. Headless results are raster evidence, never device performance.
1407
-
1408
- ## Benchmark: DOM, canvas and official WebGL
1409
-
1410
- ```bash
1411
- bun run bench:dev # the page, for a person to watch (and for Safari)
1412
- bun run bench # drives it in a headed Chromium and writes a report
1413
- bun run bench --headless # counters only; all timing fields are null
1414
- ```
1415
-
1416
- `bench/` is a separate vite project — not part of the library, and not part of
1417
- what `bun run build` emits — that compares DOM, whole-canvas and the independent
1418
- official WebGL reader, each with its own stats line. The paired JSON hash must
1419
- match the model before an official comparison can publish readings.
1420
- Scenes by query string: `?scene=mesh` (`portrait-packed` turning its head, the
1421
- deform tier working every frame), `?scene=held` (the same pose frozen, where
1422
- unchanged meshes reuse their raster and an unchanged frame is reused),
1423
- `?scene=rigid` (`walk-packed`, region attachments only) and
1424
- `?scene=many&count=N` independent players; `?rig=` and `?model=` name another
1425
- build. All four take `&count=N` up to 64 (a page limit, so that a cell stays
1426
- something you can see), laid out on one grid whose per-rig scale shrinks with
1427
- the column count, so a step up in count adds rigs rather than cropping the ones
1428
- already there.
1429
-
1430
- **The comparison uses the same framing.** All paths place rigs with one derivation and
1431
- clip each to the same rect — the pane for a grid, the rig's own cell for `many`
1432
- — with `overflow: hidden` on a box, the way a page embeds a player, and then
1433
- *check* it: each side reports `origin Δpx`, the largest gap between where the
1434
- grid puts a rig and where that side drew it, and the report table carries it per
1435
- row. (`contain: layout paint` is deliberately not used: paint containment clips
1436
- to the same edge `overflow: hidden` does, and what it adds past the clip — a
1437
- stacking context, a containing block, an independent formatting context —
1438
- changes how one side is composited and buys the comparison nothing.) Production
1439
- modes retain the package's shared WebGL context. Official grid scenes batch
1440
- all skeletons through one context; only `many` uses independent contexts.
1441
- Official surviving contexts and drawn players are counted separately from
1442
- requested players. Production survival counters are null where its public
1443
- API cannot measure them, rather than treating requested counts as survivors.
1444
-
1445
- `scripts/bench.mjs` walks `bench/scenes.json`, opens each scene in a **headed**
1446
- Chromium (a hidden page throttles `requestAnimationFrame`, so a headless
1447
- reading would be the browser's power policy rather than either mode's cost),
1448
- runs a fixed number of frames per mode — one at a time, because rAF has a single
1449
- cadence per document — and writes JSON plus a markdown table with the
1450
- conditions attached: browser version, device pixel ratio, window size, GPU
1451
- string, and the load average before and after. A software or unidentified GPU
1452
- invalidates a headed timing report. Over a load threshold it stamps the
1453
- whole report `INVALID: load average too high` and exits non-zero, because a
1454
- benchmark on a busy machine measures the machine. `--corpus <dir>` points it at
1455
- local rigc builds that never enter this repository. `--headless` instead steps
1456
- fixed frames and records counters only: FPS, pose/render timing and GPU fields
1457
- are null, with “측정 불가: 헤드리스” in JSON and the table. The official
1458
- additive-batching and drawing-buffer settings are recorded as conditions.
1459
-
1460
- > **Performance numbers pending.** Counter reports establish the instrument,
1461
- > not a performance comparison. A timing reading taken on
1462
- > a loaded machine, a CI runner or a headless browser would not be worth
1463
- > printing. The table will be taken the way every other number in this README
1464
- > was: the stats lines read on a real device, on a quiet machine, per scene
1465
- > and per engine — including Safari, which stays manual because no automation
1466
- > preserves what is being measured there. Until then the instrument exists and
1467
- > the measurement does not.
1468
-
1469
- The server is part of the test: a run tests whatever is being served on its
1470
- port. Two checkouts of this repository on one machine (a worktree, a second
1471
- clone) default to the same port 4321 and reuse an existing server, so the
1472
- second run quietly tests the first one's build — green, and about the wrong
1473
- tree. Give each concurrent checkout its own port with `TEST_PORT`
1474
- (`TEST_PORT=4333 bun run test`); setting it also disables server reuse, so a
1475
- busy port fails the run instead of being borrowed. Unset, nothing changes:
1476
- port 4321, reuse unless `CI`.
1477
-
1478
- Standing rule: **headless numbers are never Safari performance evidence** —
1479
- nothing in the suite asserts timing, and headless-WebKit fps/ms readings do
1480
- not transfer (software rasterizer, measured up to 28× off real Safari). The
1481
- perf oracle is the demo's stats line on a real device.
1482
-
1483
222
  ## How it works
1484
223
 
1485
- 1. `loadModel` reads the rig's compiled model document (`rig-c/core`'s `readModel`),
1486
- and `RigPlayer` opens an animation on a live track (`openTrack`): bones,
1487
- constraints, physics, deform and draw order are posed by the core — all
1488
- CPU-side, renderer-agnostic — one `dt` at a time.
1489
- 2. At load time each region of the document's `pages` section is cut into its own
1490
- bitmap (rotation restored), one blob URL per region — except regions that already
1491
- cover their whole page, which are used as they are.
1492
- 3. Per frame, the pose becomes a `PosedFrame`: for each slot in draw order, a region's
1493
- four world corners (order **BL, UL, UR, BR**) or a mesh's world vertices, page UVs
1494
- and triangles, its tint, blend and clip. For a region: flip Y (the frame is Y-up),
1495
- derive the affine from three corners, write one `matrix()` string. That's the
1496
- whole rigid render path.
224
+ `loadModel` reads the rig's compiled model document (`rig-c/core`'s `readModel`),
225
+ and `RigPlayer` opens an animation on a live track (`openTrack`): bones,
226
+ constraints, physics, deform and draw order are posed by the core — all
227
+ CPU-side, renderer-agnostic — one `dt` at a time. At load time each region of
228
+ the document's `pages` section is cut into its own bitmap (rotation restored),
229
+ one blob URL per region — except regions that already cover their whole page,
230
+ which are used as they are. Per frame, the pose becomes a `PosedFrame`: for
231
+ each slot in draw order, a region's four world corners (order **BL, UL, UR,
232
+ BR**) or a mesh's world vertices, page UVs and triangles, its tint, blend and
233
+ clip. For a region: flip Y (the frame is Y-up), derive the affine from three
234
+ corners, write one `matrix()` string. That's the whole rigid render path.
1497
235
 
1498
236
  ## License
1499
237