spine-html 0.5.1 → 0.6.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
@@ -86,8 +86,29 @@ with a self-contained test suite (backend visual parity + invariants) and CI.
86
86
  - ✅ Dirty-skip: a mesh whose canvas-space vertices didn't change reuses last frame's
87
87
  raster — the CSS translate still tracks it, so parts that hold a pose (or move by
88
88
  whole pixels) pay zero raster. 10 frozen spineboys: WebKit ~141 → ~0.5 ms/frame
89
- - Clipping deliberately unsupported (counted and skipped); layered transparent
90
- parts + `overflow: hidden` cover the practical cases
89
+ - Clipping attachments as element-level CSS `clip-path`: spine-core's semantics
90
+ (one active clip at a time, applied from the clip's slot through its end slot
91
+ inclusive), with the polygon expressed in each element's own local frame — the
92
+ inverse of the `<img>` matrix for rigid slots, the canvas translate for mesh
93
+ slots. Covers **part masks** (a clip with a real end slot, like this repository's
94
+ own `portal` animation) and **whole-skeleton clips** (an end slot that never
95
+ arrives, which takes a fast path: one `clip-path` on the root instead of one per
96
+ element). **Inverse clips** are supported too: the region they keep has a hole
97
+ in it, so it needs two rings, and `polygon()` carries only one — a box and a
98
+ polygon listed inside one `polygon()` become a single self-intersecting ring
99
+ whose even-odd fill leaves a wedge along the seam between them (measured, on a
100
+ corner of spineboy's boot). So an inverse clip is written as a two-subpath
101
+ `path(evenodd, …)` whose outer ring is the element's own box. The whole feature
102
+ is element-level, so both mesh backends see the same thing and neither raster
103
+ path knows clipping exists; `renderer.clipping = false`
104
+ restores the old counted-and-skipped behaviour. Concave polygons go to CSS as
105
+ authored — the convex decomposition spine's CPU clipper performs exists to feed a
106
+ triangle rasterizer and has no job here. Two things this does not do: Spine's
107
+ clipper convexifies an *inverse* polygon (convex hull) where CSS clips it as
108
+ authored, so the two agree exactly when that polygon is convex — which is the
109
+ shape `inverse` is meant for; and the cost of many simultaneous `clip-path`s on
110
+ real-device Safari is **not measured** (the headless numbers that exist are not
111
+ Safari evidence — see Measured above)
91
112
  - ✅ Safari mesh cost root-caused by on-device triangulation (two corrections deep):
92
113
  the early "~15× slower per-triangle path" was a **headless-WebKit artifact**
93
114
  (software rasterization), and the follow-up "on par with Chromium" held only for
@@ -247,6 +268,77 @@ it — and calling it twice is a no-op. Load once for the page's lifetime and yo
247
268
  can ignore it; load and unload repeatedly without it and you leak an atlas per
248
269
  cycle.
249
270
 
271
+ ### Pages that ship at another resolution
272
+
273
+ A page image may ship at a resolution its atlas does not declare — a
274
+ half-resolution texture build, or an @2x variant, with the `size:` line left as
275
+ the packer wrote it. Nothing here needs a flag for that. A region's bounds are
276
+ read **relative to the declared page size** and scaled onto the image's natural
277
+ size, which is exactly how `spine-core` derives the UVs the mesh tier samples
278
+ with (`region.u = region.x / page.width`), so both tiers land on the same
279
+ pixels at any resolution:
280
+
281
+ ```
282
+ hero.png
283
+ size: 4096, 4096 # what the packer wrote
284
+ # hero.png itself ships 2048×2048
285
+ ```
286
+
287
+ Each unpacked bitmap comes out at the **native resolution of the pixels it was
288
+ cut from** — half-resolution pages cost a quarter of the cut pixels, and
289
+ nothing is upscaled back — while `RegionImage.width`/`height` stay in **atlas
290
+ units**. Those two numbers are the `<img>` layout box and the denominator of
291
+ its CSS matrix, so the skeleton poses identically and the browser scales the
292
+ smaller bitmap into the same box, the way a GPU samples a smaller texture
293
+ through the same UVs. Ship one atlas and swap the images per device if you
294
+ like.
295
+
296
+ The corollary: a `size:` line that is simply **wrong** is now wrong for the
297
+ whole renderer rather than for the mesh tier alone. Bounds are read against
298
+ what the atlas declares, so a page declared at a size its artwork was not
299
+ packed at reads every region from the wrong place in both tiers — correct the
300
+ `size:` line, which is what every other Spine runtime needs too.
301
+
302
+ ### Premultiplied pages (`pma: true`)
303
+
304
+ The Spine texture packer premultiplies alpha by default, and says so with a page
305
+ line:
306
+
307
+ ```
308
+ hero.png
309
+ size: 2048, 2048
310
+ pma: true
311
+ ```
312
+
313
+ Such a page's RGB is already multiplied by its alpha, and nothing is asked of
314
+ you: the flag is read off the atlas and each tier is handed the page in the
315
+ convention it actually consumes. The `webgl` mesh backend uploads the texels
316
+ unconverted (its blend already expects premultiplied source — lossless). The DOM
317
+ and canvas2d tiers cannot: an `<img>` and `drawImage` composite straight alpha by
318
+ definition, so they read a straight-alpha derivation of the page —
319
+ `rgb = round(rgb * 255 / a)`, computed once and shared by the region cuts and
320
+ the mesh raster. Without it every semi-transparent texel is multiplied by its
321
+ alpha a second time and draws darker than it was authored: soft edges, soft
322
+ shadows, glows.
323
+
324
+ What it costs: **one page-sized canvas per premultiplied page image**
325
+ (width × height × 4 bytes — 1 MiB for a 1024×256 page, 16 MiB for a 2048×2048
326
+ one), alive as long as you hold the page image and released with it; the cache
327
+ is weak, so there is nothing to free by hand. A page with no `pma:` line derives
328
+ nothing and takes exactly the path it always did. The division is 8-bit and
329
+ starts from a canvas read, which has itself quantized a premultiplied texel, so
330
+ a very transparent texel can land a few levels off — exact at alpha 0 and 255,
331
+ within one level of 255 above alpha 128, and a little more below that, by an
332
+ amount that belongs to the browser's canvas rather than to this package (some
333
+ rasterizers round premultiplied storage several times more coarsely than
334
+ others). It is bounded by what that canvas already costs, and it is invisible
335
+ wherever the texel is: a texel at alpha 11 is ~4% opaque. The `webgl` backend,
336
+ having no such step, is exact.
337
+
338
+ One consequence worth knowing if your atlas is one part per page: a whole-page
339
+ region on a premultiplied page is **cut** rather than handed through, because
340
+ the page's own URL holds premultiplied pixels (see below).
341
+
250
342
  ### What unloading frees
