@oh-just-another/renderer-canvas 0.58.1 → 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.
Files changed (70) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +63 -15
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/canvas-target.d.ts +6 -1
  5. package/dist/canvas-target.d.ts.map +1 -1
  6. package/dist/canvas-target.js +18 -5
  7. package/dist/canvas-target.js.map +1 -1
  8. package/dist/constants.d.ts +81 -0
  9. package/dist/constants.d.ts.map +1 -1
  10. package/dist/constants.js +81 -0
  11. package/dist/constants.js.map +1 -1
  12. package/dist/hi-dpi.d.ts +21 -13
  13. package/dist/hi-dpi.d.ts.map +1 -1
  14. package/dist/hi-dpi.js +28 -33
  15. package/dist/hi-dpi.js.map +1 -1
  16. package/dist/image-source.d.ts +11 -0
  17. package/dist/image-source.d.ts.map +1 -1
  18. package/dist/image-source.js +20 -0
  19. package/dist/image-source.js.map +1 -1
  20. package/dist/index.d.ts +3 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -12
  23. package/dist/index.js.map +1 -1
  24. package/dist/layered-canvas.d.ts +7 -1
  25. package/dist/layered-canvas.d.ts.map +1 -1
  26. package/dist/layered-canvas.js +6 -3
  27. package/dist/layered-canvas.js.map +1 -1
  28. package/dist/layered-surface.d.ts +16 -0
  29. package/dist/layered-surface.d.ts.map +1 -1
  30. package/dist/layered-surface.js +53 -15
  31. package/dist/layered-surface.js.map +1 -1
  32. package/dist/recording-target.d.ts +85 -25
  33. package/dist/recording-target.d.ts.map +1 -1
  34. package/dist/recording-target.js +250 -50
  35. package/dist/recording-target.js.map +1 -1
  36. package/dist/render-worker.js +16 -3
  37. package/dist/render-worker.js.map +1 -1
  38. package/dist/tile-compositor.d.ts +43 -1
  39. package/dist/tile-compositor.d.ts.map +1 -1
  40. package/dist/tile-compositor.js +48 -5
  41. package/dist/tile-compositor.js.map +1 -1
  42. package/dist/webgl-helpers.d.ts +7 -0
  43. package/dist/webgl-helpers.d.ts.map +1 -0
  44. package/dist/webgl-helpers.js +32 -0
  45. package/dist/webgl-helpers.js.map +1 -0
  46. package/dist/webgl2-curve.d.ts +11 -0
  47. package/dist/webgl2-curve.d.ts.map +1 -1
  48. package/dist/webgl2-curve.js +151 -68
  49. package/dist/webgl2-curve.js.map +1 -1
  50. package/dist/webgl2-ellipse.d.ts +0 -27
  51. package/dist/webgl2-ellipse.d.ts.map +1 -1
  52. package/dist/webgl2-ellipse.js +21 -47
  53. package/dist/webgl2-ellipse.js.map +1 -1
  54. package/dist/webgl2-msdf-text.d.ts +14 -0
  55. package/dist/webgl2-msdf-text.d.ts.map +1 -1
  56. package/dist/webgl2-msdf-text.js +84 -36
  57. package/dist/webgl2-msdf-text.js.map +1 -1
  58. package/dist/webgl2-rect-batch.d.ts +81 -0
  59. package/dist/webgl2-rect-batch.d.ts.map +1 -0
  60. package/dist/webgl2-rect-batch.js +201 -0
  61. package/dist/webgl2-rect-batch.js.map +1 -0
  62. package/dist/webgl2-stroke.d.ts +6 -30
  63. package/dist/webgl2-stroke.d.ts.map +1 -1
  64. package/dist/webgl2-stroke.js +17 -30
  65. package/dist/webgl2-stroke.js.map +1 -1
  66. package/dist/webgl2-target.d.ts +74 -8
  67. package/dist/webgl2-target.d.ts.map +1 -1
  68. package/dist/webgl2-target.js +356 -257
  69. package/dist/webgl2-target.js.map +1 -1
  70. package/package.json +10 -8
@@ -1,13 +1,17 @@
1
+ import { req } from "@oh-just-another/types";
1
2
  import { getActiveRasterizer, getActiveTextShaper } from "@oh-just-another/renderer-core";
2
3
  import { GlyphAtlas } from "@oh-just-another/glyph-atlas";
4
+ import { resolveBundledFamily } from "@oh-just-another/fonts";
3
5
  import earcut from "earcut";
4
6
  import { parseWebGL2Color } from "./webgl2-color.js";
5
- import { WEBGL2_IMAGE_TEXTURE_CACHE_CAP, WEBGL2_TEXT_BITMAP_CACHE_CAP } from "./constants.js";
6
- import { MsdfTextPipeline } from "./webgl2-msdf-text.js";
7
+ import { ELLIPSE_MAX_SEGMENTS, ELLIPSE_MIN_SEGMENTS, WEBGL2_IMAGE_TEXTURE_CACHE_CAP, WEBGL2_TEXT_BITMAP_CACHE_CAP, } from "./constants.js";
8
+ import { MsdfTextPipeline, measureGlyphRunEm } from "./webgl2-msdf-text.js";
7
9
  import { drawPolylineStroke as drawPolylineStrokeImpl } from "./webgl2-stroke.js";
