@oh-just-another/renderer-canvas 0.57.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 (81) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +36 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/canvas-target.d.ts +83 -0
  6. package/dist/canvas-target.d.ts.map +1 -0
  7. package/dist/canvas-target.js +208 -0
  8. package/dist/canvas-target.js.map +1 -0
  9. package/dist/canvas-text-shaper.d.ts +11 -0
  10. package/dist/canvas-text-shaper.d.ts.map +1 -0
  11. package/dist/canvas-text-shaper.js +56 -0
  12. package/dist/canvas-text-shaper.js.map +1 -0
  13. package/dist/constants.d.ts +52 -0
  14. package/dist/constants.d.ts.map +1 -0
  15. package/dist/constants.js +52 -0
  16. package/dist/constants.js.map +1 -0
  17. package/dist/hi-dpi.d.ts +26 -0
  18. package/dist/hi-dpi.d.ts.map +1 -0
  19. package/dist/hi-dpi.js +62 -0
  20. package/dist/hi-dpi.js.map +1 -0
  21. package/dist/image-source.d.ts +3 -0
  22. package/dist/image-source.d.ts.map +1 -0
  23. package/dist/image-source.js +65 -0
  24. package/dist/image-source.js.map +1 -0
  25. package/dist/index.d.ts +15 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +24 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/layered-canvas.d.ts +46 -0
  30. package/dist/layered-canvas.d.ts.map +1 -0
  31. package/dist/layered-canvas.js +95 -0
  32. package/dist/layered-canvas.js.map +1 -0
  33. package/dist/layered-surface.d.ts +75 -0
  34. package/dist/layered-surface.d.ts.map +1 -0
  35. package/dist/layered-surface.js +262 -0
  36. package/dist/layered-surface.js.map +1 -0
  37. package/dist/offscreen.d.ts +30 -0
  38. package/dist/offscreen.d.ts.map +1 -0
  39. package/dist/offscreen.js +50 -0
  40. package/dist/offscreen.js.map +1 -0
  41. package/dist/recording-target.d.ts +203 -0
  42. package/dist/recording-target.d.ts.map +1 -0
  43. package/dist/recording-target.js +260 -0
  44. package/dist/recording-target.js.map +1 -0
  45. package/dist/render-worker.d.ts +2 -0
  46. package/dist/render-worker.d.ts.map +1 -0
  47. package/dist/render-worker.js +112 -0
  48. package/dist/render-worker.js.map +1 -0
  49. package/dist/tile-compositor.d.ts +44 -0
  50. package/dist/tile-compositor.d.ts.map +1 -0
  51. package/dist/tile-compositor.js +110 -0
  52. package/dist/tile-compositor.js.map +1 -0
  53. package/dist/webgl2-color.d.ts +13 -0
  54. package/dist/webgl2-color.d.ts.map +1 -0
  55. package/dist/webgl2-color.js +26 -0
  56. package/dist/webgl2-color.js.map +1 -0
  57. package/dist/webgl2-curve.d.ts +46 -0
  58. package/dist/webgl2-curve.d.ts.map +1 -0
  59. package/dist/webgl2-curve.js +162 -0
  60. package/dist/webgl2-curve.js.map +1 -0
  61. package/dist/webgl2-ellipse.d.ts +52 -0
  62. package/dist/webgl2-ellipse.d.ts.map +1 -0
  63. package/dist/webgl2-ellipse.js +171 -0
  64. package/dist/webgl2-ellipse.js.map +1 -0
  65. package/dist/webgl2-msdf-text.d.ts +84 -0
  66. package/dist/webgl2-msdf-text.d.ts.map +1 -0
  67. package/dist/webgl2-msdf-text.js +306 -0
  68. package/dist/webgl2-msdf-text.js.map +1 -0
  69. package/dist/webgl2-stroke.d.ts +52 -0
  70. package/dist/webgl2-stroke.d.ts.map +1 -0
  71. package/dist/webgl2-stroke.js +318 -0
  72. package/dist/webgl2-stroke.js.map +1 -0
  73. package/dist/webgl2-target.d.ts +311 -0
  74. package/dist/webgl2-target.d.ts.map +1 -0
  75. package/dist/webgl2-target.js +1368 -0
  76. package/dist/webgl2-target.js.map +1 -0
  77. package/dist/webgpu-detect.d.ts +30 -0
  78. package/dist/webgpu-detect.d.ts.map +1 -0
  79. package/dist/webgpu-detect.js +74 -0
  80. package/dist/webgpu-detect.js.map +1 -0
  81. package/package.json +67 -0
