spine-html 0.7.1 → 1.0.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 +354 -411
  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 +7271 -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 +151 -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 -1100
  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 -130
  34. package/dist/coreCompat.d.ts.map +0 -1
  35. package/dist/coreCompat.js +0 -87
  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,249 +159,166 @@ 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';
144
-
145
- const assets = await loadSkeletonAssets({
146
- atlasUrl: '/spineboy/spineboy.atlas',
147
- skeletonUrl: '/spineboy/spineboy-pro.json',
148
- });
149
-
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);
176
+ import { loadModel, RigPlayer } from 'rig-play';
177
+
178
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
179
+
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 and older | ❌ not supported — but see below for 4.1 / 4.0 *data* |
189
-
190
- *Supported* means one specific thing: a column of this repository's CI runs the
191
- **whole** test suite on that minor, against **that version's own** example
192
- exports. `peerDependencies` is that set and nothing wider. Nothing in the API
193
- above changes with the version — the difference is absorbed internally, in one
194
- file, chosen once per renderer by looking at the objects spine-core hands over.
195
-
196
- **Worth knowing, because it is silent.** If you point a 4.3 runtime at older
197
- data, the bad outcome is not the one that throws. 4.3 reads constraints from a
198
- single top-level `constraints` array, where 4.2 and older write separate
199
- `ik` / `transform` / `path` / `physics` ones — so an older export that still
200
- parses loads **none of its constraints at all**, and the skeleton animates on
201
- bones alone: no IK, no path, no physics, no error. Measured over a real-world
202
- corpus of 267 skeletons (88% of them 4.2 exports): 40% failed to parse under
203
- 4.3.13 outright, and across those that did parse, **0 of 7,065** constraints and
204
- **0 of 5,704** physics constraints were loaded. If a skeleton renders but goes
205
- limp, this is why — and installing the matching `spine-core` is the whole fix.
206
-
207
- **If your data was exported by 4.1 or 4.0**, install spine-core **4.2**. The
208
- table above is about *runtimes*, and the 4.1 and 4.0 runtimes are out for
209
- upstream packaging reasons (`Physics` is missing from 4.1's root entry, and both
210
- ship extensionless module specifiers that plain Node cannot resolve). Their
211
- *data* is another matter: the JSON format grew from 4.0 to 4.2 by addition, and
212
- measured over a real-world corpus of 1,454 shipped web skeletons — 916 exported
213
- by 4.2, 418 by 4.0, 101 by 4.1 — spine-core 4.2.120 parsed **every one** and
214
- loaded **all 14,022** of their constraints; a 60-rig sample of them (20 per
215
- export version) then loaded and rendered through this package on that core, on
216
- both mesh backends, without an error. What that does *not* establish is that a
217
- 4.0 animation plays identically under the 4.2 runtime and under its own — that
218
- comparison was not made, and Esoteric Software's guidance remains to match
219
- versions. It is the difference between "loses every constraint, silently" (any
220
- of this data under 4.3) and "loads completely" (the same data under 4.2).
221
-
222
- Spine 3.8 and older are out of scope: they are not published under this package
223
- name.
224
-
225
- ### One atlas, several skeletons
226
-
227
- Skeletons that share an atlas — a character's `-ess` and `-pro` exports, a whole
228
- cast packed onto one page — load in two steps instead. Calling
229
- `loadSkeletonAssets` once per skeleton would fetch and unpack the same pages
230
- again and mint a second set of region blob URLs:
218
+ ### Drawing your own frames
231
219
 
232
- ```ts
233
- 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:
234
222
 
235
- const shared = await loadAtlasAssets({ atlasUrl: '/spineboy/spineboy.atlas' });
236
- const [essData, proData] = await Promise.all([
237
- loadSkeletonJson(shared, '/spineboy/spineboy-ess.json'),
238
- loadSkeletonJson(shared, '/spineboy/spineboy-pro.json'),
239
- ]);
223
+ ```ts
224
+ import { DomRenderer, type PosedFrame } from 'rig-play';
240
225
 
241
- const ess = new SpineHtmlRenderer(essRoot, shared.regionImages);
242
- const pro = new SpineHtmlRenderer(proRoot, shared.regionImages);
226
+ const renderer = new DomRenderer(rootElement, assets.regionImages, assets.pageImages);
227
+ renderer.render(frame); // frame: PosedFrame
228
+ ```
243
229
 
244
- // Unloading: every renderer first, then the one dispose() that owns the bitmaps.
245
- ess.dispose();
246
- pro.dispose();
247
- 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 } )
248
239
  ```
249
240
 
250
- `loadAtlasAssets` takes the atlas half of the options above (`resolvePage`,
251
- `crossOrigin`, `fetch`) and `loadSkeletonJson` the skeleton half (`scale`,
252
- `fetch`). Ownership is the thing to keep straight: the atlas assets belong to
253
- the caller, reading a skeleton against them never frees them, and one
254
- `regionImages` map is meant to be handed to several renderers — so there is
255
- exactly one `dispose()` for however many skeletons were read. `loadSkeletonJson`
256
- asks only for `{ atlas }`, so a caller that built its atlas by hand (below) can
257
- still use it for the read. `loadSkeletonAssets` is these two calls with the
258
- atlas half kept private: use it for one atlas and one skeleton, this for the
259
- 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).
260
247
 
261
- ### Binary exports (`.skel`)
248
+ ### One load, several players
262
249
 
263
- Binary exports are read by a second parser, `SkeletonBinary`, which is as big as
264
- the JSON one. It lives behind its own entry point so that consumers who only
265
- ever read `.json` never carry it:
250
+ One `loadModel` backs any number of players: the regions are cut once, and
251
+ every player draws from the one `regionImages` map. A player owns none of it —
252
+ disposing a player leaves the bitmaps alive for the others — so there is exactly
253
+ one `assets.dispose()`, after the last player is gone:
266
254
 
