@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +3 -0
  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 +12 -2
  7. package/dist/canvas-target.js.map +1 -1
  8. package/dist/constants.d.ts +49 -0
  9. package/dist/constants.d.ts.map +1 -1
  10. package/dist/constants.js +49 -0
  11. package/dist/constants.js.map +1 -1
  12. package/dist/hi-dpi.d.ts +10 -0
  13. package/dist/hi-dpi.d.ts.map +1 -1
  14. package/dist/hi-dpi.js +12 -1
  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 +2 -2
  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 +50 -13
  31. package/dist/layered-surface.js.map +1 -1
  32. package/dist/recording-target.d.ts +36 -12
  33. package/dist/recording-target.d.ts.map +1 -1
  34. package/dist/recording-target.js +159 -33
  35. package/dist/recording-target.js.map +1 -1
  36. package/dist/tile-compositor.d.ts +43 -1
  37. package/dist/tile-compositor.d.ts.map +1 -1
  38. package/dist/tile-compositor.js +48 -5
  39. package/dist/tile-compositor.js.map +1 -1
  40. package/dist/webgl2-curve.d.ts +11 -0
  41. package/dist/webgl2-curve.d.ts.map +1 -1
  42. package/dist/webgl2-curve.js +147 -32
  43. package/dist/webgl2-curve.js.map +1 -1
  44. package/dist/webgl2-ellipse.d.ts +0 -27
  45. package/dist/webgl2-ellipse.d.ts.map +1 -1
  46. package/dist/webgl2-ellipse.js +17 -12
  47. package/dist/webgl2-ellipse.js.map +1 -1
  48. package/dist/webgl2-msdf-text.d.ts +14 -0
  49. package/dist/webgl2-msdf-text.d.ts.map +1 -1
  50. package/dist/webgl2-msdf-text.js +80 -1
  51. package/dist/webgl2-msdf-text.js.map +1 -1
  52. package/dist/webgl2-rect-batch.d.ts +81 -0
  53. package/dist/webgl2-rect-batch.d.ts.map +1 -0
  54. package/dist/webgl2-rect-batch.js +201 -0
  55. package/dist/webgl2-rect-batch.js.map +1 -0
  56. package/dist/webgl2-stroke.d.ts +6 -30
  57. package/dist/webgl2-stroke.d.ts.map +1 -1
  58. package/dist/webgl2-stroke.js +16 -24
  59. package/dist/webgl2-stroke.js.map +1 -1
  60. package/dist/webgl2-target.d.ts +63 -7
  61. package/dist/webgl2-target.d.ts.map +1 -1
  62. package/dist/webgl2-target.js +308 -193
  63. package/dist/webgl2-target.js.map +1 -1
  64. package/package.json +4 -3