@@ -0,0 +1,1368 @@
1
+ import { getActiveRasterizer, getActiveTextShaper } from "@oh-just-another/renderer-core";
2
+ import { GlyphAtlas } from "@oh-just-another/glyph-atlas";
3
+ import earcut from "earcut";
4
+ import { parseWebGL2Color } from "./webgl2-color.js";
5
+ import { WEBGL2_IMAGE_TEXTURE_CACHE_CAP, WEBGL2_TEXT_BITMAP_CACHE_CAP, } from "./constants.js";
6
+ import { MsdfTextPipeline } from "./webgl2-msdf-text.js";
7
+ import { drawPolylineStroke as drawPolylineStrokeImpl } from "./webgl2-stroke.js";
8
+ import { LoopBlinnCurvePipeline } from "./webgl2-curve.js";
9
+ import { EllipsePipeline } from "./webgl2-ellipse.js";
10
+ import { isDrawableImageSource, warnSkippedImage } from "./image-source.js";
11
+ /**
12
+ * WebGL2 RenderTarget. Implements clear, transform/state stack, path
13
+ * primitives (rect / polyline / ellipse / Bezier), fill, stroke, text
14
+ * (MSDF or OffscreenCanvas-bitmap fallback), and image drawing.
15
+ */
16
+ export class WebGL2Target {
17
+ gl;
18
+ program;
19
+ /**
20
+ * Static unit-quad VBO ([0,0]–[1,1]) — used by every solid-fill rect
21
+ * draw. Filled once in the constructor with `STATIC_DRAW` and never
22
+ * re-written; the rect's world-space placement is done entirely via
23
+ * `uTransform`.
24
+ */
25
+ vbo;
26
+ /**
27
+ * Per-frame scratch VBO for polygon / triangle-fan / stroke vertex
28
+ * streams. Re-uploaded each draw with `DYNAMIC_DRAW`. Kept separate
29
+ * from `vbo` so the static unit quad never gets stomped.
30
+ */
31
+ dynamicVbo;
32
+ uTransformLoc;
33
+ uColorLoc;
34
+ uOpacityLoc;
35
+ /**
36
+ * Cached attribute location for the solid program. `getAttribLocation`
37
+ * is a string-keyed driver lookup; repeating it per draw is a real
38
+ * per-frame cost on integrated GPUs. Locations are stable once the
39
+ * program is linked, so they're read once in the constructor.
40
+ */
41
+ aPosLoc;
42
+ _size;
43
+ fillColor = [0, 0, 0];
44
+ fillAlpha = 1;
45
+ strokeColor = [0, 0, 0];
46
+ strokeAlpha = 1;
47
+ fillColorString = "#000";
48
+ strokeWidth = 1;
49
+ lineCap = "butt";
50
+ lineJoin = "miter";
51
+ /** Dash pattern in WORLD units (matches Canvas2D, which dashes in the
52
+ * world-space ctx transform). `null` = solid. */
53
+ dashArray = null;
54
+ opacity = 1;
55
+ currentPath = null;
56
+ // Text state — kept in sync with Canvas2D semantics and replayed into
57
+ // the hidden text bitmap canvas per fillText call.
58
+ fontFamily = "sans-serif";
59
+ fontSize = 14;
60
+ fontWeight = "normal";
61
+ fontStyle = "normal";
62
+ textAlign = "left";
63
+ textBaseline = "top";
64
+ /**
65
+ * Polyline path being assembled by moveTo / lineTo. Cleared on
66
+ * `beginPath()`; pushed to GPU on `stroke()`.
67
+ */
68
+ currentPolyline = [];
69
+ /**
70
+ * Curve segments collected since the last `beginPath()`. Quadratic
71
+ * and cubic Bezier `*CurveTo` calls push here in addition to pushing
72
+ * the curve endpoint into `currentPolyline` — the polyline forms the
73
+ * polygon hull for `fill()` triangulation, and the curve list adds
74
+ * Loop-Blinn fragment-tested triangles on top so curve regions stay
75
+ * perfectly smooth at any zoom.
76
+ */
77
+ currentCurves = [];
78
+ /**
79
+ * Ellipse parameters set by `ellipse()` — drives the fragment-SDF
80
+ * `EllipsePipeline` on `fill()`. Separate from the polygon path so
81
+ * `fill()` can skip the earcut pipeline entirely for ellipses (1 quad
82
+ * vs 24-512 segments). `stroke()` falls back to building a polyline
83
+ * lazily.
84
+ */
85
+ currentEllipse = null;
86
+ transform = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
87
+ /**
88
+ * save() / restore() snapshot stack. Mirrors Canvas2D's `ctx.save/
89
+ * restore` contract: the full paint + text state is saved, not just
90
+ * the transform — otherwise `opacity` / fill / stroke set inside a
91
+ * save()…restore() block would leak onto everything drawn afterwards.
92
+ * The current PATH is excluded — Canvas2D's save/restore doesn't
93
+ * snapshot the path either.
94
+ */
95
+ stack = [];
96
+ constructor(canvas, width, height) {
97
+ // `preserveDrawingBuffer: true` is required for an editor surface:
98
+ // the spec permits the browser to clear the drawing buffer after
99
+ // each composite when this flag is false, which makes shapes
100
+ // disappear in the steady state. Trading a small copy at composite
101
+ // time for visual correctness is the right call.
102
+ //
103
+ // Try with antialiasing first; some integrated GPUs deny the
104
+ // context when MSAA isn't available. Retry plain on failure so
105
+ // WebGL2 isn't lost entirely for a stylistic preference.
106
+ let gl = canvas.getContext("webgl2", {
107
+ antialias: true,
108
+ premultipliedAlpha: true,
109
+ preserveDrawingBuffer: true,
110
+ });
111
+ gl ??= canvas.getContext("webgl2", {
112
+ premultipliedAlpha: true,
113
+ preserveDrawingBuffer: true,
114
+ });
115
+ if (!gl) {
116
+ throw new Error("WebGL2 unavailable in this environment (probably hit the per-page GL context cap; " +
117
+ "Chrome allows ~16). LayeredSurface will fall back to canvas2d.");
118
+ }
119
+ this.gl = gl;
120
+ this._size = { width, height };
121
+ const vert = compile(this.gl, this.gl.VERTEX_SHADER, VERTEX_SHADER);
122
+ const frag = compile(this.gl, this.gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
123
+ this.program = link(this.gl, vert, frag);
124
+ this.gl.useProgram(this.program);
125
+ // Single quad shared across every solid-fill rect — the vertex
126
+ // shader applies the per-call transform to scale + translate it
127
+ // into place.
128
+ this.vbo = glReq(this.gl.createBuffer());
129
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vbo);
130
+ this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), this.gl.STATIC_DRAW);
131
+ // Dynamic buffer for polygon / stroke / fan vertex streams. Created
132
+ // empty — each draw bind + bufferData fills it.
133
+ this.dynamicVbo = glReq(this.gl.createBuffer());
134
+ this.aPosLoc = this.gl.getAttribLocation(this.program, "aPos");
135
+ this.gl.enableVertexAttribArray(this.aPosLoc);
136
+ this.gl.vertexAttribPointer(this.aPosLoc, 2, this.gl.FLOAT, false, 0, 0);
137
+ this.uTransformLoc = glReq(this.gl.getUniformLocation(this.program, "uTransform"));
138
+ this.uColorLoc = glReq(this.gl.getUniformLocation(this.program, "uColor"));
139
+ this.uOpacityLoc = glReq(this.gl.getUniformLocation(this.program, "uOpacity"));
140
+ this.gl.enable(this.gl.BLEND);
141
+ // Premultiplied-alpha blending. The context was created with
142
+ // `premultipliedAlpha: true` — that's the contract with the browser
143
+ // compositor, which treats the framebuffer RGB as
144
+ // already-multiplied-by-A. So both the blend func and every
145
+ // fragment shader speak premul: shader writes `(rgb*a, a)`, blend
146
+ // func uses `(ONE, ONE_MINUS_SRC_ALPHA)`.
147
+ this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
148
+ // Initial viewport — must match the canvas drawing buffer size. The
149
+ // WebGL spec defaults to the canvas's initial size, but if the
150
+ // canvas was resized via setupHiDpiNoContext after creation the
151
+ // viewport stays at the first size, so set it explicitly.
152
+ this.gl.viewport(0, 0, canvas.width, canvas.height);
153
+ }
154
+ get size() {
155
+ return this._size;
156
+ }
157
+ /**
158
+ * Update the recorded CSS-pixel size after the host resizes the
159
+ * underlying canvas. Callers handle `setupHiDpi` / `gl.viewport`
160
+ * updates on the actual canvas; this keeps the target's `size` getter
161
+ * in sync so downstream renderers see the new dimensions.
162
+ */
163
+ resize(width, height) {
164
+ this._size.width = width;
165
+ this._size.height = height;
166
+ this.gl.viewport(0, 0, width, height);
167
+ }
168
+ /**
169
+ * Free the underlying WebGL context immediately. Browsers cap the
170
+ * number of live WebGL contexts per page (~16 in Chrome); without
171
+ * `WEBGL_lose_context`, GC can take a while to collect old surfaces
172
+ * and runtime backend switches quickly hit the cap.
173
+ */
174
+ dispose() {
175
+ if (this.msdfPipeline) {
176
+ this.msdfPipeline.dispose();
177
+ this.msdfPipeline = null;
178
+ }
179
+ if (this.glyphAtlas) {
180
+ this.glyphAtlas.dispose(this.gl);
181
+ this.glyphAtlas = null;
182
+ this.glyphAtlasShaper = null;
183
+ }
184
+ if (this.indexBuffer) {
185
+ this.gl.deleteBuffer(this.indexBuffer);
186
+ this.indexBuffer = null;
187
+ }
188
+ if (this.curvePipeline) {
189
+ this.curvePipeline.dispose();
190
+ this.curvePipeline = null;
191
+ }
192
+ if (this.ellipsePipeline) {
193
+ this.ellipsePipeline.dispose();
194
+ this.ellipsePipeline = null;
195
+ }
196
+ this.gl.deleteBuffer(this.dynamicVbo);
197
+ if (this.imageQuadVbo) {
198
+ this.gl.deleteBuffer(this.imageQuadVbo);
199
+ this.imageQuadVbo = null;
200
+ }
201
+ // Release every uploaded image texture. `loseContext` below would
202
+ // also drop them, but explicit deletes make the resource lifecycle
203
+ // obvious.
204
+ for (const tex of this.textures.values()) {
205
+ this.gl.deleteTexture(tex);
206
+ }
207
+ this.textures.clear();
208
+ const lose = this.gl.getExtension("WEBGL_lose_context");
209
+ lose?.loseContext();
210
+ }
211
+ // --- Style ---
212
+ setFill(color) {
213
+ const parsed = parseWebGL2Color(color);
214
+ this.fillColor = [parsed[0], parsed[1], parsed[2]];
215
+ this.fillAlpha = parsed[3];
216
+ this.fillColorString = color ?? "transparent";
217
+ }
218
+ setOpacity(alpha) {
219
+ this.opacity = alpha;
220
+ }
221
+ // --- State stack ---
222
+ save() {
223
+ this.stack.push({
224
+ transform: { ...this.transform },
225
+ fillColor: [this.fillColor[0], this.fillColor[1], this.fillColor[2]],
226
+ fillAlpha: this.fillAlpha,
227
+ strokeColor: [this.strokeColor[0], this.strokeColor[1], this.strokeColor[2]],
228
+ strokeAlpha: this.strokeAlpha,
229
+ fillColorString: this.fillColorString,
230
+ strokeWidth: this.strokeWidth,
231
+ lineCap: this.lineCap,
232
+ lineJoin: this.lineJoin,
233
+ dashArray: this.dashArray,
234
+ opacity: this.opacity,
235
+ fontFamily: this.fontFamily,
236
+ fontSize: this.fontSize,
237
+ fontWeight: this.fontWeight,
238
+ fontStyle: this.fontStyle,
239
+ textAlign: this.textAlign,
240
+ textBaseline: this.textBaseline,
241
+ });
242
+ }
243
+ restore() {
244
+ const s = this.stack.pop();
245
+ if (!s)
246
+ return;
247
+ this.transform = s.transform;
248
+ this.fillColor = s.fillColor;
249
+ this.fillAlpha = s.fillAlpha;
250
+ this.strokeColor = s.strokeColor;
251
+ this.strokeAlpha = s.strokeAlpha;
252
+ this.fillColorString = s.fillColorString;
253
+ this.strokeWidth = s.strokeWidth;
254
+ this.lineCap = s.lineCap;
255
+ this.lineJoin = s.lineJoin;
256
+ this.dashArray = s.dashArray;
257
+ this.opacity = s.opacity;
258
+ this.fontFamily = s.fontFamily;
259
+ this.fontSize = s.fontSize;
260
+ this.fontWeight = s.fontWeight;
261
+ this.fontStyle = s.fontStyle;
262
+ this.textAlign = s.textAlign;
263
+ this.textBaseline = s.textBaseline;
264
+ }
265
+ // --- Transform ---
266
+ setTransform(t) {
267
+ this.transform = {
268
+ a: t.a,
269
+ b: t.b,
270
+ c: t.c,
271
+ d: t.d,
272
+ e: t.e,
273
+ f: t.f,
274
+ };
275
+ }
276
+ resetTransform() {
277
+ this.transform = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
278
+ }
279
+ translate(dx, dy) {
280
+ this.transform.e += this.transform.a * dx + this.transform.c * dy;
281
+ this.transform.f += this.transform.b * dx + this.transform.d * dy;
282
+ }
283
+ rotate(radians) {
284
+ const c = Math.cos(radians);
285
+ const s = Math.sin(radians);
286
+ const { a, b, c: tc, d } = this.transform;
287
+ this.transform.a = a * c + tc * s;
288
+ this.transform.b = b * c + d * s;
289
+ this.transform.c = a * -s + tc * c;
290
+ this.transform.d = b * -s + d * c;
291
+ }
292
+ scale(sx, sy) {
293
+ this.transform.a *= sx;
294
+ this.transform.b *= sx;
295
+ this.transform.c *= sy;
296
+ this.transform.d *= sy;
297
+ }
298
+ // --- Path primitives ---
299
+ beginPath() {
300
+ this.currentPath = null;
301
+ this.currentPolyline = [];
302
+ this.currentCurves = [];
303
+ this.currentEllipse = null;
304
+ }
305
+ rect(x, y, width, height) {
306
+ // Reuse one mutable rect — `fill()` / `stroke()` read it
307
+ // synchronously right after and never retain the reference, so a
308
+ // fresh object per rect would be waste.
309
+ this._pathRect.x = x;
310
+ this._pathRect.y = y;
311
+ this._pathRect.width = width;
312
+ this._pathRect.height = height;
313
+ this.currentPath = this._pathRect;
314
+ }
315
+ _pathRect = { x: 0, y: 0, width: 0, height: 0 };
316
+ moveTo(x, y) {
317
+ this.currentPolyline = [{ x, y }];
318
+ }
319
+ lineTo(x, y) {
320
+ this.currentPolyline.push({ x, y });
321
+ }
322
+ closePath() {
323
+ const start = this.currentPolyline[0];
324
+ if (this.currentPolyline.length > 1 && start !== undefined) {
325
+ this.currentPolyline.push({ ...start });
326
+ }
327
+ }
328
+ /**
329
+ * Stash the analytic ellipse — `fill()` draws it via the fragment-SDF
330
+ * `EllipsePipeline` (1 quad, perfect curve at any zoom). The polyline
331
+ * and polygon path are not populated up front; `stroke()` builds the
332
+ * polyline lazily if it's needed, saving the 24-512 vertex allocation
333
+ * when callers only fill.
334
+ */
335
+ ellipse(cx, cy, rx, ry) {
336
+ this.currentEllipse = { cx, cy, rx, ry };
337
+ this.currentPolyline = [];
338
+ this.currentPath = null;
339
+ }
340
+ /**
341
+ * Build a polyline approximation of `currentEllipse` for the stroke
342
+ * pipeline. Zoom-adaptive segment count keeps chord-to-arc error
343
+ * sub-pixel at any scale.
344
+ */
345
+ buildEllipseStrokePolyline(e) {
346
+ const scale = Math.hypot(this.transform.a, this.transform.b);
347
+ const screenRadius = Math.max(e.rx, e.ry) * (Number.isFinite(scale) && scale > 0 ? scale : 1);
348
+ const segments = Math.max(ELLIPSE_MIN_SEGMENTS, Math.min(ELLIPSE_MAX_SEGMENTS, Math.ceil(Math.PI * screenRadius * 0.7)));
349
+ this.currentPolyline = [];
350
+ for (let i = 0; i <= segments; i++) {
351
+ const t = (i / segments) * Math.PI * 2;
352
+ this.currentPolyline.push({
353
+ x: e.cx + e.rx * Math.cos(t),
354
+ y: e.cy + e.ry * Math.sin(t),
355
+ });
356
+ }
357
+ }
358
+ /**
359
+ * Quadratic Bezier. Pushed twice:
360
+ * 1. As a single `CurveSegment` into `currentCurves` for the
361
+ * Loop-Blinn fill pass — a perfect curve edge at any zoom (one
362
+ * fragment-tested triangle, no faceting).
363
+ * 2. As the curve endpoint into `currentPolyline` — keeps the
364
+ * polygon hull intact so `fill()`'s earcut triangulation and
365
+ * `stroke()`'s polyline math both see the chord.
366
+ *
367
+ * Strokes flatten to chord segments via the registered rasterizer
368
+ * (sub-pixel zoom-aware).
369
+ */
370
+ quadraticCurveTo(cx, cy, x, y) {
371
+ const start = this.currentPolyline[this.currentPolyline.length - 1] ?? { x: cx, y: cy };
372
+ this.currentCurves.push({
373
+ kind: "q",
374
+ points: [start, { x: cx, y: cy }, { x, y }],
375
+ });
376
+ // Stroke / hull representation: flatten to a polyline so the
377
+ // existing stroke + polygon-fan math still works.
378
+ const tolerance = this.currentFlattenTolerance();
379
+ const r = getActiveRasterizer();
380
+ if (r) {
381
+ const pts = r.flatten([
382
+ { kind: "M", to: start },
383
+ { kind: "Q", control: { x: cx, y: cy }, to: { x, y } },
384
+ ], tolerance);
385
+ for (let i = 1; i < pts.length; i++)
386
+ this.currentPolyline.push(req(pts[i]));
387
+ return;
388
+ }
389
+ const count = Math.max(8, Math.min(128, Math.ceil(curveLengthEstimate(start, { x, y }) / tolerance)));
390
+ const samples = sampleQuadratic(start, { x: cx, y: cy }, { x, y }, count);
391
+ for (let i = 1; i < samples.length; i++)
392
+ this.currentPolyline.push(req(samples[i]));
393
+ }
394
+ /** Cubic Bezier — same dual-track approach as quadratic. */
395
+ bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
396
+ const start = this.currentPolyline[this.currentPolyline.length - 1] ?? { x, y };
397
+ this.currentCurves.push({
398
+ kind: "c",
399
+ points: [start, { x: c1x, y: c1y }, { x: c2x, y: c2y }, { x, y }],
400
+ });
401
+ const tolerance = this.currentFlattenTolerance();
402
+ const r = getActiveRasterizer();
403
+ if (r) {
404
+ const pts = r.flatten([
405
+ { kind: "M", to: start },
406
+ {
407
+ kind: "C",
408
+ control1: { x: c1x, y: c1y },
409
+ control2: { x: c2x, y: c2y },
410
+ to: { x, y },
411
+ },
412
+ ], tolerance);
413
+ for (let i = 1; i < pts.length; i++)
414
+ this.currentPolyline.push(req(pts[i]));
415
+ return;
416
+ }
417
+ const count = Math.max(12, Math.min(192, Math.ceil(curveLengthEstimate(start, { x, y }) / tolerance)));
418
+ const samples = sampleCubic(start, { x: c1x, y: c1y }, { x: c2x, y: c2y }, { x, y }, count);
419
+ for (let i = 1; i < samples.length; i++)
420
+ this.currentPolyline.push(req(samples[i]));
421
+ }
422
+ /**
423
+ * World-unit tolerance that maps to ~`SCREEN_TOLERANCE_PX` on screen
424
+ * at the current transform. Used by every curve-flatten call so the
425
+ * polyline density tracks the zoom.
426
+ */
427
+ currentFlattenTolerance() {
428
+ // Linear scale factor of the affine (length of the transformed
429
+ // unit x-axis). `transform.a/b` are the matrix's first column;
430
+ // uniform scale (no shear) holds for every path Editor sends.
431
+ // Guard against zero / NaN.
432
+ const scale = Math.hypot(this.transform.a, this.transform.b);
433
+ if (!Number.isFinite(scale) || scale <= 0)
434
+ return SCREEN_TOLERANCE_PX;
435
+ return SCREEN_TOLERANCE_PX / scale;
436
+ }
437
+ /**
438
+ * Image rendering — uploads `image` to a freshly-created GL texture
439
+ * on first call, caches it by reference for subsequent frames. Drawn
440
+ * as a textured quad via a dedicated program created lazily on the
441
+ * first image call.
442
+ */
443
+ drawImage(image, dx, dy, dw, dh, dynamic) {
444
+ const tex = this.textureFor(image, dynamic ?? false);
445
+ if (!tex)
446
+ return;
447
+ if (!this.imageProgram) {
448
+ this.imageProgram = createImageProgram(this.gl);
449
+ // Lazily create the image quad VBO too — interleaved
450
+ // (pos.xy, uv.xy) for a TRIANGLE_STRIP unit quad. Static — never
451
+ // re-uploaded; the per-call placement goes through `uTransform`.
452
+ this.imageQuadVbo = this.gl.createBuffer();
453
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.imageQuadVbo);
454
+ 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);
455
+ }
456
+ const ip = this.imageProgram;
457
+ this.gl.useProgram(ip.program);
458
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.imageQuadVbo);
459
+ this.gl.enableVertexAttribArray(ip.aPos);
460
+ this.gl.vertexAttribPointer(ip.aPos, 2, this.gl.FLOAT, false, 16, 0);
461
+ this.gl.enableVertexAttribArray(ip.aUV);
462
+ this.gl.vertexAttribPointer(ip.aUV, 2, this.gl.FLOAT, false, 16, 8);
463
+ // Project the drawn region through transform + viewport.
464
+ const projected = applyImageMat({
465
+ a: this.transform.a * dw,
466
+ b: this.transform.b * dw,
467
+ c: this.transform.c * dh,
468
+ d: this.transform.d * dh,
469
+ e: this.transform.e + this.transform.a * dx + this.transform.c * dy,
470
+ f: this.transform.f + this.transform.b * dx + this.transform.d * dy,
471
+ }, this._size.width, this._size.height);
472
+ this.gl.uniformMatrix3fv(ip.uTransform, false, projected);
473
+ this.gl.uniform1f(ip.uOpacity, this.opacity);
474
+ this.gl.activeTexture(this.gl.TEXTURE0);
475
+ this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
476
+ this.gl.uniform1i(ip.uTex, 0);
477
+ this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
478
+ // Restore the solid-colour program for subsequent fills / strokes.
479
+ this.restoreSolidProgram();
480
+ }
481
+ /** Lazy image program + its static unit quad+UV VBO. */
482
+ imageProgram = null;
483
+ imageQuadVbo = null;
484
+ /**
485
+ * `TexImageSource` → uploaded `WebGLTexture` cache. A plain `Map`
486
+ * with LRU eviction + explicit `gl.deleteTexture` (see
487
+ * `evictImageTexturesIfOverCap`) so the GPU texture is released
488
+ * deterministically rather than waiting for GC.
489
+ *
490
+ * `Map` preserves insertion order, so the head is least-recently
491
+ * used. `textureFor` touches a hit by delete + set (moves to tail).
492
+ */
493
+ textures = new Map();
494
+ textureFor(src, dynamic) {
495
+ // Reject non-drawable handles: a restored scene's `metadata.image`
496
+ // is `{}` (a live `<img>` serialises to an empty object), which
497
+ // passes a bare `typeof object` check but throws "overload
498
+ // resolution failed" inside `texImage2D`. `isDrawableImageSource`
499
+ // verifies it's an actual HTMLImageElement / canvas / bitmap / etc.
500
+ if (!isDrawableImageSource(src)) {
501
+ warnSkippedImage(src);
502
+ return null;
503
+ }
504
+ const key = src;
505
+ const cached = this.textures.get(key);
506
+ if (cached) {
507
+ // Touch — re-insert at the tail so LRU eviction below picks colder
508
+ // entries first.
509
+ this.textures.delete(key);
510
+ this.textures.set(key, cached);
511
+ // Animated source (GIF `<img>`, `<video>`) — the source's pixels
512
+ // advanced since last frame, so re-upload them onto the existing
513
+ // texture handle. Static images skip this. Re-using the handle
514
+ // avoids leaking a fresh texture per frame.
515
+ if (dynamic) {
516
+ this.gl.bindTexture(this.gl.TEXTURE_2D, cached);
517
+ this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
518
+ this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, src);
519
+ }
520
+ return cached;
521
+ }
522
+ const tex = this.gl.createTexture();
523
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- createTexture typed non-null but returns null on context loss
524
+ if (!tex)
525
+ return null;
526
+ this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
527
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
528
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
529
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
530
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
531
+ this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
532
+ this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, src);
533
+ this.textures.set(key, tex);
534
+ this.evictImageTexturesIfOverCap();
535
+ return tex;
536
+ }
537
+ /**
538
+ * Trim `textures` down to `WEBGL2_IMAGE_TEXTURE_CACHE_CAP` entries by
539
+ * dropping least-recently-used keys and explicitly releasing the GPU
540
+ * texture for each evicted entry.
541
+ *
542
+ * Skips entries also held by `textBitmaps` — those are evicted through
543
+ * `evictTextBitmapsIfOverCap` instead, which already deletes the
544
+ * texture. Skipping here keeps the two cache layers from
545
+ * double-`gl.deleteTexture`'ing the same handle.
546
+ */
547
+ evictImageTexturesIfOverCap() {
548
+ while (this.textures.size > WEBGL2_IMAGE_TEXTURE_CACHE_CAP) {
549
+ const oldestKey = this.textures.keys().next().value;
550
+ if (oldestKey === undefined)
551
+ break;
552
+ // Don't evict a texture that's currently backing a live
553
+ // text-bitmap cache entry — `evictTextBitmapsIfOverCap` owns those
554
+ // handles. Move it to the tail so the LRU pointer advances and the
555
+ // next-oldest entry is tried.
556
+ if (isTextBitmapBacked(this.textBitmaps, oldestKey)) {
557
+ const tex = this.textures.get(oldestKey);
558
+ if (tex === undefined)
559
+ continue;
560
+ this.textures.delete(oldestKey);
561
+ this.textures.set(oldestKey, tex);
562
+ continue;
563
+ }
564
+ const tex = this.textures.get(oldestKey);
565
+ this.textures.delete(oldestKey);
566
+ if (tex)
567
+ this.gl.deleteTexture(tex);
568
+ }
569
+ }
570
+ fill(_rule) {
571
+ void _rule;
572
+ const effectiveAlpha = this.opacity * this.fillAlpha;
573
+ if (effectiveAlpha <= 0)
574
+ return; // transparent fill — nothing to draw
575
+ // Ellipse path — single fragment-SDF quad regardless of radius.
576
+ // Vector-perfect at any zoom; 4 vertices instead of 24-512.
577
+ if (this.currentEllipse) {
578
+ this.ellipsePipeline ??= new EllipsePipeline(this.gl);
579
+ const e = this.currentEllipse;
580
+ this.ellipsePipeline.draw(e.cx, e.cy, e.rx, e.ry, this.fillColor, effectiveAlpha, this.transform, this._size);
581
+ this.restoreSolidProgram();
582
+ return;
583
+ }
584
+ // Rect path — uses the bundled unit-quad VBO + uTransform
585
+ // pre-multiplied to map [0,1]² onto the rect bounds. Cheapest path;
586
+ // most shape backgrounds (rectangles) hit it.
587
+ if (this.currentPath) {
588
+ const r = this.currentPath;
589
+ const projected = applyMat({
590
+ a: this.transform.a * r.width,
591
+ b: this.transform.b * r.width,
592
+ c: this.transform.c * r.height,
593
+ d: this.transform.d * r.height,
594
+ e: this.transform.e + this.transform.a * r.x + this.transform.c * r.y,
595
+ f: this.transform.f + this.transform.b * r.x + this.transform.d * r.y,
596
+ }, this._size.width, this._size.height);
597
+ this.restoreSolidProgram(); // ensure the solid VBO+attrib is live
598
+ this.gl.uniformMatrix3fv(this.uTransformLoc, false, projected);
599
+ this.gl.uniform3f(this.uColorLoc, this.fillColor[0], this.fillColor[1], this.fillColor[2]);
600
+ this.gl.uniform1f(this.uOpacityLoc, effectiveAlpha);
601
+ this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
602
+ return;
603
+ }
604
+ // Polygon path — assembled via moveTo / lineTo / bezierCurveTo.
605
+ // Triangulated through earcut so concave shapes (arrows, stars,
606
+ // lightning bolts) fill correctly. Earcut is dependency-free and
607
+ // handles holes too if ever needed.
608
+ if (this.currentPolyline.length >= 3) {
609
+ this.fillPolygonEarcut(this.currentPolyline, effectiveAlpha);
610
+ }
611
+ // Loop-Blinn curve overlay. Adds fragment-tested quadratic / cubic
612
+ // triangles on top of the polygon fill so curve regions
613
+ // (rounded-rect corners, ellipse quadrants, glyph outlines) stay
614
+ // vector-perfect at any zoom. `w` per vertex flips inside / outside,
615
+ // so curves bulging outward from the polygon hull paint more pixels
616
+ // and curves bulging inward paint fewer (the polygon fill already
617
+ // covered the inward area).
618
+ //
619
+ // The Loop-Blinn triangle is always added. For shapes whose curves
620
+ // bulge inward (concave silhouettes) this can over-paint a thin
621
+ // sliver; a knockout pass that erases the inward-curve area inside
622
+ // the polygon fill would need stencil buffer plumbing the kernel
623
+ // doesn't have. The artefact is invisible at 1× zoom and tiny even
624
+ // at 20×.
625
+ if (this.currentCurves.length > 0) {
626
+ this.curvePipeline ??= new LoopBlinnCurvePipeline(this.gl);
627
+ this.curvePipeline.draw(this.currentCurves, this.fillColor, effectiveAlpha, this.transform, this._size);
628
+ this.restoreSolidProgram();
629
+ }
630
+ }
631
+ curvePipeline = null;
632
+ ellipsePipeline = null;
633
+ /**
634
+ * Triangulate the polygon via earcut and emit one TRIANGLES draw.
635
+ * Vertices are pre-projected into clip space so the program's
636
+ * `uTransform` stays identity. Falls back to a triangle-fan when
637
+ * earcut returns an empty index list (degenerate self-intersecting
638
+ * polygon).
639
+ */
640
+ fillPolygonEarcut(polyline, effectiveAlpha) {
641
+ // Skip the implicitly-closed duplicate last vertex if the caller
642
+ // already issued `closePath` — earcut would treat it as a degenerate
643
+ // sliver.
644
+ const polyFirst = polyline[0];
645
+ const polyLast = polyline[polyline.length - 1];
646
+ const n = polyline.length >= 4 &&
647
+ polyFirst !== undefined &&
648
+ polyFirst.x === polyLast?.x &&
649
+ polyFirst.y === polyLast.y
650
+ ? polyline.length - 1
651
+ : polyline.length;
652
+ if (n < 3)
653
+ return;
654
+ // earcut wants a flat [x0, y0, x1, y1, ...] in world coords. Reuse
655
+ // the module-level scratch buffers — earcut accepts any array-like
656
+ // with [i] + length, so a Float64Array view works.
657
+ ensureEarcutVertexCapacity(n);
658
+ const flat = scratchEarcutFlat;
659
+ for (let i = 0; i < n; i++) {
660
+ const p = req(polyline[i]);
661
+ flat[i * 2] = p.x;
662
+ flat[i * 2 + 1] = p.y;
663
+ }
664
+ // Pass only the populated prefix — `subarray` is a view, no copy.
665
+ const indices = earcut(flat.subarray(0, n * 2));
666
+ if (indices.length === 0) {
667
+ // Pathological polygon — fall back to a fan so something renders.
668
+ this.drawTriangleFan(polyline, n, effectiveAlpha);
669
+ return;
670
+ }
671
+ // Project once into clip space, then index-draw. Shares the earcut
672
+ // vertex-count budget — `ensureEarcutVertexCapacity` above already
673
+ // grew `scratchEarcutVerts` if needed.
674
+ const sx = 2 / this._size.width;
675
+ const sy = -2 / this._size.height;
676
+ const verts = scratchEarcutVerts;
677
+ for (let i = 0; i < n; i++) {
678
+ const p = req(polyline[i]);
679
+ const wx = this.transform.a * p.x + this.transform.c * p.y + this.transform.e;
680
+ const wy = this.transform.b * p.x + this.transform.d * p.y + this.transform.f;
681
+ verts[i * 2] = wx * sx - 1;
682
+ verts[i * 2 + 1] = wy * sy + 1;
683
+ }
684
+ // Copy earcut's `number[]` indices into the scratch Uint16Array.
685
+ // earcut returns its own JS array; the copy lets us pass a sized
686
+ // TypedArray view to bufferData with no further allocation.
687
+ ensureEarcutIndexCapacity(indices.length);
688
+ for (let i = 0; i < indices.length; i++) {
689
+ scratchEarcutIndices[i] = req(indices[i]);
690
+ }
691
+ const gl = this.gl;
692
+ gl.useProgram(this.program);
693
+ // Use the dynamic VBO — leaves the static unit-quad VBO untouched so
694
+ // subsequent rect fills don't pay for a re-upload.
695
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.dynamicVbo);
696
+ gl.bufferData(gl.ARRAY_BUFFER, verts.subarray(0, n * 2), gl.DYNAMIC_DRAW);
697
+ gl.enableVertexAttribArray(this.aPosLoc);
698
+ gl.vertexAttribPointer(this.aPosLoc, 2, gl.FLOAT, false, 0, 0);
699
+ gl.uniformMatrix3fv(this.uTransformLoc, false, IDENTITY_MAT3);
700
+ gl.uniform3f(this.uColorLoc, this.fillColor[0], this.fillColor[1], this.fillColor[2]);
701
+ gl.uniform1f(this.uOpacityLoc, effectiveAlpha);
702
+ // Lazy IBO — earcut returns 16-bit indices for ≤65535 verts (the
703
+ // realistic ceiling for any one polygon).
704
+ this.indexBuffer ??= gl.createBuffer();
705
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
706
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, scratchEarcutIndices.subarray(0, indices.length), gl.DYNAMIC_DRAW);
707
+ gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
708
+ // Restore solid program's attribute binding to the static unit-quad
709
+ // VBO so the next rect fill picks up the right vertex stream.
710
+ this.restoreSolidProgram();
711
+ }
712
+ indexBuffer = null;
713
+ /**
714
+ * Convex / fallback path — triangle fan from polyline[0]. Renders
715
+ * convex polygons correctly; concave ones get a wrong silhouette (the
716
+ * earcut path handles those instead).
717
+ */
718
+ drawTriangleFan(polyline, n, effectiveAlpha) {
719
+ const sx = 2 / this._size.width;
720
+ const sy = -2 / this._size.height;
721
+ // Share the module-level scratch verts with `fillPolygonEarcut`.
722
+ ensureEarcutVertexCapacity(n);
723
+ const verts = scratchEarcutVerts;
724
+ for (let i = 0; i < n; i++) {
725
+ const p = req(polyline[i]);
726
+ const wx = this.transform.a * p.x + this.transform.c * p.y + this.transform.e;
727
+ const wy = this.transform.b * p.x + this.transform.d * p.y + this.transform.f;
728
+ verts[i * 2] = wx * sx - 1;
729
+ verts[i * 2 + 1] = wy * sy + 1;
730
+ }
731
+ const gl = this.gl;
732
+ gl.useProgram(this.program);
733
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.dynamicVbo);
734
+ gl.bufferData(gl.ARRAY_BUFFER, verts.subarray(0, n * 2), gl.DYNAMIC_DRAW);
735
+ gl.enableVertexAttribArray(this.aPosLoc);
736
+ gl.vertexAttribPointer(this.aPosLoc, 2, gl.FLOAT, false, 0, 0);
737
+ gl.uniformMatrix3fv(this.uTransformLoc, false, IDENTITY_MAT3);
738
+ gl.uniform3f(this.uColorLoc, this.fillColor[0], this.fillColor[1], this.fillColor[2]);
739
+ gl.uniform1f(this.uOpacityLoc, effectiveAlpha);
740
+ gl.drawArrays(gl.TRIANGLE_FAN, 0, n);
741
+ this.restoreSolidProgram();
742
+ }
743
+ /**
744
+ * Clear the canvas. With no `bounds` does a full backbuffer wipe; with
745
+ * `bounds` wipes only the rectangle the editor's dirty-rect pass
746
+ * identified. Honouring `bounds` is mandatory — when the scene
747
+ * reference doesn't change, the editor sends a zero-area dirty rect
748
+ * and expects the previous frame to survive untouched.
749
+ * `preserveDrawingBuffer: true` carries the persistent frame across
750
+ * composites.
751
+ *
752
+ * For bounded clears the implementation flips on a scissor box so the
753
+ * clear is confined to the dirty rect, mirroring Canvas2D's
754
+ * partial-clear semantics. The scissor box is in DPR-bitmap pixels
755
+ * with bottom-left origin (GL convention), translated from the
756
+ * caller's top-left CSS-pixel rect.
757
+ */
758
+ clear(bounds) {
759
+ const bitmapW = this.gl.canvas.width;
760
+ const bitmapH = this.gl.canvas.height;
761
+ if (bounds) {
762
+ // Editor's "nothing changed" sentinel is a zero/negative-area rect.
763
+ if (bounds.width <= 0 || bounds.height <= 0)
764
+ return;
765
+ const dprX = this._size.width > 0 ? bitmapW / this._size.width : 1;
766
+ const dprY = this._size.height > 0 ? bitmapH / this._size.height : 1;
767
+ const x = Math.floor(bounds.x * dprX);
768
+ const w = Math.ceil(bounds.width * dprX);
769
+ const h = Math.ceil(bounds.height * dprY);
770
+ // GL scissor origin is bottom-left; the editor speaks top-left.
771
+ const y = Math.floor(bitmapH - (bounds.y + bounds.height) * dprY);
772
+ this.gl.viewport(0, 0, bitmapW, bitmapH);
773
+ this.gl.enable(this.gl.SCISSOR_TEST);
774
+ this.gl.scissor(x, y, w, h);
775
+ this.gl.clearColor(0, 0, 0, 0);
776
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT);
777
+ this.gl.disable(this.gl.SCISSOR_TEST);
778
+ return;
779
+ }
780
+ this.gl.viewport(0, 0, bitmapW, bitmapH);
781
+ this.gl.clearColor(0, 0, 0, 0);
782
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT);
783
+ }
784
+ // --- Stroke pipeline ---
785
+ setStroke(color) {
786
+ const parsed = parseWebGL2Color(color);
787
+ this.strokeColor = [parsed[0], parsed[1], parsed[2]];
788
+ this.strokeAlpha = parsed[3];
789
+ }
790
+ setStrokeWidth(width) {
791
+ this.strokeWidth = Math.max(0, width);
792
+ }
793
+ stroke() {
794
+ if (this.currentPath) {
795
+ // Rect outline → 4 corners as a closed polyline.
796
+ const r = this.currentPath;
797
+ this.currentPolyline = [
798
+ { x: r.x, y: r.y },
799
+ { x: r.x + r.width, y: r.y },
800
+ { x: r.x + r.width, y: r.y + r.height },
801
+ { x: r.x, y: r.y + r.height },
802
+ { x: r.x, y: r.y },
803
+ ];
804
+ }
805
+ // Ellipse outline — lazily generate the polyline approximation here
806
+ // so callers that only fill don't pay for the 24-512 vertex
807
+ // allocation. EllipsePipeline owns the fill path; stroke still goes
808
+ // through the polygon stroke pipeline.
809
+ if (this.currentEllipse && this.currentPolyline.length < 2) {
810
+ this.buildEllipseStrokePolyline(this.currentEllipse);
811
+ }
812
+ if (this.currentPolyline.length < 2)
813
+ return;
814
+ const effectiveAlpha = this.opacity * this.strokeAlpha;
815
+ if (effectiveAlpha <= 0)
816
+ return; // transparent stroke — nothing to draw
817
+ // Dashed: split the polyline into "on" sub-polylines in world units
818
+ // (Canvas2D dashes in the world-space ctx transform, so this
819
+ // matches it), then stroke each through the same pipeline. Solid →
820
+ // one call.
821
+ const runs = this.dashArray
822
+ ? dashPolyline(this.currentPolyline, this.dashArray)
823
+ : [this.currentPolyline];
824
+ for (const run of runs) {
825
+ if (run.length < 2)
826
+ continue;
827
+ drawPolylineStrokeImpl(this.gl, run, {
828
+ width: this.strokeWidth,
829
+ color: this.strokeColor,
830
+ opacity: effectiveAlpha,
831
+ join: this.lineJoin,
832
+ cap: this.lineCap,
833
+ }, this.transform, this._size, this.program, this.uTransformLoc, this.uColorLoc, this.uOpacityLoc, this.dynamicVbo, this.aPosLoc, IDENTITY_MAT3);
834
+ }
835
+ // Stroke wrote into the dynamic VBO; rebind the static unit-quad VBO
836
+ // so the next solid rect fill picks up the right vertex stream.
837
+ this.restoreSolidProgram();
838
+ }
839
+ // --- Stroke style state (consumed by stroke()) ---
840
+ setLineCap(cap) {
841
+ this.lineCap = cap;
842
+ }
843
+ setLineJoin(join) {
844
+ this.lineJoin = join;
845
+ }
846
+ setDashArray(dash) {
847
+ this.dashArray = dash && dash.length > 0 ? dash : null;
848
+ }
849
+ setFont(family, size, options) {
850
+ this.fontFamily = family;
851
+ this.fontSize = size;
852
+ this.fontWeight = options?.weight ?? "normal";
853
+ this.fontStyle = options?.style ?? "normal";
854
+ }
855
+ setTextAlign(align) {
856
+ this.textAlign = align;
857
+ }
858
+ setTextBaseline(baseline) {
859
+ this.textBaseline = baseline;
860
+ }
861
+ /**
862
+ * Text rendering with two paths:
863
+ *
864
+ * 1. MSDF (preferred) — when an `MsdfShaper`-compatible TextShaper
865
+ * is registered. Builds per-glyph quads against a shared
866
+ * `GlyphAtlas`, draws them with the bundled MSDF program so
867
+ * letters stay crisp at any zoom (no bitmap re-rasterisation
868
+ * when the user scales the view).
869
+ *
870
+ * 2. OffscreenCanvas fallback — used when no MSDF-capable shaper is
871
+ * registered (older module, Safari without bundled wasm, etc.).
872
+ */
873
+ fillText(text, x, y, maxWidth) {
874
+ if (text.length === 0)
875
+ return;
876
+ void maxWidth;
877
+ const atlas = this.ensureGlyphAtlas();
878
+ if (atlas) {
879
+ this.fillTextMSDF(text, x, y, atlas);
880
+ return;
881
+ }
882
+ const bitmap = this.rasteriseString(text);
883
+ if (!bitmap)
884
+ return;
885
+ const m = this.textMetrics(text);
886
+ let px = x;
887
+ if (this.textAlign === "center")
888
+ px -= m.width / 2;
889
+ else if (this.textAlign === "right")
890
+ px -= m.width;
891
+ let py = y;
892
+ if (this.textBaseline === "middle")
893
+ py -= this.fontSize / 2;
894
+ else if (this.textBaseline === "bottom")
895
+ py -= this.fontSize;
896
+ this.drawImage(bitmap, px, py, m.width, this.fontSize * 1.4);
897
+ }
898
+ msdfPipeline = null;
899
+ glyphAtlas = null;
900
+ glyphAtlasShaper = null;
901
+ /**
902
+ * Lazy-acquire the MSDF atlas — only when there's an
903
+ * `MsdfShaper`-compatible TextShaper registered via
904
+ * `setActiveTextShaper`. Held for the lifetime of the WebGL2Target;
905
+ * cleared on dispose.
906
+ *
907
+ * Re-creates the atlas if a different shaper instance gets registered
908
+ * later. Same shaper instance → reuses the existing cache, so
909
+ * steady-state cost is one map lookup.
910
+ */
911
+ ensureGlyphAtlas() {
912
+ const shaper = getActiveTextShaper();
913
+ if (!shaper)
914
+ return null;
915
+ if (!isMsdfShaper(shaper))
916
+ return null;
917
+ if (this.glyphAtlas && this.glyphAtlasShaper === shaper)
918
+ return this.glyphAtlas;
919
+ if (this.glyphAtlas)
920
+ this.glyphAtlas.dispose(this.gl);
921
+ this.glyphAtlas = new GlyphAtlas(shaper);
922
+ this.glyphAtlasShaper = shaper;
923
+ return this.glyphAtlas;
924
+ }
925
+ /**
926
+ * MSDF path for `fillText`. Honours textAlign / textBaseline by
927
+ * measuring the string width upfront and shifting the cursor. Width
928
+ * measurement walks the atlas (advance from cached metrics), so it
929
+ * doesn't round-trip the WASM measure().
930
+ */
931
+ fillTextMSDF(text, x, y, atlas) {
932
+ this.msdfPipeline ??= new MsdfTextPipeline(this.gl);
933
+ // Pick the embedded font for the current family + weight/style.
934
+ const fontId = atlas.resolveFontId(this.fontFamily, this.fontWeight === "bold", this.fontStyle === "italic");
935
+ // Horizontal alignment is handled inside `drawText` (single walk —
936
+ // it measures the run and shifts via the transform), so no separate
937
+ // width-measuring pass here.
938
+ const alignFactor = this.textAlign === "center" ? 0.5 : this.textAlign === "right" ? 1 : 0;
939
+ let py = y;
940
+ // Editor convention: baseline=top means y is the top of the text
941
+ // box. The MSDF quad math places the glyph relative to its font
942
+ // baseline, so shift y down by one font size for the "top" baseline
943
+ // (gets the visible bbox into [y, y+fontSize]).
944
+ if (this.textBaseline === "top")
945
+ py += this.fontSize;
946
+ else if (this.textBaseline === "middle")
947
+ py += this.fontSize / 2;
948
+ // baseline=bottom uses py as-is (cursor sits on the baseline).
949
+ this.msdfPipeline.drawText(text, x, py, this.fontSize, atlas, {
950
+ opacity: this.opacity,
951
+ color: this.fillColor,
952
+ transform: this.transform,
953
+ }, this._size, alignFactor, fontId);
954
+ // The MSDF pipeline left its own program active; restore the
955
+ // solid-fill program + VBO state so the next rect / polyline draw
956
+ // uses the correct shader.
957
+ this.restoreSolidProgram();
958
+ }
959
+ /**
960
+ * Rebind the solid program + static unit-quad VBO + cached aPos
961
+ * pointer. Three GL calls, no allocation, no upload, no string-keyed
962
+ * driver lookup. Called after any draw that switched program or
963
+ * rebound the buffer (image, MSDF text, curves, polygon fill, triangle
964
+ * fan).
965
+ */
966
+ restoreSolidProgram() {
967
+ this.gl.useProgram(this.program);
968
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vbo);
969
+ this.gl.enableVertexAttribArray(this.aPosLoc);
970
+ this.gl.vertexAttribPointer(this.aPosLoc, 2, this.gl.FLOAT, false, 0, 0);
971
+ }
972
+ measureText(text) {
973
+ return this.textMetrics(text);
974
+ }
975
+ // --- Glyph atlas (per-string fallback path) ---
976
+ /** Hidden Canvas2D context for measureText + bitmap rasterisation. */
977
+ textCtx = null;
978
+ /**
979
+ * Per-string OffscreenCanvas cache for the fallback text path (no MSDF
980
+ * shaper registered). Keyed by `text|font|color`. Capped via
981
+ * `WEBGL2_TEXT_BITMAP_CACHE_CAP` LRU eviction.
982
+ *
983
+ * `Map` preserves insertion order, so the oldest entry is the iterator
984
+ * head. `rasteriseString` "touches" a hit by delete + set, which moves
985
+ * it to the tail.
986
+ */
987
+ textBitmaps = new Map();
988
+ ensureTextCtx() {
989
+ if (this.textCtx)
990
+ return this.textCtx;
991
+ if (typeof OffscreenCanvas === "undefined")
992
+ return null;
993
+ const tmp = new OffscreenCanvas(1, 1);
994
+ const ctx = tmp.getContext("2d");
995
+ if (!ctx)
996
+ return null;
997
+ // OffscreenCanvasRenderingContext2D is structurally compatible with
998
+ // the methods used here (font / measureText / fillText / textAlign /
999
+ // textBaseline).
1000
+ this.textCtx = ctx;
1001
+ return this.textCtx;
1002
+ }
1003
+ textFontSpec() {
1004
+ return `${this.fontSize}px ${this.fontFamily}`;
1005
+ }
1006
+ textMetrics(text) {
1007
+ // When the MSDF path is active, measure with the same atlas glyph
1008
+ // advances the renderer uses (`fillTextMSDF` walks `glyph.advance *
1009
+ // fontSize / unitsPerEm`). Otherwise `measureText` would report the
1010
+ // system-font width (a different, usually wider font) and callers —
1011
+ // caret geometry, selection bounds — would drift from what's drawn.
1012
+ const atlas = this.ensureGlyphAtlas();
1013
+ if (atlas) {
1014
+ const fontId = atlas.resolveFontId(this.fontFamily, this.fontWeight === "bold", this.fontStyle === "italic");
1015
+ let w = 0;
1016
+ for (const ch of text) {
1017
+ const cp = ch.codePointAt(0);
1018
+ if (cp === undefined)
1019
+ continue;
1020
+ const glyph = atlas.getOrRasterize(cp, fontId);
1021
+ if (!glyph)
1022
+ continue;
1023
+ w += (glyph.advance * this.fontSize) / glyph.unitsPerEm;
1024
+ }
1025
+ return { width: w };
1026
+ }
1027
+ // Fallback (no MSDF shaper): Canvas2D system-font measurement, which
1028
+ // matches the Canvas2D bitmap text path used in that case.
1029
+ const ctx = this.ensureTextCtx();
1030
+ if (!ctx)
1031
+ return { width: text.length * this.fontSize * 0.55 };
1032
+ ctx.font = this.textFontSpec();
1033
+ return { width: ctx.measureText(text).width };
1034
+ }
1035
+ rasteriseString(text) {
1036
+ if (typeof OffscreenCanvas === "undefined")
1037
+ return null;
1038
+ const key = `${text}|${this.textFontSpec()}|${this.fillColorString}`;
1039
+ const cached = this.textBitmaps.get(key);
1040
+ if (cached) {
1041
+ // Touch — re-insert at the tail so the LRU eviction below picks
1042
+ // colder entries first.
1043
+ this.textBitmaps.delete(key);
1044
+ this.textBitmaps.set(key, cached);
1045
+ return cached;
1046
+ }
1047
+ const m = this.textMetrics(text);
1048
+ // Pad height by 40% — covers font ascent/descent fuzz without
1049
+ // requiring per-font TextMetrics support.
1050
+ const w = Math.max(1, Math.ceil(m.width));
1051
+ const h = Math.max(1, Math.ceil(this.fontSize * 1.4));
1052
+ const canvas = new OffscreenCanvas(w, h);
1053
+ const ctx = canvas.getContext("2d");
1054
+ if (!ctx)
1055
+ return null;
1056
+ ctx.font = this.textFontSpec();
1057
+ ctx.textAlign = "left";
1058
+ ctx.textBaseline = "top";
1059
+ ctx.fillStyle = this.fillColorString;
1060
+ ctx.fillText(text, 0, 0);
1061
+ this.textBitmaps.set(key, canvas);
1062
+ this.evictTextBitmapsIfOverCap();
1063
+ return canvas;
1064
+ }
1065
+ /**
1066
+ * Trim `textBitmaps` down to `WEBGL2_TEXT_BITMAP_CACHE_CAP` entries by
1067
+ * dropping least-recently-used keys. For each evicted OffscreenCanvas
1068
+ * the associated GPU texture (uploaded lazily via `drawImage` →
1069
+ * `textureFor`) is also deleted, otherwise the VRAM stays held until
1070
+ * JS GC collects the canvas.
1071
+ */
1072
+ evictTextBitmapsIfOverCap() {
1073
+ while (this.textBitmaps.size > WEBGL2_TEXT_BITMAP_CACHE_CAP) {
1074
+ const oldestKey = this.textBitmaps.keys().next().value;
1075
+ if (oldestKey === undefined)
1076
+ break;
1077
+ const oldCanvas = this.textBitmaps.get(oldestKey);
1078
+ this.textBitmaps.delete(oldestKey);
1079
+ if (oldCanvas) {
1080
+ const tex = this.textures.get(oldCanvas);
1081
+ if (tex) {
1082
+ this.gl.deleteTexture(tex);
1083
+ this.textures.delete(oldCanvas);
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ /**
1090
+ * Scan the text-bitmap LRU for an OffscreenCanvas reference matching the
1091
+ * given object. Used by `evictImageTexturesIfOverCap` to avoid
1092
+ * double-evicting a texture that's still held by the text-bitmap cache —
1093
+ * `evictTextBitmapsIfOverCap` owns those `gl.deleteTexture` calls.
1094
+ *
1095
+ * Linear scan is fine: text bitmap cache size ≤ 256, called only on
1096
+ * texture LRU eviction (rare).
1097
+ */
1098
+ const isTextBitmapBacked = (textBitmaps, candidate) => {
1099
+ for (const canvas of textBitmaps.values()) {
1100
+ if (canvas === candidate)
1101
+ return true;
1102
+ }
1103
+ return false;
1104
+ };
1105
+ /**
1106
+ * Split a polyline into the "on" dash runs for `pattern` ([on, off, on, …]),
1107
+ * lengths in the polyline's own (world) units. Walks each segment, toggling
1108
+ * on/off as the running distance crosses each pattern element, and emits the
1109
+ * point lists of the drawn runs. Used by WebGL2 `stroke()` to render dashed /
1110
+ * dotted lines (Canvas2D gets this for free from `ctx.setLineDash`).
1111
+ */
1112
+ export const dashPolyline = (pts, pattern) => {
1113
+ const runs = [];
1114
+ let idx = 0;
1115
+ let remaining = pattern[0] ?? 0;
1116
+ let drawing = true;
1117
+ let cur = [];
1118
+ if (remaining <= 0)
1119
+ return [pts.slice()]; // degenerate pattern → solid
1120
+ for (let i = 0; i < pts.length - 1; i++) {
1121
+ const a = req(pts[i]);
1122
+ const b = req(pts[i + 1]);
1123
+ const segLen = Math.hypot(b.x - a.x, b.y - a.y);
1124
+ if (segLen < 1e-9)
1125
+ continue;
1126
+ const dx = (b.x - a.x) / segLen;
1127
+ const dy = (b.y - a.y) / segLen;
1128
+ let pos = { x: a.x, y: a.y };
1129
+ let left = segLen;
1130
+ if (drawing && cur.length === 0)
1131
+ cur.push(pos);
1132
+ while (left > 1e-9) {
1133
+ const step = Math.min(left, remaining);
1134
+ const next = { x: pos.x + dx * step, y: pos.y + dy * step };
1135
+ if (drawing)
1136
+ cur.push(next);
1137
+ pos = next;
1138
+ left -= step;
1139
+ remaining -= step;
1140
+ if (remaining <= 1e-9) {
1141
+ if (drawing && cur.length >= 2)
1142
+ runs.push(cur);
1143
+ drawing = !drawing;
1144
+ idx = (idx + 1) % pattern.length;
1145
+ remaining = pattern[idx] ?? 0;
1146
+ cur = drawing ? [{ x: pos.x, y: pos.y }] : [];
1147
+ if (remaining <= 0)
1148
+ remaining = 1e-6; // avoid stall on zero-length element
1149
+ }
1150
+ }
1151
+ }
1152
+ if (drawing && cur.length >= 2)
1153
+ runs.push(cur);
1154
+ return runs;
1155
+ };
1156
+ /**
1157
+ * Module-level scratch buffers for the polygon-fill path
1158
+ * (`fillPolygonEarcut` + `drawTriangleFan`). Reused across every fill so
1159
+ * steady-state cost is zero `Float64Array` / `Float32Array` /
1160
+ * `Uint16Array` allocations.
1161
+ *
1162
+ * Initial caps cover a typical polygon (≤64 vertices, ≤128 indices)
1163
+ * without a grow. Capacity ratchets up to the next power of 2 on demand
1164
+ * and never shrinks; safe for single-threaded WebGL (fill calls are
1165
+ * serialised through the editor's render path).
1166
+ */
1167
+ let scratchEarcutFlat = new Float64Array(128);
1168
+ let scratchEarcutVerts = new Float32Array(128);
1169
+ let scratchEarcutIndices = new Uint16Array(256);
1170
+ const ensureEarcutVertexCapacity = (vertexCount) => {
1171
+ const needed = vertexCount * 2;
1172
+ if (scratchEarcutFlat.length >= needed)
1173
+ return;
1174
+ let cap = scratchEarcutFlat.length;
1175
+ while (cap < needed)
1176
+ cap *= 2;
1177
+ scratchEarcutFlat = new Float64Array(cap);
1178
+ scratchEarcutVerts = new Float32Array(cap);
1179
+ };
1180
+ const ensureEarcutIndexCapacity = (n) => {
1181
+ if (scratchEarcutIndices.length >= n)
1182
+ return;
1183
+ let cap = scratchEarcutIndices.length;
1184
+ while (cap < n)
1185
+ cap *= 2;
1186
+ scratchEarcutIndices = new Uint16Array(cap);
1187
+ };
1188
+ /**
1189
+ * Lower / upper bounds on the polygon approximation of an ellipse. The
1190
+ * minimum keeps small ellipses from collapsing to a hexagon at far zoom;
1191
+ * the maximum caps GPU work for huge ellipses where the marginal
1192
+ * pixel-error improvement is invisible.
1193
+ */
1194
+ const ELLIPSE_MIN_SEGMENTS = 24;
1195
+ const ELLIPSE_MAX_SEGMENTS = 512;
1196
+ const IDENTITY_MAT3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
1197
+ /**
1198
+ * Flatten tolerance, computed per-call: 0.5 / zoom in screen pixels.
1199
+ * The active screen-space scale is read off the current transform and
1200
+ * divided so the chord-to-curve error stays roughly half a pixel on
1201
+ * screen regardless of zoom.
1202
+ */
1203
+ const SCREEN_TOLERANCE_PX = 0.5;
1204
+ /**
1205
+ * Duck-type check for whether a TextShaper exposes the two methods
1206
+ * GlyphAtlas needs (`glyphMetrics` + `rasterizeGlyphMSDF`). The
1207
+ * `MsdfShaper` interface is structural, so any shaper that implements
1208
+ * them — including `WasmTextShaper` — qualifies. Shapers without these
1209
+ * methods return false and the renderer falls back to the
1210
+ * OffscreenCanvas bitmap path.
1211
+ */
1212
+ const isMsdfShaper = (shaper) => {
1213
+ const candidate = shaper;
1214
+ return (typeof candidate.glyphMetrics === "function" &&
1215
+ typeof candidate.rasterizeGlyphMSDF === "function");
1216
+ };
1217
+ /**
1218
+ * Cheap polyline-length stand-in for the curve length — an upper bound
1219
+ * proportional to it, used to pick a JS-fallback sample count
1220
+ * commensurate with the tolerance.
1221
+ */
1222
+ const curveLengthEstimate = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
1223
+ /** Sample a quadratic Bezier curve at `count` evenly-spaced t values. */
1224
+ const sampleQuadratic = (p0, p1, p2, count) => {
1225
+ const out = [];
1226
+ for (let i = 0; i <= count; i++) {
1227
+ const t = i / count;
1228
+ const u = 1 - t;
1229
+ out.push({
1230
+ x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
1231
+ y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y,
1232
+ });
1233
+ }
1234
+ return out;
1235
+ };
1236
+ /** Sample a cubic Bezier curve at `count` evenly-spaced t values. */
1237
+ const sampleCubic = (p0, p1, p2, p3, count) => {
1238
+ const out = [];
1239
+ for (let i = 0; i <= count; i++) {
1240
+ const t = i / count;
1241
+ const u = 1 - t;
1242
+ const u2 = u * u;
1243
+ const u3 = u2 * u;
1244
+ const t2 = t * t;
1245
+ const t3 = t2 * t;
1246
+ out.push({
1247
+ x: u3 * p0.x + 3 * u2 * t * p1.x + 3 * u * t2 * p2.x + t3 * p3.x,
1248
+ y: u3 * p0.y + 3 * u2 * t * p1.y + 3 * u * t2 * p2.y + t3 * p3.y,
1249
+ });
1250
+ }
1251
+ return out;
1252
+ };
1253
+ /** Same projection as `applyMat` but emitted from drawImage. */
1254
+ const applyImageMat = (t, w, h) => {
1255
+ const sx = 2 / w;
1256
+ const sy = -2 / h;
1257
+ return new Float32Array([
1258
+ t.a * sx, t.b * sy, 0,
1259
+ t.c * sx, t.d * sy, 0,
1260
+ t.e * sx - 1, t.f * sy + 1, 1,
1261
+ ]);
1262
+ };
1263
+ const createImageProgram = (gl) => {
1264
+ const vert = compile(gl, gl.VERTEX_SHADER, `#version 300 es
1265
+ in vec2 aPos;
1266
+ in vec2 aUV;
1267
+ uniform mat3 uTransform;
1268
+ out vec2 vUV;
1269
+ void main() {
1270
+ vec3 p = uTransform * vec3(aPos, 1.0);
1271
+ gl_Position = vec4(p.xy, 0.0, 1.0);
1272
+ vUV = aUV;
1273
+ }`);
1274
+ const frag = compile(gl, gl.FRAGMENT_SHADER, `#version 300 es
1275
+ precision mediump float;
1276
+ in vec2 vUV;
1277
+ uniform sampler2D uTex;
1278
+ uniform float uOpacity;
1279
+ out vec4 fragColor;
1280
+ void main() {
1281
+ // The texture is uploaded with UNPACK_PREMULTIPLY_ALPHA_WEBGL=true,
1282
+ // so t.rgb is already premultiplied by t.a. Output stays
1283
+ // premultiplied for blendFunc(ONE, 1-SRC_ALPHA) — scale both
1284
+ // channels by the per-call opacity.
1285
+ vec4 t = texture(uTex, vUV);
1286
+ fragColor = vec4(t.rgb * uOpacity, t.a * uOpacity);
1287
+ }`);
1288
+ const program = link(gl, vert, frag);
1289
+ return {
1290
+ program,
1291
+ aPos: gl.getAttribLocation(program, "aPos"),
1292
+ aUV: gl.getAttribLocation(program, "aUV"),
1293
+ uTransform: glReq(gl.getUniformLocation(program, "uTransform")),
1294
+ uTex: glReq(gl.getUniformLocation(program, "uTex")),
1295
+ uOpacity: glReq(gl.getUniformLocation(program, "uOpacity")),
1296
+ };
1297
+ };
1298
+ /**
1299
+ * Build a 3×3 column-major matrix that maps a unit quad [0,0]–[1,1]
1300
+ * through the supplied 2D affine + a screen-to-clip conversion
1301
+ * (pixels → NDC).
1302
+ */
1303
+ const applyMat = (t, w, h) => {
1304
+ // Pixel-space → clip-space: x' = (x / w) * 2 - 1; y' = 1 - (y / h) * 2.
1305
+ const sx = 2 / w;
1306
+ const sy = -2 / h;
1307
+ return new Float32Array([
1308
+ t.a * sx, t.b * sy, 0,
1309
+ t.c * sx, t.d * sy, 0,
1310
+ t.e * sx - 1, t.f * sy + 1, 1,
1311
+ ]);
1312
+ };
1313
+ const VERTEX_SHADER = `#version 300 es
1314
+ in vec2 aPos;
1315
+ uniform mat3 uTransform;
1316
+ void main() {
1317
+ vec3 p = uTransform * vec3(aPos, 1.0);
1318
+ gl_Position = vec4(p.xy, 0.0, 1.0);
1319
+ }`;
1320
+ const FRAGMENT_SHADER = `#version 300 es
1321
+ precision mediump float;
1322
+ uniform vec3 uColor;
1323
+ uniform float uOpacity;
1324
+ out vec4 fragColor;
1325
+ void main() {
1326
+ // Output premultiplied (rgb*a, a) — matches the context's
1327
+ // premultipliedAlpha:true contract + blendFunc(ONE, 1-SRC_ALPHA).
1328
+ fragColor = vec4(uColor * uOpacity, uOpacity);
1329
+ }`;
1330
+ /**
1331
+ * Asserts a WebGL resource handle is non-null. Creation APIs are typed
1332
+ * non-null but return `null` on context loss; surface that as a throw.
1333
+ */
1334
+ const glReq = (v) => {
1335
+ if (v === null)
1336
+ throw new Error("packages/renderer-canvas: WebGL resource creation failed");
1337
+ return v;
1338
+ };
1339
+ /** Asserts an array index is in range (loop bounds guarantee it). */
1340
+ const req = (v) => {
1341
+ if (v === undefined)
1342
+ throw new Error("packages/renderer-canvas: index out of range");
1343
+ return v;
1344
+ };
1345
+ const compile = (gl, type, src) => {
1346
+ const sh = glReq(gl.createShader(type));
1347
+ gl.shaderSource(sh, src);
1348
+ gl.compileShader(sh);
1349
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
1350
+ const log = gl.getShaderInfoLog(sh);
1351
+ gl.deleteShader(sh);
1352
+ throw new Error(`Shader compile failed: ${log}`);
1353
+ }
1354
+ return sh;
1355
+ };
1356
+ const link = (gl, vert, frag) => {
1357
+ const program = gl.createProgram();
1358
+ gl.attachShader(program, vert);
1359
+ gl.attachShader(program, frag);
1360
+ gl.linkProgram(program);
1361
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1362
+ const log = gl.getProgramInfoLog(program);
1363
+ gl.deleteProgram(program);
1364
+ throw new Error(`Program link failed: ${log}`);
1365
+ }
1366
+ return program;
1367
+ };
1368
+ //# sourceMappingURL=webgl2-target.js.map