267
255
  ```ts
268
- import { loadAtlasAssets, SpineHtmlRenderer } from 'spine-html';
269
- import { loadSkeletonBinary } from 'spine-html/binary';
256
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
257
+ const a = new RigPlayer(rootA, assets);
258
+ const b = new RigPlayer(rootB, assets, { skin: 'winter' });
270
259
 
271
- const shared = await loadAtlasAssets({ atlasUrl: '/spineboy/spineboy.atlas' });
272
- const data = await loadSkeletonBinary(shared, '/spineboy/spineboy-pro.skel');
273
-
274
- const renderer = new SpineHtmlRenderer(rootElement, shared.regionImages);
275
- // Unloading, as above: renderer.dispose(); shared.dispose();
260
+ a.dispose();
261
+ b.dispose();
262
+ assets.dispose();
276
263
  ```
277
264
 
278
- `loadSkeletonBinary` is `loadSkeletonJson` with the parser swapped: same
279
- arguments, the same `{ scale, fetch }` options, the same ownership rule — it
280
- never frees `assets`, whichever way it ends. Those two calls are the binary path
281
- in full. There is no one-call `loadSkeletonAssetsBinary`, because the atlas half
282
- is precisely what a caller reading binary usually wants kept in hand, and hiding
283
- it again would only earn back a line.
284
-
285
- The subpath is the whole mechanism, so import it as written: pulling
286
- `loadSkeletonBinary` out of `'spine-html'` is not possible, by design. Worth
287
- knowing about the format itself: binary exports are version-locked to the
288
- runtime that reads them, so a `.skel` written by an editor newer than your
289
- installed `@esotericsoftware/spine-core` fails in the read, not in the fetch.
265
+ A document is one rig. Two rigs whose builds share page images load as two
266
+ documents; the images are fetched by URL, so a page both name at the same URL
267
+ is loaded once by the browser — but each load cuts its own regions and owns its
268
+ own blob URLs.
290
269
 
291
270
  ### Loading it yourself
292
271
 
