@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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/canvas-target.d.ts +83 -0
- package/dist/canvas-target.d.ts.map +1 -0
- package/dist/canvas-target.js +208 -0
- package/dist/canvas-target.js.map +1 -0
- package/dist/canvas-text-shaper.d.ts +11 -0
- package/dist/canvas-text-shaper.d.ts.map +1 -0
- package/dist/canvas-text-shaper.js +56 -0
- package/dist/canvas-text-shaper.js.map +1 -0
- package/dist/constants.d.ts +52 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +52 -0
- package/dist/constants.js.map +1 -0
- package/dist/hi-dpi.d.ts +26 -0
- package/dist/hi-dpi.d.ts.map +1 -0
- package/dist/hi-dpi.js +62 -0
- package/dist/hi-dpi.js.map +1 -0
- package/dist/image-source.d.ts +3 -0
- package/dist/image-source.d.ts.map +1 -0
- package/dist/image-source.js +65 -0
- package/dist/image-source.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/layered-canvas.d.ts +46 -0
- package/dist/layered-canvas.d.ts.map +1 -0
- package/dist/layered-canvas.js +95 -0
- package/dist/layered-canvas.js.map +1 -0
- package/dist/layered-surface.d.ts +75 -0
- package/dist/layered-surface.d.ts.map +1 -0
- package/dist/layered-surface.js +262 -0
- package/dist/layered-surface.js.map +1 -0
- package/dist/offscreen.d.ts +30 -0
- package/dist/offscreen.d.ts.map +1 -0
- package/dist/offscreen.js +50 -0
- package/dist/offscreen.js.map +1 -0
- package/dist/recording-target.d.ts +203 -0
- package/dist/recording-target.d.ts.map +1 -0
- package/dist/recording-target.js +260 -0
- package/dist/recording-target.js.map +1 -0
- package/dist/render-worker.d.ts +2 -0
- package/dist/render-worker.d.ts.map +1 -0
- package/dist/render-worker.js +112 -0
- package/dist/render-worker.js.map +1 -0
- package/dist/tile-compositor.d.ts +44 -0
- package/dist/tile-compositor.d.ts.map +1 -0
- package/dist/tile-compositor.js +110 -0
- package/dist/tile-compositor.js.map +1 -0
- package/dist/webgl2-color.d.ts +13 -0
- package/dist/webgl2-color.d.ts.map +1 -0
- package/dist/webgl2-color.js +26 -0
- package/dist/webgl2-color.js.map +1 -0
- package/dist/webgl2-curve.d.ts +46 -0
- package/dist/webgl2-curve.d.ts.map +1 -0
- package/dist/webgl2-curve.js +162 -0
- package/dist/webgl2-curve.js.map +1 -0
- package/dist/webgl2-ellipse.d.ts +52 -0
- package/dist/webgl2-ellipse.d.ts.map +1 -0
- package/dist/webgl2-ellipse.js +171 -0
- package/dist/webgl2-ellipse.js.map +1 -0
- package/dist/webgl2-msdf-text.d.ts +84 -0
- package/dist/webgl2-msdf-text.d.ts.map +1 -0
- package/dist/webgl2-msdf-text.js +306 -0
- package/dist/webgl2-msdf-text.js.map +1 -0
- package/dist/webgl2-stroke.d.ts +52 -0
- package/dist/webgl2-stroke.d.ts.map +1 -0
- package/dist/webgl2-stroke.js +318 -0
- package/dist/webgl2-stroke.js.map +1 -0
- package/dist/webgl2-target.d.ts +311 -0
- package/dist/webgl2-target.d.ts.map +1 -0
- package/dist/webgl2-target.js +1368 -0
- package/dist/webgl2-target.js.map +1 -0
- package/dist/webgpu-detect.d.ts +30 -0
- package/dist/webgpu-detect.d.ts.map +1 -0
- package/dist/webgpu-detect.js +74 -0
- package/dist/webgpu-detect.js.map +1 -0
- 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
|