@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.
- package/CHANGELOG.md +42 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/{canvas-target.d.ts → canvas2d/canvas-target.d.ts} +1 -0
- package/dist/canvas2d/canvas-target.d.ts.map +1 -0
- package/dist/{canvas-target.js → canvas2d/canvas-target.js} +3 -0
- package/dist/canvas2d/canvas-target.js.map +1 -0
- package/dist/canvas2d/canvas-text-shaper.d.ts.map +1 -0
- package/dist/canvas2d/canvas-text-shaper.js.map +1 -0
- package/dist/canvas2d/hi-dpi.d.ts.map +1 -0
- package/dist/{hi-dpi.js → canvas2d/hi-dpi.js} +1 -1
- package/dist/canvas2d/hi-dpi.js.map +1 -0
- package/dist/{image-source.d.ts → canvas2d/image-source.d.ts} +1 -1
- package/dist/canvas2d/image-source.d.ts.map +1 -0
- package/dist/{image-source.js → canvas2d/image-source.js} +7 -42
- package/dist/canvas2d/image-source.js.map +1 -0
- package/dist/canvas2d/layered-canvas.d.ts.map +1 -0
- package/dist/{layered-canvas.js → canvas2d/layered-canvas.js} +1 -1
- package/dist/canvas2d/layered-canvas.js.map +1 -0
- package/dist/constants.d.ts +32 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +32 -0
- package/dist/constants.js.map +1 -1
- package/dist/glyph-bake-worker.d.ts +20 -0
- package/dist/glyph-bake-worker.d.ts.map +1 -0
- package/dist/glyph-bake-worker.js +43 -0
- package/dist/glyph-bake-worker.js.map +1 -0
- package/dist/index.d.ts +11 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -11
- package/dist/index.js.map +1 -1
- package/dist/{offscreen.d.ts → offscreen/offscreen.d.ts} +1 -1
- package/dist/offscreen/offscreen.d.ts.map +1 -0
- package/dist/{offscreen.js → offscreen/offscreen.js} +1 -1
- package/dist/offscreen/offscreen.js.map +1 -0
- package/dist/{recording-target.d.ts → offscreen/recording-target.d.ts} +4 -0
- package/dist/offscreen/recording-target.d.ts.map +1 -0
- package/dist/{recording-target.js → offscreen/recording-target.js} +11 -2
- package/dist/offscreen/recording-target.js.map +1 -0
- package/dist/offscreen/replay-codec.d.ts.map +1 -0
- package/dist/{replay-codec.js → offscreen/replay-codec.js} +16 -1
- package/dist/offscreen/replay-codec.js.map +1 -0
- package/dist/offscreen/worker-factory.d.ts +13 -0
- package/dist/offscreen/worker-factory.d.ts.map +1 -0
- package/dist/offscreen/worker-factory.js +13 -0
- package/dist/offscreen/worker-factory.js.map +1 -0
- package/dist/render-worker.js +2 -2
- package/dist/render-worker.js.map +1 -1
- package/dist/surface/layered-surface.d.ts.map +1 -0
- package/dist/{layered-surface.js → surface/layered-surface.js} +6 -6
- package/dist/surface/layered-surface.js.map +1 -0
- package/dist/{tile-compositor.d.ts → surface/tile-compositor.d.ts} +1 -1
- package/dist/surface/tile-compositor.d.ts.map +1 -0
- package/dist/{tile-compositor.js → surface/tile-compositor.js} +1 -1
- package/dist/surface/tile-compositor.js.map +1 -0
- package/dist/webgl2/webgl-helpers.d.ts.map +1 -0
- package/dist/webgl2/webgl-helpers.js.map +1 -0
- package/dist/webgl2/webgl2-color.d.ts.map +1 -0
- package/dist/webgl2/webgl2-color.js.map +1 -0
- package/dist/webgl2/webgl2-curve.d.ts.map +1 -0
- package/dist/{webgl2-curve.js → webgl2/webgl2-curve.js} +1 -1
- package/dist/webgl2/webgl2-curve.js.map +1 -0
- package/dist/webgl2/webgl2-ellipse.d.ts.map +1 -0
- package/dist/webgl2/webgl2-ellipse.js.map +1 -0
- package/dist/webgl2/webgl2-msdf-text.d.ts.map +1 -0
- package/dist/{webgl2-msdf-text.js → webgl2/webgl2-msdf-text.js} +1 -1
- package/dist/webgl2/webgl2-msdf-text.js.map +1 -0
- package/dist/webgl2/webgl2-rect-batch.d.ts.map +1 -0
- package/dist/{webgl2-rect-batch.js → webgl2/webgl2-rect-batch.js} +1 -1
- package/dist/webgl2/webgl2-rect-batch.js.map +1 -0
- package/dist/webgl2/webgl2-stroke.d.ts.map +1 -0
- package/dist/webgl2/webgl2-stroke.js.map +1 -0
- package/dist/{webgl2-target.d.ts → webgl2/webgl2-target.d.ts} +72 -5
- package/dist/webgl2/webgl2-target.d.ts.map +1 -0
- package/dist/{webgl2-target.js → webgl2/webgl2-target.js} +379 -20
- package/dist/webgl2/webgl2-target.js.map +1 -0
- package/dist/webgl2/webgpu-detect.d.ts.map +1 -0
- package/dist/webgl2/webgpu-detect.js.map +1 -0
- package/package.json +11 -6
- package/dist/canvas-target.d.ts.map +0 -1
- package/dist/canvas-target.js.map +0 -1
- package/dist/canvas-text-shaper.d.ts.map +0 -1
- package/dist/canvas-text-shaper.js.map +0 -1
- package/dist/hi-dpi.d.ts.map +0 -1
- package/dist/hi-dpi.js.map +0 -1
- package/dist/image-source.d.ts.map +0 -1
- package/dist/image-source.js.map +0 -1
- package/dist/layered-canvas.d.ts.map +0 -1
- package/dist/layered-canvas.js.map +0 -1
- package/dist/layered-surface.d.ts.map +0 -1
- package/dist/layered-surface.js.map +0 -1
- package/dist/offscreen.d.ts.map +0 -1
- package/dist/offscreen.js.map +0 -1
- package/dist/recording-target.d.ts.map +0 -1
- package/dist/recording-target.js.map +0 -1
- package/dist/replay-codec.d.ts.map +0 -1
- package/dist/replay-codec.js.map +0 -1
- package/dist/tile-compositor.d.ts.map +0 -1
- package/dist/tile-compositor.js.map +0 -1
- package/dist/webgl-helpers.d.ts.map +0 -1
- package/dist/webgl-helpers.js.map +0 -1
- package/dist/webgl2-color.d.ts.map +0 -1
- package/dist/webgl2-color.js.map +0 -1
- package/dist/webgl2-curve.d.ts.map +0 -1
- package/dist/webgl2-curve.js.map +0 -1
- package/dist/webgl2-ellipse.d.ts.map +0 -1
- package/dist/webgl2-ellipse.js.map +0 -1
- package/dist/webgl2-msdf-text.d.ts.map +0 -1
- package/dist/webgl2-msdf-text.js.map +0 -1
- package/dist/webgl2-rect-batch.d.ts.map +0 -1
- package/dist/webgl2-rect-batch.js.map +0 -1
- package/dist/webgl2-stroke.d.ts.map +0 -1
- package/dist/webgl2-stroke.js.map +0 -1
- package/dist/webgl2-target.d.ts.map +0 -1
- package/dist/webgl2-target.js.map +0 -1
- package/dist/webgpu-detect.d.ts.map +0 -1
- package/dist/webgpu-detect.js.map +0 -1
- package/dist/worker-factory.d.ts +0 -12
- package/dist/worker-factory.d.ts.map +0 -1
- package/dist/worker-factory.js +0 -12
- package/dist/worker-factory.js.map +0 -1
- /package/dist/{canvas-text-shaper.d.ts → canvas2d/canvas-text-shaper.d.ts} +0 -0
- /package/dist/{canvas-text-shaper.js → canvas2d/canvas-text-shaper.js} +0 -0
- /package/dist/{hi-dpi.d.ts → canvas2d/hi-dpi.d.ts} +0 -0
- /package/dist/{layered-canvas.d.ts → canvas2d/layered-canvas.d.ts} +0 -0
- /package/dist/{replay-codec.d.ts → offscreen/replay-codec.d.ts} +0 -0
- /package/dist/{layered-surface.d.ts → surface/layered-surface.d.ts} +0 -0
- /package/dist/{webgl-helpers.d.ts → webgl2/webgl-helpers.d.ts} +0 -0
- /package/dist/{webgl-helpers.js → webgl2/webgl-helpers.js} +0 -0
- /package/dist/{webgl2-color.d.ts → webgl2/webgl2-color.d.ts} +0 -0
- /package/dist/{webgl2-color.js → webgl2/webgl2-color.js} +0 -0
- /package/dist/{webgl2-curve.d.ts → webgl2/webgl2-curve.d.ts} +0 -0
- /package/dist/{webgl2-ellipse.d.ts → webgl2/webgl2-ellipse.d.ts} +0 -0
- /package/dist/{webgl2-ellipse.js → webgl2/webgl2-ellipse.js} +0 -0
- /package/dist/{webgl2-msdf-text.d.ts → webgl2/webgl2-msdf-text.d.ts} +0 -0
- /package/dist/{webgl2-rect-batch.d.ts → webgl2/webgl2-rect-batch.d.ts} +0 -0
- /package/dist/{webgl2-stroke.d.ts → webgl2/webgl2-stroke.d.ts} +0 -0
- /package/dist/{webgl2-stroke.js → webgl2/webgl2-stroke.js} +0 -0
- /package/dist/{webgpu-detect.d.ts → webgl2/webgpu-detect.d.ts} +0 -0
- /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 "
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
|
1068
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
const
|
|
1288
|
-
|
|
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,
|
|
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
|