293
- `loadSkeletonAssets` is optional sugar over five `spine-core` calls, and the
294
- package works without it. Drop to the low-level path whenever you need
295
- something no loader does — images that are already in memory, an atlas that is
296
- not fetched from a URL at all:
297
-
298
- ```ts
299
- import { TextureAtlas, AtlasAttachmentLoader, SkeletonJson }
300
- from '@esotericsoftware/spine-core';
301
- import { DomTexture, unpackRegions, revokeRegions } from 'spine-html';
302
-
303
- const atlas = new TextureAtlas(atlasText);
304
- const pageImages = new Map<string, HTMLImageElement>();
305
- for (const page of atlas.pages) {
306
- const image = await loadImage(page.name); // your loader
307
- page.setTexture(new DomTexture(image));
308
- pageImages.set(page.name, image);
309
- }
310
- const regionImages = await unpackRegions(atlas, pageImages);
311
- const data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(jsonText);
312
-
313
- // …and on unload, after every renderer using them is disposed:
314
- revokeRegions(regionImages);
315
- ```
316
-
317
- A region the packer stored turned (`rotate: 90`) is cut upright again, and which
318
- way round that is comes from spine-core's own UVs rather than from the cut: such
319
- a region's corners carry `(u2, v2), (u, v2), (u, v), (u2, v)` in the order
320
- BL, UL, UR, BR, so the artwork's top-left corner sits at the packed rect's
272
+ `loadModel` is a convenience over two calls the package exports: read the
273
+ document, load its pages, and cut the regions with `unpackRegions`, which takes
274
+ the document's `pages` section — `{ name, width, height, pma, regions: [{ name,
275
+ x, y, width, height, degrees }] }` per page — and a map of page images keyed by
276
+ those names. Use it when the page images do not come from a URL (`resolvePage`
277
+ on `loadModel` covers the ones that come from another URL), and pair it with
278
+ `revokeRegions` on unload.
279
+
280
+ A region the packer stored turned (`degrees: 90`) is cut upright again, and
281
+ which way round that is comes from the UVs a runtime assigns such a region,
282
+ not from the cut: its corners carry `(u2, v2), (u, v2), (u, v), (u2, v)` in the
283
+ order BL, UL, UR, BR, so the artwork's top-left corner sits at the packed rect's
321
284
  bottom-left and unpacking it is a clockwise turn. Every release up to 0.7.0
322
- turned it the other way and drew rotated rigid parts 180° round (#49); if you
323
- worked around that by re-packing an atlas without rotation, you can stop.
285
+ turned it the other way and drew rotated rigid parts 180° round (#49).
324
286
 
325
287
  `unpackRegions` mints one blob URL per region; `revokeRegions` is its
326
288
  counterpart. It only frees URLs `unpackRegions` created, so a map you built
327
- yourself (below) and page images reused by the whole-page pass-through survive
328
- it — and calling it twice is a no-op. Load once for the page's lifetime and you
329
- can ignore it; load and unload repeatedly without it and you leak an atlas per
289
+ yourself and page images reused by the whole-page pass-through survive it — and
290
+ calling it twice is a no-op. Load once for the page's lifetime and you can
291
+ ignore it; load and unload repeatedly without it and you leak an atlas per
330
292
  cycle.
331
293
 
332
294
  ### Pages that ship at another resolution
333
295
 
334
- A page image may ship at a resolution its atlas does not declare — a
335
- half-resolution texture build, or an @2x variant, with the `size:` line left as
336
- the packer wrote it. Nothing here needs a flag for that. A region's bounds are
337
- read **relative to the declared page size** and scaled onto the image's natural
338
- size, which is exactly how `spine-core` derives the UVs the mesh tier samples
339
- with (`region.u = region.x / page.width`), so both tiers land on the same
340
- pixels at any resolution:
341
-
342
- ```
343
- hero.png
344
- size: 4096, 4096 # what the packer wrote
345
- # hero.png itself ships 2048×2048
346
- ```
296
+ A page image may ship at a resolution its document does not declare — a
297
+ half-resolution texture build, or an @2x variant, with the page's `width` and
298
+ `height` left as the build wrote them. Nothing here needs a flag for that. A
299
+ region's bounds are read **relative to the declared page size** and scaled onto
300
+ the image's natural size, which is how the page UVs the mesh tier samples with
301
+ are derived too (`u = x / page.width`), so both tiers land on the same pixels at
302
+ any resolution.
347
303
 
348
304
  Each unpacked bitmap comes out at the **native resolution of the pixels it was
349
305
  cut from** — half-resolution pages cost a quarter of the cut pixels, and
350
306
  nothing is upscaled back — while `RegionImage.width`/`height` stay in **atlas
351
307
  units**. Those two numbers are the `<img>` layout box and the denominator of
352
- its CSS matrix, so the skeleton poses identically and the browser scales the
308
+ its CSS matrix, so the rig poses identically and the browser scales the
353
309
  smaller bitmap into the same box, the way a GPU samples a smaller texture
354
- through the same UVs. Ship one atlas and swap the images per device if you
355
- like.
356
-
357
- The corollary: a `size:` line that is simply **wrong** is now wrong for the
358
- whole renderer rather than for the mesh tier alone. Bounds are read against
359
- what the atlas declares, so a page declared at a size its artwork was not
360
- packed at reads every region from the wrong place in both tiers — correct the
361
- `size:` line, which is what every other Spine runtime needs too.
362
-
363
- ### Premultiplied pages (`pma: true`)
364
-
365
- The Spine texture packer premultiplies alpha by default, and says so with a page
366
- line:
367
-
368
- ```
369
- hero.png
370
- size: 2048, 2048
371
- pma: true
372
- ```
373
-
374
- Such a page's RGB is already multiplied by its alpha, and nothing is asked of
375
- you: the flag is read off the atlas and each tier is handed the page in the
376
- convention it actually consumes. The `webgl` mesh backend uploads the texels
377
- unconverted (its blend already expects premultiplied source — lossless). The DOM
378
- and canvas2d tiers cannot: an `<img>` and `drawImage` composite straight alpha by
379
- definition, so they read a straight-alpha derivation of the page —
310
+ through the same UVs. Ship one build and swap the images per device if you
311
+ like (`resolvePage`).
312
+
313
+ ### Premultiplied pages (`pma`)
314
+
315
+ A page's `pma` flag in the document's `pages` section — the build copies it from
316
+ the atlas's `pma: true` line — says its RGB is already multiplied by its alpha,
317
+ and nothing is asked of you: each tier is handed the page in the convention it
318
+ actually consumes. The `webgl` mesh backend uploads the texels unconverted (its
319
+ blend already expects premultiplied source — lossless). The DOM and canvas2d
320
+ tiers cannot: an `<img>` and `drawImage` composite straight alpha by definition,
321
+ so they read a straight-alpha derivation of the page —
380
322
  `rgb = round(rgb * 255 / a)`, computed once and shared by the region cuts and
381
323
  the mesh raster. Without it every semi-transparent texel is multiplied by its
382
324
  alpha a second time and draws darker than it was authored: soft edges, soft
@@ -384,100 +326,89 @@ shadows, glows.
384
326
 
385
327
  What it costs: **one page-sized canvas per premultiplied page image**
386
328
  (width × height × 4 bytes — 1 MiB for a 1024×256 page, 16 MiB for a 2048×2048
387
- one), alive as long as you hold the page image and released with it; the cache
388
- is weak, so there is nothing to free by hand. A page with no `pma:` line derives
389
- nothing and takes exactly the path it always did. The division is 8-bit and
390
- starts from a canvas read, which has itself quantized a premultiplied texel, so
391
- a very transparent texel can land a few levels off — exact at alpha 0 and 255,
392
- within one level of 255 above alpha 128, and a little more below that, by an
393
- amount that belongs to the browser's canvas rather than to this package (some
394
- rasterizers round premultiplied storage several times more coarsely than
395
- others). It is bounded by what that canvas already costs, and it is invisible
396
- wherever the texel is: a texel at alpha 11 is ~4% opaque. The `webgl` backend,
397
- having no such step, is exact.
398
-
399
- One consequence worth knowing if your atlas is one part per page: a whole-page
329
+ one), alive as long as the page image is and released with it; the cache is
330
+ weak, so there is nothing to free by hand. A page without the flag derives
331
+ nothing. The division is 8-bit and starts from a canvas read, which has itself
332
+ quantized a premultiplied texel, so a very transparent texel can land a few
333
+ levels off — exact at alpha 0 and 255, within one level of 255 above alpha 128,
334
+ and a little more below that, by an amount that belongs to the browser's canvas
335
+ rather than to this package. It is invisible wherever the texel is: a texel at
336
+ alpha 11 is ~4% opaque. The `webgl` backend, having no such step, is exact.
337
+
338
+ One consequence worth knowing if your build is one part per page: a whole-page
400
339
  region on a premultiplied page is **cut** rather than handed through, because
401
340
  the page's own URL holds premultiplied pixels (see below).
402
341
 
403
342
  ### What unloading frees
404
343
 
405
- `revokeRegions()` — and `assets.dispose()`, which just calls it — frees the blob
406
- URLs `unpackRegions` minted, and nothing else. The rest is pinned by objects you
407
- own: an atlas **page image** is held by the `DomTexture` you attached to its
408
- page, and `SkeletonData` reaches that same image through every attachment
409
- (`region.texture.getImage()`), so a page is released only once the atlas, the
410
- skeleton data and every renderer drawing from them are gone. `atlas.dispose()`
411
- does not do it — `DomTexture.dispose()` is a no-op, because the image is yours.
412
- With the `webgl` mesh backend the shared blitter also uploads each page image on
413
- first use, but that GL texture is not the module's forever: every renderer
414
- drawing the page holds a reference to it, `renderer.dispose()` hands those back,
415
- and the texture is deleted once the last renderer using that page is disposed —
416
- a later frame that needs the page uploads it again. The cache is keyed weakly by
417
- the page image, so a renderer dropped without `dispose()` costs GPU memory until
344
+ `revokeRegions()` — and `assets.dispose()`, which calls it — frees the blob URLs
345
+ `unpackRegions` minted, and nothing else. The page images are held by
346
+ `assets.pageImages` and by every player drawing from them, so a page is
347
+ released once you drop the assets and every player is disposed. With the
348
+ `webgl` mesh backend the shared blitter also uploads each page image on first
349
+ use, but that GL texture is not the module's forever: every renderer drawing
350
+ the page holds a reference to it, `player.dispose()` hands those back, and the
351
+ texture is deleted once the last renderer using that page is disposed — a later
352
+ frame that needs the page uploads it again. The cache is keyed weakly by the
353
+ page image, so a renderer dropped without `dispose()` costs GPU memory until
418
354
  the context is lost, but never keeps the image itself alive.
419
355
 
420
- A meshed skeleton samples the page bitmap every frame, so its decoded form stays
421
- in use: a floor on the order of `page width × page height × 4` bytes per page —
422
- 1 MiB for spineboy's single 1024×256 page. A rigid-only skeleton draws nothing
423
- from the page after `unpackRegions`, and what a merely reachable, undrawn image
424
- costs is up to the browser, not measurable from script.
356
+ A rig with meshes samples its page bitmaps every frame, so their decoded form
357
+ stays in use: a floor on the order of `page width × page height × 4` bytes per
358
+ page. A rigid-only rig draws nothing from the pages after `unpackRegions`, and
359
+ what a merely reachable, undrawn image costs is up to the browser, not
360
+ measurable from script.
425
361
 
426
362
  Unload in this order:
427
363
 
428
- 1. `renderer.dispose()`, for every renderer using the images.
429
- 2. `revokeRegions(regionImages)` (or `assets.dispose()`).
430
- 3. Drop your references to the atlas and the skeleton data.
364
+ 1. `player.dispose()`, for every player using the assets.
365
+ 2. `assets.dispose()` (or `revokeRegions(regionImages)`).
366
+ 3. Drop your references to the assets.
431
367
 
432
- ### One part per page (loose part PNGs)
368
+ ### One part per page
433
369
 
434
- Not every pipeline runs the Spine editor's texture packer. If your parts are
435
- loose PNGs, declare each one as its own atlas page — a blank line closes a page
436
- block, the next line opens the next:
370
+ `rigc build` writes one page per part by default — each part PNG is its own
371
+ page, its one region covering it — and `rigc build --pack` arranges the parts
372
+ onto shared pages instead. Both play here, unchanged:
437
373
 
438
- ```
439
- head.png
440
- size: 512, 512
441
- head
442
- bounds: 0, 0, 512, 512
443
-
444
- torso.png
445
- size: 640, 480
446
- torso
447
- bounds: 0, 0, 640, 480
448
- ```
449
-
450
- `spine-core` parses this as a normal multi-page atlas and nothing here needs a
451
- flag. Two things to know:
452
-
453
- - **`size:` is the frame every coordinate is read in**, so each page's
454
- `bounds` must be written against the `size:` above them. The two need not be
455
- the PNG's pixel size — that is the point of the section above, and a
456
- half-resolution `head.png` under `size: 512, 512` works — but a `size:` that
457
- matches neither the bounds nor the artwork reads every region from the wrong
458
- place, in both tiers.
459
- - Regions like these cover their whole page, so `unpackRegions` hands the page
460
- image straight through instead of cutting and re-encoding it — at any image
461
- resolution, since covering the page is a statement about the declared size.
462
- Load cost for this atlas shape is just the image loads. The exception is a
463
- page marked `pma: true`: its URL holds premultiplied pixels, which an `<img>`
464
- would composite as straight alpha, so those regions are cut from the
465
- straight-alpha derivation like any other (and the blob is revoked by
466
- `revokeRegions` like any other).
467
-
468
- For a **rigid-only** skeleton you can skip atlas unpacking altogether and hand
469
- the renderer a map you build yourself — meshes cannot, because the deform tier
470
- samples the page bitmap through the atlas region:
471
-
472
- ```ts
473
- const regionImages = new Map([
474
- ['head', { url: '/parts/head.png', width: 512, height: 512 }],
475
- ]);
476
- const renderer = new SpineHtmlRenderer(rootElement, regionImages);
477
- ```
374
+ - Regions that cover their whole page are handed straight through by
375
+ `unpackRegions` instead of being cut and re-encoded — at any image resolution,
376
+ since covering the page is a statement about the declared size. Load cost for
377
+ this shape is just the image loads. The exception is a page marked `pma`: its
378
+ URL holds premultiplied pixels, which an `<img>` would composite as straight
379
+ alpha, so those regions are cut from the straight-alpha derivation like any
380
+ other (and the blob is revoked by `revokeRegions` like any other).
381
+ - A packed build cuts every region from its page once, at load, and its meshes
382
+ sample the shared page through page UVs.
478
383
 
479
384
  ### Runtime knobs and what they cost
480
385
 
386
+ - `player.mode` / `new RigPlayer(root, assets, { mode })` — `'dom'` (default)
387
+ or `'canvas'`. Changing it disposes the current renderer (elements removed,
388
+ GPU pages handed back) and creates the other over the same root and assets;
389
+ `pixelRatio` and `clipping` carry over and the held pose is redrawn at once.
390
+ `player.renderer` is then a `CanvasRenderer`, whose knobs are `pixelRatio`,
391
+ `clipping`, `viewport` and `backend`. What each mode costs, as counters:
392
+ - **dom** — one element per drawn slot, one style write per changed slot,
393
+ and the mesh tier's rasters (the knobs below).
394
+ - **canvas** — one canvas element per player and, per frame that changed,
395
+ one pass over every slot: `drawCalls` (one per run of one page under one
396
+ blend), `blendFlushes` (runs a blend change ended), `trianglesDrawn`,
397
+ `blits` (one), or `framesReused` (one) when the record did not change and
398
+ nothing is drawn. It never creates a WebGL context of its own, so any number
399
+ of players stays under the browser's ~16-context cap (20 players, one
400
+ context — `tests/canvas-mode.spec.ts`). The canvas covers a rect of the
401
+ root's coordinates that grows (never shrinks) to what the frames draw —
402
+ `canvasReallocCount` settles at zero — or a fixed `renderer.viewport` (world
403
+ units, Y up) when you set one. Blends are composed inside that canvas, which
404
+ starts transparent: an additive slot adds onto the slots under it and is
405
+ then composited onto the page normally, where the DOM tier's
406
+ `plus-lighter` adds onto the page itself. Where WebGL is unavailable or the
407
+ context is lost, the frame is drawn with canvas2d on the same canvas so it
408
+ never draws nothing — **alpha tint only, RGB and dark tint unsupported
409
+ there** — and that fallback is **not the measured path** (`backendActive`
410
+ says which ran).
411
+
481
412
  - `renderer.pixelRatio` — mesh-canvas backing pixels per CSS pixel (defaults to
482
413
  `devicePixelRatio`; if you scale the root element, fold that scale in so the
483
414
  raster matches the screen: `devicePixelRatio * rootScale`). **Writing it
@@ -511,8 +442,12 @@ const renderer = new SpineHtmlRenderer(rootElement, regionImages);
511
442
  mesh once (no reallocation), so it is fine to expose as a user setting.
512
443
  - `renderer.triangleExpand` — clip overdraw in px that closes antialiased mesh
513
444
  seams (default 0.5). Also re-rasters every mesh once when changed.
514
- - `renderer.clipping` — apply clipping attachments (default `true`). What it
515
- writes is one CSS `clip-path` per element the active clip covers, in that
445
+ - `renderer.clipping` — apply the clips the frame carries (default `true`).
446
+ Which slots a clip covers is decided where the frame is made — the player's
447
+ adapter walks the draw order the way the runtime's draw loop does (one clip in
448
+ force at a time, a second one met while one is in force ignored, the end slot
449
+ inside its own clip). What the renderer writes is one CSS `clip-path` per
450
+ element a clip covers, in that
516
451
  element's own local frame; nothing reaches the raster backends, and a clip is
517
452
  not part of the mesh dirty signature, so a mesh that held still keeps reusing
518
453
  its raster under a moving clip. **Writes happen on change only**: each
@@ -520,11 +455,11 @@ const renderer = new SpineHtmlRenderer(rootElement, regionImages);
520
455
  1/1000 of a local unit so float jitter cannot defeat that cache, and a static
521
456
  polygon over a static pose therefore costs **zero style writes per frame after
522
457
  the first** — `renderer.clipWriteCount` is the check, alongside
523
- `clipCount` (applied) and `clipSkipCount` (not applied: switched off, a second
524
- clip met while one was active, an inactive bone, a degenerate polygon).
525
- **The whole-skeleton fast path**: when the clip starts before anything has been
526
- drawn and never ends, one `clip-path` goes on the root instead of one per
527
- element. The root is *your* element, so its inline `clip-path` is **borrowed,
458
+ `clipCount` (clips applied) and `clipSkipCount` (clips in the frame not
459
+ applied: switched off, or a degenerate polygon; a clip the runtime itself
460
+ ignores never reaches the frame). **The whole-skeleton fast path**: when one
461
+ clip covers every slot of the frame, one `clip-path` goes on the root instead
462
+ of one per element. The root is *your* element, so its inline `clip-path` is **borrowed,
528
463
  not taken** — saved on the first write and put back verbatim when the clip
529
464
  stops covering the frame, when `clipping` goes `false`, and by `dispose()`.
530
465
  Per-element clip-paths and the root clip-path are never both in force for the
@@ -565,18 +500,22 @@ bun install
565
500
  bun run dev
566
501
  ```
