@oh-just-another/renderer-canvas 0.59.0 → 0.61.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 +108 -0
  2. package/README.md +10 -7
  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 +59 -14
  31. package/dist/layered-surface.js.map +1 -1
  32. package/dist/recording-target.d.ts +56 -13
  33. package/dist/recording-target.d.ts.map +1 -1
  34. package/dist/recording-target.js +229 -38
  35. package/dist/recording-target.js.map +1 -1
  36. package/dist/render-worker.js +19 -7
  37. package/dist/render-worker.js.map +1 -1
  38. package/dist/replay-codec.d.ts +56 -0
  39. package/dist/replay-codec.d.ts.map +1 -0
  40. package/dist/replay-codec.js +543 -0
  41. package/dist/replay-codec.js.map +1 -0
  42. package/dist/tile-compositor.d.ts +52 -1
  43. package/dist/tile-compositor.d.ts.map +1 -1
  44. package/dist/tile-compositor.js +82 -5
  45. package/dist/tile-compositor.js.map +1 -1
  46. package/dist/webgl2-curve.d.ts +12 -2
  47. package/dist/webgl2-curve.d.ts.map +1 -1
  48. package/dist/webgl2-curve.js +168 -40
  49. package/dist/webgl2-curve.js.map +1 -1
  50. package/dist/webgl2-ellipse.d.ts +1 -29
  51. package/dist/webgl2-ellipse.d.ts.map +1 -1
  52. package/dist/webgl2-ellipse.js +35 -22
  53. package/dist/webgl2-ellipse.js.map +1 -1
  54. package/dist/webgl2-msdf-text.d.ts +15 -2
  55. package/dist/webgl2-msdf-text.d.ts.map +1 -1
  56. package/dist/webgl2-msdf-text.js +101 -9
  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 +14 -35
  63. package/dist/webgl2-stroke.d.ts.map +1 -1
  64. package/dist/webgl2-stroke.js +27 -30
  65. package/dist/webgl2-stroke.js.map +1 -1
  66. package/dist/webgl2-target.d.ts +95 -13
  67. package/dist/webgl2-target.d.ts.map +1 -1
  68. package/dist/webgl2-target.js +423 -230
  69. package/dist/webgl2-target.js.map +1 -1
  70. 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
