spine-html 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,25 +25,49 @@ The idea has been floated on the official forum three times since 2014 and was n
25
25
  built — the blockers named were meshes, clipping, and DOM update overhead. This project
26
26
  is the experiment: how far does the DOM actually go, and how cheap is it?
27
27
 
28
- Measured so far (PoC, Chromium on Apple silicon):
28
+ Measured so far (PoC, Apple silicon — headless and on-device numbers are labeled,
29
+ they do **not** substitute for each other):
29
30
 
30
- - Rigid only (spineboy-ess): 10 skeletons / 180 slot images at **~0.03 ms skeleton math
31
- + ~0.22 ms DOM writes per frame** — about 1.5% of a 60 fps frame budget.
32
- - With meshes (spineboy-pro): 1 skeleton = **~0.05 ms + ~0.5 ms render** (8 mesh
33
- canvases, 323 triangles); 10 running skeletons = **~3.5 ms/frame** total.
34
- - Dirty-skip: 10 *static* skeletons (pose held) = **~0.4 ms/frame** on both Chromium
35
- and WebKit — unchanged meshes reuse their raster, so idle parts cost nothing.
36
- The same scene animating costs WebKit ~140 ms/frame (headless), which is the
37
- remaining optimization target below.
31
+ - Rigid only (spineboy-ess), headless Chromium: 10 skeletons / 180 slot images at
32
+ **~0.03 ms skeleton math + ~0.22 ms DOM writes per frame** — about 1.5% of a
33
+ 60 fps frame budget. On-device Safari: 10 rigid skeletons hold **60 fps**.
34
+ - With meshes (spineboy-pro), headless Chromium: 1 skeleton = **~0.05 ms + ~0.5 ms
35
+ render** (8 mesh canvases, 323 triangles); 10 running skeletons = **~3.5 ms/frame**
36
+ total, smooth.
37
+ - Dirty-skip: 10 *static* skeletons (pose held) = **~0.4 ms/frame** headless and
38
+ **~0.6 ms** on-device Safari — unchanged meshes reuse their raster, so idle or
39
+ held parts cost nothing anywhere.
40
+ - Real Safari (on-device), canvas2d mesh backend: in-callback JS stays **~4–5
41
+ ms/frame** for 10 running meshed skeletons — but the rAF rate tells the real
42
+ story: **meshed ×1 = 37 fps, meshed ×10 = 3–4 fps** (rigid-only ×10 = 60 fps).
43
+ The cost lives outside the frame callback, in the compositor/GPU process:
44
+ Safari antialiases canvas2d **clip paths**, so every triangle pays for an AA
45
+ mask. Chromium doesn't antialias clips and stays smooth. This is what the
46
+ optional WebGL mesh backend (below) removes.
47
+ - Real Safari (on-device), **webgl mesh backend**: meshed ×1 holds **60 fps**
48
+ (vs 37 on canvas2d), and the meshed ×10 stress scene jumps **3–4 → 45 fps** —
49
+ 80 mesh canvases / 3230 triangles redrawn every frame (0 reused, the worst
50
+ case), in-callback JS ~5.8 ms at dpr=1. The per-triangle clip-AA tax is gone;
51
+ the remaining gap to 60 is the blit/compositing cost of a 10-skeleton stress
52
+ scene, not a per-triangle cost.
53
+ - Headless-WebKit numbers are a **software rasterizer** and measured up to 28×
54
+ off real Safari in both directions — useful for visual regression only, never
55
+ as Safari perf evidence.
38
56
 
39
57
  ## Status
40
58
 
41
- **Proof of concept.**
59
+ **Production.** The questions this started as a PoC to answer — how far does the
60
+ DOM actually go, and how cheap is it — are answered with on-device numbers: the
61
+ practical scenario (a character or two, mostly holding pose, a few parts
62
+ deforming) holds **60 fps on every engine measured**, and the 10-skeleton
63
+ stress scene holds 45 fps on the weakest one (Safari, webgl backend). Ships
64
+ with a self-contained test suite (backend visual parity + invariants) and CI.
42
65
 
43
66
  - ✅ Region attachments (rigid parts): exact affine mapping, draw-order via `z-index`,
44
67
  attachment swaps, alpha
45
68
  - ✅ Atlas unpacking at load time (90°-packed regions restored), so the rigid per-frame