567
502
 
568
- The official spineboy example assets are downloaded automatically on first `dev`/`build`
569
- (they are owned by Esoteric Software and not redistributed in this repository — see
570
- [NOTICE.md](NOTICE.md)); `bun run fetch-assets` runs the same idempotent step manually.
503
+ The demo plays rigc's own gallery rigs (`walk`, `flex`, `squash`, `portrait`,
504
+ `nod`, `look`, `ride`), each built by `rigc build` from a pinned rigc tag into
505
+ `public/rigs/<name>/` on first `dev`/`build` — and each again with `--pack` into
506
+ `public/rigs/<name>-packed/`. The same step builds this repository's test rigs
507
+ (`tests/rigs/`: `figure`, `glow`, `turned`), which `?rig=` also reaches; nothing
508
+ is downloaded but the rigc tag's gallery sources. `bun run fetch-assets` runs it
509
+ manually, and it is idempotent.
571
510
 
572
- Debug knobs (query string): `?skel=pro|ess` `?anim=walk` `?count=10` pick the scene,
573
- `?tint=ff8080` tints the whole skeleton, `?dpr=2` overrides the mesh-canvas backing
511
+ Debug knobs (query string): `?rig=flex` `?anim=wave` `?count=10` pick the scene,
512
+ `?tint=ff8080` tints the whole rig, `?dpr=2` overrides the mesh-canvas backing
574
513
  ratio, `?timescale=0` freezes the pose (every mesh should report "reused"),