251
343
 
252
344
  `revokeRegions()` — and `assets.dispose()`, which just calls it — frees the blob
@@ -297,12 +389,20 @@ bounds: 0, 0, 640, 480
297
389
  `spine-core` parses this as a normal multi-page atlas and nothing here needs a
298
390
  flag. Two things to know:
299
391
 
300
- - **`size:` must be the PNG's real pixel size.** UVs are derived from it
301
- (`region.x / page.width`), and a wrong value skews the mesh tier while the
302
- rigid tier still looks fine a confusing failure to chase.
392
+ - **`size:` is the frame every coordinate is read in**, so each page's
393
+ `bounds` must be written against the `size:` above them. The two need not be
394
+ the PNG's pixel sizethat is the point of the section above, and a
395
+ half-resolution `head.png` under `size: 512, 512` works — but a `size:` that
396
+ matches neither the bounds nor the artwork reads every region from the wrong
397
+ place, in both tiers.
303
398
  - Regions like these cover their whole page, so `unpackRegions` hands the page
304
- image straight through instead of cutting and re-encoding it. Load cost for
305
- this atlas shape is just the image loads.
399
+ image straight through instead of cutting and re-encoding it at any image
400
+ resolution, since covering the page is a statement about the declared size.
401
+ Load cost for this atlas shape is just the image loads. The exception is a
402
+ page marked `pma: true`: its URL holds premultiplied pixels, which an `<img>`
403
+ would composite as straight alpha, so those regions are cut from the
404
+ straight-alpha derivation like any other (and the blob is revoked by
405
+ `revokeRegions` like any other).
306
406
 