46
- path never touches a canvas
69
+ path never touches a canvas — a region that covers its whole page is passed through
70
+ uncut, and `revokeRegions()` frees the rest when a skeleton is unloaded
47
71
  - ✅ Mesh attachments (deform tier): small per-part canvases sized to the mesh's world
48
72
  bounds, interleaved with the rigid `<img>` slots in one stacking context — the DOM
49
73
  handles the bones, a rasterizer handles the warps
@@ -64,11 +88,25 @@ Measured so far (PoC, Chromium on Apple silicon):
64
88
  whole pixels) pay zero raster. 10 frozen spineboys: WebKit ~141 → ~0.5 ms/frame
65
89
  - ⬜ Clipping — deliberately unsupported (counted and skipped); layered transparent
66
90
  parts + `overflow: hidden` cover the practical cases
67
- - WebKit cost for *continuously deforming* meshes: the per-triangle canvas2d path
68
- is ~15× slower than Chromium and the cost is per-triangle, not per-pixel (a 0.4×
69
- backing store measures the same as 1×) an optional WebGL blit backend
70
- (one offscreen GL context `transferToImageBitmap` per-part `bitmaprenderer`)
71
- is the remaining candidate
91
+ - Safari mesh cost root-caused by on-device triangulation (two corrections deep):
92
+ the early "~15× slower per-triangle path" was a **headless-WebKit artifact**
93
+ (software rasterization), and the follow-up "on par with Chromium" held only for
94
+ in-callback JS time. Real-Safari rAF rates rigid ×10 = 60 fps, meshed ×1 =
95
+ 37 fps, meshed ×10 = 3–4 fps with JS flat at ~4–5 ms — put the real cost in the
96
+ GPU process: Safari antialiases canvas2d **clip paths**, so the per-triangle clip
97
+ mapping pays a per-triangle AA-mask tax (the same AA that caused the seam cracks)
98
+ - ✅ Optional WebGL blit backend for the mesh tier: `renderer.meshBackend =
99
+ 'webgl'` (default stays `'canvas2d'`). All dirty meshes are shelf-packed into
100
+ **one shared offscreen WebGL context** (module-level — browsers cap contexts at
101
+ ~16), drawn as textured triangles with premultiplied alpha, then rect-blitted
102
+ onto the same per-part canvases with an unclipped `drawImage` — cheap on Safari.
103
+ Everything element-level is unchanged: z-index interleave, tint filter,
104
+ `mix-blend-mode`, dirty-skip, grow-only backing. GL rasterizes shared triangle
105
+ edges seamlessly, so this path needs no crack overdraw. Falls back to canvas2d
106
+ when WebGL is unavailable or the context is lost. Backend parity verified by
107
+ headless Chromium+WebKit screenshot diffs (glow / clipping / tint scenes;
108
+ sub-pixel edge differences only). On-device Safari, meshed ×10 with every mesh
109
+ redrawn per frame: **3–4 fps (canvas2d) → 45 fps (webgl)**
72
110
 
73
111
  ## Install
74
112
 
@@ -77,30 +115,21 @@ npm i spine-html @esotericsoftware/spine-core
77
115
  ```
78
116
 
79
117
  ```ts
80
- import { TextureAtlas, AtlasAttachmentLoader, SkeletonJson, Skeleton,
81
- AnimationState, AnimationStateData, Physics } from '@esotericsoftware/spine-core';
82
- import { SpineHtmlRenderer, DomTexture, unpackRegions } from 'spine-html';
118
+ import { Skeleton, AnimationState, AnimationStateData, Physics }
119
+ from '@esotericsoftware/spine-core';
120
+ import { loadSkeletonAssets, SpineHtmlRenderer } from 'spine-html';
83
121
 
84
- // Load: parse the atlas, attach page images, unpack per-region bitmaps once.
85
- const atlas = new TextureAtlas(atlasText);
86
- const pageImages = new Map<string, HTMLImageElement>();
87
- for (const page of atlas.pages) {
88
- const image = await loadImage(page.name); // your loader
89
- page.setTexture(new DomTexture(image));
90
- pageImages.set(page.name, image);
91
- }
92
- const regionImages = await unpackRegions(atlas, pageImages);
93
- const data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(jsonText);
122
+ const assets = await loadSkeletonAssets({
123
+ atlasUrl: '/spineboy/spineboy.atlas',
124
+ skeletonUrl: '/spineboy/spineboy-pro.json',
125
+ });
94
126
 
95
127
  // A positioned element becomes the skeleton origin (Spine is Y-up: the