575
514
  `?expand=0` disables the crack-closing clip overdraw, `?backend=webgl` rasterizes
576
515
  meshes through the shared WebGL blitter (also a live header select; the stats line
577
- names the active backend), `?clipping=0` turns clipping attachments back off (they
578
- are then counted and skipped, as before v0.6 — try it on `?anim=portal`), and
579
- `?time=1.2` seeks every instance to the same pose for deterministic captures.
516
+ names the active backend), `?clipping=0` turns clips back off (they are then
517
+ counted and skipped), and `?time=1.2` seeks every instance to the same pose for
518
+ deterministic captures.
580
519
 
581
520
  ## Tests
582
521
 
@@ -585,23 +524,33 @@ bun run test # builds + serves the demo, then runs chromium + webkit
585
524
  ```
586
525
 
587
526
  Playwright drives the demo (`tests/`, config in `playwright.config.ts`; CI
588
- runs the same suite on ubuntu, **once per supported spine-core minor**). That
589
- matrix is what the compatibility table above is made of: each column installs
590
- that minor over the lockfile's, fetches the matching spine-runtimes branch's
591
- example exports, and runs everything. `SPINE_ASSETS_BRANCH=4.2 bun run
592
- fetch-assets` gets those exports locally, and `SPINE_CORE_MINOR` tells the run
593
- which minor it is against — anything but the typed one builds without
594
- `tsc --noEmit`, since the package is typed against one generation and runs on
595
- several. The visual check is **A/B within one run**:
596
- no golden snapshots are committed (they rot across platforms/GPUs) — instead
597
- the same deterministic pose (`?time` + `?timescale=0`) is screenshotted with
598
- `?backend=canvas2d` and `?backend=webgl` in the same engine and the buffers
599
- are diffed directly with a shift-tolerant comparison, so missing parts,
600
- wrong colors, and tint/blend divergence fail regardless of platform.
601
- Hairline seams are guarded by a deterministic canary (`?expand=0` must
602
- change the canvas2d raster), and counter tests pin the dirty-skip /
603
- grow-only-backing / clip-counter invariants plus spine-core's region corner
604
- order (BL, UL, UR, BR).
527
+ runs the same suite on ubuntu, one job). Every scene is a rig built by
528
+ `rigc build` and posed by `rig-c/core` through `RigPlayer` — rigc's gallery rigs,
529
+ and three test rigs authored in this repository (`tests/rigs/`) for what the
530
+ gallery does not carry: `figure` (clipping attachments in rigc's format —
531
+ convex, inverse, nested, whole-skeleton — rigid parts on turned bones, two grid
532
+ meshes), `glow` (additive slots with slot colours over two weighted meshes) and
533
+ `turned` (a region packed turned 90° on a hand-packed page). No Spine runtime
534
+ is installed or compared against: the pose is rig-c's, held to spine-core at
535
+ tolerance 0 by rig-c's own CI, and a pose doubt is a rigc issue. The visual
536
+ checks are **A/B within one run**: no golden snapshots are committed (they rot
537
+ across platforms/GPUs) — instead the same deterministic pose (`?time` +
538
+ `?timescale=0`) is screenshotted twice in the same engine and the buffers are
539
+ diffed directly with a shift-tolerant comparison, so missing parts, wrong
540
+ colors, and tint/blend/clip divergence fail regardless of platform.
541
+
542
+ **The picture gate** is the two render modes drawing the same picture: every
543
+ scene captured in the DOM mode and in the canvas mode and diffed — bad pixels at
544
+ most 1.5% of drawn content on the test-rig scenes (measured floors 0.007–0.524%,
545
+ macOS Chromium; each scene's own feature dropped from the canvas mode — tint,
546
+ additive blend, clipping, a mesh — scores 4.2–77%) and 2.5% on the gallery
547
+ scenes (floors 0.585–1.553%, Chromium and WebKit). The DOM tier's two mesh
548
+ backends get the same A/B (`?backend=canvas2d` against `?backend=webgl`, at most
549
+ 0.5%). Hairline seams are guarded by a deterministic canary (`?expand=0` must
550
+ change the canvas2d raster), the canvas2d source sub-rect by another (expand 32
551
+ against 8), and counter tests pin the dirty-skip / grow-only-backing /
552
+ clip-counter invariants plus the region corner order (BL, UL, UR, BR) and the
553
+ turned-region UVs, read off rig-c's pose against hand-written numbers.
605
554
 
606
555
  Clipping gets its own oracle (`tests/clipping.spec.ts`), because a clip-path
607
556
  that went through the wrong transform still parses and still counts as applied.
@@ -625,47 +574,7 @@ resolving, so blob ownership — every unpacked URL freed, nothing the caller
625
574
  owns touched, nothing stranded by a failed load — is asserted rather than
626
575
  assumed.
627
576
 
628
- ### The pixel oracle: a reference that is not us
629
-
630
- Everything above compares this package against itself. That catches one mesh
631
- backend drifting from the other and, by construction, cannot catch a defect
632
- they **share** — which is what happened with `pma: true` pages, drawn one
633
- multiply too dark in every tier while the suite stayed green for months.
634
-
635
- So `tests/oracle.spec.ts` compares against the official runtime. The same
636
- export, the same frozen pose, the same size, the same atlas page, drawn by
637
- `@esotericsoftware/spine-webgl` into a canvas whose orthographic camera is
638
- solved to land world space on exactly the pixels the DOM stage lands it on —
639
- then both are screenshotted and diffed. Eleven cells: the rigid tier, the mesh
640
- tier on both backends, the exporter's own premultiplied page (`spineboy-pma`)
641
- on all three, a clipping pose, an additive-blend pose, a per-slot rgba/alpha
642
- pose, and a whole-skeleton tint.
643
-
644
- It earned its keep on the first run: the rigid tier was drawing every
645
- 90°-packed atlas region 180° round (#49), in every release up to 0.7.0, and no
646
- comparison of this package against itself could have seen it. Rotated packing is
647
- held there now — the example exports from the 4.2 branch rotate ten regions
648
- where the 4.3 branch's rotate none, so the CI column that installs 4.2 is what
649
- keeps the restore honest, and each cell logs how many regions its atlas rotated
650
- so a green run on an unrotated one cannot be mistaken for the proof.
651
-
652
- Two things make it evidence rather than decoration. The camera match is
653
- **derived** — solved against the DOM mapping, not fitted to a picture — and
654
- then **proved by driving it**: a deliberate one-world-unit offset of one side
655
- must change the diff, which it does by 3.3× and 6.5×. And what is forgiven is
656
- antialiasing and texture filtering and nothing else, so the stage sits where
657
- one atlas texel is one CSS pixel and neither runtime resamples. Limits are
658
- ratios of drawn content, plus a darker-minus-lighter excess on the
659
- premultiplied cells, because that defect class can only go one way — never an
660
- absolute per-pixel precision number, which is a platform's number and not a
661
- package's.
662
-
663
- spine-webgl is a **development dependency**. It is named by one file under
664
- `tests/`, it is in neither `dependencies` nor `peerDependencies`, and
665
- `tests/package.spec.ts` asserts that nothing the published package contains so
666
- much as mentions it.
667
-
668
- ## Benchmark: side by side with the official runtime
577
+ ## Benchmark: DOM mode vs canvas mode
669
578
 
670
579
  ```bash
