@oh-just-another/renderer-canvas 0.61.1 → 0.62.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 (139) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/{canvas-target.d.ts → canvas2d/canvas-target.d.ts} +1 -0
  4. package/dist/canvas2d/canvas-target.d.ts.map +1 -0
  5. package/dist/{canvas-target.js → canvas2d/canvas-target.js} +3 -0
  6. package/dist/canvas2d/canvas-target.js.map +1 -0
  7. package/dist/canvas2d/canvas-text-shaper.d.ts.map +1 -0
  8. package/dist/canvas2d/canvas-text-shaper.js.map +1 -0
  9. package/dist/canvas2d/hi-dpi.d.ts.map +1 -0
  10. package/dist/{hi-dpi.js → canvas2d/hi-dpi.js} +1 -1
  11. package/dist/canvas2d/hi-dpi.js.map +1 -0
  12. package/dist/{image-source.d.ts → canvas2d/image-source.d.ts} +1 -1
  13. package/dist/canvas2d/image-source.d.ts.map +1 -0
  14. package/dist/{image-source.js → canvas2d/image-source.js} +7 -42
  15. package/dist/canvas2d/image-source.js.map +1 -0
  16. package/dist/canvas2d/layered-canvas.d.ts.map +1 -0
  17. package/dist/{layered-canvas.js → canvas2d/layered-canvas.js} +1 -1
  18. package/dist/canvas2d/layered-canvas.js.map +1 -0
  19. package/dist/constants.d.ts +32 -0
  20. package/dist/constants.d.ts.map +1 -1
  21. package/dist/constants.js +32 -0
  22. package/dist/constants.js.map +1 -1
  23. package/dist/glyph-bake-worker.d.ts +20 -0
  24. package/dist/glyph-bake-worker.d.ts.map +1 -0
  25. package/dist/glyph-bake-worker.js +43 -0
  26. package/dist/glyph-bake-worker.js.map +1 -0
  27. package/dist/index.d.ts +11 -11
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +11 -11
  30. package/dist/index.js.map +1 -1
  31. package/dist/{offscreen.d.ts → offscreen/offscreen.d.ts} +1 -1
  32. package/dist/offscreen/offscreen.d.ts.map +1 -0
  33. package/dist/{offscreen.js → offscreen/offscreen.js} +1 -1
  34. package/dist/offscreen/offscreen.js.map +1 -0
  35. package/dist/{recording-target.d.ts → offscreen/recording-target.d.ts} +4 -0
  36. package/dist/offscreen/recording-target.d.ts.map +1 -0
  37. package/dist/{recording-target.js → offscreen/recording-target.js} +11 -2
  38. package/dist/offscreen/recording-target.js.map +1 -0
  39. package/dist/offscreen/replay-codec.d.ts.map +1 -0
  40. package/dist/{replay-codec.js → offscreen/replay-codec.js} +16 -1
  41. package/dist/offscreen/replay-codec.js.map +1 -0
  42. package/dist/offscreen/worker-factory.d.ts +13 -0
  43. package/dist/offscreen/worker-factory.d.ts.map +1 -0
  44. package/dist/offscreen/worker-factory.js +13 -0
  45. package/dist/offscreen/worker-factory.js.map +1 -0
  46. package/dist/render-worker.js +2 -2
  47. package/dist/render-worker.js.map +1 -1
  48. package/dist/surface/layered-surface.d.ts.map +1 -0
  49. package/dist/{layered-surface.js → surface/layered-surface.js} +6 -6
  50. package/dist/surface/layered-surface.js.map +1 -0
  51. package/dist/{tile-compositor.d.ts → surface/tile-compositor.d.ts} +1 -1
  52. package/dist/surface/tile-compositor.d.ts.map +1 -0
  53. package/dist/{tile-compositor.js → surface/tile-compositor.js} +1 -1
  54. package/dist/surface/tile-compositor.js.map +1 -0
  55. package/dist/webgl2/webgl-helpers.d.ts.map +1 -0
  56. package/dist/webgl2/webgl-helpers.js.map +1 -0
  57. package/dist/webgl2/webgl2-color.d.ts.map +1 -0
  58. package/dist/webgl2/webgl2-color.js.map +1 -0
  59. package/dist/webgl2/webgl2-curve.d.ts.map +1 -0
  60. package/dist/{webgl2-curve.js → webgl2/webgl2-curve.js} +1 -1
  61. package/dist/webgl2/webgl2-curve.js.map +1 -0
  62. package/dist/webgl2/webgl2-ellipse.d.ts.map +1 -0
  63. package/dist/webgl2/webgl2-ellipse.js.map +1 -0
  64. package/dist/webgl2/webgl2-msdf-text.d.ts.map +1 -0
  65. package/dist/{webgl2-msdf-text.js → webgl2/webgl2-msdf-text.js} +1 -1
  66. package/dist/webgl2/webgl2-msdf-text.js.map +1 -0
  67. package/dist/webgl2/webgl2-rect-batch.d.ts.map +1 -0
  68. package/dist/{webgl2-rect-batch.js → webgl2/webgl2-rect-batch.js} +1 -1
  69. package/dist/webgl2/webgl2-rect-batch.js.map +1 -0
  70. package/dist/webgl2/webgl2-stroke.d.ts.map +1 -0
  71. package/dist/webgl2/webgl2-stroke.js.map +1 -0
  72. package/dist/{webgl2-target.d.ts → webgl2/webgl2-target.d.ts} +72 -5
  73. package/dist/webgl2/webgl2-target.d.ts.map +1 -0
  74. package/dist/{webgl2-target.js → webgl2/webgl2-target.js} +379 -20
  75. package/dist/webgl2/webgl2-target.js.map +1 -0
  76. package/dist/webgl2/webgpu-detect.d.ts.map +1 -0
  77. package/dist/webgl2/webgpu-detect.js.map +1 -0
  78. package/package.json +11 -6
  79. package/dist/canvas-target.d.ts.map +0 -1
  80. package/dist/canvas-target.js.map +0 -1
  81. package/dist/canvas-text-shaper.d.ts.map +0 -1
  82. package/dist/canvas-text-shaper.js.map +0 -1
  83. package/dist/hi-dpi.d.ts.map +0 -1
  84. package/dist/hi-dpi.js.map +0 -1
  85. package/dist/image-source.d.ts.map +0 -1
  86. package/dist/image-source.js.map +0 -1
  87. package/dist/layered-canvas.d.ts.map +0 -1
  88. package/dist/layered-canvas.js.map +0 -1
  89. package/dist/layered-surface.d.ts.map +0 -1
  90. package/dist/layered-surface.js.map +0 -1
  91. package/dist/offscreen.d.ts.map +0 -1
  92. package/dist/offscreen.js.map +0 -1
  93. package/dist/recording-target.d.ts.map +0 -1
  94. package/dist/recording-target.js.map +0 -1
  95. package/dist/replay-codec.d.ts.map +0 -1
  96. package/dist/replay-codec.js.map +0 -1
  97. package/dist/tile-compositor.d.ts.map +0 -1
  98. package/dist/tile-compositor.js.map +0 -1
  99. package/dist/webgl-helpers.d.ts.map +0 -1
  100. package/dist/webgl-helpers.js.map +0 -1
  101. package/dist/webgl2-color.d.ts.map +0 -1
  102. package/dist/webgl2-color.js.map +0 -1
  103. package/dist/webgl2-curve.d.ts.map +0 -1
  104. package/dist/webgl2-curve.js.map +0 -1
  105. package/dist/webgl2-ellipse.d.ts.map +0 -1
  106. package/dist/webgl2-ellipse.js.map +0 -1
  107. package/dist/webgl2-msdf-text.d.ts.map +0 -1
  108. package/dist/webgl2-msdf-text.js.map +0 -1
  109. package/dist/webgl2-rect-batch.d.ts.map +0 -1
  110. package/dist/webgl2-rect-batch.js.map +0 -1
  111. package/dist/webgl2-stroke.d.ts.map +0 -1
  112. package/dist/webgl2-stroke.js.map +0 -1
  113. package/dist/webgl2-target.d.ts.map +0 -1
  114. package/dist/webgl2-target.js.map +0 -1
  115. package/dist/webgpu-detect.d.ts.map +0 -1
  116. package/dist/webgpu-detect.js.map +0 -1
  117. package/dist/worker-factory.d.ts +0 -12
  118. package/dist/worker-factory.d.ts.map +0 -1
  119. package/dist/worker-factory.js +0 -12
  120. package/dist/worker-factory.js.map +0 -1
  121. /package/dist/{canvas-text-shaper.d.ts → canvas2d/canvas-text-shaper.d.ts} +0 -0
  122. /package/dist/{canvas-text-shaper.js → canvas2d/canvas-text-shaper.js} +0 -0
  123. /package/dist/{hi-dpi.d.ts → canvas2d/hi-dpi.d.ts} +0 -0
  124. /package/dist/{layered-canvas.d.ts → canvas2d/layered-canvas.d.ts} +0 -0
  125. /package/dist/{replay-codec.d.ts → offscreen/replay-codec.d.ts} +0 -0
  126. /package/dist/{layered-surface.d.ts → surface/layered-surface.d.ts} +0 -0
  127. /package/dist/{webgl-helpers.d.ts → webgl2/webgl-helpers.d.ts} +0 -0
  128. /package/dist/{webgl-helpers.js → webgl2/webgl-helpers.js} +0 -0
  129. /package/dist/{webgl2-color.d.ts → webgl2/webgl2-color.d.ts} +0 -0
  130. /package/dist/{webgl2-color.js → webgl2/webgl2-color.js} +0 -0
  131. /package/dist/{webgl2-curve.d.ts → webgl2/webgl2-curve.d.ts} +0 -0
  132. /package/dist/{webgl2-ellipse.d.ts → webgl2/webgl2-ellipse.d.ts} +0 -0
  133. /package/dist/{webgl2-ellipse.js → webgl2/webgl2-ellipse.js} +0 -0
  134. /package/dist/{webgl2-msdf-text.d.ts → webgl2/webgl2-msdf-text.d.ts} +0 -0
  135. /package/dist/{webgl2-rect-batch.d.ts → webgl2/webgl2-rect-batch.d.ts} +0 -0
  136. /package/dist/{webgl2-stroke.d.ts → webgl2/webgl2-stroke.d.ts} +0 -0
  137. /package/dist/{webgl2-stroke.js → webgl2/webgl2-stroke.js} +0 -0
  138. /package/dist/{webgpu-detect.d.ts → webgl2/webgpu-detect.d.ts} +0 -0
  139. /package/dist/{webgpu-detect.js → webgl2/webgpu-detect.js} +0 -0
