rig-play 0.0.0-stage → 0.8.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.
package/README.md CHANGED
@@ -1,3 +1,792 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="assets/banner.svg" alt="spine-html - DOM-first Spine 2D runtime" width="100%" />
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/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>
8
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-38BDF8.svg?style=flat-square" alt="license" /></a>
9
+ </p>
10
+
11
+ 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.
14
+
15
+ ## Why
16
+
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):
30
+
31
+ - Rigid only (spineboy-ess), headless Chromium: 10 skeletons / 180 slot images at
32
+ **~0.03 ms skeleton math + ~0.22 ms DOM writes per frame** — about 1.5% of a
33
+ 60 fps frame budget. On-device Safari: 10 rigid skeletons hold **60 fps**.
34
+ - With meshes (spineboy-pro), headless Chromium: 1 skeleton = **~0.05 ms + ~0.5 ms
35
+ render** (8 mesh canvases, 323 triangles); 10 running skeletons = **~3.5 ms/frame**
36
+ total, smooth.
37
+ - Dirty-skip: 10 *static* skeletons (pose held) = **~0.4 ms/frame** headless and
38
+ **~0.6 ms** on-device Safari — unchanged meshes reuse their raster, so idle or
39
+ held parts cost nothing anywhere.
40
+ - Real Safari (on-device), canvas2d mesh backend: in-callback JS stays **~4–5
41
+ ms/frame** for 10 running meshed skeletons — but the rAF rate tells the real
42
+ story: **meshed ×1 = 37 fps, meshed ×10 = 3–4 fps** (rigid-only ×10 = 60 fps).
43
+ The cost lives outside the frame callback, in the compositor/GPU process:
44
+ Safari antialiases canvas2d **clip paths**, so every triangle pays for an AA
45
+ mask. Chromium doesn't antialias clips and stays smooth. This is what the
46
+ optional WebGL mesh backend (below) removes.
47
+ - Real Safari (on-device), **webgl mesh backend**: meshed ×1 holds **60 fps**
48
+ (vs 37 on canvas2d), and the meshed ×10 stress scene jumps **3–4 → 45 fps** —
49
+ 80 mesh canvases / 3230 triangles redrawn every frame (0 reused, the worst
50
+ case), in-callback JS ~5.8 ms at dpr=1. The per-triangle clip-AA tax is gone;
51
+ the remaining gap to 60 is the blit/compositing cost of a 10-skeleton stress
52
+ scene, not a per-triangle cost.
53
+ - Headless-WebKit numbers are a **software rasterizer** and measured up to 28×
54
+ off real Safari in both directions — useful for visual regression only, never
55
+ as Safari perf evidence.
56
+
57
+ ## Status
58
+
59
+ **Production.** The questions this started as a PoC to answer — how far does the
60
+ DOM actually go, and how cheap is it — are answered with on-device numbers: the
61
+ practical scenario (a character or two, mostly holding pose, a few parts
62
+ deforming) holds **60 fps on every engine measured**, and the 10-skeleton
63
+ stress scene holds 45 fps on the weakest one (Safari, webgl backend). Ships
64
+ with a self-contained test suite (backend visual parity + invariants) and CI.
65
+
66
+ - ✅ Region attachments (rigid parts): exact affine mapping, draw-order via `z-index`,
67
+ attachment swaps, alpha
68
+ - ✅ 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),
70
+ so the rigid per-frame path never touches a canvas — a region that covers its whole
71
+ page is passed through uncut, and `revokeRegions()` frees the rest when a skeleton
72
+ is unloaded
73
+ - ✅ Mesh attachments (deform tier): small per-part canvases sized to the mesh's world
74
+ bounds, interleaved with the rigid `<img>` slots in one stacking context — the DOM
75
+ handles the bones, a rasterizer handles the warps
76
+ - ✅ Blend modes via `mix-blend-mode` (additive = `plus-lighter`)
77
+ - ✅ Crack-free mesh seams on clip-antialiasing browsers (Safari): each triangle's
78
+ clip polygon is expanded 0.5px from its centroid so neighbours overlap — the
79
+ texture is continuous across shared edges, so the overlap is invisible
80
+ (`?expand=0` shows the cracks for comparison)
81
+ - ✅ RGB tinting (skeleton × slot × attachment color) via an SVG `feColorMatrix`
82
+ reference filter per element — an exact channel multiply that works identically on
83
+ `<img>` and `<canvas>` without touching the raster (needs reference-filter support,
84
+ Safari 15+; verified pixel-level on Chromium and WebKit). Dark/two-color tint is
85
+ not expressible this way and stays out of scope
86
+ - ✅ DPR-aware mesh canvas backing store: `renderer.pixelRatio` (defaults to
87
+ `devicePixelRatio`; if you scale the root element, fold that scale in)
88
+ - ✅ Dirty-skip: a mesh whose canvas-space vertices didn't change reuses last frame's
89
+ raster — the CSS translate still tracks it, so parts that hold a pose (or move by
90
+ whole pixels) pay zero raster. 10 frozen spineboys: WebKit ~141 → ~0.5 ms/frame
91
+ - ✅ Clipping attachments as element-level CSS `clip-path`: spine-core's semantics
92
+ (one active clip at a time, applied from the clip's slot through its end slot
93
+ inclusive), with the polygon expressed in each element's own local frame — the
94
+ 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
97
+ arrives, which takes a fast path: one `clip-path` on the root instead of one per
98
+ element). **Inverse clips** are supported too: the region they keep has a hole
99
+ in it, so it needs two rings, and `polygon()` carries only one — a box and a
100
+ polygon listed inside one `polygon()` become a single self-intersecting ring
101
+ whose even-odd fill leaves a wedge along the seam between them (measured, on a
102
+ corner of spineboy's boot). So an inverse clip is written as a two-subpath
103
+ `path(evenodd, …)` whose outer ring is the element's own box. The whole feature
104
+ is element-level, so both mesh backends see the same thing and neither raster
105
+ path knows clipping exists; `renderer.clipping = false`
106
+ restores the old counted-and-skipped behaviour. Concave polygons go to CSS as
107
+ authored — the convex decomposition spine's CPU clipper performs exists to feed a
108
+ triangle rasterizer and has no job here. Two things this does not do: Spine's
109
+ clipper convexifies an *inverse* polygon (convex hull) where CSS clips it as
110
+ authored, so the two agree exactly when that polygon is convex — which is the
111
+ shape `inverse` is meant for; and the cost of many simultaneous `clip-path`s on
112
+ real-device Safari is **not measured** (the headless numbers that exist are not
113
+ Safari evidence — see Measured above)
114
+ - ✅ Safari mesh cost root-caused by on-device triangulation (two corrections deep):
115
+ the early "~15× slower per-triangle path" was a **headless-WebKit artifact**
116
+ (software rasterization), and the follow-up "on par with Chromium" held only for
117
+ in-callback JS time. Real-Safari rAF rates — rigid ×10 = 60 fps, meshed ×1 =
118
+ 37 fps, meshed ×10 = 3–4 fps with JS flat at ~4–5 ms — put the real cost in the
119
+ GPU process: Safari antialiases canvas2d **clip paths**, so the per-triangle clip
120
+ mapping pays a per-triangle AA-mask tax (the same AA that caused the seam cracks)
121
+ - ✅ Optional WebGL blit backend for the mesh tier: `renderer.meshBackend =
122
+ 'webgl'` (default stays `'canvas2d'`). All dirty meshes are shelf-packed into
123
+ **one shared offscreen WebGL context** (module-level — browsers cap contexts at
124
+ ~16), drawn as textured triangles with premultiplied alpha, then rect-blitted
125
+ onto the same per-part canvases with an unclipped `drawImage` — cheap on Safari.
126
+ Everything element-level is unchanged: z-index interleave, tint filter,
127
+ `mix-blend-mode`, dirty-skip, grow-only backing. GL rasterizes shared triangle
128
+ edges seamlessly, so this path needs no crack overdraw. Falls back to canvas2d
129
+ when WebGL is unavailable or the context is lost. Backend parity verified by
130
+ headless Chromium+WebKit screenshot diffs (glow / clipping / tint scenes;
131
+ sub-pixel edge differences only). On-device Safari, meshed ×10 with every mesh
132
+ redrawn per frame: **3–4 fps (canvas2d) → 45 fps (webgl)**
133
+
134
+ ## Install
135
+
136
+ ```bash
137
+ npm i spine-html @esotericsoftware/spine-core
138
+ ```
139
+
140
+ ```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);
156
+
157
+ 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);
163
+ }
164
+
165
+ // Unloading (a cutscene ends, a level swaps): elements first, bitmaps second.
166
+ // The unpacked regions are blob URLs — nothing else frees them.
167
+ renderer.dispose();
168
+ assets.dispose();
169
+ ```
170
+
171
+ `spine-html` is a third-party renderer and is not affiliated with or endorsed by
172
+ Esoteric Software.
173
+
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:
266
+
267
+ ```ts
268
+ import { loadAtlasAssets, loadSkeletonJson, SpineHtmlRenderer } from 'spine-html';
269
+
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
+ ]);
275
+
276
+ const ess = new SpineHtmlRenderer(essRoot, shared.regionImages);
277
+ const pro = new SpineHtmlRenderer(proRoot, shared.regionImages);
278
+
279
+ // Unloading: every renderer first, then the one dispose() that owns the bitmaps.
280
+ ess.dispose();
281
+ pro.dispose();
282
+ shared.dispose();
283
+ ```
284
+
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.
295
+
296
+ ### Binary exports (`.skel`)
297
+
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:
301
+
302
+ ```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();
311
+ ```
312
+
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.
319
+
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
327
+
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:
332
+
333
+ ```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);
347
+
348
+ // …and on unload, after every renderer using them is disposed:
349
+ revokeRegions(regionImages);
350
+ ```
351
+
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
356
+ 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.
359
+
360
+ `unpackRegions` mints one blob URL per region; `revokeRegions` is its
361
+ 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
365
+ cycle.
366
+
367
+ ### Pages that ship at another resolution
368
+
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
+ ```
382
+
383
+ Each unpacked bitmap comes out at the **native resolution of the pixels it was
384
+ cut from** — half-resolution pages cost a quarter of the cut pixels, and
385
+ nothing is upscaled back — while `RegionImage.width`/`height` stay in **atlas
386
+ 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
388
+ 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 —
415
+ `rgb = round(rgb * 255 / a)`, computed once and shared by the region cuts and
416
+ the mesh raster. Without it every semi-transparent texel is multiplied by its
417
+ alpha a second time and draws darker than it was authored: soft edges, soft
418
+ shadows, glows.
419
+
420
+ What it costs: **one page-sized canvas per premultiplied page image**
421
+ (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
435
+ region on a premultiplied page is **cut** rather than handed through, because
436
+ the page's own URL holds premultiplied pixels (see below).
437
+
438
+ ### What unloading frees
439
+
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
453
+ the context is lost, but never keeps the image itself alive.
454
+
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.
460
+
461
+ Unload in this order:
462
+
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.
466
+
467
+ ### One part per page (loose part PNGs)
468
+
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:
472
+
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
+ ```
513
+
514
+ ### Runtime knobs and what they cost
515
+
516
+ - `renderer.pixelRatio` — mesh-canvas backing pixels per CSS pixel (defaults to
517
+ `devicePixelRatio`; if you scale the root element, fold that scale in so the
518
+ raster matches the screen: `devicePixelRatio * rootScale`). **Writing it
519
+ reallocates every mesh canvas backing store on the next frame**, and each
520
+ reallocation recreates a GPU surface — the cost that took real Safari to ~3 fps
521
+ when it happened per frame. Each canvas is then sized from what the new ratio
522
+ needs, in both directions: lowering the ratio gives the backing pixels back
523
+ (the backing is grow-only *within* a ratio, not across a change of one). Set
524
+ it when a layout settles, never per frame:
525
+ debounce resize drags and quantize the value instead of tracking it
526
+ continuously. `renderer.canvasReallocCount` is the check — it must fall back to
527
+ zero within a second or two.
528
+ - `renderer.syncPixelRatio()` — measures the root's effective on-screen scale
529
+ and sets `pixelRatio` to `devicePixelRatio × scale`, returning the ratio now
530
+ in effect. It appends a hidden 100 px box to the root, reads its box once and
531
+ removes it (the root itself is usually 0×0), so it is **one forced layout per
532
+ call** — which is exactly why the renderer never calls it for you: there is no
533
+ per-frame layout read anywhere in this library. Call it when a zoom or a
534
+ layout *settles* (gesture end, debounced resize), not during the drag. A
535
+ change under 0.1% is ignored, so layout jitter cannot churn GPU surfaces, and
536
+ a root that is not laid out (a `display: none` ancestor) leaves the ratio
537
+ alone. Under an ancestor rotation the measured box is inflated and the ratio
538
+ errs high — oversampling costs pixels, undersampling costs picture.
539
+ - `renderer.meshBackingPixels` — allocated mesh-canvas backing pixels
540
+ (Σ width × height), computed on demand. This is what `pixelRatio` moves
541
+ quadratically, and the cheapest way to see an oversampling stage; the demo
542
+ prints it in the stats line as `backing N Mpx`.
543
+ - `renderer.meshBackend` — `'canvas2d'` (default) or `'webgl'`; same output, but
544
+ heavy deforming scenes on Safari want `'webgl'` (see Measured above). Falls back
545
+ to canvas2d automatically when WebGL is unavailable. Switching re-rasters every
546
+ mesh once (no reallocation), so it is fine to expose as a user setting.
547
+ - `renderer.triangleExpand` — clip overdraw in px that closes antialiased mesh
548
+ 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
551
+ element's own local frame; nothing reaches the raster backends, and a clip is
552
+ not part of the mesh dirty signature, so a mesh that held still keeps reusing
553
+ its raster under a moving clip. **Writes happen on change only**: each
554
+ clip-path is cached exactly as `transform` is, coordinates are quantized to
555
+ 1/1000 of a local unit so float jitter cannot defeat that cache, and a static
556
+ polygon over a static pose therefore costs **zero style writes per frame after
557
+ 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,
563
+ not taken** — saved on the first write and put back verbatim when the clip
564
+ stops covering the frame, when `clipping` goes `false`, and by `dispose()`.
565
+ Per-element clip-paths and the root clip-path are never both in force for the
566
+ same clip. Two things to know about that path: the polygon is written in the
567
+ root's **border box** frame, which is where absolutely-positioned slot elements
568
+ start too *unless the root has a CSS border* (a border would offset the
569
+ whole-skeleton clip by its width — keep borders off the render root, which is
570
+ the normal shape for a 0×0 origin element); and an inverse clip never takes it,
571
+ because its CSS form needs an outer ring around a box and the root has none.
572
+ One further consequence of that path: a `clip-path` other than `none` makes an
573
+ element a stacking context (CSS Masking), so a whole-skeleton clip isolates
574
+ `mix-blend-mode` slots from backdrops *outside* the root — which a root
575
+ carrying a `transform` (the usual pan/zoom stage) already does. Per-element
576
+ clips do not change blending, since a blended slot is its own stacking context
577
+ either way. Setting `clipping = false` removes every clip-path this renderer
578
+ wrote.
579
+
580
+ **A zoomable stage.** The mesh tier rasters at `world × pixelRatio` in the
581
+ root's own coordinates, and it cannot see a CSS transform above the root — so
582
+ the most natural pan/zoom stage, `transform: scale(zoom)` on an ancestor, makes
583
+ it oversample by `1/zoom²` in backing pixels until the zoom is folded in. The
584
+ picture stays correct throughout, which is what makes this easy to ship: only
585
+ the allocation and the frame rate move. Call `syncPixelRatio()` when the zoom
586
+ settles, or set `pixelRatio = devicePixelRatio * zoom` yourself. Measured
587
+ on-device (Chromium, dpr 2, stage under `scale(0.25)`, ~2.2 M CSS px on screen):
588
+
589
+ | scene | `pixelRatio` | `meshBackingPixels` | fps |
590
+ | --- | --- | --- | --- |
591
+ | 57 meshes | `devicePixelRatio` (2) | 139.1 Mpx | 14–21 |
592
+ | 57 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 61 |
593
+ | 92 meshes | `devicePixelRatio` (2) | 141.1 Mpx | 36–39 |
594
+ | 92 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 60–61 |
595
+
596
+ ## Demo (this repository)
597
+
598
+ ```bash
599
+ bun install
600
+ bun run dev
601
+ ```
602
+
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.
606
+
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
609
+ ratio, `?timescale=0` freezes the pose (every mesh should report "reused"),
610
+ `?expand=0` disables the crack-closing clip overdraw, `?backend=webgl` rasterizes
611
+ 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.
615
+
616
+ ## Tests
617
+
618
+ ```bash
619
+ bun run test # builds + serves the demo, then runs chromium + webkit
620
+ ```
621
+
622
+ 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).
646
+
647
+ Clipping gets its own oracle (`tests/clipping.spec.ts`), because a clip-path
648
+ that went through the wrong transform still parses and still counts as applied.
649
+ A clipped capture is compared against the **unclipped** capture of the same
650
+ slots masked in **screen space** by the world polygon — built once through the
651
+ stage transform, never through an element's local frame, so the check cannot
652
+ agree with the renderer by sharing its mistake. What it asserts is occupancy,
653
+ not pixel values: outside a 3 px band around the polygon's outline (where the
654
+ browser's clip-path antialiasing and the canvas `ctx.clip()` the oracle uses
655
+ legitimately differ), **no artwork may survive where the polygon excludes it
656
+ and none may go missing where it does not** — both counts absolute, no budget.
657
+ Pixel values are logged but not asserted, because a clipped element is drawn
658
+ through a mask and its silhouettes come out a shade different all over the
659
+ picture; a control capture, clipped by a polygon that removes nothing, pins
660
+ that down by coming back byte-identical to the unclipped one.
661
+
662
+ The loading path is not observable in a rendered frame, so it gets its own
663
+ page (`tests/harness.html`, a second build entry) that exposes the library to
664
+ the specs directly. Its oracle is the browser: a revoked object URL stops
665
+ resolving, so blob ownership — every unpacked URL freed, nothing the caller
666
+ owns touched, nothing stranded by a failed load — is asserted rather than
667
+ assumed.
668
+
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
719
+
720
+ ```bash
721
+ bun run bench:dev # the page, for a person to watch (and for Safari)
722
+ bun run bench # drives it in a headed Chromium and writes a report
723
+ ```
724
+
725
+ `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.
735
+
736
+ `scripts/bench.mjs` walks `bench/scenes.json`, opens each scene in a **headed**
737
+ 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
741
+ conditions attached: browser version, device pixel ratio, window size, GPU
742
+ string, and the load average before and after. Over a threshold it stamps the
743
+ whole report `INVALID: load average too high` and exits non-zero, because a
744
+ benchmark on a busy machine measures the machine. `--corpus <dir>` points it at
745
+ local rigs that never enter this repository.
746
+
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.
754
+
755
+ The server is part of the test: a run tests whatever is being served on its
756
+ port. Two checkouts of this repository on one machine (a worktree, a second
757
+ clone) default to the same port 4321 and reuse an existing server, so the
758
+ second run quietly tests the first one's build — green, and about the wrong
759
+ tree. Give each concurrent checkout its own port with `TEST_PORT`
760
+ (`TEST_PORT=4333 bun run test`); setting it also disables server reuse, so a
761
+ busy port fails the run instead of being borrowed. Unset, nothing changes:
762
+ port 4321, reuse unless `CI`.
763
+
764
+ Standing rule: **headless numbers are never Safari performance evidence** —
765
+ nothing in the suite asserts timing, and headless-WebKit fps/ms readings do
766
+ not transfer (software rasterizer, measured up to 28× off real Safari). The
767
+ perf oracle is the demo's stats line on a real device.
768
+
769
+ ## How it works
770
+
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.
780
+
781
+ ## License
782
+
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.