@@ -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. Cleared on
69
- * `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.
70
75
  */
71
- 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
+ }
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
- constructor(canvas, width, height) {
100
- // `preserveDrawingBuffer: true` is required for an editor surface:
101
- // the spec permits the browser to clear the drawing buffer after
102
- // each composite when this flag is false, which makes shapes
103
- // disappear in the steady state. Trading a small copy at composite
104
- // 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.
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: true,
153
+ preserveDrawingBuffer,
113
154
  });
114
155
  gl ??= canvas.getContext("webgl2", {
115
156
  premultipliedAlpha: true,
116
- preserveDrawingBuffer: true,
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.currentPolyline = [];
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.currentPolyline = [{ x, y }];
371
+ this.pathPts = 0;
372
+ this.pushPathPoint(x, y);
321
373
  }
322
374
  lineTo(x, y) {
323
- this.currentPolyline.push({ x, y });
375
+ this.pushPathPoint(x, y);
324
376
  }
325
377
  closePath() {
326
- const start = this.currentPolyline[0];
327
- if (this.currentPolyline.length > 1 && start !== undefined) {
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.currentPolyline = [];
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.currentPolyline = [];
403
+ this.pathPts = 0;
353
404
  for (let i = 0; i <= segments; i++) {
354
405
  const t = (i / segments) * Math.PI * 2;
355
- this.currentPolyline.push({
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.currentPolyline[this.currentPolyline.length - 1] ?? { x: cx, y: cy };
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
- 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
+ }
390
440
  return;
391
441
  }
392
- const count = Math.max(8, Math.min(128, Math.ceil(curveLengthEstimate(start, { x, y }) / tolerance)));
393
- const samples = sampleQuadratic(start, { x: cx, y: cy }, { x, y }, count);
394
- for (let i = 1; i < samples.length; i++)
395
- 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
+ }
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.currentPolyline[this.currentPolyline.length - 1] ?? { x, y };
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
- 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
+ }
418
474
  return;
419
475
  }
420
- const count = Math.max(12, Math.min(192, Math.ceil(curveLengthEstimate(start, { x, y }) / tolerance)));
421
- const samples = sampleCubic(start, { x: c1x, y: c1y }, { x: c2x, y: c2y }, { x, y }, count);
422
- for (let i = 1; i < samples.length; i++)
423
- 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
+ };
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 = applyImageMat({
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
- const projected = applyMat({
593
- a: this.transform.a * r.width,
594
- b: this.transform.b * r.width,
595
- c: this.transform.c * r.height,
596
- d: this.transform.d * r.height,
597
- e: this.transform.e + this.transform.a * r.x + this.transform.c * r.y,
598
- f: this.transform.f + this.transform.b * r.x + this.transform.d * r.y,
599
- }, this._size.width, this._size.height);
600
- this.restoreSolidProgram(); // ensure the solid VBO+attrib is live
601
- this.gl.uniformMatrix3fv(this.uTransformLoc, false, projected);
602
- this.gl.uniform3f(this.uColorLoc, this.fillColor[0], this.fillColor[1], this.fillColor[2]);
603
- this.gl.uniform1f(this.uOpacityLoc, effectiveAlpha);
604
- 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);
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.currentPolyline.length >= 3) {
612
- 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);
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(polyline, effectiveAlpha) {
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 polyFirst = polyline[0];
648
- const polyLast = polyline[polyline.length - 1];
649
- const n = polyline.length >= 4 &&
650
- polyFirst !== undefined &&
651
- polyFirst.x === polyLast?.x &&
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. Reuse
658
- // the module-level scratch buffers earcut accepts any array-like
659
- // with [i] + length, so a Float64Array view works.
660
- ensureEarcutVertexCapacity(n);
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(polyline, n, effectiveAlpha);
749
+ this.drawTriangleFan(xy, n, effectiveAlpha);
672
750
  return;
673
751
  }
674
- // Project once into clip space, then index-draw. Shares the earcut
675
- // vertex-count budget — `ensureEarcutVertexCapacity` above already
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 p = req(polyline[i]);
682
- const wx = this.transform.a * p.x + this.transform.c * p.y + this.transform.e;
683
- 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;
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(polyline, n, effectiveAlpha) {
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 p = req(polyline[i]);
729
- const wx = this.transform.a * p.x + this.transform.c * p.y + this.transform.e;
730
- 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;
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.currentPolyline = [
801
- { x: r.x, y: r.y },
802
- { x: r.x + r.width, y: r.y },
803
- { x: r.x + r.width, y: r.y + r.height },
804
- { x: r.x, y: r.y + r.height },
805
- { x: r.x, y: r.y },
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.currentPolyline.length < 2) {
895
+ if (this.currentEllipse && this.pathPts < 2) {
813
896
  this.buildEllipseStrokePolyline(this.currentEllipse);
814
897
  }
815
- if (this.currentPolyline.length < 2)
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
- // Dashed: split the polyline into "on" sub-polylines in world units
821
- // (Canvas2D dashes in the world-space ctx transform, so this
822
- // matches it), then stroke each through the same pipeline. Solid →
823
- // one call.
824
- const runs = this.dashArray
825
- ? dashPolyline(this.currentPolyline, this.dashArray)
826
- : [this.currentPolyline];
827
- for (const run of runs) {
828
- if (run.length < 2)
829
- continue;
830
- drawPolylineStrokeImpl(this.gl, run, {
831
- width: this.strokeWidth,
832
- color: this.strokeColor,
833
- opacity: effectiveAlpha,
834
- join: this.lineJoin,
835
- cap: this.lineCap,
836
- }, 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);
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
- // Bundled face first so the no-MSDF fallback matches the MSDF path and
1003
- // the Canvas2D backend.
1004
- return `${this.fontSize}px "${resolveBundledFamily(this.fontFamily)}", ${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}`;
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
- let w = 0;
1046
- for (const ch of text) {
1047
- const cp = ch.codePointAt(0);
1048
- if (cp === undefined)
1049
- continue;
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 (scratchEarcutFlat.length >= needed)
1330
+ if (scratchEarcutVerts.length >= needed)
1203
1331
  return;
1204
- let cap = scratchEarcutFlat.length;
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 = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
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
- vUV = aUV;
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
- return new Float32Array([
1336
- t.a * sx,
1337
- t.b * sy,
1338
- 0,
1339
- t.c * sx,
1340
- t.d * sy,
1341
- 0,
1342
- t.e * sx - 1,
1343
- t.f * sy + 1,
1344
- 1,
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;