307
407
  For a **rigid-only** skeleton you can skip atlas unpacking altogether and hand
308
408
  the renderer a map you build yourself — meshes cannot, because the deform tier
@@ -350,6 +450,36 @@ const renderer = new SpineHtmlRenderer(rootElement, regionImages);
350
450
  mesh once (no reallocation), so it is fine to expose as a user setting.
351
451
  - `renderer.triangleExpand` — clip overdraw in px that closes antialiased mesh
352
452
  seams (default 0.5). Also re-rasters every mesh once when changed.
453
+ - `renderer.clipping` — apply clipping attachments (default `true`). What it
454
+ writes is one CSS `clip-path` per element the active clip covers, in that
455
+ element's own local frame; nothing reaches the raster backends, and a clip is
456
+ not part of the mesh dirty signature, so a mesh that held still keeps reusing
457
+ its raster under a moving clip. **Writes happen on change only**: each
458
+ clip-path is cached exactly as `transform` is, coordinates are quantized to
459
+ 1/1000 of a local unit so float jitter cannot defeat that cache, and a static
460
+ polygon over a static pose therefore costs **zero style writes per frame after
461
+ the first** — `renderer.clipWriteCount` is the check, alongside
462
+ `clipCount` (applied) and `clipSkipCount` (not applied: switched off, a second
463
+ clip met while one was active, an inactive bone, a degenerate polygon).
464
+ **The whole-skeleton fast path**: when the clip starts before anything has been
465
+ drawn and never ends, one `clip-path` goes on the root instead of one per
466
+ element. The root is *your* element, so its inline `clip-path` is **borrowed,
467
+ not taken** — saved on the first write and put back verbatim when the clip
468
+ stops covering the frame, when `clipping` goes `false`, and by `dispose()`.
469
+ Per-element clip-paths and the root clip-path are never both in force for the
470
+ same clip. Two things to know about that path: the polygon is written in the
471
+ root's **border box** frame, which is where absolutely-positioned slot elements
472
+ start too *unless the root has a CSS border* (a border would offset the
473
+ whole-skeleton clip by its width — keep borders off the render root, which is
474
+ the normal shape for a 0×0 origin element); and an inverse clip never takes it,
475
+ because its CSS form needs an outer ring around a box and the root has none.
476
+ One further consequence of that path: a `clip-path` other than `none` makes an
477
+ element a stacking context (CSS Masking), so a whole-skeleton clip isolates
478
+ `mix-blend-mode` slots from backdrops *outside* the root — which a root
479
+ carrying a `transform` (the usual pan/zoom stage) already does. Per-element
480
+ clips do not change blending, since a blended slot is its own stacking context
481
+ either way. Setting `clipping = false` removes every clip-path this renderer
482
+ wrote.
353
483
 
354
484
  **A zoomable stage.** The mesh tier rasters at `world × pixelRatio` in the
355
485
  root's own coordinates, and it cannot see a CSS transform above the root — so
@@ -383,8 +513,9 @@ Debug knobs (query string): `?skel=pro|ess` `?anim=walk` `?count=10` pick the sc
383
513
  ratio, `?timescale=0` freezes the pose (every mesh should report "reused"),
384
514
  `?expand=0` disables the crack-closing clip overdraw, `?backend=webgl` rasterizes
385
515
  meshes through the shared WebGL blitter (also a live header select; the stats line
386
- names the active backend), and `?time=1.2` seeks every instance to the same pose
387
- for deterministic captures.
516
+ names the active backend), `?clipping=0` turns clipping attachments back off (they
517
+ are then counted and skipped, as before v0.6 — try it on `?anim=portal`), and
518
+ `?time=1.2` seeks every instance to the same pose for deterministic captures.
388
519
 
