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