@rig-c/play 0.0.0-stage → 1.8.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
@@ -1,3 +1,967 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="assets/banner.svg" alt="@rig-c/play - plays rigc's compiled model as DOM elements or one canvas" width="100%" />
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@rig-c/play"><img src="https://img.shields.io/npm/v/@rig-c/play.svg?style=flat-square&color=FF6B4A" alt="npm version" /></a>
7
+ <a href="https://www.npmjs.com/package/@rig-c/play"><img src="https://img.shields.io/npm/dm/@rig-c/play.svg?style=flat-square&color=EC4899" alt="npm downloads" /></a>
8
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-38BDF8.svg?style=flat-square" alt="license" /></a>
9
+ </p>
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).
19
+
20
+ ## Why
21
+
22
+ The package began in 0.x as `spine-html`, an experiment in drawing Spine skeletons
23
+ without a canvas. Every Spine web runtime rasterizes into one, yet the pose itself is
24
+ computed on the CPU, and for a **region attachment** (a rigid part) the bone transform is
25
+ a plain affine map that CSS `transform: matrix()` expresses *exactly* — so the rigid tier
26
+ could live in the DOM, composited by the browser, inspectable in devtools, styled with
27
+ CSS. The idea had been floated on the Spine forum three times since 2014 and never built;
28
+ the blockers named were meshes, clipping and DOM update overhead, and the 0.x line
29
+ answered each of them (per-part mesh canvases, element-level `clip-path`, dirty-skip).
30
+
31
+ At 1.0 it stopped being a Spine runtime. It plays the model document `rigc build` writes,
32
+ poses it through `rig-c/core` — rigc's own posing core, bundled into the package — and
33
+ links nothing from Esoteric Software; a Spine export is refused by name. It gained a
34
+ second render mode that draws the whole frame into one canvas. And it took the name
35
+ `rig-play`, which says what it is; after 1.8 it moved with the rest of the toolchain to
36
+ `@rig-c/play`. `rig-play` and `spine-html` keep being published with the same files, so
37
+ links to either earlier name keep working.
38
+
39
+ ### Measured
40
+
41
+ Measured on the 0.x line, on the spineboy exports it then played (PoC, Apple silicon —
42
+ headless and on-device numbers are labeled, they do **not** substitute for each other).
43
+ The renderer tiers they describe carried over; they have not been re-measured since the
44
+ player moved to rigc's model (#60):
45
+
46
+ - Rigid only (spineboy-ess), headless Chromium: 10 skeletons / 180 slot images at
47
+ **~0.03 ms skeleton math + ~0.22 ms DOM writes per frame** — about 1.5% of a
48
+ 60 fps frame budget. On-device Safari: 10 rigid skeletons hold **60 fps**.
49
+ - With meshes (spineboy-pro), headless Chromium: 1 skeleton = **~0.05 ms + ~0.5 ms
50
+ render** (8 mesh canvases, 323 triangles); 10 running skeletons = **~3.5 ms/frame**
51
+ total, smooth.
52
+ - Dirty-skip: 10 *static* skeletons (pose held) = **~0.4 ms/frame** headless and
53
+ **~0.6 ms** on-device Safari — unchanged meshes reuse their raster, so idle or
54
+ held parts cost nothing anywhere.
55
+ - Real Safari (on-device), canvas2d mesh backend: in-callback JS stays **~4–5
56
+ ms/frame** for 10 running meshed skeletons — but the rAF rate tells the real
57
+ story: **meshed ×1 = 37 fps, meshed ×10 = 3–4 fps** (rigid-only ×10 = 60 fps).
58
+ The cost lives outside the frame callback, in the compositor/GPU process:
59
+ Safari antialiases canvas2d **clip paths**, so every triangle pays for an AA
60
+ mask. Chromium doesn't antialias clips and stays smooth. This is what the
61
+ optional WebGL mesh backend (below) removes.
62
+ - Real Safari (on-device), **webgl mesh backend**: meshed ×1 holds **60 fps**
63
+ (vs 37 on canvas2d), and the meshed ×10 stress scene jumps **3–4 → 45 fps** —
64
+ 80 mesh canvases / 3230 triangles redrawn every frame (0 reused, the worst
65
+ case), in-callback JS ~5.8 ms at dpr=1. The per-triangle clip-AA tax is gone;
66
+ the remaining gap to 60 is the blit/compositing cost of a 10-skeleton stress
67
+ scene, not a per-triangle cost.
68
+ - Headless-WebKit numbers are a **software rasterizer** and measured up to 28×
69
+ off real Safari in both directions — useful for visual regression only, never
70
+ as Safari perf evidence.
71
+
72
+ ## Status
73
+
74
+ **Production.** The questions this started as a PoC to answer — how far does the
75
+ DOM actually go, and how cheap is it — are answered with on-device numbers: the
76
+ practical scenario (a character or two, mostly holding pose, a few parts
77
+ deforming) holds **60 fps on every engine measured**, and the 10-skeleton
78
+ stress scene holds 45 fps on the weakest one (Safari, webgl backend). Ships
79
+ with a self-contained test suite (backend visual parity + invariants) and CI.
80
+
81
+ **Two render modes.** `mode: 'dom'` (the default, everything below) draws a rig
82
+ as elements. `mode: 'canvas'` draws the whole frame into **one `<canvas>`** per
83
+ player: every slot as textured triangles from its atlas page through the same
84
+ shared WebGL context the mesh tier's `webgl` backend uses, into one rect, then
85
+ one `drawImage` onto the player's canvas. Tint and dark (two-colour) tint are
86
+ vertex colours, blend modes are batch state, clips are cut on the CPU by
87
+ `rig-c/core`'s clipper — no `clip-path`, no SVG filter, no per-slot element.
88
+ `RigPlayer` swaps between the two in place (`player.mode = 'canvas'`). The
89
+ canvas mode is held against the DOM tier pixel for pixel on the parity suite's
90
+ frozen poses (bad pixels ≤ 1.6% of drawn content, measured, macOS Chromium and
91
+ WebKit); which mode is faster where is **not measured on a device yet**, so the
92
+ default stays `dom`.
93
+
94
+ - ✅ Region attachments (rigid parts): exact affine mapping, draw-order via `z-index`,
95
+ attachment swaps, alpha
96
+ - ✅ Atlas unpacking at load time (90°-packed regions restored to the orientation
97
+ the region's UVs name, held against hand-written UVs on a rigc build whose region
98
+ is packed turned),
99
+ so the rigid per-frame path never touches a canvas — a region that covers its whole
100
+ page is passed through uncut, and `revokeRegions()` frees the rest when a skeleton
101
+ is unloaded
102
+ - ✅ Mesh attachments (deform tier): small per-part canvases sized to the mesh's world
103
+ bounds, interleaved with the rigid `<img>` slots in one stacking context — the DOM
104
+ handles the bones, a rasterizer handles the warps
105
+ - ✅ Blend modes via `mix-blend-mode` (additive = `plus-lighter`)
106
+ - ✅ Crack-free mesh seams on clip-antialiasing browsers (Safari): each triangle's
107
+ clip polygon is expanded 0.5px from its centroid so neighbours overlap — the
108
+ texture is continuous across shared edges, so the overlap is invisible
109
+ (`?expand=0` shows the cracks for comparison)
110
+ - ✅ RGB tinting (skeleton × slot × attachment color) via an SVG `feColorMatrix`
111
+ reference filter per element — an exact channel multiply that works identically on
112
+ `<img>` and `<canvas>` without touching the raster (needs reference-filter support,
113
+ Safari 15+; verified pixel-level on Chromium and WebKit). Dark/two-color tint is
114
+ not expressible this way and stays out of scope
115
+ - ✅ DPR-aware mesh canvas backing store: `renderer.pixelRatio` (defaults to
116
+ `devicePixelRatio`; if you scale the root element, fold that scale in)
117
+ - ✅ Dirty-skip: a mesh whose canvas-space vertices didn't change reuses last frame's
118
+ raster — the CSS translate still tracks it, so parts that hold a pose (or move by
119
+ whole pixels) pay zero raster. 10 frozen spineboys: WebKit ~141 → ~0.5 ms/frame
120
+ - ✅ Clipping attachments as element-level CSS `clip-path`: spine-core's semantics
121
+ (one active clip at a time, applied from the clip's slot through its end slot
122
+ inclusive), with the polygon expressed in each element's own local frame — the
123
+ inverse of the `<img>` matrix for rigid slots, the canvas translate for mesh
124
+ slots. Covers **part masks** (a clip with a real end slot, like the figure test
125
+ rig's `mask` animation) and **whole-skeleton clips** (an end slot that never
126
+ arrives, which takes a fast path: one `clip-path` on the root instead of one per
127
+ element). **Inverse clips** are supported too: the region they keep has a hole
128
+ in it, so it needs two rings, and `polygon()` carries only one — a box and a
129
+ polygon listed inside one `polygon()` become a single self-intersecting ring
130
+ whose even-odd fill leaves a wedge along the seam between them (measured, on a
131
+ corner of spineboy's boot). So an inverse clip is written as a two-subpath
132
+ `path(evenodd, …)` whose outer ring is the element's own box. The whole feature
133
+ is element-level, so both mesh backends see the same thing and neither raster
134
+ path knows clipping exists; `renderer.clipping = false`
135
+ restores the old counted-and-skipped behaviour. Concave polygons go to CSS as
136
+ authored — the convex decomposition spine's CPU clipper performs exists to feed a
137
+ triangle rasterizer and has no job here. Two things this does not do: Spine's
138
+ clipper convexifies an *inverse* polygon (convex hull) where CSS clips it as
139
+ authored, so the two agree exactly when that polygon is convex — which is the
140
+ shape `inverse` is meant for; and the cost of many simultaneous `clip-path`s on
141
+ real-device Safari is **not measured** (the headless numbers that exist are not
142
+ Safari evidence — see Measured above)
143
+ - ✅ Safari mesh cost root-caused by on-device triangulation (two corrections deep):
144
+ the early "~15× slower per-triangle path" was a **headless-WebKit artifact**
145
+ (software rasterization), and the follow-up "on par with Chromium" held only for
146
+ in-callback JS time. Real-Safari rAF rates — rigid ×10 = 60 fps, meshed ×1 =
147
+ 37 fps, meshed ×10 = 3–4 fps with JS flat at ~4–5 ms — put the real cost in the
148
+ GPU process: Safari antialiases canvas2d **clip paths**, so the per-triangle clip
149
+ mapping pays a per-triangle AA-mask tax (the same AA that caused the seam cracks)
150
+ - ✅ Optional WebGL blit backend for the mesh tier: `renderer.meshBackend =
151
+ 'webgl'` (default stays `'canvas2d'`). All dirty meshes are shelf-packed into
152
+ **one shared offscreen WebGL context** (module-level — browsers cap contexts at
153
+ ~16), drawn as textured triangles with premultiplied alpha, then rect-blitted
154
+ onto the same per-part canvases with an unclipped `drawImage` — cheap on Safari.
155
+ Everything element-level is unchanged: z-index interleave, tint filter,
156
+ `mix-blend-mode`, dirty-skip, grow-only backing. GL rasterizes shared triangle
157
+ edges seamlessly, so this path needs no crack overdraw. Falls back to canvas2d
158
+ when WebGL is unavailable or the context is lost. Backend parity verified by
159
+ headless Chromium+WebKit screenshot diffs (glow / clipping / tint scenes;
160
+ sub-pixel edge differences only). On-device Safari, meshed ×10 with every mesh
161
+ redrawn per frame: **3–4 fps (canvas2d) → 45 fps (webgl)**
162
+
163
+ ## Install
164
+
165
+ ```bash
166
+ npm i @rig-c/play
167
+ npm i -D rig-c # the build tool: `rigc build` writes the model the player loads
168
+ ```
169
+
170
+ The package plays rigs built by [rigc](https://github.com/rig-c/core):
171
+ `rigc build` writes a compiled model document, `skeleton.model.json` (spec
172
+ `rigc-compiled/3`), next to the Spine pair and the atlas pages. That document
173
+ is what the player loads, and it states everything posing and drawing need —
174
+ the skeleton, the animations, and where every region sits on its page — so the
175
+ `.atlas` and `skeleton.json` beside it are not fetched. Poses are computed by
176
+ `rig-c/core`, rigc's own posing core, which is bundled into this package: there
177
+ is nothing else to install.
178
+
179
+ ```ts
180
+ import { loadModel, RigPlayer } from '@rig-c/play';
181
+
182
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
183
+
184
+ // A positioned element becomes the rig's world origin (Y-up: the rig grows
185
+ // upward from it). Layout and scaling are the caller's.
186
+ const player = new RigPlayer(rootElement, assets);
187
+ player.play('walk'); // looping; { loop: false } holds at the end
188
+
189
+ function frame(delta: number) {
190
+ player.frame(delta); // step the track by delta × timeScale, draw
191
+ }
192
+
193
+ // Unloading (a cutscene ends, a level swaps): elements first, bitmaps second.
194
+ // The unpacked regions are blob URLs — nothing else frees them.
195
+ player.dispose();
196
+ assets.dispose();
197
+ ```
198
+
199
+ The player owns one track: `play(name, { loop })` opens an animation at its first
200
+ pose, `frame(dt)` steps it (or `advance(dt)` then `draw()`, to time the two
201
+ apart), `pause()` / `resume()` hold and release it, `timeScale` scales every
202
+ step, and `seek(t)` moves it to a time. A seek is "reopen, then step to the
203
+ time" in `seekStep` increments (1/60 s by default) — never a jump, because a
204
+ physics rig has history and its pose at a time depends on the steps that
205
+ reached it. `on('event', …)` receives the event keys each step crosses, in the
206
+ runtime's order across a loop's wrap; `on('complete', …)` fires once a step
207
+ completes a cycle. `skin` (a constructor option) poses under a named skin;
208
+ without it the rig is posed the way a fresh Spine skeleton is, with no skin
209
+ set. `player.color` multiplies a whole-rig colour into every slot's tint.
210
+ `player.renderer` is the `DomRenderer` underneath, whose knobs and counters
211
+ are below.
212
+
213
+ **What it refuses, by name.** A Spine export (`.json`, `.skel`) is refused
214
+ before anything is fetched: this package reads compiled models and nothing
215
+ else. A document whose `spec` is not one `rig-c/core` reads is refused with the
216
+ core's own sentence plus "build it with `rigc build`", and so is a
217
+ `rigc-compiled/1` document, which predates the `pages` section.
218
+
219
+ `@rig-c/play` is a third-party renderer and is not affiliated with or endorsed by
220
+ Esoteric Software.
221
+
222
+ ### Drawing your own frames
223
+
224
+ `RigPlayer` is the convenient path; the renderer underneath takes a plain
225
+ record, so anything that can produce one can draw through it:
226
+
227
+ ```ts
228
+ import { DomRenderer, type PosedFrame } from '@rig-c/play';
229
+
230
+ const renderer = new DomRenderer(rootElement, assets.regionImages, assets.pageImages);
231
+ renderer.render(frame); // frame: PosedFrame
232
+ ```
233
+
234
+ ```ts
235
+ PosedFrame { order: PosedSlot[] } // the slots that draw, in draw order
236
+ PosedSlot = { slot; z; blend: 'Normal'|'Additive'|'Multiply'|'Screen';
237
+ tint: [r, g, b, a]; dark?: [r, g, b];
238
+ clip?: { polygon: number[]; inverse: boolean; convex: boolean } } // world space, Y up
239
+ & ( { kind: 'region'; region: string; corners: number[] /* BL, UL, UR, BR */;
240
+ page: string; pma: boolean; uvs: number[] /* page UVs at the corners */ }
241
+ | { kind: 'mesh'; page: string; pma: boolean; vertices: number[];
242
+ uvs: number[]; triangles: number[]; sequence: number } )
243
+ ```
244
+
245
+ `player.posedFrame()` returns the record the player would draw. Two things the
246
+ renderer reads off it by identity rather than by value: every slot a clip
247
+ covers carries the **same** `clip` object (one that covers every slot of the
248
+ frame takes the one-`clip-path`-on-the-root path), and a mesh's `uvs` and
249
+ `triangles` are the same arrays from frame to frame for the same texture
250
+ mapping (a new array re-rasters the mesh).
251
+
252
+ ### Reading the pose
253
+
254
+ Two reads off the pose the player holds — the last `frame`/`advance`/`seek`,
255
+ or the setup pose before `play` — for framing a stage around a rig:
256
+
257
+ ```ts
258
+ const head = player.bone('head'); // { x, y, a, b, c, d, rotationX, rotationY, scaleX, scaleY, active }
259
+ const box = player.bounds(); // { x, y, width, height } | null — world units, Y up
260
+ ```
261
+
262
+ `bone` is the core's world transform for that bone (`rig-c/core`'s `RawBone`,
263
+ unchanged), and an unknown name is refused by name. `bounds` is the box of
264
+ every region's four corners and every mesh's vertices in the frame record the
265
+ renderer takes; a slot under a clip counts only inside the clip polygon's own
266
+ box, an inverse clip removes nothing, and bounding boxes, paths and points do
267
+ not draw and do not count. Both cost nothing until called and read the same
268
+ numbers in `dom` and `canvas` mode.
269
+
270
+ ### Drawing some slots only
271
+
272
+ `slots` is a draw filter — an allow list of slot names or a predicate — settable up
273
+ front and on a live player. The pose is the whole rig's whatever the filter:
274
+ bones, constraints, physics, `bone()`, `adjust`, events and tracks see no filter;
275
+ only the draw does, in both modes, and `bounds()` reads the drawn slots. A
276
+ clipping slot the filter leaves out does not clip. An allow list naming a slot
277
+ the document does not have is refused by name.
278
+
279
+ ```ts
280
+ const hand = new RigPlayer(frontRoot, assets, { slots: ['hand_f', 'arm_f_lo'] });
281
+ player.slots = (slot) => !slot.startsWith('weapon');
282
+ ```
283
+
284
+ Two players over one `ModelAssets` share the load but each poses the rig. To
285
+ pose it once and draw it twice — a hand drawn again in front of another
286
+ character — give a second renderer this player's pose under another filter:
287
+
288
+ ```ts
289
+ const front = new DomRenderer(frontRoot, assets.regionImages, assets.pageImages);
290
+ // each frame, after player.frame(dt):
291
+ front.render(player.posedFrame(['hand_f', 'arm_f_lo']));
292
+ ```
293
+
294
+ ### Several tracks
295
+
296
+ A base on track 0, a layer over it, a one-shot played once and mixed back out —
297
+ rig-c's live tracks (`openTracks`), whose mixing is the Spine runtime's
298
+ `AnimationState` held at tolerance 0:
299
+
300
+ ```ts
301
+ player.play('idle'); // track 0, as always
302
+ player.play('smile', { track: 1, loop: false, mix: 0.2 }); // held at its end, mixed in over 0.2 s
303
+ player.play('wave', { track: 2, loop: false, mix: 0.1 }); // a one-shot…
304
+ player.queueClear(2, { mix: 0.1 }); // …mixed back out over its last 0.1 s
305
+ player.queue('idle_b', { track: 0, mix: 0.3 }); // after the current idle cycle completes
306
+ player.clear(1, { mix: 0.2 }); // the layer gone, the base showing through
307
+ player.entry(2); // { animation, loop, trackTime, animationTime, duration, alpha, additive } | null
308
+ player.play('blink', { track: 3, alpha: 0.6 }); // the same clip at a weight…
309
+ player.setAlpha(3, 0.85); // …changed while it plays
310
+ player.play('breath', { track: 4, additive: true }); // adds to what the tracks below posed
311
+ ```
312
+
313
+ **Weight and additive layers** (rig-c 2.39.0, the runtime's `TrackEntry.alpha` and
314
+ `additive`): `alpha` (0..1, default 1) multiplies every timeline the entry applies;
315
+ `setAlpha(track, alpha)` changes the current entry's weight from the next pose and
316
+ re-poses the held pose. It does not touch events — an entry fires at any alpha, 0
317
+ included, and one mixing out fires none — or attachments: the current entry keeps
318
+ its attachments at any alpha, one mixing out keeps none. `additive: true`, fixed
319
+ when the entry is set or queued, adds the entry's keyed bone and deform values to
320
+ what the tracks below posed instead of replacing them, so a layer keying the same
321
+ bone channels as an idle does not cover its motion; a rotation is added as it
322
+ stands, with no shortest-angle wrap, and an additive entry mixing out fades with its
323
+ alpha and is never held. Colour, attachment and event timelines apply as on a
324
+ replacing entry. A weight below 1 or an additive entry composes, as a mix does.
325
+
326
+ `play(name)` keeps meaning what it meant: a plain play on track 0 runs on rig-c's
327
+ single live track, which poses everything the core poses. The first call that
328
+ composes — a `mix`, a `track` above 0, a `queue`, a `clear` — moves the player onto
329
+ the tracks, carrying the single track over by **replay** (its animation set, pose 0
330
+ taken, stepped to its time in `seekStep` increments — one pose per step played, so
331
+ on a stage that idled a while that is one long frame; the core offers no hand-over
332
+ of a live track's state). To never pay it, stand the player on the tracks from the
333
+ start: `new RigPlayer(root, assets, { tracks: true })`. Pick the single track for a
334
+ rig whose animations key what the tracks do not mix (below); pick the tracks for
335
+ layering. Either way, after the first `mix`, `track` above 0, `queue` or `clear`,
336
+ `seek` and `skin` are refused by name, since the player cannot replay a composition
337
+ to a time — step it forward instead.
338
+ Every entry mixes from the setup pose each step, so `adjust` writes still last one
339
+ pose. **What the tracks do not mix**, in rig-c's words at `play`/`queue`: an
340
+ animation keying a constraint (ik, transform, path, physics, slider), a sequence,
341
+ the draw order, or a path's deform is refused when it is set on the tracks — it
342
+ plays on the single track only. `'event'` carries `track`; a one-shot that is
343
+ mixing out fires nothing. `'complete'` is per track, the player's rule: a looping
344
+ entry each cycle, a held one when it reaches its duration, and an entry that is
345
+ replaced — by `play`, or by the queued entry that starts at its completion less the
346
+ mix — completes no more than it fires.
347
+
348
+ ### Adjusting bones
349
+
350
+ A per-pose write to bone locals — a breath's scale, a look's rotation, a mouth
351
+ driven while the body holds still:
352
+
353
+ ```ts
354
+ const player = new RigPlayer(root, assets, {
355
+ adjust: (bones) => { bones.bone('mouth').scaleY = 1 + openness; },
356
+ });
357
+ player.adjust = null; // or set it later; setting it re-poses the held pose at once
358
+ ```
359
+
360
+ The function runs once per pose, after the animation's timelines and before the
361
+ world transforms, constraints and physics — `rig-c/core`'s `openTrack({ adjust })`,
362
+ which rig-c holds to the Spine runtime at tolerance 0. **A write lasts one
363
+ pose.** The core poses every step from the setup pose, so a value you want held
364
+ is written again each pose and nothing compounds; a 0.8 caller that multiplied a
365
+ channel on a skeleton it kept between frames writes the value it wants, every
366
+ pose. It runs on every pose the animation is applied to, the first one after
367
+ `play` included, and `seek` and a `mode` change keep it.
368
+
369
+ A paused player and a zero step re-pose nothing, so a change the function would
370
+ make is not seen until the next step — unless you ask: setting `player.adjust`
371
+ re-poses the held pose at the same track time, and `player.repose()` does the
372
+ same for a function whose own state changed (the mouth above), without stepping,
373
+ firing events or drawing. A non-finite value, a bone the document does not have,
374
+ or an error the function throws refuses that step in the core's words, and the
375
+ track is reopened by the next `play` or `seek`.
376
+
377
+ ### One load, several players
378
+
379
+ One `loadModel` backs any number of players: the regions are cut once, and
380
+ every player draws from the one `regionImages` map. A player owns none of it —
381
+ disposing a player leaves the bitmaps alive for the others — so there is exactly
382
+ one `assets.dispose()`, after the last player is gone:
383
+
384
+ ```ts
385
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
386
+ const a = new RigPlayer(rootA, assets);
387
+ const b = new RigPlayer(rootB, assets, { skin: 'winter' });
388
+
389
+ a.dispose();
390
+ b.dispose();
391
+ assets.dispose();
392
+ ```
393
+
394
+ A document is one rig. Two rigs whose builds share page images load as two
395
+ documents; the images are fetched by URL, so a page both name at the same URL
396
+ is loaded once by the browser — but each load cuts its own regions and owns its
397
+ own blob URLs.
398
+
399
+ ### Loading it yourself
400
+
401
+ `loadModel` is a convenience over two calls the package exports: read the
402
+ document, load its pages, and cut the regions with `unpackRegions`, which takes
403
+ the document's `pages` section — `{ name, width, height, pma, regions: [{ name,
404
+ x, y, width, height, degrees }] }` per page — and a map of page images keyed by
405
+ those names. Use it when the page images do not come from a URL (`resolvePage`
406
+ on `loadModel` covers the ones that come from another URL), and pair it with
407
+ `revokeRegions` on unload.
408
+
409
+ A region the packer stored turned (`degrees: 90`) is cut upright again, and
410
+ which way round that is comes from the UVs a runtime assigns such a region,
411
+ not from the cut: its corners carry `(u2, v2), (u, v2), (u, v), (u2, v)` in the
412
+ order BL, UL, UR, BR, so the artwork's top-left corner sits at the packed rect's
413
+ bottom-left and unpacking it is a clockwise turn. Every release up to 0.7.0
414
+ turned it the other way and drew rotated rigid parts 180° round (#49).
415
+
416
+ `unpackRegions` mints one blob URL per region; `revokeRegions` is its
417
+ counterpart. It only frees URLs `unpackRegions` created, so a map you built
418
+ yourself and page images reused by the whole-page pass-through survive it — and
419
+ calling it twice is a no-op. Load once for the page's lifetime and you can
420
+ ignore it; load and unload repeatedly without it and you leak an atlas per
421
+ cycle.
422
+
423
+ ### Loading for the canvas mode only, and `data:` regions
424
+
425
+ The region cut makes the rigid tier's bitmaps, which only the DOM mode draws.
426
+ `loadModel({ regions })` says what to do about it:
427
+
428
+ - `'blob'` (default) — `blob:` URLs the load owns; `assets.dispose()` frees them.
429
+ - `'data'` — `data:` URLs instead, for a content policy or a snapshot that does
430
+ not carry `blob:`. The same PNG bytes, inline; nothing is minted, so there is
431
+ nothing to free, and the base64 lives as long as the map does.
432
+ - `'none'` — no cut: the pages load, `assets.regionImages` is empty, and the
433
+ load is for the canvas mode. A DOM-mode player opened on it — `new RigPlayer`
434
+ without `mode: 'canvas'`, or `player.mode = 'dom'` — is refused by name, and
435
+ a refused mode change leaves the player as it was.
436
+
437
+ `assets.regions` records the choice. `unpackRegions` takes the same thing as
438
+ `{ encoding: 'blob' | 'data' }`.
439
+
440
+ ### Pages that ship at another resolution
441
+
442
+ A page image may ship at a resolution its document does not declare — a
443
+ half-resolution texture build, or an @2x variant, with the page's `width` and
444
+ `height` left as the build wrote them. Nothing here needs a flag for that. A
445
+ region's bounds are read **relative to the declared page size** and scaled onto
446
+ the image's natural size, which is how the page UVs the mesh tier samples with
447
+ are derived too (`u = x / page.width`), so both tiers land on the same pixels at
448
+ any resolution.
449
+
450
+ Each unpacked bitmap comes out at the **native resolution of the pixels it was
451
+ cut from** — half-resolution pages cost a quarter of the cut pixels, and
452
+ nothing is upscaled back — while `RegionImage.width`/`height` stay in **atlas
453
+ units**. Those two numbers are the `<img>` layout box and the denominator of
454
+ its CSS matrix, so the rig poses identically and the browser scales the
455
+ smaller bitmap into the same box, the way a GPU samples a smaller texture
456
+ through the same UVs. Ship one build and swap the images per device if you
457
+ like (`resolvePage`).
458
+
459
+ ### Premultiplied pages (`pma`)
460
+
461
+ A page's `pma` flag in the document's `pages` section — the build copies it from
462
+ the atlas's `pma: true` line — says its RGB is already multiplied by its alpha,
463
+ and nothing is asked of you: each tier is handed the page in the convention it
464
+ actually consumes. The `webgl` mesh backend uploads the texels unconverted (its
465
+ blend already expects premultiplied source — lossless). The DOM and canvas2d
466
+ tiers cannot: an `<img>` and `drawImage` composite straight alpha by definition,
467
+ so they read a straight-alpha derivation of the page —
468
+ `rgb = round(rgb * 255 / a)`, computed once and shared by the region cuts and
469
+ the mesh raster. Without it every semi-transparent texel is multiplied by its
470
+ alpha a second time and draws darker than it was authored: soft edges, soft
471
+ shadows, glows.
472
+
473
+ What it costs: **one page-sized canvas per premultiplied page image**
474
+ (width × height × 4 bytes — 1 MiB for a 1024×256 page, 16 MiB for a 2048×2048
475
+ one), alive as long as the page image is and released with it; the cache is
476
+ weak, so there is nothing to free by hand. A page without the flag derives
477
+ nothing. The division is 8-bit and starts from a canvas read, which has itself
478
+ quantized a premultiplied texel, so a very transparent texel can land a few
479
+ levels off — exact at alpha 0 and 255, within one level of 255 above alpha 128,
480
+ and a little more below that, by an amount that belongs to the browser's canvas
481
+ rather than to this package. It is invisible wherever the texel is: a texel at
482
+ alpha 11 is ~4% opaque. The `webgl` backend, having no such step, is exact.
483
+
484
+ One consequence worth knowing if your build is one part per page: a whole-page
485
+ region on a premultiplied page is **cut** rather than handed through, because
486
+ the page's own URL holds premultiplied pixels (see below).
487
+
488
+ ### What unloading frees
489
+
490
+ `revokeRegions()` — and `assets.dispose()`, which calls it — frees the blob URLs
491
+ `unpackRegions` minted, and nothing else (a `regions: 'data'` or `'none'` load
492
+ minted none, so it frees nothing). The page images are held by
493
+ `assets.pageImages` and by every player drawing from them, so a page is
494
+ released once you drop the assets and every player is disposed. With the
495
+ `webgl` mesh backend the shared blitter also uploads each page image on first
496
+ use, but that GL texture is not the module's forever: every renderer drawing
497
+ the page holds a reference to it, `player.dispose()` hands those back, and the
498
+ texture is deleted once the last renderer using that page is disposed — a later
499
+ frame that needs the page uploads it again. The cache is keyed weakly by the
500
+ page image, so a renderer dropped without `dispose()` costs GPU memory until
501
+ the context is lost, but never keeps the image itself alive.
502
+
503
+ A rig with meshes samples its page bitmaps every frame, so their decoded form
504
+ stays in use: a floor on the order of `page width × page height × 4` bytes per
505
+ page. A rigid-only rig draws nothing from the pages after `unpackRegions`, and
506
+ what a merely reachable, undrawn image costs is up to the browser, not
507
+ measurable from script.
508
+
509
+ Unload in this order:
510
+
511
+ 1. `player.dispose()`, for every player using the assets.
512
+ 2. `assets.dispose()` (or `revokeRegions(regionImages)`).
513
+ 3. Drop your references to the assets.
514
+
515
+ ### One part per page
516
+
517
+ `rigc build` writes one page per part by default — each part PNG is its own
518
+ page, its one region covering it — and `rigc build --pack` arranges the parts
519
+ onto shared pages instead. Both play here, unchanged:
520
+
521
+ - Regions that cover their whole page are handed straight through by
522
+ `unpackRegions` instead of being cut and re-encoded — at any image resolution,
523
+ since covering the page is a statement about the declared size. Load cost for
524
+ this shape is just the image loads. The exception is a page marked `pma`: its
525
+ URL holds premultiplied pixels, which an `<img>` would composite as straight
526
+ alpha, so those regions are cut from the straight-alpha derivation like any
527
+ other (and the blob is revoked by `revokeRegions` like any other).
528
+ - A packed build cuts every region from its page once, at load, and its meshes
529
+ sample the shared page through page UVs.
530
+
531
+ ### Runtime knobs and what they cost
532
+
533
+ - `player.mode` / `new RigPlayer(root, assets, { mode })` — `'dom'` (default)
534
+ or `'canvas'`. Changing it disposes the current renderer (elements removed,
535
+ GPU pages handed back) and creates the other over the same root and assets;
536
+ `pixelRatio` and `clipping` carry over and the held pose is redrawn at once.
537
+ `player.renderer` is then a `CanvasRenderer`, whose knobs are `pixelRatio`,
538
+ `syncPixelRatio()`, `clipping`, `viewport` and `backend`. What each mode costs, as counters:
539
+ - **dom** — one element per drawn slot, one style write per changed slot,
540
+ and the mesh tier's rasters (the knobs below).
541
+ - **canvas** — one canvas element per player and, per frame that changed,
542
+ one pass over every slot: `drawCalls` (one per run of one page under one
543
+ blend), `blendFlushes` (runs a blend change ended), `trianglesDrawn`,
544
+ `blits` (one), or `framesReused` (one) when the record did not change and
545
+ nothing is drawn. It never creates a WebGL context of its own, so any number
546
+ of players stays under the browser's ~16-context cap (20 players, one
547
+ context — `tests/canvas-mode.spec.ts`). The canvas covers a rect of the
548
+ root's coordinates that grows (never shrinks) to what the frames draw —
549
+ `canvasReallocCount` settles at zero — or a fixed `renderer.viewport` (world
550
+ units, Y up) when you set one. Blends are composed inside that canvas, which
551
+ starts transparent: an additive slot adds onto the slots under it and is
552
+ then composited onto the page normally, where the DOM tier's
553
+ `plus-lighter` adds onto the page itself. Where WebGL is unavailable or the
554
+ context is lost, the frame is drawn with canvas2d on the same canvas so it
555
+ never draws nothing — **alpha tint only, RGB and dark tint unsupported
556
+ there** — and that fallback is **not the measured path** (`backendActive`
557
+ says which ran).
558
+
559
+ - `renderer.pixelRatio` — mesh-canvas backing pixels per CSS pixel (defaults to
560
+ `devicePixelRatio`; if you scale the root element, fold that scale in so the
561
+ raster matches the screen: `devicePixelRatio * rootScale`). **Writing it
562
+ reallocates every mesh canvas backing store on the next frame**, and each
563
+ reallocation recreates a GPU surface — the cost that took real Safari to ~3 fps
564
+ when it happened per frame. Each canvas is then sized from what the new ratio
565
+ needs, in both directions: lowering the ratio gives the backing pixels back
566
+ (the backing is grow-only *within* a ratio, not across a change of one). Set
567
+ it when a layout settles, never per frame:
568
+ debounce resize drags and quantize the value instead of tracking it
569
+ continuously. `renderer.canvasReallocCount` is the check — it must fall back to
570
+ zero within a second or two.
571
+ - `renderer.syncPixelRatio()` — measures the root's effective on-screen scale
572
+ and sets `pixelRatio` to `devicePixelRatio × scale`, returning the ratio now
573
+ in effect. It appends a hidden 100 px box to the root, reads its box once and
574
+ removes it (the root itself is usually 0×0), so it is **one forced layout per
575
+ call** — which is exactly why the renderer never calls it for you: there is no
576
+ per-frame layout read anywhere in this library. Call it when a zoom or a
577
+ layout *settles* (gesture end, debounced resize), not during the drag. A
578
+ change under 0.1% is ignored, so layout jitter cannot churn GPU surfaces, and
579
+ a root that is not laid out (a `display: none` ancestor) leaves the ratio
580
+ alone. Under an ancestor rotation the measured box is inflated and the ratio
581
+ errs high — oversampling costs pixels, undersampling costs picture.
582
+ - `renderer.meshBackingPixels` — allocated mesh-canvas backing pixels
583
+ (Σ width × height), computed on demand. This is what `pixelRatio` moves
584
+ quadratically, and the cheapest way to see an oversampling stage; the demo
585
+ prints it in the stats line as `backing N Mpx`.
586
+ - `renderer.meshBackend` — `'canvas2d'` (default) or `'webgl'`; same output, but
587
+ heavy deforming scenes on Safari want `'webgl'` (see Measured above). Falls back
588
+ to canvas2d automatically when WebGL is unavailable. Switching re-rasters every
589
+ mesh once (no reallocation), so it is fine to expose as a user setting.
590
+ - `renderer.triangleExpand` — clip overdraw in px that closes antialiased mesh
591
+ seams (default 0.5). Also re-rasters every mesh once when changed.
592
+ - `renderer.clipping` — apply the clips the frame carries (default `true`).
593
+ Which slots a clip covers is decided where the frame is made — the player's
594
+ adapter walks the draw order the way the runtime's draw loop does (one clip in
595
+ force at a time, a second one met while one is in force ignored, the end slot
596
+ inside its own clip). What the renderer writes is one CSS `clip-path` per
597
+ element a clip covers, in that
598
+ element's own local frame; nothing reaches the raster backends, and a clip is
599
+ not part of the mesh dirty signature, so a mesh that held still keeps reusing
600
+ its raster under a moving clip. **Writes happen on change only**: each
601
+ clip-path is cached exactly as `transform` is, coordinates are quantized to
602
+ 1/1000 of a local unit so float jitter cannot defeat that cache, and a static
603
+ polygon over a static pose therefore costs **zero style writes per frame after
604
+ the first** — `renderer.clipWriteCount` is the check, alongside
605
+ `clipCount` (clips applied) and `clipSkipCount` (clips in the frame not
606
+ applied: switched off, or a degenerate polygon; a clip the runtime itself
607
+ ignores never reaches the frame). **The whole-skeleton fast path**: when one
608
+ clip covers every slot of the frame, one `clip-path` goes on the root instead
609
+ of one per element. The root is *your* element, so its inline `clip-path` is **borrowed,
610
+ not taken** — saved on the first write and put back verbatim when the clip
611
+ stops covering the frame, when `clipping` goes `false`, and by `dispose()`.
612
+ Per-element clip-paths and the root clip-path are never both in force for the
613
+ same clip. Two things to know about that path: the polygon is written in the
614
+ root's **border box** frame, which is where absolutely-positioned slot elements
615
+ start too *unless the root has a CSS border* (a border would offset the
616
+ whole-skeleton clip by its width — keep borders off the render root, which is
617
+ the normal shape for a 0×0 origin element); and an inverse clip never takes it,
618
+ because its CSS form needs an outer ring around a box and the root has none.
619
+ One further consequence of that path: a `clip-path` other than `none` makes an
620
+ element a stacking context (CSS Masking), so a whole-skeleton clip isolates
621
+ `mix-blend-mode` slots from backdrops *outside* the root — which a root
622
+ carrying a `transform` (the usual pan/zoom stage) already does. Per-element
623
+ clips do not change blending, since a blended slot is its own stacking context
624
+ either way. Setting `clipping = false` removes every clip-path this renderer
625
+ wrote.
626
+
627
+ **A zoomable stage.** The mesh tier rasters at `world × pixelRatio` in the
628
+ root's own coordinates, and it cannot see a CSS transform above the root — so
629
+ the most natural pan/zoom stage, `transform: scale(zoom)` on an ancestor, makes
630
+ it oversample by `1/zoom²` in backing pixels until the zoom is folded in. The
631
+ picture stays correct throughout, which is what makes this easy to ship: only
632
+ the allocation and the frame rate move. Call `syncPixelRatio()` when the zoom
633
+ settles, or set `pixelRatio = devicePixelRatio * zoom` yourself. Measured
634
+ on-device (Chromium, dpr 2, stage under `scale(0.25)`, ~2.2 M CSS px on screen):
635
+
636
+ | scene | `pixelRatio` | `meshBackingPixels` | fps |
637
+ | --- | --- | --- | --- |
638
+ | 57 meshes | `devicePixelRatio` (2) | 139.1 Mpx | 14–21 |
639
+ | 57 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 61 |
640
+ | 92 meshes | `devicePixelRatio` (2) | 141.1 Mpx | 36–39 |
641
+ | 92 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 60–61 |
642
+
643
+ ## Coming from spine-html 0.8
644
+
645
+ This is not a drop-in upgrade. 0.8 drew a Spine export (`.json` or `.skel`, plus its
646
+ `.atlas`) that `@esotericsoftware/spine-core` parsed and posed: the caller built the
647
+ `Skeleton` and the `AnimationState`, advanced them each frame, and handed the skeleton to
648
+ `renderer.render(skeleton)`. 1.0 loads one file, the `skeleton.model.json` that
649
+ `rigc build` writes, poses it through `rig-c/core` (bundled into the package, with no peer
650
+ dependency), and `RigPlayer` owns the track, so the caller's frame is `player.frame(dt)`.
651
+ A Spine export is refused by name before anything is fetched: the data has to be a rigc
652
+ build, and this package declares no loader for anything else.
653
+
654
+ What did not move: a positioned root element is still the rig's origin (Y up) with its
655
+ layout and scaling left to the caller, the DOM tier keeps its knobs under a new class
656
+ name, and unloading is still elements first, bitmaps second.
657
+
658
+ ### 0.8 → 1.0
659
+
660
+ | 0.8.0 (`spine-html`) | 1.x (`@rig-c/play`, formerly `rig-play`) | Note |
661
+ | --- | --- | --- |
662
+ | `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. |
663
+ | `loadSkeletonAssets(options)` | `loadModel(options)` | One URL instead of two. The `.atlas` and `skeleton.json` a build writes beside the document are not fetched. |
664
+ | `LoadSkeletonAssetsOptions.atlasUrl`, `skeletonUrl` | `LoadModelOptions.modelUrl` | |
665
+ | `resolvePage(pageName, atlasUrl)` | `resolvePage(pageName, modelUrl)` | The default now resolves a page name against the model URL's directory. |
666
+ | `crossOrigin`, `fetch` | `crossOrigin`, `fetch` | Unchanged. |
667
+ | `scale` (`LoadSkeletonAssetsOptions`, `LoadSkeletonJsonOptions`) | none | See below. |
668
+ | `SkeletonAssets` | `ModelAssets` | |
669
+ | `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). |
670
+ | `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. |
671
+ | `SkeletonAssets.regionImages` | `ModelAssets.regionImages` | Same `Map<string, RegionImage>`. |
672
+ | `SkeletonAssets.dispose()`, `AtlasAssets.dispose()` | `ModelAssets.dispose()` | Same contract: frees the blob URLs the load minted, idempotent, called after the players are disposed. |
673
+ | `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. |
674
+ | `loadSkeletonBinary` from `spine-html/binary` | none | 1.0.0 has one entry point. A `.skel` URL is refused by name. |
675
+ | `unpackRegions(atlas, pageImages)` | `unpackRegions(pages, pageImages)` | The first argument is the document's `pages` section (`readonly PageLayout[]`) instead of a `TextureAtlas`. |
676
+ | `revokeRegions(images)` | `revokeRegions(images)` | Unchanged. |
677
+ | `RegionImage` | `RegionImage` | Unchanged (`url`, `width`, `height`, in atlas units). |
678
+ | `DomTexture` | none | Not exported; there is no atlas page to attach a texture to. |
679
+ | `new Skeleton(assets.data)` and `new AnimationState(new AnimationStateData(assets.data))` | `new RigPlayer(root, assets, options?)` | The player holds the pose and the track. |
680
+ | `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. |
681
+ | `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*. |
682
+ | `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*. |
683
+ | `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. |
684
+ | 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. |
685
+ | holding a pose (the caller stopped advancing) | `player.pause()`, `player.resume()` | `frame` keeps drawing the held pose. |
686
+ | 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. |
687
+ | 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. |
688
+ | 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. |
689
+ | `SpineHtmlRenderer` | `DomRenderer`, reached as `player.renderer` | In `mode: 'dom'`, the default. `mode: 'canvas'` is new and makes `player.renderer` a `CanvasRenderer`. |
690
+ | `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. |
691
+ | `renderer.render(skeleton)` | `renderer.render(frame)` | Takes a `PosedFrame`, not a skeleton. `player.posedFrame()` returns the one the player would draw. |
692
+ | `renderer.pixelRatio` | `player.renderer.pixelRatio` | On both renderers. Carried over when `player.mode` changes. |
693
+ | `renderer.syncPixelRatio()` | `player.renderer.syncPixelRatio()` | On both renderers, the same meaning and the same deadband (since 1.3.0; before it, DOM mode only). |
694
+ | `renderer.meshBackingPixels` | `DomRenderer.meshBackingPixels` | DOM mode only. |
695
+ | `renderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | `DomRenderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | Same values (`'canvas2d'` default, `'webgl'`). Also settable up front as `RigPlayerOptions.meshBackend`. |
696
+ | `renderer.triangleExpand` | `DomRenderer.triangleExpand` | Same default (0.5). |
697
+ | `renderer.clipping` | `player.renderer.clipping` | On both renderers. Carried over when `player.mode` changes. |
698
+ | `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. |
699
+ | `meshCount`, `meshReuseCount`, `canvasReallocCount`, `triangleCount` | same names on `DomRenderer` | |
700
+ | 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`. |
701
+ | dark (two-colour) tint: unsupported | `DomRenderer`: unsupported. `CanvasRenderer`: drawn | Carried as `dark` on a `PosedSlot`. The canvas mode's canvas2d fallback draws alpha only. |
702
+ | `renderer.dispose()` | `player.dispose()` | Then `assets.dispose()`, as before. `DomRenderer.dispose()` and `CanvasRenderer.dispose()` exist for a renderer you constructed yourself. |
703
+ | `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. |
704
+ | `bone.scaleY = …` on a `Skeleton` between `state.apply` and `updateWorldTransform` | `RigPlayerOptions.adjust`, `player.adjust`, `player.repose()` | A function called once per pose with the pose's bone locals (`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. |
705
+ | `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. |
706
+
707
+ ### No counterpart in 1.0.0
708
+
709
+ Things a 0.8 caller could do because it held the spine-core objects, or through a 0.8
710
+ export, for which 1.0.0 declares nothing:
711
+
712
+ - **A Spine export as input.** `loadSkeletonJson`, `loadSkeletonBinary`, `loadAtlasAssets`,
713
+ the `spine-html/binary` entry and `DomTexture` have no counterpart, and neither does
714
+ choosing which spine-core generation reads the data. `loadModel` reads a compiled model
715
+ document and nothing else.
716
+ - **Scaling the skeleton as it is read.** `LoadModelOptions` declares no `scale`. Scaling
717
+ the root element is still the caller's.
718
+ - **One unpacked atlas shared by different skeletons.** A document is one rig, and each
719
+ `loadModel` cuts its own regions and owns its own blob URLs. Sharing is per rig: one
720
+ load, several players.
721
+ - **Changing one slot from code** (its attachment, its colour). `RigPlayer` declares only
722
+ the whole-rig `color`. The frame record is a plain object, so a caller that draws its
723
+ own frames through `DomRenderer.render` or `CanvasRenderer.render` decides what is in
724
+ it.
725
+ - **Choosing how physics is stepped each frame.** 0.8's loop passed `Physics.update`
726
+ itself; 1.0.0 declares no physics option. `play` and `seek` reset physics at the
727
+ animation's first pose and every step advances it.
728
+ - **Track callbacks other than the two declared.** `on` accepts `'event'` and
729
+ `'complete'`.
730
+
731
+ Knobs on `DomRenderer` that `CanvasRenderer` does not declare:
732
+
733
+ - `meshBackend` and `meshBackendActive`. The canvas mode has its own pair, `backend` and
734
+ `backendActive` (type `CanvasBackend`, `'webgl'` by default).
735
+ - `triangleExpand`, `meshBackingPixels`, `clipWriteCount`, `meshCount`, `meshReuseCount`,
736
+ `triangleCount`. The canvas mode's counters are `drawCalls`, `blendFlushes`,
737
+ `trianglesDrawn`, `framesReused` and `blits`.
738
+
739
+ Both declare `pixelRatio`, `clipping`, `clipCount`, `clipSkipCount`, `canvasReallocCount`,
740
+ `render` and `dispose`. Only `pixelRatio` and `clipping` carry over when `player.mode`
741
+ changes.
742
+
743
+ ### Before and after
744
+
745
+ 0.8.0:
746
+
747
+ ```ts
748
+ import { Skeleton, AnimationState, AnimationStateData, Physics } from '@esotericsoftware/spine-core';
749
+ import { loadSkeletonAssets, SpineHtmlRenderer } from 'spine-html';
750
+ const assets = await loadSkeletonAssets({ atlasUrl: '/hero/hero.atlas', skeletonUrl: '/hero/hero.json' });
751
+ const skeleton = new Skeleton(assets.data);
752
+ const state = new AnimationState(new AnimationStateData(assets.data));
753
+ state.setAnimation(0, 'walk', true);
754
+ const renderer = new SpineHtmlRenderer(rootElement, assets.regionImages);
755
+ function frame(delta: number) {
756
+ state.update(delta);
757
+ state.apply(skeleton);
758
+ skeleton.update(delta);
759
+ skeleton.updateWorldTransform(Physics.update);
760
+ renderer.render(skeleton);
761
+ }
762
+ renderer.dispose(); assets.dispose(); // on unload
763
+ ```
764
+
765
+ 1.0.0:
766
+
767
+ ```ts
768
+ import { loadModel, RigPlayer } from '@rig-c/play';
769
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
770
+ const player = new RigPlayer(rootElement, assets);
771
+ player.play('walk', { loop: true });
772
+ function frame(delta: number) {
773
+ player.frame(delta);
774
+ }
775
+ player.dispose(); assets.dispose(); // on unload
776
+ ```
777
+
778
+ ## Demo (this repository)
779
+
780
+ ```bash
781
+ bun install
782
+ bun run dev
783
+ ```
784
+
785
+ The demo plays rigc's own gallery rigs (`walk`, `flex`, `squash`, `portrait`,
786
+ `nod`, `look`, `ride`), each built by `rigc build` from a pinned rigc tag into
787
+ `public/rigs/<name>/` on first `dev`/`build` — and each again with `--pack` into
788
+ `public/rigs/<name>-packed/`. The same step builds this repository's test rigs
789
+ (`tests/rigs/`: `figure`, `glow`, `turned`), which `?rig=` also reaches; nothing
790
+ is downloaded but the rigc tag's gallery sources. `bun run fetch-assets` runs it
791
+ manually, and it is idempotent.
792
+
793
+ Debug knobs (query string): `?rig=flex` `?anim=wave` `?count=10` pick the scene,
794
+ `?tint=ff8080` tints the whole rig, `?dpr=2` overrides the mesh-canvas backing
795
+ ratio, `?timescale=0` freezes the pose (every mesh should report "reused"),
796
+ `?expand=0` disables the crack-closing clip overdraw, `?backend=webgl` rasterizes
797
+ meshes through the shared WebGL blitter (also a live header select; the stats line
798
+ names the active backend), `?clipping=0` turns clips back off (they are then
799
+ counted and skipped), and `?time=1.2` seeks every instance to the same pose for
800
+ deterministic captures.
801
+
802
+ ## Tests
803
+
804
+ ```bash
805
+ bun run test # builds + serves the demo, then runs chromium + webkit
806
+ ```
807
+
808
+ Playwright drives the demo (`tests/`, config in `playwright.config.ts`; CI
809
+ runs the same suite on ubuntu, one job). Every scene is a rig built by
810
+ `rigc build` and posed by `rig-c/core` through `RigPlayer` — rigc's gallery rigs,
811
+ and three test rigs authored in this repository (`tests/rigs/`) for what the
812
+ gallery does not carry: `figure` (clipping attachments in rigc's format —
813
+ convex, inverse, nested, whole-skeleton — rigid parts on turned bones, two grid
814
+ meshes), `glow` (additive slots with slot colours over two weighted meshes) and
815
+ `turned` (a region packed turned 90° on a hand-packed page). No Spine runtime
816
+ is installed or compared against: the pose is rig-c's, held to spine-core at
817
+ tolerance 0 by rig-c's own CI, and a pose doubt is a rigc issue. The visual
818
+ checks are **A/B within one run**: no golden snapshots are committed (they rot
819
+ across platforms/GPUs) — instead the same deterministic pose (`?time` +
820
+ `?timescale=0`) is screenshotted twice in the same engine and the buffers are
821
+ diffed directly with a shift-tolerant comparison, so missing parts, wrong
822
+ colors, and tint/blend/clip divergence fail regardless of platform.
823
+
824
+ **The picture gate** is the two render modes drawing the same picture: every
825
+ scene captured in the DOM mode and in the canvas mode and diffed — bad pixels at
826
+ most 1.5% of drawn content on the test-rig scenes (measured floors 0.007–0.524%,
827
+ macOS Chromium; each scene's own feature dropped from the canvas mode — tint,
828
+ additive blend, clipping, a mesh — scores 4.2–77%) and 2.5% on the gallery
829
+ scenes (floors 0.585–1.553%, Chromium and WebKit). The DOM tier's two mesh
830
+ backends get the same A/B (`?backend=canvas2d` against `?backend=webgl`, at most
831
+ 0.5%). Hairline seams are guarded by a deterministic canary (`?expand=0` must
832
+ change the canvas2d raster), the canvas2d source sub-rect by another (expand 32
833
+ against 8), and counter tests pin the dirty-skip / grow-only-backing /
834
+ clip-counter invariants plus the region corner order (BL, UL, UR, BR) and the
835
+ turned-region UVs, read off rig-c's pose against hand-written numbers.
836
+
837
+ Clipping gets its own oracle (`tests/clipping.spec.ts`), because a clip-path
838
+ that went through the wrong transform still parses and still counts as applied.
839
+ A clipped capture is compared against the **unclipped** capture of the same
840
+ slots masked in **screen space** by the world polygon — built once through the
841
+ stage transform, never through an element's local frame, so the check cannot
842
+ agree with the renderer by sharing its mistake. What it asserts is occupancy,
843
+ not pixel values: outside a 3 px band around the polygon's outline (where the
844
+ browser's clip-path antialiasing and the canvas `ctx.clip()` the oracle uses
845
+ legitimately differ), **no artwork may survive where the polygon excludes it
846
+ and none may go missing where it does not** — both counts absolute, no budget.
847
+ Pixel values are logged but not asserted, because a clipped element is drawn
848
+ through a mask and its silhouettes come out a shade different all over the
849
+ picture; a control capture, clipped by a polygon that removes nothing, pins
850
+ that down by coming back byte-identical to the unclipped one.
851
+
852
+ The loading path is not observable in a rendered frame, so it gets its own
853
+ page (`tests/harness.html`, a second build entry) that exposes the library to
854
+ the specs directly. Its oracle is the browser: a revoked object URL stops
855
+ resolving, so blob ownership — every unpacked URL freed, nothing the caller
856
+ owns touched, nothing stranded by a failed load — is asserted rather than
857
+ assumed.
858
+
859
+ ## Benchmark: DOM mode vs canvas mode
860
+
861
+ ```bash
862
+ bun run bench:dev # the page, for a person to watch (and for Safari)
863
+ bun run bench # drives it in a headed Chromium and writes a report
864
+ ```
865
+
866
+ `bench/` is a separate vite project — not part of the library, and not part of
867
+ what `bun run build` emits — that plays the same rigc gallery build in both
868
+ render modes side by side, each with its own stats line in the demo's style.
869
+ Scenes by query string: `?scene=mesh` (`portrait-packed` turning its head, the
870
+ deform tier working every frame), `?scene=held` (the same pose frozen, where
871
+ unchanged meshes reuse their raster and an unchanged frame is reused),
872
+ `?scene=rigid` (`walk-packed`, region attachments only) and
873
+ `?scene=many&count=N` independent players; `?rig=` and `?model=` name another
874
+ build. All four take `&count=N` up to 64 (a page limit, so that a cell stays
875
+ something you can see), laid out on one grid whose per-rig scale shrinks with
876
+ the column count, so a step up in count adds rigs rather than cropping the ones
877
+ already there.
878
+
879
+ **The two sides draw the same picture.** Both place rigs with one derivation and
880
+ clip each to the same rect — the pane for a grid, the rig's own cell for `many`
881
+ — with `overflow: hidden` on a box, the way a page embeds a player, and then
882
+ *check* it: each side reports `origin Δpx`, the largest gap between where the
883
+ grid puts a rig and where that side drew it, and the report table carries it per
884
+ row. (`contain: layout paint` is deliberately not used: paint containment clips
885
+ to the same edge `overflow: hidden` does, and what it adds past the clip — a
886
+ stacking context, a containing block, an independent formatting context —
887
+ changes how one side is composited and buys the comparison nothing.) Both modes
888
+ share the package's single WebGL context however many players there are; the
889
+ canvas side's stats line says how many contexts the page holds.
890
+
891
+ `scripts/bench.mjs` walks `bench/scenes.json`, opens each scene in a **headed**
892
+ Chromium (a hidden page throttles `requestAnimationFrame`, so a headless
893
+ reading would be the browser's power policy rather than either mode's cost),
894
+ runs a fixed number of frames per mode — one at a time, because rAF has a single
895
+ cadence per document — and writes JSON plus a markdown table with the
896
+ conditions attached: browser version, device pixel ratio, window size, GPU
897
+ string, and the load average before and after. Over a threshold it stamps the
898
+ whole report `INVALID: load average too high` and exits non-zero, because a
899
+ benchmark on a busy machine measures the machine. `--corpus <dir>` points it at
900
+ local rigc builds that never enter this repository.
901
+
902
+ > **Numbers pending.** No comparison table is published yet. A reading taken on
903
+ > a loaded machine, a CI runner or a headless browser would not be worth
904
+ > printing. The table will be taken the way every other number in this README
905
+ > was: the two stats lines read on a real device, on a quiet machine, per scene
906
+ > and per engine — including Safari, which stays manual because no automation
907
+ > preserves what is being measured there. Until then the instrument exists and
908
+ > the measurement does not.
909
+
910
+ The server is part of the test: a run tests whatever is being served on its
911
+ port. Two checkouts of this repository on one machine (a worktree, a second
912
+ clone) default to the same port 4321 and reuse an existing server, so the
913
+ second run quietly tests the first one's build — green, and about the wrong
914
+ tree. Give each concurrent checkout its own port with `TEST_PORT`
915
+ (`TEST_PORT=4333 bun run test`); setting it also disables server reuse, so a
916
+ busy port fails the run instead of being borrowed. Unset, nothing changes:
917
+ port 4321, reuse unless `CI`.
918
+
919
+ Standing rule: **headless numbers are never Safari performance evidence** —
920
+ nothing in the suite asserts timing, and headless-WebKit fps/ms readings do
921
+ not transfer (software rasterizer, measured up to 28× off real Safari). The
922
+ perf oracle is the demo's stats line on a real device.
923
+
924
+ ## How it works
925
+
926
+ 1. `loadModel` reads the rig's compiled model document (`rig-c/core`'s `readModel`),
927
+ and `RigPlayer` opens an animation on a live track (`openTrack`): bones,
928
+ constraints, physics, deform and draw order are posed by the core — all
929
+ CPU-side, renderer-agnostic — one `dt` at a time.
930
+ 2. At load time each region of the document's `pages` section is cut into its own
931
+ bitmap (rotation restored), one blob URL per region — except regions that already
932
+ cover their whole page, which are used as they are.
933
+ 3. Per frame, the pose becomes a `PosedFrame`: for each slot in draw order, a region's
934
+ four world corners (order **BL, UL, UR, BR**) or a mesh's world vertices, page UVs
935
+ and triangles, its tint, blend and clip. For a region: flip Y (the frame is Y-up),
936
+ derive the affine from three corners, write one `matrix()` string. That's the
937
+ whole rigid render path.
938
+
939
+ ## License
940
+
941
+ @rig-c/play's own code is MIT (see [LICENSE](LICENSE)). That says nothing about Spine,
942
+ so here is what is true of this package and this repository, stated plainly:
943
+
944
+ 1. The published package — `@rig-c/play`, and `rig-play` and `spine-html`, the same files — **links
945
+ nothing from Esoteric Software**. Its one module bundles the renderer and
946
+ `rig-c/core` (MIT), which poses rigc's model document; no part of the
947
+ [Spine Runtimes](https://github.com/EsotericSoftware/spine-runtimes) is in it or
948
+ required beside it.
949
+ 2. This repository links no Spine runtime either, since #63: no package of Esoteric
950
+ Software's npm scope is a dependency of any kind, and the lockfile names none.
951
+ `tests/package.spec.ts` asserts both.
952
+ 3. `rigc build`, which this repository runs to make its fixtures, also writes Spine
953
+ skeleton data (`skeleton.json` and `skeleton.atlas`) beside each model document.
954
+ Nothing here reads it, and it is neither committed nor published.
955
+ 4. A product that plays that Spine data with a Spine Runtime has integrated the Spine
956
+ Runtimes, whose licence permits it **under Section 2 of the
957
+ [Spine Editor License Agreement](https://esotericsoftware.com/spine-editor-license)**,
958
+ or otherwise on the condition that **each user of the product obtains their own
959
+ Spine Editor license** — the licence's own two routes, restated, not a term of
960
+ this project.
961
+
962
+ > **What a consumer does with rigc's Spine-format output is governed by Esoteric
963
+ > Software's terms**, not by this package — @rig-c/play neither adds those terms nor
964
+ > removes them.
965
+
966
+ No example assets of Esoteric Software's are fetched or redistributed. See
967
+ [NOTICE.md](NOTICE.md) for the full notice.