389
520
  ## Tests
390
521
 
@@ -401,9 +532,24 @@ are diffed directly with a shift-tolerant comparison, so missing parts,
401
532
  wrong colors, and tint/blend divergence fail regardless of platform.
402
533
  Hairline seams are guarded by a deterministic canary (`?expand=0` must
403
534
  change the canvas2d raster), and counter tests pin the dirty-skip /
404
- grow-only-backing / clip-skip invariants plus spine-core's region corner
535
+ grow-only-backing / clip-counter invariants plus spine-core's region corner
405
536
  order (BL, UL, UR, BR).
406
537
 
538
+ Clipping gets its own oracle (`tests/clipping.spec.ts`), because a clip-path
539
+ that went through the wrong transform still parses and still counts as applied.
540
+ A clipped capture is compared against the **unclipped** capture of the same
541
+ slots masked in **screen space** by the world polygon — built once through the
542
+ stage transform, never through an element's local frame, so the check cannot
543
+ agree with the renderer by sharing its mistake. What it asserts is occupancy,
544
+ not pixel values: outside a 3 px band around the polygon's outline (where the
545
+ browser's clip-path antialiasing and the canvas `ctx.clip()` the oracle uses
546
+ legitimately differ), **no artwork may survive where the polygon excludes it
547
+ and none may go missing where it does not** — both counts absolute, no budget.
548
+ Pixel values are logged but not asserted, because a clipped element is drawn
549
+ through a mask and its silhouettes come out a shade different all over the
550
+ picture; a control capture, clipped by a polygon that removes nothing, pins
551
+ that down by coming back byte-identical to the unclipped one.
552
+
407
553
  The loading path is not observable in a rendered frame, so it gets its own
408
554
  page (`tests/harness.html`, a second build entry) that exposes the library to
409
555
  the specs directly. Its oracle is the browser: a revoked object URL stops
@@ -16,11 +16,64 @@ export interface RegionImage {
16
16
  * whole page, or anything the caller put there in a hand-built map.
17
17
  */
18
18
  url: string;
19
- /** Unpacked width in atlas pixels. */
19
+ /**
20
+ * Unpacked width in **atlas units** — the region's own `width`, not the
21
+ * bitmap's pixel width. The two differ whenever the page image ships at a
22
+ * resolution its atlas `size:` line does not mention (see cutRegion): a
23
+ * half-resolution page yields a bitmap half this wide. This is the layout
24
+ * box the rigid tier writes onto its `<img>` and the denominator of the CSS
25
+ * matrix it builds, so it stays in the frame the skeleton is authored in
26
+ * and the browser scales the bitmap into it — the same thing a GPU does
27
+ * when it samples normalized UVs from a texture of any resolution.
28
+ */
20
29
  width: number;
21
- /** Unpacked height in atlas pixels. */
30
+ /** Unpacked height in atlas units — see `width`. */
22
31
  height: number;
23
32
  }
