ntk 7.2.0 → 7.3.1

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,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/window.js CHANGED
@@ -435,6 +435,10 @@ export default class Window extends Drawable {
435
435
  this._updateRegion = 0;
436
436
  this._presentSerial = 0;
437
437
  this._presentEid = 0;
438
+ // A direct GL context's swap chain, when one is presenting on this window
439
+ // (see _setGenericEventSink)
440
+ this._geSink = null;
441
+ this._geSinkOpcode = 0;
438
442
  // The vblank clock (see _onPresentComplete): the period learnt from
439
443
  // completion events, the samples it is drawn from, and the latch the
440
444
  // watchdog sets when completions stop arriving.
@@ -612,7 +616,13 @@ export default class Window extends Drawable {
612
616
  // and a sub-type where a core event carries its code — the name table
613
617
  // below cannot see them, and they are not events user code asked for
614
618
  if (ev.type === 35) {
615
- if (this._presentExt && ev.extension === this._presentExt.majorOpcode) {
619
+ // A direct GL context presenting on this window selected these events
620
+ // itself and owns the pixmaps they name, so they are its to read — and
621
+ // it is the only user of Present there, a GL window having no backing
622
+ // store to blit.
623
+ if (this._geSink && ev.extension === this._geSinkOpcode) {
624
+ this._geSink.handleEvent(ev);
625
+ } else if (this._presentExt && ev.extension === this._presentExt.majorOpcode) {
616
626
  this._handlePresentEvent(ev);
617
627
  }
618
628
  return;
@@ -1256,6 +1266,24 @@ export default class Window extends Drawable {
1256
1266
  });
1257
1267
  }
1258
1268
 
1269
+ /**
1270
+ * Route this window's Generic Events for one extension to `sink`, ahead of
1271
+ * the built-in Present handling.
1272
+ *
1273
+ * The direct GL context is the caller: its swap chain selects Present's
1274
+ * CompleteNotify and IdleNotify for itself, because it needs to know when a
1275
+ * *specific* buffer came back — a question the window's own present path,
1276
+ * which owns exactly one grow-only pixmap, never has to ask. Pass `null` to
1277
+ * unregister.
1278
+ *
1279
+ * @param {number} majorOpcode the extension whose events to route
1280
+ * @param {{handleEvent: (ev: object) => void}|null} sink
1281
+ */
1282
+ _setGenericEventSink(majorOpcode, sink) {
1283
+ this._geSink = sink;
1284
+ this._geSinkOpcode = sink ? majorOpcode : 0;
1285
+ }
1286
+
1259
1287
  /**
1260
1288
  * Present's events, which are the ones the core event table cannot name:
1261
1289
  * they arrive as X Generic Events (type 35), carrying an extension opcode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "7.2.0",
3
+ "version": "7.3.1",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",
@@ -48,9 +48,12 @@
48
48
  "parse-color": "^1.0.0",
49
49
  "pngjs": "^7.0.0",
50
50
  "postcss": "^8.5.23",
51
- "x11": "^3.8.0",
51
+ "x11": "^3.9.0",
52
52
  "yoga-layout": "^3.2.1"
53
53
  },
54
+ "optionalDependencies": {
55
+ "x11-dri": ">=0.2.0 <1"
56
+ },
54
57
  "scripts": {
55
58
  "test": "NTK_STRICT_COLORS=1 node --test",
56
59
  "check-release-message": "node scripts/check-release-message.mjs"