@@ -32,6 +33,15 @@ export class WebGL2Target {
32
33
  * from `vbo` so the static unit quad never gets stomped.
33
34
  */
34
35
  dynamicVbo;
36
+ /**
37
+ * VAO recording the solid program's `aPos` layout (2 × FLOAT, tight)
38
+ * on `dynamicVbo`. Recorded once in the constructor; the polygon /
39
+ * triangle-fan / stroke draws just bind it instead of re-issuing
40
+ * `enableVertexAttribArray` + `vertexAttribPointer` per draw. The
41
+ * earcut path's ELEMENT_ARRAY_BUFFER binding is captured by this VAO
42
+ * too (a single shared index buffer, bound lazily on first use).
43
+ */
44
+ dynamicVao;
35
45
  uTransformLoc;
36
46
  uColorLoc;
37
47
  uOpacityLoc;
@@ -65,10 +75,27 @@ export class WebGL2Target {
65
75
  textAlign = "left";
66
76
  textBaseline = "top";
67
77
  /**
68
- * Polyline path being assembled by moveTo / lineTo. Cleared on
69
- * `beginPath()`; pushed to GPU on `stroke()`.
78
+ * Polyline path being assembled by moveTo / lineTo, as a flat
79
+ * growable `[x0, y0, x1, y1, ...]` buffer with a point-count cursor —
80
+ * no per-vertex `{x, y}` object churn on the hot path-building path.
81
+ * Cleared on `beginPath()`; consumed by `fill()` / `stroke()`.
82
+ * Per-instance (not module-level) so interleaved path building on two
83
+ * targets can't stomp each other. Capacity ratchets up, never shrinks.
70
84
  */
71
- currentPolyline = [];
85
+ pathXY = new Float64Array(INITIAL_PATH_CAPACITY * 2);
86
+ /** Number of (x, y) points currently in `pathXY`. */
87
+ pathPts = 0;
88
+ /** Append one point to the flat path buffer, growing it if needed. */
89
+ pushPathPoint(x, y) {
90
+ if ((this.pathPts + 1) * 2 > this.pathXY.length) {
91
+ const next = new Float64Array(this.pathXY.length * 2);
92
+ next.set(this.pathXY);
93
+ this.pathXY = next;
94
+ }
95
+ this.pathXY[this.pathPts * 2] = x;
96
+ this.pathXY[this.pathPts * 2 + 1] = y;
97
+ this.pathPts++;
98
+ }
72
99
  /**
73
100
  * Curve segments collected since the last `beginPath()`. Quadratic
74
101
  * and cubic Bezier `*CurveTo` calls push here in addition to pushing
@@ -96,24 +123,47 @@ export class WebGL2Target {
96
123
  * snapshot the path either.
97
124
  */
98
125
  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.
126
+ /**
127
+ * Sharp-rect fill batcher coalesces consecutive axis-aligned
128
+ * `rect()` + `fill()` calls into one `drawArraysInstanced` (B19). Any
129
+ * non-batchable draw (`stroke`, ellipse / polygon / curve fill, image,
130
+ * text) and every surface op (`clear`, `resize`, frame `present`)
131
+ * drain it first via {@link flushRectBatch}, so submission order —
132
+ * hence z-order — is preserved. The GL pipeline is created lazily on
133
+ * the first flush; a scene with no sharp rects never allocates it.
134
+ */
135
+ rectBatch = new RectBatch();
136
+ rectPipeline = null;
137
+ constructor(canvas, width, height, options = {}) {
138
+ // `preserveDrawingBuffer` keeps the drawing buffer across composites.
139
+ // It defaults to `true` because the interactive editor renders
140
+ // incrementally: `renderScene` clears + redraws only the dirty rect
141
+ // each frame (see the dirty-rect logic in renderer-core /
142
+ // render-orchestrator) and relies on the rest of the previous frame
143
+ // surviving. When this flag is `false` the spec permits the browser
144
+ // to wipe the buffer after each composite, so everything outside the
145
+ // dirty rect disappears in the steady state.
146
+ //
147
+ // This is NOT for readback: PNG export / screenshots render through a
148
+ // separate offscreen Canvas2D target (`createOffscreenCanvas2DTarget`
149
+ // in `png-export` / `exporter` / `tile-compositor`), never this live
150
+ // context, so they don't depend on the flag.
105
151
  //
152
+ // On Safari / iOS `true` forces a full re-composite per swap. A host
153
+ // that redraws the whole frame every time (dirty-rect culling
154
+ // disabled) can pass `preserveDrawingBuffer: false` for that win.
155
+ const preserveDrawingBuffer = options.preserveDrawingBuffer ?? true;
106
156
  // Try with antialiasing first; some integrated GPUs deny the
107
157
  // context when MSAA isn't available. Retry plain on failure so
108
158
  // WebGL2 isn't lost entirely for a stylistic preference.
109
159
  let gl = canvas.getContext("webgl2", {
110
160
  antialias: true,
111
161
  premultipliedAlpha: true,
112
- preserveDrawingBuffer: true,
162
+ preserveDrawingBuffer,
113
163
  });
114
164
  gl ??= canvas.getContext("webgl2", {
115
165
  premultipliedAlpha: true,
116
- preserveDrawingBuffer: true,
166
+ preserveDrawingBuffer,
117
167
  });
118
168
  if (!gl) {
119
169
  throw new Error("WebGL2 unavailable in this environment (probably hit the per-page GL context cap; " +
@@ -135,11 +185,39 @@ export class WebGL2Target {
135
185
  // empty — each draw bind + bufferData fills it.
136
186
  this.dynamicVbo = glReq(this.gl.createBuffer());
137
187
  this.aPosLoc = this.gl.getAttribLocation(this.program, "aPos");
188
+ // Default-VAO layout: `aPos` on the static unit-quad VBO (bound
189
+ // above). Recorded once here; every VAO-owning pipeline restores the
190
+ // default VAO after its draw, so this state is never clobbered.
138
191
  this.gl.enableVertexAttribArray(this.aPosLoc);
139
192
  this.gl.vertexAttribPointer(this.aPosLoc, 2, this.gl.FLOAT, false, 0, 0);
193
+ // Dynamic-geometry VAO: same `aPos` layout, on the dynamic VBO.
194
+ // Shared by the polygon-fill, triangle-fan and stroke draws (all use
195
+ // the identical single-attribute layout).
196
+ this.dynamicVao = glReq(this.gl.createVertexArray());
197
+ this.gl.bindVertexArray(this.dynamicVao);
198
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.dynamicVbo);
199
+ this.gl.enableVertexAttribArray(this.aPosLoc);
200
+ this.gl.vertexAttribPointer(this.aPosLoc, 2, this.gl.FLOAT, false, 0, 0);
201
+ this.gl.bindVertexArray(null);
202
+ // Leave the static unit-quad VBO as the bound ARRAY_BUFFER, matching
203
+ // the default-VAO setup above.
204
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vbo);
140
205
  this.uTransformLoc = this.gl.getUniformLocation(this.program, "uTransform");
141
206
  this.uColorLoc = this.gl.getUniformLocation(this.program, "uColor");
142
207
  this.uOpacityLoc = this.gl.getUniformLocation(this.program, "uOpacity");
208
+ // Compile EVERY pipeline eagerly. Lazy first-use compilation used to
209
+ // throw in the middle of a render pass (e.g. the first ellipse of a
210
+ // frame), where nothing catches it — the frame loop died with a raw
211
+ // "shader compile failed" instead of falling back. Constructor-time
212
+ // failure is what `createLayeredSurfaceWithFallback` catches, so a
213
+ // context that can't compile (driver quirk, context-limit eviction)
214
+ // degrades to canvas2d with a toast instead of a broken canvas. The
215
+ // upfront cost is a handful of small shaders, paid once per surface.
216
+ this.rectPipeline = new RectInstancePipeline(this.gl);
217
+ this.curvePipeline = new LoopBlinnCurvePipeline(this.gl);
218
+ this.ellipsePipeline = new EllipsePipeline(this.gl);
219
+ this.msdfPipeline = new MsdfTextPipeline(this.gl);
220
+ this.ensureImageProgram();
143
221
  this.gl.enable(this.gl.BLEND);
144
222
  // Premultiplied-alpha blending. The context was created with
145
223
  // `premultipliedAlpha: true` — that's the contract with the browser
@@ -164,6 +242,9 @@ export class WebGL2Target {
164
242
  * in sync so downstream renderers see the new dimensions.
165
243
  */
166
244
  resize(width, height) {
245
+ // Queued instances carry a clip-space matrix projected against the
246
+ // old size; drain them before the size / viewport change.
247
+ this.flushRectBatch();
167
248
  this._size.width = width;
168
249
  this._size.height = height;
169
250
  this.gl.viewport(0, 0, width, height);
@@ -175,6 +256,13 @@ export class WebGL2Target {
175
256
  * and runtime backend switches quickly hit the cap.
176
257
  */
177
258
  dispose() {
259
+ // Drop any undrawn queued rects and release the instance pipeline's
260
+ // GL resources (VAO / buffers / program).
261
+ this.rectBatch.reset();
262
+ if (this.rectPipeline) {
263
+ this.rectPipeline.dispose();
264
+ this.rectPipeline = null;
265
+ }
178
266
  if (this.msdfPipeline) {
179
267
  this.msdfPipeline.dispose();
180
268
  this.msdfPipeline = null;
@@ -196,7 +284,12 @@ export class WebGL2Target {
196
284
  this.ellipsePipeline.dispose();
197
285
  this.ellipsePipeline = null;
198
286
  }
287
+ this.gl.deleteVertexArray(this.dynamicVao);
199
288
  this.gl.deleteBuffer(this.dynamicVbo);
289
+ if (this.imageQuadVao) {
290
+ this.gl.deleteVertexArray(this.imageQuadVao);
291
+ this.imageQuadVao = null;
292
+ }
200
293
  if (this.imageQuadVbo) {
201
294
  this.gl.deleteBuffer(this.imageQuadVbo);
202
295
  this.imageQuadVbo = null;
@@ -301,7 +394,7 @@ export class WebGL2Target {
301
394
  // --- Path primitives ---
302
395
  beginPath() {
303
396
  this.currentPath = null;
304
- this.currentPolyline = [];
397
+ this.pathPts = 0;
305
398
  this.currentCurves = [];
306
399
  this.currentEllipse = null;
307
400
  }
@@ -317,15 +410,15 @@ export class WebGL2Target {
317
410
  }
318
411
  _pathRect = { x: 0, y: 0, width: 0, height: 0 };
319
412
  moveTo(x, y) {
320
- this.currentPolyline = [{ x, y }];
413
+ this.pathPts = 0;
414
+ this.pushPathPoint(x, y);
321
415
  }
322
416
  lineTo(x, y) {
323
- this.currentPolyline.push({ x, y });
417
+ this.pushPathPoint(x, y);
324
418
  }
325
419
  closePath() {
326
- const start = this.currentPolyline[0];
327
- if (this.currentPolyline.length > 1 && start !== undefined) {
328
- this.currentPolyline.push({ ...start });
420
+ if (this.pathPts > 1) {
421
+ this.pushPathPoint(req(this.pathXY[0]), req(this.pathXY[1]));
329
422
  }
330
423
  }
331
424
  /**
@@ -337,7 +430,7 @@ export class WebGL2Target {
337
430
  */
338
431
  ellipse(cx, cy, rx, ry) {
339
432
  this.currentEllipse = { cx, cy, rx, ry };
340
- this.currentPolyline = [];
433
+ this.pathPts = 0;
341
434
  this.currentPath = null;
342
435
  }
343
436
  /**
@@ -349,13 +442,10 @@ export class WebGL2Target {
349
442
  const scale = Math.hypot(this.transform.a, this.transform.b);
350
443
  const screenRadius = Math.max(e.rx, e.ry) * (Number.isFinite(scale) && scale > 0 ? scale : 1);
351
444
  const segments = Math.max(ELLIPSE_MIN_SEGMENTS, Math.min(ELLIPSE_MAX_SEGMENTS, Math.ceil(Math.PI * screenRadius * 0.7)));
352
- this.currentPolyline = [];
445
+ this.pathPts = 0;
353
446
  for (let i = 0; i <= segments; i++) {
354
447
  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
- });
448
+ this.pushPathPoint(e.cx + e.rx * Math.cos(t), e.cy + e.ry * Math.sin(t));
359
449
  }
360
450
  }
361
451
  /**
@@ -371,7 +461,7 @@ export class WebGL2Target {
371
461
  * (sub-pixel zoom-aware).
372
462
  */
373
463
  quadraticCurveTo(cx, cy, x, y) {
374
- const start = this.currentPolyline[this.currentPolyline.length - 1] ?? { x: cx, y: cy };
464
+ const start = this.lastPathPoint() ?? { x: cx, y: cy };
375
465
  this.currentCurves.push({
376
466
  kind: "q",
377
467
  points: [start, { x: cx, y: cy }, { x, y }],
@@ -385,18 +475,24 @@ export class WebGL2Target {
385
475
  { kind: "M", to: start },
386
476
  { kind: "Q", control: { x: cx, y: cy }, to: { x, y } },
387
477
  ], tolerance);
388
- for (let i = 1; i < pts.length; i++)
389
- this.currentPolyline.push(req(pts[i]));
478
+ for (let i = 1; i < pts.length; i++) {
479
+ const p = req(pts[i]);
480
+ this.pushPathPoint(p.x, p.y);
481
+ }
390
482
  return;
391
483
  }
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]));
484
+ const count = Math.max(8, Math.min(128, Math.ceil(curveLengthEstimate(start.x, start.y, x, y) / tolerance)));
485
+ // Sample t in (0, 1] directly into the flat path buffer the
486
+ // start point (t = 0) is already the path's last vertex.
487
+ for (let i = 1; i <= count; i++) {
488
+ const t = i / count;
489
+ const u = 1 - t;
490
+ this.pushPathPoint(u * u * start.x + 2 * u * t * cx + t * t * x, u * u * start.y + 2 * u * t * cy + t * t * y);
491
+ }
396
492
  }
397
493
  /** Cubic Bezier — same dual-track approach as quadratic. */
398
494
  bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
399
- const start = this.currentPolyline[this.currentPolyline.length - 1] ?? { x, y };
495
+ const start = this.lastPathPoint() ?? { x, y };
400
496
  this.currentCurves.push({
401
497
  kind: "c",
402
498
  points: [start, { x: c1x, y: c1y }, { x: c2x, y: c2y }, { x, y }],
@@ -413,14 +509,32 @@ export class WebGL2Target {
413
509
  to: { x, y },
414
510
  },
415
511
  ], tolerance);
416
- for (let i = 1; i < pts.length; i++)
417
- this.currentPolyline.push(req(pts[i]));
512
+ for (let i = 1; i < pts.length; i++) {
513
+ const p = req(pts[i]);
514
+ this.pushPathPoint(p.x, p.y);
515
+ }
418
516
  return;
419
517
  }
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]));
518
+ const count = Math.max(12, Math.min(192, Math.ceil(curveLengthEstimate(start.x, start.y, x, y) / tolerance)));
519
+ // Sample t in (0, 1] directly into the flat path buffer.
520
+ for (let i = 1; i <= count; i++) {
521
+ const t = i / count;
522
+ const u = 1 - t;
523
+ const u2 = u * u;
524
+ const u3 = u2 * u;
525
+ const t2 = t * t;
526
+ const t3 = t2 * t;
527
+ 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);
528
+ }
529
+ }
530
+ /** Last point of the flat path buffer as a fresh `{x, y}`, or `undefined`. */
531
+ lastPathPoint() {
532
+ if (this.pathPts === 0)
533
+ return undefined;
534
+ return {
535
+ x: req(this.pathXY[(this.pathPts - 1) * 2]),
536
+ y: req(this.pathXY[(this.pathPts - 1) * 2 + 1]),
537
+ };
424
538
  }
425
539
  /**
426
540
  * World-unit tolerance that maps to ~`SCREEN_TOLERANCE_PX` on screen
@@ -443,28 +557,16 @@ export class WebGL2Target {
443
557
  * as a textured quad via a dedicated program created lazily on the
444
558
  * first image call.
445
559
  */
446
- drawImage(image, dx, dy, dw, dh, dynamic) {
560
+ drawImage(image, dx, dy, dw, dh, dynamic, crop) {
561
+ this.flushRectBatch(); // preserve z-order: emit queued rect fills first
447
562
  const tex = this.textureFor(image, dynamic ?? false);
448
563
  if (!tex)
449
564
  return;
450
- if (!this.imageProgram) {
451
- this.imageProgram = createImageProgram(this.gl);
452
- // Lazily create the image quad VBO too — interleaved
453
- // (pos.xy, uv.xy) for a TRIANGLE_STRIP unit quad. Static — never
454
- // re-uploaded; the per-call placement goes through `uTransform`.
455
- this.imageQuadVbo = this.gl.createBuffer();
456
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.imageQuadVbo);
457
- this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1]), this.gl.STATIC_DRAW);
458
- }
459
- const ip = this.imageProgram;
565
+ const ip = this.ensureImageProgram();
460
566
  this.gl.useProgram(ip.program);
461
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.imageQuadVbo);
462
- this.gl.enableVertexAttribArray(ip.aPos);
463
- this.gl.vertexAttribPointer(ip.aPos, 2, this.gl.FLOAT, false, 16, 0);
464
- this.gl.enableVertexAttribArray(ip.aUV);
465
- this.gl.vertexAttribPointer(ip.aUV, 2, this.gl.FLOAT, false, 16, 8);
567
+ this.gl.bindVertexArray(this.imageQuadVao);
466
568
  // Project the drawn region through transform + viewport.