@@ -1,15 +1,15 @@
1
1
  import { req } from "@oh-just-another/types";
2
- import { getActiveRasterizer, getActiveTextShaper } from "@oh-just-another/renderer-core";
2
+ import { getActiveRasterizer, getActiveTextShaper, onTextShaperChange, } from "@oh-just-another/renderer-core";
3
3
  import { GlyphAtlas } from "@oh-just-another/glyph-atlas";
4
4
  import { resolveBundledFamily } from "@oh-just-another/fonts";
5
5
  import earcut from "earcut";
6
6
  import { parseWebGL2Color } from "./webgl2-color.js";
7
- import { ELLIPSE_MAX_SEGMENTS, ELLIPSE_MIN_SEGMENTS, WEBGL2_IMAGE_TEXTURE_CACHE_CAP, WEBGL2_TEXT_BITMAP_CACHE_CAP, } from "./constants.js";
7
+ import { ELLIPSE_MAX_SEGMENTS, ELLIPSE_MIN_SEGMENTS, WEBGL2_IMAGE_TEXTURE_CACHE_CAP, WEBGL2_TEXT_BITMAP_CACHE_CAP, WEBGL2_TEXT_RASTER_MAX_SCALE, WEBGL2_TEXT_RASTER_TOP_PAD, WEBGL2_ATLAS_BAKE_REST_MS, WEBGL2_ATLAS_UPLOAD_IDLE_MS, } from "../constants.js";
8
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
- import { isDrawableImageSource, warnSkippedImage } from "./image-source.js";
12
+ import { isDrawableImageSource, warnSkippedImage } from "../canvas2d/image-source.js";
13
13
  import { compileShader, glReq, linkProgram } from "./webgl-helpers.js";
