@oh-just-another/renderer-canvas 0.59.0 → 0.60.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/CHANGELOG.md +54 -0
- package/README.md +3 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/canvas-target.d.ts +6 -1
- package/dist/canvas-target.d.ts.map +1 -1
- package/dist/canvas-target.js +12 -2
- package/dist/canvas-target.js.map +1 -1
- package/dist/constants.d.ts +49 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +49 -0
- package/dist/constants.js.map +1 -1
- package/dist/hi-dpi.d.ts +10 -0
- package/dist/hi-dpi.d.ts.map +1 -1
- package/dist/hi-dpi.js +12 -1
- package/dist/hi-dpi.js.map +1 -1
- package/dist/image-source.d.ts +11 -0
- package/dist/image-source.d.ts.map +1 -1
- package/dist/image-source.js +20 -0
- package/dist/image-source.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/layered-canvas.d.ts +7 -1
- package/dist/layered-canvas.d.ts.map +1 -1
- package/dist/layered-canvas.js +6 -3
- package/dist/layered-canvas.js.map +1 -1
- package/dist/layered-surface.d.ts +16 -0
- package/dist/layered-surface.d.ts.map +1 -1
- package/dist/layered-surface.js +50 -13
- package/dist/layered-surface.js.map +1 -1
- package/dist/recording-target.d.ts +36 -12
- package/dist/recording-target.d.ts.map +1 -1
- package/dist/recording-target.js +159 -33
- package/dist/recording-target.js.map +1 -1
- package/dist/tile-compositor.d.ts +43 -1
- package/dist/tile-compositor.d.ts.map +1 -1
- package/dist/tile-compositor.js +48 -5
- package/dist/tile-compositor.js.map +1 -1
- package/dist/webgl2-curve.d.ts +11 -0
- package/dist/webgl2-curve.d.ts.map +1 -1
- package/dist/webgl2-curve.js +147 -32
- package/dist/webgl2-curve.js.map +1 -1
- package/dist/webgl2-ellipse.d.ts +0 -27
- package/dist/webgl2-ellipse.d.ts.map +1 -1
- package/dist/webgl2-ellipse.js +17 -12
- package/dist/webgl2-ellipse.js.map +1 -1
- package/dist/webgl2-msdf-text.d.ts +14 -0
- package/dist/webgl2-msdf-text.d.ts.map +1 -1
- package/dist/webgl2-msdf-text.js +80 -1
- package/dist/webgl2-msdf-text.js.map +1 -1
- package/dist/webgl2-rect-batch.d.ts +81 -0
- package/dist/webgl2-rect-batch.d.ts.map +1 -0
- package/dist/webgl2-rect-batch.js +201 -0
- package/dist/webgl2-rect-batch.js.map +1 -0
- package/dist/webgl2-stroke.d.ts +6 -30
- package/dist/webgl2-stroke.d.ts.map +1 -1
- package/dist/webgl2-stroke.js +16 -24
- package/dist/webgl2-stroke.js.map +1 -1
- package/dist/webgl2-target.d.ts +63 -7
- package/dist/webgl2-target.d.ts.map +1 -1
- package/dist/webgl2-target.js +308 -193
- package/dist/webgl2-target.js.map +1 -1
- package/package.json +4 -3
package/dist/webgl2-target.js
CHANGED
|
@@ -5,12 +5,13 @@ import { resolveBundledFamily } from "@oh-just-another/fonts";
|
|
|
5
5
|
import earcut from "earcut";
|
|
6
6
|
import { parseWebGL2Color } from "./webgl2-color.js";
|
|
7
7
|
import { ELLIPSE_MAX_SEGMENTS, ELLIPSE_MIN_SEGMENTS, WEBGL2_IMAGE_TEXTURE_CACHE_CAP, WEBGL2_TEXT_BITMAP_CACHE_CAP, } from "./constants.js";
|
|
8
|
-
import { MsdfTextPipeline } from "./webgl2-msdf-text.js";
|
|
8
|
+
import { MsdfTextPipeline, measureGlyphRunEm } from "./webgl2-msdf-text.js";
|
|
9
9
|
import { drawPolylineStroke as drawPolylineStrokeImpl } from "./webgl2-stroke.js";
|
|
10
10
|
import { LoopBlinnCurvePipeline } from "./webgl2-curve.js";
|
|
11
11
|
import { EllipsePipeline } from "./webgl2-ellipse.js";
|
|
12
12
|
import { isDrawableImageSource, warnSkippedImage } from "./image-source.js";
|
|
13
13
|
import { compileShader, glReq, linkProgram } from "./webgl-helpers.js";
|
|
14
|
+
import { RectBatch, RectInstancePipeline } from "./webgl2-rect-batch.js";
|
|
14
15
|
/**
|
|
15
16
|
* WebGL2 RenderTarget. Implements clear, transform/state stack, path
|
|
16
17
|
* primitives (rect / polyline / ellipse / Bezier), fill, stroke, text
|
|
@@ -65,10 +66,27 @@ export class WebGL2Target {
|
|
|
65
66
|
textAlign = "left";
|
|
66
67
|
textBaseline = "top";
|
|
67
68
|
/**
|
|
68
|
-
* Polyline path being assembled by moveTo / lineTo
|
|
69
|
-
* `
|
|
69
|
+
* Polyline path being assembled by moveTo / lineTo, as a flat
|
|
70
|
+
* growable `[x0, y0, x1, y1, ...]` buffer with a point-count cursor —
|
|
71
|
+
* no per-vertex `{x, y}` object churn on the hot path-building path.
|
|
72
|
+
* Cleared on `beginPath()`; consumed by `fill()` / `stroke()`.
|
|
73
|
+
* Per-instance (not module-level) so interleaved path building on two
|
|
74
|
+
* targets can't stomp each other. Capacity ratchets up, never shrinks.
|
|
70
75
|
*/
|
|
71
|
-
|
|
76
|
+
pathXY = new Float64Array(INITIAL_PATH_CAPACITY * 2);
|
|
77
|
+
/** Number of (x, y) points currently in `pathXY`. */
|
|
78
|
+
pathPts = 0;
|
|
79
|
+
/** Append one point to the flat path buffer, growing it if needed. */
|
|
80
|
+
pushPathPoint(x, y) {
|
|
81
|
+
if ((this.pathPts + 1) * 2 > this.pathXY.length) {
|
|
82
|
+
const next = new Float64Array(this.pathXY.length * 2);
|
|
83
|
+
next.set(this.pathXY);
|
|
84
|
+
this.pathXY = next;
|
|
85
|
+
}
|
|
86
|
+
this.pathXY[this.pathPts * 2] = x;
|
|
87
|
+
this.pathXY[this.pathPts * 2 + 1] = y;
|
|
88
|
+
this.pathPts++;
|
|
89
|
+
}
|
|
72
90
|
/**
|
|
73
91
|
* Curve segments collected since the last `beginPath()`. Quadratic
|
|
74
92
|
* and cubic Bezier `*CurveTo` calls push here in addition to pushing
|
|
@@ -96,24 +114,47 @@ export class WebGL2Target {
|
|
|
96
114
|
* snapshot the path either.
|
|
97
115
|
*/
|
|
98
116
|
stack = [];
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Sharp-rect fill batcher — coalesces consecutive axis-aligned
|
|
119
|
+
* `rect()` + `fill()` calls into one `drawArraysInstanced` (B19). Any
|
|
120
|
+
* non-batchable draw (`stroke`, ellipse / polygon / curve fill, image,
|
|
121
|
+
* text) and every surface op (`clear`, `resize`, frame `present`)
|
|
122
|
+
* drain it first via {@link flushRectBatch}, so submission order —
|
|
123
|
+
* hence z-order — is preserved. The GL pipeline is created lazily on
|
|
124
|
+
* the first flush; a scene with no sharp rects never allocates it.
|
|
125
|
+
*/
|
|
126
|
+
rectBatch = new RectBatch();
|
|
127
|
+
rectPipeline = null;
|
|
128
|
+
constructor(canvas, width, height, options = {}) {
|
|
129
|
+
// `preserveDrawingBuffer` keeps the drawing buffer across composites.
|
|
130
|
+
// It defaults to `true` because the interactive editor renders
|
|
131
|
+
// incrementally: `renderScene` clears + redraws only the dirty rect
|
|
132
|
+
// each frame (see the dirty-rect logic in renderer-core /
|
|
133
|
+
// render-orchestrator) and relies on the rest of the previous frame
|
|
134
|
+
// surviving. When this flag is `false` the spec permits the browser
|
|
135
|
+
// to wipe the buffer after each composite, so everything outside the
|
|
136
|
+
// dirty rect disappears in the steady state.
|
|
105
137
|
//
|
|
138
|
+
// This is NOT for readback: PNG export / screenshots render through a
|
|
139
|
+
// separate offscreen Canvas2D target (`createOffscreenCanvas2DTarget`
|
|
140
|
+
// in `png-export` / `exporter` / `tile-compositor`), never this live
|
|
141
|
+
// context, so they don't depend on the flag.
|
|
142
|
+
//
|
|
143
|
+
// On Safari / iOS `true` forces a full re-composite per swap. A host
|
|
144
|
+
// that redraws the whole frame every time (dirty-rect culling
|
|
145
|
+
// disabled) can pass `preserveDrawingBuffer: false` for that win.
|
|
146
|
+
const preserveDrawingBuffer = options.preserveDrawingBuffer ?? true;
|
|
106
147
|
// Try with antialiasing first; some integrated GPUs deny the
|
|
107
148
|
// context when MSAA isn't available. Retry plain on failure so
|
|
108
149
|
// WebGL2 isn't lost entirely for a stylistic preference.
|
|
109
150
|
let gl = canvas.getContext("webgl2", {
|
|
110
151
|
antialias: true,
|
|
111
152
|
premultipliedAlpha: true,
|
|
112
|
-
preserveDrawingBuffer
|
|
153
|
+
preserveDrawingBuffer,
|
|
113
154
|
});
|
|
114
155
|
gl ??= canvas.getContext("webgl2", {
|
|
115
156
|
premultipliedAlpha: true,
|
|
116
|
-
preserveDrawingBuffer
|
|
157
|
+
preserveDrawingBuffer,
|
|
117
158
|
});
|
|
118
159
|
if (!gl) {
|
|
119
160
|
throw new Error("WebGL2 unavailable in this environment (probably hit the per-page GL context cap; " +
|
|
@@ -164,6 +205,9 @@ export class WebGL2Target {
|
|
|
164
205
|
* in sync so downstream renderers see the new dimensions.
|
|
165
206
|
*/
|
|
166
207
|
resize(width, height) {
|
|
208
|
+
// Queued instances carry a clip-space matrix projected against the
|
|
209
|
+
// old size; drain them before the size / viewport change.
|
|
210
|
+
this.flushRectBatch();
|
|
167
211
|
this._size.width = width;
|
|
168
212
|
this._size.height = height;
|
|
169
213
|
this.gl.viewport(0, 0, width, height);
|
|
@@ -175,6 +219,13 @@ export class WebGL2Target {
|
|
|
175
219
|
* and runtime backend switches quickly hit the cap.
|
|
176
220
|
*/
|
|
177
221
|
dispose() {
|
|
222
|
+
// Drop any undrawn queued rects and release the instance pipeline's
|
|
223
|
+
// GL resources (VAO / buffers / program).
|
|
224
|
+
this.rectBatch.reset();
|
|
225
|
+
if (this.rectPipeline) {
|
|
226
|
+
this.rectPipeline.dispose();
|
|
227
|
+
this.rectPipeline = null;
|
|
228
|
+
}
|
|
178
229
|
if (this.msdfPipeline) {
|
|
179
230
|
this.msdfPipeline.dispose();
|
|
180
231
|
this.msdfPipeline = null;
|
|
@@ -301,7 +352,7 @@ export class WebGL2Target {
|
|
|
301
352
|
// --- Path primitives ---
|
|
302
353
|
beginPath() {
|
|
303
354
|
this.currentPath = null;
|
|
304
|
-
this.
|
|
355
|
+
this.pathPts = 0;
|
|
305
356
|
this.currentCurves = [];
|
|
306
357
|
this.currentEllipse = null;
|
|
307
358
|
}
|
|
@@ -317,15 +368,15 @@ export class WebGL2Target {
|
|
|
317
368
|
}
|
|
318
369
|
_pathRect = { x: 0, y: 0, width: 0, height: 0 };
|
|
319
370
|
moveTo(x, y) {
|
|
320
|
-
this.
|
|
371
|
+
this.pathPts = 0;
|
|
372
|
+
this.pushPathPoint(x, y);
|
|
321
373
|
}
|
|
322
374
|
lineTo(x, y) {
|
|
323
|
-
this.
|
|
375
|
+
this.pushPathPoint(x, y);
|
|
324
376
|
}
|
|
325
377
|
closePath() {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
this.currentPolyline.push({ ...start });
|
|
378
|
+
if (this.pathPts > 1) {
|
|
379
|
+
this.pushPathPoint(req(this.pathXY[0]), req(this.pathXY[1]));
|
|
329
380
|
}
|
|
330
381
|
}
|
|
331
382
|
/**
|
|
@@ -337,7 +388,7 @@ export class WebGL2Target {
|
|
|
337
388
|
*/
|
|
338
389
|
ellipse(cx, cy, rx, ry) {
|
|
339
390
|
this.currentEllipse = { cx, cy, rx, ry };
|
|
340
|
-
this.
|
|
391
|
+
this.pathPts = 0;
|
|
341
392
|
this.currentPath = null;
|
|
342
393
|
}
|
|
343
394
|
/**
|
|
@@ -349,13 +400,10 @@ export class WebGL2Target {
|
|
|
349
400
|
const scale = Math.hypot(this.transform.a, this.transform.b);
|
|
350
401
|
const screenRadius = Math.max(e.rx, e.ry) * (Number.isFinite(scale) && scale > 0 ? scale : 1);
|
|
351
402
|
const segments = Math.max(ELLIPSE_MIN_SEGMENTS, Math.min(ELLIPSE_MAX_SEGMENTS, Math.ceil(Math.PI * screenRadius * 0.7)));
|
|
352
|
-
this.
|
|
403
|
+
this.pathPts = 0;
|
|
353
404
|
for (let i = 0; i <= segments; i++) {
|
|
354
405
|
const t = (i / segments) * Math.PI * 2;
|
|
355
|
-
this.
|
|
356
|
-
x: e.cx + e.rx * Math.cos(t),
|
|
357
|
-
y: e.cy + e.ry * Math.sin(t),
|
|
358
|
-
});
|
|
406
|
+
this.pushPathPoint(e.cx + e.rx * Math.cos(t), e.cy + e.ry * Math.sin(t));
|
|
359
407
|
}
|
|
360
408
|
}
|
|
361
409
|
/**
|
|
@@ -371,7 +419,7 @@ export class WebGL2Target {
|
|
|
371
419
|
* (sub-pixel zoom-aware).
|
|
372
420
|
*/
|
|
373
421
|
quadraticCurveTo(cx, cy, x, y) {
|
|
374
|
-
const start = this.
|
|
422
|
+
const start = this.lastPathPoint() ?? { x: cx, y: cy };
|
|
375
423
|
this.currentCurves.push({
|
|
376
424
|
kind: "q",
|
|
377
425
|
points: [start, { x: cx, y: cy }, { x, y }],
|
|
@@ -385,18 +433,24 @@ export class WebGL2Target {
|
|
|
385
433
|
{ kind: "M", to: start },
|
|
386
434
|
{ kind: "Q", control: { x: cx, y: cy }, to: { x, y } },
|
|
387
435
|
], tolerance);
|
|
388
|
-
for (let i = 1; i < pts.length; i++)
|
|
389
|
-
|
|
436
|
+
for (let i = 1; i < pts.length; i++) {
|
|
437
|
+
const p = req(pts[i]);
|
|
438
|
+
this.pushPathPoint(p.x, p.y);
|
|
439
|
+
}
|
|
390
440
|
return;
|
|
391
441
|
}
|
|
392
|
-
const count = Math.max(8, Math.min(128, Math.ceil(curveLengthEstimate(start,
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
442
|
+
const count = Math.max(8, Math.min(128, Math.ceil(curveLengthEstimate(start.x, start.y, x, y) / tolerance)));
|
|
443
|
+
// Sample t in (0, 1] directly into the flat path buffer — the
|
|
444
|
+
// start point (t = 0) is already the path's last vertex.
|
|
445
|
+
for (let i = 1; i <= count; i++) {
|
|
446
|
+
const t = i / count;
|
|
447
|
+
const u = 1 - t;
|
|
448
|
+
this.pushPathPoint(u * u * start.x + 2 * u * t * cx + t * t * x, u * u * start.y + 2 * u * t * cy + t * t * y);
|
|
449
|
+
}
|
|
396
450
|
}
|
|
397
451
|
/** Cubic Bezier — same dual-track approach as quadratic. */
|
|
398
452
|
bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
|
|
399
|
-
const start = this.
|
|
453
|
+
const start = this.lastPathPoint() ?? { x, y };
|
|
400
454
|
this.currentCurves.push({
|
|
401
455
|
kind: "c",
|
|
402
456
|
points: [start, { x: c1x, y: c1y }, { x: c2x, y: c2y }, { x, y }],
|
|
@@ -413,14 +467,32 @@ export class WebGL2Target {
|
|
|
413
467
|
to: { x, y },
|
|
414
468
|
},
|
|
415
469
|
], tolerance);
|
|
416
|
-
for (let i = 1; i < pts.length; i++)
|
|
417
|
-
|
|
470
|
+
for (let i = 1; i < pts.length; i++) {
|
|
471
|
+
const p = req(pts[i]);
|
|
472
|
+
this.pushPathPoint(p.x, p.y);
|
|
473
|
+
}
|
|
418
474
|
return;
|
|
419
475
|
}
|
|
420
|
-
const count = Math.max(12, Math.min(192, Math.ceil(curveLengthEstimate(start,
|
|
421
|
-
|
|
422
|
-
for (let i = 1; i
|
|
423
|
-
|
|
476
|
+
const count = Math.max(12, Math.min(192, Math.ceil(curveLengthEstimate(start.x, start.y, x, y) / tolerance)));
|
|
477
|
+
// Sample t in (0, 1] directly into the flat path buffer.
|
|
478
|
+
for (let i = 1; i <= count; i++) {
|
|
479
|
+
const t = i / count;
|
|
480
|
+
const u = 1 - t;
|
|
481
|
+
const u2 = u * u;
|
|
482
|
+
const u3 = u2 * u;
|
|
483
|
+
const t2 = t * t;
|
|
484
|
+
const t3 = t2 * t;
|
|
485
|
+
this.pushPathPoint(u3 * start.x + 3 * u2 * t * c1x + 3 * u * t2 * c2x + t3 * x, u3 * start.y + 3 * u2 * t * c1y + 3 * u * t2 * c2y + t3 * y);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** Last point of the flat path buffer as a fresh `{x, y}`, or `undefined`. */
|
|
489
|
+
lastPathPoint() {
|
|
490
|
+
if (this.pathPts === 0)
|
|
491
|
+
return undefined;
|
|
492
|
+
return {
|
|
493
|
+
x: req(this.pathXY[(this.pathPts - 1) * 2]),
|
|
494
|
+
y: req(this.pathXY[(this.pathPts - 1) * 2 + 1]),
|
|
495
|
+
};
|
|
424
496
|
}
|
|
425
497
|
/**
|
|
426
498
|
* World-unit tolerance that maps to ~`SCREEN_TOLERANCE_PX` on screen
|
|
@@ -443,7 +515,8 @@ export class WebGL2Target {
|
|
|
443
515
|
* as a textured quad via a dedicated program created lazily on the
|
|
444
516
|
* first image call.
|
|
445
517
|
*/
|
|
446
|
-
drawImage(image, dx, dy, dw, dh, dynamic) {
|
|
518
|
+
drawImage(image, dx, dy, dw, dh, dynamic, crop) {
|
|
519
|
+
this.flushRectBatch(); // preserve z-order: emit queued rect fills first
|
|
447
520
|
const tex = this.textureFor(image, dynamic ?? false);
|
|
448
521
|
if (!tex)
|
|
449
522
|
return;
|
|
@@ -464,7 +537,7 @@ export class WebGL2Target {
|
|
|
464
537
|
this.gl.enableVertexAttribArray(ip.aUV);
|
|
465
538
|
this.gl.vertexAttribPointer(ip.aUV, 2, this.gl.FLOAT, false, 16, 8);
|
|
466
539
|
// Project the drawn region through transform + viewport.
|
|
467
|
-
const projected =
|
|
540
|
+
const projected = applyMat({
|
|
468
541
|
a: this.transform.a * dw,
|
|
469
542
|
b: this.transform.b * dw,
|
|
470
543
|
c: this.transform.c * dh,
|
|
@@ -474,6 +547,17 @@ export class WebGL2Target {
|
|
|
474
547
|
}, this._size.width, this._size.height);
|
|
475
548
|
this.gl.uniformMatrix3fv(ip.uTransform, false, projected);
|
|
476
549
|
this.gl.uniform1f(ip.uOpacity, this.opacity);
|
|
550
|
+
// Crop as a UV sub-rect. Identity (no crop) is offset (0,0), scale (1,1);
|
|
551
|
+
// crop fractions are already in texture-UV [0,1] space (unlike Canvas2D,
|
|
552
|
+
// which multiplies by intrinsic pixels), so they map straight to uniforms.
|
|
553
|
+
if (crop && (crop.x !== 0 || crop.y !== 0 || crop.width !== 1 || crop.height !== 1)) {
|
|
554
|
+
this.gl.uniform2f(ip.uUvOffset, crop.x, crop.y);
|
|
555
|
+
this.gl.uniform2f(ip.uUvScale, crop.width, crop.height);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
this.gl.uniform2f(ip.uUvOffset, 0, 0);
|
|
559
|
+
this.gl.uniform2f(ip.uUvScale, 1, 1);
|
|
560
|
+
}
|
|
477
561
|
this.gl.activeTexture(this.gl.TEXTURE0);
|
|
478
562
|
this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
|
|
479
563
|
this.gl.uniform1i(ip.uTex, 0);
|
|
@@ -578,6 +662,7 @@ export class WebGL2Target {
|
|
|
578
662
|
// Ellipse path — single fragment-SDF quad regardless of radius.
|
|
579
663
|
// Vector-perfect at any zoom; 4 vertices instead of 24-512.
|
|
580
664
|
if (this.currentEllipse) {
|
|
665
|
+
this.flushRectBatch(); // preserve z-order: emit queued rects first
|
|
581
666
|
this.ellipsePipeline ??= new EllipsePipeline(this.gl);
|
|
582
667
|
const e = this.currentEllipse;
|
|
583
668
|
this.ellipsePipeline.draw(e.cx, e.cy, e.rx, e.ry, this.fillColor, effectiveAlpha, this.transform, this._size);
|
|
@@ -589,27 +674,30 @@ export class WebGL2Target {
|
|
|
589
674
|
// most shape backgrounds (rectangles) hit it.
|
|
590
675
|
if (this.currentPath) {
|
|
591
676
|
const r = this.currentPath;
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
677
|
+
// Project the rect's unit-quad → clip affine, then queue it as one
|
|
678
|
+
// instance instead of issuing a draw. Same math as `applyMat`
|
|
679
|
+
// (unit-quad → NDC) inlined into scalars — the batcher stores the
|
|
680
|
+
// two variable columns + translation; the constant [0,0,1] third
|
|
681
|
+
// column is reconstructed in the instance vertex shader.
|
|
682
|
+
const t = this.transform;
|
|
683
|
+
const sx = 2 / this._size.width;
|
|
684
|
+
const sy = -2 / this._size.height;
|
|
685
|
+
const pa = t.a * r.width;
|
|
686
|
+
const pb = t.b * r.width;
|
|
687
|
+
const pc = t.c * r.height;
|
|
688
|
+
const pd = t.d * r.height;
|
|
689
|
+
const pe = t.e + t.a * r.x + t.c * r.y;
|
|
690
|
+
const pf = t.f + t.b * r.x + t.d * r.y;
|
|
691
|
+
this.rectBatch.add(pa * sx, pb * sy, pc * sx, pd * sy, pe * sx - 1, pf * sy + 1, this.fillColor[0], this.fillColor[1], this.fillColor[2], effectiveAlpha);
|
|
605
692
|
return;
|
|
606
693
|
}
|
|
607
694
|
// Polygon path — assembled via moveTo / lineTo / bezierCurveTo.
|
|
608
695
|
// Triangulated through earcut so concave shapes (arrows, stars,
|
|
609
696
|
// lightning bolts) fill correctly. Earcut is dependency-free and
|
|
610
697
|
// handles holes too if ever needed.
|
|
611
|
-
if (this.
|
|
612
|
-
this.
|
|
698
|
+
if (this.pathPts >= 3) {
|
|
699
|
+
this.flushRectBatch(); // preserve z-order: emit queued rects first
|
|
700
|
+
this.fillPolygonEarcut(this.pathXY, this.pathPts, effectiveAlpha);
|
|
613
701
|
}
|
|
614
702
|
// Loop-Blinn curve overlay. Adds fragment-tested quadratic / cubic
|
|
615
703
|
// triangles on top of the polygon fill so curve regions
|
|
@@ -626,6 +714,7 @@ export class WebGL2Target {
|
|
|
626
714
|
// doesn't have. The artefact is invisible at 1× zoom and tiny even
|
|
627
715
|
// at 20×.
|
|
628
716
|
if (this.currentCurves.length > 0) {
|
|
717
|
+
this.flushRectBatch(); // preserve z-order: emit queued rects first
|
|
629
718
|
this.curvePipeline ??= new LoopBlinnCurvePipeline(this.gl);
|
|
630
719
|
this.curvePipeline.draw(this.currentCurves, this.fillColor, effectiveAlpha, this.transform, this._size);
|
|
631
720
|
this.restoreSolidProgram();
|
|
@@ -640,47 +729,36 @@ export class WebGL2Target {
|
|
|
640
729
|
* earcut returns an empty index list (degenerate self-intersecting
|
|
641
730
|
* polygon).
|
|
642
731
|
*/
|
|
643
|
-
fillPolygonEarcut(
|
|
732
|
+
fillPolygonEarcut(xy, pointCount, effectiveAlpha) {
|
|
644
733
|
// Skip the implicitly-closed duplicate last vertex if the caller
|
|
645
734
|
// already issued `closePath` — earcut would treat it as a degenerate
|
|
646
735
|
// sliver.
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
polyFirst.y === polyLast.y
|
|
653
|
-
? polyline.length - 1
|
|
654
|
-
: polyline.length;
|
|
736
|
+
const n = pointCount >= 4 &&
|
|
737
|
+
xy[0] === xy[(pointCount - 1) * 2] &&
|
|
738
|
+
xy[1] === xy[(pointCount - 1) * 2 + 1]
|
|
739
|
+
? pointCount - 1
|
|
740
|
+
: pointCount;
|
|
655
741
|
if (n < 3)
|
|
656
742
|
return;
|
|
657
|
-
// earcut wants a flat [x0, y0, x1, y1, ...] in world coords
|
|
658
|
-
//
|
|
659
|
-
// with [i] + length, so a
|
|
660
|
-
|
|
661
|
-
const flat = scratchEarcutFlat;
|
|
662
|
-
for (let i = 0; i < n; i++) {
|
|
663
|
-
const p = req(polyline[i]);
|
|
664
|
-
flat[i * 2] = p.x;
|
|
665
|
-
flat[i * 2 + 1] = p.y;
|
|
666
|
-
}
|
|
667
|
-
// Pass only the populated prefix — `subarray` is a view, no copy.
|
|
668
|
-
const indices = earcut(flat.subarray(0, n * 2));
|
|
743
|
+
// earcut wants a flat [x0, y0, x1, y1, ...] in world coords — the
|
|
744
|
+
// path buffer already is one, and earcut accepts any array-like
|
|
745
|
+
// with [i] + length, so pass a no-copy `subarray` view directly.
|
|
746
|
+
const indices = earcut(xy.subarray(0, n * 2));
|
|
669
747
|
if (indices.length === 0) {
|
|
670
748
|
// Pathological polygon — fall back to a fan so something renders.
|
|
671
|
-
this.drawTriangleFan(
|
|
749
|
+
this.drawTriangleFan(xy, n, effectiveAlpha);
|
|
672
750
|
return;
|
|
673
751
|
}
|
|
674
|
-
// Project once into clip space, then index-draw.
|
|
675
|
-
|
|
676
|
-
// grew `scratchEarcutVerts` if needed.
|
|
752
|
+
// Project once into clip space, then index-draw.
|
|
753
|
+
ensureEarcutVertexCapacity(n);
|
|
677
754
|
const sx = 2 / this._size.width;
|
|
678
755
|
const sy = -2 / this._size.height;
|
|
679
756
|
const verts = scratchEarcutVerts;
|
|
680
757
|
for (let i = 0; i < n; i++) {
|
|
681
|
-
const
|
|
682
|
-
const
|
|
683
|
-
const
|
|
758
|
+
const px = req(xy[i * 2]);
|
|
759
|
+
const py = req(xy[i * 2 + 1]);
|
|
760
|
+
const wx = this.transform.a * px + this.transform.c * py + this.transform.e;
|
|
761
|
+
const wy = this.transform.b * px + this.transform.d * py + this.transform.f;
|
|
684
762
|
verts[i * 2] = wx * sx - 1;
|
|
685
763
|
verts[i * 2 + 1] = wy * sy + 1;
|
|
686
764
|
}
|
|
@@ -718,16 +796,17 @@ export class WebGL2Target {
|
|
|
718
796
|
* convex polygons correctly; concave ones get a wrong silhouette (the
|
|
719
797
|
* earcut path handles those instead).
|
|
720
798
|
*/
|
|
721
|
-
drawTriangleFan(
|
|
799
|
+
drawTriangleFan(xy, n, effectiveAlpha) {
|
|
722
800
|
const sx = 2 / this._size.width;
|
|
723
801
|
const sy = -2 / this._size.height;
|
|
724
802
|
// Share the module-level scratch verts with `fillPolygonEarcut`.
|
|
725
803
|
ensureEarcutVertexCapacity(n);
|
|
726
804
|
const verts = scratchEarcutVerts;
|
|
727
805
|
for (let i = 0; i < n; i++) {
|
|
728
|
-
const
|
|
729
|
-
const
|
|
730
|
-
const
|
|
806
|
+
const px = req(xy[i * 2]);
|
|
807
|
+
const py = req(xy[i * 2 + 1]);
|
|
808
|
+
const wx = this.transform.a * px + this.transform.c * py + this.transform.e;
|
|
809
|
+
const wy = this.transform.b * px + this.transform.d * py + this.transform.f;
|
|
731
810
|
verts[i * 2] = wx * sx - 1;
|
|
732
811
|
verts[i * 2 + 1] = wy * sy + 1;
|
|
733
812
|
}
|
|
@@ -748,9 +827,9 @@ export class WebGL2Target {
|
|
|
748
827
|
* `bounds` wipes only the rectangle the editor's dirty-rect pass
|
|
749
828
|
* identified. Honouring `bounds` is mandatory — when the scene
|
|
750
829
|
* reference doesn't change, the editor sends a zero-area dirty rect
|
|
751
|
-
* and expects the previous frame to survive untouched.
|
|
830
|
+
* and expects the previous frame to survive untouched. The default
|
|
752
831
|
* `preserveDrawingBuffer: true` carries the persistent frame across
|
|
753
|
-
* composites.
|
|
832
|
+
* composites (see the constructor for the opt-out).
|
|
754
833
|
*
|
|
755
834
|
* For bounded clears the implementation flips on a scissor box so the
|
|
756
835
|
* clear is confined to the dirty rect, mirroring Canvas2D's
|
|
@@ -759,6 +838,10 @@ export class WebGL2Target {
|
|
|
759
838
|
* caller's top-left CSS-pixel rect.
|
|
760
839
|
*/
|
|
761
840
|
clear(bounds) {
|
|
841
|
+
// Drain queued rect fills before wiping pixels — a clear that lands
|
|
842
|
+
// mid-stream must not erase rects queued after it, nor let them
|
|
843
|
+
// survive a wipe meant to cover them.
|
|
844
|
+
this.flushRectBatch();
|
|
762
845
|
const bitmapW = this.gl.canvas.width;
|
|
763
846
|
const bitmapH = this.gl.canvas.height;
|
|
764
847
|
if (bounds) {
|
|
@@ -794,51 +877,69 @@ export class WebGL2Target {
|
|
|
794
877
|
this.strokeWidth = Math.max(0, width);
|
|
795
878
|
}
|
|
796
879
|
stroke() {
|
|
880
|
+
this.flushRectBatch(); // preserve z-order: emit queued rect fills first
|
|
797
881
|
if (this.currentPath) {
|
|
798
882
|
// Rect outline → 4 corners as a closed polyline.
|
|
799
883
|
const r = this.currentPath;
|
|
800
|
-
this.
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
];
|
|
884
|
+
this.pathPts = 0;
|
|
885
|
+
this.pushPathPoint(r.x, r.y);
|
|
886
|
+
this.pushPathPoint(r.x + r.width, r.y);
|
|
887
|
+
this.pushPathPoint(r.x + r.width, r.y + r.height);
|
|
888
|
+
this.pushPathPoint(r.x, r.y + r.height);
|
|
889
|
+
this.pushPathPoint(r.x, r.y);
|
|
807
890
|
}
|
|
808
891
|
// Ellipse outline — lazily generate the polyline approximation here
|
|
809
892
|
// so callers that only fill don't pay for the 24-512 vertex
|
|
810
893
|
// allocation. EllipsePipeline owns the fill path; stroke still goes
|
|
811
894
|
// through the polygon stroke pipeline.
|
|
812
|
-
if (this.currentEllipse && this.
|
|
895
|
+
if (this.currentEllipse && this.pathPts < 2) {
|
|
813
896
|
this.buildEllipseStrokePolyline(this.currentEllipse);
|
|
814
897
|
}
|
|
815
|
-
if (this.
|
|
898
|
+
if (this.pathPts < 2)
|
|
816
899
|
return;
|
|
817
900
|
const effectiveAlpha = this.opacity * this.strokeAlpha;
|
|
818
901
|
if (effectiveAlpha <= 0)
|
|
819
902
|
return; // transparent stroke — nothing to draw
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
}
|
|
903
|
+
const style = {
|
|
904
|
+
width: this.strokeWidth,
|
|
905
|
+
color: this.strokeColor,
|
|
906
|
+
opacity: effectiveAlpha,
|
|
907
|
+
join: this.lineJoin,
|
|
908
|
+
cap: this.lineCap,
|
|
909
|
+
};
|
|
910
|
+
if (this.dashArray) {
|
|
911
|
+
// Dashed: split the polyline into "on" sub-polylines in world
|
|
912
|
+
// units (Canvas2D dashes in the world-space ctx transform, so
|
|
913
|
+
// this matches it), then stroke each run. `dashPolyline` keeps
|
|
914
|
+
// its Vec2 contract, so materialise the flat path once — dashing
|
|
915
|
+
// is opt-in styling, the solid hot path below stays object-free.
|
|
916
|
+
const pts = new Array(this.pathPts);
|
|
917
|
+
for (let i = 0; i < this.pathPts; i++) {
|
|
918
|
+
pts[i] = { x: req(this.pathXY[i * 2]), y: req(this.pathXY[i * 2 + 1]) };
|
|
919
|
+
}
|
|
920
|
+
for (const run of dashPolyline(pts, this.dashArray)) {
|
|
921
|
+
if (run.length < 2)
|
|
922
|
+
continue;
|
|
923
|
+
ensureDashRunCapacity(run.length * 2);
|
|
924
|
+
for (let i = 0; i < run.length; i++) {
|
|
925
|
+
const p = req(run[i]);
|
|
926
|
+
scratchDashRunXY[i * 2] = p.x;
|
|
927
|
+
scratchDashRunXY[i * 2 + 1] = p.y;
|
|
928
|
+
}
|
|
929
|
+
this.strokePolylineFlat(scratchDashRunXY, run.length, style);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
this.strokePolylineFlat(this.pathXY, this.pathPts, style);
|
|
837
934
|
}
|
|
838
935
|
// Stroke wrote into the dynamic VBO; rebind the static unit-quad VBO
|
|
839
936
|
// so the next solid rect fill picks up the right vertex stream.
|
|
840
937
|
this.restoreSolidProgram();
|
|
841
938
|
}
|
|
939
|
+
/** Route one flat polyline through the shared stroke pipeline. */
|
|
940
|
+
strokePolylineFlat(xy, pointCount, style) {
|
|
941
|
+
drawPolylineStrokeImpl(this.gl, xy, pointCount, style, this.transform, this._size, this.program, this.uTransformLoc, this.uColorLoc, this.uOpacityLoc, this.dynamicVbo, this.aPosLoc, IDENTITY_MAT3);
|
|
942
|
+
}
|
|
842
943
|
// --- Stroke style state (consumed by stroke()) ---
|
|
843
944
|
setLineCap(cap) {
|
|
844
945
|
this.lineCap = cap;
|
|
@@ -876,6 +977,7 @@ export class WebGL2Target {
|
|
|
876
977
|
fillText(text, x, y, maxWidth) {
|
|
877
978
|
if (text.length === 0)
|
|
878
979
|
return;
|
|
980
|
+
this.flushRectBatch(); // preserve z-order: emit queued rect fills first
|
|
879
981
|
void maxWidth;
|
|
880
982
|
const atlas = this.ensureGlyphAtlas();
|
|
881
983
|
if (atlas) {
|
|
@@ -967,6 +1069,33 @@ export class WebGL2Target {
|
|
|
967
1069
|
this.gl.enableVertexAttribArray(this.aPosLoc);
|
|
968
1070
|
this.gl.vertexAttribPointer(this.aPosLoc, 2, this.gl.FLOAT, false, 0, 0);
|
|
969
1071
|
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Draw any queued sharp-rect fill instances as one instanced call,
|
|
1074
|
+
* then restore the solid-program plumbing. Called before every
|
|
1075
|
+
* non-batchable draw and surface op to keep z-order intact, and from
|
|
1076
|
+
* {@link flushBatch} at frame end. No-op when the queue is empty.
|
|
1077
|
+
*/
|
|
1078
|
+
flushRectBatch() {
|
|
1079
|
+
if (this.rectBatch.pending === 0)
|
|
1080
|
+
return;
|
|
1081
|
+
this.rectPipeline ??= new RectInstancePipeline(this.gl);
|
|
1082
|
+
const pipeline = this.rectPipeline;
|
|
1083
|
+
this.rectBatch.flush((data, count) => {
|
|
1084
|
+
pipeline.draw(data, count);
|
|
1085
|
+
});
|
|
1086
|
+
this.restoreSolidProgram();
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Flush the deferred sharp-rect batch to the GPU. The host's
|
|
1090
|
+
* `LayeredSurface.present()` calls this once per frame after the
|
|
1091
|
+
* Editor finishes drawing, so trailing rect fills reach the
|
|
1092
|
+
* framebuffer within the frame that queued them. Backends without a
|
|
1093
|
+
* batcher (Canvas2D) have no equivalent — this is WebGL2-specific and
|
|
1094
|
+
* not part of the `RenderTarget` interface.
|
|
1095
|
+
*/
|
|
1096
|
+
flushBatch() {
|
|
1097
|
+
this.flushRectBatch();
|
|
1098
|
+
}
|
|
970
1099
|
measureText(text) {
|
|
971
1100
|
return this.textMetrics(text);
|
|
972
1101
|
}
|
|
@@ -999,9 +1128,15 @@ export class WebGL2Target {
|
|
|
999
1128
|
return this.textCtx;
|
|
1000
1129
|
}
|
|
1001
1130
|
textFontSpec() {
|
|
1002
|
-
//
|
|
1003
|
-
// the
|
|
1004
|
-
|
|
1131
|
+
// CSS font shorthand order: `<style> <weight> <size> <family>` — must
|
|
1132
|
+
// carry weight/style so the no-MSDF fallback (OffscreenCanvas bitmaps)
|
|
1133
|
+
// draws bold/italic like the MSDF path and the Canvas2D backend do.
|
|
1134
|
+
// It also keys the bitmap cache, so a bold word can't collide with the
|
|
1135
|
+
// regular one (which would render regular while colour still applied).
|
|
1136
|
+
// Bundled face first so the fallback matches the MSDF/Canvas2D metrics.
|
|
1137
|
+
const style = this.fontStyle === "italic" ? "italic " : "";
|
|
1138
|
+
const weight = this.fontWeight === "bold" ? "bold " : "";
|
|
1139
|
+
return `${style}${weight}${this.fontSize}px "${resolveBundledFamily(this.fontFamily)}", ${this.fontFamily}`;
|
|
1005
1140
|
}
|
|
1006
1141
|
baselineOffsetCache = new Map();
|
|
1007
1142
|
/**
|
|
@@ -1042,17 +1177,11 @@ export class WebGL2Target {
|
|
|
1042
1177
|
const atlas = this.ensureGlyphAtlas();
|
|
1043
1178
|
if (atlas) {
|
|
1044
1179
|
const fontId = atlas.resolveFontId(this.fontFamily, this.fontWeight === "bold", this.fontStyle === "italic");
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
const glyph = atlas.getOrRasterize(cp, fontId);
|
|
1051
|
-
if (!glyph)
|
|
1052
|
-
continue;
|
|
1053
|
-
w += (glyph.advance * this.fontSize) / glyph.unitsPerEm;
|
|
1054
|
-
}
|
|
1055
|
-
return { width: w };
|
|
1180
|
+
// Shared single-pass, memoized walk — same advances `fillTextMSDF`
|
|
1181
|
+
// lays out (`advance * fontSize / unitsPerEm`), so measured width
|
|
1182
|
+
// and drawn width stay 1:1. em-width is fontSize-independent; scale
|
|
1183
|
+
// here. A measure after the same run was drawn hits the memo.
|
|
1184
|
+
return { width: measureGlyphRunEm(text, atlas, fontId) * this.fontSize };
|
|
1056
1185
|
}
|
|
1057
1186
|
// Fallback (no MSDF shaper): Canvas2D system-font measurement, which
|
|
1058
1187
|
// matches the Canvas2D bitmap text path used in that case.
|
|
@@ -1194,19 +1323,37 @@ export const dashPolyline = (pts, pattern) => {
|
|
|
1194
1323
|
* and never shrinks; safe for single-threaded WebGL (fill calls are
|
|
1195
1324
|
* serialised through the editor's render path).
|
|
1196
1325
|
*/
|
|
1197
|
-
let scratchEarcutFlat = new Float64Array(128);
|
|
1198
1326
|
let scratchEarcutVerts = new Float32Array(128);
|
|
1199
1327
|
let scratchEarcutIndices = new Uint16Array(256);
|
|
1200
1328
|
const ensureEarcutVertexCapacity = (vertexCount) => {
|
|
1201
1329
|
const needed = vertexCount * 2;
|
|
1202
|
-
if (
|
|
1330
|
+
if (scratchEarcutVerts.length >= needed)
|
|
1203
1331
|
return;
|
|
1204
|
-
let cap =
|
|
1332
|
+
let cap = scratchEarcutVerts.length;
|
|
1205
1333
|
while (cap < needed)
|
|
1206
1334
|
cap *= 2;
|
|
1207
|
-
scratchEarcutFlat = new Float64Array(cap);
|
|
1208
1335
|
scratchEarcutVerts = new Float32Array(cap);
|
|
1209
1336
|
};
|
|
1337
|
+
/**
|
|
1338
|
+
* Module-level scratch for converting one dashed-stroke run back to the
|
|
1339
|
+
* flat layout `drawPolylineStroke` consumes. Only the dashed path uses
|
|
1340
|
+
* it, and runs are consumed synchronously one at a time.
|
|
1341
|
+
*/
|
|
1342
|
+
let scratchDashRunXY = new Float64Array(128);
|
|
1343
|
+
const ensureDashRunCapacity = (n) => {
|
|
1344
|
+
if (scratchDashRunXY.length >= n)
|
|
1345
|
+
return;
|
|
1346
|
+
let cap = scratchDashRunXY.length;
|
|
1347
|
+
while (cap < n)
|
|
1348
|
+
cap *= 2;
|
|
1349
|
+
scratchDashRunXY = new Float64Array(cap);
|
|
1350
|
+
};
|
|
1351
|
+
/**
|
|
1352
|
+
* Initial per-target flat path buffer capacity, in points. 128 covers a
|
|
1353
|
+
* rounded rect (≈ 60-100 flattened vertices at 1×) without a grow;
|
|
1354
|
+
* capacity doubles on demand and never shrinks.
|
|
1355
|
+
*/
|
|
1356
|
+
const INITIAL_PATH_CAPACITY = 128;
|
|
1210
1357
|
const ensureEarcutIndexCapacity = (n) => {
|
|
1211
1358
|
if (scratchEarcutIndices.length >= n)
|
|
1212
1359
|
return;
|
|
@@ -1241,63 +1388,21 @@ const isMsdfShaper = (shaper) => {
|
|
|
1241
1388
|
* proportional to it, used to pick a JS-fallback sample count
|
|
1242
1389
|
* commensurate with the tolerance.
|
|
1243
1390
|
*/
|
|
1244
|
-
const curveLengthEstimate = (
|
|
1245
|
-
/** Sample a quadratic Bezier curve at `count` evenly-spaced t values. */
|
|
1246
|
-
const sampleQuadratic = (p0, p1, p2, count) => {
|
|
1247
|
-
const out = [];
|
|
1248
|
-
for (let i = 0; i <= count; i++) {
|
|
1249
|
-
const t = i / count;
|
|
1250
|
-
const u = 1 - t;
|
|
1251
|
-
out.push({
|
|
1252
|
-
x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
|
|
1253
|
-
y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y,
|
|
1254
|
-
});
|
|
1255
|
-
}
|
|
1256
|
-
return out;
|
|
1257
|
-
};
|
|
1258
|
-
/** Sample a cubic Bezier curve at `count` evenly-spaced t values. */
|
|
1259
|
-
const sampleCubic = (p0, p1, p2, p3, count) => {
|
|
1260
|
-
const out = [];
|
|
1261
|
-
for (let i = 0; i <= count; i++) {
|
|
1262
|
-
const t = i / count;
|
|
1263
|
-
const u = 1 - t;
|
|
1264
|
-
const u2 = u * u;
|
|
1265
|
-
const u3 = u2 * u;
|
|
1266
|
-
const t2 = t * t;
|
|
1267
|
-
const t3 = t2 * t;
|
|
1268
|
-
out.push({
|
|
1269
|
-
x: u3 * p0.x + 3 * u2 * t * p1.x + 3 * u * t2 * p2.x + t3 * p3.x,
|
|
1270
|
-
y: u3 * p0.y + 3 * u2 * t * p1.y + 3 * u * t2 * p2.y + t3 * p3.y,
|
|
1271
|
-
});
|
|
1272
|
-
}
|
|
1273
|
-
return out;
|
|
1274
|
-
};
|
|
1275
|
-
/** Same projection as `applyMat` but emitted from drawImage. */
|
|
1276
|
-
const applyImageMat = (t, w, h) => {
|
|
1277
|
-
const sx = 2 / w;
|
|
1278
|
-
const sy = -2 / h;
|
|
1279
|
-
return new Float32Array([
|
|
1280
|
-
t.a * sx,
|
|
1281
|
-
t.b * sy,
|
|
1282
|
-
0,
|
|
1283
|
-
t.c * sx,
|
|
1284
|
-
t.d * sy,
|
|
1285
|
-
0,
|
|
1286
|
-
t.e * sx - 1,
|
|
1287
|
-
t.f * sy + 1,
|
|
1288
|
-
1,
|
|
1289
|
-
]);
|
|
1290
|
-
};
|
|
1391
|
+
const curveLengthEstimate = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
|
1291
1392
|
const createImageProgram = (gl) => {
|
|
1292
1393
|
const vert = compileShader(gl, gl.VERTEX_SHADER, `#version 300 es
|
|
1293
1394
|
in vec2 aPos;
|
|
1294
1395
|
in vec2 aUV;
|
|
1295
1396
|
uniform mat3 uTransform;
|
|
1397
|
+
uniform vec2 uUvOffset;
|
|
1398
|
+
uniform vec2 uUvScale;
|
|
1296
1399
|
out vec2 vUV;
|
|
1297
1400
|
void main() {
|
|
1298
1401
|
vec3 p = uTransform * vec3(aPos, 1.0);
|
|
1299
1402
|
gl_Position = vec4(p.xy, 0.0, 1.0);
|
|
1300
|
-
|
|
1403
|
+
// Crop: map the unit-quad UV into the source sub-rect. Identity is
|
|
1404
|
+
// offset (0,0) + scale (1,1); a crop narrows it to the kept region.
|
|
1405
|
+
vUV = aUV * uUvScale + uUvOffset;
|
|
1301
1406
|
}`, "WebGL2");
|
|
1302
1407
|
const frag = compileShader(gl, gl.FRAGMENT_SHADER, `#version 300 es
|
|
1303
1408
|
precision mediump float;
|
|
@@ -1321,28 +1426,38 @@ void main() {
|
|
|
1321
1426
|
uTransform: gl.getUniformLocation(program, "uTransform"),
|
|
1322
1427
|
uTex: gl.getUniformLocation(program, "uTex"),
|
|
1323
1428
|
uOpacity: gl.getUniformLocation(program, "uOpacity"),
|
|
1429
|
+
uUvOffset: gl.getUniformLocation(program, "uUvOffset"),
|
|
1430
|
+
uUvScale: gl.getUniformLocation(program, "uUvScale"),
|
|
1324
1431
|
};
|
|
1325
1432
|
};
|
|
1433
|
+
/**
|
|
1434
|
+
* Module-level scratch for `applyMat` — the same reuse pattern as the
|
|
1435
|
+
* earcut / stroke scratch buffers above. The projected mat3 is consumed
|
|
1436
|
+
* synchronously by `uniformMatrix3fv` (which copies the values into GL
|
|
1437
|
+
* state) before the next `applyMat` call, so one shared buffer avoids a
|
|
1438
|
+
* Float32Array allocation per rect-fill / drawImage.
|
|
1439
|
+
*/
|
|
1440
|
+
const scratchMat3 = new Float32Array(9);
|
|
1326
1441
|
/**
|
|
1327
1442
|
* Build a 3×3 column-major matrix that maps a unit quad [0,0]–[1,1]
|
|
1328
1443
|
* through the supplied 2D affine + a screen-to-clip conversion
|
|
1329
|
-
* (pixels → NDC).
|
|
1444
|
+
* (pixels → NDC). Returns the module-level scratch — consume it before
|
|
1445
|
+
* the next call.
|
|
1330
1446
|
*/
|
|
1331
1447
|
const applyMat = (t, w, h) => {
|
|
1332
1448
|
// Pixel-space → clip-space: x' = (x / w) * 2 - 1; y' = 1 - (y / h) * 2.
|
|
1333
1449
|
const sx = 2 / w;
|
|
1334
1450
|
const sy = -2 / h;
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
]);
|
|
1451
|
+
scratchMat3[0] = t.a * sx;
|
|
1452
|
+
scratchMat3[1] = t.b * sy;
|
|
1453
|
+
scratchMat3[2] = 0;
|
|
1454
|
+
scratchMat3[3] = t.c * sx;
|
|
1455
|
+
scratchMat3[4] = t.d * sy;
|
|
1456
|
+
scratchMat3[5] = 0;
|
|
1457
|
+
scratchMat3[6] = t.e * sx - 1;
|
|
1458
|
+
scratchMat3[7] = t.f * sy + 1;
|
|
1459
|
+
scratchMat3[8] = 1;
|
|
1460
|
+
return scratchMat3;
|
|
1346
1461
|
};
|
|
1347
1462
|
const VERTEX_SHADER = `#version 300 es
|
|
1348
1463
|
in vec2 aPos;
|