671
580
  bun run bench:dev # the page, for a person to watch (and for Safari)
@@ -673,34 +582,48 @@ bun run bench # drives it in a headed Chromium and writes a report
673
582
  ```
674
583
 
675
584
  `bench/` is a separate vite project — not part of the library, and not part of
676
- what `bun run build` emits — that runs the same skeleton in both runtimes side
677
- by side, each with its own stats line in the demo's style. Scenes by query
678
- string: `?scene=mesh` (the deform tier working every frame), `?scene=held` (a
679
- frozen pose, where unchanged meshes reuse their raster and the reference
680
- redraws regardless), `?scene=rigid`, and `?scene=many&count=N` independent
681
- players — which is also where the architectural difference shows, since the
682
- reference needs one WebGL context per player against browsers' cap of ~16 and
683
- this package shares a single one however many there are. The page reports
684
- reaching that cap rather than dying at it.
585
+ what `bun run build` emits — that plays the same rigc gallery build in both
586
+ render modes side by side, each with its own stats line in the demo's style.
587
+ Scenes by query string: `?scene=mesh` (`portrait-packed` turning its head, the
588
+ deform tier working every frame), `?scene=held` (the same pose frozen, where
589
+ unchanged meshes reuse their raster and an unchanged frame is reused),
590
+ `?scene=rigid` (`walk-packed`, region attachments only) and
591
+ `?scene=many&count=N` independent players; `?rig=` and `?model=` name another
592
+ build. All four take `&count=N` up to 64 (a page limit, so that a cell stays
593
+ something you can see), laid out on one grid whose per-rig scale shrinks with
594
+ the column count, so a step up in count adds rigs rather than cropping the ones
595
+ already there.
596
+
597
+ **The two sides draw the same picture.** Both place rigs with one derivation and
598
+ clip each to the same rect — the pane for a grid, the rig's own cell for `many`
599
+ — with `overflow: hidden` on a box, the way a page embeds a player, and then
600
+ *check* it: each side reports `origin Δpx`, the largest gap between where the
601
+ grid puts a rig and where that side drew it, and the report table carries it per
602
+ row. (`contain: layout paint` is deliberately not used: paint containment clips
603
+ to the same edge `overflow: hidden` does, and what it adds past the clip — a
604
+ stacking context, a containing block, an independent formatting context —
605
+ changes how one side is composited and buys the comparison nothing.) Both modes
606
+ share the package's single WebGL context however many players there are; the
607
+ canvas side's stats line says how many contexts the page holds.
685
608
 
686
609
  `scripts/bench.mjs` walks `bench/scenes.json`, opens each scene in a **headed**
687
610
  Chromium (a hidden page throttles `requestAnimationFrame`, so a headless
688
- reading would be the browser's power policy rather than either runtime's cost),
689
- runs a fixed number of frames per runtime — one at a time, because rAF has a
690
- single cadence per document — and writes JSON plus a markdown table with the
611
+ reading would be the browser's power policy rather than either mode's cost),
612
+ runs a fixed number of frames per mode — one at a time, because rAF has a single
613
+ cadence per document — and writes JSON plus a markdown table with the
691
614
  conditions attached: browser version, device pixel ratio, window size, GPU
692
615
  string, and the load average before and after. Over a threshold it stamps the
693
616
  whole report `INVALID: load average too high` and exits non-zero, because a
694
617
  benchmark on a busy machine measures the machine. `--corpus <dir>` points it at
695
- local rigs that never enter this repository.
618
+ local rigc builds that never enter this repository.
696
619
 
697
- > **Numbers pending.** No comparison table is published yet, and one taken on a
698
- > loaded CI runner or in a headless browser would not be worth reading. The
699
- > table will be taken the way every other number in this README was: the two
700
- > stats lines read on a real device, on a quiet machine, per scene and per
701
- > engine — including Safari, which stays manual because no automation preserves
702
- > what is being measured there. Until then the instrument exists and the
703
- > measurement does not.
620
+ > **Numbers pending.** No comparison table is published yet. A reading taken on
621
+ > a loaded machine, a CI runner or a headless browser would not be worth
622
+ > printing. The table will be taken the way every other number in this README
623
+ > was: the two stats lines read on a real device, on a quiet machine, per scene
624
+ > and per engine — including Safari, which stays manual because no automation
625
+ > preserves what is being measured there. Until then the instrument exists and
626
+ > the measurement does not.
704
627
 
705
628
  The server is part of the test: a run tests whatever is being served on its
706
629
  port. Two checkouts of this repository on one machine (a worktree, a second
@@ -718,25 +641,45 @@ perf oracle is the demo's stats line on a real device.
718
641
 
719
642
  ## How it works
720
643
 
721
- 1. `@esotericsoftware/spine-core` loads the skeleton and drives `AnimationState`,
722
- constraints, and physics — all CPU-side, renderer-agnostic.
723
- 2. At load time each atlas region is cut into its own bitmap (rotation restored), one
724
- blob URL per region — except regions that already cover their whole page, which are
725
- used as they are.
726
- 3. Per frame, for each slot in draw order: `computeWorldVertices` yields the region's
727
- four corners in world space (order **BL, UL, UR, BR** — note the comments inside
728
- `computeWorldVertices` are stale). Flip Y (Spine is Y-up), derive the affine from
729
- three corners, write one `matrix()` string. That's the whole render path.
644
+ 1. `loadModel` reads the rig's compiled model document (`rig-c/core`'s `readModel`),
645
+ and `RigPlayer` opens an animation on a live track (`openTrack`): bones,
646
+ constraints, physics, deform and draw order are posed by the core — all
647
+ CPU-side, renderer-agnostic — one `dt` at a time.
648
+ 2. At load time each region of the document's `pages` section is cut into its own
649
+ bitmap (rotation restored), one blob URL per region — except regions that already
650
+ cover their whole page, which are used as they are.
651
+ 3. Per frame, the pose becomes a `PosedFrame`: for each slot in draw order, a region's
652
+ four world corners (order **BL, UL, UR, BR**) or a mesh's world vertices, page UVs
653
+ and triangles, its tint, blend and clip. For a region: flip Y (the frame is Y-up),
654
+ derive the affine from three corners, write one `matrix()` string. That's the
655
+ whole rigid render path.
730
656
 
731
657
  ## License
732
658
 
733
- The code in this repository is MIT licensed.
734
-
735
- It depends on the [Spine Runtimes](https://github.com/EsotericSoftware/spine-runtimes)
736
- (`@esotericsoftware/spine-core`), which are licensed under the
737
- [Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license):
738
- **integrating the Spine Runtimes — including through this project — requires each user
739
- to have their own valid Spine Editor license.** See [NOTICE.md](NOTICE.md).
740
-
741
- Example assets (spineboy) are owned by Esoteric Software and are fetched from the
742
- official repository at setup time, not redistributed here.
659
+ rig-play's own code is MIT (see [LICENSE](LICENSE)). That says nothing about Spine,
660
+ so here is what is true of this package and this repository, stated plainly:
661
+
662
+ 1. The published package — `rig-play`, and `spine-html`, the same files — **links
663
+ nothing from Esoteric Software**. Its one module bundles the renderer and
664
+ `rig-c/core` (MIT), which poses rigc's model document; no part of the
665
+ [Spine Runtimes](https://github.com/EsotericSoftware/spine-runtimes) is in it or
666
+ required beside it.
667
+ 2. This repository links no Spine runtime either, since #63: no package of Esoteric
668
+ Software's npm scope is a dependency of any kind, and the lockfile names none.
669
+ `tests/package.spec.ts` asserts both.
670
+ 3. `rigc build`, which this repository runs to make its fixtures, also writes Spine
671
+ skeleton data (`skeleton.json` and `skeleton.atlas`) beside each model document.
672
+ Nothing here reads it, and it is neither committed nor published.
673
+ 4. A product that plays that Spine data with a Spine Runtime has integrated the Spine
674
+ Runtimes, whose licence permits it **under Section 2 of the
675
+ [Spine Editor License Agreement](https://esotericsoftware.com/spine-editor-license)**,
676
+ or otherwise on the condition that **each user of the product obtains their own
677
+ Spine Editor license** — the licence's own two routes, restated, not a term of
678
+ this project.
679
+
680
+ > **What a consumer does with rigc's Spine-format output is governed by Esoteric
681
+ > Software's terms**, not by this package — rig-play neither adds those terms nor
682
+ > removes them.
683
+
684
+ No example assets of Esoteric Software's are fetched or redistributed. See
685
+ [NOTICE.md](NOTICE.md) for the full notice.