8
10
  import { LoopBlinnCurvePipeline } from "./webgl2-curve.js";
9
11
  import { EllipsePipeline } from "./webgl2-ellipse.js";
10
12
  import { isDrawableImageSource, warnSkippedImage } from "./image-source.js";
13
+ import { compileShader, glReq, linkProgram } from "./webgl-helpers.js";
14
+ import { RectBatch, RectInstancePipeline } from "./webgl2-rect-batch.js";
11
15
  /**
12
16
  * WebGL2 RenderTarget. Implements clear, transform/state stack, path
13
17
  * primitives (rect / polyline / ellipse / Bezier), fill, stroke, text
@@ -62,10 +66,27 @@ export class WebGL2Target {
62
66
  textAlign = "left";
63
67
  textBaseline = "top";
64
68
  /**
65
- * Polyline path being assembled by moveTo / lineTo. Cleared on
66
- * `beginPath()`; pushed to GPU on `stroke()`.
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.
67
75
  */
68
- currentPolyline = [];
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
+ }
69
90
  /**
70
91
  * Curve segments collected since the last `beginPath()`. Quadratic
71
92
  * and cubic Bezier `*CurveTo` calls push here in addition to pushing
@@ -93,24 +114,47 @@ export class WebGL2Target {
93
114
  * snapshot the path either.
94
115
  */
95
116
  stack = [];
