rig-play 0.8.0 → 1.1.0

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.
Files changed (44) hide show
  1. package/NOTICE.md +50 -23
  2. package/README.md +511 -456
  3. package/dist/CanvasRenderer.d.ts +139 -0
  4. package/dist/CanvasRenderer.d.ts.map +1 -0
  5. package/dist/{SpineHtmlRenderer.d.ts → DomRenderer.d.ts} +70 -65
  6. package/dist/DomRenderer.d.ts.map +1 -0
  7. package/dist/DomTexture.d.ts +34 -14
  8. package/dist/DomTexture.d.ts.map +1 -1
  9. package/dist/MeshGlBlitter.d.ts +67 -1
  10. package/dist/MeshGlBlitter.d.ts.map +1 -1
  11. package/dist/index.d.ts +6 -4
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7470 -5
  14. package/dist/index.js.map +1 -1
  15. package/dist/loadModel.d.ts +92 -0
  16. package/dist/loadModel.d.ts.map +1 -0
  17. package/dist/player.d.ts +198 -0
  18. package/dist/player.d.ts.map +1 -0
  19. package/dist/posedFrame.d.ts +99 -0
  20. package/dist/posedFrame.d.ts.map +1 -0
  21. package/package.json +13 -19
  22. package/dist/DomTexture.js +0 -520
  23. package/dist/DomTexture.js.map +0 -1
  24. package/dist/MeshGlBlitter.js +0 -365
  25. package/dist/MeshGlBlitter.js.map +0 -1
  26. package/dist/SpineHtmlRenderer.d.ts.map +0 -1
  27. package/dist/SpineHtmlRenderer.js +0 -1107
  28. package/dist/SpineHtmlRenderer.js.map +0 -1
  29. package/dist/binary.d.ts +0 -48
  30. package/dist/binary.d.ts.map +0 -1
  31. package/dist/binary.js +0 -29
  32. package/dist/binary.js.map +0 -1
  33. package/dist/coreCompat.d.ts +0 -153
  34. package/dist/coreCompat.d.ts.map +0 -1
  35. package/dist/coreCompat.js +0 -136
  36. package/dist/coreCompat.js.map +0 -1
  37. package/dist/loadAtlasAssets.d.ts +0 -73
  38. package/dist/loadAtlasAssets.d.ts.map +0 -1
  39. package/dist/loadAtlasAssets.js +0 -75
  40. package/dist/loadAtlasAssets.js.map +0 -1
  41. package/dist/loadSkeletonAssets.d.ts +0 -88
  42. package/dist/loadSkeletonAssets.d.ts.map +0 -1
  43. package/dist/loadSkeletonAssets.js +0 -79
  44. package/dist/loadSkeletonAssets.js.map +0 -1
package/README.md CHANGED
@@ -1,32 +1,43 @@
1
1
  <p align="center">