14
14
  import { RectBatch, RectInstancePipeline } from "./webgl2-rect-batch.js";
15
15
  /**
@@ -17,6 +17,12 @@ import { RectBatch, RectInstancePipeline } from "./webgl2-rect-batch.js";
17
17
  * primitives (rect / polyline / ellipse / Bezier), fill, stroke, text
18
18
  * (MSDF or OffscreenCanvas-bitmap fallback), and image drawing.
19
19
  */
20
+ /**
21
+ * Colour-glyph detector: emoji / pictographs (incl. variation selectors and
22
+ * ZWJ sequences) that the monochrome MSDF glyph atlas cannot render. Strings
23
+ * matching this take the rasterised-bitmap text path instead.
24
+ */
25
+ const HAS_PICTOGRAPH_RE = /\p{Extended_Pictographic}|\uFE0F|\u200D/u;
20
26
  export class WebGL2Target {
21
27
  gl;
22
28
  program;
@@ -160,10 +166,12 @@ export class WebGL2Target {
160
166
  antialias: true,
161
167
  premultipliedAlpha: true,
162
168
  preserveDrawingBuffer,
169
+ stencil: true, // clip() carves regions through the stencil buffer
163
170
  });
164
171
  gl ??= canvas.getContext("webgl2", {
165
172
  premultipliedAlpha: true,
166
173
  preserveDrawingBuffer,
174
+ stencil: true,
167
175
  });