467
- const projected = applyImageMat({
569
+ const projected = applyMat({
468
570
  a: this.transform.a * dw,
469
571
  b: this.transform.b * dw,
470
572
  c: this.transform.c * dh,
@@ -474,16 +576,56 @@ export class WebGL2Target {
474
576
  }, this._size.width, this._size.height);
475
577
  this.gl.uniformMatrix3fv(ip.uTransform, false, projected);
476
578
  this.gl.uniform1f(ip.uOpacity, this.opacity);
579
+ // Crop as a UV sub-rect. Identity (no crop) is offset (0,0), scale (1,1);
580
+ // crop fractions are already in texture-UV [0,1] space (unlike Canvas2D,
581
+ // which multiplies by intrinsic pixels), so they map straight to uniforms.
582
+ if (crop && (crop.x !== 0 || crop.y !== 0 || crop.width !== 1 || crop.height !== 1)) {
583
+ this.gl.uniform2f(ip.uUvOffset, crop.x, crop.y);
584
+ this.gl.uniform2f(ip.uUvScale, crop.width, crop.height);
585
+ }
586
+ else {
587
+ this.gl.uniform2f(ip.uUvOffset, 0, 0);
588
+ this.gl.uniform2f(ip.uUvScale, 1, 1);
589
+ }
477
590
  this.gl.activeTexture(this.gl.TEXTURE0);
478
591
  this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
479
592
  this.gl.uniform1i(ip.uTex, 0);
480
593
  this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
594
+ // Reset to the default VAO so the image quad's attribute state can't
595
+ // leak into other pipelines (matches the rect-batch discipline).
596
+ this.gl.bindVertexArray(null);
481
597
  // Restore the solid-colour program for subsequent fills / strokes.
482
598
  this.restoreSolidProgram();
483
599
  }
484
- /** Lazy image program + its static unit quad+UV VBO. */
600
+ /**
601
+ * Image program + its static unit quad+UV VBO + recorded VAO. Built by
602
+ * `ensureImageProgram` — called from the constructor so a shader compile
603
+ * failure surfaces there (and triggers the backend fallback) rather than
604
+ * mid-frame on the first drawn image.
605
+ */
485
606
  imageProgram = null;
607
+ ensureImageProgram() {
608
+ if (this.imageProgram)
609
+ return this.imageProgram;
610
+ this.imageProgram = createImageProgram(this.gl);
611
+ // Interleaved (pos.xy, uv.xy) TRIANGLE_STRIP unit quad. Static — never
612
+ // re-uploaded; per-call placement goes through `uTransform`. The
613
+ // attribute layout is recorded into a VAO once; per draw the image path
614
+ // just binds the VAO.
615
+ this.imageQuadVbo = this.gl.createBuffer();
616
+ this.imageQuadVao = glReq(this.gl.createVertexArray());
617
+ this.gl.bindVertexArray(this.imageQuadVao);
618
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.imageQuadVbo);
619
+ this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1]), this.gl.STATIC_DRAW);
620
+ this.gl.enableVertexAttribArray(this.imageProgram.aPos);
621
+ this.gl.vertexAttribPointer(this.imageProgram.aPos, 2, this.gl.FLOAT, false, 16, 0);
622
+ this.gl.enableVertexAttribArray(this.imageProgram.aUV);
623
+ this.gl.vertexAttribPointer(this.imageProgram.aUV, 2, this.gl.FLOAT, false, 16, 8);
624
+ this.gl.bindVertexArray(null);
625
+ return this.imageProgram;
626
+ }
486
627
  imageQuadVbo = null;
