ntk 7.2.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 +128 -6
- package/lib/renderingcontext_gles.js +312 -0
- package/lib/window.js +29 -1
- package/package.json +5 -2
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// The swap chain behind a direct rendering context: GPU buffers in, X pixmaps
|
|
2
|
+
// out, and the bookkeeping that keeps the two in step.
|
|
3
|
+
//
|
|
4
|
+
// A frame's life:
|
|
5
|
+
//
|
|
6
|
+
// draw with GL -> surface.swap() -> dma-buf fd (first time only)
|
|
7
|
+
// -> DRI3.PixmapFromBuffer -> a pixmap for that buffer
|
|
8
|
+
// -> Present.Pixmap -> shown at the next vblank
|
|
9
|
+
// <- PresentIdleNotify -> the buffer is ours again
|
|
10
|
+
//
|
|
11
|
+
// No pixel data crosses the socket: the descriptor is passed once per buffer,
|
|
12
|
+
// and every later frame in that buffer is a Present of a pixmap the server
|
|
13
|
+
// already has.
|
|
14
|
+
//
|
|
15
|
+
// Two rules shape the code. **A buffer belongs to the server until IdleNotify
|
|
16
|
+
// says otherwise** — drawing into one before then paints whatever is on
|
|
17
|
+
// screen — so a buffer is released back to GBM there and nowhere else. And
|
|
18
|
+
// **a pixmap must exist before it is presented**: a new buffer's import is
|
|
19
|
+
// confirmed by a round trip before the first Present names it, or the Present
|
|
20
|
+
// would fail with BadPixmap. A new buffer appears a handful of times per
|
|
21
|
+
// generation (and never again in steady state), so that round trip is a
|
|
22
|
+
// startup cost, not a per-frame one.
|
|
23
|
+
|
|
24
|
+
import { GLError, glError } from './gl.js';
|
|
25
|
+
|
|
26
|
+
// bpp is 32 for both formats we use; depth is what tells them apart (24 =
|
|
27
|
+
// XRGB, no alpha; 32 = ARGB, per-pixel alpha the compositor blends)
|
|
28
|
+
const BPP = 32;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One generation of buffers: a GBM surface at one size, and the pixmap each
|
|
32
|
+
* of its buffers was imported as.
|
|
33
|
+
*
|
|
34
|
+
* A resize cannot just drop the old surface — the server may still be
|
|
35
|
+
* scanning out one of its buffers — so the old generation is retired and
|
|
36
|
+
* torn down as its buffers come back idle.
|
|
37
|
+
*/
|
|
38
|
+
class Generation {
|
|
39
|
+
constructor(surface, width, height, linear) {
|
|
40
|
+
this.surface = surface;
|
|
41
|
+
this.width = width;
|
|
42
|
+
this.height = height;
|
|
43
|
+
this.linear = linear;
|
|
44
|
+
this.pixmaps = new Map(); // buffer key -> { pixmap, busy }
|
|
45
|
+
this.retired = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** buffers the server still holds */
|
|
49
|
+
get busyCount() {
|
|
50
|
+
let n = 0;
|
|
51
|
+
for (const entry of this.pixmaps.values()) if (entry.busy) n++;
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class GLSwapchain {
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} opts
|
|
59
|
+
* @param {import('./window.js').default} opts.window the drawable presented to
|
|
60
|
+
* @param {object} opts.gpu an x11-dri Gpu
|
|
61
|
+
* @param {object} opts.dri the x11-dri module
|
|
62
|
+
* @param {object} opts.DRI3 node-x11 dri3 extension
|
|
63
|
+
* @param {object} opts.Present node-x11 present extension
|
|
64
|
+
* @param {number} opts.depth the window's depth (24 or 32)
|
|
65
|
+
* @param {object} opts.policy resolved glPolicy
|
|
66
|
+
*/
|
|
67
|
+
constructor({ window, gpu, dri, DRI3, Present, depth, policy }) {
|
|
68
|
+
this.window = window;
|
|
69
|
+
this.X = window.X;
|
|
70
|
+
this.gpu = gpu;
|
|
71
|
+
this.dri = dri;
|
|
72
|
+
this.DRI3 = DRI3;
|
|
73
|
+
this.Present = Present;
|
|
74
|
+
this.depth = depth;
|
|
75
|
+
this.policy = policy;
|
|
76
|
+
|
|
77
|
+
this.generation = null;
|
|
78
|
+
this.retired = [];
|
|
79
|
+
this.serial = 0;
|
|
80
|
+
this.inFlight = 0;
|
|
81
|
+
/** an import is being confirmed; no frame may swap until it lands */
|
|
82
|
+
this.importing = false;
|
|
83
|
+
/** the last swap found every buffer busy — a frame is owed once one frees */
|
|
84
|
+
this.starved = false;
|
|
85
|
+
this.destroyed = false;
|
|
86
|
+
this.error = null;
|
|
87
|
+
/** called when a frame became possible again (idle buffer, import done) */
|
|
88
|
+
this.onReady = null;
|
|
89
|
+
/** called once the first buffer has been imported, or it failed */
|
|
90
|
+
this.onValidated = null;
|
|
91
|
+
this._validated = false;
|
|
92
|
+
|
|
93
|
+
this._eid = this.X.AllocID();
|
|
94
|
+
this.Present.SelectInput(
|
|
95
|
+
this._eid,
|
|
96
|
+
window.id,
|
|
97
|
+
Present.EventMask.CompleteNotify | Present.EventMask.IdleNotify
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Point the chain at a size. Returns the GBM surface to draw into, creating
|
|
103
|
+
* a new generation when the size changed.
|
|
104
|
+
*/
|
|
105
|
+
surfaceFor(width, height) {
|
|
106
|
+
const w = Math.max(1, Math.round(width));
|
|
107
|
+
const h = Math.max(1, Math.round(height));
|
|
108
|
+
const gen = this.generation;
|
|
109
|
+
if (gen && gen.width === w && gen.height === h) return gen.surface;
|
|
110
|
+
this._retire(gen);
|
|
111
|
+
// a generation that had to go linear keeps that layout: whatever made the
|
|
112
|
+
// server refuse the tiled buffer has not changed since
|
|
113
|
+
return this._newGeneration(w, h, gen ? gen.linear : false);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_newGeneration(w, h, linear) {
|
|
117
|
+
const use = linear ? this.dri.GBM_USE.RENDERING | this.dri.GBM_USE.LINEAR : undefined;
|
|
118
|
+
let surface;
|
|
119
|
+
try {
|
|
120
|
+
surface = use === undefined ? this.gpu.createSurface(w, h) : this.gpu.createSurface(w, h, use);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw glError(GLError.CONTEXT_FAILED, `could not create a ${w}x${h} GPU surface: ${err.message}`, null, err);
|
|
123
|
+
}
|
|
124
|
+
this.generation = new Generation(surface, w, h, linear);
|
|
125
|
+
return surface;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Can a frame be drawn and presented right now? */
|
|
129
|
+
canRender() {
|
|
130
|
+
if (this.destroyed || this.error) return false;
|
|
131
|
+
if (this.importing) return false;
|
|
132
|
+
return this.inFlight < this.policy.maxInFlight;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Show what was just drawn.
|
|
137
|
+
*
|
|
138
|
+
* Returns false when the frame could not go out — every buffer still held
|
|
139
|
+
* by the server, or an import in flight. The frame is not lost: `onReady`
|
|
140
|
+
* fires when one is free again, and the caller draws it then.
|
|
141
|
+
*/
|
|
142
|
+
swap() {
|
|
143
|
+
if (this.destroyed || this.error || this.importing) return false;
|
|
144
|
+
const gen = this.generation;
|
|
145
|
+
if (!gen) return false;
|
|
146
|
+
|
|
147
|
+
let out;
|
|
148
|
+
try {
|
|
149
|
+
out = gen.surface.swap();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
this._fail(glError(GLError.CONTEXT_FAILED, `GPU buffer swap failed: ${err.message}`, null, err));
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
// every buffer is still on the server's side of the fence
|
|
155
|
+
if (!out) {
|
|
156
|
+
this.starved = true;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
this.starved = false;
|
|
160
|
+
|
|
161
|
+
const known = gen.pixmaps.get(out.key);
|
|
162
|
+
if (known) {
|
|
163
|
+
this._present(gen, out.key, known);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
this._import(gen, out, true);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Prove the path before anything is drawn: take a buffer, hand its
|
|
172
|
+
* descriptor to the server, and give it straight back.
|
|
173
|
+
*
|
|
174
|
+
* `ready` has to be answerable *before* the first frame — a caller deciding
|
|
175
|
+
* whether to render a fallback cannot be asked to render a frame first, and
|
|
176
|
+
* `await gl.ready` before drawing is the obvious thing to write. Importing
|
|
177
|
+
* without presenting settles it at no cost: the import is what every part of
|
|
178
|
+
* the path has to survive (a descriptor over the socket, a server willing to
|
|
179
|
+
* take this device's buffers), showing it is not, and the pixmap stays
|
|
180
|
+
* cached for whichever frame draws into that buffer later.
|
|
181
|
+
*/
|
|
182
|
+
validate() {
|
|
183
|
+
if (this._validated || this.destroyed || this.error) return;
|
|
184
|
+
const gen = this.generation;
|
|
185
|
+
if (!gen) return;
|
|
186
|
+
let out;
|
|
187
|
+
try {
|
|
188
|
+
out = gen.surface.swap();
|
|
189
|
+
} catch (err) {
|
|
190
|
+
return this._fail(glError(GLError.CONTEXT_FAILED, `GPU buffer swap failed: ${err.message}`, null, err));
|
|
191
|
+
}
|
|
192
|
+
// a fresh surface always has a buffer to give, and a new one is new
|
|
193
|
+
if (!out) return;
|
|
194
|
+
if (!out.isNew) return this._validate(null);
|
|
195
|
+
this._import(gen, out, false);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A buffer the server has not seen: hand over its descriptor, and only
|
|
200
|
+
* present it once the server confirms it took it — a Present naming a
|
|
201
|
+
* pixmap whose import failed is a BadPixmap, which would bury the real
|
|
202
|
+
* error under a second one.
|
|
203
|
+
*
|
|
204
|
+
* @param {boolean} present show this buffer once it is imported, or hand it
|
|
205
|
+
* straight back (the validation path, which has drawn nothing worth
|
|
206
|
+
* showing)
|
|
207
|
+
*/
|
|
208
|
+
_import(gen, out, present) {
|
|
209
|
+
const pixmap = this.X.AllocID();
|
|
210
|
+
const entry = { pixmap, busy: false };
|
|
211
|
+
this.importing = true;
|
|
212
|
+
this.DRI3.PixmapFromBuffer(
|
|
213
|
+
pixmap,
|
|
214
|
+
this.window.id,
|
|
215
|
+
{
|
|
216
|
+
fd: out.fd,
|
|
217
|
+
width: out.width,
|
|
218
|
+
height: out.height,
|
|
219
|
+
stride: out.stride,
|
|
220
|
+
depth: this.depth,
|
|
221
|
+
bpp: BPP
|
|
222
|
+
},
|
|
223
|
+
(err) => {
|
|
224
|
+
this.importing = false;
|
|
225
|
+
if (this.destroyed) return;
|
|
226
|
+
if (err) return this._importFailed(gen, out, present, err);
|
|
227
|
+
gen.pixmaps.set(out.key, entry);
|
|
228
|
+
this._validate(null);
|
|
229
|
+
// the generation may have been retired while this was in flight; its
|
|
230
|
+
// buffer is still ours to give back, and the pixmap still needs freeing
|
|
231
|
+
if (gen.retired) {
|
|
232
|
+
this._releaseBuffer(gen, out.key, entry);
|
|
233
|
+
this._sweep(gen);
|
|
234
|
+
} else if (present) {
|
|
235
|
+
this._present(gen, out.key, entry);
|
|
236
|
+
} else {
|
|
237
|
+
this._releaseBuffer(gen, out.key, entry);
|
|
238
|
+
}
|
|
239
|
+
this.onReady?.();
|
|
240
|
+
}
|
|
241
|
+
);
|
|
242
|
+
this.X.flush?.();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The server would not take the buffer. Almost always this means client and
|
|
247
|
+
* server are on different DRM devices and the tiled layout means nothing to
|
|
248
|
+
* the one displaying it — which a linear buffer fixes, at the cost of some
|
|
249
|
+
* bandwidth. Worth one retry before giving up on the whole backend.
|
|
250
|
+
*/
|
|
251
|
+
_importFailed(gen, out, present, err) {
|
|
252
|
+
if (this.policy.linearFallback && !gen.linear && !this._validated) {
|
|
253
|
+
const { width, height } = gen;
|
|
254
|
+
this._retire(gen);
|
|
255
|
+
try {
|
|
256
|
+
this._newGeneration(width, height, true);
|
|
257
|
+
} catch (retryErr) {
|
|
258
|
+
return this._fail(retryErr);
|
|
259
|
+
}
|
|
260
|
+
// the retry has to happen here: on the validation path there is no
|
|
261
|
+
// frame loop yet to come back and try again
|
|
262
|
+
if (!present) this.validate();
|
|
263
|
+
this.onReady?.();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
this._fail(
|
|
267
|
+
glError(
|
|
268
|
+
GLError.IMPORT_FAILED,
|
|
269
|
+
`the X server refused the GPU buffer (${err.message}), so there is nothing to show`,
|
|
270
|
+
'Client and server are probably on different DRM devices. Name the server\'s\n' +
|
|
271
|
+
"device with glPolicy: { devicePath: '/dev/dri/renderD###' }, or fall back to\n" +
|
|
272
|
+
"indirect GLX with glPolicy: 'indirect'.",
|
|
273
|
+
err
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_present(gen, key, entry) {
|
|
279
|
+
entry.busy = true;
|
|
280
|
+
this.inFlight++;
|
|
281
|
+
// targetMsc 0 is "the next vblank", and no Option.Copy: a swap chain wants
|
|
282
|
+
// the server to flip when it can, which is the copy-free path. IdleNotify
|
|
283
|
+
// is what says the buffer came back, whichever it did.
|
|
284
|
+
this.Present.Pixmap(this.window.id, entry.pixmap, { serial: ++this.serial });
|
|
285
|
+
this.X.flush?.();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Present's events for this window: CompleteNotify and IdleNotify. */
|
|
289
|
+
handleEvent(ev) {
|
|
290
|
+
if (this.destroyed) return;
|
|
291
|
+
const P = this.Present;
|
|
292
|
+
if (ev.evtype === P.events.IdleNotify) return this._onIdle(ev);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_onIdle(ev) {
|
|
296
|
+
for (const gen of this._generations()) {
|
|
297
|
+
for (const [key, entry] of gen.pixmaps) {
|
|
298
|
+
if (entry.pixmap !== ev.pixmap || !entry.busy) continue;
|
|
299
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
300
|
+
this._releaseBuffer(gen, key, entry);
|
|
301
|
+
// every retired generation, not just this buffer's: a sweep can be
|
|
302
|
+
// held back by an import in flight, and this is where it gets another
|
|
303
|
+
// chance to finish
|
|
304
|
+
this._sweepRetired();
|
|
305
|
+
// a frame that could not draw for want of a buffer now can
|
|
306
|
+
if (this.starved || this.inFlight < this.policy.maxInFlight) this.onReady?.();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Give a buffer back to GBM so the next frame can be drawn into it. */
|
|
313
|
+
_releaseBuffer(gen, key, entry) {
|
|
314
|
+
entry.busy = false;
|
|
315
|
+
try {
|
|
316
|
+
gen.surface.release(key);
|
|
317
|
+
} catch {
|
|
318
|
+
// a destroyed surface has nothing to take back
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
*_generations() {
|
|
323
|
+
if (this.generation) yield this.generation;
|
|
324
|
+
yield* this.retired;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resolve the "does this actually work" promise, once. The first successful
|
|
329
|
+
* import proves the whole path — addon, device, descriptor passing, and a
|
|
330
|
+
* server willing to take the buffer.
|
|
331
|
+
*/
|
|
332
|
+
_validate(err) {
|
|
333
|
+
if (this._validated) return;
|
|
334
|
+
this._validated = true;
|
|
335
|
+
this.onValidated?.(err);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
_fail(err) {
|
|
339
|
+
if (this.error) return;
|
|
340
|
+
this.error = err;
|
|
341
|
+
this._validate(err);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_retire(gen) {
|
|
345
|
+
if (!gen || gen.retired) return;
|
|
346
|
+
gen.retired = true;
|
|
347
|
+
this.retired.push(gen);
|
|
348
|
+
this.generation = null;
|
|
349
|
+
this._sweep(gen);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_sweepRetired() {
|
|
353
|
+
for (const gen of [...this.retired]) this._sweep(gen);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Free what a retired generation no longer needs; drop it when empty. */
|
|
357
|
+
_sweep(gen) {
|
|
358
|
+
for (const [key, entry] of gen.pixmaps) {
|
|
359
|
+
if (entry.busy) continue;
|
|
360
|
+
gen.pixmaps.delete(key);
|
|
361
|
+
this._freePixmap(entry.pixmap);
|
|
362
|
+
}
|
|
363
|
+
if (gen.pixmaps.size || this.importing) return;
|
|
364
|
+
const index = this.retired.indexOf(gen);
|
|
365
|
+
if (index >= 0) this.retired.splice(index, 1);
|
|
366
|
+
try {
|
|
367
|
+
gen.surface.destroy();
|
|
368
|
+
} catch {
|
|
369
|
+
// already gone
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
_freePixmap(pixmap) {
|
|
374
|
+
try {
|
|
375
|
+
this.X.FreePixmap(pixmap);
|
|
376
|
+
} catch {
|
|
377
|
+
// the connection is closing; the server frees everything with it
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
destroy() {
|
|
382
|
+
if (this.destroyed) return;
|
|
383
|
+
this.destroyed = true;
|
|
384
|
+
this.onReady = null;
|
|
385
|
+
this.onValidated = null;
|
|
386
|
+
for (const gen of this._generations()) {
|
|
387
|
+
for (const entry of gen.pixmaps.values()) this._freePixmap(entry.pixmap);
|
|
388
|
+
gen.pixmaps.clear();
|
|
389
|
+
try {
|
|
390
|
+
gen.surface.destroy();
|
|
391
|
+
} catch {
|
|
392
|
+
// already gone
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
this.generation = null;
|
|
396
|
+
this.retired.length = 0;
|
|
397
|
+
if (this._eid) {
|
|
398
|
+
try {
|
|
399
|
+
this.Present.SelectInput(this._eid, this.window.id, this.Present.EventMask.NoEvent);
|
|
400
|
+
} catch {
|
|
401
|
+
// the window or the connection is already gone
|
|
402
|
+
}
|
|
403
|
+
this._eid = 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export default GLSwapchain;
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import x11 from 'x11';
|
|
|
3
3
|
import App from './app.js';
|
|
4
4
|
import Clipboard from './clipboard.js';
|
|
5
5
|
import { BLANK_CURSOR, CursorCache, cursorNames, cursorShapes, resolveCursorShape } from './cursor.js';
|
|
6
|
+
import { DEFAULT_GL_POLICY, GLError, GL_MODES, resolveGLPolicy, wantsDirect } from './gl.js';
|
|
6
7
|
import { GLXError } from './glx.js';
|
|
7
8
|
import Window from './window.js';
|
|
8
9
|
import { loadLayout } from './yoga.js';
|
|
@@ -46,10 +47,13 @@ import { tokenize as highlightCode } from './widgets/highlight.js';
|
|
|
46
47
|
import { cssColorStraight, premultiply } from './color.js';
|
|
47
48
|
import { cssColor, cssLength } from './widgets/css.js';
|
|
48
49
|
|
|
49
|
-
// rendering context modules register themselves on Drawable
|
|
50
|
+
// rendering context modules register themselves on Drawable. The direct one
|
|
51
|
+
// comes last on purpose: it wraps the 'opengl' factory the indirect one just
|
|
52
|
+
// registered, so that the backend-neutral name can dispatch on glPolicy.
|
|
50
53
|
import './renderingcontext_x11.js';
|
|
51
54
|
import './renderingcontext_2d.js';
|
|
52
55
|
import './renderingcontext_opengl.js';
|
|
56
|
+
import './renderingcontext_gles.js';
|
|
53
57
|
|
|
54
58
|
// One socket write per frame instead of one per request (x11 >= 3.6). A frame
|
|
55
59
|
// is emitted in one synchronous run of _runFrame() and ends with the frame
|
|
@@ -80,7 +84,11 @@ const DEFAULT_BUFFER_REQUESTS = { maxSize: 64 * 1024 };
|
|
|
80
84
|
* { rasterizer, rasterPolicy } — where small fills and strokes are
|
|
81
85
|
* rasterized, and the thresholds for that choice (see docs/context-2d.md) —
|
|
82
86
|
* { glxVisual } — a visual id for getContext('opengl') to use instead of
|
|
83
|
-
* querying the server for one —
|
|
87
|
+
* querying the server for one —
|
|
88
|
+
* { glPolicy } — which OpenGL backend to draw through: `'indirect'` (the
|
|
89
|
+
* default, indirect GLX), `'auto'` (direct rendering where it is available),
|
|
90
|
+
* `'direct'` or `'off'`; an object sets the direct backend's knobs too (see
|
|
91
|
+
* docs/context-gles.md) — and
|
|
84
92
|
* { onXError } — called with X protocol errors no request callback
|
|
85
93
|
* claimed (default: console.warn).
|
|
86
94
|
* x11's { bufferRequests } defaults to a 64 KB output buffer here, which
|
|
@@ -118,6 +126,11 @@ export function createClient(options, callback) {
|
|
|
118
126
|
// so a later setDefaultFontSource still takes effect.
|
|
119
127
|
defaultFontSource();
|
|
120
128
|
}
|
|
129
|
+
// Same reasoning for the GL backend: a policy that names no real mode is
|
|
130
|
+
// a rejected connect, not an exception thrown from inside a connection
|
|
131
|
+
// callback later on — and asking now means a bad NTK_GL_POLICY is caught
|
|
132
|
+
// even by an app that never mentions GL.
|
|
133
|
+
const glPolicy = resolveGLPolicy(appOptions);
|
|
121
134
|
|
|
122
135
|
x11.createClient(x11Options, (error, display) => {
|
|
123
136
|
if (error) return reject(error);
|
|
@@ -162,7 +175,20 @@ export function createClient(options, callback) {
|
|
|
162
175
|
});
|
|
163
176
|
updateKeyboardMapping(display.min_keycode, display.max_keycode);
|
|
164
177
|
|
|
165
|
-
|
|
178
|
+
const app = new App(display, appOptions);
|
|
179
|
+
// Under a policy that could pick the direct backend, find out
|
|
180
|
+
// during the handshake whether it can: two extension queries,
|
|
181
|
+
// running alongside the round trips already in flight here, so
|
|
182
|
+
// that getContext() can choose a backend synchronously later.
|
|
183
|
+
// Nothing to do under the default policy, which is why the common
|
|
184
|
+
// case pays nothing for this.
|
|
185
|
+
if (!wantsDirect(glPolicy)) return resolve(app);
|
|
186
|
+
app.glCapabilities().then(
|
|
187
|
+
() => resolve(app),
|
|
188
|
+
// a probe that throws is a probe that found nothing, and GL is
|
|
189
|
+
// not what the connection is for
|
|
190
|
+
() => resolve(app)
|
|
191
|
+
);
|
|
166
192
|
});
|
|
167
193
|
});
|
|
168
194
|
});
|
|
@@ -232,7 +258,11 @@ export {
|
|
|
232
258
|
decodeKey,
|
|
233
259
|
groupForState,
|
|
234
260
|
// the `code` on a failed GL setup: branch on it rather than on the message
|
|
235
|
-
GLXError
|
|
261
|
+
GLXError,
|
|
262
|
+
// the same, for the direct backend, plus what glPolicy accepts
|
|
263
|
+
GLError,
|
|
264
|
+
GL_MODES,
|
|
265
|
+
DEFAULT_GL_POLICY
|
|
236
266
|
};
|
|
237
267
|
// The layout engine ntk lays HtmlView out with — downstream layout consumers
|
|
238
268
|
// (e.g. the react-x11 renderer) must import it from here rather than from
|
package/lib/path.js
CHANGED
|
@@ -732,6 +732,13 @@ export function transformCommands(cmds, m) {
|
|
|
732
732
|
// --------------------------------------------------------------------------
|
|
733
733
|
// flattening
|
|
734
734
|
|
|
735
|
+
/** how far a control point's projection `e` falls outside [0, chord²], times |chord| */
|
|
736
|
+
function overshoot(e, chord2) {
|
|
737
|
+
if (e < 0) return -e;
|
|
738
|
+
if (e > chord2) return e - chord2;
|
|
739
|
+
return 0;
|
|
740
|
+
}
|
|
741
|
+
|
|
735
742
|
function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
|
|
736
743
|
const dx = x3 - x0;
|
|
737
744
|
const dy = y3 - y0;
|
|
@@ -753,6 +760,27 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
|
|
|
753
760
|
// over-subdivided a quarter circle ~2x at any radius (issue #213).
|
|
754
761
|
const err = (0.75 * Math.max(Math.abs(d1), Math.abs(d2))) ** 2;
|
|
755
762
|
flat = err <= tol2 * chord2;
|
|
763
|
+
// That bound is on the offset *perpendicular* to the chord, and says
|
|
764
|
+
// nothing about how far along it the curve travels: a cubic whose
|
|
765
|
+
// handles point back down the chord runs past an endpoint and returns,
|
|
766
|
+
// staying arbitrarily close to the chord's *line* the whole way. The
|
|
767
|
+
// chord is then within tolerance as a set of points but is traversed in
|
|
768
|
+
// the wrong direction, which is invisible in a fill and catastrophic in
|
|
769
|
+
// a stroke — the join to the next segment becomes a ~180° cusp and its
|
|
770
|
+
// miter shoots off to infinity (issue #233).
|
|
771
|
+
//
|
|
772
|
+
// Project each control point onto the chord: the longitudinal
|
|
773
|
+
// coordinate is itself a cubic in Bernstein form with control values
|
|
774
|
+
// 0, e1, e2, |chord|², so it stays inside the chord's span whenever
|
|
775
|
+
// those do. `overshoot` is the distance past an end times |chord|,
|
|
776
|
+
// which is what makes it comparable to the perpendicular term without
|
|
777
|
+
// a square root.
|
|
778
|
+
if (flat) {
|
|
779
|
+
const e1 = (x1 - x0) * dx + (y1 - y0) * dy;
|
|
780
|
+
const e2 = (x2 - x0) * dx + (y2 - y0) * dy;
|
|
781
|
+
const over = Math.max(overshoot(e1, chord2), overshoot(e2, chord2));
|
|
782
|
+
flat = over * over <= tol2 * chord2;
|
|
783
|
+
}
|
|
756
784
|
}
|
|
757
785
|
if (flat || depth >= 18) {
|
|
758
786
|
pts.push(x3, y3);
|
|
@@ -1010,8 +1010,9 @@ class RenderingContext2d {
|
|
|
1010
1010
|
// extrude-polyline pushes the first and last points outward along the
|
|
1011
1011
|
// line, which on a closed loop extends the seam over band it already
|
|
1012
1012
|
// covers — invisible against an opaque colour, a double-blended edge
|
|
1013
|
-
// against a translucent one.
|
|
1014
|
-
|
|
1013
|
+
// against a translucent one. Runs cut at an escaping join (see
|
|
1014
|
+
// escapingJoins) want butt ends for the same reason.
|
|
1015
|
+
const buttStroke =
|
|
1015
1016
|
cap === "butt"
|
|
1016
1017
|
? stroke
|
|
1017
1018
|
: extrudePolyline({
|
|
@@ -1065,6 +1066,108 @@ class RenderingContext2d {
|
|
|
1065
1066
|
// bevel-to-arc gap depth for turn angle θ: r * (1 - cos(θ/2))
|
|
1066
1067
|
if (r * (1 - Math.sqrt((1 + dot) / 2)) > 0.05) addDisk(b[0], b[1]);
|
|
1067
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
|
+
};
|
|
1068
1171
|
// one polyline through extrusion + round-geometry post-processing;
|
|
1069
1172
|
// closed loops carry the seam point at both ends and get no caps
|
|
1070
1173
|
const extrudeRun = (pts, closed) => {
|
|
@@ -1091,11 +1194,30 @@ class RenderingContext2d {
|
|
|
1091
1194
|
];
|
|
1092
1195
|
pts = [mid, ...pts.slice(1), mid];
|
|
1093
1196
|
}
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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;
|
|
1098
1219
|
}
|
|
1220
|
+
for (const i of cuts) addJoinWedge(pts[i - 1], pts[i], pts[i + 1]);
|
|
1099
1221
|
}
|
|
1100
1222
|
if (roundJoin) {
|
|
1101
1223
|
// every real vertex is interior now, the seam included: a closed
|