@rig-c/play 1.17.0 → 1.18.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 +15 -4
- package/README.md +45 -1360
- package/dist/CanvasRenderer.d.ts +2 -1
- package/dist/CanvasRenderer.d.ts.map +1 -1
- package/dist/DomRenderer.d.ts +2 -1
- package/dist/DomRenderer.d.ts.map +1 -1
- package/dist/MeshGlBlitter.d.ts +1 -0
- package/dist/MeshGlBlitter.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2158 -160
- package/dist/index.js.map +1 -1
- package/dist/liveFrame.d.ts +47 -0
- package/dist/liveFrame.d.ts.map +1 -0
- package/dist/liveFrameSource.d.ts +2 -0
- package/dist/liveFrameSource.d.ts.map +1 -0
- package/dist/loadModel.d.ts.map +1 -1
- package/dist/player.d.ts +24 -0
- package/dist/player.d.ts.map +1 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -8,14 +8,15 @@
|
|
|
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
|
-
document `rigc build` writes, `skeleton.model.json` — as **plain
|
|
13
|
-
absolutely-positioned `<img>` per rigid slot, posed with a single CSS
|
|
14
|
-
per frame, deforming meshes on small per-part canvases; or
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
`@rig-c/play` plays the rigs [rigc](https://github.com/rig-c/core) builds — the
|
|
12
|
+
compiled model document `rigc build` writes, `skeleton.model.json` — as **plain
|
|
13
|
+
DOM**: one absolutely-positioned `<img>` per rigid slot, posed with a single CSS
|
|
14
|
+
`matrix()` write per frame, and deforming meshes on small per-part canvases; or
|
|
15
|
+
as **one canvas** per player. It poses through `rig-c/core`, rigc's own posing
|
|
16
|
+
core, bundled into the package, and links nothing from Esoteric Software.
|
|
17
|
+
`rig-play` and `spine-html` are the same package under the names it shipped as
|
|
18
|
+
before; the compiler is [rig-c/core](https://github.com/rig-c/core) (npm `rig-c`)
|
|
19
|
+
and the part generator [rig-c/parts](https://github.com/rig-c/parts).
|
|
19
20
|
|
|
20
21
|
## Why
|
|
21
22
|
|
|
@@ -95,164 +96,8 @@ mounts independent player instances):
|
|
|
95
96
|
| 20 small players | canvas | `walk-packed` `walk` | cell | 59.99 | 0.69 | 0.54 | —/20 | 600 | 20 | 0.015 | —/1 |
|
|
96
97
|
| 20 small players | official | `walk-packed` `walk` | cell | 60.00 | 0.13 | 0.24 | 16/20 | 480 | 16 | 0.006 | 16/20 |
|
|
97
98
|
|
|
98
|
-
Where each side wins and loses on that table
|
|
99
|
-
|
|
100
|
-
- **High-count meshed grids (`36` and `64 meshed rigs animating`) — `official` wins**: batching
|
|
101
|
-
all 64 skeletons into one WebGL canvas holds `59.99 fps` (`5,632 triangles` in `1 draw call`),
|
|
102
|
-
while `canvas` mode drops to `53.99 fps` (per-player `drawImage` blits from the shared WebGL
|
|
103
|
-
context) and `dom` mode drops to `48.28 fps` at 36 rigs and `29.07 fps` at 64 rigs (per-part
|
|
104
|
-
mesh canvas blits + DOM compositing). Up to 16 animating meshed rigs all three hold `60 fps`.
|
|
105
|
-
- **Held poses (`1`, `16`, `64 held poses`) — `dom` and `canvas` skip rasterization**: both
|
|
106
|
-
modes reuse their rasters (`0 triangles`, `0 draw calls`), while `official` redraws
|
|
107
|
-
`88` / `1,408` / `5,632 triangles` every frame.
|
|
108
|
-
- **Rigid-heavy grids (`64 rigid rigs animating`) — `dom` and batched `official` hold `60 fps`**:
|
|
109
|
-
`dom` updates `<img>` CSS transforms (`0 triangles`, `59.89 fps`), whereas `canvas` pays 64
|
|
110
|
-
per-player `drawImage` blits (`42.00 fps`).
|
|
111
|
-
- **Many independent players on one page (`20 small players`) — `dom` and `canvas` draw all 20;
|
|
112
|
-
`official` loses 4 to the browser's 16-context WebGL cap**: both `@rig-c/play` modes share at
|
|
113
|
-
most one WebGL context across the page, while mounting 20 independent `spine-webgl` players
|
|
114
|
-
evicts 4 contexts (`16/20 drawn players`, `16/20 live contexts`).
|
|
115
|
-
|
|
116
|
-
**The element mode's mesh backend is `'webgl'` by default since 1.9 (#109)**, read off
|
|
117
|
-
macOS Safari on 2026-10-13 (1.8.2, gallery rig `look-packed`: 4 meshes, 464
|
|
118
|
-
triangles, `sweep`; the demo's stats line after a 30 s settle; the Mac was also
|
|
119
|
-
serving the builds, so absolute fps is low and only the comparison is the point —
|
|
120
|
-
readings verbatim on #25):
|
|
121
|
-
|
|
122
|
-
| macOS Safari | 1 rig | 10 rigs (4,640 triangles) |
|
|
123
|
-
| --- | --: | --: |
|
|
124
|
-
| canvas2d mesh backend | 21–26 fps | 2–3 fps |
|
|
125
|
-
| webgl mesh backend | 60 fps | 58 fps |
|
|
126
|
-
|
|
127
|
-
Nothing is claimed here for mobile browsers: no phone or tablet has been measured
|
|
128
|
-
on the 1.x line.
|
|
129
|
-
|
|
130
|
-
The numbers below are older: measured on the 0.x line, on the spineboy exports it then played (PoC, Apple silicon —
|
|
131
|
-
headless and on-device numbers are labeled, they do **not** substitute for each other).
|
|
132
|
-
The renderer tiers they describe carried over; they have not been re-measured since the
|
|
133
|
-
player moved to rigc's model (#60):
|
|
134
|
-
|
|
135
|
-
- Rigid only (spineboy-ess), headless Chromium: 10 skeletons / 180 slot images at
|
|
136
|
-
**~0.03 ms skeleton math + ~0.22 ms DOM writes per frame** — about 1.5% of a
|
|
137
|
-
60 fps frame budget. On-device Safari: 10 rigid skeletons hold **60 fps**.
|
|
138
|
-
- With meshes (spineboy-pro), headless Chromium: 1 skeleton = **~0.05 ms + ~0.5 ms
|
|
139
|
-
render** (8 mesh canvases, 323 triangles); 10 running skeletons = **~3.5 ms/frame**
|
|
140
|
-
total, smooth.
|
|
141
|
-
- Dirty-skip: 10 *static* skeletons (pose held) = **~0.4 ms/frame** headless and
|
|
142
|
-
**~0.6 ms** on-device Safari — unchanged meshes reuse their raster, so idle or
|
|
143
|
-
held parts cost nothing anywhere.
|
|
144
|
-
- Real Safari (on-device), canvas2d mesh backend: in-callback JS stays **~4–5
|
|
145
|
-
ms/frame** for 10 running meshed skeletons — but the rAF rate tells the real
|
|
146
|
-
story: **meshed ×1 = 37 fps, meshed ×10 = 3–4 fps** (rigid-only ×10 = 60 fps).
|
|
147
|
-
The cost lives outside the frame callback, in the compositor/GPU process:
|
|
148
|
-
Safari antialiases canvas2d **clip paths**, so every triangle pays for an AA
|
|
149
|
-
mask. Chromium doesn't antialias clips and stays smooth. This is what the
|
|
150
|
-
optional WebGL mesh backend (below) removes.
|
|
151
|
-
- Real Safari (on-device), **webgl mesh backend**: meshed ×1 holds **60 fps**
|
|
152
|
-
(vs 37 on canvas2d), and the meshed ×10 stress scene jumps **3–4 → 45 fps** —
|
|
153
|
-
80 mesh canvases / 3230 triangles redrawn every frame (0 reused, the worst
|
|
154
|
-
case), in-callback JS ~5.8 ms at dpr=1. The per-triangle clip-AA tax is gone;
|
|
155
|
-
the remaining gap to 60 is the blit/compositing cost of a 10-skeleton stress
|
|
156
|
-
scene, not a per-triangle cost.
|
|
157
|
-
- Headless-WebKit numbers are a **software rasterizer** and measured up to 28×
|
|
158
|
-
off real Safari in both directions — useful for visual regression only, never
|
|
159
|
-
as Safari perf evidence.
|
|
160
|
-
|
|
161
|
-
## Status
|
|
162
|
-
|
|
163
|
-
**Production.** The questions this started as a PoC to answer — how far does the
|
|
164
|
-
DOM actually go, and how cheap is it — are answered with on-device numbers: the
|
|
165
|
-
practical scenario (a character or two, mostly holding pose, a few parts
|
|
166
|
-
deforming) holds **60 fps on every engine measured**, and the 10-skeleton
|
|
167
|
-
stress scene holds 45 fps on the weakest one (Safari, webgl backend). Ships
|
|
168
|
-
with a self-contained test suite (backend visual parity + invariants) and CI.
|
|
169
|
-
|
|
170
|
-
**Two render modes.** `mode: 'dom'` (the default, everything below) draws a rig
|
|
171
|
-
as elements. `mode: 'canvas'` draws the whole frame into **one `<canvas>`** per
|
|
172
|
-
player: every slot as textured triangles from its atlas page through the same
|
|
173
|
-
shared WebGL context the mesh tier's `webgl` backend uses, into one rect, then
|
|
174
|
-
one `drawImage` onto the player's canvas. Tint and dark (two-colour) tint are
|
|
175
|
-
vertex colours, blend modes are batch state, clips are cut on the CPU by
|
|
176
|
-
`rig-c/core`'s clipper — no `clip-path`, no SVG filter, no per-slot element.
|
|
177
|
-
`RigPlayer` swaps between the two in place (`player.mode = 'canvas'`). The
|
|
178
|
-
canvas mode is held against the DOM tier pixel for pixel on the parity suite's
|
|
179
|
-
frozen poses (bad pixels ≤ 1.6% of drawn content, measured, macOS Chromium and
|
|
180
|
-
WebKit); which mode is faster where is **not measured on a device yet**, so the
|
|
181
|
-
default stays `dom`.
|
|
182
|
-
|
|
183
|
-
- ✅ Region attachments (rigid parts): exact affine mapping, draw-order via `z-index`,
|
|
184
|
-
attachment swaps, alpha
|
|
185
|
-
- ✅ Atlas unpacking at load time (90°-packed regions restored to the orientation
|
|
186
|
-
the region's UVs name, held against hand-written UVs on a rigc build whose region
|
|
187
|
-
is packed turned),
|
|
188
|
-
so the rigid per-frame path never touches a canvas — a region that covers its whole
|
|
189
|
-
page is passed through uncut, and `revokeRegions()` frees the rest when a skeleton
|
|
190
|
-
is unloaded
|
|
191
|
-
- ✅ Mesh attachments (deform tier): small per-part canvases sized to the mesh's world
|
|
192
|
-
bounds, interleaved with the rigid `<img>` slots in one stacking context — the DOM
|
|
193
|
-
handles the bones, a rasterizer handles the warps
|
|
194
|
-
- ✅ Blend modes via `mix-blend-mode` (additive = `plus-lighter`)
|
|
195
|
-
- ✅ Crack-free mesh seams on the canvas2d backend: canvas2d antialiases each
|
|
196
|
-
triangle's clip edge, so two triangles sharing an edge each leave it partly
|
|
197
|
-
transparent and the background shows through as a hairline — plainest over a
|
|
198
|
-
light background behind light art (#104). Each edge of a triangle's clip
|
|
199
|
-
polygon is moved 0.5px outward along its own normal so neighbours overlap —
|
|
200
|
-
the texture is continuous across shared edges, so the overlap is invisible
|
|
201
|
-
(`?expand=0` shows the cracks for comparison)
|
|
202
|
-
- ✅ RGB tinting (skeleton × slot × attachment color) via an SVG `feColorMatrix`
|
|
203
|
-
reference filter per element — an exact channel multiply that works identically on
|
|
204
|
-
`<img>` and `<canvas>` without touching the raster (needs reference-filter support,
|
|
205
|
-
Safari 15+; verified pixel-level on Chromium and WebKit). Dark/two-color tint is
|
|
206
|
-
not expressible this way and stays out of scope
|
|
207
|
-
- ✅ DPR-aware mesh canvas backing store: `renderer.pixelRatio` (defaults to
|
|
208
|
-
`devicePixelRatio`; if you scale the root element, fold that scale in)
|
|
209
|
-
- ✅ Dirty-skip: a mesh whose canvas-space vertices match the last raster reuses it;
|
|
210
|
-
whole-pixel translations allow at most half a Float32 ULP at the current world
|
|
211
|
-
coordinate, strictly less than half a pixel. CSS still tracks the translation, while
|
|
212
|
-
stationary axes stay exact and the comparison anchor changes only on a redraw.
|
|
213
|
-
10 frozen spineboys: WebKit ~141 → ~0.5 ms/frame
|
|
214
|
-
- ✅ Clipping attachments as element-level CSS `clip-path`: spine-core's semantics
|
|
215
|
-
(one active clip at a time, applied from the clip's slot through its end slot
|
|
216
|
-
inclusive), with the polygon expressed in each element's own local frame — the
|
|
217
|
-
inverse of the `<img>` matrix for rigid slots, the canvas translate for mesh
|
|
218
|
-
slots. Covers **part masks** (a clip with a real end slot, like the figure test
|
|
219
|
-
rig's `mask` animation) and **whole-skeleton clips** (an end slot that never
|
|
220
|
-
arrives, which takes a fast path: one `clip-path` on the root instead of one per
|
|
221
|
-
element). **Inverse clips** are supported too: the region they keep has a hole
|
|
222
|
-
in it, so it needs two rings, and `polygon()` carries only one — a box and a
|
|
223
|
-
polygon listed inside one `polygon()` become a single self-intersecting ring
|
|
224
|
-
whose even-odd fill leaves a wedge along the seam between them (measured, on a
|
|
225
|
-
corner of spineboy's boot). So an inverse clip is written as a two-subpath
|
|
226
|
-
`path(evenodd, …)` whose outer ring is the element's own box. The whole feature
|
|
227
|
-
is element-level, so both mesh backends see the same thing and neither raster
|
|
228
|
-
path knows clipping exists; `renderer.clipping = false`
|
|
229
|
-
restores the old counted-and-skipped behaviour. Concave polygons go to CSS as
|
|
230
|
-
authored — the convex decomposition spine's CPU clipper performs exists to feed a
|
|
231
|
-
triangle rasterizer and has no job here. Two things this does not do: Spine's
|
|
232
|
-
clipper convexifies an *inverse* polygon (convex hull) where CSS clips it as
|
|
233
|
-
authored, so the two agree exactly when that polygon is convex — which is the
|
|
234
|
-
shape `inverse` is meant for; and the cost of many simultaneous `clip-path`s on
|
|
235
|
-
real-device Safari is **not measured** (the headless numbers that exist are not
|
|
236
|
-
Safari evidence — see Measured above)
|
|
237
|
-
- ✅ Safari mesh cost root-caused by on-device triangulation (two corrections deep):
|
|
238
|
-
the early "~15× slower per-triangle path" was a **headless-WebKit artifact**
|
|
239
|
-
(software rasterization), and the follow-up "on par with Chromium" held only for
|
|
240
|
-
in-callback JS time. Real-Safari rAF rates — rigid ×10 = 60 fps, meshed ×1 =
|
|
241
|
-
37 fps, meshed ×10 = 3–4 fps with JS flat at ~4–5 ms — put the real cost in the
|
|
242
|
-
GPU process: Safari antialiases canvas2d **clip paths**, so the per-triangle clip
|
|
243
|
-
mapping pays a per-triangle AA-mask tax (the same AA that caused the seam cracks)
|
|
244
|
-
- ✅ WebGL blit backend for the mesh tier, the default since 1.9 (#109;
|
|
245
|
-
`renderer.meshBackend = 'canvas2d'` keeps the 2d raster). All dirty meshes are shelf-packed into
|
|
246
|
-
**one shared offscreen WebGL context** (module-level — browsers cap contexts at
|
|
247
|
-
~16), drawn as textured triangles with premultiplied alpha, then rect-blitted
|
|
248
|
-
onto the same per-part canvases with an unclipped `drawImage` — cheap on Safari.
|
|
249
|
-
Everything element-level is unchanged: z-index interleave, tint filter,
|
|
250
|
-
`mix-blend-mode`, dirty-skip, grow-only backing. GL rasterizes shared triangle
|
|
251
|
-
edges seamlessly, so this path needs no crack overdraw. Falls back to canvas2d
|
|
252
|
-
when WebGL is unavailable or the context is lost. Backend parity verified by
|
|
253
|
-
headless Chromium+WebKit screenshot diffs (glow / clipping / tint scenes;
|
|
254
|
-
sub-pixel edge differences only). On-device Safari, meshed ×10 with every mesh
|
|
255
|
-
redrawn per frame: **3–4 fps (canvas2d) → 45 fps (webgl)**
|
|
99
|
+
Where each side wins and loses on that table, the Safari mesh-backend readings
|
|
100
|
+
and the 0.x numbers are in [docs/BENCHMARK.md](docs/BENCHMARK.md).
|
|
256
101
|
|
|
257
102
|
## Install
|
|
258
103
|
|
|
@@ -328,1023 +173,27 @@ tells the renderer about the scale, and plays the first animation:
|
|
|
328
173
|
```
|
|
329
174
|
|
|
330
175
|
`syncPixelRatio()` is what makes the scale cheap: without it the mesh canvases
|
|
331
|
-
raster at the device pixel ratio of the *unscaled* rig, `1 / scale
|
|
332
|
-
pixels than the screen shows. Call it again after anything that changes the
|
|
176
|
+
raster at the device pixel ratio of the *unscaled* rig, `1 / scale²` times more
|
|
177
|
+
pixels than the screen shows (ideally 16× at `scale = 0.25`, before backing-store rounding and slack). Call it again after anything that changes the
|
|
333
178
|
scale. `tests/readme-page.spec.ts` runs this block as written, against the
|
|
334
179
|
package built from this tree.
|
|
335
180
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// Drag the rig: move the root, and tell the physics how far, in world units with y up.
|
|
354
|
-
let held = null; // the pointer's position while a drag is held
|
|
355
|
-
stage.addEventListener('pointerdown', (e) => { if (e.target !== wind) held = { x: e.clientX, y: e.clientY }; });
|
|
356
|
-
addEventListener('pointerup', () => { held = null; });
|
|
357
|
-
stage.addEventListener('pointermove', (e) => {
|
|
358
|
-
if (!held) return;
|
|
359
|
-
const dx = e.clientX - held.x, dy = e.clientY - held.y;
|
|
360
|
-
held = { x: e.clientX, y: e.clientY };
|
|
361
|
-
root.style.left = `${parseFloat(root.style.left) + dx}px`;
|
|
362
|
-
root.style.top = `${parseFloat(root.style.top) + dy}px`;
|
|
363
|
-
player.moved({ x: dx / scale, y: -dy / scale });
|
|
364
|
-
});
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
The block is plain JavaScript, so it runs inside the page as written.
|
|
368
|
-
`environment(null)` returns to the runtime's start values, and `environment()`
|
|
369
|
-
reads what stands. The state is the player's, not a track's: every track and
|
|
370
|
-
layer answers the same world, and a `seek` replays under the environment as it
|
|
371
|
-
stands — no history is kept. A rig with no physics ignores both.
|
|
372
|
-
`tests/readme-page.spec.ts` runs this block in the minimal page.
|
|
373
|
-
|
|
374
|
-
The demo's `wind.html` is the same thing with controls: wind and gravity
|
|
375
|
-
sliders, a drag on the stage, and the rig named by `?model=` (the `cloth` test
|
|
376
|
-
rig by default; `bun run dev`, then `/wind.html?model=rigs/<name>/skeleton.model.json`).
|
|
377
|
-
`tests/wind-page.spec.ts` holds it.
|
|
378
|
-
|
|
379
|
-
The player owns one track: `play(name, { loop })` opens an animation at its first
|
|
380
|
-
pose, `frame(dt)` steps it (or `advance(dt)` then `draw()`, to time the two
|
|
381
|
-
apart), `pause()` / `resume()` hold and release it, `timeScale` scales every
|
|
382
|
-
step, and `seek(t)` moves it to a time. A seek is "reopen, then step to the
|
|
383
|
-
time" in `seekStep` increments (1/60 s by default) — never a jump, because a
|
|
384
|
-
physics rig has history and its pose at a time depends on the steps that
|
|
385
|
-
reached it. The physics integrates on rig-c's render clock (`physicsClockStep`:
|
|
386
|
-
each step and 2⁻³⁰ of it more), so stepping at a fixed rate — `frame(1/60)`, a
|
|
387
|
-
capture, a test — shows no single-frame spring jump where a frame ends on a
|
|
388
|
-
physics step; the animation, the events and `time` stay on the steps (#103). `on('event', …)` receives the event keys each step crosses, in the
|
|
389
|
-
runtime's order across a loop's wrap; `on('complete', …)` fires once a step
|
|
390
|
-
completes a cycle. `skin` (a constructor option) poses under a named skin;
|
|
391
|
-
without it the rig is posed the way a fresh Spine skeleton is, with no skin
|
|
392
|
-
set. `player.color` multiplies a whole-rig colour into every slot's tint.
|
|
393
|
-
`player.renderer` is the `DomRenderer` underneath, whose knobs and counters
|
|
394
|
-
are below.
|
|
395
|
-
|
|
396
|
-
**What it refuses, by name.** A Spine export (`.json`, `.skel`) is refused
|
|
397
|
-
before anything is fetched: this package reads compiled models and nothing
|
|
398
|
-
else. A document whose `spec` is not one `rig-c/core` reads is refused with the
|
|
399
|
-
core's own sentence plus "build it with `rigc build`", and so is a
|
|
400
|
-
`rigc-compiled/1` document, which predates the `pages` section.
|
|
401
|
-
|
|
402
|
-
`@rig-c/play` is a third-party renderer and is not affiliated with or endorsed by
|
|
403
|
-
Esoteric Software.
|
|
404
|
-
|
|
405
|
-
### Drawing your own frames
|
|
406
|
-
|
|
407
|
-
`RigPlayer` is the convenient path; the renderer underneath takes a plain
|
|
408
|
-
record, so anything that can produce one can draw through it:
|
|
409
|
-
|
|
410
|
-
```ts
|
|
411
|
-
import { DomRenderer, type PosedFrame } from '@rig-c/play';
|
|
412
|
-
|
|
413
|
-
const renderer = new DomRenderer(rootElement, assets.regionImages, assets.pageImages);
|
|
414
|
-
renderer.render(frame); // frame: PosedFrame
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
```ts
|
|
418
|
-
PosedFrame { order: PosedSlot[] } // the slots that draw, in draw order
|
|
419
|
-
PosedSlot = { slot; z; blend: 'Normal'|'Additive'|'Multiply'|'Screen';
|
|
420
|
-
tint: [r, g, b, a]; dark?: [r, g, b];
|
|
421
|
-
clip?: { polygon: number[]; inverse: boolean; convex: boolean } } // world space, Y up
|
|
422
|
-
& ( { kind: 'region'; region: string; corners: number[] /* BL, UL, UR, BR */;
|
|
423
|
-
page: string; pma: boolean; uvs: number[] /* page UVs at the corners */ }
|
|
424
|
-
| { kind: 'mesh'; page: string; pma: boolean; vertices: number[];
|
|
425
|
-
uvs: number[]; triangles: number[]; sequence: number } )
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
`player.posedFrame()` returns the record the player would draw. Two things the
|
|
429
|
-
renderer reads off it by identity rather than by value: every slot a clip
|
|
430
|
-
covers carries the **same** `clip` object (one that covers every slot of the
|
|
431
|
-
frame takes the one-`clip-path`-on-the-root path), and a mesh's `uvs` and
|
|
432
|
-
`triangles` are the same arrays from frame to frame for the same texture
|
|
433
|
-
mapping (a new array re-rasters the mesh).
|
|
434
|
-
|
|
435
|
-
### Reading the pose
|
|
436
|
-
|
|
437
|
-
Two reads off the pose the player holds — the last `frame`/`advance`/`seek`,
|
|
438
|
-
or the setup pose before `play` — for framing a stage around a rig:
|
|
439
|
-
|
|
440
|
-
```ts
|
|
441
|
-
const head = player.bone('head'); // { x, y, a, b, c, d, rotationX, rotationY, scaleX, scaleY, active }
|
|
442
|
-
const box = player.bounds(); // { x, y, width, height } | null — world units, Y up
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
`bone` is the core's world transform for that bone (`rig-c/core`'s `RawBone`,
|
|
446
|
-
unchanged), and an unknown name is refused by name. `bounds` is the box of
|
|
447
|
-
every region's four corners and every mesh's vertices in the frame record the
|
|
448
|
-
renderer takes; a slot under a clip counts only inside the clip polygon's own
|
|
449
|
-
box, an inverse clip removes nothing, and bounding boxes, paths and points do
|
|
450
|
-
not draw and do not count. Both cost nothing until called and read the same
|
|
451
|
-
numbers in `dom` and `canvas` mode.
|
|
452
|
-
|
|
453
|
-
`readSetupMesh` reads a named mesh from loaded assets before or during playback,
|
|
454
|
-
including an attachment the setup pose does not select:
|
|
455
|
-
|
|
456
|
-
```ts
|
|
457
|
-
import { readSetupMesh } from '@rig-c/play';
|
|
458
|
-
|
|
459
|
-
const mesh = readSetupMesh(assets, { slot: 'flag', attachment: 'alternate' });
|
|
460
|
-
// mesh: { slot, attachment, skin, vertices, triangles, hull }
|
|
461
|
-
// vertices: flat world x,y pairs, Y up; hull counts vertices, or is null.
|
|
462
|
-
```
|
|
463
|
-
|
|
464
|
-
Names are exact slot names and attachment table keys, not display names or atlas
|
|
465
|
-
regions. Omitted or `null` skin means no skin set, as for a fresh player. A named
|
|
466
|
-
`skin` uses that skin's activation lists with default-table fallback; the returned
|
|
467
|
-
`skin` names the supplying table. `skin: 'all'` uses all activation lists and
|
|
468
|
-
refuses differing definitions rather than guessing a merge order. Linked meshes
|
|
469
|
-
retain the requested identity and borrow source topology/bindings; unweighted
|
|
470
|
-
vertices use the queried slot's bone. Inactive bones retain core's zero matrix.
|
|
471
|
-
The result and its arrays are detached and mutable. Missing slot/attachment/skin,
|
|
472
|
-
non-mesh and ambiguous targets throw core's named `CoreInputError` messages;
|
|
473
|
-
setup/non-finite refusals propagate unchanged. The read creates no player and
|
|
474
|
-
changes no document, existing player, time, generation or rendered frame.
|
|
475
|
-
|
|
476
|
-
**Setup-slider boundary (core 2.66.0):** selected meshes include setup-slider
|
|
477
|
-
deform, exactly as core's setup pose does. Unselected meshes use stored geometry
|
|
478
|
-
through the same setup constraint/reset-physics matrices, without switching the
|
|
479
|
-
attachment or borrowing another selection's deform. Core measured **four of six
|
|
480
|
-
nonselected official-runtime comparisons differing by up to
|
|
481
|
-
`2.9999999999999982` world units (about 3.0)**: spine-core 4.3.13's
|
|
482
|
-
`computeWorldVertices` borrows the selected slot's deform, including for a linked
|
|
483
|
-
mesh with `timelines: false`. The other two nonselected calls and all three
|
|
484
|
-
selected reads agree exactly. This wrapper passes core's output through; it does
|
|
485
|
-
not correct or round it. Stored-only formula comparisons therefore have a
|
|
486
|
-
different domain from selected geometry with setup deform. See
|
|
487
|
-
[core AUTHORING §14, including S1538_09](https://github.com/rig-c/core/blob/v2.66.0/docs/AUTHORING.md#14-read-a-named-mesh-at-setup).
|
|
488
|
-
|
|
489
|
-
### Drawing some slots only
|
|
490
|
-
|
|
491
|
-
`slots` is a draw filter — an allow list of slot names or a predicate — settable up
|
|
492
|
-
front and on a live player. The pose is the whole rig's whatever the filter:
|
|
493
|
-
bones, constraints, physics, `bone()`, `adjust`, events and tracks see no filter;
|
|
494
|
-
only the draw does, in both modes, and `bounds()` reads the drawn slots. A
|
|
495
|
-
clipping slot the filter leaves out does not clip. An allow list naming a slot
|
|
496
|
-
the document does not have is refused by name.
|
|
497
|
-
|
|
498
|
-
```ts
|
|
499
|
-
const hand = new RigPlayer(frontRoot, assets, { slots: ['hand_f', 'arm_f_lo'] });
|
|
500
|
-
player.slots = (slot) => !slot.startsWith('weapon');
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
Two players over one `ModelAssets` share the load but each poses the rig. To
|
|
504
|
-
pose it once and draw it twice — a hand drawn again in front of another
|
|
505
|
-
character — give a second renderer this player's pose under another filter:
|
|
506
|
-
|
|
507
|
-
```ts
|
|
508
|
-
const front = new DomRenderer(frontRoot, assets.regionImages, assets.pageImages);
|
|
509
|
-
// each frame, after player.frame(dt):
|
|
510
|
-
front.render(player.posedFrame(['hand_f', 'arm_f_lo']));
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
### Attachment sets (`sets.json`)
|
|
514
|
-
|
|
515
|
-
A build from `@rig-c/parts` (or authored alongside a rig) may ship a `sets.json`
|
|
516
|
-
sidecar beside `skeleton.model.json` declaring named attachment sets — an
|
|
517
|
-
expression, a viseme, a side view — under spec `"rig-parts-sets/1"` or
|
|
518
|
-
`"spine-parts-sets/1"`:
|
|
519
|
-
|
|
520
|
-
```json
|
|
521
|
-
{
|
|
522
|
-
"spec": "rig-parts-sets/1",
|
|
523
|
-
"sets": {
|
|
524
|
-
"smile": {
|
|
525
|
-
"slots": { "face": "face_smile", "prop": "prop_star" },
|
|
526
|
-
"hides": []
|
|
527
|
-
},
|
|
528
|
-
"wink": {
|
|
529
|
-
"slots": { "face": "face_wink" },
|
|
530
|
-
"hides": ["prop"]
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
```
|
|
535
|
-
|
|
536
|
-
`loadModel` probes `sets.json` in the model document's directory by default
|
|
537
|
-
(treating a 404 as "no sets"; pass `setsUrl: null` to skip the probe, or a URL
|
|
538
|
-
string to require one at another path) and validates every slot and attachment
|
|
539
|
-
name against the model document. `player.sets()` (and `assets.sets`) returns the
|
|
540
|
-
declared `readonly RigSet[]` (`{ name, slots, hides }`), and `player.setSet(name)`
|
|
541
|
-
switches the active set at runtime (#134):
|
|
542
|
-
|
|
543
|
-
```ts
|
|
544
|
-
player.sets(); // [{ name: 'smile', slots: { ... }, hides: [] }, ...]
|
|
545
|
-
player.setSet('smile'); // covered slots take the set's attachments; <slot>_still and hides are hidden
|
|
546
|
-
player.set; // 'smile'
|
|
547
|
-
player.setSet(null); // restore base / track attachments
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
The active set is applied immediately to the held pose (including the setup pose
|
|
551
|
-
before `play`) and after track evaluation on every subsequent `advance`, so a
|
|
552
|
-
track key on a covered slot is overridden while the set is active and restored
|
|
553
|
-
on the next frame when cleared with `setSet(null)`. Uncovered slots keep their
|
|
554
|
-
track attachments, mesh deforms and sequence frames untouched; when no set is
|
|
555
|
-
active (`null`), track evaluation runs with zero extra work and produces
|
|
556
|
-
byte-identical poses. An unknown set name throws by name; on a build without
|
|
557
|
-
`sets.json`, `sets()` is empty (`[]`) and `setSet` throws.
|
|
558
|
-
|
|
559
|
-
**Several sets at once** (#168): sets that touch disjoint slots can be active
|
|
560
|
-
together — a mouth viseme driven by audio beside an arm gesture driven by
|
|
561
|
-
input, without building every combination as its own set:
|
|
562
|
-
|
|
563
|
-
```ts
|
|
564
|
-
player.setSets(['viseme_o', 'gesture_chin']); // exactly these, together
|
|
565
|
-
player.removeSet('viseme_o'); // the gesture stays
|
|
566
|
-
player.addSet('viseme_a'); // beside the active ones; already active = no change
|
|
567
|
-
player.activeSets; // ['viseme_a', 'gesture_chin'] in sets.json order
|
|
568
|
-
player.set; // null: several are active (one active → its name)
|
|
569
|
-
player.setSets([]); // restore base / track attachments
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
A set *touches* every slot it covers, every slot it `hides` and the
|
|
573
|
-
`<base>_still` companion it hides. Two active sets touching the same slot are
|
|
574
|
-
refused by name — `sets "viseme_o" and "talk_point" both touch slot "mouth"` —
|
|
575
|
-
and nothing changes; precedence is never guessed. Disjoint sets compose as the
|
|
576
|
-
union of each alone, whatever the order they were named or added in, and every
|
|
577
|
-
rule above (applied to the held pose, after track evaluation, uncovered slots
|
|
578
|
-
untouched) holds for the composition. `setSet(name)` keeps meaning *exactly this
|
|
579
|
-
one* (`setSets([name])`) and `setSet(null)` is `setSets([])`; an unknown or
|
|
580
|
-
repeated name throws by name. `slotIdentity()` reports the list as `sets`
|
|
581
|
-
beside `set`.
|
|
582
|
-
|
|
583
|
-
**Drawn bones** (#170): a set from `@rig-c/parts` that replaces a limb
|
|
584
|
-
(a *gesture* subset set) carries `drawn` — the world rotation, in degrees
|
|
585
|
-
(CCW, Y up), each of its bones was painted at — and its attachments are
|
|
586
|
-
placed in the frame of the bones posed there:
|
|
587
|
-
|
|
588
|
-
```json
|
|
589
|
-
"wave": {
|
|
590
|
-
"slots": { "arm_upper": "arm_upper_wave", "arm_lower": "arm_lower_wave" },
|
|
591
|
-
"drawn": { "shoulder": 50, "elbow": 95 }
|
|
592
|
-
}
|
|
593
|
-
```
|
|
594
|
-
|
|
595
|
-
While such a set is active its bones are posed at those angles, the way
|
|
596
|
-
`rig-parts check --set` poses them: in document order, each drawn bone's
|
|
597
|
-
local rotation is the drawn angle less its world rotation at local 0 under
|
|
598
|
-
its already-posed parents (no constraint applied), computed once when the set
|
|
599
|
-
is activated. On a track that local rotation replaces the clip's rotation
|
|
600
|
-
of that bone on every pose, before `adjust` (so `adjust` still has the last
|
|
601
|
-
word); every other bone keeps playing. Activating or clearing a drawn set
|
|
602
|
-
poses the held pose again at the same time. Two active sets drawing the same
|
|
603
|
-
bone are refused by name, as two sets touching one slot are. A `drawn` naming
|
|
604
|
-
a bone the document does not have is refused when `sets.json` is loaded.
|
|
605
|
-
|
|
606
|
-
**Physics under a drawn bone** (#173). A physics chain hung from a bone a set
|
|
607
|
-
re-poses — a sleeve under a raised arm — reads the jump to the drawn angle as
|
|
608
|
-
motion: the constraint remembers where its bone was and pointed, so the
|
|
609
|
-
switch kicks it (on the test rig `drape`, a 110° raise throws the sleeve's
|
|
610
|
-
tip about 297 units off the arm in the switch itself, against an idle
|
|
611
|
-
frame-to-frame range of 1.5). Every set call takes an option for the chains
|
|
612
|
-
under the bones the switch re-poses:
|
|
613
|
-
|
|
614
|
-
```ts
|
|
615
|
-
player.setSets(['raise'], { physics: 'carry' }); // also setSet, addSet, removeSet
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
- `'carry'` moves each affected constraint's remembered state by the rigid
|
|
619
|
-
turn of its deepest re-posed ancestor, so the chain keeps its pose relative
|
|
620
|
-
to the arm and goes on swinging from there (the switch itself moves the
|
|
621
|
-
sleeve 0 relative to the arm; gravity then re-settles it in the turned pose,
|
|
622
|
-
which is physics and not the switch).
|
|
623
|
-
- `'reset'` restarts each affected constraint at the new pose, as a physics
|
|
624
|
-
`reset` key does: the chain snaps straight along its bones (17 units on the
|
|
625
|
-
same raise) and settles from there.
|
|
626
|
-
- Absent, nothing changes: today's behaviour, frame for frame. This stays the
|
|
627
|
-
default.
|
|
628
|
-
|
|
629
|
-
`'carry'` is recommended: on `drape` it moves the sleeve 0 relative to the arm
|
|
630
|
-
at the switch and 0 at the deactivation, where `'reset'` moves it 17 units at
|
|
631
|
-
the switch and 118 at the deactivation (no option: 297 and 298).
|
|
632
|
-
|
|
633
|
-
A constraint is affected when its bone is a bone whose drawn rotation the call
|
|
634
|
-
adds, removes or changes, or a descendant of one; every other chain is not
|
|
635
|
-
touched. The option does nothing before `play` (the setup pose carries no
|
|
636
|
-
physics state) and nothing when no physics hangs under a re-posed bone. Three limits:
|
|
637
|
-
|
|
638
|
-
- Carry moves the positions and direction the constraint remembers; a
|
|
639
|
-
constraint with `x`/`y` keeps its translation offsets along the world axes,
|
|
640
|
-
unturned.
|
|
641
|
-
- A drawn bone nested under a carried chain reads its new world from the
|
|
642
|
-
chain's state before the carry.
|
|
643
|
-
- A later `seek` replays from the start with the sets then active, as it
|
|
644
|
-
always has, so it does not reproduce a carried switch.
|
|
645
|
-
|
|
646
|
-
**Easing a drawn set in and out** (#174). A drawn set snaps its bones to the
|
|
647
|
-
drawn angles in one frame. `ease` turns them there over a duration instead,
|
|
648
|
-
and swaps the attachments halfway, the way an alternate drawing takes over
|
|
649
|
-
at a middle angle:
|
|
650
|
-
|
|
651
|
-
```ts
|
|
652
|
-
player.setSets(['wave_r'], { ease: 0.5 }); // also setSet, addSet, removeSet
|
|
653
|
-
player.setSets([], { ease: 0.5 }); // and back
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
- **The curve.** Each bone whose drawn rotation the call adds, removes or
|
|
657
|
-
changes turns from what it shows to its new local rotation along the
|
|
658
|
-
shorter way: `start + shortestΔ(start, end) · smoothstep(u)`, with
|
|
659
|
-
`u = elapsed / ease`. Going on, the start is the clip's value at the time
|
|
660
|
-
(it moves with the clip) and the end the drawn local; going off, the start
|
|
661
|
-
is the drawn local and the end the clip's value. So the progress from start
|
|
662
|
-
to end never leaves `[0, 1]` and never turns back, while the clip plays
|
|
663
|
-
underneath. On `@rig-c/parts`' public wind example with its gesture set
|
|
664
|
-
(`wave_r`, 0.5 s), it is 0 to 1 and non-decreasing on every frame, on and
|
|
665
|
-
off; on a clip that holds the arm still, the sleeve's world angle goes
|
|
666
|
-
−119.32° → −110° and back with no overshoot.
|
|
667
|
-
- **The swap.** The held pose draws the attachments it showed until the frame
|
|
668
|
-
whose `u` is nearest ½, then the set's (a tie goes to the later frame; the
|
|
669
|
-
next step is predicted from the current one, so a constant `dt` lands on
|
|
670
|
-
the nearest frame). On the wind example at `frame(1/60)`, 0.5 s swaps on
|
|
671
|
-
frame 15 and 0.25 s on frame 8. `activeSets`, `set` and `slotIdentity().sets`
|
|
672
|
-
name the call's sets from the call on; `slotIdentity().slots` reports what
|
|
673
|
-
the pose draws, so before the swap it shows the previous attachments.
|
|
674
|
-
- **The end.** On the frame the elapsed time reaches `ease`, the player is in
|
|
675
|
-
exactly the instant switch's state: from then on every bone matches an
|
|
676
|
-
instant switch made at the same frame (0° measured) and the slot identity
|
|
677
|
-
too.
|
|
678
|
-
- **Time.** The elapsed time is the player's step (`dt × timeScale`): a
|
|
679
|
-
paused player, or a step of 0, holds the ease where it is. Before `play`
|
|
680
|
-
there is nothing to step and the switch is instant; so is a call that
|
|
681
|
-
re-poses no bone. A `seek` or a skin change lands an ease in flight at
|
|
682
|
-
once.
|
|
683
|
-
- **A call during an ease** starts a new one from the angles shown (no jump:
|
|
684
|
-
reversing a 0.5 s ease at 0.3 makes no frame-to-frame step larger than the
|
|
685
|
-
ease's own), and the attachment swap starts again from what is drawn. A
|
|
686
|
-
call without `ease` during an ease lands it at once.
|
|
687
|
-
- `ease: 0`, or no `ease`, is the instant switch, frame for frame. Anything
|
|
688
|
-
but a finite number at or above 0 is refused by name before anything
|
|
689
|
-
changes.
|
|
690
|
-
|
|
691
|
-
With physics: `ease` and `physics: 'carry'` carry the chains under the eased
|
|
692
|
-
bones on every eased frame — before each step, the turn the frame's blend
|
|
693
|
-
makes is read as #173's switch is (a trial pose at the previous time) and
|
|
694
|
-
the chains are moved by it — so the ease's own turn is not read as motion.
|
|
695
|
-
`physics: 'reset'` with `ease` restarts them once, at the call. On `drape`
|
|
696
|
-
(0.5 s raise, sleeve tips in the arm's frame): the ease alone moves the
|
|
697
|
-
sleeve up to 30 units a frame relative to the arm (the chain reads the turn
|
|
698
|
-
as motion), ease + carry at most 3.9 on the `idle` clip (its idle ranges are
|
|
699
|
-
1.5 hanging and 4.2 raised) and 2.3 on `shift` (0.85 and 0.07), where
|
|
700
|
-
gravity re-settles the chain in the turning arm's frame (with gravity at 0,
|
|
701
|
-
0.83 against 0.84).
|
|
702
|
-
|
|
703
|
-
`sets.json` keys are read strictly. Besides `slots`, `hides` and `drawn`, a
|
|
704
|
-
set may carry `drawnLength`, `registration` and `threshold`, which parts
|
|
705
|
-
writes and which place nothing under `check --set`: play reads past them by
|
|
706
|
-
name and gives them no meaning. Any other key, on a set or on the document
|
|
707
|
-
(which takes `spec`, `sets` and `threshold`), is refused by name rather than
|
|
708
|
-
ignored.
|
|
709
|
-
|
|
710
|
-
To switch one driver's set while another stays — a new viseme while a gesture
|
|
711
|
-
holds — name the new list in one call (`setSets(['viseme_a', 'gesture_chin'])`)
|
|
712
|
-
or `removeSet` then `addSet`; the first re-poses once, the second twice.
|
|
713
|
-
|
|
714
|
-
### Slot identity snapshot (`slotIdentity()`)
|
|
715
|
-
|
|
716
|
-
To inspect which attachment key, owning skin and visibility state each slot
|
|
717
|
-
holds on the current frame — for a parts explorer or review overlay — without
|
|
718
|
-
`seek`, `repose`, or restarting physics (#144):
|
|
719
|
-
|
|
720
|
-
```ts
|
|
721
|
-
const snap = player.slotIdentity();
|
|
722
|
-
// {
|
|
723
|
-
// generation: number, // matches player.generation and player.posedFrame().generation
|
|
724
|
-
// set: string | null, // the one active set (player.set); null when none or several
|
|
725
|
-
// sets: readonly string[], // every active set (player.activeSets), sets.json order
|
|
726
|
-
// skin: string | null, // active skin name (player.skin ?? null)
|
|
727
|
-
// slots: readonly [ // every slot in posed draw order (z = 0 .. slots.length - 1)
|
|
728
|
-
// {
|
|
729
|
-
// slot: string,
|
|
730
|
-
// z: number,
|
|
731
|
-
// attachment: string | null, // resolved attachment key when drawn, null when not drawn
|
|
732
|
-
// skin: string | null, // skin owning the resolved attachment ('default' or named skin), or null
|
|
733
|
-
// reason: 'hidden' | 'no-attachment' | 'timeline-null' | 'set-hide' | null,
|
|
734
|
-
// },
|
|
735
|
-
// ...
|
|
736
|
-
// ],
|
|
737
|
-
// }
|
|
738
|
-
```
|
|
739
|
-
|
|
740
|
-
Every slot in the posed draw order (`z = 0 .. slots.length - 1`, hidden and null
|
|
741
|
-
ones included) appears in `snap.slots`:
|
|
742
|
-
|
|
743
|
-
- Drawn slots (`reason: null`) appear in the same `z` order as
|
|
744
|
-
`player.posedFrame().order`, naming the resolved attachment key (`attachment`)
|
|
745
|
-
and the skin that owns it (`skin`).
|
|
746
|
-
- Non-drawn slots report `attachment: null`, `skin: null`, and why they are
|
|
747
|
-
absent from `posedFrame().order`:
|
|
748
|
-
- `'set-hide'` — hidden by an active attachment set (`set.hides` or a
|
|
749
|
-
`<base>_still` companion of a slot the set covers or hides).
|
|
750
|
-
- `'timeline-null'` — cleared by an active attachment timeline key (`null`).
|
|
751
|
-
- `'no-attachment'` — no attachment in the setup pose or under the active skin
|
|
752
|
-
(or a non-drawable attachment kind such as clipping/boundingbox/path/point).
|
|
753
|
-
- `'hidden'` — filtered out by `player.slots` or on an inactive bone under the
|
|
754
|
-
current skin.
|
|
755
|
-
- `snap.generation` ticks monotonically with `player.generation` and
|
|
756
|
-
`player.posedFrame().generation` whenever the held pose, active set, active
|
|
757
|
-
skin or slot filter changes. The returned snapshot is frozen and cached per
|
|
758
|
-
generation, so reading it repeatedly on a held pose is allocation-free and
|
|
759
|
-
mutates no pixel or physics state.
|
|
760
|
-
|
|
761
|
-
### Several tracks
|
|
762
|
-
|
|
763
|
-
A base on track 0, a layer over it, a one-shot played once and mixed back out —
|
|
764
|
-
rig-c's live tracks (`openTracks`), whose mixing is the Spine runtime's
|
|
765
|
-
`AnimationState` held at tolerance 0:
|
|
766
|
-
|
|
767
|
-
```ts
|
|
768
|
-
player.play('idle'); // track 0, as always
|
|
769
|
-
player.play('smile', { track: 1, loop: false, mix: 0.2 }); // held at its end, mixed in over 0.2 s
|
|
770
|
-
player.play('wave', { track: 2, loop: false, mix: 0.1 }); // a one-shot…
|
|
771
|
-
player.queueClear(2, { mix: 0.1 }); // …mixed back out over its last 0.1 s
|
|
772
|
-
player.queue('idle_b', { track: 0, mix: 0.3 }); // after the current idle cycle completes
|
|
773
|
-
player.clear(1, { mix: 0.2 }); // the layer gone, the base showing through
|
|
774
|
-
player.entry(2); // { animation, loop, trackTime, animationTime, duration, alpha, additive } | null
|
|
775
|
-
player.play('blink', { track: 3, alpha: 0.6 }); // the same clip at a weight…
|
|
776
|
-
player.setAlpha(3, 0.85); // …changed while it plays
|
|
777
|
-
player.play('breath', { track: 4, additive: true }); // adds to what the tracks below posed
|
|
778
|
-
```
|
|
779
|
-
|
|
780
|
-
**Weight and additive layers** (rig-c 2.39.0, the runtime's `TrackEntry.alpha` and
|
|
781
|
-
`additive`): `alpha` (0..1, default 1) multiplies every timeline the entry applies;
|
|
782
|
-
`setAlpha(track, alpha)` changes the current entry's weight from the next pose and
|
|
783
|
-
re-poses the held pose. It does not touch events — an entry fires at any alpha, 0
|
|
784
|
-
included, and one mixing out fires none — or attachments: the current entry keeps
|
|
785
|
-
its attachments at any alpha, one mixing out keeps none. `additive: true`, fixed
|
|
786
|
-
when the entry is set or queued, adds the entry's keyed bone and deform values to
|
|
787
|
-
what the tracks below posed instead of replacing them, so a layer keying the same
|
|
788
|
-
bone channels as an idle does not cover its motion; a rotation is added as it
|
|
789
|
-
stands, with no shortest-angle wrap, and an additive entry mixing out fades with its
|
|
790
|
-
alpha and is never held. Colour, attachment and event timelines apply as on a
|
|
791
|
-
replacing entry. A weight below 1 or an additive entry composes, as a mix does.
|
|
792
|
-
|
|
793
|
-
`play(name)` keeps meaning what it meant: a plain play on track 0 runs on rig-c's
|
|
794
|
-
single live track, which poses everything the core poses. The first call that
|
|
795
|
-
composes — a `mix`, a `track` above 0, a `queue`, a `clear` — moves the player onto
|
|
796
|
-
the tracks, carrying the single track over by **replay** (its animation set, pose 0
|
|
797
|
-
taken, stepped to its time in `seekStep` increments — one pose per step played, so
|
|
798
|
-
on a stage that idled a while that is one long frame; the core offers no hand-over
|
|
799
|
-
of a live track's state). To never pay it, stand the player on the tracks from the
|
|
800
|
-
start: `new RigPlayer(root, assets, { tracks: true })`. Pick the single track for a
|
|
801
|
-
rig whose animations key what the tracks do not mix (below); pick the tracks for
|
|
802
|
-
layering. Either way, after the first `mix`, `track` above 0, `queue` or `clear`,
|
|
803
|
-
`seek` and `skin` are refused by name, since the player cannot replay a composition
|
|
804
|
-
to a time — step it forward instead.
|
|
805
|
-
Every entry mixes from the setup pose each step, so `adjust` writes still last one
|
|
806
|
-
pose. **What the tracks do not mix**, in rig-c's words at `play`/`queue`: an
|
|
807
|
-
animation keying a constraint (ik, transform, path, physics, slider), a sequence,
|
|
808
|
-
the draw order, or a path's deform is refused when it is set on the tracks — it
|
|
809
|
-
plays on the single track only. `'event'` carries `track`; a one-shot that is
|
|
810
|
-
mixing out fires nothing. `'complete'` is per track, the player's rule: a looping
|
|
811
|
-
entry each cycle, a held one when it reaches its duration, and an entry that is
|
|
812
|
-
replaced — by `play`, or by the queued entry that starts at its completion less the
|
|
813
|
-
mix — completes no more than it fires.
|
|
814
|
-
|
|
815
|
-
### Adjusting bones
|
|
816
|
-
|
|
817
|
-
A per-pose write to bone locals — a breath's scale, a look's rotation, a mouth
|
|
818
|
-
driven while the body holds still:
|
|
819
|
-
|
|
820
|
-
```ts
|
|
821
|
-
const player = new RigPlayer(root, assets, {
|
|
822
|
-
adjust: (bones) => { bones.bone('mouth').scaleY = 1 + openness; },
|
|
823
|
-
});
|
|
824
|
-
player.adjust = null; // or set it later; setting it re-poses the held pose at once
|
|
825
|
-
```
|
|
826
|
-
|
|
827
|
-
The function runs once per pose, after the animation's timelines and before the
|
|
828
|
-
world transforms, constraints and physics — `rig-c/core`'s `openTrack({ adjust })`,
|
|
829
|
-
which rig-c holds to the Spine runtime at tolerance 0. **A write lasts one
|
|
830
|
-
pose.** The core poses every step from the setup pose, so a value you want held
|
|
831
|
-
is written again each pose and nothing compounds; a 0.8 caller that multiplied a
|
|
832
|
-
channel on a skeleton it kept between frames writes the value it wants, every
|
|
833
|
-
pose. It runs on every pose the animation is applied to, the first one after
|
|
834
|
-
`play` included, and `seek` and a `mode` change keep it.
|
|
835
|
-
|
|
836
|
-
A paused player and a zero step re-pose nothing, so a change the function would
|
|
837
|
-
make is not seen until the next step — unless you ask: setting `player.adjust`
|
|
838
|
-
re-poses the held pose at the same track time, and `player.repose()` does the
|
|
839
|
-
same for a function whose own state changed (the mouth above), without stepping,
|
|
840
|
-
firing events or drawing. A non-finite value, a bone the document does not have,
|
|
841
|
-
or an error the function throws refuses that step in the core's words, and the
|
|
842
|
-
track is reopened by the next `play` or `seek`.
|
|
843
|
-
|
|
844
|
-
The callback also receives a `BoneAdjustContext` from the core:
|
|
845
|
-
|
|
846
|
-
```ts
|
|
847
|
-
player.adjust = (bones, ctx) => {
|
|
848
|
-
bones.bone('head').rotation = 12 * (ctx.trackTime ?? 0);
|
|
849
|
-
};
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
`ctx.trackTime` is the **current track-0 entry's unwrapped seconds**, starting at
|
|
853
|
-
0 for each new entry. Loops do not wrap it, and a nonlooping entry keeps counting
|
|
854
|
-
past its duration. `ctx.animation` names that entry. Without a track-0 entry both
|
|
855
|
-
are null; an explicit empty entry has **numeric trackTime and null animation**.
|
|
856
|
-
Upper tracks supply no fallback. `ctx.globalTime` is the core walk's running sum
|
|
857
|
-
of steps in seconds, so it can differ from a new entry's time. Neither clock is
|
|
858
|
-
wall time, wrapped animation time, nor the physics clock's small lead. Unary
|
|
859
|
-
callbacks remain compatible. The hook runs once after composition, including a
|
|
860
|
-
crossfade, rather than once for each contributing entry.
|
|
861
|
-
|
|
862
|
-
Seek evaluates its replay times (`seekStep` plus any final remainder), independent
|
|
863
|
-
of the currently displayed player time. Exact cache hits may reuse a pose without
|
|
864
|
-
calling the hook. A continuous walk and seek are bit-identical for an **unchanged
|
|
865
|
-
patch and matched history**: the same animation, environment, adjustment, reset
|
|
866
|
-
and step partition. Closed-over callback state still needs `repose()` or the
|
|
867
|
-
adjust setter to invalidate cached poses. Pause, zero advance and renderer mode
|
|
868
|
-
changes add no evaluations; explicit `repose()` keeps the same context time.
|
|
869
|
-
|
|
870
|
-
### Live physics parameters
|
|
871
|
-
|
|
872
|
-
```ts
|
|
873
|
-
player.play('hang');
|
|
874
|
-
player.setPhysicsParameters('link_1_phys', { damping: 0.4, wind: 8 });
|
|
875
|
-
player.readPhysicsParameters('link_1_phys'); // { override, effective }
|
|
876
|
-
player.clearPhysicsParameters('link_1_phys');
|
|
877
|
-
```
|
|
878
|
-
|
|
879
|
-
`setPhysicsParameters(name, patch)` forwards a per-player patch of `strength`,
|
|
880
|
-
`damping`, `inertia`, `wind`, `gravity`, and `mix` to the core. Omitted fields keep
|
|
881
|
-
their standing override. Values are runtime scalars with no clamping, conversion,
|
|
882
|
-
new clock or solver. The core applies them **after authored/keyed values and
|
|
883
|
-
sliders, before the integrator**. Unknown constraints, a non-physics constraint,
|
|
884
|
-
unknown fields and non-finite/non-number values are refused atomically by name.
|
|
885
|
-
These methods require a live track: before `play`/`queue`, set, read and clear
|
|
886
|
-
refuse with `no live track` instead of creating one. The model and other players
|
|
887
|
-
sharing its assets are untouched.
|
|
888
|
-
|
|
889
|
-
`PhysicsParameterRead` contains detached readonly snapshots: `override` is the
|
|
890
|
-
pending patch or null; `effective` is the six values from the last active
|
|
891
|
-
evaluation, or null until evaluated. An inactive constraint can retain its last
|
|
892
|
-
active values within that live core instance. Setting or clearing changes no
|
|
893
|
-
pose, time or physics state, and **does not evaluate**: effective keeps the last
|
|
894
|
-
values until a normal evaluation (or an explicitly requested `repose()`). Clear
|
|
895
|
-
removes the patch; authored keys resume at the next evaluation. It preserves
|
|
896
|
-
integrated history and does not instantly undo the motion caused by old values.
|
|
897
|
-
|
|
898
|
-
The player retains the **current standing patch** across new play, seek, skin
|
|
899
|
-
replay and promotion to composed tracks. Like `environment()`, it records no
|
|
900
|
-
historical changes. **After a mid-run patch change, a backward seek recomputes the
|
|
901
|
-
past interval with the patch standing now**, under the current environment; it
|
|
902
|
-
does not reproduce the old trajectory across that change. New single tracks and
|
|
903
|
-
new composed tracks first evaluate their ordinary authored first pose, then
|
|
904
|
-
receive the standing patch as pending, with no extra adjustment callback. At
|
|
905
|
-
seek time 0, effective can therefore still be the initial authored value while
|
|
906
|
-
override already reports the patch. The next normal core evaluation uses it.
|
|
907
|
-
Changing entries on existing composed tracks follows that core instance's
|
|
908
|
-
lifetime. Existing seek/skin restrictions after composition remain unchanged.
|
|
909
|
-
|
|
910
|
-
Set/clear invalidate seek caches without re-posing. Seek snapshots retain the
|
|
911
|
-
public effective read alongside their held pose, so backward and exact cache
|
|
912
|
-
hits report that pose's evaluation rather than the frontier's latest evaluation.
|
|
913
|
-
Readback itself never advances, evaluates or resets physics.
|
|
914
|
-
|
|
915
|
-
### One load, several players
|
|
916
|
-
|
|
917
|
-
One `loadModel` backs any number of players: the regions are cut once, and
|
|
918
|
-
every player draws from the one `regionImages` map. A player owns none of it —
|
|
919
|
-
disposing a player leaves the bitmaps alive for the others — so there is exactly
|
|
920
|
-
one `assets.dispose()`, after the last player is gone:
|
|
921
|
-
|
|
922
|
-
```ts
|
|
923
|
-
const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
|
|
924
|
-
const a = new RigPlayer(rootA, assets);
|
|
925
|
-
const b = new RigPlayer(rootB, assets, { skin: 'winter' });
|
|
926
|
-
|
|
927
|
-
a.dispose();
|
|
928
|
-
b.dispose();
|
|
929
|
-
assets.dispose();
|
|
930
|
-
```
|
|
931
|
-
|
|
932
|
-
A document is one rig. Two rigs whose builds share page images load as two
|
|
933
|
-
documents; the images are fetched by URL, so a page both name at the same URL
|
|
934
|
-
is loaded once by the browser — but each load cuts its own regions and owns its
|
|
935
|
-
own blob URLs.
|
|
936
|
-
|
|
937
|
-
### Loading it yourself
|
|
938
|
-
|
|
939
|
-
`loadModel` is a convenience over two calls the package exports: read the
|
|
940
|
-
document, load its pages, and cut the regions with `unpackRegions`, which takes
|
|
941
|
-
the document's `pages` section — `{ name, width, height, pma, regions: [{ name,
|
|
942
|
-
x, y, width, height, degrees }] }` per page — and a map of page images keyed by
|
|
943
|
-
those names. Use it when the page images do not come from a URL (`resolvePage`
|
|
944
|
-
on `loadModel` covers the ones that come from another URL), and pair it with
|
|
945
|
-
`revokeRegions` on unload.
|
|
946
|
-
|
|
947
|
-
A region the packer stored turned (`degrees: 90`) is cut upright again, and
|
|
948
|
-
which way round that is comes from the UVs a runtime assigns such a region,
|
|
949
|
-
not from the cut: its corners carry `(u2, v2), (u, v2), (u, v), (u2, v)` in the
|
|
950
|
-
order BL, UL, UR, BR, so the artwork's top-left corner sits at the packed rect's
|
|
951
|
-
bottom-left and unpacking it is a clockwise turn. Every release up to 0.7.0
|
|
952
|
-
turned it the other way and drew rotated rigid parts 180° round (#49).
|
|
953
|
-
|
|
954
|
-
`unpackRegions` mints one blob URL per region; `revokeRegions` is its
|
|
955
|
-
counterpart. It only frees URLs `unpackRegions` created, so a map you built
|
|
956
|
-
yourself and page images reused by the whole-page pass-through survive it — and
|
|
957
|
-
calling it twice is a no-op. Load once for the page's lifetime and you can
|
|
958
|
-
ignore it; load and unload repeatedly without it and you leak an atlas per
|
|
959
|
-
cycle.
|
|
960
|
-
|
|
961
|
-
### Loading for the canvas mode only, and `data:` regions
|
|
962
|
-
|
|
963
|
-
The region cut makes the rigid tier's bitmaps, which only the DOM mode draws.
|
|
964
|
-
`loadModel({ regions })` says what to do about it:
|
|
965
|
-
|
|
966
|
-
- `'blob'` (default) — `blob:` URLs the load owns; `assets.dispose()` frees them.
|
|
967
|
-
- `'data'` — `data:` URLs instead, for a content policy or a snapshot that does
|
|
968
|
-
not carry `blob:`. The same PNG bytes, inline; nothing is minted, so there is
|
|
969
|
-
nothing to free, and the base64 lives as long as the map does.
|
|
970
|
-
- `'none'` — no cut: the pages load, `assets.regionImages` is empty, and the
|
|
971
|
-
load is for the canvas mode. A DOM-mode player opened on it — `new RigPlayer`
|
|
972
|
-
without `mode: 'canvas'`, or `player.mode = 'dom'` — is refused by name, and
|
|
973
|
-
a refused mode change leaves the player as it was.
|
|
974
|
-
|
|
975
|
-
`assets.regions` records the choice. `unpackRegions` takes the same thing as
|
|
976
|
-
`{ encoding: 'blob' | 'data' }`.
|
|
977
|
-
|
|
978
|
-
### Pages that ship at another resolution
|
|
979
|
-
|
|
980
|
-
A page image may ship at a resolution its document does not declare — a
|
|
981
|
-
half-resolution texture build, or an @2x variant, with the page's `width` and
|
|
982
|
-
`height` left as the build wrote them. Nothing here needs a flag for that. A
|
|
983
|
-
region's bounds are read **relative to the declared page size** and scaled onto
|
|
984
|
-
the image's natural size, which is how the page UVs the mesh tier samples with
|
|
985
|
-
are derived too (`u = x / page.width`), so both tiers land on the same pixels at
|
|
986
|
-
any resolution.
|
|
987
|
-
|
|
988
|
-
Each unpacked bitmap comes out at the **native resolution of the pixels it was
|
|
989
|
-
cut from** — half-resolution pages cost a quarter of the cut pixels, and
|
|
990
|
-
nothing is upscaled back — while `RegionImage.width`/`height` stay in **atlas
|
|
991
|
-
units**. Those two numbers are the `<img>` layout box and the denominator of
|
|
992
|
-
its CSS matrix, so the rig poses identically and the browser scales the
|
|
993
|
-
smaller bitmap into the same box, the way a GPU samples a smaller texture
|
|
994
|
-
through the same UVs. Ship one build and swap the images per device if you
|
|
995
|
-
like (`resolvePage`).
|
|
996
|
-
|
|
997
|
-
### Premultiplied pages (`pma`)
|
|
998
|
-
|
|
999
|
-
A page's `pma` flag in the document's `pages` section — the build copies it from
|
|
1000
|
-
the atlas's `pma: true` line — says its RGB is already multiplied by its alpha,
|
|
1001
|
-
and nothing is asked of you: each tier is handed the page in the convention it
|
|
1002
|
-
actually consumes. The `webgl` mesh backend uploads the texels unconverted (its
|
|
1003
|
-
blend already expects premultiplied source — lossless). The DOM and canvas2d
|
|
1004
|
-
tiers cannot: an `<img>` and `drawImage` composite straight alpha by definition,
|
|
1005
|
-
so they read a straight-alpha derivation of the page —
|
|
1006
|
-
`rgb = round(rgb * 255 / a)`, computed once and shared by the region cuts and
|
|
1007
|
-
the mesh raster. Without it every semi-transparent texel is multiplied by its
|
|
1008
|
-
alpha a second time and draws darker than it was authored: soft edges, soft
|
|
1009
|
-
shadows, glows.
|
|
1010
|
-
|
|
1011
|
-
What it costs: **one page-sized canvas per premultiplied page image**
|
|
1012
|
-
(width × height × 4 bytes — 1 MiB for a 1024×256 page, 16 MiB for a 2048×2048
|
|
1013
|
-
one), alive as long as the page image is and released with it; the cache is
|
|
1014
|
-
weak, so there is nothing to free by hand. A page without the flag derives
|
|
1015
|
-
nothing. The division is 8-bit and starts from a canvas read, which has itself
|
|
1016
|
-
quantized a premultiplied texel, so a very transparent texel can land a few
|
|
1017
|
-
levels off — exact at alpha 0 and 255, within one level of 255 above alpha 128,
|
|
1018
|
-
and a little more below that, by an amount that belongs to the browser's canvas
|
|
1019
|
-
rather than to this package. It is invisible wherever the texel is: a texel at
|
|
1020
|
-
alpha 11 is ~4% opaque. The `webgl` backend, having no such step, is exact.
|
|
1021
|
-
|
|
1022
|
-
One consequence worth knowing if your build is one part per page: a whole-page
|
|
1023
|
-
region on a premultiplied page is **cut** rather than handed through, because
|
|
1024
|
-
the page's own URL holds premultiplied pixels (see below).
|
|
1025
|
-
|
|
1026
|
-
### What unloading frees
|
|
1027
|
-
|
|
1028
|
-
`revokeRegions()` — and `assets.dispose()`, which calls it — frees the blob URLs
|
|
1029
|
-
`unpackRegions` minted, and nothing else (a `regions: 'data'` or `'none'` load
|
|
1030
|
-
minted none, so it frees nothing). The page images are held by
|
|
1031
|
-
`assets.pageImages` and by every player drawing from them, so a page is
|
|
1032
|
-
released once you drop the assets and every player is disposed. With the
|
|
1033
|
-
`webgl` mesh backend the shared blitter also uploads each page image on first
|
|
1034
|
-
use, but that GL texture is not the module's forever: every renderer drawing
|
|
1035
|
-
the page holds a reference to it, `player.dispose()` hands those back, and the
|
|
1036
|
-
texture is deleted once the last renderer using that page is disposed — a later
|
|
1037
|
-
frame that needs the page uploads it again. The cache is keyed weakly by the
|
|
1038
|
-
page image, so a renderer dropped without `dispose()` costs GPU memory until
|
|
1039
|
-
the context is lost, but never keeps the image itself alive.
|
|
1040
|
-
|
|
1041
|
-
A rig with meshes samples its page bitmaps every frame, so their decoded form
|
|
1042
|
-
stays in use: a floor on the order of `page width × page height × 4` bytes per
|
|
1043
|
-
page. A rigid-only rig draws nothing from the pages after `unpackRegions`, and
|
|
1044
|
-
what a merely reachable, undrawn image costs is up to the browser, not
|
|
1045
|
-
measurable from script.
|
|
1046
|
-
|
|
1047
|
-
Unload in this order:
|
|
1048
|
-
|
|
1049
|
-
1. `player.dispose()`, for every player using the assets.
|
|
1050
|
-
2. `assets.dispose()` (or `revokeRegions(regionImages)`).
|
|
1051
|
-
3. Drop your references to the assets.
|
|
1052
|
-
|
|
1053
|
-
### One part per page
|
|
1054
|
-
|
|
1055
|
-
`rigc build` writes one page per part by default — each part PNG is its own
|
|
1056
|
-
page, its one region covering it — and `rigc build --pack` arranges the parts
|
|
1057
|
-
onto shared pages instead. Both play here, unchanged:
|
|
1058
|
-
|
|
1059
|
-
- Regions that cover their whole page are handed straight through by
|
|
1060
|
-
`unpackRegions` instead of being cut and re-encoded — at any image resolution,
|
|
1061
|
-
since covering the page is a statement about the declared size. Load cost for
|
|
1062
|
-
this shape is just the image loads. The exception is a page marked `pma`: its
|
|
1063
|
-
URL holds premultiplied pixels, which an `<img>` would composite as straight
|
|
1064
|
-
alpha, so those regions are cut from the straight-alpha derivation like any
|
|
1065
|
-
other (and the blob is revoked by `revokeRegions` like any other).
|
|
1066
|
-
- A packed build cuts every region from its page once, at load, and its meshes
|
|
1067
|
-
sample the shared page through page UVs.
|
|
1068
|
-
|
|
1069
|
-
### Runtime knobs and what they cost
|
|
1070
|
-
|
|
1071
|
-
- `player.mode` / `new RigPlayer(root, assets, { mode })` — `'dom'` (default)
|
|
1072
|
-
or `'canvas'`. Changing it disposes the current renderer (elements removed,
|
|
1073
|
-
GPU pages handed back) and creates the other over the same root and assets;
|
|
1074
|
-
`pixelRatio` and `clipping` carry over and the held pose is redrawn at once.
|
|
1075
|
-
`player.renderer` is then a `CanvasRenderer`, whose knobs are `pixelRatio`,
|
|
1076
|
-
`syncPixelRatio()`, `clipping`, `viewport` and `backend`. What each mode costs, as counters:
|
|
1077
|
-
- **dom** — one element per drawn slot, one style write per changed slot,
|
|
1078
|
-
and the mesh tier's rasters (the knobs below).
|
|
1079
|
-
- **canvas** — one canvas element per player and, per frame that changed,
|
|
1080
|
-
one pass over every slot: `drawCalls` (one per run of one page under one
|
|
1081
|
-
blend), `blendFlushes` (runs a blend change ended), `trianglesDrawn`,
|
|
1082
|
-
`blits` (one), or `framesReused` (one) when the record did not change and
|
|
1083
|
-
nothing is drawn. It never creates a WebGL context of its own, so any number
|
|
1084
|
-
of players stays under the browser's ~16-context cap (20 players, one
|
|
1085
|
-
context — `tests/canvas-mode.spec.ts`). The canvas covers a rect of the
|
|
1086
|
-
root's coordinates that grows (never shrinks) to what the frames draw —
|
|
1087
|
-
`canvasReallocCount` settles at zero — or a fixed `renderer.viewport` (world
|
|
1088
|
-
units, Y up) when you set one. Blends are composed inside that canvas, which
|
|
1089
|
-
starts transparent: an additive slot adds onto the slots under it and is
|
|
1090
|
-
then composited onto the page normally, where the DOM tier's
|
|
1091
|
-
`plus-lighter` adds onto the page itself. Where WebGL is unavailable or the
|
|
1092
|
-
context is lost, the frame is drawn with canvas2d on the same canvas so it
|
|
1093
|
-
never draws nothing — **alpha tint only, RGB and dark tint unsupported
|
|
1094
|
-
there** — and that fallback is **not the measured path** (`backendActive`
|
|
1095
|
-
says which ran).
|
|
1096
|
-
|
|
1097
|
-
- `renderer.pixelRatio` — mesh-canvas backing pixels per CSS pixel (defaults to
|
|
1098
|
-
`devicePixelRatio`; if you scale the root element, fold that scale in so the
|
|
1099
|
-
raster matches the screen: `devicePixelRatio * rootScale`). **Writing it
|
|
1100
|
-
reallocates every mesh canvas backing store on the next frame**, and each
|
|
1101
|
-
reallocation recreates a GPU surface — the cost that took real Safari to ~3 fps
|
|
1102
|
-
when it happened per frame. Each canvas is then sized from what the new ratio
|
|
1103
|
-
needs, in both directions: lowering the ratio gives the backing pixels back
|
|
1104
|
-
(the backing is grow-only *within* a ratio, not across a change of one). Set
|
|
1105
|
-
it when a layout settles, never per frame:
|
|
1106
|
-
debounce resize drags and quantize the value instead of tracking it
|
|
1107
|
-
continuously. `renderer.canvasReallocCount` is the check — it must fall back to
|
|
1108
|
-
zero within a second or two.
|
|
1109
|
-
- `renderer.syncPixelRatio()` — measures the root's effective on-screen scale
|
|
1110
|
-
and sets `pixelRatio` to `devicePixelRatio × scale`, returning the ratio now
|
|
1111
|
-
in effect. It appends a hidden 100 px box to the root, reads its box once and
|
|
1112
|
-
removes it (the root itself is usually 0×0), so it is **one forced layout per
|
|
1113
|
-
call** — which is exactly why the renderer never calls it for you: there is no
|
|
1114
|
-
per-frame layout read anywhere in this library. Call it when a zoom or a
|
|
1115
|
-
layout *settles* (gesture end, debounced resize), not during the drag. A
|
|
1116
|
-
change under 0.1% is ignored, so layout jitter cannot churn GPU surfaces, and
|
|
1117
|
-
a root that is not laid out (a `display: none` ancestor) leaves the ratio
|
|
1118
|
-
alone. Under an ancestor rotation the measured box is inflated and the ratio
|
|
1119
|
-
errs high — oversampling costs pixels, undersampling costs picture.
|
|
1120
|
-
- `renderer.meshBackingPixels` — allocated mesh-canvas backing pixels
|
|
1121
|
-
(Σ width × height), computed on demand. This is what `pixelRatio` moves
|
|
1122
|
-
quadratically, and the cheapest way to see an oversampling stage; the demo
|
|
1123
|
-
prints it in the stats line as `backing N Mpx`.
|
|
1124
|
-
- `renderer.meshBackend` — `'webgl'` (default since 1.9) or `'canvas2d'`; same
|
|
1125
|
-
output, and on macOS Safari webgl holds 58–60 fps where canvas2d reads 2–26
|
|
1126
|
-
(see Measured above). Falls back to canvas2d automatically when WebGL is
|
|
1127
|
-
unavailable or the shared context is lost; `meshBackendActive` says which drew. Switching re-rasters every
|
|
1128
|
-
mesh once (no reallocation), so it is fine to expose as a user setting.
|
|
1129
|
-
- `renderer.triangleExpand` — how far (canvas px) each triangle edge's clip is
|
|
1130
|
-
moved outward to close antialiased mesh seams (default 0.5). Also re-rasters every mesh once when changed.
|
|
1131
|
-
- `renderer.meshSourceRect` — what a canvas2d mesh triangle is drawn from:
|
|
1132
|
-
`'page'` (the whole page) or `'triangle'` (a sub-rect covering its clip). Both
|
|
1133
|
-
draw the same picture; the default is `'triangle'` on Linux WebKit, which
|
|
1134
|
-
garbles whole-page draws, and `'page'` elsewhere, because macOS Safari ran the
|
|
1135
|
-
sub-rect 12–15% slower (#25). Also on `CanvasRenderer` for its canvas2d
|
|
1136
|
-
fallback. Re-rasters every mesh once when changed.
|
|
1137
|
-
- `renderer.clipping` — apply the clips the frame carries (default `true`).
|
|
1138
|
-
Which slots a clip covers is decided where the frame is made — the player's
|
|
1139
|
-
adapter walks the draw order the way the runtime's draw loop does (one clip in
|
|
1140
|
-
force at a time, a second one met while one is in force ignored, the end slot
|
|
1141
|
-
inside its own clip). What the renderer writes is one CSS `clip-path` per
|
|
1142
|
-
element a clip covers, in that
|
|
1143
|
-
element's own local frame; nothing reaches the raster backends, and a clip is
|
|
1144
|
-
not part of the mesh dirty signature, so a mesh that held still keeps reusing
|
|
1145
|
-
its raster under a moving clip. **Writes happen on change only**: each
|
|
1146
|
-
clip-path is cached exactly as `transform` is, coordinates are quantized to
|
|
1147
|
-
1/1000 of a local unit so float jitter cannot defeat that cache, and a static
|
|
1148
|
-
polygon over a static pose therefore costs **zero style writes per frame after
|
|
1149
|
-
the first** — `renderer.clipWriteCount` is the check, alongside
|
|
1150
|
-
`clipCount` (clips applied) and `clipSkipCount` (clips in the frame not
|
|
1151
|
-
applied: switched off, or a degenerate polygon; a clip the runtime itself
|
|
1152
|
-
ignores never reaches the frame). **The whole-skeleton fast path**: when one
|
|
1153
|
-
clip covers every slot of the frame, one `clip-path` goes on the root instead
|
|
1154
|
-
of one per element. The root is *your* element, so its inline `clip-path` is **borrowed,
|
|
1155
|
-
not taken** — saved on the first write and put back verbatim when the clip
|
|
1156
|
-
stops covering the frame, when `clipping` goes `false`, and by `dispose()`.
|
|
1157
|
-
Per-element clip-paths and the root clip-path are never both in force for the
|
|
1158
|
-
same clip. Two things to know about that path: the polygon is written in the
|
|
1159
|
-
root's **border box** frame, which is where absolutely-positioned slot elements
|
|
1160
|
-
start too *unless the root has a CSS border* (a border would offset the
|
|
1161
|
-
whole-skeleton clip by its width — keep borders off the render root, which is
|
|
1162
|
-
the normal shape for a 0×0 origin element); and an inverse clip never takes it,
|
|
1163
|
-
because its CSS form needs an outer ring around a box and the root has none.
|
|
1164
|
-
One further consequence of that path: a `clip-path` other than `none` makes an
|
|
1165
|
-
element a stacking context (CSS Masking), so a whole-skeleton clip isolates
|
|
1166
|
-
`mix-blend-mode` slots from backdrops *outside* the root — which a root
|
|
1167
|
-
carrying a `transform` (the usual pan/zoom stage) already does. Per-element
|
|
1168
|
-
clips do not change blending, since a blended slot is its own stacking context
|
|
1169
|
-
either way. Setting `clipping = false` removes every clip-path this renderer
|
|
1170
|
-
wrote.
|
|
1171
|
-
|
|
1172
|
-
**A zoomable stage.** The mesh tier rasters at `world × pixelRatio` in the
|
|
1173
|
-
root's own coordinates, and it cannot see a CSS transform above the root — so
|
|
1174
|
-
the most natural pan/zoom stage, `transform: scale(zoom)` on an ancestor, makes
|
|
1175
|
-
it oversample by `1/zoom²` in backing pixels until the zoom is folded in. The
|
|
1176
|
-
picture stays correct throughout, which is what makes this easy to ship: only
|
|
1177
|
-
the allocation and the frame rate move. Call `syncPixelRatio()` when the zoom
|
|
1178
|
-
settles, or set `pixelRatio = devicePixelRatio * zoom` yourself. Measured
|
|
1179
|
-
on-device (Chromium, dpr 2, stage under `scale(0.25)`, ~2.2 M CSS px on screen):
|
|
1180
|
-
|
|
1181
|
-
| scene | `pixelRatio` | `meshBackingPixels` | fps |
|
|
1182
|
-
| --- | --- | --- | --- |
|
|
1183
|
-
| 57 meshes | `devicePixelRatio` (2) | 139.1 Mpx | 14–21 |
|
|
1184
|
-
| 57 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 61 |
|
|
1185
|
-
| 92 meshes | `devicePixelRatio` (2) | 141.1 Mpx | 36–39 |
|
|
1186
|
-
| 92 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 60–61 |
|
|
1187
|
-
|
|
1188
|
-
## Coming from 1.8: meshes draw through WebGL by default
|
|
1189
|
-
|
|
1190
|
-
From 1.9 the element mode rasterizes its meshes through the one shared WebGL
|
|
1191
|
-
context unless told otherwise (`DomRenderer.meshBackend` defaults to `'webgl'`,
|
|
1192
|
-
#109). The picture is the same within the parity suite's bounds, the API is
|
|
1193
|
-
unchanged, and canvas2d is still drawn wherever WebGL is unavailable or the
|
|
1194
|
-
context is lost. What a caller can notice:
|
|
1195
|
-
|
|
1196
|
-
- The page holds one more WebGL context (one for every mesh of every player, not
|
|
1197
|
-
one per player). A page already near the browser's cap of about 16 live
|
|
1198
|
-
contexts can lose its oldest one when this one is created.
|
|
1199
|
-
- `meshBackendActive` reads `'webgl'` where it read `'canvas2d'`.
|
|
1200
|
-
- Frame cost moves from the page to the GPU blit (see *Measured*).
|
|
1201
|
-
|
|
1202
|
-
To keep 1.8's raster, set it back, up front or on a live player:
|
|
1203
|
-
|
|
1204
|
-
```ts
|
|
1205
|
-
const player = new RigPlayer(root, assets, { meshBackend: 'canvas2d' });
|
|
1206
|
-
// or, on a renderer you hold:
|
|
1207
|
-
renderer.meshBackend = 'canvas2d';
|
|
1208
|
-
```
|
|
1209
|
-
|
|
1210
|
-
The canvas mode was already webgl by default and does not change.
|
|
1211
|
-
|
|
1212
|
-
## Coming from spine-html 0.8
|
|
1213
|
-
|
|
1214
|
-
This is not a drop-in upgrade. 0.8 drew a Spine export (`.json` or `.skel`, plus its
|
|
1215
|
-
`.atlas`) that `@esotericsoftware/spine-core` parsed and posed: the caller built the
|
|
1216
|
-
`Skeleton` and the `AnimationState`, advanced them each frame, and handed the skeleton to
|
|
1217
|
-
`renderer.render(skeleton)`. 1.0 loads one file, the `skeleton.model.json` that
|
|
1218
|
-
`rigc build` writes, poses it through `rig-c/core` (bundled into the package, with no peer
|
|
1219
|
-
dependency), and `RigPlayer` owns the track, so the caller's frame is `player.frame(dt)`.
|
|
1220
|
-
A Spine export is refused by name before anything is fetched: the data has to be a rigc
|
|
1221
|
-
build, and this package declares no loader for anything else.
|
|
1222
|
-
|
|
1223
|
-
What did not move: a positioned root element is still the rig's origin (Y up) with its
|
|
1224
|
-
layout and scaling left to the caller, the DOM tier keeps its knobs under a new class
|
|
1225
|
-
name, and unloading is still elements first, bitmaps second.
|
|
1226
|
-
|
|
1227
|
-
### 0.8 → 1.0
|
|
1228
|
-
|
|
1229
|
-
| 0.8.0 (`spine-html`) | 1.x (`@rig-c/play`, formerly `rig-play`) | Note |
|
|
1230
|
-
| --- | --- | --- |
|
|
1231
|
-
| `npm i spine-html @esotericsoftware/spine-core` | `npm i @rig-c/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. |
|
|
1232
|
-
| `loadSkeletonAssets(options)` | `loadModel(options)` | One URL instead of two. The `.atlas` and `skeleton.json` a build writes beside the document are not fetched. |
|
|
1233
|
-
| `LoadSkeletonAssetsOptions.atlasUrl`, `skeletonUrl` | `LoadModelOptions.modelUrl` | |
|
|
1234
|
-
| `resolvePage(pageName, atlasUrl)` | `resolvePage(pageName, modelUrl)` | The default now resolves a page name against the model URL's directory. |
|
|
1235
|
-
| `crossOrigin`, `fetch` | `crossOrigin`, `fetch` | Unchanged. |
|
|
1236
|
-
| `scale` (`LoadSkeletonAssetsOptions`, `LoadSkeletonJsonOptions`) | none | See below. |
|
|
1237
|
-
| `SkeletonAssets` | `ModelAssets` | |
|
|
1238
|
-
| `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). |
|
|
1239
|
-
| `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. |
|
|
1240
|
-
| `SkeletonAssets.regionImages` | `ModelAssets.regionImages` | Same `Map<string, RegionImage>`. |
|
|
1241
|
-
| `SkeletonAssets.dispose()`, `AtlasAssets.dispose()` | `ModelAssets.dispose()` | Same contract: frees the blob URLs the load minted, idempotent, called after the players are disposed. |
|
|
1242
|
-
| `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. |
|
|
1243
|
-
| `loadSkeletonBinary` from `spine-html/binary` | none | 1.0.0 has one entry point. A `.skel` URL is refused by name. |
|
|
1244
|
-
| `unpackRegions(atlas, pageImages)` | `unpackRegions(pages, pageImages)` | The first argument is the document's `pages` section (`readonly PageLayout[]`) instead of a `TextureAtlas`. |
|
|
1245
|
-
| `revokeRegions(images)` | `revokeRegions(images)` | Unchanged. |
|
|
1246
|
-
| `RegionImage` | `RegionImage` | Unchanged (`url`, `width`, `height`, in atlas units). |
|
|
1247
|
-
| `DomTexture` | none | Not exported; there is no atlas page to attach a texture to. |
|
|
1248
|
-
| `new Skeleton(assets.data)` and `new AnimationState(new AnimationStateData(assets.data))` | `new RigPlayer(root, assets, options?)` | The player holds the pose and the track. |
|
|
1249
|
-
| `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. |
|
|
1250
|
-
| `skeleton.findSlot('x').attachment = null` to hide a slot, or a second skeleton with slots emptied | `RigPlayerOptions.slots`, `player.slots`, `player.posedFrame(slots)` | A draw filter since 1.6.0 — the pose is the whole rig's; a clipping slot left out does not clip. See *Drawing some slots only*. |
|
|
1251
|
-
| `state.setAnimation(1, 'smile', false)` with a mix, `addAnimation`, `setEmptyAnimation`, `addEmptyAnimation`, `getCurrent(track)`, `entry.alpha`, `entry.additive` | `player.play(name, { track, loop, mix, alpha, additive })`, `player.queue(name, { track, loop, mix, delay, alpha, additive })`, `player.clear(track, { mix })`, `player.queueClear(track, { mix, delay })`, `player.setAlpha(track, alpha)`, `player.entry(track)` | Since 1.5.0, rig-c's live tracks: the runtime's mixing at tolerance 0. Constraint, sequence, draw-order and path-deform timelines are not mixed and are refused on the tracks. See *Several tracks*. |
|
|
1252
|
-
| `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. |
|
|
1253
|
-
| 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. |
|
|
1254
|
-
| holding a pose (the caller stopped advancing) | `player.pause()`, `player.resume()` | `frame` keeps drawing the held pose. |
|
|
1255
|
-
| 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. |
|
|
1256
|
-
| 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`, `track`); `'complete'` hands `{ animation, track }`. `on` returns a function that removes the listener. |
|
|
1257
|
-
| skin (set by the caller on its `Skeleton`) | `RigPlayerOptions.skin`, `player.skin` | A constructor option, and settable on a live player since 1.4.0: the pose re-resolves under the new skin and the track keeps its animation, loop and time (reopened and stepped there, as `seek` does). The names are in `ModelAssets.skins`. Without it the rig is posed with no skin set. |
|
|
1258
|
-
| `SpineHtmlRenderer` | `DomRenderer`, reached as `player.renderer` | In `mode: 'dom'`, the default. `mode: 'canvas'` is new and makes `player.renderer` a `CanvasRenderer`. |
|
|
1259
|
-
| `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. |
|
|
1260
|
-
| `renderer.render(skeleton)` | `renderer.render(frame)` | Takes a `PosedFrame`, not a skeleton. `player.posedFrame()` returns the one the player would draw. |
|
|
1261
|
-
| `renderer.pixelRatio` | `player.renderer.pixelRatio` | On both renderers. Carried over when `player.mode` changes. |
|
|
1262
|
-
| `renderer.syncPixelRatio()` | `player.renderer.syncPixelRatio()` | On both renderers, the same meaning and the same deadband (since 1.3.0; before it, DOM mode only). |
|
|
1263
|
-
| `renderer.meshBackingPixels` | `DomRenderer.meshBackingPixels` | DOM mode only. |
|
|
1264
|
-
| `renderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | `DomRenderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | Same values; the default is `'webgl'` since 1.9 (`'canvas2d'` before). Also settable up front as `RigPlayerOptions.meshBackend`. |
|
|
1265
|
-
| `renderer.triangleExpand` | `DomRenderer.triangleExpand` | Same default (0.5). |
|
|
1266
|
-
| `renderer.clipping` | `player.renderer.clipping` | On both renderers. Carried over when `player.mode` changes. |
|
|
1267
|
-
| `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. |
|
|
1268
|
-
| `meshCount`, `meshReuseCount`, `canvasReallocCount`, `triangleCount` | same names on `DomRenderer` | |
|
|
1269
|
-
| 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`. |
|
|
1270
|
-
| dark (two-colour) tint: unsupported | `DomRenderer`: unsupported. `CanvasRenderer`: drawn | Carried as `dark` on a `PosedSlot`. The canvas mode's canvas2d fallback draws alpha only. |
|
|
1271
|
-
| `renderer.dispose()` | `player.dispose()` | Then `assets.dispose()`, as before. `DomRenderer.dispose()` and `CanvasRenderer.dispose()` exist for a renderer you constructed yourself. |
|
|
1272
|
-
| `skeleton.findBone('head').worldX`, `.worldY`, `.a` … `.d` | `player.bone('head')` | `{ x, y, a, b, c, d, rotationX, rotationY, scaleX, scaleY, active }`, the core's world transform for the pose the player holds (the setup pose before `play`). An unknown name is refused by name. Since 1.1.0. |
|
|
1273
|
-
| `bone.scaleY = …` on a `Skeleton` between `state.apply` and `updateWorldTransform` | `RigPlayerOptions.adjust`, `player.adjust`, `player.repose()` | A function `(bones, ctx)` called once per pose with the pose's bone locals and current-entry context (unary callbacks remain valid) (`bones.bone('mouth').scaleY = v`). A write lasts one pose: the core poses every step from the setup pose, so write the value you want, every pose — nothing compounds. See *Adjusting bones*. Since 1.2.0. |
|
|
1274
|
-
| `skeleton.getBounds(offset, size)` | `player.bounds()` | `{ x, y, width, height }` or null, world units, Y up: the box of every drawn region's corners and mesh's vertices, a slot under a clip counted inside the clip's own box. `ModelAssets.stage` remains the declared framing, not this box. Since 1.1.0. |
|
|
1275
|
-
| live named physics constraint tuning | `player.setPhysicsParameters(name, patch)`, `readPhysicsParameters(name)`, `clearPhysicsParameters(name)` | Six scalar fields, per-player pending patch plus last evaluated effective values. Clear resumes authored values without resetting history. Replay uses the current standing patch, with no historical journal. See *Live physics parameters*. |
|
|
1276
|
-
| named setup mesh geometry | `readSetupMesh(assets, { slot, attachment, skin? })` | Detached full-double world x/y (Y up), triangles and hull for any named mesh/linked mesh, including non-setup selections; no player/time mutation. Selected geometry includes setup-slider deform; unselected geometry is stored geometry through unchanged setup matrices. See the measured official-runtime distinction under *Reading the pose*. |
|
|
1277
|
-
|
|
1278
|
-
### No counterpart in 1.0.0
|
|
1279
|
-
|
|
1280
|
-
Things a 0.8 caller could do because it held the spine-core objects, or through a 0.8
|
|
1281
|
-
export, for which 1.0.0 declares nothing:
|
|
1282
|
-
|
|
1283
|
-
- **A Spine export as input.** `loadSkeletonJson`, `loadSkeletonBinary`, `loadAtlasAssets`,
|
|
1284
|
-
the `spine-html/binary` entry and `DomTexture` have no counterpart, and neither does
|
|
1285
|
-
choosing which spine-core generation reads the data. `loadModel` reads a compiled model
|
|
1286
|
-
document and nothing else.
|
|
1287
|
-
- **Scaling the skeleton as it is read.** `LoadModelOptions` declares no `scale`. Scaling
|
|
1288
|
-
the root element is still the caller's.
|
|
1289
|
-
- **One unpacked atlas shared by different skeletons.** A document is one rig, and each
|
|
1290
|
-
`loadModel` cuts its own regions and owns its own blob URLs. Sharing is per rig: one
|
|
1291
|
-
load, several players.
|
|
1292
|
-
- **Changing one slot from code** (its attachment, its colour). `RigPlayer` declares only
|
|
1293
|
-
the whole-rig `color`. The frame record is a plain object, so a caller that draws its
|
|
1294
|
-
own frames through `DomRenderer.render` or `CanvasRenderer.render` decides what is in
|
|
1295
|
-
it.
|
|
1296
|
-
- **Choosing how physics is stepped each frame.** 0.8's loop passed `Physics.update`
|
|
1297
|
-
itself; 1.0.0 declares no physics option. `play` and `seek` reset physics at the
|
|
1298
|
-
animation's first pose and every step advances it.
|
|
1299
|
-
- **Track callbacks other than the two declared.** `on` accepts `'event'` and
|
|
1300
|
-
`'complete'`.
|
|
1301
|
-
|
|
1302
|
-
Knobs on `DomRenderer` that `CanvasRenderer` does not declare:
|
|
1303
|
-
|
|
1304
|
-
- `meshBackend` and `meshBackendActive`. The canvas mode has its own pair, `backend` and
|
|
1305
|
-
`backendActive` (type `CanvasBackend`, `'webgl'` by default).
|
|
1306
|
-
- `triangleExpand`, `meshBackingPixels`, `clipWriteCount`, `meshCount`, `meshReuseCount`,
|
|
1307
|
-
`triangleCount`. The canvas mode's counters are `drawCalls`, `blendFlushes`,
|
|
1308
|
-
`trianglesDrawn`, `framesReused` and `blits`.
|
|
1309
|
-
|
|
1310
|
-
Both declare `pixelRatio`, `clipping`, `clipCount`, `clipSkipCount`, `canvasReallocCount`,
|
|
1311
|
-
`render` and `dispose`. Only `pixelRatio` and `clipping` carry over when `player.mode`
|
|
1312
|
-
changes.
|
|
1313
|
-
|
|
1314
|
-
### Before and after
|
|
1315
|
-
|
|
1316
|
-
0.8.0:
|
|
1317
|
-
|
|
1318
|
-
```ts
|
|
1319
|
-
import { Skeleton, AnimationState, AnimationStateData, Physics } from '@esotericsoftware/spine-core';
|
|
1320
|
-
import { loadSkeletonAssets, SpineHtmlRenderer } from 'spine-html';
|
|
1321
|
-
const assets = await loadSkeletonAssets({ atlasUrl: '/hero/hero.atlas', skeletonUrl: '/hero/hero.json' });
|
|
1322
|
-
const skeleton = new Skeleton(assets.data);
|
|
1323
|
-
const state = new AnimationState(new AnimationStateData(assets.data));
|
|
1324
|
-
state.setAnimation(0, 'walk', true);
|
|
1325
|
-
const renderer = new SpineHtmlRenderer(rootElement, assets.regionImages);
|
|
1326
|
-
function frame(delta: number) {
|
|
1327
|
-
state.update(delta);
|
|
1328
|
-
state.apply(skeleton);
|
|
1329
|
-
skeleton.update(delta);
|
|
1330
|
-
skeleton.updateWorldTransform(Physics.update);
|
|
1331
|
-
renderer.render(skeleton);
|
|
1332
|
-
}
|
|
1333
|
-
renderer.dispose(); assets.dispose(); // on unload
|
|
1334
|
-
```
|
|
1335
|
-
|
|
1336
|
-
1.0.0:
|
|
1337
|
-
|
|
1338
|
-
```ts
|
|
1339
|
-
import { loadModel, RigPlayer } from '@rig-c/play';
|
|
1340
|
-
const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
|
|
1341
|
-
const player = new RigPlayer(rootElement, assets);
|
|
1342
|
-
player.play('walk', { loop: true });
|
|
1343
|
-
function frame(delta: number) {
|
|
1344
|
-
player.frame(delta);
|
|
1345
|
-
}
|
|
1346
|
-
player.dispose(); assets.dispose(); // on unload
|
|
1347
|
-
```
|
|
181
|
+
## Where to read next
|
|
182
|
+
|
|
183
|
+
- [docs/REFERENCE.md](docs/REFERENCE.md) — what the player does once it is on
|
|
184
|
+
the page: what ships and what is measured (*Status*), then every API section
|
|
185
|
+
from wind, gravity and dragging to the runtime knobs and what they cost.
|
|
186
|
+
- [docs/MIGRATION.md](docs/MIGRATION.md) — coming from 1.8 (meshes draw through
|
|
187
|
+
WebGL by default since 1.9) and from `spine-html` 0.8 (the 0.8 → 1.0 table,
|
|
188
|
+
what has no counterpart, before and after).
|
|
189
|
+
- [docs/TESTING.md](docs/TESTING.md) — how the suite holds the picture: the two
|
|
190
|
+
render modes' parity, the clipping oracle, the loading harness, and the
|
|
191
|
+
independent official raster oracle.
|
|
192
|
+
- [docs/BENCHMARK.md](docs/BENCHMARK.md) — the readings behind *Measured*, and
|
|
193
|
+
the bench that produces them: scenes, framing, and what a headless number is
|
|
194
|
+
not evidence of.
|
|
195
|
+
- [CHANGELOG.md](CHANGELOG.md) — what each version changed;
|
|
196
|
+
[RELEASING.md](RELEASING.md) — how a version is cut.
|
|
1348
197
|
|
|
1349
198
|
## Demo (this repository)
|
|
1350
199
|
|
|
@@ -1370,187 +219,19 @@ names the active backend), `?clipping=0` turns clips back off (they are then
|
|
|
1370
219
|
counted and skipped), and `?time=1.2` seeks every instance to the same pose for
|
|
1371
220
|
deterministic captures.
|
|
1372
221
|
|
|
1373
|
-
## Tests
|
|
1374
|
-
|
|
1375
|
-
```bash
|
|
1376
|
-
bun run test # builds + serves the demo, then runs chromium + webkit
|
|
1377
|
-
```
|
|
1378
|
-
|
|
1379
|
-
Playwright drives rigc-built gallery and procedural test rigs through the
|
|
1380
|
-
production `RigPlayer`; its pose comes from `rig-c/core`. CI runs the same
|
|
1381
|
-
suite on Ubuntu with Chromium and WebKit. Internal parity captures the same
|
|
1382
|
-
deterministic pose in two modes/backends within one engine. An independent
|
|
1383
|
-
development oracle also reads the paired Spine export through the official
|
|
1384
|
-
WebGL runtime. No snapshot goldens are committed. Residue limits are measured
|
|
1385
|
-
per engine, case and renderer; an A/B design alone does not make a measured
|
|
1386
|
-
budget portable to another platform. Pose discrepancies remain rigc issues;
|
|
1387
|
-
the independent oracle checks the resulting raster.
|
|
1388
|
-
|
|
1389
|
-
**Internal picture parity** checks the two render modes drawing the same picture: every
|
|
1390
|
-
scene captured in the DOM mode and in the canvas mode and diffed — bad pixels at
|
|
1391
|
-
most 1.5% of drawn content on the test-rig scenes (measured floors 0.007–0.524%,
|
|
1392
|
-
macOS Chromium; each scene's own feature dropped from the canvas mode — tint,
|
|
1393
|
-
additive blend, clipping, a mesh — scores 4.2–77%) and 2.5% on the gallery
|
|
1394
|
-
scenes (floors 0.585–1.553%, Chromium and WebKit). The DOM tier's two mesh
|
|
1395
|
-
backends get the same A/B (`?backend=canvas2d` against `?backend=webgl`, at most
|
|
1396
|
-
0.5%). Hairline seams are guarded by a deterministic canary (`?expand=0` must
|
|
1397
|
-
change the canvas2d raster), the canvas2d source sub-rect by another (expand 32
|
|
1398
|
-
against 8, forced to the sub-rect), the two source rects by an A/B that must
|
|
1399
|
-
match exactly, and counter tests pin the dirty-skip / grow-only-backing /
|
|
1400
|
-
clip-counter invariants plus the region corner order (BL, UL, UR, BR) and the
|
|
1401
|
-
turned-region UVs, read off rig-c's pose against hand-written numbers.
|
|
1402
|
-
|
|
1403
|
-
Clipping gets its own oracle (`tests/clipping.spec.ts`), because a clip-path
|
|
1404
|
-
that went through the wrong transform still parses and still counts as applied.
|
|
1405
|
-
A clipped capture is compared against the **unclipped** capture of the same
|
|
1406
|
-
slots masked in **screen space** by the world polygon — built once through the
|
|
1407
|
-
stage transform, never through an element's local frame, so the check cannot
|
|
1408
|
-
agree with the renderer by sharing its mistake. What it asserts is occupancy,
|
|
1409
|
-
not pixel values: outside a 3 px band around the polygon's outline (where the
|
|
1410
|
-
browser's clip-path antialiasing and the canvas `ctx.clip()` the oracle uses
|
|
1411
|
-
legitimately differ), **no artwork may survive where the polygon excludes it
|
|
1412
|
-
and none may go missing where it does not** — both counts absolute, no budget.
|
|
1413
|
-
Pixel values are logged but not asserted, because a clipped element is drawn
|
|
1414
|
-
through a mask and its silhouettes come out a shade different all over the
|
|
1415
|
-
picture; a control capture, clipped by a polygon that removes nothing, pins
|
|
1416
|
-
that down by coming back byte-identical to the unclipped one.
|
|
1417
|
-
|
|
1418
|
-
The loading path is not observable in a rendered frame, so it gets its own
|
|
1419
|
-
page (`tests/harness.html`, a second build entry) that exposes the library to
|
|
1420
|
-
the specs directly. Its oracle is the browser: a revoked object URL stops
|
|
1421
|
-
resolving, so blob ownership — every unpacked URL freed, nothing the caller
|
|
1422
|
-
owns touched, nothing stranded by a failed load — is asserted rather than
|
|
1423
|
-
assumed.
|
|
1424
|
-
|
|
1425
|
-
### Independent official raster oracle
|
|
1426
|
-
|
|
1427
|
-
`tests/oracle.spec.ts` compares real DOM WebGL, DOM canvas2d and whole-canvas
|
|
1428
|
-
output with `@esotericsoftware/spine-webgl` 4.3.13. The official reader parses
|
|
1429
|
-
the same build's Spine JSON, atlas and original PNGs independently; it never
|
|
1430
|
-
receives a `PosedFrame` or the player's alpha-corrected textures. JSON SHA-256
|
|
1431
|
-
binds the export to the compiled model, and generation/page dimensions fail
|
|
1432
|
-
closed. Both posers receive the same fixed-step history, crop, origin, scale
|
|
1433
|
-
and DPR 1; world geometry must agree exactly before raster acceptance.
|
|
1434
|
-
|
|
1435
|
-
Captures include the actual images, element canvases, CSS transforms, tint
|
|
1436
|
-
and clipping. Transparent RGB remains diagnostic, except for additional narrow
|
|
1437
|
-
raw checks on the whole-canvas PMA case. Acceptance compares those
|
|
1438
|
-
same captured bytes on black and white backgrounds with the shared pixel
|
|
1439
|
-
differ, and separately constrains alpha coverage, sum, unequal-pixel count
|
|
1440
|
-
and maximum. `tests/oracleLimits.ts` stores measured engine/case/mode floors:
|
|
1441
|
-
zero stays exact; nonzero counts receive 10% plus two units, and nonzero byte
|
|
1442
|
-
maxima one byte. macOS and Linux have separately measured tables; other
|
|
1443
|
-
platforms are refused as uncalibrated. Tests never regenerate limits from the
|
|
1444
|
-
image under test.
|
|
1445
|
-
|
|
1446
|
-
Cases cover geometry, tint, clipping, draw order with multiply/screen,
|
|
1447
|
-
rotated/named regions, additive glow and an original-PMA twin. Named UV and
|
|
1448
|
-
rotation coverage uses native scale: measured half-scale checker sampling
|
|
1449
|
-
has additional residue, so arbitrary-scale pixel identity is not claimed.
|
|
1450
|
-
The shared #37 negative control serves false PMA flags only in the player's
|
|
1451
|
-
model response, leaving original files and the official reader unchanged.
|
|
1452
|
-
Each renderer must first pass its healthy capture, then exceed the same
|
|
1453
|
-
healthy black-composite budget with the altered response. This runs on both
|
|
1454
|
-
CI browser projects. Original PMA PNG bytes are encoded before either reader
|
|
1455
|
-
sees them.
|
|
1456
|
-
|
|
1457
|
-
The reference uses `antialias:false`, `preserveDrawingBuffer:true` and the
|
|
1458
|
-
official `pmaAdditiveBatching:false` option to retain additive coverage in
|
|
1459
|
-
transparent captures. Initialization finishes model/region extraction before
|
|
1460
|
-
official page uploads, avoiding a measured concurrent WebKit initialization
|
|
1461
|
-
artifact. Use the required nova-run guard and a unique `TEST_PORT` for a
|
|
1462
|
-
focused `bun run test tests/oracle.spec.ts --workers=1`; full-suite acceptance
|
|
1463
|
-
remains CI. Headless results are raster evidence, never device performance.
|
|
1464
|
-
|
|
1465
|
-
## Benchmark: DOM, canvas and official WebGL
|
|
1466
|
-
|
|
1467
|
-
```bash
|
|
1468
|
-
bun run bench:dev # the page, for a person to watch (and for Safari)
|
|
1469
|
-
bun run bench # drives it in a headed Chromium and writes a report
|
|
1470
|
-
bun run bench --headless # counters only; all timing fields are null
|
|
1471
|
-
```
|
|
1472
|
-
|
|
1473
|
-
`bench/` is a separate vite project — not part of the library, and not part of
|
|
1474
|
-
what `bun run build` emits — that compares DOM, whole-canvas and the independent
|
|
1475
|
-
official WebGL reader, each with its own stats line. The paired JSON hash must
|
|
1476
|
-
match the model before an official comparison can publish readings.
|
|
1477
|
-
Scenes by query string: `?scene=mesh` (`portrait-packed` turning its head, the
|
|
1478
|
-
deform tier working every frame), `?scene=held` (the same pose frozen, where
|
|
1479
|
-
unchanged meshes reuse their raster and an unchanged frame is reused),
|
|
1480
|
-
`?scene=rigid` (`walk-packed`, region attachments only) and
|
|
1481
|
-
`?scene=many&count=N` independent players; `?rig=` and `?model=` name another
|
|
1482
|
-
build. All four take `&count=N` up to 64 (a page limit, so that a cell stays
|
|
1483
|
-
something you can see), laid out on one grid whose per-rig scale shrinks with
|
|
1484
|
-
the column count, so a step up in count adds rigs rather than cropping the ones
|
|
1485
|
-
already there.
|
|
1486
|
-
|
|
1487
|
-
**The comparison uses the same framing.** All paths place rigs with one derivation and
|
|
1488
|
-
clip each to the same rect — the pane for a grid, the rig's own cell for `many`
|
|
1489
|
-
— with `overflow: hidden` on a box, the way a page embeds a player, and then
|
|
1490
|
-
*check* it: each side reports `origin Δpx`, the largest gap between where the
|
|
1491
|
-
grid puts a rig and where that side drew it, and the report table carries it per
|
|
1492
|
-
row. (`contain: layout paint` is deliberately not used: paint containment clips
|
|
1493
|
-
to the same edge `overflow: hidden` does, and what it adds past the clip — a
|
|
1494
|
-
stacking context, a containing block, an independent formatting context —
|
|
1495
|
-
changes how one side is composited and buys the comparison nothing.) Production
|
|
1496
|
-
modes retain the package's shared WebGL context. Official grid scenes batch
|
|
1497
|
-
all skeletons through one context; only `many` uses independent contexts.
|
|
1498
|
-
Official surviving contexts and drawn players are counted separately from
|
|
1499
|
-
requested players. Production survival counters are null where its public
|
|
1500
|
-
API cannot measure them, rather than treating requested counts as survivors.
|
|
1501
|
-
|
|
1502
|
-
`scripts/bench.mjs` walks `bench/scenes.json`, opens each scene in a **headed**
|
|
1503
|
-
Chromium (a hidden page throttles `requestAnimationFrame`, so a headless
|
|
1504
|
-
reading would be the browser's power policy rather than either mode's cost),
|
|
1505
|
-
runs a fixed number of frames per mode — one at a time, because rAF has a single
|
|
1506
|
-
cadence per document — and writes JSON plus a markdown table with the
|
|
1507
|
-
conditions attached: browser version, device pixel ratio, window size, GPU
|
|
1508
|
-
string, and the load average before and after. A software or unidentified GPU
|
|
1509
|
-
invalidates a headed timing report. Over a load threshold it stamps the
|
|
1510
|
-
whole report `INVALID: load average too high` and exits non-zero, because a
|
|
1511
|
-
benchmark on a busy machine measures the machine. `--corpus <dir>` points it at
|
|
1512
|
-
local rigc builds that never enter this repository. `--headless` instead steps
|
|
1513
|
-
fixed frames and records counters only: FPS, pose/render timing and GPU fields
|
|
1514
|
-
are null, with “측정 불가: 헤드리스” in JSON and the table. The official
|
|
1515
|
-
additive-batching and drawing-buffer settings are recorded as conditions.
|
|
1516
|
-
|
|
1517
|
-
> **Performance numbers pending.** Counter reports establish the instrument,
|
|
1518
|
-
> not a performance comparison. A timing reading taken on
|
|
1519
|
-
> a loaded machine, a CI runner or a headless browser would not be worth
|
|
1520
|
-
> printing. The table will be taken the way every other number in this README
|
|
1521
|
-
> was: the stats lines read on a real device, on a quiet machine, per scene
|
|
1522
|
-
> and per engine — including Safari, which stays manual because no automation
|
|
1523
|
-
> preserves what is being measured there. Until then the instrument exists and
|
|
1524
|
-
> the measurement does not.
|
|
1525
|
-
|
|
1526
|
-
The server is part of the test: a run tests whatever is being served on its
|
|
1527
|
-
port. Two checkouts of this repository on one machine (a worktree, a second
|
|
1528
|
-
clone) default to the same port 4321 and reuse an existing server, so the
|
|
1529
|
-
second run quietly tests the first one's build — green, and about the wrong
|
|
1530
|
-
tree. Give each concurrent checkout its own port with `TEST_PORT`
|
|
1531
|
-
(`TEST_PORT=4333 bun run test`); setting it also disables server reuse, so a
|
|
1532
|
-
busy port fails the run instead of being borrowed. Unset, nothing changes:
|
|
1533
|
-
port 4321, reuse unless `CI`.
|
|
1534
|
-
|
|
1535
|
-
Standing rule: **headless numbers are never Safari performance evidence** —
|
|
1536
|
-
nothing in the suite asserts timing, and headless-WebKit fps/ms readings do
|
|
1537
|
-
not transfer (software rasterizer, measured up to 28× off real Safari). The
|
|
1538
|
-
perf oracle is the demo's stats line on a real device.
|
|
1539
|
-
|
|
1540
222
|
## How it works
|
|
1541
223
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
whole rigid render path.
|
|
224
|
+
`loadModel` reads the rig's compiled model document (`rig-c/core`'s `readModel`),
|
|
225
|
+
and `RigPlayer` opens an animation on a live track (`openTrack`): bones,
|
|
226
|
+
constraints, physics, deform and draw order are posed by the core — all
|
|
227
|
+
CPU-side, renderer-agnostic — one `dt` at a time. At load time each region of
|
|
228
|
+
the document's `pages` section is cut into its own bitmap (rotation restored),
|
|
229
|
+
one blob URL per region — except regions that already cover their whole page,
|
|
230
|
+
which are used as they are. Per frame, the pose becomes a `PosedFrame`: for
|
|
231
|
+
each slot in draw order, a region's four world corners (order **BL, UL, UR,
|
|
232
|
+
BR**) or a mesh's world vertices, page UVs and triangles, its tint, blend and
|
|
233
|
+
clip. For a region: flip Y (the frame is Y-up), derive the affine from three
|
|
234
|
+
corners, write one `matrix()` string. That's the whole rigid render path.
|
|
1554
235
|
|
|
1555
236
|
## License
|
|
1556
237
|
|
|
@@ -1568,6 +249,8 @@ so here is what is true of this package and this repository, stated plainly:
|
|
|
1568
249
|
covers those runtimes, not this project's MIT license. They remain outside
|
|
1569
250
|
production source, the public API and the library package; package guards
|
|
1570
251
|
and the library-only pack allowlist hold that boundary.
|
|
252
|
+
The optional load-profile harness keeps `spine-pixi-v8` 4.3.13 and PixiJS
|
|
253
|
+
in a separate private install under `bench/oracle`.
|
|
1571
254
|
3. `rigc build`, which this repository runs to make its fixtures, also writes Spine
|
|
1572
255
|
skeleton data (`skeleton.json` and `skeleton.atlas`) beside each model document.
|
|
1573
256
|
Development tooling reads this paired export independently. Generated fixtures
|
|
@@ -1583,5 +266,7 @@ so here is what is true of this package and this repository, stated plainly:
|
|
|
1583
266
|
> Software's terms**, not by this package — @rig-c/play neither adds those terms nor
|
|
1584
267
|
> removes them.
|
|
1585
268
|
|
|
1586
|
-
|
|
269
|
+
The optional load-profile harness fetches checksum-pinned spineboy and raptor
|
|
270
|
+
example assets together with their upstream `license.txt`. These development
|
|
271
|
+
fixtures are gitignored and excluded from the library package. See
|
|
1587
272
|
[NOTICE.md](NOTICE.md) for the full notice.
|