168
176
  if (!gl) {
169
177
  throw new Error("WebGL2 unavailable in this environment (probably hit the per-page GL context cap; " +
@@ -231,6 +239,27 @@ export class WebGL2Target {
231
239
  // canvas was resized via setupHiDpi (no-context) after creation the
232
240
  // viewport stays at the first size, so set it explicitly.
233
241
  this.gl.viewport(0, 0, canvas.width, canvas.height);
242
+ // Warm the MSDF atlas the moment a shaper becomes active — not on
243
+ // the first frame that happens to draw text (which used to be the
244
+ // user's first pan/zoom). Also covers a shaper that loaded BEFORE
245
+ // this target was constructed.
246
+ this.ensureGlyphAtlas();
247
+ this.offShaperChange = onTextShaperChange(() => {
248
+ if (!this.disposed)
249
+ this.ensureGlyphAtlas();
250
+ });
251
+ // Pre-compile every lazy GL pipeline off the first interaction frame:
252
+ // shader compile + link through ANGLE can cost tens of ms each, and
253
+ // paying them lazily used to land inside the user's first pan/zoom.
254
+ setTimeout(() => {
255
+ if (this.disposed)
256
+ return;
257
+ this.rectPipeline ??= new RectInstancePipeline(this.gl);
258
+ this.ellipsePipeline ??= new EllipsePipeline(this.gl);
259
+ this.curvePipeline ??= new LoopBlinnCurvePipeline(this.gl);
260
+ this.msdfPipeline ??= new MsdfTextPipeline(this.gl);
261
+ this.restoreSolidProgram();
262
+ }, 0);
234
263
  }
235
264
  get size() {
236
265
  return this._size;
@@ -255,7 +284,13 @@ export class WebGL2Target {
255
284
  * `WEBGL_lose_context`, GC can take a while to collect old surfaces
256
285
  * and runtime backend switches quickly hit the cap.
257
286
  */
287
+ offShaperChange = null;
258
288
  dispose() {
289
+ this.disposed = true;
290
+ this.offShaperChange?.();
291
+ this.offShaperChange = null;
292
+ this.glyphBakeWorker?.terminate();
293
+ this.glyphBakeWorker = null;
259
294
  // Drop any undrawn queued rects and release the instance pipeline's
260
295
  // GL resources (VAO / buffers / program).
261
296
  this.rectBatch.reset();
@@ -334,12 +369,26 @@ export class WebGL2Target {
334
369
  fontStyle: this.fontStyle,
335
370
  textAlign: this.textAlign,
336
371
  textBaseline: this.textBaseline,
372
+ clipDepth: this.clipDepth,
337
373
  });
338
374
  }
339
375
  restore() {
340
376
  const s = this.stack.pop();
341
377
  if (!s)
342
378
  return;
379
+ // Lift clip levels installed since the matching save(). Queued rect
380
+ // fills must hit the framebuffer while their clip is still active.
381
+ if (this.clipDepth > s.clipDepth) {
382
+ this.flushRectBatch();
383
+ while (this.clipDepth > s.clipDepth) {
384
+ const level = this.clipLevels.pop();
385
+ if (!level)
386
+ break;
387
+ this.writeClipStencil(level, -1);
388
+ this.clipDepth--;
389
+ }
390
+ this.applyStencilTest();
391
+ }
343
392
  this.transform = s.transform;
344
393
  this.fillColor = s.fillColor;
345
394
  this.fillAlpha = s.fillAlpha;
@@ -558,6 +607,8 @@ export class WebGL2Target {
558
607
  * first image call.
559
608
  */
560
609
  drawImage(image, dx, dy, dw, dh, dynamic, crop) {
610
+ if (this.disposed)
611
+ return; // late async frame — never recompile on a lost context
561
612
  this.flushRectBatch(); // preserve z-order: emit queued rect fills first
562
613
  const tex = this.textureFor(image, dynamic ?? false);
563
614
  if (!tex)
@@ -733,6 +784,8 @@ export class WebGL2Target {
733
784
  }
734
785
  fill(_rule) {
735
786
  void _rule;
787
+ if (this.disposed)
788
+ return; // late async frame — never recompile on a lost context
736
789
  const effectiveAlpha = this.opacity * this.fillAlpha;
737
790
  if (effectiveAlpha <= 0)
738
791
  return; // transparent fill — nothing to draw
@@ -799,6 +852,96 @@ export class WebGL2Target {
799
852
  }
800
853
  curvePipeline = null;
801
854
  ellipsePipeline = null;
855
+ // --- Clip (stencil buffer) ---
856
+ /** Nesting depth of active clip regions (= stencil value inside them all). */
857
+ clipDepth = 0;
858
+ /**
859
+ * Flattened polygon + transform snapshot per active clip level, so
860
+ * `restore()` can erase exactly the pixels that level incremented
861
+ * (same geometry, same transform → same rasterisation → clean DECR).
862
+ */
863
+ clipLevels = [];
864
+ /**
865
+ * Intersect the clip region with the current path. The path is
866
+ * flattened to a polygon ring (curves are already flattened into the
867
+ * path buffer; rect / ellipse fast-paths convert here) and drawn into
868
+ * the STENCIL buffer with colour writes off: inside pixels increment
869
+ * to `clipDepth + 1`, then every subsequent draw stencil-tests
870
+ * `EQUAL clipDepth`. Nested save/clip pairs intersect naturally.
871
+ * Note: stencil coverage is aliased (no MSAA resolve on the mask
872
+ * edge) — acceptable for image masks; Canvas2D clips are aliased too.
873
+ */
874
+ clip(_rule) {
875
+ void _rule; // stencil pass uses earcut (nonzero); evenodd masks are not used by the kernel
876
+ if (this.disposed)
877
+ return;
878
+ this.flushRectBatch(); // queued rects belong OUTSIDE the new clip
879
+ let pts;
880
+ let count;
881
+ if (this.currentEllipse) {
882
+ this.buildEllipseStrokePolyline(this.currentEllipse);
883
+ count = this.pathPts;
884
+ pts = this.pathXY.slice(0, count * 2);
885
+ }
886
+ else if (this.currentPath) {
887
+ const r = this.currentPath;
888
+ pts = Float64Array.of(r.x, r.y, r.x + r.width, r.y, r.x + r.width, r.y + r.height, r.x, r.y + r.height);
889
+ count = 4;
890
+ }
891
+ else if (this.pathPts >= 3) {
892
+ count = this.pathPts;
893
+ pts = this.pathXY.slice(0, count * 2);
894
+ }
895
+ else {
896
+ return; // no meaningful path — Canvas2D would clip to empty; keep drawing instead
897
+ }
898
+ const level = { pts, count, transform: { ...this.transform } };
899
+ this.writeClipStencil(level, 1);
900
+ this.clipLevels.push(level);
901
+ this.clipDepth++;
902
+ this.applyStencilTest();
903
+ }
904
+ /**
905
+ * Rasterise a clip level's polygon into the stencil buffer.
906
+ * `delta = 1` increments inside pixels (install, intersecting with the
907
+ * existing clip via the EQUAL test); `delta = -1` decrements them back
908
+ * (uninstall on restore). Colour writes are off for the whole pass.
909
+ */
910
+ writeClipStencil(level, delta) {
911
+ const gl = this.gl;
912
+ gl.enable(gl.STENCIL_TEST);
913
+ gl.colorMask(false, false, false, false);
914
+ // Install: only pixels already inside every outer clip (== depth)
915
+ // increment. Uninstall: only pixels this level raised (== depth)
916
+ // decrement — the caller pops levels in LIFO order.
917
+ gl.stencilFunc(gl.EQUAL, this.clipDepth, 0xff);
918
+ gl.stencilOp(gl.KEEP, gl.KEEP, delta === 1 ? gl.INCR : gl.DECR);
919
+ const saved = this.transform;
920
+ this.transform = level.transform;
921
+ this.fillPolygonEarcut(level.pts, level.count, 1);
922
+ this.transform = saved;
923
+ gl.colorMask(true, true, true, true);
924
+ }
925
+ /** Sync the global stencil test with the current clip depth. */
926
+ applyStencilTest() {
927
+ const gl = this.gl;
928
+ if (this.clipDepth > 0) {
929
+ gl.enable(gl.STENCIL_TEST);
930
+ gl.stencilFunc(gl.EQUAL, this.clipDepth, 0xff);
931
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
932
+ }
933
+ else {
934
+ gl.disable(gl.STENCIL_TEST);
935
+ }
936
+ }
937
+ /**
938
+ * Set by {@link dispose}. Draw entry points bail out when set: a late
939
+ * async frame (image decode / font load resolving after a backend
940
+ * switch) must not touch the lost context — the lazy `??=` pipeline
941
+ * rebuilds would recompile shaders on it and throw
942
+ * "shader compile failed: null" from inside a promise chain.
943
+ */
944
+ disposed = false;
802
945
  /**
803
946
  * Triangulate the polygon via earcut and emit one TRIANGLES draw.
804
947
  * Vertices are pre-projected into clip space so the program's
@@ -945,7 +1088,15 @@ export class WebGL2Target {
945
1088
  }
946
1089
  this.gl.viewport(0, 0, bitmapW, bitmapH);
947
1090
  this.gl.clearColor(0, 0, 0, 0);
948
- this.gl.clear(this.gl.COLOR_BUFFER_BIT);
1091
+ // Also wipe the stencil buffer — a frame that died mid-clip (lost
1092
+ // restore) must not leak its mask into the next frame.
1093
+ this.gl.clearStencil(0);
1094
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.STENCIL_BUFFER_BIT);
1095
+ if (this.clipDepth > 0) {
1096
+ this.clipDepth = 0;
1097
+ this.clipLevels.length = 0;
1098
+ this.applyStencilTest();
1099
+ }
949
1100
  }
950
1101
  // --- Stroke pipeline ---
951
1102
  setStroke(color) {
@@ -1057,15 +1208,24 @@ export class WebGL2Target {
1057
1208
  fillText(text, x, y, maxWidth) {
1058
1209
  if (text.length === 0)
1059
1210
  return;
1211
+ if (this.disposed)
1212
+ return; // late async frame — never recompile on a lost context
1060
1213
  this.flushRectBatch(); // preserve z-order: emit queued rect fills first
1061
1214
  void maxWidth;
1062
1215
  const atlas = this.ensureGlyphAtlas();
1063
- if (atlas) {
1064
- this.fillTextMSDF(text, x, y, atlas);
1065
- return;
1216
+ // Emoji (and other pictographs) are colour glyphs the MSDF atlas
1217
+ // cannot shape — routing them through it draws nothing. Strings with
1218
+ // un-baked glyphs also take the bitmap path (baking is background
1219
+ // work, never in-frame — see `atlasCovers`).
1220
+ if (atlas && !HAS_PICTOGRAPH_RE.test(text)) {
1221
+ const fontId = atlas.resolveFontId(this.fontFamily, this.fontWeight === "bold", this.fontStyle === "italic");
1222
+ if (this.atlasCovers(text, fontId, atlas)) {
1223
+ this.fillTextMSDF(text, x, y, atlas);
1224
+ return;
1225
+ }
1066
1226
  }
1067
- const bitmap = this.rasteriseString(text);
1068
- if (!bitmap)
1227
+ const raster = this.rasteriseString(text);
1228
+ if (!raster)
1069
1229
  return;
1070
1230
  const m = this.textMetrics(text);
1071
1231
  let px = x;
@@ -1078,7 +1238,10 @@ export class WebGL2Target {
1078
1238
  py -= this.fontSize / 2;
1079
1239
  else if (this.textBaseline === "bottom")
1080
1240
  py -= this.fontSize;
1081
- this.drawImage(bitmap, px, py, m.width, this.fontSize * 1.4);
1241
+ // Shift up by the raster's top pad so the glyph lands where an
1242
+ // unpadded bake would put it (the pad only adds emoji headroom).
1243
+ const topPad = this.fontSize * WEBGL2_TEXT_RASTER_TOP_PAD;
1244
+ this.drawImage(raster.canvas, px, py - topPad, m.width, this.fontSize * 1.4 + topPad);
1082
1245
  }
1083
1246
  msdfPipeline = null;
1084
1247
  glyphAtlas = null;
@@ -1103,10 +1266,157 @@ export class WebGL2Target {
1103
1266
  return this.glyphAtlas;
1104
1267
  if (this.glyphAtlas)
1105
1268
  this.glyphAtlas.dispose(this.gl);
1269
+ // The atlas object itself is cheap; the EXPENSIVE part — per-glyph WASM
1270
+ // MSDF baking — never happens inside a frame. Strings whose glyphs are
1271
+ // not baked yet render through the bitmap path while the background
1272
+ // queue bakes them in small chunks (printable ASCII is pre-queued).
1106
1273
  this.glyphAtlas = new GlyphAtlas(shaper);
1107
1274
  this.glyphAtlasShaper = shaper;
1275
+ this.pendingGlyphBake.clear();
1276
+ for (let cp = 0x20; cp <= 0x7e; cp++)
1277
+ this.queueGlyphBake(cp, 0);
1108
1278
  return this.glyphAtlas;
1109
1279
  }
1280
+ /** Code points awaiting background baking. Key = fontId × 0x110000 + cp. */
1281
+ pendingGlyphBake = new Set();
1282
+ glyphBakeScheduled = false;
1283
+ queueGlyphBake(codePoint, fontId) {
1284
+ this.pendingGlyphBake.add(fontId * 0x110000 + codePoint);
1285
+ if (this.glyphBakeScheduled)
1286
+ return;
1287
+ this.glyphBakeScheduled = true;
1288
+ setTimeout(() => {
1289
+ this.drainGlyphBakeQueue();
1290
+ }, 0);
1291
+ }
1292
+ /** Worker doing the WASM MSDF baking; spawned lazily, killed on dispose. */
1293
+ glyphBakeWorker = null;
1294
+ glyphBakeWorkerFailed = false;
1295
+ ensureGlyphBakeWorker() {
1296
+ if (this.glyphBakeWorkerFailed || typeof Worker === "undefined")
1297
+ return null;
1298
+ if (this.glyphBakeWorker)
1299
+ return this.glyphBakeWorker;
1300
+ try {
1301
+ const worker = new Worker(new URL("../glyph-bake-worker.js", import.meta.url), {
1302
+ type: "module",
1303
+ });
1304
+ worker.onmessage = (ev) => {
1305
+ this.onGlyphBaked(ev.data);
1306
+ };
1307
+ worker.onerror = () => {
1308
+ // Worker path unavailable (CSP / bundler) — fall back to the
1309
+ // throttled main-thread baking for future requests.
1310
+ this.glyphBakeWorkerFailed = true;
1311
+ this.glyphBakeWorker?.terminate();
1312
+ this.glyphBakeWorker = null;
1313
+ if (this.pendingGlyphBake.size > 0 && !this.glyphBakeScheduled) {
1314
+ this.glyphBakeScheduled = true;
1315
+ setTimeout(() => {
1316
+ this.drainGlyphBakeQueueSync();
1317
+ }, 0);
1318
+ }
1319
+ };
1320
+ this.glyphBakeWorker = worker;
1321
+ return worker;
1322
+ }
1323
+ catch {
1324
+ this.glyphBakeWorkerFailed = true;
1325
+ return null;
1326
+ }
1327
+ }
1328
+ onGlyphBaked(res) {
1329
+ if (this.disposed || !this.glyphAtlas)
1330
+ return;
1331
+ if (res.metrics === null)
1332
+ return; // shaper couldn't resolve the glyph
1333
+ this.glyphAtlas.insertBaked(res.codePoint, res.fontId, res.metrics, res.tile);
1334
+ // Push the new tiles (and the one-time 12 MB full upload) to the GPU
1335
+ // from an idle task, NOT from the next draw — texture uploads landing
1336
+ // inside a pan frame read as intermittent slow-downs.
1337
+ if (!this.glyphUploadScheduled) {
1338
+ this.glyphUploadScheduled = true;
1339
+ setTimeout(() => {
1340
+ this.glyphUploadScheduled = false;
1341
+ if (this.disposed || !this.glyphAtlas)
1342
+ return;
1343
+ this.glyphAtlas.uploadTo(this.gl);
1344
+ }, WEBGL2_ATLAS_UPLOAD_IDLE_MS);
1345
+ }
1346
+ }
1347
+ glyphUploadScheduled = false;
1348
+ /**
1349
+ * Drain the bake queue: WASM MSDF generation costs 15–50 ms PER
1350
+ * GLYPH, so the requests go to a dedicated worker and the main thread
1351
+ * never rasterises. The synchronous fallback below only runs where
1352
+ * workers are unavailable (tests / exotic hosts), throttled to one
1353
+ * glyph per macrotask.
1354
+ */
1355
+ drainGlyphBakeQueue() {
1356
+ this.glyphBakeScheduled = false;
1357
+ if (this.disposed || !this.glyphAtlas) {
1358
+ this.pendingGlyphBake.clear();
1359
+ return;
1360
+ }
1361
+ const worker = this.ensureGlyphBakeWorker();
1362
+ if (!worker) {
1363
+ this.drainGlyphBakeQueueSync();
1364
+ return;
1365
+ }
1366
+ for (const key of this.pendingGlyphBake) {
1367
+ this.pendingGlyphBake.delete(key);
1368
+ const fontId = Math.floor(key / 0x110000);
1369
+ const cp = key % 0x110000;
1370
+ if (this.glyphAtlas.has(cp, fontId))
1371
+ continue;
1372
+ const request = {
1373
+ codePoint: cp,
1374
+ fontId,
1375
+ tileSize: this.glyphAtlas.tileSize,
1376
+ range: this.glyphAtlas.range,
1377
+ };
1378
+ worker.postMessage(request);
1379
+ }
1380
+ }
1381
+ /** Main-thread fallback: one glyph per macrotask (workers unavailable). */
1382
+ drainGlyphBakeQueueSync() {
1383
+ this.glyphBakeScheduled = false;
1384
+ if (this.disposed || !this.glyphAtlas) {
1385
+ this.pendingGlyphBake.clear();
1386
+ return;
1387
+ }
1388
+ for (const key of this.pendingGlyphBake) {
1389
+ this.pendingGlyphBake.delete(key);
1390
+ const fontId = Math.floor(key / 0x110000);
1391
+ const cp = key % 0x110000;
1392
+ this.glyphAtlas.getOrRasterize(cp, fontId);
1393
+ break;
1394
+ }
1395
+ if (this.pendingGlyphBake.size > 0) {
1396
+ this.glyphBakeScheduled = true;
1397
+ setTimeout(() => {
1398
+ this.drainGlyphBakeQueueSync();
1399
+ }, WEBGL2_ATLAS_BAKE_REST_MS);
1400
+ }
1401
+ }
1402
+ /**
1403
+ * True when every glyph of `text` is already baked. Missing ones are
1404
+ * queued for background baking — the caller falls back to the bitmap
1405
+ * path for THIS frame and switches to MSDF once the queue catches up.
1406
+ */
1407
+ atlasCovers(text, fontId, atlas) {
1408
+ let covered = true;
1409
+ for (const ch of text) {
1410
+ const cp = ch.codePointAt(0);
1411
+ if (cp === undefined)
1412
+ continue;
1413
+ if (!atlas.has(cp, fontId)) {
1414
+ covered = false;
1415
+ this.queueGlyphBake(cp, fontId);
1416
+ }
1417
+ }
1418
+ return covered;
1419
+ }
1110
1420
  /**
1111
1421
  * MSDF path for `fillText`. Honours textAlign / textBaseline by
1112
1422
  * measuring the string width upfront and shifting the cursor. Width
@@ -1156,6 +1466,10 @@ export class WebGL2Target {
1156
1466
  flushRectBatch() {
1157
1467
  if (this.rectBatch.pending === 0)
1158
1468
  return;
1469
+ if (this.disposed) {
1470
+ this.rectBatch.reset();
1471
+ return;
1472
+ }
1159
1473
  this.rectPipeline ??= new RectInstancePipeline(this.gl);
1160
1474
  const pipeline = this.rectPipeline;
1161
1475
  this.rectBatch.flush((data, count) => {
@@ -1253,8 +1567,19 @@ export class WebGL2Target {
1253
1567
  // system-font width (a different, usually wider font) and callers —
1254
1568
  // caret geometry, selection bounds — would drift from what's drawn.
1255
1569
  const atlas = this.ensureGlyphAtlas();
1256
- if (atlas) {
1570
+ // Pictographs (emoji) are not in the MSDF atlas — their glyph-run
1571
+ // measure is NaN; those strings render AND measure via Canvas2D. So
1572
+ // do strings whose glyphs are not baked yet (measuring would bake
1573
+ // them synchronously — the very jank the background queue avoids).
1574
+ if (atlas && !HAS_PICTOGRAPH_RE.test(text)) {
1257
1575
  const fontId = atlas.resolveFontId(this.fontFamily, this.fontWeight === "bold", this.fontStyle === "italic");
1576
+ if (!this.atlasCovers(text, fontId, atlas)) {
1577
+ const fallbackCtx = this.ensureTextCtx();
1578
+ if (!fallbackCtx)
1579
+ return { width: text.length * this.fontSize * 0.55 };
1580
+ fallbackCtx.font = this.textFontSpec();
1581
+ return { width: fallbackCtx.measureText(text).width };
1582
+ }
1258
1583
  // Shared single-pass, memoized walk — same advances `fillTextMSDF`
1259
1584
  // lays out (`advance * fontSize / unitsPerEm`), so measured width
1260
1585
  // and drawn width stay 1:1. em-width is fontSize-independent; scale
@@ -1269,35 +1594,69 @@ export class WebGL2Target {
1269
1594
  ctx.font = this.textFontSpec();
1270
1595
  return { width: ctx.measureText(text).width };
1271
1596
  }
1597
+ /**
1598
+ * Effective on-screen scale of text drawn at the current transform:
1599
+ * the transform's linear scale (view zoom × shape scale) times the
1600
+ * backbuffer's device-pixel ratio (the transform maps to LOGICAL
1601
+ * pixels; the backbuffer is physical). Quantised to powers of two so a
1602
+ * smooth zoom re-rasterises at discrete steps, clamped to
1603
+ * `WEBGL2_TEXT_RASTER_MAX_SCALE`.
1604
+ */
1605
+ textRasterScale() {
1606
+ const t = Math.hypot(this.transform.a, this.transform.b);
1607
+ const dpr = this._size.width > 0 ? this.gl.drawingBufferWidth / this._size.width : 1;
1608
+ const s = t * dpr;
1609
+ // Non-finite guard: stub GL contexts (tests) may lack a real
1610
+ // drawingBufferWidth.
1611
+ if (!Number.isFinite(s) || s <= 1)
1612
+ return 1;
1613
+ return 2 ** Math.ceil(Math.log2(Math.min(s, WEBGL2_TEXT_RASTER_MAX_SCALE)));
1614
+ }
1272
1615
  rasteriseString(text) {
1273
1616
  if (typeof OffscreenCanvas === "undefined")
1274
1617
  return null;
1275
- const key = `${text}|${this.textFontSpec()}|${this.fillColorString}`;
1618
+ // Rasterise at the current screen scale so bitmap text (emoji, pill
1619
+ // labels) stays sharp under zoom instead of stretching a 1× bake.
1620
+ const scale = this.textRasterScale();
1621
+ const key = `${text}|${this.textFontSpec()}|${this.fillColorString}|${String(scale)}`;
1276
1622
  const cached = this.textBitmaps.get(key);
1277
1623
  if (cached) {
1278
1624
  // Touch — re-insert at the tail so the LRU eviction below picks
1279
1625
  // colder entries first.
1280
1626
  this.textBitmaps.delete(key);
1281
1627
  this.textBitmaps.set(key, cached);
1282
- return cached;
1628
+ return { canvas: cached, scale };
1283
1629
  }
1284
- const m = this.textMetrics(text);
1285
- // Pad height by 40% covers font ascent/descent fuzz without
1286
- // requiring per-font TextMetrics support.
1287
- const w = Math.max(1, Math.ceil(m.width));
1288
- const h = Math.max(1, Math.ceil(this.fontSize * 1.4));
1630
+ // Measure with the SAME Canvas2D font the bitmap is painted with —
1631
+ // atlas advances would disagree with the system font (and are NaN
1632
+ // for emoji, which previously blew up the OffscreenCanvas ctor).
1633
+ const measureCtx = this.ensureTextCtx();
1634
+ let width = this.fontSize * 0.55 * text.length;
1635
+ if (measureCtx) {
1636
+ measureCtx.font = this.textFontSpec();
1637
+ width = measureCtx.measureText(text).width;
1638
+ }
1639
+ // Pad height by 40% below and `WEBGL2_TEXT_RASTER_TOP_PAD` above —
1640
+ // covers font ascent/descent fuzz (emoji paint above the em top and
1641
+ // would otherwise clip) without per-font TextMetrics support. The
1642
+ // top pad is compensated at draw time (`fillText` shifts up by it).
1643
+ const topPad = this.fontSize * WEBGL2_TEXT_RASTER_TOP_PAD;
1644
+ const w = Math.max(1, Math.ceil((Number.isFinite(width) ? width : 1) * scale));
1645
+ const h = Math.max(1, Math.ceil((this.fontSize * 1.4 + topPad) * scale));
1289
1646
  const canvas = new OffscreenCanvas(w, h);
1290
1647
  const ctx = canvas.getContext("2d");
1291
1648
  if (!ctx)
1292
1649
  return null;
1650
+ if (scale !== 1)
1651
+ ctx.scale(scale, scale);
1293
1652
  ctx.font = this.textFontSpec();
1294
1653
  ctx.textAlign = "left";
1295
1654
  ctx.textBaseline = "top";
1296
1655
  ctx.fillStyle = this.fillColorString;
1297
- ctx.fillText(text, 0, 0);
1656
+ ctx.fillText(text, 0, topPad);
1298
1657
  this.textBitmaps.set(key, canvas);
1299
1658
  this.evictTextBitmapsIfOverCap();
1300
- return canvas;
1659
+ return { canvas, scale };
1301
1660
  }
1302
1661
  /**
1303
1662
  * Trim `textBitmaps` down to `WEBGL2_TEXT_BITMAP_CACHE_CAP` entries by