2
- <img src="assets/banner.svg" alt="spine-html - DOM-first Spine 2D runtime" width="100%" />
2
+ <img src="assets/banner.svg" alt="rig-play - plays rigc's compiled model as DOM elements or one canvas" width="100%" />
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <a href="https://www.npmjs.com/package/spine-html"><img src="https://img.shields.io/npm/v/spine-html.svg?style=flat-square&color=FF6B4A" alt="npm version" /></a>
7
- <a href="https://www.npmjs.com/package/spine-html"><img src="https://img.shields.io/npm/dm/spine-html.svg?style=flat-square&color=EC4899" alt="npm downloads" /></a>
6
+ <a href="https://www.npmjs.com/package/rig-play"><img src="https://img.shields.io/npm/v/rig-play.svg?style=flat-square&color=FF6B4A" alt="npm version" /></a>
7
+ <a href="https://www.npmjs.com/package/rig-play"><img src="https://img.shields.io/npm/dm/rig-play.svg?style=flat-square&color=EC4899" alt="npm downloads" /></a>
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
- Render [Spine](https://esotericsoftware.com/) skeletal animations as **plain DOM** — one
12
- absolutely-positioned `<img>` per slot, posed with a single CSS `matrix()` write per frame.
13
- No canvas, no WebGL for the rigid tier.
11
+ Plays the rigs [rigc](https://github.com/firejune/rigc) 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
+ `spine-html` is the same package under the name it first shipped as.
14
16
 
15
17
  ## Why
16
18
 
17
- Every Spine web runtime rasterizes into a canvas. But `spine-core` is fully
18
- renderer-independent: it computes bone world transforms — and even deformed mesh
19
- vertices — on the CPU. For **region attachments** (the rigid parts of a skeleton), a bone
20
- transform is a plain affine map, and CSS `transform: matrix()` expresses that *exactly*.
21
- So the rigid tier of a Spine skeleton can live in the DOM, composited by the browser,
22
- inspectable in devtools, styled with CSS.
23
-
24
- The idea has been floated on the official forum three times since 2014 and was never
25
- built — the blockers named were meshes, clipping, and DOM update overhead. This project
26
- is the experiment: how far does the DOM actually go, and how cheap is it?
27
-
28
- Measured so far (PoC, Apple silicon — headless and on-device numbers are labeled,
29
- they do **not** substitute for each other):
19
+ The package began in 0.x as `spine-html`, an experiment in drawing Spine skeletons
20
+ without a canvas. Every Spine web runtime rasterizes into one, yet the pose itself is
21
+ computed on the CPU, and for a **region attachment** (a rigid part) the bone transform is
22
+ a plain affine map that CSS `transform: matrix()` expresses *exactly* — so the rigid tier
23
+ could live in the DOM, composited by the browser, inspectable in devtools, styled with
24
+ CSS. The idea had been floated on the Spine forum three times since 2014 and never built;
25
+ the blockers named were meshes, clipping and DOM update overhead, and the 0.x line
26
+ answered each of them (per-part mesh canvases, element-level `clip-path`, dirty-skip).
27
+
28
+ At 1.0 it stopped being a Spine runtime. It plays the model document `rigc build` writes,
29
+ poses it through `rig-c/core` — rigc's own posing core, bundled into the package — and
30
+ links nothing from Esoteric Software; a Spine export is refused by name. It gained a
31
+ second render mode that draws the whole frame into one canvas. And it took the name
32
+ `rig-play`, which says what it is; `spine-html` keeps being published with the same
33
+ files, so links to the old name keep working.
34
+
35
+ ### Measured
36
+
37
+ Measured on the 0.x line, on the spineboy exports it then played (PoC, Apple silicon —
38
+ headless and on-device numbers are labeled, they do **not** substitute for each other).
39
+ The renderer tiers they describe carried over; they have not been re-measured since the
40
+ player moved to rigc's model (#60):
30
41
 
31
42
  - Rigid only (spineboy-ess), headless Chromium: 10 skeletons / 180 slot images at
32
43
  **~0.03 ms skeleton math + ~0.22 ms DOM writes per frame** — about 1.5% of a
@@ -63,10 +74,24 @@ deforming) holds **60 fps on every engine measured**, and the 10-skeleton
63
74
  stress scene holds 45 fps on the weakest one (Safari, webgl backend). Ships
64
75
  with a self-contained test suite (backend visual parity + invariants) and CI.
65
76
 
77
+ **Two render modes.** `mode: 'dom'` (the default, everything below) draws a rig
78
+ as elements. `mode: 'canvas'` draws the whole frame into **one `<canvas>`** per
79
+ player: every slot as textured triangles from its atlas page through the same
80
+ shared WebGL context the mesh tier's `webgl` backend uses, into one rect, then
81
+ one `drawImage` onto the player's canvas. Tint and dark (two-colour) tint are
82
+ vertex colours, blend modes are batch state, clips are cut on the CPU by
83
+ `rig-c/core`'s clipper — no `clip-path`, no SVG filter, no per-slot element.
84
+ `RigPlayer` swaps between the two in place (`player.mode = 'canvas'`). The
85
+ canvas mode is held against the DOM tier pixel for pixel on the parity suite's
86
+ frozen poses (bad pixels ≤ 1.6% of drawn content, measured, macOS Chromium and
87
+ WebKit); which mode is faster where is **not measured on a device yet**, so the
88
+ default stays `dom`.
89
+
66
90
  - ✅ Region attachments (rigid parts): exact affine mapping, draw-order via `z-index`,
67
91
  attachment swaps, alpha
68
92
  - ✅ Atlas unpacking at load time (90°-packed regions restored to the orientation
69
- spine-core's UVs name, held by the pixel oracle on exports whose packer rotated),
93
+ the region's UVs name, held against hand-written UVs on a rigc build whose region
94
+ is packed turned),
70
95
  so the rigid per-frame path never touches a canvas — a region that covers its whole
71
96
  page is passed through uncut, and `revokeRegions()` frees the rest when a skeleton
72
97
  is unloaded
@@ -92,8 +117,8 @@ with a self-contained test suite (backend visual parity + invariants) and CI.
92
117
  (one active clip at a time, applied from the clip's slot through its end slot
93
118
  inclusive), with the polygon expressed in each element's own local frame — the
94
119
  inverse of the `<img>` matrix for rigid slots, the canvas translate for mesh
95
- slots. Covers **part masks** (a clip with a real end slot, like this repository's
96
- own `portal` animation) and **whole-skeleton clips** (an end slot that never
120
+ slots. Covers **part masks** (a clip with a real end slot, like the figure test
121
+ rig's `mask` animation) and **whole-skeleton clips** (an end slot that never
97
122
  arrives, which takes a fast path: one `clip-path` on the root instead of one per
98
123
  element). **Inverse clips** are supported too: the region they keep has a hole
99
124
  in it, so it needs two rings, and `polygon()` carries only one — a box and a
@@ -134,284 +159,184 @@ with a self-contained test suite (backend visual parity + invariants) and CI.
134
159
  ## Install
135
160
 
136
161
  ```bash
137
- npm i spine-html @esotericsoftware/spine-core
162
+ npm i rig-play
163
+ npm i -D rig-c # the build tool: `rigc build` writes the model rig-play loads
138
164
  ```
139
165
 
166
+ The package plays rigs built by [rigc](https://github.com/firejune/rigc):
167
+ `rigc build` writes a compiled model document, `skeleton.model.json` (spec
168
+ `rigc-compiled/3`), next to the Spine pair and the atlas pages. That document
169
+ is what the player loads, and it states everything posing and drawing need —
170
+ the skeleton, the animations, and where every region sits on its page — so the
171
+ `.atlas` and `skeleton.json` beside it are not fetched. Poses are computed by
172
+ `rig-c/core`, rigc's own posing core, which is bundled into this package: there
173
+ is nothing else to install.
174
+
140
175
  ```ts
141
- import { Skeleton, AnimationState, AnimationStateData, Physics }
142
- from '@esotericsoftware/spine-core';
143
- import { loadSkeletonAssets, SpineHtmlRenderer } from 'spine-html';
176
+ import { loadModel, RigPlayer } from 'rig-play';
144
177
 
145
- const assets = await loadSkeletonAssets({
146
- atlasUrl: '/spineboy/spineboy.atlas',
147
- skeletonUrl: '/spineboy/spineboy-pro.json',
148
- });
178
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
149
179
 
150
- // A positioned element becomes the skeleton origin (Spine is Y-up: the
151
- // skeleton grows upward from it). Layout and scaling are the caller's.
152
- const skeleton = new Skeleton(assets.data);
153
- const state = new AnimationState(new AnimationStateData(assets.data));
154
- state.setAnimation(0, 'walk', true);
155
- const renderer = new SpineHtmlRenderer(rootElement, assets.regionImages);
180
+ // A positioned element becomes the rig's world origin (Y-up: the rig grows
181
+ // upward from it). Layout and scaling are the caller's.
182
+ const player = new RigPlayer(rootElement, assets);
183
+ player.play('walk'); // looping; { loop: false } holds at the end
156
184
 
157
185
  function frame(delta: number) {
158
- state.update(delta);
159
- state.apply(skeleton);
160
- skeleton.update(delta);
161
- skeleton.updateWorldTransform(Physics.update);
162
- renderer.render(skeleton);
186
+ player.frame(delta); // step the track by delta × timeScale, draw
163
187
  }
164
188
 
165
189
  // Unloading (a cutscene ends, a level swaps): elements first, bitmaps second.
166
190
  // The unpacked regions are blob URLs — nothing else frees them.
167
- renderer.dispose();
191
+ player.dispose();
168
192
  assets.dispose();
169
193
  ```
170
194
 
171
- `spine-html` is a third-party renderer and is not affiliated with or endorsed by
195
+ The player owns one track: `play(name, { loop })` opens an animation at its first
196
+ pose, `frame(dt)` steps it (or `advance(dt)` then `draw()`, to time the two
197
+ apart), `pause()` / `resume()` hold and release it, `timeScale` scales every
198
+ step, and `seek(t)` moves it to a time. A seek is "reopen, then step to the
199
+ time" in `seekStep` increments (1/60 s by default) — never a jump, because a
200
+ physics rig has history and its pose at a time depends on the steps that
201
+ reached it. `on('event', …)` receives the event keys each step crosses, in the
202
+ runtime's order across a loop's wrap; `on('complete', …)` fires once a step
203
+ completes a cycle. `skin` (a constructor option) poses under a named skin;
204
+ without it the rig is posed the way a fresh Spine skeleton is, with no skin
205
+ set. `player.color` multiplies a whole-rig colour into every slot's tint.
206
+ `player.renderer` is the `DomRenderer` underneath, whose knobs and counters
207
+ are below.
208
+
209
+ **What it refuses, by name.** A Spine export (`.json`, `.skel`) is refused
210
+ before anything is fetched: this package reads compiled models and nothing
211
+ else. A document whose `spec` is not one `rig-c/core` reads is refused with the
212
+ core's own sentence plus "build it with `rigc build`", and so is a
213
+ `rigc-compiled/1` document, which predates the `pages` section.
214
+
215
+ `rig-play` is a third-party renderer and is not affiliated with or endorsed by
172
216
  Esoteric Software.
173
217
 
174
- ### Which spine-core to install
175
-
176
- > **Install the `@esotericsoftware/spine-core` that matches the Spine editor
177
- > version your data was exported from.**
178
-
179
- A Spine runtime reads only the data its own generation exported, so this is
180
- never a choice between "newest" and "oldest" — it is decided by your `.json` or
181
- `.skel` files. `spine-core` is a **peer** dependency here precisely so the
182
- decision stays yours, and `spine-html` draws from either generation:
183
-
184
- | `@esotericsoftware/spine-core` | `spine-html` |
185
- | --- | --- |
186
- | **4.3.x** | ✅ supported |
187
- | **4.2.x** | ✅ supported |
188
- | **4.1.x** | ✅ supported — browser/bundler only, see the Node note below |
189
- | **4.0.x** | ✅ supported — browser/bundler only, see the Node note below |
190
- | 3.8.x and older | ❌ not supported — no runtime is published under this package name |
191
-
192
- *Supported* means one specific thing: a column of this repository's CI runs the
193
- **whole** test suite on that minor, against **that version's own** example
194
- exports, with the pixel oracle drawing the same poses through
195
- `@esotericsoftware/spine-webgl` of the same generation. `peerDependencies` is
196
- that set and nothing wider. Nothing in the API above changes with the version —
197
- the difference is absorbed internally, in one file, chosen once per renderer by
198
- looking at the objects spine-core hands over.
199
-
200
- > **Node note, for 4.1 and 4.0 only.** Those two releases of `spine-core`
201
- > cannot be imported by plain Node: they ship extensionless relative specifiers
202
- > in their own `dist/`, and 4.0 also omits `"type": "module"`. Every bundler
203
- > resolves them (so do CDNs such as esm.sh), which is how a browser consumer
204
- > meets them, and `spine-html` itself is unaffected — but `import` of either
205
- > package from a Node process fails, so **SSR and Node-side tooling cannot load
206
- > them**. It is upstream's defect and not fixable from here. 4.1 has one more:
207
- > a single extensionless specifier inside its own `.d.ts` makes it fail a
208
- > `moduleResolution: "nodenext"` typecheck. 4.2 and 4.3 have neither problem.
209
-
210
- **Worth knowing, because it is silent.** If you point a 4.3 runtime at older
211
- data, the bad outcome is not the one that throws. 4.3 reads constraints from a
212
- single top-level `constraints` array, where 4.2 and older write separate
213
- `ik` / `transform` / `path` / `physics` ones — so an older export that still
214
- parses loads **none of its constraints at all**, and the skeleton animates on
215
- bones alone: no IK, no path, no physics, no error. Measured over a real-world
216
- corpus of 267 skeletons (88% of them 4.2 exports): 40% failed to parse under
217
- 4.3.13 outright, and across those that did parse, **0 of 7,065** constraints and
218
- **0 of 5,704** physics constraints were loaded. If a skeleton renders but goes
219
- limp, this is why — and installing the matching `spine-core` is the whole fix.
220
-
221
- **If your data was exported by 4.1 or 4.0, install the matching runtime.**
222
- Since 0.8.0 both are supported columns, and installing spine-core 4.2 for such
223
- data is **not** a substitute — the failure is the quiet kind again. Read off
224
- `SkeletonJson` in each generation:
225
-
226
- - 4.0 and 4.1 write a bone's inheritance mode as `transform`
227
- (`noRotationOrReflection`, `onlyTranslation`, …); 4.2 reads `inherit` and
228
- nothing else, so every such bone falls back to `Normal`.
229
- - 4.0 writes mesh deform timelines under the animation's `deform`; 4.1 and 4.2
230
- read `attachments` and never look at `deform`, so those timelines are dropped.
231
-
232
- Neither throws. Over a real-world corpus of shipped web skeletons, 96 of 418
233
- exports made by 4.0 and 20 of 101 made by 4.1 carry such bones (643 of them),
234
- and 91 of the 4.0 exports carry 1,031 deform timelines between them — all of it
235
- lost without a message under 4.2, while the file parses, every constraint
236
- loads, and the rig renders without an error.
237
-
238
- Spine's own spineboy export carries both, which is why every CI column checks
239
- this rather than trusting it: four bones at `noRotationOrReflection` and four
240
- mesh deform timelines, declared under the key that branch's exporter writes, and
241
- asserted to have arrived in the parsed `SkeletonData`.
242
-
243
- > **Correction.** The README shipped with 0.7.1, and the 0.7.0 release notes,
244
- > said "install spine-core 4.2" for 4.1 / 4.0 data — on the strength of exactly
245
- > the evidence that looks conclusive: every export parsed, all 14,022
246
- > constraints loaded, a 60-rig sample rendered cleanly. That evidence was real
247
- > and the conclusion was wrong, because **parsing without an error says nothing
248
- > about keys a newer reader no longer looks for.** The same corpus holds 19
249
- > Spine 3.8 exports that also parse cleanly under a 4.x runtime — and then pose
250
- > most of their bones at non-finite transforms (3.8 writes a rotate key as
251
- > `angle` and a curve as scalars; 4.0 reads `value` and an array). The test that
252
- > would have caught this is a diff of the keys each generation's reader
253
- > consumes, which is what the list above is. 0.7.1 corrected the advice to "4.2
254
- > is not a stand-in, and 4.1 / 4.0 are not supported"; 0.8.0 supports them, so
255
- > the advice is now simply the rule at the top of this section.
256
-
257
- Spine 3.8 and older are out of scope: they are not published under this package
258
- name.
259
-
260
- ### One atlas, several skeletons
261
-
262
- Skeletons that share an atlas — a character's `-ess` and `-pro` exports, a whole
263
- cast packed onto one page — load in two steps instead. Calling
264
- `loadSkeletonAssets` once per skeleton would fetch and unpack the same pages
265
- again and mint a second set of region blob URLs:
218
+ ### Drawing your own frames
266
219
 
267
- ```ts
268
- import { loadAtlasAssets, loadSkeletonJson, SpineHtmlRenderer } from 'spine-html';
220
+ `RigPlayer` is the convenient path; the renderer underneath takes a plain
221
+ record, so anything that can produce one can draw through it:
269
222
 
270
- const shared = await loadAtlasAssets({ atlasUrl: '/spineboy/spineboy.atlas' });
271
- const [essData, proData] = await Promise.all([
272
- loadSkeletonJson(shared, '/spineboy/spineboy-ess.json'),
273
- loadSkeletonJson(shared, '/spineboy/spineboy-pro.json'),
274
- ]);
223
+ ```ts
224
+ import { DomRenderer, type PosedFrame } from 'rig-play';
275
225
 
276
- const ess = new SpineHtmlRenderer(essRoot, shared.regionImages);
277
- const pro = new SpineHtmlRenderer(proRoot, shared.regionImages);
226
+ const renderer = new DomRenderer(rootElement, assets.regionImages, assets.pageImages);
227
+ renderer.render(frame); // frame: PosedFrame
228
+ ```
278
229
 
279
- // Unloading: every renderer first, then the one dispose() that owns the bitmaps.
280
- ess.dispose();
281
- pro.dispose();
282
- shared.dispose();
230
+ ```ts
231
+ PosedFrame { order: PosedSlot[] } // the slots that draw, in draw order
232
+ PosedSlot = { slot; z; blend: 'Normal'|'Additive'|'Multiply'|'Screen';
233
+ tint: [r, g, b, a]; dark?: [r, g, b];
234
+ clip?: { polygon: number[]; inverse: boolean; convex: boolean } } // world space, Y up
235
+ & ( { kind: 'region'; region: string; corners: number[] /* BL, UL, UR, BR */;
236
+ page: string; pma: boolean; uvs: number[] /* page UVs at the corners */ }
237
+ | { kind: 'mesh'; page: string; pma: boolean; vertices: number[];
238
+ uvs: number[]; triangles: number[]; sequence: number } )
283
239
  ```
284
240
 
285
- `loadAtlasAssets` takes the atlas half of the options above (`resolvePage`,
286
- `crossOrigin`, `fetch`) and `loadSkeletonJson` the skeleton half (`scale`,
287
- `fetch`). Ownership is the thing to keep straight: the atlas assets belong to
288
- the caller, reading a skeleton against them never frees them, and one
289
- `regionImages` map is meant to be handed to several renderers — so there is
290
- exactly one `dispose()` for however many skeletons were read. `loadSkeletonJson`
291
- asks only for `{ atlas }`, so a caller that built its atlas by hand (below) can
292
- still use it for the read. `loadSkeletonAssets` is these two calls with the
293
- atlas half kept private: use it for one atlas and one skeleton, this for the
294
- rest.
241
+ `player.posedFrame()` returns the record the player would draw. Two things the
242
+ renderer reads off it by identity rather than by value: every slot a clip
243
+ covers carries the **same** `clip` object (one that covers every slot of the
244
+ frame takes the one-`clip-path`-on-the-root path), and a mesh's `uvs` and
245
+ `triangles` are the same arrays from frame to frame for the same texture
246
+ mapping (a new array re-rasters the mesh).
295
247
 
296
- ### Binary exports (`.skel`)
248
+ ### Reading the pose
297
249
 
298
- Binary exports are read by a second parser, `SkeletonBinary`, which is as big as
299
- the JSON one. It lives behind its own entry point so that consumers who only
300
- ever read `.json` never carry it:
250
+ Two reads off the pose the player holds — the last `frame`/`advance`/`seek`,
251
+ or the setup pose before `play` — for framing a stage around a rig:
301
252
 
302
253
  ```ts
303
- import { loadAtlasAssets, SpineHtmlRenderer } from 'spine-html';
304
- import { loadSkeletonBinary } from 'spine-html/binary';
305
-
306
- const shared = await loadAtlasAssets({ atlasUrl: '/spineboy/spineboy.atlas' });
307
- const data = await loadSkeletonBinary(shared, '/spineboy/spineboy-pro.skel');
308
-
309
- const renderer = new SpineHtmlRenderer(rootElement, shared.regionImages);
310
- // Unloading, as above: renderer.dispose(); shared.dispose();
254
+ const head = player.bone('head'); // { x, y, a, b, c, d, rotationX, rotationY, scaleX, scaleY, active }
255
+ const box = player.bounds(); // { x, y, width, height } | null — world units, Y up
311
256
  ```
312
257
 
313
- `loadSkeletonBinary` is `loadSkeletonJson` with the parser swapped: same
314
- arguments, the same `{ scale, fetch }` options, the same ownership rule — it
315
- never frees `assets`, whichever way it ends. Those two calls are the binary path
316
- in full. There is no one-call `loadSkeletonAssetsBinary`, because the atlas half
317
- is precisely what a caller reading binary usually wants kept in hand, and hiding
318
- it again would only earn back a line.
258
+ `bone` is the core's world transform for that bone (`rig-c/core`'s `RawBone`,
259
+ unchanged), and an unknown name is refused by name. `bounds` is the box of
260
+ every region's four corners and every mesh's vertices in the frame record the
261
+ renderer takes; a slot under a clip counts only inside the clip polygon's own
262
+ box, an inverse clip removes nothing, and bounding boxes, paths and points do
263
+ not draw and do not count. Both cost nothing until called and read the same
264
+ numbers in `dom` and `canvas` mode.
319
265
 
320
- The subpath is the whole mechanism, so import it as written: pulling
321
- `loadSkeletonBinary` out of `'spine-html'` is not possible, by design. Worth
322
- knowing about the format itself: binary exports are version-locked to the
323
- runtime that reads them, so a `.skel` written by an editor newer than your
324
- installed `@esotericsoftware/spine-core` fails in the read, not in the fetch.
325
-
326
- ### Loading it yourself
266
+ ### One load, several players
327
267
 
328
- `loadSkeletonAssets` is optional sugar over five `spine-core` calls, and the
329
- package works without it. Drop to the low-level path whenever you need
330
- something no loader does — images that are already in memory, an atlas that is
331
- not fetched from a URL at all:
268
+ One `loadModel` backs any number of players: the regions are cut once, and
269
+ every player draws from the one `regionImages` map. A player owns none of it —
270
+ disposing a player leaves the bitmaps alive for the others — so there is exactly
271
+ one `assets.dispose()`, after the last player is gone:
332
272
 
333
273
  ```ts
334
- import { TextureAtlas, AtlasAttachmentLoader, SkeletonJson }
335
- from '@esotericsoftware/spine-core';
336
- import { DomTexture, unpackRegions, revokeRegions } from 'spine-html';
337
-
338
- const atlas = new TextureAtlas(atlasText);
339
- const pageImages = new Map<string, HTMLImageElement>();
340
- for (const page of atlas.pages) {
341
- const image = await loadImage(page.name); // your loader
342
- page.setTexture(new DomTexture(image));
343
- pageImages.set(page.name, image);
344
- }
345
- const regionImages = await unpackRegions(atlas, pageImages);
346
- const data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(jsonText);
274
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
275
+ const a = new RigPlayer(rootA, assets);
276
+ const b = new RigPlayer(rootB, assets, { skin: 'winter' });
347
277
 
348
- // …and on unload, after every renderer using them is disposed:
349
- revokeRegions(regionImages);
278
+ a.dispose();
279
+ b.dispose();
280
+ assets.dispose();
350
281
  ```
351
282
 
352
- A region the packer stored turned (`rotate: 90`) is cut upright again, and which
353
- way round that is comes from spine-core's own UVs rather than from the cut: such
354
- a region's corners carry `(u2, v2), (u, v2), (u, v), (u2, v)` in the order
355
- BL, UL, UR, BR, so the artwork's top-left corner sits at the packed rect's
283
+ A document is one rig. Two rigs whose builds share page images load as two
284
+ documents; the images are fetched by URL, so a page both name at the same URL
285
+ is loaded once by the browser — but each load cuts its own regions and owns its
286
+ own blob URLs.
287
+
288
+ ### Loading it yourself
289
+
290
+ `loadModel` is a convenience over two calls the package exports: read the
291
+ document, load its pages, and cut the regions with `unpackRegions`, which takes
292
+ the document's `pages` section — `{ name, width, height, pma, regions: [{ name,
293
+ x, y, width, height, degrees }] }` per page — and a map of page images keyed by
294
+ those names. Use it when the page images do not come from a URL (`resolvePage`
295
+ on `loadModel` covers the ones that come from another URL), and pair it with
296
+ `revokeRegions` on unload.
297
+
298
+ A region the packer stored turned (`degrees: 90`) is cut upright again, and
299
+ which way round that is comes from the UVs a runtime assigns such a region,
300
+ not from the cut: its corners carry `(u2, v2), (u, v2), (u, v), (u2, v)` in the
301
+ order BL, UL, UR, BR, so the artwork's top-left corner sits at the packed rect's
356
302
  bottom-left and unpacking it is a clockwise turn. Every release up to 0.7.0
357
- turned it the other way and drew rotated rigid parts 180° round (#49); if you
358
- worked around that by re-packing an atlas without rotation, you can stop.
303
+ turned it the other way and drew rotated rigid parts 180° round (#49).
359
304
 
360
305
  `unpackRegions` mints one blob URL per region; `revokeRegions` is its
361
306
  counterpart. It only frees URLs `unpackRegions` created, so a map you built
362
- yourself (below) and page images reused by the whole-page pass-through survive
363
- it — and calling it twice is a no-op. Load once for the page's lifetime and you
364
- can ignore it; load and unload repeatedly without it and you leak an atlas per
307
+ yourself and page images reused by the whole-page pass-through survive it — and
308
+ calling it twice is a no-op. Load once for the page's lifetime and you can
309
+ ignore it; load and unload repeatedly without it and you leak an atlas per
365
310
  cycle.
366
311
 
367
312
  ### Pages that ship at another resolution
368
313
 
369
- A page image may ship at a resolution its atlas does not declare — a
370
- half-resolution texture build, or an @2x variant, with the `size:` line left as
371
- the packer wrote it. Nothing here needs a flag for that. A region's bounds are
372
- read **relative to the declared page size** and scaled onto the image's natural
373
- size, which is exactly how `spine-core` derives the UVs the mesh tier samples
374
- with (`region.u = region.x / page.width`), so both tiers land on the same
375
- pixels at any resolution:
376
-
377
- ```
378
- hero.png
379
- size: 4096, 4096 # what the packer wrote
380
- # hero.png itself ships 2048×2048
381
- ```
314
+ A page image may ship at a resolution its document does not declare — a
315
+ half-resolution texture build, or an @2x variant, with the page's `width` and
316
+ `height` left as the build wrote them. Nothing here needs a flag for that. A
317
+ region's bounds are read **relative to the declared page size** and scaled onto
318
+ the image's natural size, which is how the page UVs the mesh tier samples with
319
+ are derived too (`u = x / page.width`), so both tiers land on the same pixels at
320
+ any resolution.
382
321
 
383
322
  Each unpacked bitmap comes out at the **native resolution of the pixels it was
384
323
  cut from** — half-resolution pages cost a quarter of the cut pixels, and
385
324
  nothing is upscaled back — while `RegionImage.width`/`height` stay in **atlas
386
325
  units**. Those two numbers are the `<img>` layout box and the denominator of
387
- its CSS matrix, so the skeleton poses identically and the browser scales the
326
+ its CSS matrix, so the rig poses identically and the browser scales the
388
327
  smaller bitmap into the same box, the way a GPU samples a smaller texture
389
- through the same UVs. Ship one atlas and swap the images per device if you
390
- like.
391
-
392
- The corollary: a `size:` line that is simply **wrong** is now wrong for the
393
- whole renderer rather than for the mesh tier alone. Bounds are read against
394
- what the atlas declares, so a page declared at a size its artwork was not
395
- packed at reads every region from the wrong place in both tiers — correct the
396
- `size:` line, which is what every other Spine runtime needs too.
397
-
398
- ### Premultiplied pages (`pma: true`)
399
-
400
- The Spine texture packer premultiplies alpha by default, and says so with a page
401
- line:
402
-
403
- ```
404
- hero.png
405
- size: 2048, 2048
406
- pma: true
407
- ```
408
-
409
- Such a page's RGB is already multiplied by its alpha, and nothing is asked of
410
- you: the flag is read off the atlas and each tier is handed the page in the
411
- convention it actually consumes. The `webgl` mesh backend uploads the texels
412
- unconverted (its blend already expects premultiplied source — lossless). The DOM
413
- and canvas2d tiers cannot: an `<img>` and `drawImage` composite straight alpha by
414
- definition, so they read a straight-alpha derivation of the page —
328
+ through the same UVs. Ship one build and swap the images per device if you
329
+ like (`resolvePage`).
330
+
331
+ ### Premultiplied pages (`pma`)
332
+
333
+ A page's `pma` flag in the document's `pages` section — the build copies it from
334
+ the atlas's `pma: true` line — says its RGB is already multiplied by its alpha,
335
+ and nothing is asked of you: each tier is handed the page in the convention it
336
+ actually consumes. The `webgl` mesh backend uploads the texels unconverted (its
337
+ blend already expects premultiplied source — lossless). The DOM and canvas2d
338
+ tiers cannot: an `<img>` and `drawImage` composite straight alpha by definition,
339
+ so they read a straight-alpha derivation of the page —
415
340
  `rgb = round(rgb * 255 / a)`, computed once and shared by the region cuts and
416
341
  the mesh raster. Without it every semi-transparent texel is multiplied by its
417
342
  alpha a second time and draws darker than it was authored: soft edges, soft
@@ -419,100 +344,89 @@ shadows, glows.
419
344
 
420
345
  What it costs: **one page-sized canvas per premultiplied page image**
421
346
  (width × height × 4 bytes — 1 MiB for a 1024×256 page, 16 MiB for a 2048×2048
422
- one), alive as long as you hold the page image and released with it; the cache
423
- is weak, so there is nothing to free by hand. A page with no `pma:` line derives
424
- nothing and takes exactly the path it always did. The division is 8-bit and
425
- starts from a canvas read, which has itself quantized a premultiplied texel, so
426
- a very transparent texel can land a few levels off — exact at alpha 0 and 255,
427
- within one level of 255 above alpha 128, and a little more below that, by an
428
- amount that belongs to the browser's canvas rather than to this package (some
429
- rasterizers round premultiplied storage several times more coarsely than
430
- others). It is bounded by what that canvas already costs, and it is invisible
431
- wherever the texel is: a texel at alpha 11 is ~4% opaque. The `webgl` backend,
432
- having no such step, is exact.
433
-
434
- One consequence worth knowing if your atlas is one part per page: a whole-page
347
+ one), alive as long as the page image is and released with it; the cache is
348
+ weak, so there is nothing to free by hand. A page without the flag derives
349
+ nothing. The division is 8-bit and starts from a canvas read, which has itself
350
+ quantized a premultiplied texel, so a very transparent texel can land a few
351
+ levels off — exact at alpha 0 and 255, within one level of 255 above alpha 128,
352
+ and a little more below that, by an amount that belongs to the browser's canvas
353
+ rather than to this package. It is invisible wherever the texel is: a texel at
354
+ alpha 11 is ~4% opaque. The `webgl` backend, having no such step, is exact.
355
+
356
+ One consequence worth knowing if your build is one part per page: a whole-page
435
357
  region on a premultiplied page is **cut** rather than handed through, because
436
358
  the page's own URL holds premultiplied pixels (see below).
437
359
 
438
360
  ### What unloading frees
439
361
 
440
- `revokeRegions()` — and `assets.dispose()`, which just calls it — frees the blob
441
- URLs `unpackRegions` minted, and nothing else. The rest is pinned by objects you
442
- own: an atlas **page image** is held by the `DomTexture` you attached to its
443
- page, and `SkeletonData` reaches that same image through every attachment
444
- (`region.texture.getImage()`), so a page is released only once the atlas, the
445
- skeleton data and every renderer drawing from them are gone. `atlas.dispose()`
446
- does not do it — `DomTexture.dispose()` is a no-op, because the image is yours.
447
- With the `webgl` mesh backend the shared blitter also uploads each page image on
448
- first use, but that GL texture is not the module's forever: every renderer
449
- drawing the page holds a reference to it, `renderer.dispose()` hands those back,
450
- and the texture is deleted once the last renderer using that page is disposed —
451
- a later frame that needs the page uploads it again. The cache is keyed weakly by
452
- the page image, so a renderer dropped without `dispose()` costs GPU memory until
362
+ `revokeRegions()` — and `assets.dispose()`, which calls it — frees the blob URLs
363
+ `unpackRegions` minted, and nothing else. The page images are held by
364
+ `assets.pageImages` and by every player drawing from them, so a page is
365
+ released once you drop the assets and every player is disposed. With the
366
+ `webgl` mesh backend the shared blitter also uploads each page image on first
367
+ use, but that GL texture is not the module's forever: every renderer drawing
368
+ the page holds a reference to it, `player.dispose()` hands those back, and the
369
+ texture is deleted once the last renderer using that page is disposed — a later
370
+ frame that needs the page uploads it again. The cache is keyed weakly by the
371
+ page image, so a renderer dropped without `dispose()` costs GPU memory until
453
372
  the context is lost, but never keeps the image itself alive.
454
373
 
455
- A meshed skeleton samples the page bitmap every frame, so its decoded form stays
456
- in use: a floor on the order of `page width × page height × 4` bytes per page —
457
- 1 MiB for spineboy's single 1024×256 page. A rigid-only skeleton draws nothing
458
- from the page after `unpackRegions`, and what a merely reachable, undrawn image
459
- costs is up to the browser, not measurable from script.
374
+ A rig with meshes samples its page bitmaps every frame, so their decoded form
375
+ stays in use: a floor on the order of `page width × page height × 4` bytes per
376
+ page. A rigid-only rig draws nothing from the pages after `unpackRegions`, and
377
+ what a merely reachable, undrawn image costs is up to the browser, not
378
+ measurable from script.
460
379
 
461
380
  Unload in this order:
462
381
 
463
- 1. `renderer.dispose()`, for every renderer using the images.
464
- 2. `revokeRegions(regionImages)` (or `assets.dispose()`).
465
- 3. Drop your references to the atlas and the skeleton data.
382
+ 1. `player.dispose()`, for every player using the assets.
383
+ 2. `assets.dispose()` (or `revokeRegions(regionImages)`).
384
+ 3. Drop your references to the assets.
466
385
 
467
- ### One part per page (loose part PNGs)
386
+ ### One part per page
468
387
 
469
- Not every pipeline runs the Spine editor's texture packer. If your parts are
470
- loose PNGs, declare each one as its own atlas page — a blank line closes a page
471
- block, the next line opens the next:
388
+ `rigc build` writes one page per part by default — each part PNG is its own
389
+ page, its one region covering it — and `rigc build --pack` arranges the parts
390
+ onto shared pages instead. Both play here, unchanged:
472
391
 
473
- ```
474
- head.png
475
- size: 512, 512
476
- head
477
- bounds: 0, 0, 512, 512
478
-
479
- torso.png
480
- size: 640, 480
481
- torso
482
- bounds: 0, 0, 640, 480
483
- ```
484
-
485
- `spine-core` parses this as a normal multi-page atlas and nothing here needs a
486
- flag. Two things to know:
487
-
488
- - **`size:` is the frame every coordinate is read in**, so each page's
489
- `bounds` must be written against the `size:` above them. The two need not be
490
- the PNG's pixel size — that is the point of the section above, and a
491
- half-resolution `head.png` under `size: 512, 512` works — but a `size:` that
492
- matches neither the bounds nor the artwork reads every region from the wrong
493
- place, in both tiers.
494
- - Regions like these cover their whole page, so `unpackRegions` hands the page
495
- image straight through instead of cutting and re-encoding it — at any image
496
- resolution, since covering the page is a statement about the declared size.
497
- Load cost for this atlas shape is just the image loads. The exception is a
498
- page marked `pma: true`: its URL holds premultiplied pixels, which an `<img>`
499
- would composite as straight alpha, so those regions are cut from the
500
- straight-alpha derivation like any other (and the blob is revoked by
501
- `revokeRegions` like any other).
502
-
503
- For a **rigid-only** skeleton you can skip atlas unpacking altogether and hand
504
- the renderer a map you build yourself — meshes cannot, because the deform tier
505
- samples the page bitmap through the atlas region:
506
-
507
- ```ts
508
- const regionImages = new Map([
509
- ['head', { url: '/parts/head.png', width: 512, height: 512 }],
510
- ]);
511
- const renderer = new SpineHtmlRenderer(rootElement, regionImages);
512
- ```
392
+ - Regions that cover their whole page are handed straight through by
393
+ `unpackRegions` instead of being cut and re-encoded — at any image resolution,
394
+ since covering the page is a statement about the declared size. Load cost for
395
+ this shape is just the image loads. The exception is a page marked `pma`: its
396
+ URL holds premultiplied pixels, which an `<img>` would composite as straight
397
+ alpha, so those regions are cut from the straight-alpha derivation like any
398
+ other (and the blob is revoked by `revokeRegions` like any other).
399
+ - A packed build cuts every region from its page once, at load, and its meshes
400
+ sample the shared page through page UVs.
513
401
 
514
402
  ### Runtime knobs and what they cost
515
403
 
404
+ - `player.mode` / `new RigPlayer(root, assets, { mode })` — `'dom'` (default)
405
+ or `'canvas'`. Changing it disposes the current renderer (elements removed,
406
+ GPU pages handed back) and creates the other over the same root and assets;
407
+ `pixelRatio` and `clipping` carry over and the held pose is redrawn at once.
408
+ `player.renderer` is then a `CanvasRenderer`, whose knobs are `pixelRatio`,
409
+ `clipping`, `viewport` and `backend`. What each mode costs, as counters:
410
+ - **dom** — one element per drawn slot, one style write per changed slot,
411
+ and the mesh tier's rasters (the knobs below).
412
+ - **canvas** — one canvas element per player and, per frame that changed,
413
+ one pass over every slot: `drawCalls` (one per run of one page under one
414
+ blend), `blendFlushes` (runs a blend change ended), `trianglesDrawn`,
415
+ `blits` (one), or `framesReused` (one) when the record did not change and
416
+ nothing is drawn. It never creates a WebGL context of its own, so any number
417
+ of players stays under the browser's ~16-context cap (20 players, one
418
+ context — `tests/canvas-mode.spec.ts`). The canvas covers a rect of the
419
+ root's coordinates that grows (never shrinks) to what the frames draw —
420
+ `canvasReallocCount` settles at zero — or a fixed `renderer.viewport` (world
421
+ units, Y up) when you set one. Blends are composed inside that canvas, which
422
+ starts transparent: an additive slot adds onto the slots under it and is
423
+ then composited onto the page normally, where the DOM tier's
424
+ `plus-lighter` adds onto the page itself. Where WebGL is unavailable or the
425
+ context is lost, the frame is drawn with canvas2d on the same canvas so it
426
+ never draws nothing — **alpha tint only, RGB and dark tint unsupported
427
+ there** — and that fallback is **not the measured path** (`backendActive`
428
+ says which ran).
429
+
516
430
  - `renderer.pixelRatio` — mesh-canvas backing pixels per CSS pixel (defaults to
517
431
  `devicePixelRatio`; if you scale the root element, fold that scale in so the
518
432
  raster matches the screen: `devicePixelRatio * rootScale`). **Writing it
@@ -546,8 +460,12 @@ const renderer = new SpineHtmlRenderer(rootElement, regionImages);
546
460
  mesh once (no reallocation), so it is fine to expose as a user setting.
547
461
  - `renderer.triangleExpand` — clip overdraw in px that closes antialiased mesh
548
462
  seams (default 0.5). Also re-rasters every mesh once when changed.
549
- - `renderer.clipping` — apply clipping attachments (default `true`). What it
550
- writes is one CSS `clip-path` per element the active clip covers, in that
463
+ - `renderer.clipping` — apply the clips the frame carries (default `true`).
464
+ Which slots a clip covers is decided where the frame is made — the player's
465
+ adapter walks the draw order the way the runtime's draw loop does (one clip in
466
+ force at a time, a second one met while one is in force ignored, the end slot
467
+ inside its own clip). What the renderer writes is one CSS `clip-path` per
468
+ element a clip covers, in that
551
469
  element's own local frame; nothing reaches the raster backends, and a clip is
552
470
  not part of the mesh dirty signature, so a mesh that held still keeps reusing
553
471
  its raster under a moving clip. **Writes happen on change only**: each
@@ -555,11 +473,11 @@ const renderer = new SpineHtmlRenderer(rootElement, regionImages);
555
473
  1/1000 of a local unit so float jitter cannot defeat that cache, and a static
556
474
  polygon over a static pose therefore costs **zero style writes per frame after
557
475
  the first** — `renderer.clipWriteCount` is the check, alongside
558
- `clipCount` (applied) and `clipSkipCount` (not applied: switched off, a second
559
- clip met while one was active, an inactive bone, a degenerate polygon).
560
- **The whole-skeleton fast path**: when the clip starts before anything has been
561
- drawn and never ends, one `clip-path` goes on the root instead of one per
562
- element. The root is *your* element, so its inline `clip-path` is **borrowed,
476
+ `clipCount` (clips applied) and `clipSkipCount` (clips in the frame not
477
+ applied: switched off, or a degenerate polygon; a clip the runtime itself
478
+ ignores never reaches the frame). **The whole-skeleton fast path**: when one
479
+ clip covers every slot of the frame, one `clip-path` goes on the root instead
480
+ of one per element. The root is *your* element, so its inline `clip-path` is **borrowed,
563
481
  not taken** — saved on the first write and put back verbatim when the clip
564
482
  stops covering the frame, when `clipping` goes `false`, and by `dispose()`.
565
483
  Per-element clip-paths and the root clip-path are never both in force for the
@@ -593,6 +511,150 @@ on-device (Chromium, dpr 2, stage under `scale(0.25)`, ~2.2 M CSS px on screen):
593
511
  | 92 meshes | `devicePixelRatio` (2) | 141.1 Mpx | 36–39 |
594
512
  | 92 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 60–61 |
595
513
 
514
+ ## Coming from spine-html 0.8
515
+
516
+ This is not a drop-in upgrade. 0.8 drew a Spine export (`.json` or `.skel`, plus its
517
+ `.atlas`) that `@esotericsoftware/spine-core` parsed and posed: the caller built the
518
+ `Skeleton` and the `AnimationState`, advanced them each frame, and handed the skeleton to
519
+ `renderer.render(skeleton)`. 1.0 loads one file, the `skeleton.model.json` that
520
+ `rigc build` writes, poses it through `rig-c/core` (bundled into the package, with no peer
521
+ dependency), and `RigPlayer` owns the track, so the caller's frame is `player.frame(dt)`.
522
+ A Spine export is refused by name before anything is fetched: the data has to be a rigc
523
+ build, and this package declares no loader for anything else.
524
+
525
+ What did not move: a positioned root element is still the rig's origin (Y up) with its
526
+ layout and scaling left to the caller, the DOM tier keeps its knobs under a new class
527
+ name, and unloading is still elements first, bitmaps second.
528
+
529
+ ### 0.8 → 1.0
530
+
531
+ | 0.8.0 (`spine-html`) | 1.0.0 (`rig-play`) | Note |
532
+ | --- | --- | --- |
533
+ | `npm i spine-html @esotericsoftware/spine-core` | `npm i rig-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. |
534
+ | `loadSkeletonAssets(options)` | `loadModel(options)` | One URL instead of two. The `.atlas` and `skeleton.json` a build writes beside the document are not fetched. |
535
+ | `LoadSkeletonAssetsOptions.atlasUrl`, `skeletonUrl` | `LoadModelOptions.modelUrl` | |
536
+ | `resolvePage(pageName, atlasUrl)` | `resolvePage(pageName, modelUrl)` | The default now resolves a page name against the model URL's directory. |
537
+ | `crossOrigin`, `fetch` | `crossOrigin`, `fetch` | Unchanged. |
538
+ | `scale` (`LoadSkeletonAssetsOptions`, `LoadSkeletonJsonOptions`) | none | See below. |
539
+ | `SkeletonAssets` | `ModelAssets` | |
540
+ | `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). |
541
+ | `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. |
542
+ | `SkeletonAssets.regionImages` | `ModelAssets.regionImages` | Same `Map<string, RegionImage>`. |
543
+ | `SkeletonAssets.dispose()`, `AtlasAssets.dispose()` | `ModelAssets.dispose()` | Same contract: frees the blob URLs the load minted, idempotent, called after the players are disposed. |
544
+ | `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. |
545
+ | `loadSkeletonBinary` from `spine-html/binary` | none | 1.0.0 has one entry point. A `.skel` URL is refused by name. |
546
+ | `unpackRegions(atlas, pageImages)` | `unpackRegions(pages, pageImages)` | The first argument is the document's `pages` section (`readonly PageLayout[]`) instead of a `TextureAtlas`. |
547
+ | `revokeRegions(images)` | `revokeRegions(images)` | Unchanged. |
548
+ | `RegionImage` | `RegionImage` | Unchanged (`url`, `width`, `height`, in atlas units). |
549
+ | `DomTexture` | none | Not exported; there is no atlas page to attach a texture to. |
550
+ | `new Skeleton(assets.data)` and `new AnimationState(new AnimationStateData(assets.data))` | `new RigPlayer(root, assets, options?)` | The player holds the pose and the track. |
551
+ | `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. |
552
+ | `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. |
553
+ | 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. |
554
+ | holding a pose (the caller stopped advancing) | `player.pause()`, `player.resume()` | `frame` keeps drawing the held pose. |
555
+ | 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. |
556
+ | 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`); `'complete'` hands `{ animation }`. `on` returns a function that removes the listener. |
557
+ | skin (set by the caller on its `Skeleton`) | `RigPlayerOptions.skin` | A constructor option. The names are in `ModelAssets.skins`. Without it the rig is posed with no skin set. |
558
+ | `SpineHtmlRenderer` | `DomRenderer`, reached as `player.renderer` | In `mode: 'dom'`, the default. `mode: 'canvas'` is new and makes `player.renderer` a `CanvasRenderer`. |
559
+ | `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. |
560
+ | `renderer.render(skeleton)` | `renderer.render(frame)` | Takes a `PosedFrame`, not a skeleton. `player.posedFrame()` returns the one the player would draw. |
561
+ | `renderer.pixelRatio` | `player.renderer.pixelRatio` | On both renderers. Carried over when `player.mode` changes. |
562
+ | `renderer.syncPixelRatio()` | `DomRenderer.syncPixelRatio()` | DOM mode only. `player.renderer` is typed as either renderer, so narrow it (`instanceof DomRenderer`) before calling. |
563
+ | `renderer.meshBackingPixels` | `DomRenderer.meshBackingPixels` | DOM mode only. |
564
+ | `renderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | `DomRenderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | Same values (`'canvas2d'` default, `'webgl'`). Also settable up front as `RigPlayerOptions.meshBackend`. |
565
+ | `renderer.triangleExpand` | `DomRenderer.triangleExpand` | Same default (0.5). |
566
+ | `renderer.clipping` | `player.renderer.clipping` | On both renderers. Carried over when `player.mode` changes. |
567
+ | `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. |
568
+ | `meshCount`, `meshReuseCount`, `canvasReallocCount`, `triangleCount` | same names on `DomRenderer` | |
569
+ | 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`. |
570
+ | dark (two-colour) tint: unsupported | `DomRenderer`: unsupported. `CanvasRenderer`: drawn | Carried as `dark` on a `PosedSlot`. The canvas mode's canvas2d fallback draws alpha only. |
571
+ | `renderer.dispose()` | `player.dispose()` | Then `assets.dispose()`, as before. `DomRenderer.dispose()` and `CanvasRenderer.dispose()` exist for a renderer you constructed yourself. |
572
+
573
+ ### No counterpart in 1.0.0
574
+
575
+ Things a 0.8 caller could do because it held the spine-core objects, or through a 0.8
576
+ export, for which 1.0.0 declares nothing:
577
+
578
+ - **A Spine export as input.** `loadSkeletonJson`, `loadSkeletonBinary`, `loadAtlasAssets`,
579
+ the `spine-html/binary` entry and `DomTexture` have no counterpart, and neither does
580
+ choosing which spine-core generation reads the data. `loadModel` reads a compiled model
581
+ document and nothing else.
582
+ - **Scaling the skeleton as it is read.** `LoadModelOptions` declares no `scale`. Scaling
583
+ the root element is still the caller's.
584
+ - **One unpacked atlas shared by different skeletons.** A document is one rig, and each
585
+ `loadModel` cuts its own regions and owns its own blob URLs. Sharing is per rig: one
586
+ load, several players.
587
+ - **More than one track, mixing between animations, or queueing one after another.**
588
+ `RigPlayer` declares one track; `play` reopens it at the animation's first pose.
589
+ - **Reading or writing a bone.** No bone is exposed: nothing sets a bone's local values
590
+ between the step and the draw, and nothing returns a bone's world transform. What is
591
+ readable is the posed slots: `player.posedFrame()` carries each drawn slot's world
592
+ `corners` or `vertices`.
593
+ - **A bounds query for the current pose.** None is declared. `ModelAssets.stage` is the
594
+ stage the document states (or null), which is the rig's declared framing and not the
595
+ box of the pose being drawn.
596
+ - **Changing the skin after construction.** `skin` is a `RigPlayerOptions` field and
597
+ `RigPlayer` declares no way to change it later; a second player over the same assets
598
+ is the declared route.
599
+ - **Changing one slot from code** (its attachment, its colour). `RigPlayer` declares only
600
+ the whole-rig `color`. The frame record is a plain object, so a caller that draws its
601
+ own frames through `DomRenderer.render` or `CanvasRenderer.render` decides what is in
602
+ it.
603
+ - **Choosing how physics is stepped each frame.** 0.8's loop passed `Physics.update`
604
+ itself; 1.0.0 declares no physics option. `play` and `seek` reset physics at the
605
+ animation's first pose and every step advances it.
606
+ - **Track callbacks other than the two declared.** `on` accepts `'event'` and
607
+ `'complete'`.
608
+
609
+ Knobs on `DomRenderer` that `CanvasRenderer` does not declare:
610
+
611
+ - `syncPixelRatio()`. `CanvasRenderer.pixelRatio` exists and is set by hand
612
+ (`devicePixelRatio * rootScale`).
613
+ - `meshBackend` and `meshBackendActive`. The canvas mode has its own pair, `backend` and
614
+ `backendActive` (type `CanvasBackend`, `'webgl'` by default).
615
+ - `triangleExpand`, `meshBackingPixels`, `clipWriteCount`, `meshCount`, `meshReuseCount`,
616
+ `triangleCount`. The canvas mode's counters are `drawCalls`, `blendFlushes`,
617
+ `trianglesDrawn`, `framesReused` and `blits`.
618
+
619
+ Both declare `pixelRatio`, `clipping`, `clipCount`, `clipSkipCount`, `canvasReallocCount`,
620
+ `render` and `dispose`. Only `pixelRatio` and `clipping` carry over when `player.mode`
621
+ changes.
622
+
623
+ ### Before and after
624
+
625
+ 0.8.0:
626
+
627
+ ```ts
628
+ import { Skeleton, AnimationState, AnimationStateData, Physics } from '@esotericsoftware/spine-core';
629
+ import { loadSkeletonAssets, SpineHtmlRenderer } from 'spine-html';
630
+ const assets = await loadSkeletonAssets({ atlasUrl: '/hero/hero.atlas', skeletonUrl: '/hero/hero.json' });
631
+ const skeleton = new Skeleton(assets.data);
632
+ const state = new AnimationState(new AnimationStateData(assets.data));
633
+ state.setAnimation(0, 'walk', true);
634
+ const renderer = new SpineHtmlRenderer(rootElement, assets.regionImages);
635
+ function frame(delta: number) {
636
+ state.update(delta);
637
+ state.apply(skeleton);
638
+ skeleton.update(delta);
639
+ skeleton.updateWorldTransform(Physics.update);
640
+ renderer.render(skeleton);
641
+ }
642
+ renderer.dispose(); assets.dispose(); // on unload
643
+ ```
644
+
645
+ 1.0.0:
646
+
647
+ ```ts
648
+ import { loadModel, RigPlayer } from 'rig-play';
649
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
650
+ const player = new RigPlayer(rootElement, assets);
651
+ player.play('walk', { loop: true });
652
+ function frame(delta: number) {
653
+ player.frame(delta);
654
+ }
655
+ player.dispose(); assets.dispose(); // on unload
656
+ ```
657
+
596
658
  ## Demo (this repository)
597
659
 
598
660
  ```bash
@@ -600,18 +662,22 @@ bun install
600
662
  bun run dev
601
663
  ```
602
664
 
603
- The official spineboy example assets are downloaded automatically on first `dev`/`build`
604
- (they are owned by Esoteric Software and not redistributed in this repository — see
605
- [NOTICE.md](NOTICE.md)); `bun run fetch-assets` runs the same idempotent step manually.
665
+ The demo plays rigc's own gallery rigs (`walk`, `flex`, `squash`, `portrait`,
666
+ `nod`, `look`, `ride`), each built by `rigc build` from a pinned rigc tag into
667
+ `public/rigs/<name>/` on first `dev`/`build` — and each again with `--pack` into
668
+ `public/rigs/<name>-packed/`. The same step builds this repository's test rigs
669
+ (`tests/rigs/`: `figure`, `glow`, `turned`), which `?rig=` also reaches; nothing
670
+ is downloaded but the rigc tag's gallery sources. `bun run fetch-assets` runs it
671
+ manually, and it is idempotent.
606
672
 
607
- Debug knobs (query string): `?skel=pro|ess` `?anim=walk` `?count=10` pick the scene,
608
- `?tint=ff8080` tints the whole skeleton, `?dpr=2` overrides the mesh-canvas backing
673
+ Debug knobs (query string): `?rig=flex` `?anim=wave` `?count=10` pick the scene,
674
+ `?tint=ff8080` tints the whole rig, `?dpr=2` overrides the mesh-canvas backing
609
675
  ratio, `?timescale=0` freezes the pose (every mesh should report "reused"),
610
676
  `?expand=0` disables the crack-closing clip overdraw, `?backend=webgl` rasterizes
611
677
  meshes through the shared WebGL blitter (also a live header select; the stats line
612
- names the active backend), `?clipping=0` turns clipping attachments back off (they
613
- are then counted and skipped, as before v0.6 — try it on `?anim=portal`), and
614
- `?time=1.2` seeks every instance to the same pose for deterministic captures.
678
+ names the active backend), `?clipping=0` turns clips back off (they are then
679
+ counted and skipped), and `?time=1.2` seeks every instance to the same pose for
680
+ deterministic captures.
615
681
 
616
682
  ## Tests
617
683
 
@@ -620,29 +686,33 @@ bun run test # builds + serves the demo, then runs chromium + webkit
620
686
  ```
621
687
 
622
688
  Playwright drives the demo (`tests/`, config in `playwright.config.ts`; CI
623
- runs the same suite on ubuntu, **once per supported spine-core minor**). That
624
- matrix is what the compatibility table above is made of: each column installs
625
- that minor over the lockfile's, fetches the matching spine-runtimes branch's
626
- example exports, and runs everything. `SPINE_ASSETS_BRANCH=4.2 bun run
627
- fetch-assets` gets those exports locally, and `SPINE_CORE_MINOR` tells the run
628
- which minor it is against — anything but the typed one builds without
629
- `tsc --noEmit`, since the package is typed against one generation and runs on
630
- several. Two checks in `tests/package.spec.ts` are skipped, with the reason
631
- printed, where the **installed peer alone** already fails them: importing
632
- `spine-core` 4.0 in plain Node, and typechecking a consumer of `spine-core` 4.1
633
- under `nodenext`. Both are the upstream packaging defect described in the
634
- compatibility section, both are probed rather than keyed on a version, and
635
- nothing is relaxed in the columns that can run them. The visual check is
636
- **A/B within one run**:
637
- no golden snapshots are committed (they rot across platforms/GPUs) — instead
638
- the same deterministic pose (`?time` + `?timescale=0`) is screenshotted with
639
- `?backend=canvas2d` and `?backend=webgl` in the same engine and the buffers
640
- are diffed directly with a shift-tolerant comparison, so missing parts,
641
- wrong colors, and tint/blend divergence fail regardless of platform.
642
- Hairline seams are guarded by a deterministic canary (`?expand=0` must
643
- change the canvas2d raster), and counter tests pin the dirty-skip /
644
- grow-only-backing / clip-counter invariants plus spine-core's region corner
645
- order (BL, UL, UR, BR).
689
+ runs the same suite on ubuntu, one job). Every scene is a rig built by
690
+ `rigc build` and posed by `rig-c/core` through `RigPlayer` — rigc's gallery rigs,
691
+ and three test rigs authored in this repository (`tests/rigs/`) for what the
692
+ gallery does not carry: `figure` (clipping attachments in rigc's format —
693
+ convex, inverse, nested, whole-skeleton — rigid parts on turned bones, two grid
694
+ meshes), `glow` (additive slots with slot colours over two weighted meshes) and
695
+ `turned` (a region packed turned 90° on a hand-packed page). No Spine runtime
696
+ is installed or compared against: the pose is rig-c's, held to spine-core at
697
+ tolerance 0 by rig-c's own CI, and a pose doubt is a rigc issue. The visual
698
+ checks are **A/B within one run**: no golden snapshots are committed (they rot
699
+ across platforms/GPUs) — instead the same deterministic pose (`?time` +
700
+ `?timescale=0`) is screenshotted twice in the same engine and the buffers are
701
+ diffed directly with a shift-tolerant comparison, so missing parts, wrong
702
+ colors, and tint/blend/clip divergence fail regardless of platform.
703
+
704
+ **The picture gate** is the two render modes drawing the same picture: every
705
+ scene captured in the DOM mode and in the canvas mode and diffed — bad pixels at
706
+ most 1.5% of drawn content on the test-rig scenes (measured floors 0.007–0.524%,
707
+ macOS Chromium; each scene's own feature dropped from the canvas mode — tint,
708
+ additive blend, clipping, a mesh — scores 4.2–77%) and 2.5% on the gallery
709
+ scenes (floors 0.585–1.553%, Chromium and WebKit). The DOM tier's two mesh
710
+ backends get the same A/B (`?backend=canvas2d` against `?backend=webgl`, at most
711
+ 0.5%). Hairline seams are guarded by a deterministic canary (`?expand=0` must
712
+ change the canvas2d raster), the canvas2d source sub-rect by another (expand 32
713
+ against 8), and counter tests pin the dirty-skip / grow-only-backing /
714
+ clip-counter invariants plus the region corner order (BL, UL, UR, BR) and the
715
+ turned-region UVs, read off rig-c's pose against hand-written numbers.
646
716
 
647
717
  Clipping gets its own oracle (`tests/clipping.spec.ts`), because a clip-path
648
718
  that went through the wrong transform still parses and still counts as applied.
@@ -666,56 +736,7 @@ resolving, so blob ownership — every unpacked URL freed, nothing the caller
666
736
  owns touched, nothing stranded by a failed load — is asserted rather than
667
737
  assumed.
668
738
 
669
- ### The pixel oracle: a reference that is not us
670
-
671
- Everything above compares this package against itself. That catches one mesh
672
- backend drifting from the other and, by construction, cannot catch a defect
673
- they **share** — which is what happened with `pma: true` pages, drawn one
674
- multiply too dark in every tier while the suite stayed green for months.
675
-
676
- So `tests/oracle.spec.ts` compares against the official runtime. The same
677
- export, the same frozen pose, the same size, the same atlas page, drawn by
678
- `@esotericsoftware/spine-webgl` into a canvas whose orthographic camera is
679
- solved to land world space on exactly the pixels the DOM stage lands it on —
680
- then both are screenshotted and diffed. Thirteen cells: the rigid tier, the mesh
681
- tier on both backends, the exporter's own premultiplied page (`spineboy-pma`)
682
- on all three, a clipping pose, an additive-blend pose, a mesh-deform pose on
683
- both backends, a per-slot rgba/alpha pose, and a whole-skeleton tint.
684
-
685
- It earned its keep on the first run: the rigid tier was drawing every
686
- 90°-packed atlas region 180° round (#49), in every release up to 0.7.0, and no
687
- comparison of this package against itself could have seen it. Rotated packing is
688
- held there now — the example exports rotate 17 regions on the 4.0 and 4.1
689
- branches and ten on the 4.2 branch where the 4.3 branch's rotate none, so the
690
- older columns are what keep the restore honest, and each cell logs how many
691
- regions its atlas rotated so a green run on an unrotated one cannot be mistaken
692
- for the proof.
693
-
694
- The deform cell is the one chosen for what the *data* carries rather than for a
695
- feature of the renderer: `hoverboard` is the only animation in spineboy-pro with
696
- mesh deform keys, it is the section whose JSON key moved between 4.0 and 4.1,
697
- and the pose drives two of the four bones whose inheritance mode moved between
698
- 4.1 and 4.2. On the older columns it is therefore a deformed mesh posed through
699
- bones only the matching runtime reads, diffed against that runtime's own
700
- reference.
701
-
702
- Two things make it evidence rather than decoration. The camera match is
703
- **derived** — solved against the DOM mapping, not fitted to a picture — and
704
- then **proved by driving it**: a deliberate one-world-unit offset of one side
705
- must change the diff, which it does by 3.3× and 6.5×. And what is forgiven is
706
- antialiasing and texture filtering and nothing else, so the stage sits where
707
- one atlas texel is one CSS pixel and neither runtime resamples. Limits are
708
- ratios of drawn content, plus a darker-minus-lighter excess on the
709
- premultiplied cells, because that defect class can only go one way — never an
710
- absolute per-pixel precision number, which is a platform's number and not a
711
- package's.
712
-
713
- spine-webgl is a **development dependency**. It is named by one file under
714
- `tests/`, it is in neither `dependencies` nor `peerDependencies`, and
715
- `tests/package.spec.ts` asserts that nothing the published package contains so
716
- much as mentions it.
717
-
718
- ## Benchmark: side by side with the official runtime
739
+ ## Benchmark: DOM mode vs canvas mode
719
740
 
720
741
  ```bash
721
742
  bun run bench:dev # the page, for a person to watch (and for Safari)
@@ -723,34 +744,48 @@ bun run bench # drives it in a headed Chromium and writes a report
723
744
  ```
724
745
 
725
746
  `bench/` is a separate vite project — not part of the library, and not part of
726
- what `bun run build` emits — that runs the same skeleton in both runtimes side
727
- by side, each with its own stats line in the demo's style. Scenes by query
728
- string: `?scene=mesh` (the deform tier working every frame), `?scene=held` (a
729
- frozen pose, where unchanged meshes reuse their raster and the reference
730
- redraws regardless), `?scene=rigid`, and `?scene=many&count=N` independent
731
- players — which is also where the architectural difference shows, since the
732
- reference needs one WebGL context per player against browsers' cap of ~16 and
733
- this package shares a single one however many there are. The page reports
734
- reaching that cap rather than dying at it.
747
+ what `bun run build` emits — that plays the same rigc gallery build in both
748
+ render modes side by side, each with its own stats line in the demo's style.
749
+ Scenes by query string: `?scene=mesh` (`portrait-packed` turning its head, the
750
+ deform tier working every frame), `?scene=held` (the same pose frozen, where
751
+ unchanged meshes reuse their raster and an unchanged frame is reused),
752
+ `?scene=rigid` (`walk-packed`, region attachments only) and
753
+ `?scene=many&count=N` independent players; `?rig=` and `?model=` name another
754
+ build. All four take `&count=N` up to 64 (a page limit, so that a cell stays
755
+ something you can see), laid out on one grid whose per-rig scale shrinks with
756
+ the column count, so a step up in count adds rigs rather than cropping the ones
757
+ already there.
758
+
759
+ **The two sides draw the same picture.** Both place rigs with one derivation and
760
+ clip each to the same rect — the pane for a grid, the rig's own cell for `many`
761
+ — with `overflow: hidden` on a box, the way a page embeds a player, and then
762
+ *check* it: each side reports `origin Δpx`, the largest gap between where the
763
+ grid puts a rig and where that side drew it, and the report table carries it per
764
+ row. (`contain: layout paint` is deliberately not used: paint containment clips
765
+ to the same edge `overflow: hidden` does, and what it adds past the clip — a
766
+ stacking context, a containing block, an independent formatting context —
767
+ changes how one side is composited and buys the comparison nothing.) Both modes
768
+ share the package's single WebGL context however many players there are; the
769
+ canvas side's stats line says how many contexts the page holds.
735
770
 
736
771
  `scripts/bench.mjs` walks `bench/scenes.json`, opens each scene in a **headed**
737
772
  Chromium (a hidden page throttles `requestAnimationFrame`, so a headless
738
- reading would be the browser's power policy rather than either runtime's cost),
739
- runs a fixed number of frames per runtime — one at a time, because rAF has a
740
- single cadence per document — and writes JSON plus a markdown table with the
773
+ reading would be the browser's power policy rather than either mode's cost),
774
+ runs a fixed number of frames per mode — one at a time, because rAF has a single
775
+ cadence per document — and writes JSON plus a markdown table with the
741
776
  conditions attached: browser version, device pixel ratio, window size, GPU
742
777
  string, and the load average before and after. Over a threshold it stamps the
743
778
  whole report `INVALID: load average too high` and exits non-zero, because a
744
779
  benchmark on a busy machine measures the machine. `--corpus <dir>` points it at
745
- local rigs that never enter this repository.
780
+ local rigc builds that never enter this repository.
746
781
 
747
- > **Numbers pending.** No comparison table is published yet, and one taken on a
748
- > loaded CI runner or in a headless browser would not be worth reading. The
749
- > table will be taken the way every other number in this README was: the two
750
- > stats lines read on a real device, on a quiet machine, per scene and per
751
- > engine — including Safari, which stays manual because no automation preserves
752
- > what is being measured there. Until then the instrument exists and the
753
- > measurement does not.
782
+ > **Numbers pending.** No comparison table is published yet. A reading taken on
783
+ > a loaded machine, a CI runner or a headless browser would not be worth
784
+ > printing. The table will be taken the way every other number in this README
785
+ > was: the two stats lines read on a real device, on a quiet machine, per scene
786
+ > and per engine — including Safari, which stays manual because no automation
787
+ > preserves what is being measured there. Until then the instrument exists and
788
+ > the measurement does not.
754
789
 
755
790
  The server is part of the test: a run tests whatever is being served on its
756
791
  port. Two checkouts of this repository on one machine (a worktree, a second
@@ -768,25 +803,45 @@ perf oracle is the demo's stats line on a real device.
768
803
 
769
804
  ## How it works
770
805
 
771
- 1. `@esotericsoftware/spine-core` loads the skeleton and drives `AnimationState`,
772
- constraints, and physics — all CPU-side, renderer-agnostic.
773
- 2. At load time each atlas region is cut into its own bitmap (rotation restored), one
774
- blob URL per region — except regions that already cover their whole page, which are
775
- used as they are.
776
- 3. Per frame, for each slot in draw order: `computeWorldVertices` yields the region's
777
- four corners in world space (order **BL, UL, UR, BR** — note the comments inside
778
- `computeWorldVertices` are stale). Flip Y (Spine is Y-up), derive the affine from
779
- three corners, write one `matrix()` string. That's the whole render path.
806
+ 1. `loadModel` reads the rig's compiled model document (`rig-c/core`'s `readModel`),
807
+ and `RigPlayer` opens an animation on a live track (`openTrack`): bones,
808
+ constraints, physics, deform and draw order are posed by the core — all
809
+ CPU-side, renderer-agnostic — one `dt` at a time.
810
+ 2. At load time each region of the document's `pages` section is cut into its own
811
+ bitmap (rotation restored), one blob URL per region — except regions that already
812
+ cover their whole page, which are used as they are.
813
+ 3. Per frame, the pose becomes a `PosedFrame`: for each slot in draw order, a region's
814
+ four world corners (order **BL, UL, UR, BR**) or a mesh's world vertices, page UVs
815
+ and triangles, its tint, blend and clip. For a region: flip Y (the frame is Y-up),
816
+ derive the affine from three corners, write one `matrix()` string. That's the
817
+ whole rigid render path.
780
818
 
781
819
  ## License
782
820
 
783
- The code in this repository is MIT licensed.
784
-
785
- It depends on the [Spine Runtimes](https://github.com/EsotericSoftware/spine-runtimes)
786
- (`@esotericsoftware/spine-core`), which are licensed under the
787
- [Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license):
788
- **integrating the Spine Runtimes — including through this project — requires each user
789
- to have their own valid Spine Editor license.** See [NOTICE.md](NOTICE.md).
790
-
791
- Example assets (spineboy) are owned by Esoteric Software and are fetched from the
792
- official repository at setup time, not redistributed here.
821
+ rig-play's own code is MIT (see [LICENSE](LICENSE)). That says nothing about Spine,
822
+ so here is what is true of this package and this repository, stated plainly:
823
+
824
+ 1. The published package — `rig-play`, and `spine-html`, the same files — **links
825
+ nothing from Esoteric Software**. Its one module bundles the renderer and
826
+ `rig-c/core` (MIT), which poses rigc's model document; no part of the
827
+ [Spine Runtimes](https://github.com/EsotericSoftware/spine-runtimes) is in it or
828
+ required beside it.
829
+ 2. This repository links no Spine runtime either, since #63: no package of Esoteric
830
+ Software's npm scope is a dependency of any kind, and the lockfile names none.
831
+ `tests/package.spec.ts` asserts both.
832
+ 3. `rigc build`, which this repository runs to make its fixtures, also writes Spine
833
+ skeleton data (`skeleton.json` and `skeleton.atlas`) beside each model document.
834
+ Nothing here reads it, and it is neither committed nor published.
835
+ 4. A product that plays that Spine data with a Spine Runtime has integrated the Spine
836
+ Runtimes, whose licence permits it **under Section 2 of the
837
+ [Spine Editor License Agreement](https://esotericsoftware.com/spine-editor-license)**,
838
+ or otherwise on the condition that **each user of the product obtains their own
839
+ Spine Editor license** — the licence's own two routes, restated, not a term of
840
+ this project.
841
+
842
+ > **What a consumer does with rigc's Spine-format output is governed by Esoteric
843
+ > Software's terms**, not by this package — rig-play neither adds those terms nor
844
+ > removes them.
845
+
846
+ No example assets of Esoteric Software's are fetched or redistributed. See
847
+ [NOTICE.md](NOTICE.md) for the full notice.