ntk 7.1.0 → 7.3.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/lib/app.js +95 -0
- package/lib/gl.js +321 -0
- package/lib/glswapchain.js +408 -0
- package/lib/index.js +34 -4
- package/lib/path.js +28 -0
- package/lib/renderingcontext_2d.js +160 -7
- package/lib/renderingcontext_gles.js +312 -0
- package/lib/text/glyphs.js +70 -8
- package/lib/text/layout.js +10 -1
- package/lib/window.js +29 -1
- package/package.json +5 -2
|
@@ -525,6 +525,7 @@ class RenderingContext2d {
|
|
|
525
525
|
textStyle: this._textStyle,
|
|
526
526
|
fontString: this._lastFontString,
|
|
527
527
|
fontVariations: this._fontVariations,
|
|
528
|
+
textRendering: this._textRendering,
|
|
528
529
|
textAlign: this.textAlign,
|
|
529
530
|
textBaseline: this.textBaseline,
|
|
530
531
|
m: this._m.slice(),
|
|
@@ -548,6 +549,7 @@ class RenderingContext2d {
|
|
|
548
549
|
this._textStyle = s.textStyle;
|
|
549
550
|
this._lastFontString = s.fontString;
|
|
550
551
|
this._fontVariations = s.fontVariations;
|
|
552
|
+
this._textRendering = s.textRendering;
|
|
551
553
|
this.textAlign = s.textAlign;
|
|
552
554
|
this.textBaseline = s.textBaseline;
|
|
553
555
|
this._m = s.m;
|
|
@@ -1008,8 +1010,9 @@ class RenderingContext2d {
|
|
|
1008
1010
|
// extrude-polyline pushes the first and last points outward along the
|
|
1009
1011
|
// line, which on a closed loop extends the seam over band it already
|
|
1010
1012
|
// covers — invisible against an opaque colour, a double-blended edge
|
|
1011
|
-
// against a translucent one.
|
|
1012
|
-
|
|
1013
|
+
// against a translucent one. Runs cut at an escaping join (see
|
|
1014
|
+
// escapingJoins) want butt ends for the same reason.
|
|
1015
|
+
const buttStroke =
|
|
1013
1016
|
cap === "butt"
|
|
1014
1017
|
? stroke
|
|
1015
1018
|
: extrudePolyline({
|
|
@@ -1063,6 +1066,108 @@ class RenderingContext2d {
|
|
|
1063
1066
|
// bevel-to-arc gap depth for turn angle θ: r * (1 - cos(θ/2))
|
|
1064
1067
|
if (r * (1 - Math.sqrt((1 + dot) / 2)) > 0.05) addDisk(b[0], b[1]);
|
|
1065
1068
|
};
|
|
1069
|
+
/**
|
|
1070
|
+
* Interior vertices whose join extrude-polyline cannot be trusted with,
|
|
1071
|
+
* ascending; null — the common case — for a polyline with none.
|
|
1072
|
+
*
|
|
1073
|
+
* Whatever the join style, it closes the *inner* side of a join at the
|
|
1074
|
+
* intersection of the two inner offsets, r/cos(φ/2) from the vertex for
|
|
1075
|
+
* a turn of φ: 'bevel' bevels the outer side and still emits that
|
|
1076
|
+
* point, and `miterLimit` only chooses which side gets bevelled. As φ
|
|
1077
|
+
* approaches a reversal the intersection runs away to infinity, so a
|
|
1078
|
+
* hairpin — a cusp in a curve, a polyline that doubles back — threw a
|
|
1079
|
+
* spike hundreds of pixels off the path that no join style and no miter
|
|
1080
|
+
* limit could reach (issue #233).
|
|
1081
|
+
*
|
|
1082
|
+
* The intersection is legitimate only while it stays inside the two
|
|
1083
|
+
* segments, which it does when each is at least r·tan(φ/2) long: that
|
|
1084
|
+
* is how far back along both the point sits. Where they are shorter it
|
|
1085
|
+
* is ink outside the path, and the run is cut at that vertex instead —
|
|
1086
|
+
* both sides then end butt on it, so the inner corner is the union of
|
|
1087
|
+
* the two rectangles, and the outer side gets addJoinWedge.
|
|
1088
|
+
*/
|
|
1089
|
+
const escapingJoins = (run) => {
|
|
1090
|
+
let cuts = null;
|
|
1091
|
+
for (let i = 1; i < run.length - 1; i++) {
|
|
1092
|
+
const ax = run[i][0] - run[i - 1][0];
|
|
1093
|
+
const ay = run[i][1] - run[i - 1][1];
|
|
1094
|
+
const bx = run[i + 1][0] - run[i][0];
|
|
1095
|
+
const by = run[i + 1][1] - run[i][1];
|
|
1096
|
+
// This runs over every vertex of every stroke and almost never
|
|
1097
|
+
// fires, so it is written to answer "no" in multiplications alone:
|
|
1098
|
+
// squared lengths, and the raw (unnormalized) dot and cross, which
|
|
1099
|
+
// give tan(φ/2) = cross / (|a||b| + dot) directly.
|
|
1100
|
+
const la = ax * ax + ay * ay;
|
|
1101
|
+
const lb = bx * bx + by * by;
|
|
1102
|
+
if (!la || !lb) continue;
|
|
1103
|
+
const shortest = la < lb ? la : lb;
|
|
1104
|
+
const dot = ax * bx + ay * by;
|
|
1105
|
+
// a turn of 90° or less has tan(φ/2) <= 1, and so cannot reach past
|
|
1106
|
+
// a segment that is already at least r long
|
|
1107
|
+
if (dot >= 0 && r * r <= shortest) continue;
|
|
1108
|
+
const cross = ax * by - ay * bx;
|
|
1109
|
+
// r·tan(φ/2) > min(|a|, |b|), squared. A non-positive denominator is
|
|
1110
|
+
// the reversal the tangent is infinite at.
|
|
1111
|
+
const denom = Math.sqrt(la * lb) + dot;
|
|
1112
|
+
if (denom <= 0 || (r * cross) ** 2 > shortest * denom * denom) {
|
|
1113
|
+
(cuts ??= []).push(i);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return cuts;
|
|
1117
|
+
};
|
|
1118
|
+
/**
|
|
1119
|
+
* The outer side of the join at `b`, which cutting the run leaves to us:
|
|
1120
|
+
* the wedge between the two segments' outer offsets. A miter within the
|
|
1121
|
+
* limit fills it to the tip, everything else bevels — the same choice
|
|
1122
|
+
* the extruder would have made, on geometry that stays put.
|
|
1123
|
+
*/
|
|
1124
|
+
const addJoinWedge = (a, b, c) => {
|
|
1125
|
+
const l1 = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
1126
|
+
const l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
|
|
1127
|
+
if (!l1 || !l2) return;
|
|
1128
|
+
const ux = (b[0] - a[0]) / l1;
|
|
1129
|
+
const uy = (b[1] - a[1]) / l1;
|
|
1130
|
+
const vx = (c[0] - b[0]) / l2;
|
|
1131
|
+
const vy = (c[1] - b[1]) / l2;
|
|
1132
|
+
// outer side: away from the turn. A zero cross product is a straight
|
|
1133
|
+
// run or an exact reversal, and neither leaves a wedge to fill.
|
|
1134
|
+
const cross = ux * vy - uy * vx;
|
|
1135
|
+
if (!cross) return;
|
|
1136
|
+
// each segment's own offset at b: the normal r out on the outer side
|
|
1137
|
+
const s = cross > 0 ? -r : r;
|
|
1138
|
+
const p1x = b[0] - uy * s;
|
|
1139
|
+
const p1y = b[1] + ux * s;
|
|
1140
|
+
const p2x = b[0] - vy * s;
|
|
1141
|
+
const p2y = b[1] + vx * s;
|
|
1142
|
+
const dot = Math.max(-1, Math.min(1, ux * vx + uy * vy));
|
|
1143
|
+
const ratio = 1 / Math.sqrt((1 + dot) / 2); // miter length / r
|
|
1144
|
+
if (join === "miter" && ratio <= this.miterLimit) {
|
|
1145
|
+
// the tip is r·ratio along the bisector, where the two offsets meet
|
|
1146
|
+
const mx = p1x + p2x - 2 * b[0];
|
|
1147
|
+
const my = p1y + p2y - 2 * b[1];
|
|
1148
|
+
const ml = Math.hypot(mx, my);
|
|
1149
|
+
const tx = b[0] + (mx / ml) * r * ratio;
|
|
1150
|
+
const ty = b[1] + (my / ml) * r * ratio;
|
|
1151
|
+
tris.push(b[0], b[1], p1x, p1y, tx, ty);
|
|
1152
|
+
tris.push(b[0], b[1], tx, ty, p2x, p2y);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
tris.push(b[0], b[1], p1x, p1y, p2x, p2y);
|
|
1156
|
+
};
|
|
1157
|
+
const emit = (run, extruder) => {
|
|
1158
|
+
const mesh = extruder.build(run);
|
|
1159
|
+
for (const tri of mesh.cells) {
|
|
1160
|
+
for (let i = 0; i < 3; ++i) {
|
|
1161
|
+
tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
// a square cap's extension of end point `p` away from its neighbour `q`
|
|
1166
|
+
const capOut = (p, q) => {
|
|
1167
|
+
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
|
1168
|
+
if (!d) return p;
|
|
1169
|
+
return [p[0] + ((p[0] - q[0]) / d) * r, p[1] + ((p[1] - q[1]) / d) * r];
|
|
1170
|
+
};
|
|
1066
1171
|
// one polyline through extrusion + round-geometry post-processing;
|
|
1067
1172
|
// closed loops carry the seam point at both ends and get no caps
|
|
1068
1173
|
const extrudeRun = (pts, closed) => {
|
|
@@ -1089,11 +1194,30 @@ class RenderingContext2d {
|
|
|
1089
1194
|
];
|
|
1090
1195
|
pts = [mid, ...pts.slice(1), mid];
|
|
1091
1196
|
}
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1197
|
+
const cuts = escapingJoins(pts);
|
|
1198
|
+
if (!cuts) {
|
|
1199
|
+
emit(pts, closed ? buttStroke : stroke);
|
|
1200
|
+
} else {
|
|
1201
|
+
// Extrude the pieces between the cuts, each ending butt on the cut
|
|
1202
|
+
// vertex it shares with the next. That leaves the run's own two ends
|
|
1203
|
+
// to us as well: extrude-polyline squares both ends of whatever it
|
|
1204
|
+
// is handed, so a square cap is applied here instead.
|
|
1205
|
+
const last = pts.length - 1;
|
|
1206
|
+
let from = 0;
|
|
1207
|
+
for (const to of [...cuts, last]) {
|
|
1208
|
+
const run = pts.slice(from, to + 1);
|
|
1209
|
+
if (!closed && cap === "square") {
|
|
1210
|
+
if (from === 0) run[0] = capOut(run[0], run[1]);
|
|
1211
|
+
if (to === last)
|
|
1212
|
+
run[run.length - 1] = capOut(
|
|
1213
|
+
run[run.length - 1],
|
|
1214
|
+
run[run.length - 2],
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
emit(run, buttStroke);
|
|
1218
|
+
from = to;
|
|
1096
1219
|
}
|
|
1220
|
+
for (const i of cuts) addJoinWedge(pts[i - 1], pts[i], pts[i + 1]);
|
|
1097
1221
|
}
|
|
1098
1222
|
if (roundJoin) {
|
|
1099
1223
|
// every real vertex is interior now, the seam included: a closed
|
|
@@ -2339,7 +2463,12 @@ class RenderingContext2d {
|
|
|
2339
2463
|
const positioned = [];
|
|
2340
2464
|
let cursor = ox;
|
|
2341
2465
|
for (const run of reorderRuns(shaped.runs)) {
|
|
2342
|
-
positioned.push({
|
|
2466
|
+
positioned.push({
|
|
2467
|
+
run,
|
|
2468
|
+
x: cursor,
|
|
2469
|
+
y: oy,
|
|
2470
|
+
textRendering: this._textRendering,
|
|
2471
|
+
});
|
|
2343
2472
|
cursor += run.width;
|
|
2344
2473
|
}
|
|
2345
2474
|
this.drawGlyphs(
|
|
@@ -2451,6 +2580,30 @@ class RenderingContext2d {
|
|
|
2451
2580
|
return this._fontVariations ?? null;
|
|
2452
2581
|
}
|
|
2453
2582
|
|
|
2583
|
+
/**
|
|
2584
|
+
* CSS's `text-rendering`: which glyph path this text takes, overriding the
|
|
2585
|
+
* size thresholds in `app.textPolicy`.
|
|
2586
|
+
*
|
|
2587
|
+
* - `'geometricPrecision'` — outlines every draw, glyph origins **not**
|
|
2588
|
+
* rounded to whole pixels. What display text wants, and what any text
|
|
2589
|
+
* whose shape is being animated wants: a variable font's axis moves
|
|
2590
|
+
* advances by fractions of a pixel, and cached glyphs can only land on
|
|
2591
|
+
* whole ones, so those fractions accumulate until a glyph crosses a
|
|
2592
|
+
* rounding boundary and jumps a pixel on its own.
|
|
2593
|
+
* - `'optimizeSpeed'` — cached server-side glyphs at any size.
|
|
2594
|
+
* - `'auto'` (default) — the thresholds decide.
|
|
2595
|
+
*
|
|
2596
|
+
* `'optimizeLegibility'` is accepted and means `'auto'`; ntk has no
|
|
2597
|
+
* hinting to turn on.
|
|
2598
|
+
*/
|
|
2599
|
+
set textRendering(val) {
|
|
2600
|
+
this._textRendering = val || undefined;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
get textRendering() {
|
|
2604
|
+
return this._textRendering ?? "auto";
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2454
2607
|
// ------------------------------------------------------------------
|
|
2455
2608
|
// gradients / images
|
|
2456
2609
|
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Direct rendering context: OpenGL ES 2 on the GPU, frames delivered to the
|
|
2
|
+
// server as dma-buf descriptors over DRI3 + Present (lib/glswapchain.js).
|
|
3
|
+
//
|
|
4
|
+
// The API is WebGL-shaped and camelCase — `gl.createShader`, `gl.drawElements`
|
|
5
|
+
// — because that is what the addon's ES 2 binding exposes and what anyone
|
|
6
|
+
// writing shaders already knows. It is *not* the same API as the indirect GLX
|
|
7
|
+
// context, whose OpenGL 1.x commands are PascalCase and whose pipeline has no
|
|
8
|
+
// shaders at all; the two backends are honestly different rather than one
|
|
9
|
+
// pretending to be the other. Cross-backend code branches on `gl.backend`
|
|
10
|
+
// ('direct' or 'indirect') — see docs/context-gles.md.
|
|
11
|
+
//
|
|
12
|
+
// Setup is synchronous, which is the point: creating the GPU context and its
|
|
13
|
+
// buffers needs the DRM device and nothing from the X server, so `gl.*` works
|
|
14
|
+
// on the line after `getContext`. Only *presenting* needs the server, and
|
|
15
|
+
// `ready` is what reports whether that works — it resolves when the first
|
|
16
|
+
// buffer has been imported by the server, which is the moment the whole path
|
|
17
|
+
// is proven.
|
|
18
|
+
|
|
19
|
+
import Drawable from './drawable.js';
|
|
20
|
+
import { GLError, backendFor, glError, loadDriAddon } from './gl.js';
|
|
21
|
+
import { GLSwapchain } from './glswapchain.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One GPU context per app and pixel format, not one per surface.
|
|
25
|
+
*
|
|
26
|
+
* An EGL context is expensive (a device, a GBM device, a display, a config
|
|
27
|
+
* scan) and every surface in an app wants the same one; sharing it also means
|
|
28
|
+
* textures and programs are shared between surfaces, as they are between
|
|
29
|
+
* canvases in a browser tab. The cost is that exactly one surface is current
|
|
30
|
+
* at a time, which `_bind` takes care of.
|
|
31
|
+
*/
|
|
32
|
+
function sharedGpu(app, dri, { format, depthSize, devicePath }) {
|
|
33
|
+
const key = `${format}|${depthSize}|${devicePath ?? ''}`;
|
|
34
|
+
const cache = (app._glGpus ??= new Map());
|
|
35
|
+
const existing = cache.get(key);
|
|
36
|
+
if (existing) return existing;
|
|
37
|
+
let gpu;
|
|
38
|
+
try {
|
|
39
|
+
gpu = new dri.Gpu({ format, depthSize, ...(devicePath ? { devicePath } : {}) });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw glError(
|
|
42
|
+
GLError.CONTEXT_FAILED,
|
|
43
|
+
`could not create a GPU context on ${devicePath ?? 'the default render node'}: ${err.message}`,
|
|
44
|
+
null,
|
|
45
|
+
err
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
cache.set(key, gpu);
|
|
49
|
+
return gpu;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class RenderingContextGLES {
|
|
53
|
+
constructor(window, config = {}) {
|
|
54
|
+
const app = window.app;
|
|
55
|
+
const caps = app._glCapsResolved;
|
|
56
|
+
if (!caps) {
|
|
57
|
+
throw glError(
|
|
58
|
+
GLError.CONTEXT_FAILED,
|
|
59
|
+
"getContext('gles') needs the direct-rendering probe to have answered, and it has not",
|
|
60
|
+
`createClient() runs the probe during the handshake when glPolicy could pick the
|
|
61
|
+
direct backend, so a context can be created synchronously afterwards. Under the
|
|
62
|
+
default policy ('indirect') it does not run, and asking for this context by name
|
|
63
|
+
does not make it retroactive. Either:
|
|
64
|
+
|
|
65
|
+
const app = await createClient({ glPolicy: 'auto' }); // probe at connect
|
|
66
|
+
await app.glCapabilities(); // or ask, once, later`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!caps.direct) throw caps.reason;
|
|
70
|
+
|
|
71
|
+
const dri = loadDriAddon();
|
|
72
|
+
this.window = window;
|
|
73
|
+
this.app = app;
|
|
74
|
+
this.X = window.X;
|
|
75
|
+
this.dri = dri;
|
|
76
|
+
/** which backend this is, for code that runs on either */
|
|
77
|
+
this.backend = 'direct';
|
|
78
|
+
this.error = null;
|
|
79
|
+
|
|
80
|
+
// The buffer format has to match how the server will read the pixmap, and
|
|
81
|
+
// that is the window's depth: 24 is XRGB (opaque), 32 is ARGB (the
|
|
82
|
+
// compositor blends the alpha). A window created without an explicit depth
|
|
83
|
+
// has the root's.
|
|
84
|
+
const depth = window.depth || app.display.screen[0].root_depth || 24;
|
|
85
|
+
if (depth !== 24 && depth !== 32) {
|
|
86
|
+
throw glError(
|
|
87
|
+
GLError.CONTEXT_FAILED,
|
|
88
|
+
`direct rendering needs a 24- or 32-bit window, and this one is ${depth}-bit`,
|
|
89
|
+
'Create the window with depth 24 (opaque) or 32 (per-pixel alpha, via\napp.findArgbVisual()).'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
this.depth = depth;
|
|
93
|
+
|
|
94
|
+
const policy = app.glPolicy;
|
|
95
|
+
this.gpu = sharedGpu(app, dri, {
|
|
96
|
+
format: depth === 32 ? dri.FORMAT.ARGB8888 : dri.FORMAT.XRGB8888,
|
|
97
|
+
depthSize: config.depthSize ?? config.DEPTH_SIZE ?? 16,
|
|
98
|
+
devicePath: policy.devicePath ?? caps.device
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.swapchain = new GLSwapchain({
|
|
102
|
+
window,
|
|
103
|
+
gpu: this.gpu,
|
|
104
|
+
dri,
|
|
105
|
+
DRI3: caps.DRI3,
|
|
106
|
+
Present: caps.Present,
|
|
107
|
+
depth,
|
|
108
|
+
policy
|
|
109
|
+
});
|
|
110
|
+
window._setGenericEventSink(caps.Present.majorOpcode, this.swapchain);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolves once a frame's buffer has been accepted by the server — the
|
|
114
|
+
* whole path proven, not just the parts on this side of the socket — and
|
|
115
|
+
* rejects with a coded error if it cannot be. Nothing needs to await it
|
|
116
|
+
* before drawing; it is how a caller decides to show a fallback instead.
|
|
117
|
+
*/
|
|
118
|
+
this.ready = new Promise((resolve, reject) => {
|
|
119
|
+
this.swapchain.onValidated = (err) => {
|
|
120
|
+
if (err) {
|
|
121
|
+
this.error = err;
|
|
122
|
+
reject(err);
|
|
123
|
+
} else resolve(this);
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
// a rejection nobody is listening for must not take the process down;
|
|
127
|
+
// `error` and the onError hook are the other ways to find out
|
|
128
|
+
this.ready.catch(() => {});
|
|
129
|
+
this.swapchain.onReady = () => this._onFrameAvailable();
|
|
130
|
+
this._frameWanted = null;
|
|
131
|
+
|
|
132
|
+
// GL entry points and constants, bound so that whichever surface this
|
|
133
|
+
// context owns is the current one when they run
|
|
134
|
+
this._installGL();
|
|
135
|
+
this.makeCurrent();
|
|
136
|
+
// settle `ready` now rather than on the first frame — see validate()
|
|
137
|
+
this.swapchain.validate();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Copy the addon's ES 2 namespace onto this context.
|
|
142
|
+
*
|
|
143
|
+
* Every function is wrapped with the currency check rather than documented
|
|
144
|
+
* as the caller's job: the shared context means another surface may have
|
|
145
|
+
* been current since the last call here, and a GL call against the wrong
|
|
146
|
+
* surface draws into the wrong window. The check is one comparison against
|
|
147
|
+
* a field — next to a native call, it does not register.
|
|
148
|
+
*/
|
|
149
|
+
_installGL() {
|
|
150
|
+
const table = this.dri.gl;
|
|
151
|
+
for (const key in table) {
|
|
152
|
+
const value = table[key];
|
|
153
|
+
if (typeof value !== 'function') {
|
|
154
|
+
this[key] = value; // GL constants
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
this[key] = (...args) => {
|
|
158
|
+
if (this.app._glCurrent !== this) this._bind();
|
|
159
|
+
return value(...args);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Make this context's surface current, sizing it to the window first.
|
|
166
|
+
*
|
|
167
|
+
* Call it at the top of a frame: that is where a resize can be honoured
|
|
168
|
+
* without throwing away a half-drawn one.
|
|
169
|
+
*/
|
|
170
|
+
makeCurrent() {
|
|
171
|
+
if (this.error || this._destroyed || this.window._destroyed) return this;
|
|
172
|
+
const width = this.window.width;
|
|
173
|
+
const height = this.window.height;
|
|
174
|
+
if (width !== this._width || height !== this._height) {
|
|
175
|
+
this._width = width;
|
|
176
|
+
this._height = height;
|
|
177
|
+
this._surface = null; // a new size is a new generation
|
|
178
|
+
}
|
|
179
|
+
this._bind();
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_bind() {
|
|
184
|
+
if (this.error || this._destroyed) return;
|
|
185
|
+
if (!this._surface) {
|
|
186
|
+
this._surface = this.swapchain.surfaceFor(this._width ?? this.window.width, this._height ?? this.window.height);
|
|
187
|
+
}
|
|
188
|
+
this.gpu.makeCurrent(this._surface);
|
|
189
|
+
this.app._glCurrent = this;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Is a frame worth drawing right now?
|
|
194
|
+
*
|
|
195
|
+
* False when every buffer is still with the server. Drawing anyway is not
|
|
196
|
+
* wrong, only wasted: the swap that followed would have nowhere to go.
|
|
197
|
+
* `onFrameAvailable` is the other half — it fires when this turns true.
|
|
198
|
+
*/
|
|
199
|
+
canRender() {
|
|
200
|
+
return this.swapchain.canRender();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Called when `canRender()` became true again after a swap was refused. */
|
|
204
|
+
set onFrameAvailable(fn) {
|
|
205
|
+
this._frameWanted = fn;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
get onFrameAvailable() {
|
|
209
|
+
return this._frameWanted;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_onFrameAvailable() {
|
|
213
|
+
this._frameWanted?.();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Show the frame just drawn.
|
|
218
|
+
*
|
|
219
|
+
* Named as the indirect context names it, so a draw loop can end the same
|
|
220
|
+
* way on either backend; `swapBuffers` is the same call under the spelling
|
|
221
|
+
* the rest of this API uses. Returns false when the frame could not be
|
|
222
|
+
* shown yet — see `canRender`.
|
|
223
|
+
*/
|
|
224
|
+
SwapBuffers() {
|
|
225
|
+
if (this.error || this._destroyed || this.window._destroyed) return false;
|
|
226
|
+
const sent = this.swapchain.swap();
|
|
227
|
+
// A resize seen only now still gets picked up: the next frame binds a
|
|
228
|
+
// generation at the new size. Checked after the swap so the frame that was
|
|
229
|
+
// drawn at the old size is the one that goes out.
|
|
230
|
+
if (this.window.width !== this._width || this.window.height !== this._height) {
|
|
231
|
+
this._surface = null;
|
|
232
|
+
}
|
|
233
|
+
return sent;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
swapBuffers() {
|
|
237
|
+
return this.SwapBuffers();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The GL renderer string, once there is a context — handy in bug reports. */
|
|
241
|
+
get renderer() {
|
|
242
|
+
try {
|
|
243
|
+
return this.dri.gl.getString(this.dri.GL.RENDERER);
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
destroy() {
|
|
250
|
+
if (this._destroyed) return;
|
|
251
|
+
this._destroyed = true;
|
|
252
|
+
this.swapchain.destroy();
|
|
253
|
+
this.window._setGenericEventSink(0, null);
|
|
254
|
+
if (this.app._glCurrent === this) {
|
|
255
|
+
this.app._glCurrent = null;
|
|
256
|
+
try {
|
|
257
|
+
this.gpu.makeCurrent(null);
|
|
258
|
+
} catch {
|
|
259
|
+
// the context is going away regardless
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
this._surface = null;
|
|
263
|
+
// the Gpu itself is shared and outlives this context (App#close frees it)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
[Symbol.dispose]() {
|
|
267
|
+
this.destroy();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
Drawable.renderingContextFactory['gles'] = (window, config) => new RenderingContextGLES(window, config);
|
|
272
|
+
|
|
273
|
+
// `getContext('opengl')` is the backend-neutral name: it is what the indirect
|
|
274
|
+
// context registered before there was a choice, and what code that does not
|
|
275
|
+
// care should keep asking for. The policy decides which one it gets, and the
|
|
276
|
+
// default policy is still the indirect one, so nothing changes under an app
|
|
277
|
+
// that has not opted in.
|
|
278
|
+
const indirectFactory = Drawable.renderingContextFactory['opengl'];
|
|
279
|
+
Drawable.renderingContextFactory['opengl'] = (window, config) => {
|
|
280
|
+
const app = window.app;
|
|
281
|
+
const backend = backendFor(app);
|
|
282
|
+
if (backend === 'direct') return new RenderingContextGLES(window, config);
|
|
283
|
+
if (backend === 'off') {
|
|
284
|
+
const caps = app._glCapsResolved;
|
|
285
|
+
throw (
|
|
286
|
+
caps?.reason ??
|
|
287
|
+
glError(GLError.DISABLED, "glPolicy is 'off', so getContext('opengl') has no backend to use")
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
// null: the policy could pick direct, but the probe has not answered — a
|
|
291
|
+
// policy raised after connecting. 'direct' must not quietly become the other
|
|
292
|
+
// backend, because the whole point of asking for it by name is that the draw
|
|
293
|
+
// code only speaks ES 2.
|
|
294
|
+
if (backend === null) {
|
|
295
|
+
if (app.glPolicy.mode === 'direct') {
|
|
296
|
+
throw glError(
|
|
297
|
+
GLError.CONTEXT_FAILED,
|
|
298
|
+
"glPolicy is 'direct' but the direct-rendering probe has not answered, so there is no context to give you",
|
|
299
|
+
'The probe runs inside createClient() when the policy is set there. A policy\n' +
|
|
300
|
+
'raised afterwards needs one `await app.glCapabilities()` first.'
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
console.warn(
|
|
304
|
+
"ntk: glPolicy is 'auto' but the direct-rendering probe has not answered yet, so " +
|
|
305
|
+
"getContext('opengl') is using indirect GLX. Pass glPolicy to createClient(), or " +
|
|
306
|
+
'await app.glCapabilities() before creating the context.'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return indirectFactory(window, config);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export default RenderingContextGLES;
|
package/lib/text/glyphs.js
CHANGED
|
@@ -20,11 +20,17 @@ import { trapezoidize } from '../trapezoid.js';
|
|
|
20
20
|
* - `cacheBytes` — LRU budget for uploaded glyph bitmaps per connection;
|
|
21
21
|
* least-recently-drawn (face, size) pages are freed server-side
|
|
22
22
|
* (FreeGlyphSet) so transient sizes don't accumulate.
|
|
23
|
+
* - `textRendering` — an app-wide default for the per-run property of the
|
|
24
|
+
* same name (see `routeForRendering`). Left undefined the thresholds
|
|
25
|
+
* decide, which is what almost every app wants; `'geometricPrecision'`
|
|
26
|
+
* here is the blunt instrument for a window that is all display text.
|
|
27
|
+
* A run that names its own always wins.
|
|
23
28
|
*/
|
|
24
29
|
export const DEFAULT_TEXT_POLICY = {
|
|
25
30
|
bitmapMax: 128,
|
|
26
31
|
vectorFrom: 256,
|
|
27
|
-
cacheBytes: 8 << 20
|
|
32
|
+
cacheBytes: 8 << 20,
|
|
33
|
+
textRendering: undefined
|
|
28
34
|
};
|
|
29
35
|
|
|
30
36
|
function policyOf(app) {
|
|
@@ -228,21 +234,75 @@ export function positionGlyphs(positioned) {
|
|
|
228
234
|
*
|
|
229
235
|
* @returns {'bitmap'|'vector'}
|
|
230
236
|
*/
|
|
231
|
-
|
|
237
|
+
/**
|
|
238
|
+
* CSS's `text-rendering`, as far as it means anything here: a run's own
|
|
239
|
+
* answer to which glyph path it takes, overriding the size thresholds.
|
|
240
|
+
*
|
|
241
|
+
* - `geometricPrecision` — the vector path, always. Outlines are flattened
|
|
242
|
+
* at the exact size and glyph origins are not rounded, so advances land
|
|
243
|
+
* where shaping put them. This is what display text wants, and what any
|
|
244
|
+
* text whose shape is being animated wants: a variable font's axis moves
|
|
245
|
+
* advances by fractions of a pixel, and on the bitmap path those fractions
|
|
246
|
+
* accumulate silently until a glyph crosses a rounding boundary and jumps
|
|
247
|
+
* a whole pixel on its own. It is also the path that caches nothing, so
|
|
248
|
+
* an axis under a slider stops minting a glyph page per step.
|
|
249
|
+
* - `optimizeSpeed` — the bitmap path, always. Cached server-side glyphs at
|
|
250
|
+
* any size, for text that is large but static.
|
|
251
|
+
* - `auto` (the default, and anything unrecognized) — the size thresholds
|
|
252
|
+
* and the churn ring decide, as before.
|
|
253
|
+
*
|
|
254
|
+
* `optimizeLegibility` is accepted and means `auto`: ntk has no hinting to
|
|
255
|
+
* turn on, so promising anything by it would be a lie.
|
|
256
|
+
*/
|
|
257
|
+
export function routeForRendering(textRendering) {
|
|
258
|
+
if (textRendering === 'geometricPrecision') return 'vector';
|
|
259
|
+
if (textRendering === 'optimizeSpeed') return 'bitmap';
|
|
260
|
+
return null; // auto: ask the thresholds
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function routeGlyphSize(app, font, size, policy = policyOf(app), textRendering) {
|
|
264
|
+
// Inlined rather than a call to `routeForRendering`: this is the first
|
|
265
|
+
// thing every run of every draw does, and the overwhelmingly common answer
|
|
266
|
+
// is "nobody asked". One `??` and one comparison get us past it.
|
|
267
|
+
const asked = textRendering ?? policy.textRendering;
|
|
268
|
+
if (asked !== undefined && asked !== 'auto') {
|
|
269
|
+
const route = routeForRendering(asked);
|
|
270
|
+
if (route) return route;
|
|
271
|
+
}
|
|
232
272
|
if (size <= policy.bitmapMax) return 'bitmap';
|
|
233
273
|
if (size > policy.vectorFrom) return 'vector';
|
|
234
274
|
|
|
275
|
+
// The ring answers "is this face being drawn at something it has drawn
|
|
276
|
+
// recently, or is it churning?" — so it has to be keyed by the thing that
|
|
277
|
+
// stays put while the churn happens.
|
|
278
|
+
//
|
|
279
|
+
// For a variable font that is the *base* face, not the instance: every
|
|
280
|
+
// point on an axis is a Font of its own, with its own key, so an animated
|
|
281
|
+
// axis handed each step a fresh empty ring and the churn was invisible —
|
|
282
|
+
// it read as eight unrelated faces each drawn once. Keyed by the base and
|
|
283
|
+
// recording the instance alongside the size, an axis sweep and a size
|
|
284
|
+
// sweep look like what they both are, and a page of static text at one
|
|
285
|
+
// weight still reuses its entry on every frame.
|
|
235
286
|
if (!app._sizeRings) app._sizeRings = new Map();
|
|
236
|
-
|
|
287
|
+
// A face with no instances behind it keeps the old ring exactly: its key
|
|
288
|
+
// is already constant across the ring, so the size alone identifies a
|
|
289
|
+
// glyph page and the entries stay numbers. Only a variable instance pays
|
|
290
|
+
// for the composite key, and only in this band — text at or below
|
|
291
|
+
// `bitmapMax` returned above without touching any of it.
|
|
292
|
+
const base = font.variationOf;
|
|
293
|
+
let ring = app._sizeRings.get(base ? base.key : font.key);
|
|
237
294
|
if (!ring) {
|
|
238
295
|
ring = [];
|
|
239
|
-
app._sizeRings.set(font.key, ring);
|
|
296
|
+
app._sizeRings.set(base ? base.key : font.key, ring);
|
|
240
297
|
}
|
|
241
|
-
|
|
298
|
+
// what a glyph page is keyed by, which is exactly what has to repeat for
|
|
299
|
+
// caching to pay for itself
|
|
300
|
+
const entry = base ? `${font.key}@${size}` : size;
|
|
301
|
+
const reused = ring.includes(entry);
|
|
242
302
|
// dedupe consecutive entries so one frame drawing many runs at one size
|
|
243
303
|
// occupies a single slot — the ring then spans ~8 distinct frames
|
|
244
|
-
if (ring[ring.length - 1] !==
|
|
245
|
-
ring.push(
|
|
304
|
+
if (ring[ring.length - 1] !== entry) {
|
|
305
|
+
ring.push(entry);
|
|
246
306
|
if (ring.length > 8) ring.shift();
|
|
247
307
|
}
|
|
248
308
|
|
|
@@ -269,7 +329,9 @@ export function drawGlyphRuns(app, op, srcId, dstId, positioned) {
|
|
|
269
329
|
let vector = null;
|
|
270
330
|
for (let i = 0; i < positioned.length; i++) {
|
|
271
331
|
const { run } = positioned[i];
|
|
272
|
-
if (
|
|
332
|
+
if (
|
|
333
|
+
routeGlyphSize(app, run.font, run.size, policy, positioned[i].textRendering) === 'vector'
|
|
334
|
+
) {
|
|
273
335
|
if (!vector) {
|
|
274
336
|
vector = [];
|
|
275
337
|
bitmap = positioned.slice(0, i);
|
package/lib/text/layout.js
CHANGED
|
@@ -67,6 +67,7 @@ export class TextLayout {
|
|
|
67
67
|
weight: s.weight ?? style.weight,
|
|
68
68
|
style: s.style ?? style.style,
|
|
69
69
|
variations: s.variations ?? style.variations,
|
|
70
|
+
textRendering: s.textRendering ?? style.textRendering,
|
|
70
71
|
features: s.features ?? style.features,
|
|
71
72
|
language: s.language ?? style.language,
|
|
72
73
|
color: s.color ?? style.color ?? null
|
|
@@ -497,7 +498,15 @@ export class TextLayout {
|
|
|
497
498
|
const color = r.span.color;
|
|
498
499
|
if (batch.length && color !== batchColor) flush();
|
|
499
500
|
batchColor = color;
|
|
500
|
-
batch.push({
|
|
501
|
+
batch.push({
|
|
502
|
+
run: r.run,
|
|
503
|
+
x: x + line.x + r.x,
|
|
504
|
+
y: y + line.baseline,
|
|
505
|
+
// per run, because it is a span property: one paragraph may hold
|
|
506
|
+
// a display word that wants exact positions and body text that
|
|
507
|
+
// wants its glyph cache. `drawGlyphRuns` already partitions.
|
|
508
|
+
textRendering: r.span.textRendering
|
|
509
|
+
});
|
|
501
510
|
}
|
|
502
511
|
}
|
|
503
512
|
flush();
|