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.
@@ -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 — and
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
- resolve(new App(display, appOptions));
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);