628
+ imageQuadVao = null;
487
629
  /**
488
630
  * `TexImageSource` → uploaded `WebGLTexture` cache. A plain `Map`
489
631
  * with LRU eviction + explicit `gl.deleteTexture` (see
@@ -570,6 +712,25 @@ export class WebGL2Target {
570
712
  this.gl.deleteTexture(tex);
571
713
  }
572
714
  }
715
+ /**
716
+ * Synchronously release the GPU texture cached for `source` (B6). Hosts
717
+ * call this when an image is discarded or its bitmap replaced, so the VRAM
718
+ * is freed immediately instead of waiting for LRU pressure to reach the
719
+ * entry. No-op (returns `false`) for sources that were never uploaded.
720
+ * Text-bitmap-backed handles are owned by the text cache (its own evictor
721
+ * deletes them) and are left alone.
722
+ */
723
+ invalidateImage(source) {
724
+ const key = source;
725
+ const tex = this.textures.get(key);
726
+ if (tex === undefined)
727
+ return false;
728
+ if (isTextBitmapBacked(this.textBitmaps, key))
729
+ return false;
730
+ this.textures.delete(key);
731
+ this.gl.deleteTexture(tex);
732
+ return true;
733
+ }
573
734
  fill(_rule) {
574
735
  void _rule;
575
736
  const effectiveAlpha = this.opacity * this.fillAlpha;
@@ -578,7 +739,8 @@ export class WebGL2Target {
578
739
  // Ellipse path — single fragment-SDF quad regardless of radius.
579
740
  // Vector-perfect at any zoom; 4 vertices instead of 24-512.
580
741
  if (this.currentEllipse) {
581
- this.ellipsePipeline ??= new EllipsePipeline(this.gl);
742
+ this.flushRectBatch(); // preserve z-order: emit queued rects first
743
+ this.ellipsePipeline ??= new EllipsePipeline(this.gl); // rebuilt only after dispose
582
744
  const e = this.currentEllipse;
583
745
  this.ellipsePipeline.draw(e.cx, e.cy, e.rx, e.ry, this.fillColor, effectiveAlpha, this.transform, this._size);
584
746
  this.restoreSolidProgram();
@@ -589,27 +751,30 @@ export class WebGL2Target {
589
751
  // most shape backgrounds (rectangles) hit it.
590
752
  if (this.currentPath) {
591
753
  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);
754
+ // Project the rect's unit-quad → clip affine, then queue it as one
755
+ // instance instead of issuing a draw. Same math as `applyMat`
756
+ // (unit-quad NDC) inlined into scalars — the batcher stores the
757
+ // two variable columns + translation; the constant [0,0,1] third
758
+ // column is reconstructed in the instance vertex shader.
759
+ const t = this.transform;
760
+ const sx = 2 / this._size.width;
761
+ const sy = -2 / this._size.height;
762
+ const pa = t.a * r.width;
763
+ const pb = t.b * r.width;
764
+ const pc = t.c * r.height;
765
+ const pd = t.d * r.height;
766
+ const pe = t.e + t.a * r.x + t.c * r.y;
767
+ const pf = t.f + t.b * r.x + t.d * r.y;
768
+ 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
769
  return;
606
770
  }
607
771
  // Polygon path — assembled via moveTo / lineTo / bezierCurveTo.
608
772
  // Triangulated through earcut so concave shapes (arrows, stars,
609
773
  // lightning bolts) fill correctly. Earcut is dependency-free and
610
774
  // handles holes too if ever needed.
611
- if (this.currentPolyline.length >= 3) {
612
- this.fillPolygonEarcut(this.currentPolyline, effectiveAlpha);
775
+ if (this.pathPts >= 3) {
776
+ this.flushRectBatch(); // preserve z-order: emit queued rects first
777
+ this.fillPolygonEarcut(this.pathXY, this.pathPts, effectiveAlpha);
613
778
  }
614
779
  // Loop-Blinn curve overlay. Adds fragment-tested quadratic / cubic
615
780
  // triangles on top of the polygon fill so curve regions
@@ -626,7 +791,8 @@ export class WebGL2Target {
626
791
  // doesn't have. The artefact is invisible at 1× zoom and tiny even
627
792
  // at 20×.
628
793
  if (this.currentCurves.length > 0) {
629
- this.curvePipeline ??= new LoopBlinnCurvePipeline(this.gl);
794
+ this.flushRectBatch(); // preserve z-order: emit queued rects first
795
+ this.curvePipeline ??= new LoopBlinnCurvePipeline(this.gl); // rebuilt only after dispose
630
796
  this.curvePipeline.draw(this.currentCurves, this.fillColor, effectiveAlpha, this.transform, this._size);
631
797
  this.restoreSolidProgram();
632
798
  }
@@ -640,47 +806,36 @@ export class WebGL2Target {
640
806
  * earcut returns an empty index list (degenerate self-intersecting
641
807
  * polygon).
642
808
  */
643
- fillPolygonEarcut(polyline, effectiveAlpha) {
809
+ fillPolygonEarcut(xy, pointCount, effectiveAlpha) {
644
810
  // Skip the implicitly-closed duplicate last vertex if the caller
645
811
  // already issued `closePath` — earcut would treat it as a degenerate
646
812
  // 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;
813
+ const n = pointCount >= 4 &&
814
+ xy[0] === xy[(pointCount - 1) * 2] &&
815
+ xy[1] === xy[(pointCount - 1) * 2 + 1]
816
+ ? pointCount - 1
817
+ : pointCount;
655
818
  if (n < 3)
656
819
  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));
820
+ // earcut wants a flat [x0, y0, x1, y1, ...] in world coords — the
821
+ // path buffer already is one, and earcut accepts any array-like
822
+ // with [i] + length, so pass a no-copy `subarray` view directly.
823
+ const indices = earcut(xy.subarray(0, n * 2));
669
824
  if (indices.length === 0) {
670
825
  // Pathological polygon — fall back to a fan so something renders.
671
- this.drawTriangleFan(polyline, n, effectiveAlpha);
826
+ this.drawTriangleFan(xy, n, effectiveAlpha);
672
827
  return;
673
828
  }
674
- // Project once into clip space, then index-draw. Shares the earcut
675
- // vertex-count budget — `ensureEarcutVertexCapacity` above already
676
- // grew `scratchEarcutVerts` if needed.
829
+ // Project once into clip space, then index-draw.
830
+ ensureEarcutVertexCapacity(n);
677
831
  const sx = 2 / this._size.width;
678
832
  const sy = -2 / this._size.height;
679
833
  const verts = scratchEarcutVerts;
680
834
  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;
835
+ const px = req(xy[i * 2]);
836
+ const py = req(xy[i * 2 + 1]);
837
+ const wx = this.transform.a * px + this.transform.c * py + this.transform.e;
838
+ const wy = this.transform.b * px + this.transform.d * py + this.transform.f;
684
839
  verts[i * 2] = wx * sx - 1;
685
840
  verts[i * 2 + 1] = wy * sy + 1;
686
841
  }
@@ -693,23 +848,25 @@ export class WebGL2Target {
693
848
  }
694
849
  const gl = this.gl;
695
850
  gl.useProgram(this.program);
696
- // Use the dynamic VBOleaves the static unit-quad VBO untouched so
697
- // subsequent rect fills don't pay for a re-upload.
851
+ // Bind the dynamic-geometry VAO — the `aPos` layout on the dynamic
852
+ // VBO was recorded once in the constructor. The static unit-quad VBO
853
+ // stays untouched so subsequent rect fills don't pay for a re-upload.
854
+ gl.bindVertexArray(this.dynamicVao);
698
855
  gl.bindBuffer(gl.ARRAY_BUFFER, this.dynamicVbo);
699
856
  gl.bufferData(gl.ARRAY_BUFFER, verts.subarray(0, n * 2), gl.DYNAMIC_DRAW);
700
- gl.enableVertexAttribArray(this.aPosLoc);
701
- gl.vertexAttribPointer(this.aPosLoc, 2, gl.FLOAT, false, 0, 0);
702
857
  gl.uniformMatrix3fv(this.uTransformLoc, false, IDENTITY_MAT3);
703
858
  gl.uniform3f(this.uColorLoc, this.fillColor[0], this.fillColor[1], this.fillColor[2]);
704
859
  gl.uniform1f(this.uOpacityLoc, effectiveAlpha);
705
860
  // Lazy IBO — earcut returns 16-bit indices for ≤65535 verts (the
706
- // realistic ceiling for any one polygon).
861
+ // realistic ceiling for any one polygon). Bound while `dynamicVao`
862
+ // is active, so the ELEMENT_ARRAY_BUFFER binding is captured by the
863
+ // VAO and never leaks into the default VAO.
707
864
  this.indexBuffer ??= gl.createBuffer();
708
865
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
709
866
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, scratchEarcutIndices.subarray(0, indices.length), gl.DYNAMIC_DRAW);
710
867
  gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
711
- // Restore solid program's attribute binding to the static unit-quad
712
- // VBO so the next rect fill picks up the right vertex stream.
868
+ // Reset to the default VAO so the next draw starts from clean state.
869
+ gl.bindVertexArray(null);
713
870
  this.restoreSolidProgram();
714
871
  }
715
872
  indexBuffer = null;
@@ -718,29 +875,31 @@ export class WebGL2Target {
718
875
  * convex polygons correctly; concave ones get a wrong silhouette (the
719
876
  * earcut path handles those instead).
720
877
  */
721
- drawTriangleFan(polyline, n, effectiveAlpha) {
878
+ drawTriangleFan(xy, n, effectiveAlpha) {
722
879
  const sx = 2 / this._size.width;
723
880
  const sy = -2 / this._size.height;
724
881
  // Share the module-level scratch verts with `fillPolygonEarcut`.
725
882
  ensureEarcutVertexCapacity(n);
726
883
  const verts = scratchEarcutVerts;
727
884
  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;
885
+ const px = req(xy[i * 2]);
886
+ const py = req(xy[i * 2 + 1]);
887
+ const wx = this.transform.a * px + this.transform.c * py + this.transform.e;
888
+ const wy = this.transform.b * px + this.transform.d * py + this.transform.f;
731
889
  verts[i * 2] = wx * sx - 1;
732
890
  verts[i * 2 + 1] = wy * sy + 1;
733
891
  }
734
892
  const gl = this.gl;
735
893
  gl.useProgram(this.program);
894
+ gl.bindVertexArray(this.dynamicVao);
736
895
  gl.bindBuffer(gl.ARRAY_BUFFER, this.dynamicVbo);
737
896
  gl.bufferData(gl.ARRAY_BUFFER, verts.subarray(0, n * 2), gl.DYNAMIC_DRAW);
738
- gl.enableVertexAttribArray(this.aPosLoc);
739
- gl.vertexAttribPointer(this.aPosLoc, 2, gl.FLOAT, false, 0, 0);
740
897
  gl.uniformMatrix3fv(this.uTransformLoc, false, IDENTITY_MAT3);
741
898
  gl.uniform3f(this.uColorLoc, this.fillColor[0], this.fillColor[1], this.fillColor[2]);
742
899
  gl.uniform1f(this.uOpacityLoc, effectiveAlpha);
743
900
  gl.drawArrays(gl.TRIANGLE_FAN, 0, n);
901
+ // Reset to the default VAO so the next draw starts from clean state.
902
+ gl.bindVertexArray(null);
744
903
  this.restoreSolidProgram();
745
904
  }
746
905
  /**
@@ -748,9 +907,9 @@ export class WebGL2Target {
748
907
  * `bounds` wipes only the rectangle the editor's dirty-rect pass
749
908
  * identified. Honouring `bounds` is mandatory — when the scene
750
909
  * reference doesn't change, the editor sends a zero-area dirty rect
751
- * and expects the previous frame to survive untouched.
910
+ * and expects the previous frame to survive untouched. The default
752
911
  * `preserveDrawingBuffer: true` carries the persistent frame across
753
- * composites.
912
+ * composites (see the constructor for the opt-out).
754
913
  *
755
914
  * For bounded clears the implementation flips on a scissor box so the
756
915
  * clear is confined to the dirty rect, mirroring Canvas2D's
@@ -759,6 +918,10 @@ export class WebGL2Target {
759
918
  * caller's top-left CSS-pixel rect.
760
919
  */
761
920
  clear(bounds) {
921
+ // Drain queued rect fills before wiping pixels — a clear that lands
922
+ // mid-stream must not erase rects queued after it, nor let them
923
+ // survive a wipe meant to cover them.
924
+ this.flushRectBatch();
762
925
  const bitmapW = this.gl.canvas.width;
763
926
  const bitmapH = this.gl.canvas.height;
764
927
  if (bounds) {
@@ -794,51 +957,69 @@ export class WebGL2Target {
794
957
  this.strokeWidth = Math.max(0, width);
795
958
  }
796
959
  stroke() {
960
+ this.flushRectBatch(); // preserve z-order: emit queued rect fills first
797
961
  if (this.currentPath) {
798
962
  // Rect outline → 4 corners as a closed polyline.
799
963
  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
- ];
964
+ this.pathPts = 0;
965
+ this.pushPathPoint(r.x, r.y);
966
+ this.pushPathPoint(r.x + r.width, r.y);
967
+ this.pushPathPoint(r.x + r.width, r.y + r.height);
968
+ this.pushPathPoint(r.x, r.y + r.height);
969
+ this.pushPathPoint(r.x, r.y);
807
970
  }
808
971
  // Ellipse outline — lazily generate the polyline approximation here
809
972
  // so callers that only fill don't pay for the 24-512 vertex
810
973
  // allocation. EllipsePipeline owns the fill path; stroke still goes
811
974
  // through the polygon stroke pipeline.
812
- if (this.currentEllipse && this.currentPolyline.length < 2) {
975
+ if (this.currentEllipse && this.pathPts < 2) {
813
976
  this.buildEllipseStrokePolyline(this.currentEllipse);
814
977
  }
815
- if (this.currentPolyline.length < 2)
978
+ if (this.pathPts < 2)
816
979
  return;
817
980
  const effectiveAlpha = this.opacity * this.strokeAlpha;
818
981
  if (effectiveAlpha <= 0)
819
982
  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);
983
+ const style = {
984
+ width: this.strokeWidth,
985
+ color: this.strokeColor,
986
+ opacity: effectiveAlpha,
987
+ join: this.lineJoin,
988
+ cap: this.lineCap,
989
+ };
990
+ if (this.dashArray) {
991
+ // Dashed: split the polyline into "on" sub-polylines in world
992
+ // units (Canvas2D dashes in the world-space ctx transform, so
993
+ // this matches it), then stroke each run. `dashPolyline` keeps
994
+ // its Vec2 contract, so materialise the flat path once — dashing
995
+ // is opt-in styling, the solid hot path below stays object-free.
996
+ const pts = new Array(this.pathPts);
997
+ for (let i = 0; i < this.pathPts; i++) {
998
+ pts[i] = { x: req(this.pathXY[i * 2]), y: req(this.pathXY[i * 2 + 1]) };
999
+ }
1000
+ for (const run of dashPolyline(pts, this.dashArray)) {
1001
+ if (run.length < 2)
1002
+ continue;
1003
+ ensureDashRunCapacity(run.length * 2);
1004
+ for (let i = 0; i < run.length; i++) {
1005
+ const p = req(run[i]);
1006
+ scratchDashRunXY[i * 2] = p.x;
1007
+ scratchDashRunXY[i * 2 + 1] = p.y;
1008
+ }
1009
+ this.strokePolylineFlat(scratchDashRunXY, run.length, style);
1010
+ }
1011
+ }
1012
+ else {
1013
+ this.strokePolylineFlat(this.pathXY, this.pathPts, style);
837
1014
  }
838
- // Stroke wrote into the dynamic VBO; rebind the static unit-quad VBO
839
- // so the next solid rect fill picks up the right vertex stream.
1015
+ // Stroke drew via the dynamic-geometry VAO and restored the default
1016
+ // VAO; just make sure the solid program is active again.
840
1017
  this.restoreSolidProgram();
841
1018
  }
1019
+ /** Route one flat polyline through the shared stroke pipeline. */
1020
+ strokePolylineFlat(xy, pointCount, style) {
1021
+ drawPolylineStrokeImpl(this.gl, xy, pointCount, style, this.transform, this._size, this.program, this.uTransformLoc, this.uColorLoc, this.uOpacityLoc, this.dynamicVbo, this.dynamicVao, IDENTITY_MAT3);
1022
+ }
842
1023
  // --- Stroke style state (consumed by stroke()) ---
843
1024
  setLineCap(cap) {
844
1025
  this.lineCap = cap;
@@ -876,6 +1057,7 @@ export class WebGL2Target {
876
1057
  fillText(text, x, y, maxWidth) {
877
1058
  if (text.length === 0)
878
1059
  return;
1060
+ this.flushRectBatch(); // preserve z-order: emit queued rect fills first
879
1061
  void maxWidth;
880
1062
  const atlas = this.ensureGlyphAtlas();
881
1063
  if (atlas) {
@@ -955,17 +1137,42 @@ export class WebGL2Target {
955
1137
  this.restoreSolidProgram();
956
1138
  }
957
1139
  /**
958
- * Rebind the solid program + static unit-quad VBO + cached aPos
959
- * pointer. Three GL calls, no allocation, no upload, no string-keyed
960
- * driver lookup. Called after any draw that switched program or
961
- * rebound the buffer (image, MSDF text, curves, polygon fill, triangle
962
- * fan).
1140
+ * Rebind the solid program after a draw that switched programs
1141
+ * (image, MSDF text, curves, ellipse, polygon fill, triangle fan,
1142
+ * rect batch). One GL call the default VAO's `aPos` layout on the
1143
+ * static unit-quad VBO was recorded once in the constructor and every
1144
+ * pipeline restores the default VAO (`bindVertexArray(null)`) after
1145
+ * its draw, so attribute state never needs re-declaring here.
963
1146
  */
964
1147
  restoreSolidProgram() {
965
1148
  this.gl.useProgram(this.program);
966
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vbo);
967
- this.gl.enableVertexAttribArray(this.aPosLoc);
968
- this.gl.vertexAttribPointer(this.aPosLoc, 2, this.gl.FLOAT, false, 0, 0);
1149
+ }
1150
+ /**
1151
+ * Draw any queued sharp-rect fill instances as one instanced call,
1152
+ * then restore the solid-program plumbing. Called before every
1153
+ * non-batchable draw and surface op to keep z-order intact, and from
1154
+ * {@link flushBatch} at frame end. No-op when the queue is empty.
1155
+ */
1156
+ flushRectBatch() {
1157
+ if (this.rectBatch.pending === 0)
1158
+ return;
1159
+ this.rectPipeline ??= new RectInstancePipeline(this.gl);
1160
+ const pipeline = this.rectPipeline;
1161
+ this.rectBatch.flush((data, count) => {
1162
+ pipeline.draw(data, count);
1163
+ });
1164
+ this.restoreSolidProgram();
1165
+ }
1166
+ /**
1167
+ * Flush the deferred sharp-rect batch to the GPU. The host's
1168
+ * `LayeredSurface.present()` calls this once per frame after the
1169
+ * Editor finishes drawing, so trailing rect fills reach the
1170
+ * framebuffer within the frame that queued them. Backends without a
1171
+ * batcher (Canvas2D) have no equivalent — this is WebGL2-specific and
1172
+ * not part of the `RenderTarget` interface.
1173
+ */
1174
+ flushBatch() {
1175
+ this.flushRectBatch();
969
1176
  }
970
1177
  measureText(text) {
971
1178
  return this.textMetrics(text);
@@ -999,9 +1206,15 @@ export class WebGL2Target {
999
1206
  return this.textCtx;
1000
1207
  }
1001
1208
  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}`;
1209
+ // CSS font shorthand order: `<style> <weight> <size> <family>` must
1210
+ // carry weight/style so the no-MSDF fallback (OffscreenCanvas bitmaps)
1211
+ // draws bold/italic like the MSDF path and the Canvas2D backend do.
1212
+ // It also keys the bitmap cache, so a bold word can't collide with the
1213
+ // regular one (which would render regular while colour still applied).
1214
+ // Bundled face first so the fallback matches the MSDF/Canvas2D metrics.
1215
+ const style = this.fontStyle === "italic" ? "italic " : "";
1216
+ const weight = this.fontWeight === "bold" ? "bold " : "";
1217
+ return `${style}${weight}${this.fontSize}px "${resolveBundledFamily(this.fontFamily)}", ${this.fontFamily}`;
1005
1218
  }
1006
1219
  baselineOffsetCache = new Map();
1007
1220
  /**
@@ -1042,17 +1255,11 @@ export class WebGL2Target {
1042
1255
  const atlas = this.ensureGlyphAtlas();
1043
1256
  if (atlas) {
1044
1257
  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 };
1258
+ // Shared single-pass, memoized walk — same advances `fillTextMSDF`
1259
+ // lays out (`advance * fontSize / unitsPerEm`), so measured width
1260
+ // and drawn width stay 1:1. em-width is fontSize-independent; scale
1261
+ // here. A measure after the same run was drawn hits the memo.
1262
+ return { width: measureGlyphRunEm(text, atlas, fontId) * this.fontSize };
1056
1263
  }
1057
1264
  // Fallback (no MSDF shaper): Canvas2D system-font measurement, which
1058
1265
  // matches the Canvas2D bitmap text path used in that case.
@@ -1194,19 +1401,37 @@ export const dashPolyline = (pts, pattern) => {
1194
1401
  * and never shrinks; safe for single-threaded WebGL (fill calls are
1195
1402
  * serialised through the editor's render path).
1196
1403
  */
1197
- let scratchEarcutFlat = new Float64Array(128);
1198
1404
  let scratchEarcutVerts = new Float32Array(128);
1199
1405
  let scratchEarcutIndices = new Uint16Array(256);
1200
1406
  const ensureEarcutVertexCapacity = (vertexCount) => {
1201
1407
  const needed = vertexCount * 2;
1202
- if (scratchEarcutFlat.length >= needed)
1408
+ if (scratchEarcutVerts.length >= needed)
1203
1409
  return;
1204
- let cap = scratchEarcutFlat.length;
1410
+ let cap = scratchEarcutVerts.length;
1205
1411
  while (cap < needed)
1206
1412
  cap *= 2;
1207
- scratchEarcutFlat = new Float64Array(cap);
1208
1413
  scratchEarcutVerts = new Float32Array(cap);
1209
1414
  };
1415
+ /**
1416
+ * Module-level scratch for converting one dashed-stroke run back to the
1417
+ * flat layout `drawPolylineStroke` consumes. Only the dashed path uses
1418
+ * it, and runs are consumed synchronously one at a time.
1419
+ */
1420
+ let scratchDashRunXY = new Float64Array(128);
1421
+ const ensureDashRunCapacity = (n) => {
1422
+ if (scratchDashRunXY.length >= n)
1423
+ return;
1424
+ let cap = scratchDashRunXY.length;
1425
+ while (cap < n)
1426
+ cap *= 2;
1427
+ scratchDashRunXY = new Float64Array(cap);
1428
+ };
1429
+ /**
1430
+ * Initial per-target flat path buffer capacity, in points. 128 covers a
1431
+ * rounded rect (≈ 60-100 flattened vertices at 1×) without a grow;
1432
+ * capacity doubles on demand and never shrinks.
1433
+ */
1434
+ const INITIAL_PATH_CAPACITY = 128;
1210
1435
  const ensureEarcutIndexCapacity = (n) => {
1211
1436
  if (scratchEarcutIndices.length >= n)
1212
1437
  return;
@@ -1241,63 +1466,21 @@ const isMsdfShaper = (shaper) => {
1241
1466
  * proportional to it, used to pick a JS-fallback sample count
1242
1467
  * commensurate with the tolerance.
1243
1468
  */
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
- };
1469
+ const curveLengthEstimate = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
1291
1470
  const createImageProgram = (gl) => {
1292
1471
  const vert = compileShader(gl, gl.VERTEX_SHADER, `#version 300 es
1293
1472
  in vec2 aPos;
1294
1473
  in vec2 aUV;
1295
1474
  uniform mat3 uTransform;
1475
+ uniform vec2 uUvOffset;
1476
+ uniform vec2 uUvScale;
1296
1477
  out vec2 vUV;
1297
1478
  void main() {
1298
1479
  vec3 p = uTransform * vec3(aPos, 1.0);
1299
1480
  gl_Position = vec4(p.xy, 0.0, 1.0);
1300
- vUV = aUV;
1481
+ // Crop: map the unit-quad UV into the source sub-rect. Identity is
1482
+ // offset (0,0) + scale (1,1); a crop narrows it to the kept region.
1483
+ vUV = aUV * uUvScale + uUvOffset;
1301
1484
  }`, "WebGL2");
1302
1485
  const frag = compileShader(gl, gl.FRAGMENT_SHADER, `#version 300 es
1303
1486
  precision mediump float;
@@ -1321,28 +1504,38 @@ void main() {
1321
1504
  uTransform: gl.getUniformLocation(program, "uTransform"),
1322
1505
  uTex: gl.getUniformLocation(program, "uTex"),
1323
1506
  uOpacity: gl.getUniformLocation(program, "uOpacity"),
1507
+ uUvOffset: gl.getUniformLocation(program, "uUvOffset"),
1508
+ uUvScale: gl.getUniformLocation(program, "uUvScale"),
1324
1509
  };
1325
1510
  };
1511
+ /**
1512
+ * Module-level scratch for `applyMat` — the same reuse pattern as the
1513
+ * earcut / stroke scratch buffers above. The projected mat3 is consumed
1514
+ * synchronously by `uniformMatrix3fv` (which copies the values into GL
1515
+ * state) before the next `applyMat` call, so one shared buffer avoids a
1516
+ * Float32Array allocation per rect-fill / drawImage.
1517
+ */
1518
+ const scratchMat3 = new Float32Array(9);
1326
1519
  /**
1327
1520
  * Build a 3×3 column-major matrix that maps a unit quad [0,0]–[1,1]
1328
1521
  * through the supplied 2D affine + a screen-to-clip conversion
1329
- * (pixels → NDC).
1522
+ * (pixels → NDC). Returns the module-level scratch — consume it before
1523
+ * the next call.
1330
1524
  */
1331
1525
  const applyMat = (t, w, h) => {
1332
1526
  // Pixel-space → clip-space: x' = (x / w) * 2 - 1; y' = 1 - (y / h) * 2.
1333
1527
  const sx = 2 / w;
1334
1528
  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
- ]);
1529
+ scratchMat3[0] = t.a * sx;
1530
+ scratchMat3[1] = t.b * sy;
1531
+ scratchMat3[2] = 0;
1532
+ scratchMat3[3] = t.c * sx;
1533
+ scratchMat3[4] = t.d * sy;
1534
+ scratchMat3[5] = 0;
1535
+ scratchMat3[6] = t.e * sx - 1;
1536
+ scratchMat3[7] = t.f * sy + 1;
1537
+ scratchMat3[8] = 1;
1538
+ return scratchMat3;
1346
1539
  };
1347
1540
  const VERTEX_SHADER = `#version 300 es
1348
1541
  in vec2 aPos;