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/LICENSE +21 -0
- package/NOTICE.md +30 -0
- package/README.md +791 -2
- package/dist/DomTexture.d.ts +150 -0
- package/dist/DomTexture.d.ts.map +1 -0
- package/dist/DomTexture.js +520 -0
- package/dist/DomTexture.js.map +1 -0
- package/dist/MeshGlBlitter.d.ts +131 -0
- package/dist/MeshGlBlitter.d.ts.map +1 -0
- package/dist/MeshGlBlitter.js +365 -0
- package/dist/MeshGlBlitter.js.map +1 -0
- package/dist/SpineHtmlRenderer.d.ts +344 -0
- package/dist/SpineHtmlRenderer.d.ts.map +1 -0
- package/dist/SpineHtmlRenderer.js +1107 -0
- package/dist/SpineHtmlRenderer.js.map +1 -0
- package/dist/binary.d.ts +48 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +29 -0
- package/dist/binary.js.map +1 -0
- package/dist/coreCompat.d.ts +153 -0
- package/dist/coreCompat.d.ts.map +1 -0
- package/dist/coreCompat.js +136 -0
- package/dist/coreCompat.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/loadAtlasAssets.d.ts +73 -0
- package/dist/loadAtlasAssets.d.ts.map +1 -0
- package/dist/loadAtlasAssets.js +75 -0
- package/dist/loadAtlasAssets.js.map +1 -0
- package/dist/loadSkeletonAssets.d.ts +88 -0
- package/dist/loadSkeletonAssets.d.ts.map +1 -0
- package/dist/loadSkeletonAssets.js +79 -0
- package/dist/loadSkeletonAssets.js.map +1 -0
- package/package.json +68 -4
package/README.md
CHANGED
|
@@ -1,3 +1,792 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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.
|