33
+ /**
34
+ * Page pixels in the alpha convention a DOM/canvas2d consumer needs: the page
35
+ * image itself, or the derived canvas below when the page is premultiplied.
36
+ */
37
+ export type PageSource = HTMLImageElement | HTMLCanvasElement;
38
+ /**
39
+ * How many straight-alpha page derivations have been built in this document.
40
+ *
41
+ * Deterministic (no GC in the path) and monotonic, so a test reads it before
42
+ * and after and asserts the *difference*. Exported for the tests only — it is
43
+ * not re-exported from index.ts and the `exports` map denies deep imports, so
44
+ * it is not package API. (The same arrangement as `liveTextureCount` on the GL
45
+ * blitter, and for the same reason: a cache that works is invisible.)
46
+ */
47
+ export declare function straightAlphaDerivations(): number;
48
+ /**
49
+ * The page as a straight-alpha source: the image itself when `pma` is false,
50
+ * otherwise its (cached) un-premultiplied derivation.
51
+ *
52
+ * `rgb = round(rgb * 255 / a)`, clamped, with `a === 0` left at (0,0,0,0) —
53
+ * there is no colour to recover from a fully transparent texel.
54
+ *
55
+ * **Precision.** The division is done in 8 bits and cannot be done anywhere
56
+ * else: a 2D canvas stores premultiplied colour, so `getImageData` hands back
57
+ * `round(round(u * a / 255) * 255 / a)` for a file value `u`, already quantized
58
+ * by the read (measured on both engines: put→get moves a value by up to 127 at
59
+ * a = 1). The colour a low-alpha texel composites to is therefore off by at
60
+ * most ~min(a, 127.5/a + 0.5) of 255 — nothing at a = 255 or a = 0, ≤ 1 above
61
+ * a = 128, worst ~12 around a = 11, and bounded by `a` below that. Writing the
62
+ * values back is exact, not a second premultiply: the canvas quantizer is
63
+ * idempotent, so putting a value that came out of it stores it unchanged
64
+ * (measured: a second round trip moves nothing on either engine). The remaining
65
+ * error is one-way rounding on near-invisible texels, against a defect that
66
+ * darkened every semi-transparent texel by up to 59 luma.
67
+ *
68
+ * A page image that cannot be read — no 2D context, or a cross-origin image
69
+ * without CORS, where `getImageData` throws — falls back to the page itself and
70
+ * caches *that*: the colour is then as wrong as it is today, but it happens
71
+ * once per image instead of once per frame, and no frame throws. (The cut path
72
+ * already fails on such an image at `toBlob`, so only the mesh tier can get
73
+ * here.) An image that has not decoded yet is left uncached, so the derivation
74
+ * happens once it has.
75
+ */
76
+ export declare function straightAlphaSource(page: HTMLImageElement, pma: boolean): PageSource;
24
77
  /**
25
78
  * Cuts every atlas region out of the page image into its own bitmap once at
26
79
  * load time, restoring 90° packing rotation, so the per-frame path never
@@ -30,7 +83,23 @@ export interface RegionImage {
30
83
  * rendering is pure DOM (one <img> per slot, one CSS matrix write per frame).
31
84
  *
32
85
  * A region that covers its whole page unrotated skips the cut and reuses the
33
- * page image URL — see the pass-through in cutRegion.
86
+ * page image URL — see the pass-through in planCut.
87
+ *
88
+ * **A page may ship at a resolution its atlas does not declare.** Half-size
89
+ * texture builds and @2x variants are normal, and no option is needed for
90
+ * them: a region's bounds are read relative to the declared page size and
91
+ * scaled onto the image's natural size, which is exactly how spine-core
92
+ * derives the UVs the mesh tier already samples with. Each bitmap comes out at
93
+ * the native resolution of the pixels it was cut from, and the `RegionImage`
94
+ * sizes beside it stay in atlas units, so nothing downstream has to know.
95
+ *
96
+ * *Behaviour change (0.5.1 → next).* An atlas whose `size:` line is simply
97
+ * wrong — bounds written in image pixels under a page declared some other size
98
+ * — used to be cut "as the coordinates say", because the cut read the image
99
+ * directly. It is now read the way every other Spine runtime reads it:
100
+ * relative to the declared size. Such an atlas rendered with a skewed mesh
101
+ * tier and an intact rigid tier before; now both tiers agree, and the fix is
102
+ * to correct the `size:` line.
34
103
  *
35
104
  * **The cuts run concurrently.** PNG encoding is asynchronous and the browser
36
105
  * does it off the main thread, so the encodes are started together and awaited
@@ -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;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAC;CAChB;AAsGD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;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,CA0EnC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,IAAI,CAEpE"}
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;;;;;;;;;OASG;IACH,KAAK,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,MAAM,EAAE,MAAM,CAAC;CAChB;AAkBD;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG,gBAAgB,GAAG,iBAAiB,CAAC;AAqC9D;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,gBAAgB,EAAE,GAAG,EAAE,OAAO,GAAG,UAAU,CAcpF;AAgQD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;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,CA0EnC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,IAAI,CAEpE"}