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