spine-html 0.5.2 → 0.7.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 +161 -7
- package/dist/DomTexture.d.ts +44 -0
- package/dist/DomTexture.d.ts.map +1 -1
- package/dist/DomTexture.js +166 -2
- package/dist/DomTexture.js.map +1 -1
- package/dist/MeshGlBlitter.d.ts +20 -0
- package/dist/MeshGlBlitter.d.ts.map +1 -1
- package/dist/MeshGlBlitter.js +31 -8
- package/dist/MeshGlBlitter.js.map +1 -1
- package/dist/SpineHtmlRenderer.d.ts +107 -7
- package/dist/SpineHtmlRenderer.d.ts.map +1 -1
- package/dist/SpineHtmlRenderer.js +353 -23
- package/dist/SpineHtmlRenderer.js.map +1 -1
- package/dist/coreCompat.d.ts +130 -0
- package/dist/coreCompat.d.ts.map +1 -0
- package/dist/coreCompat.js +87 -0
- package/dist/coreCompat.js.map +1 -0
- package/package.json +4 -2
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { BlendMode, ClippingAttachment, MeshAttachment, RegionAttachment, } from '@esotericsoftware/spine-core';
|
|
2
|
+
import { coreCompatFor } from './coreCompat.js';
|
|
3
|
+
import { straightAlphaSource } from './DomTexture.js';
|
|
2
4
|
import { getMeshGlBlitter } from './MeshGlBlitter.js';
|
|
3
5
|
const regionVertices = new Float32Array(8);
|
|
4
6
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
@@ -25,6 +27,56 @@ function expandPoint(x, y, cx, cy, amount) {
|
|
|
25
27
|
const s = amount / len;
|
|
26
28
|
return [x + dx * s, y + dy * s];
|
|
27
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Formats one clip-path coordinate, rounded to 1/1000 of the element's local
|
|
32
|
+
* unit so that a pose which recomputes to a bit-different float does not
|
|
33
|
+
* defeat the write cache.
|
|
34
|
+
*
|
|
35
|
+
* Why 1/1000: the local unit is an atlas unit for a rigid `<img>` (the matrix
|
|
36
|
+
* denominator) and a CSS pixel for a mesh canvas, so the worst error this can
|
|
37
|
+
* add on screen is half a quantum times the element's local-to-screen scale.
|
|
38
|
+
* At a 50× upscale — far past anything a skeleton is posed at — that is
|
|
39
|
+
* 0.025 px, an order of magnitude under one device pixel at dpr 3, while
|
|
40
|
+
* still sitting three orders of magnitude above the f32 recomputation jitter
|
|
41
|
+
* of coordinates that run 10²–10³ units. Shortest-round-trip number
|
|
42
|
+
* formatting then keeps every coordinate at three decimals or fewer.
|
|
43
|
+
*/
|
|
44
|
+
function clipCoord(v) {
|
|
45
|
+
return `${clipNum(v)}px`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The same quantized number without a unit, for `path()` data — SVG path
|
|
49
|
+
* coordinates are user units and a `px` suffix there is a parse error.
|
|
50
|
+
*/
|
|
51
|
+
function clipNum(v) {
|
|
52
|
+
return Math.round(v * 1000) / 1000;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Bound past which a local coordinate is treated as degenerate rather than
|
|
56
|
+
* written. Two reasons, and the second is the hard one: a near-singular
|
|
57
|
+
* element matrix sends the inverse — and with it these coordinates — toward
|
|
58
|
+
* infinity, and JavaScript switches to exponential notation at 1e21, which is
|
|
59
|
+
* not valid CSS. The browser would then drop the whole declaration and the
|
|
60
|
+
* element would render UNCLIPPED, which is the one failure mode a clip must
|
|
61
|
+
* never have. A billion local units is already far outside anything a
|
|
62
|
+
* skeleton poses, so nothing expressible is lost by stopping here.
|
|
63
|
+
*/
|
|
64
|
+
const CLIP_COORD_LIMIT = 1e9;
|
|
65
|
+
/**
|
|
66
|
+
* Does `endSlot` still lie ahead of `index` in the draw order? A null end slot
|
|
67
|
+
* never does, and neither does the clip's own slot (the scan starts past it)
|
|
68
|
+
* nor one already passed — which is exactly why each of those clips runs to
|
|
69
|
+
* the end of the draw order instead of ending.
|
|
70
|
+
*/
|
|
71
|
+
function endsAhead(drawOrder, index, endSlot) {
|
|
72
|
+
if (!endSlot)
|
|
73
|
+
return false;
|
|
74
|
+
for (let i = index + 1, n = drawOrder.length; i < n; i++) {
|
|
75
|
+
if (drawOrder[i].data === endSlot)
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
28
80
|
const BLEND_CSS = {
|
|
29
81
|
[BlendMode.Normal]: '',
|
|
30
82
|
[BlendMode.Additive]: 'plus-lighter',
|
|
@@ -49,9 +101,9 @@ const BLEND_CSS = {
|
|
|
49
101
|
* RGB tint (skeleton × slot × attachment color) is applied per element with
|
|
50
102
|
* an SVG feColorMatrix reference filter — exact channel multiply, works the
|
|
51
103
|
* same on <img> and <canvas>. Dark (two-color) tint is not expressible that
|
|
52
|
-
* way and is unsupported. Clipping attachments
|
|
53
|
-
*
|
|
54
|
-
*
|
|
104
|
+
* way and is unsupported. Clipping attachments become a CSS clip-path per
|
|
105
|
+
* element, in that element's own local frame, on spine-core's semantics — one
|
|
106
|
+
* active clip at a time, ending at the end slot inclusive (see `clipping`).
|
|
55
107
|
*
|
|
56
108
|
* Coordinate mapping: Spine is Y-up, CSS is Y-down — world Y is negated.
|
|
57
109
|
* spine-core computes everything (bones, constraints, physics, deformed
|
|
@@ -60,8 +112,42 @@ const BLEND_CSS = {
|
|
|
60
112
|
export class SpineHtmlRenderer {
|
|
61
113
|
root;
|
|
62
114
|
regionImages;
|
|
63
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Apply clipping attachments (default). Each element drawn inside an active
|
|
117
|
+
* clip gets a CSS `clip-path` in its own local frame — an element-level
|
|
118
|
+
* feature, so both mesh backends see exactly the same thing and neither
|
|
119
|
+
* raster path knows clipping exists.
|
|
120
|
+
*
|
|
121
|
+
* spine-core's semantics, not an approximation of them: one clip is active
|
|
122
|
+
* at a time (a second clipping attachment met while one is active is
|
|
123
|
+
* ignored, as SkeletonClipping.clipStart does), a clip applies to the slots
|
|
124
|
+
* drawn after its own slot through its end slot inclusive, and an end slot
|
|
125
|
+
* that is the clip's own slot — or one already passed — never ends it, so
|
|
126
|
+
* the clip runs to the end of the draw order. That last case is the
|
|
127
|
+
* whole-skeleton clip, and it takes a fast path: one `clip-path` on the root
|
|
128
|
+
* instead of one per element. The root is the caller's element, so its
|
|
129
|
+
* inline `clip-path` is borrowed — saved on the first write and restored
|
|
130
|
+
* when the clip stops covering the frame, when `clipping` goes false, and by
|
|
131
|
+
* `dispose()`.
|
|
132
|
+
*
|
|
133
|
+
* False restores the pre-0.6 behaviour: clips are counted in clipSkipCount
|
|
134
|
+
* and nothing is clipped, with any clip-path this renderer wrote removed.
|
|
135
|
+
*/
|
|
136
|
+
clipping = true;
|
|
137
|
+
/** Clipping attachments applied last frame. */
|
|
138
|
+
clipCount = 0;
|
|
139
|
+
/**
|
|
140
|
+
* Clipping attachments NOT applied last frame: `clipping` is false, another
|
|
141
|
+
* clip was already active (spine-core ignores the second one), the slot's
|
|
142
|
+
* bone is inactive, or the polygon has fewer than three points.
|
|
143
|
+
*/
|
|
64
144
|
clipSkipCount = 0;
|
|
145
|
+
/**
|
|
146
|
+
* `clip-path` style writes performed last frame, across slot elements and
|
|
147
|
+
* the root, clears included. A static polygon over static elements settles
|
|
148
|
+
* at zero: every clip-path is cached per element exactly as `transform` is.
|
|
149
|
+
*/
|
|
150
|
+
clipWriteCount = 0;
|
|
65
151
|
/** Mesh canvases rasterized last frame. */
|
|
66
152
|
meshCount = 0;
|
|
67
153
|
/** Mesh canvases that reused their previous raster last frame. */
|
|
@@ -105,6 +191,13 @@ export class SpineHtmlRenderer {
|
|
|
105
191
|
meshBackend = 'canvas2d';
|
|
106
192
|
/** Backend that actually rasterized the mesh tier during the last render(). */
|
|
107
193
|
meshBackendActive = 'canvas2d';
|
|
194
|
+
/**
|
|
195
|
+
* How this renderer reaches spine-core, picked from the first skeleton it is
|
|
196
|
+
* handed and then reused — see coreCompat.ts. Null until then, because the
|
|
197
|
+
* constructor has no skeleton to look at and the installed core is not
|
|
198
|
+
* knowable from the root element.
|
|
199
|
+
*/
|
|
200
|
+
core = null;
|
|
108
201
|
views = new Map();
|
|
109
202
|
pendingJobs = [];
|
|
110
203
|
pendingViews = [];
|
|
@@ -116,6 +209,25 @@ export class SpineHtmlRenderer {
|
|
|
116
209
|
glPages = null;
|
|
117
210
|
scratchVertices = new Float32Array(256);
|
|
118
211
|
tintDefs = null;
|
|
212
|
+
/** The clip in force at the current point of the draw order, if any. */
|
|
213
|
+
clipAttachment = null;
|
|
214
|
+
clipEndSlot = null;
|
|
215
|
+
clipInverse = false;
|
|
216
|
+
/** True while the active clip is served by one clip-path on the root. */
|
|
217
|
+
clipOnRoot = false;
|
|
218
|
+
/** Whether any element has been drawn yet this frame (the fast-path test). */
|
|
219
|
+
drewAnything = false;
|
|
220
|
+
/** The active clip polygon in CSS space (Y already negated), flat x,y. */
|
|
221
|
+
clipPolygon = new Float64Array(0);
|
|
222
|
+
clipPolygonLength = 0;
|
|
223
|
+
/**
|
|
224
|
+
* The root's inline `clip-path` before this renderer borrowed it; null means
|
|
225
|
+
* not borrowed. The root belongs to the caller, so the fast path gives back
|
|
226
|
+
* exactly the string it found.
|
|
227
|
+
*/
|
|
228
|
+
rootClipSaved = null;
|
|
229
|
+
/** Last clip-path string written to the root, to skip no-op style writes. */
|
|
230
|
+
rootClipWritten = '';
|
|
119
231
|
/**
|
|
120
232
|
* @param root Positioned element (e.g. position:absolute) that becomes the
|
|
121
233
|
* skeleton origin. The renderer only appends slot elements to it — layout
|
|
@@ -216,20 +328,49 @@ export class SpineHtmlRenderer {
|
|
|
216
328
|
return next;
|
|
217
329
|
}
|
|
218
330
|
render(skeleton) {
|
|
331
|
+
// One feature detection per renderer, never per slot and never per frame.
|
|
332
|
+
const core = (this.core ??= coreCompatFor(skeleton));
|
|
333
|
+
this.clipCount = 0;
|
|
219
334
|
this.clipSkipCount = 0;
|
|
335
|
+
this.clipWriteCount = 0;
|
|
220
336
|
this.meshCount = 0;
|
|
221
337
|
this.meshReuseCount = 0;
|
|
222
338
|
this.canvasReallocCount = 0;
|
|
223
339
|
this.triangleCount = 0;
|
|
340
|
+
this.clipAttachment = null;
|
|
341
|
+
this.clipEndSlot = null;
|
|
342
|
+
this.clipInverse = false;
|
|
343
|
+
this.clipOnRoot = false;
|
|
344
|
+
this.drewAnything = false;
|
|
224
345
|
const blitter = this.meshBackend === 'webgl' ? getMeshGlBlitter() : null;
|
|
225
346
|
this.meshBackendActive = blitter ? 'webgl' : 'canvas2d';
|
|
226
|
-
const drawOrder =
|
|
347
|
+
const drawOrder = core.drawOrder(skeleton);
|
|
227
348
|
for (let i = 0, n = drawOrder.length; i < n; i++) {
|
|
228
349
|
const slot = drawOrder[i];
|
|
229
|
-
const pose = slot
|
|
350
|
+
const pose = core.pose(slot);
|
|
230
351
|
const attachment = pose.attachment;
|
|
352
|
+
// The official draw loops take the clipping attachment before anything
|
|
353
|
+
// else and in this order: end whatever clip this slot closes, start this
|
|
354
|
+
// slot's clip, then `continue` — skipping the per-slot clipEnd at the
|
|
355
|
+
// bottom. That `continue` is the whole own-slot rule: the slot that
|
|
356
|
+
// STARTS a clip is never offered to clipEnd afterwards, so an end slot
|
|
357
|
+
// equal to the clip's own slot ends nothing and the clip runs to the end
|
|
358
|
+
// of the draw order. [spine-core 4.3.13 dist/SkeletonRendererCore.js,
|
|
359
|
+
// and dist/SkeletonRenderer.js of @esotericsoftware/spine-webgl 4.3.13.]
|
|
360
|
+
if (attachment instanceof ClippingAttachment) {
|
|
361
|
+
this.clipEnd(slot);
|
|
362
|
+
// An inactive bone means the official loop never reaches the clipping
|
|
363
|
+
// branch at all, so no clip starts.
|
|
364
|
+
if (slot.bone.active)
|
|
365
|
+
this.clipStart(skeleton, slot, attachment, drawOrder, i);
|
|
366
|
+
else
|
|
367
|
+
this.clipSkipCount++;
|
|
368
|
+
this.hide(slot);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
231
371
|
if (!slot.bone.active) {
|
|
232
372
|
this.hide(slot);
|
|
373
|
+
this.clipEnd(slot);
|
|
233
374
|
continue;
|
|
234
375
|
}
|
|
235
376
|
if (attachment instanceof RegionAttachment) {
|
|
@@ -239,11 +380,15 @@ export class SpineHtmlRenderer {
|
|
|
239
380
|
this.renderMesh(skeleton, slot, pose, attachment, i);
|
|
240
381
|
}
|
|
241
382
|
else {
|
|
242
|
-
if (attachment instanceof ClippingAttachment)
|
|
243
|
-
this.clipSkipCount++;
|
|
244
383
|
this.hide(slot);
|
|
245
384
|
}
|
|
385
|
+
// After the slot is drawn: the end slot is inside its own clip.
|
|
386
|
+
this.clipEnd(slot);
|
|
246
387
|
}
|
|
388
|
+
// Only a clip that covered the whole frame keeps the root's style; every
|
|
389
|
+
// other frame hands the caller's inline value back.
|
|
390
|
+
if (!this.clipOnRoot)
|
|
391
|
+
this.releaseRootClip();
|
|
247
392
|
if (this.pendingJobs.length) {
|
|
248
393
|
if (!blitter || !blitter.flush(this.pendingJobs)) {
|
|
249
394
|
// Context lost mid-frame: rasterize this batch on the 2d path so the
|
|
@@ -251,7 +396,10 @@ export class SpineHtmlRenderer {
|
|
|
251
396
|
for (let i = 0; i < this.pendingJobs.length; i++) {
|
|
252
397
|
const job = this.pendingJobs[i];
|
|
253
398
|
this.pendingViews[i].meshBackendDrawn = 'canvas2d';
|
|
254
|
-
this.rasterizeMesh2d(job.canvas,
|
|
399
|
+
this.rasterizeMesh2d(job.canvas,
|
|
400
|
+
// The 2d path wants straight alpha where the upload wanted it
|
|
401
|
+
// premultiplied; the derivation is cached, so this costs a lookup.
|
|
402
|
+
straightAlphaSource(job.page, job.pma), job.vertices, job.uvs, job.triangles, job.ratio);
|
|
255
403
|
}
|
|
256
404
|
}
|
|
257
405
|
this.pendingJobs.length = 0;
|
|
@@ -260,8 +408,9 @@ export class SpineHtmlRenderer {
|
|
|
260
408
|
}
|
|
261
409
|
/**
|
|
262
410
|
* Removes every element this renderer added to the root (slot elements and
|
|
263
|
-
* the tint filter defs),
|
|
264
|
-
*
|
|
411
|
+
* the tint filter defs), restores the root's own inline `clip-path` if the
|
|
412
|
+
* whole-skeleton clip path borrowed it, and hands the atlas pages it
|
|
413
|
+
* uploaded back to the shared GL blitter — the one resource here that outlives the instance, so
|
|
265
414
|
* the one that has to be given back explicitly (GPU memory, and no garbage
|
|
266
415
|
* collector feels pressure from it). The pages are reference-counted there:
|
|
267
416
|
* a page another live renderer still draws stays uploaded, and the texture
|
|
@@ -276,6 +425,9 @@ export class SpineHtmlRenderer {
|
|
|
276
425
|
* releases nothing a second time.
|
|
277
426
|
*/
|
|
278
427
|
dispose() {
|
|
428
|
+
// The root is the caller's element and outlives this renderer, so the
|
|
429
|
+
// borrowed inline clip-path goes back before the slot elements go.
|
|
430
|
+
this.releaseRootClip();
|
|
279
431
|
for (const view of this.views.values())
|
|
280
432
|
view.el.remove();
|
|
281
433
|
this.views.clear();
|
|
@@ -291,8 +443,8 @@ export class SpineHtmlRenderer {
|
|
|
291
443
|
}
|
|
292
444
|
// --- rigid tier -----------------------------------------------------------
|
|
293
445
|
renderRegion(skeleton, slot, pose, attachment, zIndex) {
|
|
294
|
-
const
|
|
295
|
-
const region =
|
|
446
|
+
const core = this.core;
|
|
447
|
+
const region = core.regionAt(attachment, slot, core.sequenceIndex(attachment, pose));
|
|
296
448
|
const regionImage = region && this.regionImages.get(region.name);
|
|
297
449
|
if (!regionImage) {
|
|
298
450
|
this.hide(slot);
|
|
@@ -306,7 +458,7 @@ export class SpineHtmlRenderer {
|
|
|
306
458
|
img.width = regionImage.width;
|
|
307
459
|
img.height = regionImage.height;
|
|
308
460
|
}
|
|
309
|
-
|
|
461
|
+
core.regionWorldVertices(attachment, slot, pose, regionVertices);
|
|
310
462
|
// Corner order from spine-core is BL, UL, UR, BR — derived from
|
|
311
463
|
// computeUVs, whose per-vertex UVs are (u,v2), (u,v), (u2,v), (u2,v2).
|
|
312
464
|
// (The br/bl/ul/ur comments inside computeWorldVertices are stale.)
|
|
@@ -321,23 +473,30 @@ export class SpineHtmlRenderer {
|
|
|
321
473
|
const c = (blx - ulx) / h;
|
|
322
474
|
const d = (bly - uly) / h;
|
|
323
475
|
this.setTransform(view, `matrix(${a},${b},${c},${d},${ulx},${uly})`);
|
|
476
|
+
// Rigid elements carry a full affine, so the clip polygon rides its
|
|
477
|
+
// inverse into the local (atlas-unit) frame the <img> box spans.
|
|
478
|
+
this.applyClip(view, a, b, c, d, ulx, uly, w, h);
|
|
324
479
|
this.applyCommon(view, slot, pose, attachment.color, skeleton, zIndex);
|
|
325
480
|
}
|
|
326
481
|
// --- deform tier ----------------------------------------------------------
|
|
327
482
|
renderMesh(skeleton, slot, pose, attachment, zIndex) {
|
|
328
|
-
const
|
|
329
|
-
const sequenceIndex =
|
|
330
|
-
const region =
|
|
483
|
+
const core = this.core;
|
|
484
|
+
const sequenceIndex = core.sequenceIndex(attachment, pose);
|
|
485
|
+
const region = core.regionAt(attachment, slot, sequenceIndex);
|
|
331
486
|
const page = region?.texture?.getImage();
|
|
332
|
-
if (!page) {
|
|
487
|
+
if (!region || !page) {
|
|
333
488
|
this.hide(slot);
|
|
334
489
|
return;
|
|
335
490
|
}
|
|
491
|
+
// Which alpha convention the page's texels are in. The two backends consume
|
|
492
|
+
// opposite ones — GL wants them premultiplied, `drawImage` wants them
|
|
493
|
+
// straight — so this decides what each is handed, never what it does.
|
|
494
|
+
const pma = region.page.pma;
|
|
336
495
|
const count = attachment.worldVerticesLength;
|
|
337
496
|
if (this.scratchVertices.length < count)
|
|
338
497
|
this.scratchVertices = new Float32Array(count);
|
|
339
498
|
const vertices = this.scratchVertices;
|
|
340
|
-
|
|
499
|
+
core.vertexWorldVertices(attachment, skeleton, slot, 0, count, vertices, 0, 2);
|
|
341
500
|
// World bounds (in CSS coords: Y negated).
|
|
342
501
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
343
502
|
for (let v = 0; v < count; v += 2) {
|
|
@@ -423,6 +582,11 @@ export class SpineHtmlRenderer {
|
|
|
423
582
|
rel[v + 1] = y;
|
|
424
583
|
}
|
|
425
584
|
this.setTransform(view, `translate(${minX}px,${minY}px)`);
|
|
585
|
+
// A mesh canvas is posed by a translate only, so its local frame is the
|
|
586
|
+
// CSS-space polygon minus that translate. The clip never reaches the
|
|
587
|
+
// raster: it is not part of the dirty signature, and a mesh whose vertices
|
|
588
|
+
// held still still reuses its raster under a moving clip.
|
|
589
|
+
this.applyClip(view, 1, 0, 0, 1, minX, minY, view.canvasW / ratio, view.canvasH / ratio);
|
|
426
590
|
if (!dirty) {
|
|
427
591
|
this.meshReuseCount++;
|
|
428
592
|
}
|
|
@@ -432,7 +596,7 @@ export class SpineHtmlRenderer {
|
|
|
432
596
|
view.meshExpand = this.triangleExpand;
|
|
433
597
|
view.meshVertexCount = count;
|
|
434
598
|
view.meshBackendDrawn = this.meshBackendActive;
|
|
435
|
-
const uvs =
|
|
599
|
+
const uvs = core.meshUVs(attachment, sequenceIndex);
|
|
436
600
|
const triangles = attachment.triangles;
|
|
437
601
|
if (this.meshBackendActive === 'webgl') {
|
|
438
602
|
// Queued, not drawn: render() flushes the whole batch through the
|
|
@@ -441,6 +605,7 @@ export class SpineHtmlRenderer {
|
|
|
441
605
|
this.pendingJobs.push({
|
|
442
606
|
canvas,
|
|
443
607
|
page,
|
|
608
|
+
pma,
|
|
444
609
|
vertices: rel,
|
|
445
610
|
uvs,
|
|
446
611
|
triangles,
|
|
@@ -452,7 +617,7 @@ export class SpineHtmlRenderer {
|
|
|
452
617
|
this.retainPage(page);
|
|
453
618
|
}
|
|
454
619
|
else {
|
|
455
|
-
this.rasterizeMesh2d(canvas, page, rel, uvs, triangles, ratio);
|
|
620
|
+
this.rasterizeMesh2d(canvas, straightAlphaSource(page, pma), rel, uvs, triangles, ratio);
|
|
456
621
|
}
|
|
457
622
|
this.meshCount++;
|
|
458
623
|
this.triangleCount += triangles.length / 3;
|
|
@@ -493,7 +658,9 @@ export class SpineHtmlRenderer {
|
|
|
493
658
|
// [0, size] and texel i covers [i, i+1). drawImage-under-a-transform and
|
|
494
659
|
// texture2D() both read that frame, so both backends land on the same
|
|
495
660
|
// source pixels. (No half-texel term: the affine below *maps* corners, it
|
|
496
|
-
// does not sample — see drawTriangle.)
|
|
661
|
+
// does not sample — see drawTriangle.) `page` is the page image, or its
|
|
662
|
+
// straight-alpha derivation on a premultiplied one; the derivation is the
|
|
663
|
+
// image's own natural size, so the frame is the same either way.
|
|
497
664
|
const uw = page.width;
|
|
498
665
|
const uh = page.height;
|
|
499
666
|
for (let t = 0; t < triangles.length; t += 3) {
|
|
@@ -639,6 +806,158 @@ export class SpineHtmlRenderer {
|
|
|
639
806
|
ctx.drawImage(img, sx, sy, sw, sh, sx, sy, sw, sh);
|
|
640
807
|
ctx.restore();
|
|
641
808
|
}
|
|
809
|
+
// --- clipping tier ---------------------------------------------------------
|
|
810
|
+
/**
|
|
811
|
+
* Begins a clip, mirroring SkeletonClipping.clipStart — whose first line is
|
|
812
|
+
* `if (this.clipAttachment) return`, so a second clipping attachment met
|
|
813
|
+
* while one is active is ignored rather than nested.
|
|
814
|
+
*
|
|
815
|
+
* The polygon is spine-core's: computeWorldVertices gives it in world space
|
|
816
|
+
* each frame, and the only thing done to it here is the Y negation the rest
|
|
817
|
+
* of the renderer already applies. It is NOT convexified or triangulated the
|
|
818
|
+
* way the CPU clipper does — CSS `polygon()` takes a concave polygon
|
|
819
|
+
* directly, so the decomposition that exists to feed a triangle rasterizer
|
|
820
|
+
* has no job here.
|
|
821
|
+
*/
|
|
822
|
+
clipStart(skeleton, slot, clip, drawOrder, index) {
|
|
823
|
+
const count = clip.worldVerticesLength;
|
|
824
|
+
if (this.clipAttachment || !this.clipping || count < 6) {
|
|
825
|
+
this.clipSkipCount++;
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const core = this.core;
|
|
829
|
+
if (this.scratchVertices.length < count)
|
|
830
|
+
this.scratchVertices = new Float32Array(count);
|
|
831
|
+
const world = this.scratchVertices;
|
|
832
|
+
core.vertexWorldVertices(clip, skeleton, slot, 0, count, world, 0, 2);
|
|
833
|
+
if (this.clipPolygon.length < count)
|
|
834
|
+
this.clipPolygon = new Float64Array(count);
|
|
835
|
+
const poly = this.clipPolygon;
|
|
836
|
+
for (let v = 0; v < count; v += 2) {
|
|
837
|
+
poly[v] = world[v];
|
|
838
|
+
poly[v + 1] = -world[v + 1]; // Spine is Y-up, CSS is Y-down.
|
|
839
|
+
}
|
|
840
|
+
this.clipPolygonLength = count;
|
|
841
|
+
this.clipAttachment = clip;
|
|
842
|
+
this.clipEndSlot = clip.endSlot;
|
|
843
|
+
this.clipInverse = core.inverse(clip);
|
|
844
|
+
this.clipCount++;
|
|
845
|
+
// Whole-skeleton fast path: nothing was drawn before this clip started and
|
|
846
|
+
// it never ends, so every element of this frame is inside it — one
|
|
847
|
+
// clip-path on the root beats one per element. An inverse clip is
|
|
848
|
+
// excluded: its CSS form needs an outer ring around the element's own box,
|
|
849
|
+
// and the root is an origin element with no box to use.
|
|
850
|
+
this.clipOnRoot =
|
|
851
|
+
!this.drewAnything && !this.clipInverse && !endsAhead(drawOrder, index, clip.endSlot);
|
|
852
|
+
if (this.clipOnRoot)
|
|
853
|
+
this.writeRootClip(this.rootClipPath());
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Ends the active clip if this slot is its end slot, called after the slot
|
|
857
|
+
* has been drawn so that the end slot is itself clipped. Every other slot is
|
|
858
|
+
* ignored, exactly as SkeletonClipping.clipEnd ignores it.
|
|
859
|
+
*
|
|
860
|
+
* This cannot fire for a clip on the root fast path: that path is only taken
|
|
861
|
+
* when no slot ahead of the clip carries its end slot.
|
|
862
|
+
*/
|
|
863
|
+
clipEnd(slot) {
|
|
864
|
+
if (this.clipAttachment && this.clipEndSlot === slot.data) {
|
|
865
|
+
this.clipAttachment = null;
|
|
866
|
+
this.clipEndSlot = null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Writes (or clears) this element's clip-path. `a…f` is the element's own
|
|
871
|
+
* CSS transform, and the world polygon is carried through its INVERSE:
|
|
872
|
+
* clip-path is resolved in the element's local frame and then transformed
|
|
873
|
+
* along with the element, so the polygon has to arrive there already.
|
|
874
|
+
*
|
|
875
|
+
* `transform-origin` is `0 0` on every slot element (see view()), so the
|
|
876
|
+
* matrix maps local (0,0) to (e,f) with no origin term to undo, and
|
|
877
|
+
* `left/top: 0` puts the border box — clip-path's reference box — on that
|
|
878
|
+
* same local origin.
|
|
879
|
+
*/
|
|
880
|
+
applyClip(view, a, b, c, d, e, f, boxW, boxH) {
|
|
881
|
+
if (!this.clipAttachment || this.clipOnRoot) {
|
|
882
|
+
this.setClipPath(view, '');
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const det = a * d - b * c;
|
|
886
|
+
// Singular: the element is collapsed onto a line or a point and draws
|
|
887
|
+
// nothing, so there is no clip to express and no style write to pay for.
|
|
888
|
+
if (det === 0)
|
|
889
|
+
return;
|
|
890
|
+
const inv = 1 / det;
|
|
891
|
+
const poly = this.clipPolygon;
|
|
892
|
+
const n = this.clipPolygonLength;
|
|
893
|
+
// An inverse clip shows what is OUTSIDE the polygon. That shape has a hole
|
|
894
|
+
// in it, so it needs TWO rings — an outer one and the polygon — and
|
|
895
|
+
// `polygon()` cannot carry two: it is one closed ring, and listing a box
|
|
896
|
+
// and a polygon inside it just makes one self-intersecting ring whose
|
|
897
|
+
// even-odd fill leaves a wedge of the seam between them (measured, on a
|
|
898
|
+
// corner of spineboy's boot). `path()` takes subpaths, so an inverse clip
|
|
899
|
+
// is written as two of them with the even-odd rule. The outer ring is the
|
|
900
|
+
// element's own box: the element paints nothing outside it anyway, so
|
|
901
|
+
// box-minus-polygon is exactly the visible part of the inverse region.
|
|
902
|
+
const inverse = this.clipInverse;
|
|
903
|
+
let s = inverse
|
|
904
|
+
? `path(evenodd,'M0 0H${clipNum(boxW)}V${clipNum(boxH)}H0Z`
|
|
905
|
+
: 'polygon(';
|
|
906
|
+
for (let v = 0; v < n; v += 2) {
|
|
907
|
+
const dx = poly[v] - e;
|
|
908
|
+
const dy = poly[v + 1] - f;
|
|
909
|
+
const lx = (d * dx - c * dy) * inv;
|
|
910
|
+
const ly = (a * dy - b * dx) * inv;
|
|
911
|
+
// Near-singular matrices blow the inverse up; see CLIP_COORD_LIMIT for
|
|
912
|
+
// why an unwritable coordinate must not become a written-and-ignored one.
|
|
913
|
+
if (!(Math.abs(lx) <= CLIP_COORD_LIMIT) || !(Math.abs(ly) <= CLIP_COORD_LIMIT))
|
|
914
|
+
return;
|
|
915
|
+
if (inverse) {
|
|
916
|
+
s += `${v > 0 ? 'L' : 'M'}${clipNum(lx)} ${clipNum(ly)}`;
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
s += `${v > 0 ? ',' : ''}${clipCoord(lx)} ${clipCoord(ly)}`;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
this.setClipPath(view, inverse ? `${s}Z')` : `${s})`);
|
|
923
|
+
}
|
|
924
|
+
setClipPath(view, clipPath) {
|
|
925
|
+
if (view.clipPath === clipPath)
|
|
926
|
+
return;
|
|
927
|
+
view.clipPath = clipPath;
|
|
928
|
+
view.el.style.clipPath = clipPath;
|
|
929
|
+
this.clipWriteCount++;
|
|
930
|
+
}
|
|
931
|
+
/** The active polygon in the root's own frame — no element transform. */
|
|
932
|
+
rootClipPath() {
|
|
933
|
+
const poly = this.clipPolygon;
|
|
934
|
+
let s = 'polygon(';
|
|
935
|
+
for (let v = 0, n = this.clipPolygonLength; v < n; v += 2) {
|
|
936
|
+
if (v > 0)
|
|
937
|
+
s += ',';
|
|
938
|
+
s += `${clipCoord(poly[v])} ${clipCoord(poly[v + 1])}`;
|
|
939
|
+
}
|
|
940
|
+
return `${s})`;
|
|
941
|
+
}
|
|
942
|
+
writeRootClip(clipPath) {
|
|
943
|
+
// Borrow the caller's inline value once, so releaseRootClip gives back
|
|
944
|
+
// exactly what was there. Reading `.style` is CSSOM, not a layout read.
|
|
945
|
+
if (this.rootClipSaved === null)
|
|
946
|
+
this.rootClipSaved = this.root.style.clipPath;
|
|
947
|
+
if (this.rootClipWritten === clipPath)
|
|
948
|
+
return;
|
|
949
|
+
this.rootClipWritten = clipPath;
|
|
950
|
+
this.root.style.clipPath = clipPath;
|
|
951
|
+
this.clipWriteCount++;
|
|
952
|
+
}
|
|
953
|
+
releaseRootClip() {
|
|
954
|
+
if (this.rootClipSaved === null)
|
|
955
|
+
return;
|
|
956
|
+
this.root.style.clipPath = this.rootClipSaved;
|
|
957
|
+
this.rootClipSaved = null;
|
|
958
|
+
this.rootClipWritten = '';
|
|
959
|
+
this.clipWriteCount++;
|
|
960
|
+
}
|
|
642
961
|
// --- shared plumbing -------------------------------------------------------
|
|
643
962
|
setTransform(view, transform) {
|
|
644
963
|
if (view.transform !== transform) {
|
|
@@ -647,6 +966,9 @@ export class SpineHtmlRenderer {
|
|
|
647
966
|
}
|
|
648
967
|
}
|
|
649
968
|
applyCommon(view, slot, pose, attachmentColor, skeleton, zIndex) {
|
|
969
|
+
// This element is about to be visible: the whole-skeleton fast path is
|
|
970
|
+
// only open to a clip that starts before anything has been drawn.
|
|
971
|
+
this.drewAnything = true;
|
|
650
972
|
const sc = skeleton.color;
|
|
651
973
|
const pc = pose.color;
|
|
652
974
|
if (view.zIndex !== zIndex) {
|
|
@@ -739,6 +1061,7 @@ export class SpineHtmlRenderer {
|
|
|
739
1061
|
opacity: 1,
|
|
740
1062
|
blendMode: BlendMode.Normal,
|
|
741
1063
|
transform: '',
|
|
1064
|
+
clipPath: '',
|
|
742
1065
|
tintR: 1,
|
|
743
1066
|
tintG: 1,
|
|
744
1067
|
tintB: 1,
|
|
@@ -761,10 +1084,17 @@ export class SpineHtmlRenderer {
|
|
|
761
1084
|
}
|
|
762
1085
|
hide(slot) {
|
|
763
1086
|
const view = this.views.get(slot);
|
|
764
|
-
if (view
|
|
1087
|
+
if (!view)
|
|
1088
|
+
return;
|
|
1089
|
+
if (view.visible) {
|
|
765
1090
|
view.visible = false;
|
|
766
1091
|
view.el.style.display = 'none';
|
|
767
1092
|
}
|
|
1093
|
+
// A hidden element keeps no clip-path: one left behind would sit there
|
|
1094
|
+
// stale until the slot draws again, and "nothing is clipped" has to be
|
|
1095
|
+
// true of the DOM, not only of what gets painted. Cached like every other
|
|
1096
|
+
// write, so the sweep costs one write per element, once.
|
|
1097
|
+
this.setClipPath(view, '');
|
|
768
1098
|
}
|
|
769
1099
|
}
|
|
770
1100
|
//# sourceMappingURL=SpineHtmlRenderer.js.map
|