96
- // skeleton grows upward from it).
97
- const skeleton = new Skeleton(data);
98
- const state = new AnimationState(new AnimationStateData(data));
128
+ // skeleton grows upward from it). Layout and scaling are the caller's.
129
+ const skeleton = new Skeleton(assets.data);
130
+ const state = new AnimationState(new AnimationStateData(assets.data));
99
131
  state.setAnimation(0, 'walk', true);
100
- const renderer = new SpineHtmlRenderer(rootElement, regionImages);
101
- // Mesh canvases raster at devicePixelRatio by default; if you scale the
102
- // root element, fold that scale in so the raster matches the screen:
103
- // renderer.pixelRatio = devicePixelRatio * rootScale;
132
+ const renderer = new SpineHtmlRenderer(rootElement, assets.regionImages);
104
133
 
105
134
  function frame(delta: number) {
106
135
  state.update(delta);
@@ -109,11 +138,106 @@ function frame(delta: number) {
109
138
  skeleton.updateWorldTransform(Physics.update);
110
139
  renderer.render(skeleton);
111
140
  }
141
+
142
+ // Unloading (a cutscene ends, a level swaps): elements first, bitmaps second.
143
+ // The unpacked regions are blob URLs — nothing else frees them.
144
+ renderer.dispose();
145
+ assets.dispose();
112
146
  ```
113
147
 
114
148
  `spine-html` is a third-party renderer and is not affiliated with or endorsed by
115
149
  Esoteric Software.
116
150
 
151
+ ### Loading it yourself
152
+
153
+ `loadSkeletonAssets` is optional sugar over five `spine-core` calls, and the
154
+ package works without it. Drop to the low-level path whenever you need
155
+ something it does not do — one atlas shared by several skeletons, a binary
156
+ export, images that are already in memory:
157
+
158
+ ```ts
159
+ import { TextureAtlas, AtlasAttachmentLoader, SkeletonJson }
160
+ from '@esotericsoftware/spine-core';
161
+ import { DomTexture, unpackRegions, revokeRegions } from 'spine-html';
162
+
163
+ const atlas = new TextureAtlas(atlasText);
164
+ const pageImages = new Map<string, HTMLImageElement>();
165
+ for (const page of atlas.pages) {
166
+ const image = await loadImage(page.name); // your loader
167
+ page.setTexture(new DomTexture(image));
168
+ pageImages.set(page.name, image);
169
+ }
170
+ const regionImages = await unpackRegions(atlas, pageImages);
171
+ const data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(jsonText);
172
+
173
+ // …and on unload, after every renderer using them is disposed:
174
+ revokeRegions(regionImages);
175
+ ```
176
+
177
+ `unpackRegions` mints one blob URL per region; `revokeRegions` is its
178
+ counterpart. It only frees URLs `unpackRegions` created, so a map you built
179
+ yourself (below) and page images reused by the whole-page pass-through survive
180
+ it — and calling it twice is a no-op. Load once for the page's lifetime and you
181
+ can ignore it; load and unload repeatedly without it and you leak an atlas per
182
+ cycle.
183
+
184
+ ### One part per page (loose part PNGs)
185
+
186
+ Not every pipeline runs the Spine editor's texture packer. If your parts are
187
+ loose PNGs, declare each one as its own atlas page — a blank line closes a page
188
+ block, the next line opens the next:
189
+
190
+ ```
191
+ head.png
192
+ size: 512, 512
193
+ head
194
+ bounds: 0, 0, 512, 512
195
+
196
+ torso.png
197
+ size: 640, 480
198
+ torso
199
+ bounds: 0, 0, 640, 480
200
+ ```
201
+
202
+ `spine-core` parses this as a normal multi-page atlas and nothing here needs a
203
+ flag. Two things to know:
204
+
205
+ - **`size:` must be the PNG's real pixel size.** UVs are derived from it
206
+ (`region.x / page.width`), and a wrong value skews the mesh tier while the
207
+ rigid tier still looks fine — a confusing failure to chase.
208
+ - Regions like these cover their whole page, so `unpackRegions` hands the page
209
+ image straight through instead of cutting and re-encoding it. Load cost for
210
+ this atlas shape is just the image loads.
211
+
212
+ For a **rigid-only** skeleton you can skip atlas unpacking altogether and hand
213
+ the renderer a map you build yourself — meshes cannot, because the deform tier
214
+ samples the page bitmap through the atlas region:
215
+
216
+ ```ts
217
+ const regionImages = new Map([
218
+ ['head', { url: '/parts/head.png', width: 512, height: 512 }],
219
+ ]);
220
+ const renderer = new SpineHtmlRenderer(rootElement, regionImages);
221
+ ```
222
+
223
+ ### Runtime knobs and what they cost
224
+
225
+ - `renderer.pixelRatio` — mesh-canvas backing pixels per CSS pixel (defaults to
226
+ `devicePixelRatio`; if you scale the root element, fold that scale in so the
227
+ raster matches the screen: `devicePixelRatio * rootScale`). **Writing it
228
+ reallocates every mesh canvas backing store on the next frame**, and each
229
+ reallocation recreates a GPU surface — the cost that took real Safari to ~3 fps
230
+ when it happened per frame. Set it when a layout settles, never per frame:
231
+ debounce resize drags and quantize the value instead of tracking it
232
+ continuously. `renderer.canvasReallocCount` is the check — it must fall back to
233
+ zero within a second or two.
234
+ - `renderer.meshBackend` — `'canvas2d'` (default) or `'webgl'`; same output, but
235
+ heavy deforming scenes on Safari want `'webgl'` (see Measured above). Falls back
236
+ to canvas2d automatically when WebGL is unavailable. Switching re-rasters every
237
+ mesh once (no reallocation), so it is fine to expose as a user setting.
238
+ - `renderer.triangleExpand` — clip overdraw in px that closes antialiased mesh
239
+ seams (default 0.5). Also re-rasters every mesh once when changed.
240
+
117
241
  ## Demo (this repository)
118
242
 
119
243
  ```bash
@@ -127,15 +251,49 @@ The official spineboy example assets are downloaded automatically on first `dev`
127
251
 
128
252
  Debug knobs (query string): `?skel=pro|ess` `?anim=walk` `?count=10` pick the scene,
129
253
  `?tint=ff8080` tints the whole skeleton, `?dpr=2` overrides the mesh-canvas backing
130
- ratio, `?timescale=0` freezes the pose (every mesh should report "reused"), and
131
- `?expand=0` disables the crack-closing clip overdraw.
254
+ ratio, `?timescale=0` freezes the pose (every mesh should report "reused"),
255
+ `?expand=0` disables the crack-closing clip overdraw, `?backend=webgl` rasterizes
256
+ meshes through the shared WebGL blitter (also a live header select; the stats line
257
+ names the active backend), and `?time=1.2` seeks every instance to the same pose
258
+ for deterministic captures.
259
+
260
+ ## Tests
261
+
262
+ ```bash
263
+ bun run test # builds + serves the demo, then runs chromium + webkit
264
+ ```
265
+
266
+ Playwright drives the demo (`tests/`, config in `playwright.config.ts`; CI
267
+ runs the same suite on ubuntu). The visual check is **A/B within one run**:
268
+ no golden snapshots are committed (they rot across platforms/GPUs) — instead
269
+ the same deterministic pose (`?time` + `?timescale=0`) is screenshotted with
270
+ `?backend=canvas2d` and `?backend=webgl` in the same engine and the buffers
271
+ are diffed directly with a shift-tolerant comparison, so missing parts,
272
+ wrong colors, and tint/blend divergence fail regardless of platform.
273
+ Hairline seams are guarded by a deterministic canary (`?expand=0` must
274
+ change the canvas2d raster), and counter tests pin the dirty-skip /
275
+ grow-only-backing / clip-skip invariants plus spine-core's region corner
276
+ order (BL, UL, UR, BR).
277
+
278
+ The loading path is not observable in a rendered frame, so it gets its own
279
+ page (`tests/harness.html`, a second build entry) that exposes the library to
280
+ the specs directly. Its oracle is the browser: a revoked object URL stops
281
+ resolving, so blob ownership — every unpacked URL freed, nothing the caller
282
+ owns touched, nothing stranded by a failed load — is asserted rather than
283
+ assumed.
284
+
285
+ Standing rule: **headless numbers are never Safari performance evidence** —
286
+ nothing in the suite asserts timing, and headless-WebKit fps/ms readings do
287
+ not transfer (software rasterizer, measured up to 28× off real Safari). The
288
+ perf oracle is the demo's stats line on a real device.
132
289
 
133
290
  ## How it works
134
291
 
135
292
  1. `@esotericsoftware/spine-core` loads the skeleton and drives `AnimationState`,
136
293
  constraints, and physics — all CPU-side, renderer-agnostic.
137
294
  2. At load time each atlas region is cut into its own bitmap (rotation restored), one
138
- blob URL per region.
295
+ blob URL per region — except regions that already cover their whole page, which are
296
+ used as they are.
139
297
  3. Per frame, for each slot in draw order: `computeWorldVertices` yields the region's
140
298
  four corners in world space (order **BL, UL, UR, BR** — note the comments inside
141
299
  `computeWorldVertices` are stale). Flip Y (Spine is Y-up), derive the affine from
@@ -10,7 +10,11 @@ export declare class DomTexture extends Texture {
10
10
  dispose(): void;
11
11
  }
12
12
  export interface RegionImage {
13
- /** Blob URL of the unpacked (rotation-restored) region pixels. */
13
+ /**
14
+ * URL of the unpacked (rotation-restored) region pixels: a blob URL minted
15
+ * by unpackRegions, the page image URL itself when the region covers its
16
+ * whole page, or anything the caller put there in a hand-built map.
17
+ */
14
18
  url: string;
15
19
  /** Unpacked width in atlas pixels. */
16
20
  width: number;
@@ -20,10 +24,35 @@ export interface RegionImage {
20
24
  /**
21
25
  * Cuts every atlas region out of the page image into its own bitmap once at
22
26
  * load time, restoring 90° packing rotation, so the per-frame path never
23
- * touches a canvas. Returns blob URLs keyed by region name.
27
+ * touches a canvas. Returns image URLs keyed by region name.
24
28
  *
25
29
  * This is a loading-pipeline step, not a rendering step: after this runs,
26
30
  * rendering is pure DOM (one <img> per slot, one CSS matrix write per frame).
31
+ *
32
+ * A region that covers its whole page unrotated skips the cut and reuses the
33
+ * page image URL — see the pass-through below.
34
+ *
35
+ * The blob URLs stay alive until revokeRegions() frees them — a document-wide
36
+ * allocation the GC cannot reclaim on its own. Callers that load and unload
37
+ * skeletons repeatedly (cutscenes, level transitions) must pair every
38
+ * unpackRegions() with a revokeRegions(); a caller that loads once for the
39
+ * page lifetime can ignore it. If this function throws part-way through,
40
+ * everything it already minted is revoked before the error propagates.
27
41
  */
28
42
  export declare function unpackRegions(atlas: TextureAtlas, pageImages: Map<string, HTMLImageElement>): Promise<Map<string, RegionImage>>;
43
+ /**
44
+ * Frees the blob URLs unpackRegions minted for `images` — the counterpart of
45
+ * unpackRegions, to call once the skeletons using them are gone (after
46
+ * `renderer.dispose()`, since a live <img> would keep pointing at a dead URL).
47
+ *
48
+ * Only URLs this module created are revoked: entries the caller supplied
49
+ * itself (a hand-built map of loose part PNGs) and page images reused
50
+ * verbatim by the whole-page pass-through are left alone. Idempotent —
51
+ * revoking twice is a no-op, so it is safe on a map that is shared or
52
+ * partially reused.
53
+ *
54
+ * The map keeps its entries; drop the reference (or reload) afterwards, since
55
+ * the URLs no longer resolve.
56
+ */
57
+ export declare function revokeRegions(images: Map<string, RegionImage>): void;
29
58
  //# sourceMappingURL=DomTexture.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"DomTexture.d.ts","sourceRoot":"","sources":["../src/DomTexture.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,OAAO,EACP,YAAY,EAEZ,KAAK,aAAa,EAClB,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC;;;;GAIG;AACH,qBAAa,UAAW,SAAQ,OAAO;IACrC,UAAU,CAAC,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,aAAa,GAAG,IAAI;IACtE,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,IAAI;IACxD,OAAO,IAAI,IAAI;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,kEAAkE;IAClE,GAAG,EAAE,MAAM,CAAC;IACZ,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,YAAY,EACnB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,GACxC,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAiCnC"}
1
+ {"version":3,"file":"DomTexture.d.ts","sourceRoot":"","sources":["../src/DomTexture.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,OAAO,EACP,YAAY,EAEZ,KAAK,aAAa,EAClB,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC;;;;GAIG;AACH,qBAAa,UAAW,SAAQ,OAAO;IACrC,UAAU,CAAC,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,aAAa,GAAG,IAAI;IACtE,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,IAAI;IACxD,OAAO,IAAI,IAAI;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAC;CAChB;AAkBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,YAAY,EACnB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,GACxC,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAqEnC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,IAAI,CAEpE"}
@@ -9,44 +9,121 @@ export class DomTexture extends Texture {
9
9
  setWraps(_uWrap, _vWrap) { }
10
10
  dispose() { }
11
11
  }
12
+ /**
13
+ * Blob URLs minted by unpackRegions, i.e. the ones this module owns.
14
+ *
15
+ * Ownership has to be tracked because a RegionImage map is not necessarily
16
+ * ours: callers may hand the renderer a map built from their own URLs (loose
17
+ * part PNGs need no unpacking at all). revokeRegions() therefore frees only
18
+ * what is listed here and leaves caller-owned URLs untouched.
19
+ */
20
+ const ownedUrls = new Set();
21
+ /** Revokes `url` if unpackRegions minted it. Idempotent, caller-safe. */
22
+ function revokeOwned(url) {
23
+ if (!ownedUrls.delete(url))
24
+ return;
25
+ URL.revokeObjectURL(url);
26
+ }
12
27
  /**
13
28
  * Cuts every atlas region out of the page image into its own bitmap once at
14
29
  * load time, restoring 90° packing rotation, so the per-frame path never
15
- * touches a canvas. Returns blob URLs keyed by region name.
30
+ * touches a canvas. Returns image URLs keyed by region name.
16
31
  *
17
32
  * This is a loading-pipeline step, not a rendering step: after this runs,
18
33
  * rendering is pure DOM (one <img> per slot, one CSS matrix write per frame).
34
+ *
35
+ * A region that covers its whole page unrotated skips the cut and reuses the
36
+ * page image URL — see the pass-through below.
37
+ *
38
+ * The blob URLs stay alive until revokeRegions() frees them — a document-wide
39
+ * allocation the GC cannot reclaim on its own. Callers that load and unload
40
+ * skeletons repeatedly (cutscenes, level transitions) must pair every
41
+ * unpackRegions() with a revokeRegions(); a caller that loads once for the
42
+ * page lifetime can ignore it. If this function throws part-way through,
43
+ * everything it already minted is revoked before the error propagates.
19
44
  */
20
45
  export async function unpackRegions(atlas, pageImages) {
21
46
  const result = new Map();
22
- for (const region of atlas.regions) {
23
- const atlasRegion = region;
24
- const image = pageImages.get(atlasRegion.page.name);
25
- if (!image)
26
- throw new Error(`Missing page image: ${atlasRegion.page.name}`);
27
- const w = region.width;
28
- const h = region.height;
29
- const canvas = document.createElement('canvas');
30
- canvas.width = w;
31
- canvas.height = h;
32
- const ctx = canvas.getContext('2d');
33
- if (!ctx)
34
- throw new Error('2d context unavailable');
35
- if (region.degrees === 90) {
36
- // The region is packed rotated: it occupies an h×w rect in the page.
37
- // Rotate it back so the bitmap is in artwork orientation.
38
- ctx.translate(0, h);
39
- ctx.rotate(-Math.PI / 2);
40
- ctx.drawImage(image, atlasRegion.x, atlasRegion.y, h, w, 0, 0, h, w);
41
- }
42
- else {
43
- ctx.drawImage(image, atlasRegion.x, atlasRegion.y, w, h, 0, 0, w, h);
47
+ // Duplicate region names would otherwise strand the shadowed URL in the
48
+ // ledger with nothing left pointing at it.
49
+ const put = (name, entry) => {
50
+ const shadowed = result.get(name);
51
+ if (shadowed)
52
+ revokeOwned(shadowed.url);
53
+ result.set(name, entry);
54
+ };
55
+ try {
56
+ for (const region of atlas.regions) {
57
+ const atlasRegion = region;
58
+ const image = pageImages.get(atlasRegion.page.name);
59
+ if (!image)
60
+ throw new Error(`Missing page image: ${atlasRegion.page.name}`);
61
+ const w = region.width;
62
+ const h = region.height;
63
+ // Whole-page pass-through: an unrotated region covering its entire page
64
+ // would be cut into a pixel-for-pixel copy of the page image, so reuse
65
+ // the page URL instead no canvas, no PNG re-encode, no second decoded
66
+ // copy in memory, and nothing to revoke afterwards. Atlases written as
67
+ // one part per page (the loose-part-PNG workflow, where every part is
68
+ // declared its own page) hit this for every single region.
69
+ //
70
+ // The test is against the image, not the atlas `size:` line: a page
71
+ // declared at the wrong size still goes through the cut, since the cut
72
+ // is what the region's coordinates actually describe.
73
+ if (region.degrees === 0 &&
74
+ atlasRegion.x === 0 &&
75
+ atlasRegion.y === 0 &&
76
+ w === image.naturalWidth &&
77
+ h === image.naturalHeight) {
78
+ put(atlasRegion.name, { url: image.src, width: w, height: h });
79
+ continue;
80
+ }
81
+ const canvas = document.createElement('canvas');
82
+ canvas.width = w;
83
+ canvas.height = h;
84
+ const ctx = canvas.getContext('2d');
85
+ if (!ctx)
86
+ throw new Error('2d context unavailable');
87
+ if (region.degrees === 90) {
88
+ // The region is packed rotated: it occupies an h×w rect in the page.
89
+ // Rotate it back so the bitmap is in artwork orientation.
90
+ ctx.translate(0, h);
91
+ ctx.rotate(-Math.PI / 2);
92
+ ctx.drawImage(image, atlasRegion.x, atlasRegion.y, h, w, 0, 0, h, w);
93
+ }
94
+ else {
95
+ ctx.drawImage(image, atlasRegion.x, atlasRegion.y, w, h, 0, 0, w, h);
96
+ }
97
+ const blob = await new Promise((resolve, reject) => {
98
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
99
+ });
100
+ const url = URL.createObjectURL(blob);
101
+ ownedUrls.add(url);
102
+ put(atlasRegion.name, { url, width: w, height: h });
44
103
  }
45
- const blob = await new Promise((resolve, reject) => {
46
- canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
47
- });
48
- result.set(atlasRegion.name, { url: URL.createObjectURL(blob), width: w, height: h });
104
+ }
105
+ catch (error) {
106
+ revokeRegions(result);
107
+ throw error;
49
108
  }
50
109
  return result;
51
110
  }
111
+ /**
112
+ * Frees the blob URLs unpackRegions minted for `images` — the counterpart of
113
+ * unpackRegions, to call once the skeletons using them are gone (after
114
+ * `renderer.dispose()`, since a live <img> would keep pointing at a dead URL).
115
+ *
116
+ * Only URLs this module created are revoked: entries the caller supplied
117
+ * itself (a hand-built map of loose part PNGs) and page images reused
118
+ * verbatim by the whole-page pass-through are left alone. Idempotent —
119
+ * revoking twice is a no-op, so it is safe on a map that is shared or
120
+ * partially reused.
121
+ *
122
+ * The map keeps its entries; drop the reference (or reload) afterwards, since
123
+ * the URLs no longer resolve.
124
+ */
125
+ export function revokeRegions(images) {
126
+ for (const image of images.values())
127
+ revokeOwned(image.url);
128
+ }
52
129
  //# sourceMappingURL=DomTexture.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"DomTexture.js","sourceRoot":"","sources":["../src/DomTexture.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,OAAO,GAKR,MAAM,8BAA8B,CAAC;AAEtC;;;;GAIG;AACH,MAAM,OAAO,UAAW,SAAQ,OAAO;IACrC,UAAU,CAAC,UAAyB,EAAE,UAAyB,IAAS,CAAC;IACzE,QAAQ,CAAC,MAAmB,EAAE,MAAmB,IAAS,CAAC;IAC3D,OAAO,KAAU,CAAC;CACnB;AAWD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,KAAmB,EACnB,UAAyC;IAEzC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAE9C,KAAK,MAAM,MAAM,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;QACnC,MAAM,WAAW,GAAG,MAA4B,CAAC;QACjD,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACpD,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,WAAW,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QAE5E,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC;QACvB,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC;QACxB,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAChD,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC;QACjB,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;QAClB,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;QACpC,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;QAEpD,IAAI,MAAM,CAAC,OAAO,KAAK,EAAE,EAAE,CAAC;YAC1B,qEAAqE;YACrE,0DAA0D;YAC1D,GAAG,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACpB,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;YACzB,GAAG,CAAC,SAAS,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QACvE,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,SAAS,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QACvE,CAAC;QAED,MAAM,IAAI,GAAG,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YACvD,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;QAC3F,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC;IACxF,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"DomTexture.js","sourceRoot":"","sources":["../src/DomTexture.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared offscreen WebGL rasterizer for the mesh (deform) tier.
3
+ *
4
+ * One module-level context serves every renderer instance: browsers cap live
5
+ * WebGL contexts (16 on Chrome/Safari) and a page can hold many renderers, so
6
+ * per-renderer contexts would evict each other. Per flush, every dirty mesh is
7
+ * shelf-packed into a rect of the offscreen canvas, its triangles are drawn
8
+ * textured (atlas page texture, premultiplied alpha), and each rect is blitted
9
+ * onto the mesh's per-part 2d canvas with an unclipped drawImage rect copy.
10
+ *
11
+ * Why this exists: Safari antialiases canvas2d clip paths, so the standard
12
+ * per-triangle clip+transform+drawImage mapping pays a per-triangle AA-mask
13
+ * cost in the GPU process — invisible to in-callback JS timing, but it
14
+ * rAF-limits heavy scenes. GL rasterizes shared triangle edges seamlessly
15
+ * (no clip, no crack overdraw needed), and the remaining canvas2d work is a
16
+ * plain rect blit.
17
+ *
18
+ * The offscreen buffer is scratch space: rects are scissor-cleared and
19
+ * redrawn every flush and read back in the same task, so neither
20
+ * preserveDrawingBuffer nor cross-frame content is relied on. The buffer
21
+ * grows quantized and never shrinks, mirroring the per-part backing policy
22
+ * (reallocating GPU surfaces per frame is a Safari killer).
23
+ */
24
+ export interface MeshBlitJob {
25
+ /** Destination per-part canvas; the blit clears its full backing first. */
26
+ canvas: HTMLCanvasElement;
27
+ /** Atlas page image the mesh samples (cached as a GL texture on first use). */
28
+ page: HTMLImageElement;
29
+ /** Bbox-relative vertices in CSS px, x/y interleaved (indexed via `triangles`). */
30
+ vertices: Float64Array;
31
+ /** Normalized page UVs aligned with `vertices`. */
32
+ uvs: ArrayLike<number>;
33
+ /** Triangle index list into vertex/UV pairs. */
34
+ triangles: ArrayLike<number>;
35
+ /** Backing-store pixels per CSS px. */
36
+ ratio: number;
37
+ /** Rasterized region in device px — the packed rect and the blit both use it. */
38
+ width: number;
39
+ height: number;
40
+ }
41
+ declare class MeshGlBlitter {
42
+ lost: boolean;
43
+ private readonly canvas;
44
+ private readonly gl;
45
+ private readonly uResolution;
46
+ private readonly textures;
47
+ private readonly maxSize;
48
+ private vertexData;
49
+ /** Per-job packed rect origins, filled by flush(). */
50
+ private packX;
51
+ private packY;
52
+ constructor();
53
+ /**
54
+ * Rasterize every job into the offscreen buffer, then blit each rect onto
55
+ * its per-part canvas. Returns false when the context is lost (or the jobs
56
+ * cannot fit) so the caller can rasterize the batch on the canvas2d path.
57
+ */
58
+ flush(jobs: MeshBlitJob[]): boolean;
59
+ private compile;
60
+ private textureFor;
61
+ }
62
+ /**
63
+ * The module-level blitter, created on first use. Returns null when WebGL is
64
+ * unavailable or the shared context has been lost — callers then stay on the
65
+ * canvas2d path.
66
+ */
67
+ export declare function getMeshGlBlitter(): MeshGlBlitter | null;
68
+ export {};
69
+ //# sourceMappingURL=MeshGlBlitter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MeshGlBlitter.d.ts","sourceRoot":"","sources":["../src/MeshGlBlitter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,MAAM,WAAW,WAAW;IAC1B,2EAA2E;IAC3E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,+EAA+E;IAC/E,IAAI,EAAE,gBAAgB,CAAC;IACvB,mFAAmF;IACnF,QAAQ,EAAE,YAAY,CAAC;IACvB,mDAAmD;IACnD,GAAG,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IACvB,gDAAgD;IAChD,SAAS,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IAC7B,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AA4BD,cAAM,aAAa;IACjB,IAAI,UAAS;IAEb,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;IAC3D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAwB;IAC3C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAuB;IACnD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6C;IACtE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,UAAU,CAA0B;IAC5C,sDAAsD;IACtD,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,KAAK,CAAgB;;IAoD7B;;;;OAIG;IACH,KAAK,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,OAAO;IAwGnC,OAAO,CAAC,OAAO;IAYf,OAAO,CAAC,UAAU;CAmBnB;AAID;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,GAAG,IAAI,CASvD"}