96
- constructor(canvas, width, height) {
97
- // `preserveDrawingBuffer: true` is required for an editor surface:
98
- // the spec permits the browser to clear the drawing buffer after
99
- // each composite when this flag is false, which makes shapes
100
- // disappear in the steady state. Trading a small copy at composite
101
- // time for visual correctness is the right call.
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.
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.
102
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;
103
147
  // Try with antialiasing first; some integrated GPUs deny the
104
148
  // context when MSAA isn't available. Retry plain on failure so
105
149
  // WebGL2 isn't lost entirely for a stylistic preference.
106
150
  let gl = canvas.getContext("webgl2", {
107
151
  antialias: true,
108
152
  premultipliedAlpha: true,
109
- preserveDrawingBuffer: true,
153
+ preserveDrawingBuffer,
110
154
  });
111
155
  gl ??= canvas.getContext("webgl2", {
112
156
  premultipliedAlpha: true,
113
- preserveDrawingBuffer: true,
157
+ preserveDrawingBuffer,
114
158
  });
115
159
  if (!gl) {
116
160
  throw new Error("WebGL2 unavailable in this environment (probably hit the per-page GL context cap; " +
@@ -118,9 +162,9 @@ export class WebGL2Target {
118
162
  }
119
163
  this.gl = gl;
120
164
  this._size = { width, height };
121
- const vert = compile(this.gl, this.gl.VERTEX_SHADER, VERTEX_SHADER);
122
- const frag = compile(this.gl, this.gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
123
- this.program = link(this.gl, vert, frag);
165
+ const vert = compileShader(this.gl, this.gl.VERTEX_SHADER, VERTEX_SHADER, "WebGL2");
166
+ const frag = compileShader(this.gl, this.gl.FRAGMENT_SHADER, FRAGMENT_SHADER, "WebGL2");
167
+ this.program = linkProgram(this.gl, vert, frag, "WebGL2");
124
168
  this.gl.useProgram(this.program);
125
169
  // Single quad shared across every solid-fill rect — the vertex
126
170
  // shader applies the per-call transform to scale + translate it
@@ -147,7 +191,7 @@ export class WebGL2Target {
147
191
  this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
148
192
  // Initial viewport — must match the canvas drawing buffer size. The
149
193
  // WebGL spec defaults to the canvas's initial size, but if the
150
- // canvas was resized via setupHiDpiNoContext after creation the
194
+ // canvas was resized via setupHiDpi (no-context) after creation the
151
195
  // viewport stays at the first size, so set it explicitly.
152
196
  this.gl.viewport(0, 0, canvas.width, canvas.height);
153
197
  }
@@ -161,6 +205,9 @@ export class WebGL2Target {
161
205
  * in sync so downstream renderers see the new dimensions.
162
206
  */
163
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();
164
211
  this._size.width = width;
165
212
  this._size.height = height;
166
213
  this.gl.viewport(0, 0, width, height);
@@ -172,6 +219,13 @@ export class WebGL2Target {
172
219
  * and runtime backend switches quickly hit the cap.
173
220
  */
174
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
+ }
175
229
  if (this.msdfPipeline) {
176
230
  this.msdfPipeline.dispose();
177
231
  this.msdfPipeline = null;
@@ -298,7 +352,7 @@ export class WebGL2Target {
298
352
  // --- Path primitives ---
299
353
  beginPath() {
300
354
  this.currentPath = null;
301
- this.currentPolyline = [];
355
+ this.pathPts = 0;
302
356
  this.currentCurves = [];
303
357
  this.currentEllipse = null;
304
358
  }
@@ -314,15 +368,15 @@ export class WebGL2Target {
314
368
  }
315
369
  _pathRect = { x: 0, y: 0, width: 0, height: 0 };
316
370
  moveTo(x, y) {
317
- this.currentPolyline = [{ x, y }];
371
+ this.pathPts = 0;
372
+ this.pushPathPoint(x, y);
318
373
  }
319
374
  lineTo(x, y) {
320
- this.currentPolyline.push({ x, y });
375
+ this.pushPathPoint(x, y);
321
376
  }
322
377
  closePath() {
323
- const start = this.currentPolyline[0];
324
- if (this.currentPolyline.length > 1 && start !== undefined) {
325
- this.currentPolyline.push({ ...start });
378
+ if (this.pathPts > 1) {
379
+ this.pushPathPoint(req(this.pathXY[0]), req(this.pathXY[1]));
326
380
  }
327
381
  }
328
382
  /**
@@ -334,7 +388,7 @@ export class WebGL2Target {
334
388
  */
335
389
  ellipse(cx, cy, rx, ry) {
336
390
  this.currentEllipse = { cx, cy, rx, ry };
337
- this.currentPolyline = [];
391
+ this.pathPts = 0;
338
392
  this.currentPath = null;
339
393
  }
340
394
  /**
@@ -346,13 +400,10 @@ export class WebGL2Target {
346
400
  const scale = Math.hypot(this.transform.a, this.transform.b);
347
401
  const screenRadius = Math.max(e.rx, e.ry) * (Number.isFinite(scale) && scale > 0 ? scale : 1);
348
402
  const segments = Math.max(ELLIPSE_MIN_SEGMENTS, Math.min(ELLIPSE_MAX_SEGMENTS, Math.ceil(Math.PI * screenRadius * 0.7)));
349
- this.currentPolyline = [];
403
+ this.pathPts = 0;
350
404
  for (let i = 0; i <= segments; i++) {
351
405
  const t = (i / segments) * Math.PI * 2;
352
- this.currentPolyline.push({
353
- x: e.cx + e.rx * Math.cos(t),
354
- y: e.cy + e.ry * Math.sin(t),
355
- });
406
+ this.pushPathPoint(e.cx + e.rx * Math.cos(t), e.cy + e.ry * Math.sin(t));
356
407
  }
357
408
  }
358
409
  /**
@@ -368,7 +419,7 @@ export class WebGL2Target {
368
419
  * (sub-pixel zoom-aware).
369
420
  */
370
421
  quadraticCurveTo(cx, cy, x, y) {
371
- const start = this.currentPolyline[this.currentPolyline.length - 1] ?? { x: cx, y: cy };
422
+ const start = this.lastPathPoint() ?? { x: cx, y: cy };
372
423
  this.currentCurves.push({
373
424
  kind: "q",
374
425
  points: [start, { x: cx, y: cy }, { x, y }],
@@ -382,18 +433,24 @@ export class WebGL2Target {
382
433
  { kind: "M", to: start },
383
434
  { kind: "Q", control: { x: cx, y: cy }, to: { x, y } },
384
435
  ], tolerance);
385
- for (let i = 1; i < pts.length; i++)
386
- this.currentPolyline.push(req(pts[i]));
436
+ for (let i = 1; i < pts.length; i++) {
437
+ const p = req(pts[i]);
438
+ this.pushPathPoint(p.x, p.y);
439
+ }
387
440
  return;
388
441
  }
389
- const count = Math.max(8, Math.min(128, Math.ceil(curveLengthEstimate(start, { x, y }) / tolerance)));
390
- const samples = sampleQuadratic(start, { x: cx, y: cy }, { x, y }, count);
391
- for (let i = 1; i < samples.length; i++)
392
- this.currentPolyline.push(req(samples[i]));
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
+ }
393
450
  }
394
451
  /** Cubic Bezier — same dual-track approach as quadratic. */
395
452
  bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
396
- const start = this.currentPolyline[this.currentPolyline.length - 1] ?? { x, y };
453
+ const start = this.lastPathPoint() ?? { x, y };
397
454
  this.currentCurves.push({
398
455
  kind: "c",
399
456
  points: [start, { x: c1x, y: c1y }, { x: c2x, y: c2y }, { x, y }],
@@ -410,14 +467,32 @@ export class WebGL2Target {
410
467
  to: { x, y },
411
468
  },
412
469
  ], tolerance);
413
- for (let i = 1; i < pts.length; i++)
414
- this.currentPolyline.push(req(pts[i]));
470
+ for (let i = 1; i < pts.length; i++) {
471
+ const p = req(pts[i]);
472
+ this.pushPathPoint(p.x, p.y);
473
+ }
415
474
  return;
416
475
  }
417
- const count = Math.max(12, Math.min(192, Math.ceil(curveLengthEstimate(start, { x, y }) / tolerance)));
418
- const samples = sampleCubic(start, { x: c1x, y: c1y }, { x: c2x, y: c2y }, { x, y }, count);
419
- for (let i = 1; i < samples.length; i++)
420
- this.currentPolyline.push(req(samples[i]));
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
+ };
421
496
  }
422
497
  /**
423
498
  * World-unit tolerance that maps to ~`SCREEN_TOLERANCE_PX` on screen
@@ -440,7 +515,8 @@ export class WebGL2Target {
440
515
  * as a textured quad via a dedicated program created lazily on the
441
516
  * first image call.
442
517
  */
443
- 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
444
520
  const tex = this.textureFor(image, dynamic ?? false);
445
521
  if (!tex)
446
522
  return;
@@ -461,7 +537,7 @@ export class WebGL2Target {
461
537
  this.gl.enableVertexAttribArray(ip.aUV);
462
538
  this.gl.vertexAttribPointer(ip.aUV, 2, this.gl.FLOAT, false, 16, 8);
463
539
  // Project the drawn region through transform + viewport.
464
- const projected = applyImageMat({
540
+ const projected = applyMat({
465
541
  a: this.transform.a * dw,
466
542
  b: this.transform.b * dw,
467
543
  c: this.transform.c * dh,
@@ -471,6 +547,17 @@ export class WebGL2Target {
471
547
  }, this._size.width, this._size.height);
472
548
  this.gl.uniformMatrix3fv(ip.uTransform, false, projected);
473
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
+ }
474
561
  this.gl.activeTexture(this.gl.TEXTURE0);
475
562
  this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
476
563
  this.gl.uniform1i(ip.uTex, 0);
@@ -575,6 +662,7 @@ export class WebGL2Target {
575
662
  // Ellipse path — single fragment-SDF quad regardless of radius.
576
663
  // Vector-perfect at any zoom; 4 vertices instead of 24-512.
577
664
  if (this.currentEllipse) {
665
+ this.flushRectBatch(); // preserve z-order: emit queued rects first
578
666
  this.ellipsePipeline ??= new EllipsePipeline(this.gl);
579
667
  const e = this.currentEllipse;
580
668
  this.ellipsePipeline.draw(e.cx, e.cy, e.rx, e.ry, this.fillColor, effectiveAlpha, this.transform, this._size);
@@ -586,27 +674,30 @@ export class WebGL2Target {
586
674
  // most shape backgrounds (rectangles) hit it.
587
675
  if (this.currentPath) {
588
676
  const r = this.currentPath;
589
- const projected = applyMat({
590
- a: this.transform.a * r.width,
591
- b: this.transform.b * r.width,
592
- c: this.transform.c * r.height,
593
- d: this.transform.d * r.height,
594
- e: this.transform.e + this.transform.a * r.x + this.transform.c * r.y,
595
- f: this.transform.f + this.transform.b * r.x + this.transform.d * r.y,
596
- }, this._size.width, this._size.height);
597
- this.restoreSolidProgram(); // ensure the solid VBO+attrib is live
598
- this.gl.uniformMatrix3fv(this.uTransformLoc, false, projected);
599
- this.gl.uniform3f(this.uColorLoc, this.fillColor[0], this.fillColor[1], this.fillColor[2]);
600
- this.gl.uniform1f(this.uOpacityLoc, effectiveAlpha);
601
- this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
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);
602
692
  return;
603
693
  }
604
694
  // Polygon path — assembled via moveTo / lineTo / bezierCurveTo.
605
695
  // Triangulated through earcut so concave shapes (arrows, stars,
606
696
  // lightning bolts) fill correctly. Earcut is dependency-free and
607
697
  // handles holes too if ever needed.
608
- if (this.currentPolyline.length >= 3) {
609
- this.fillPolygonEarcut(this.currentPolyline, effectiveAlpha);
698
+ if (this.pathPts >= 3) {
699
+ this.flushRectBatch(); // preserve z-order: emit queued rects first
700
+ this.fillPolygonEarcut(this.pathXY, this.pathPts, effectiveAlpha);
610
701
  }
611
702
  // Loop-Blinn curve overlay. Adds fragment-tested quadratic / cubic
612
703
  // triangles on top of the polygon fill so curve regions
@@ -623,6 +714,7 @@ export class WebGL2Target {
623
714
  // doesn't have. The artefact is invisible at 1× zoom and tiny even
624
715
  // at 20×.
625
716
  if (this.currentCurves.length > 0) {
717
+ this.flushRectBatch(); // preserve z-order: emit queued rects first
626
718
  this.curvePipeline ??= new LoopBlinnCurvePipeline(this.gl);
627
719
  this.curvePipeline.draw(this.currentCurves, this.fillColor, effectiveAlpha, this.transform, this._size);
628
720
  this.restoreSolidProgram();
@@ -637,47 +729,36 @@ export class WebGL2Target {
637
729
  * earcut returns an empty index list (degenerate self-intersecting
638
730
  * polygon).
639
731
  */
640
- fillPolygonEarcut(polyline, effectiveAlpha) {
732
+ fillPolygonEarcut(xy, pointCount, effectiveAlpha) {
641
733
  // Skip the implicitly-closed duplicate last vertex if the caller
642
734
  // already issued `closePath` — earcut would treat it as a degenerate
643
735
  // sliver.
644
- const polyFirst = polyline[0];
645
- const polyLast = polyline[polyline.length - 1];
646
- const n = polyline.length >= 4 &&
647
- polyFirst !== undefined &&
648
- polyFirst.x === polyLast?.x &&
649
- polyFirst.y === polyLast.y
650
- ? polyline.length - 1
651
- : 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;
652
741
  if (n < 3)
653
742
  return;
654
- // earcut wants a flat [x0, y0, x1, y1, ...] in world coords. Reuse
655
- // the module-level scratch buffers earcut accepts any array-like
656
- // with [i] + length, so a Float64Array view works.
657
- ensureEarcutVertexCapacity(n);
658
- const flat = scratchEarcutFlat;
659
- for (let i = 0; i < n; i++) {
660
- const p = req(polyline[i]);
661
- flat[i * 2] = p.x;
662
- flat[i * 2 + 1] = p.y;
663
- }
664
- // Pass only the populated prefix — `subarray` is a view, no copy.
665
- 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));
666
747
  if (indices.length === 0) {
667
748
  // Pathological polygon — fall back to a fan so something renders.
668
- this.drawTriangleFan(polyline, n, effectiveAlpha);
749
+ this.drawTriangleFan(xy, n, effectiveAlpha);
669
750
  return;
670
751
  }
671
- // Project once into clip space, then index-draw. Shares the earcut
672
- // vertex-count budget — `ensureEarcutVertexCapacity` above already
673
- // grew `scratchEarcutVerts` if needed.
752
+ // Project once into clip space, then index-draw.
753
+ ensureEarcutVertexCapacity(n);
674
754
  const sx = 2 / this._size.width;
675
755
  const sy = -2 / this._size.height;
676
756
  const verts = scratchEarcutVerts;
677
757
  for (let i = 0; i < n; i++) {
678
- const p = req(polyline[i]);
679
- const wx = this.transform.a * p.x + this.transform.c * p.y + this.transform.e;
680
- const wy = this.transform.b * p.x + this.transform.d * p.y + this.transform.f;
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;
681
762
  verts[i * 2] = wx * sx - 1;
682
763
  verts[i * 2 + 1] = wy * sy + 1;
683
764
  }
@@ -715,16 +796,17 @@ export class WebGL2Target {
715
796
  * convex polygons correctly; concave ones get a wrong silhouette (the
716
797
  * earcut path handles those instead).
717
798
  */
718
- drawTriangleFan(polyline, n, effectiveAlpha) {
799
+ drawTriangleFan(xy, n, effectiveAlpha) {
719
800
  const sx = 2 / this._size.width;
720
801
  const sy = -2 / this._size.height;
721
802
  // Share the module-level scratch verts with `fillPolygonEarcut`.
722
803
  ensureEarcutVertexCapacity(n);
723
804
  const verts = scratchEarcutVerts;
724
805
  for (let i = 0; i < n; i++) {
725
- const p = req(polyline[i]);
726
- const wx = this.transform.a * p.x + this.transform.c * p.y + this.transform.e;
727
- const wy = this.transform.b * p.x + this.transform.d * p.y + this.transform.f;
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;
728
810
  verts[i * 2] = wx * sx - 1;
729
811
  verts[i * 2 + 1] = wy * sy + 1;
730
812
  }
@@ -745,9 +827,9 @@ export class WebGL2Target {
745
827
  * `bounds` wipes only the rectangle the editor's dirty-rect pass
746
828
  * identified. Honouring `bounds` is mandatory — when the scene
747
829
  * reference doesn't change, the editor sends a zero-area dirty rect
748
- * and expects the previous frame to survive untouched.
830
+ * and expects the previous frame to survive untouched. The default
749
831
  * `preserveDrawingBuffer: true` carries the persistent frame across
750
- * composites.
832
+ * composites (see the constructor for the opt-out).
751
833
  *
752
834
  * For bounded clears the implementation flips on a scissor box so the
753
835
  * clear is confined to the dirty rect, mirroring Canvas2D's
@@ -756,6 +838,10 @@ export class WebGL2Target {
756
838
  * caller's top-left CSS-pixel rect.
757
839
  */
758
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();
759
845
  const bitmapW = this.gl.canvas.width;
760
846
  const bitmapH = this.gl.canvas.height;
761
847
  if (bounds) {
@@ -791,51 +877,69 @@ export class WebGL2Target {
791
877
  this.strokeWidth = Math.max(0, width);
792
878
  }
793
879
  stroke() {
880
+ this.flushRectBatch(); // preserve z-order: emit queued rect fills first
794
881
  if (this.currentPath) {
795
882
  // Rect outline → 4 corners as a closed polyline.
796
883
  const r = this.currentPath;
797
- this.currentPolyline = [
798
- { x: r.x, y: r.y },
799
- { x: r.x + r.width, y: r.y },
800
- { x: r.x + r.width, y: r.y + r.height },
801
- { x: r.x, y: r.y + r.height },
802
- { x: r.x, y: r.y },
803
- ];
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);
804
890
  }
805
891
  // Ellipse outline — lazily generate the polyline approximation here
806
892
  // so callers that only fill don't pay for the 24-512 vertex
807
893
  // allocation. EllipsePipeline owns the fill path; stroke still goes
808
894
  // through the polygon stroke pipeline.
809
- if (this.currentEllipse && this.currentPolyline.length < 2) {
895
+ if (this.currentEllipse && this.pathPts < 2) {
810
896
  this.buildEllipseStrokePolyline(this.currentEllipse);
811
897
  }
812
- if (this.currentPolyline.length < 2)
898
+ if (this.pathPts < 2)
813
899
  return;
814
900
  const effectiveAlpha = this.opacity * this.strokeAlpha;
815
901
  if (effectiveAlpha <= 0)
816
902
  return; // transparent stroke — nothing to draw
817
- // Dashed: split the polyline into "on" sub-polylines in world units
818
- // (Canvas2D dashes in the world-space ctx transform, so this
819
- // matches it), then stroke each through the same pipeline. Solid →
820
- // one call.
821
- const runs = this.dashArray
822
- ? dashPolyline(this.currentPolyline, this.dashArray)
823
- : [this.currentPolyline];
824
- for (const run of runs) {
825
- if (run.length < 2)
826
- continue;
827
- drawPolylineStrokeImpl(this.gl, run, {
828
- width: this.strokeWidth,
829
- color: this.strokeColor,
830
- opacity: effectiveAlpha,
831
- join: this.lineJoin,
832
- cap: this.lineCap,
833
- }, this.transform, this._size, this.program, this.uTransformLoc, this.uColorLoc, this.uOpacityLoc, this.dynamicVbo, this.aPosLoc, IDENTITY_MAT3);
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);
834
934
  }
835
935
  // Stroke wrote into the dynamic VBO; rebind the static unit-quad VBO
836
936
  // so the next solid rect fill picks up the right vertex stream.
837
937
  this.restoreSolidProgram();
838
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
+ }
839
943
  // --- Stroke style state (consumed by stroke()) ---
840
944
  setLineCap(cap) {
841
945
  this.lineCap = cap;
@@ -873,6 +977,7 @@ export class WebGL2Target {
873
977
  fillText(text, x, y, maxWidth) {
874
978
  if (text.length === 0)
875
979
  return;
980
+ this.flushRectBatch(); // preserve z-order: emit queued rect fills first
876
981
  void maxWidth;
877
982
  const atlas = this.ensureGlyphAtlas();
878
983
  if (atlas) {
@@ -936,16 +1041,11 @@ export class WebGL2Target {
936
1041
  // it measures the run and shifts via the transform), so no separate
937
1042
  // width-measuring pass here.
938
1043
  const alignFactor = this.textAlign === "center" ? 0.5 : this.textAlign === "right" ? 1 : 0;
939
- let py = y;
940
- // Editor convention: baseline=top means y is the top of the text
941
- // box. The MSDF quad math places the glyph relative to its font
942
- // baseline, so shift y down by one font size for the "top" baseline
943
- // (gets the visible bbox into [y, y+fontSize]).
944
- if (this.textBaseline === "top")
945
- py += this.fontSize;
946
- else if (this.textBaseline === "middle")
947
- py += this.fontSize / 2;
948
- // baseline=bottom uses py as-is (cursor sits on the baseline).
1044
+ // The MSDF quad math places glyphs relative to the font baseline, so
1045
+ // convert the requested `textBaseline` into a baseline `y` using the same
1046
+ // browser metrics Canvas2D honours (measured on a hidden 2D context), so
1047
+ // the two backends place text at exactly the same height.
1048
+ const py = y + this.baselineOffsetForBaseline();
949
1049
  this.msdfPipeline.drawText(text, x, py, this.fontSize, atlas, {
950
1050
  opacity: this.opacity,
951
1051
  color: this.fillColor,
@@ -969,6 +1069,33 @@ export class WebGL2Target {
969
1069
  this.gl.enableVertexAttribArray(this.aPosLoc);
970
1070
  this.gl.vertexAttribPointer(this.aPosLoc, 2, this.gl.FLOAT, false, 0, 0);
971
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
+ }
972
1099
  measureText(text) {
973
1100
  return this.textMetrics(text);
974
1101
  }
@@ -1001,7 +1128,45 @@ export class WebGL2Target {
1001
1128
  return this.textCtx;
1002
1129
  }
1003
1130
  textFontSpec() {
1004
- return `${this.fontSize}px ${this.fontFamily}`;
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}`;
1140
+ }
1141
+ baselineOffsetCache = new Map();
1142
+ /**
1143
+ * Distance (px) from the line of the active `textBaseline` down to the
1144
+ * alphabetic baseline, for the current font. Measured on a hidden 2D
1145
+ * context so it uses the exact same browser font metrics Canvas2D applies
1146
+ * when it honours `textBaseline` — keeping the MSDF text at the same
1147
+ * vertical position as the Canvas2D backend. Falls back to a proportional
1148
+ * estimate where measurement isn't available.
1149
+ */
1150
+ baselineOffsetForBaseline() {
1151
+ const ctx = this.ensureTextCtx();
1152
+ if (!ctx)
1153
+ return this.fontSize * 0.8;
1154
+ const spec = this.textFontSpec();
1155
+ const key = `${spec}|${this.textBaseline}`;
1156
+ const cached = this.baselineOffsetCache.get(key);
1157
+ if (cached !== undefined)
1158
+ return cached;
1159
+ ctx.font = spec;
1160
+ ctx.textBaseline = "alphabetic";
1161
+ const alphaDescent = ctx.measureText("Mg").fontBoundingBoxDescent;
1162
+ ctx.textBaseline = this.textBaseline;
1163
+ const thisDescent = ctx.measureText("Mg").fontBoundingBoxDescent;
1164
+ // `fontBoundingBox*` is absent on a few old engines — fall back there.
1165
+ const offset = Number.isFinite(alphaDescent) && Number.isFinite(thisDescent)
1166
+ ? thisDescent - alphaDescent
1167
+ : this.fontSize * 0.8;
1168
+ this.baselineOffsetCache.set(key, offset);
1169
+ return offset;
1005
1170
  }
1006
1171
  textMetrics(text) {
1007
1172
  // When the MSDF path is active, measure with the same atlas glyph
@@ -1012,17 +1177,11 @@ export class WebGL2Target {
1012
1177
  const atlas = this.ensureGlyphAtlas();
1013
1178
  if (atlas) {
1014
1179
  const fontId = atlas.resolveFontId(this.fontFamily, this.fontWeight === "bold", this.fontStyle === "italic");
1015
- let w = 0;
1016
- for (const ch of text) {
1017
- const cp = ch.codePointAt(0);
1018
- if (cp === undefined)
1019
- continue;
1020
- const glyph = atlas.getOrRasterize(cp, fontId);
1021
- if (!glyph)
1022
- continue;
1023
- w += (glyph.advance * this.fontSize) / glyph.unitsPerEm;
1024
- }
1025
- 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 };
1026
1185
  }
1027
1186
  // Fallback (no MSDF shaper): Canvas2D system-font measurement, which
1028
1187
  // matches the Canvas2D bitmap text path used in that case.
@@ -1164,19 +1323,37 @@ export const dashPolyline = (pts, pattern) => {
1164
1323
  * and never shrinks; safe for single-threaded WebGL (fill calls are
1165
1324
  * serialised through the editor's render path).
1166
1325
  */
1167
- let scratchEarcutFlat = new Float64Array(128);
1168
1326
  let scratchEarcutVerts = new Float32Array(128);
1169
1327
  let scratchEarcutIndices = new Uint16Array(256);
1170
1328
  const ensureEarcutVertexCapacity = (vertexCount) => {
1171
1329
  const needed = vertexCount * 2;
1172
- if (scratchEarcutFlat.length >= needed)
1330
+ if (scratchEarcutVerts.length >= needed)
1173
1331
  return;
1174
- let cap = scratchEarcutFlat.length;
1332
+ let cap = scratchEarcutVerts.length;
1175
1333
  while (cap < needed)
1176
1334
  cap *= 2;
1177
- scratchEarcutFlat = new Float64Array(cap);
1178
1335
  scratchEarcutVerts = new Float32Array(cap);
1179
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;
1180
1357
  const ensureEarcutIndexCapacity = (n) => {
1181
1358
  if (scratchEarcutIndices.length >= n)
1182
1359
  return;
@@ -1185,14 +1362,6 @@ const ensureEarcutIndexCapacity = (n) => {
1185
1362
  cap *= 2;
1186
1363
  scratchEarcutIndices = new Uint16Array(cap);
1187
1364
  };
1188
- /**
1189
- * Lower / upper bounds on the polygon approximation of an ellipse. The
1190
- * minimum keeps small ellipses from collapsing to a hexagon at far zoom;
1191
- * the maximum caps GPU work for huge ellipses where the marginal
1192
- * pixel-error improvement is invisible.
1193
- */
1194
- const ELLIPSE_MIN_SEGMENTS = 24;
1195
- const ELLIPSE_MAX_SEGMENTS = 512;
1196
1365
  const IDENTITY_MAT3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
1197
1366
  /**
1198
1367
  * Flatten tolerance, computed per-call: 0.5 / zoom in screen pixels.
@@ -1219,65 +1388,23 @@ const isMsdfShaper = (shaper) => {
1219
1388
  * proportional to it, used to pick a JS-fallback sample count
1220
1389
  * commensurate with the tolerance.
1221
1390
  */
1222
- const curveLengthEstimate = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
1223
- /** Sample a quadratic Bezier curve at `count` evenly-spaced t values. */
1224
- const sampleQuadratic = (p0, p1, p2, count) => {
1225
- const out = [];
1226
- for (let i = 0; i <= count; i++) {
1227
- const t = i / count;
1228
- const u = 1 - t;
1229
- out.push({
1230
- x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
1231
- y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y,
1232
- });
1233
- }
1234
- return out;
1235
- };
1236
- /** Sample a cubic Bezier curve at `count` evenly-spaced t values. */
1237
- const sampleCubic = (p0, p1, p2, p3, count) => {
1238
- const out = [];
1239
- for (let i = 0; i <= count; i++) {
1240
- const t = i / count;
1241
- const u = 1 - t;
1242
- const u2 = u * u;
1243
- const u3 = u2 * u;
1244
- const t2 = t * t;
1245
- const t3 = t2 * t;
1246
- out.push({
1247
- x: u3 * p0.x + 3 * u2 * t * p1.x + 3 * u * t2 * p2.x + t3 * p3.x,
1248
- y: u3 * p0.y + 3 * u2 * t * p1.y + 3 * u * t2 * p2.y + t3 * p3.y,
1249
- });
1250
- }
1251
- return out;
1252
- };
1253
- /** Same projection as `applyMat` but emitted from drawImage. */
1254
- const applyImageMat = (t, w, h) => {
1255
- const sx = 2 / w;
1256
- const sy = -2 / h;
1257
- return new Float32Array([
1258
- t.a * sx,
1259
- t.b * sy,
1260
- 0,
1261
- t.c * sx,
1262
- t.d * sy,
1263
- 0,
1264
- t.e * sx - 1,
1265
- t.f * sy + 1,
1266
- 1,
1267
- ]);
1268
- };
1391
+ const curveLengthEstimate = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
1269
1392
  const createImageProgram = (gl) => {
1270
- const vert = compile(gl, gl.VERTEX_SHADER, `#version 300 es
1393
+ const vert = compileShader(gl, gl.VERTEX_SHADER, `#version 300 es
1271
1394
  in vec2 aPos;
1272
1395
  in vec2 aUV;
1273
1396
  uniform mat3 uTransform;
1397
+ uniform vec2 uUvOffset;
1398
+ uniform vec2 uUvScale;
1274
1399
  out vec2 vUV;
1275
1400
  void main() {
1276
1401
  vec3 p = uTransform * vec3(aPos, 1.0);
1277
1402
  gl_Position = vec4(p.xy, 0.0, 1.0);
1278
- vUV = aUV;
1279
- }`);
1280
- const frag = compile(gl, gl.FRAGMENT_SHADER, `#version 300 es
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;
1406
+ }`, "WebGL2");
1407
+ const frag = compileShader(gl, gl.FRAGMENT_SHADER, `#version 300 es
1281
1408
  precision mediump float;
1282
1409
  in vec2 vUV;
1283
1410
  uniform sampler2D uTex;
@@ -1290,8 +1417,8 @@ void main() {
1290
1417
  // channels by the per-call opacity.
1291
1418
  vec4 t = texture(uTex, vUV);
1292
1419
  fragColor = vec4(t.rgb * uOpacity, t.a * uOpacity);
1293
- }`);
1294
- const program = link(gl, vert, frag);
1420
+ }`, "WebGL2");
1421
+ const program = linkProgram(gl, vert, frag, "WebGL2");
1295
1422
  return {
1296
1423
  program,
1297
1424
  aPos: gl.getAttribLocation(program, "aPos"),
@@ -1299,28 +1426,38 @@ void main() {
1299
1426
  uTransform: gl.getUniformLocation(program, "uTransform"),
1300
1427
  uTex: gl.getUniformLocation(program, "uTex"),
1301
1428
  uOpacity: gl.getUniformLocation(program, "uOpacity"),
1429
+ uUvOffset: gl.getUniformLocation(program, "uUvOffset"),
1430
+ uUvScale: gl.getUniformLocation(program, "uUvScale"),
1302
1431
  };
1303
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);
1304
1441
  /**
1305
1442
  * Build a 3×3 column-major matrix that maps a unit quad [0,0]–[1,1]
1306
1443
  * through the supplied 2D affine + a screen-to-clip conversion
1307
- * (pixels → NDC).
1444
+ * (pixels → NDC). Returns the module-level scratch — consume it before
1445
+ * the next call.
1308
1446
  */
1309
1447
  const applyMat = (t, w, h) => {
1310
1448
  // Pixel-space → clip-space: x' = (x / w) * 2 - 1; y' = 1 - (y / h) * 2.
1311
1449
  const sx = 2 / w;
1312
1450
  const sy = -2 / h;
1313
- return new Float32Array([
1314
- t.a * sx,
1315
- t.b * sy,
1316
- 0,
1317
- t.c * sx,
1318
- t.d * sy,
1319
- 0,
1320
- t.e * sx - 1,
1321
- t.f * sy + 1,
1322
- 1,
1323
- ]);
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;
1324
1461
  };
1325
1462
  const VERTEX_SHADER = `#version 300 es
1326
1463
  in vec2 aPos;
@@ -1339,42 +1476,4 @@ void main() {
1339
1476
  // premultipliedAlpha:true contract + blendFunc(ONE, 1-SRC_ALPHA).
1340
1477
  fragColor = vec4(uColor * uOpacity, uOpacity);
1341
1478
  }`;
1342
- /**
1343
- * Asserts a WebGL resource handle is non-null. Creation APIs are typed
1344
- * non-null but return `null` on context loss; surface that as a throw.
1345
- */
1346
- const glReq = (v) => {
1347
- if (v === null)
1348
- throw new Error("packages/renderer-canvas: WebGL resource creation failed");
1349
- return v;
1350
- };
1351
- /** Asserts an array index is in range (loop bounds guarantee it). */
1352
- const req = (v) => {
1353
- if (v === undefined)
1354
- throw new Error("packages/renderer-canvas: index out of range");
1355
- return v;
1356
- };
1357
- const compile = (gl, type, src) => {
1358
- const sh = glReq(gl.createShader(type));
1359
- gl.shaderSource(sh, src);
1360
- gl.compileShader(sh);
1361
- if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
1362
- const log = gl.getShaderInfoLog(sh);
1363
- gl.deleteShader(sh);
1364
- throw new Error(`Shader compile failed: ${log}`);
1365
- }
1366
- return sh;
1367
- };
1368
- const link = (gl, vert, frag) => {
1369
- const program = gl.createProgram();
1370
- gl.attachShader(program, vert);
1371
- gl.attachShader(program, frag);
1372
- gl.linkProgram(program);
1373
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1374
- const log = gl.getProgramInfoLog(program);
1375
- gl.deleteProgram(program);
1376
- throw new Error(`Program link failed: ${log}`);
1377
- }
1378
- return program;
1379
- };
1380
1479
  //# sourceMappingURL=webgl2-target.js.map