ntk 8.11.1 → 8.12.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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/lib/window.js +137 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -106,7 +106,8 @@ wnd.requestAnimationFrame(frame);
106
106
  ```
107
107
 
108
108
  See [docs/window.md](docs/window.md) for the knobs (`frameInterval`,
109
- `frameSync`, `coalesceEvents`) and the raw uncoalesced event stream.
109
+ `frameSync`, `maxFramesInFlight`, `coalesceEvents`) and the raw uncoalesced
110
+ event stream.
110
111
 
111
112
  ## Resource management
112
113
 
package/lib/window.js CHANGED
@@ -60,6 +60,43 @@ const BACKING_GRANULARITY = 128;
60
60
  */
61
61
  const DEFAULT_FRAME_INTERVAL = 16;
62
62
 
63
+ /**
64
+ * How many frames the fence clock lets the server owe a window at once,
65
+ * unless the window asks for another number.
66
+ *
67
+ * One is a frame per round trip: the client draws, the server works through
68
+ * it, the reply comes back, and only then does the next frame start. Neither
69
+ * side works while the other does, so a frame costs the sum of the two.
70
+ * Where the reply is slow for reasons of the server's own, most of that sum
71
+ * is waiting. XQuartz answers a fence only once the frame has gone to the
72
+ * macOS window server: 13 ms at the median during a drag, against about
73
+ * 0.1 ms for an idle round trip, and the drag ran at 75-78 fps on a 120 Hz
74
+ * panel (issue #369). With two, the next frame is drawn during that wait,
75
+ * and the same drag runs at 107-118. A slow link gains the same way: a frame
76
+ * per round trip becomes two.
77
+ *
78
+ * Not more, because past two the wait is already overlapped, and each frame
79
+ * queued behind a busy server, or on a link too narrow for it, is a frame of
80
+ * latency. Where a blit goes out through Present it stays one (see
81
+ * _frameLimit).
82
+ */
83
+ const DEFAULT_MAX_FRAMES_IN_FLIGHT = 2;
84
+
85
+ /**
86
+ * `maxFramesInFlight` as given, or a RangeError saying what it takes.
87
+ *
88
+ * Zero is refused, not read as "no limit", which is what `frameInterval: 0`
89
+ * would lead a reader to expect: a window that may have no frame in flight
90
+ * never draws. The way to wait on the server not at all is `frameSync: false`.
91
+ */
92
+ function framesInFlightLimit(n) {
93
+ if (Number.isInteger(n) && n >= 1) return n;
94
+ throw new RangeError(
95
+ `ntk: maxFramesInFlight is a number of frames, 1 or more — got ${String(n)}. ` +
96
+ 'For no limit at all, create the window with frameSync: false.'
97
+ );
98
+ }
99
+
63
100
  /**
64
101
  * The range a measured vblank period has to fall in to be believed, in ms.
65
102
  *
@@ -432,6 +469,9 @@ export default class Window extends Drawable {
432
469
  if (args.id === 0) {
433
470
  throw new Error('ntk: 0 (None) is not a window id');
434
471
  }
472
+ const maxFramesInFlight = framesInFlightLimit(
473
+ args.maxFramesInFlight ?? DEFAULT_MAX_FRAMES_IN_FLIGHT
474
+ );
435
475
  if (args.id) {
436
476
  const cached = Window._cacheFor(app).get(args.id);
437
477
  // TODO: check event mask, if more events in args - update it
@@ -564,12 +604,15 @@ export default class Window extends Drawable {
564
604
  // that probe answers adopts the result when it lands.
565
605
  this._frameInterval = args.frameInterval ?? app.frameInterval ?? DEFAULT_FRAME_INTERVAL;
566
606
  this._frameIntervalExplicit = args.frameInterval !== undefined;
607
+ // how many fences may be outstanding before the next frame waits for one
608
+ // (see _frameLimit)
609
+ this._maxFramesInFlight = maxFramesInFlight;
567
610
  this.frameLatency = null;
568
611
  this._frame = {
569
612
  pending: new Map(), // coalescible event name -> merged event
570
613
  rafCbs: [],
571
614
  rafId: 0,
572
- inFlight: false, // fence round-trip awaiting the server's reply
615
+ fences: 0, // fence round trips awaiting the server's reply
573
616
  presentInFlight: false, // present awaiting the display's CompleteNotify
574
617
  presentAt: 0, // when that present was sent, for frameLatency
575
618
  stallTimer: null,
@@ -1348,9 +1391,13 @@ export default class Window extends Drawable {
1348
1391
  * That includes a server whose Present has no display behind it and
1349
1392
  * makes its vertical blanks up from a timer (see _probeMsc).
1350
1393
  *
1351
- * Either way at most one is outstanding, which is what bounds the work in
1352
- * flight: on a slow connection frames degrade to one per round-trip instead
1353
- * of queueing a trail of stale updates.
1394
+ * Either way the frames outstanding are bounded, which is what bounds the
1395
+ * work in flight: one present on the display's clock, and on the fence
1396
+ * `maxFramesInFlight` fences, two unless the window asked otherwise (see
1397
+ * _frameLimit). On a slow connection frames degrade to that many per round
1398
+ * trip instead of queueing a trail of stale updates. Two rather than one so
1399
+ * that the next frame can be drawn while the server is still working
1400
+ * through the last, instead of each waiting out the other.
1354
1401
  *
1355
1402
  * A timer backs both up. Under the fence it is the rate limit — at most one
1356
1403
  * frame per `frameInterval` ms, so a fast local server isn't asked to redraw
@@ -1371,18 +1418,63 @@ export default class Window extends Drawable {
1371
1418
  */
1372
1419
  _scheduleFrame() {
1373
1420
  const f = this._frame;
1374
- if (f.scheduled || f.inFlight || f.presentInFlight || f.timer) return;
1421
+ if (f.scheduled || this._fencesFull() || f.presentInFlight || f.timer) return;
1375
1422
  f.scheduled = true;
1376
1423
  setImmediate(() => {
1377
1424
  f.scheduled = false;
1378
1425
  // gates may have been armed after this got scheduled (work queued
1379
1426
  // from inside a running frame, e.g. a rAF loop re-registering) —
1380
1427
  // the completion / fence reply / timer expiry will reschedule then
1381
- if (f.inFlight || f.presentInFlight || f.timer) return;
1428
+ if (this._fencesFull() || f.presentInFlight || f.timer) return;
1382
1429
  this._runFrame();
1383
1430
  });
1384
1431
  }
1385
1432
 
1433
+ /**
1434
+ * How many frames may be waiting for their fence before the next one waits
1435
+ * too: `maxFramesInFlight`, except where a blit goes out through Present.
1436
+ *
1437
+ * A second frame is safe behind a CopyArea. The server runs requests in
1438
+ * order, so the copy has read the backing store before anything drawn after
1439
+ * it lands there, and the fence says no more than that the server read the
1440
+ * frame — which is all the next one needs. A present with Option.Copy is
1441
+ * different: it owns the backing store until the copy executes, at the next
1442
+ * vertical blank, and the fence reply says only that the server has read the
1443
+ * present. A frame drawn behind it early can put half of itself on the screen
1444
+ * (the hazard issue #223 describes), and a second frame in flight would
1445
+ * widen that window. So a window whose blits are presents keeps one, whether
1446
+ * the fence or the display is ending its frames — on the display's clock a
1447
+ * present outstanding is the gate anyway, and there is never more than one.
1448
+ *
1449
+ * A window with no backing store has no blit to protect. Its frames go by
1450
+ * the limit as a CopyArea window's do.
1451
+ */
1452
+ _frameLimit() {
1453
+ return this._blitsThroughPresent() ? 1 : this._maxFramesInFlight;
1454
+ }
1455
+
1456
+ /** Have the frames waiting for their fence reached the limit? */
1457
+ _fencesFull() {
1458
+ return this._frame.fences >= this._frameLimit();
1459
+ }
1460
+
1461
+ /**
1462
+ * How many frames the server may owe this window at once, on the fence
1463
+ * clock, before the next one waits for the reply to the oldest: 2 unless
1464
+ * set. `1` is a frame per round trip. Where blits go through Present, or
1465
+ * the display is ending frames, one is all there is, whatever this says —
1466
+ * see _frameLimit.
1467
+ */
1468
+ get maxFramesInFlight() {
1469
+ return this._maxFramesInFlight;
1470
+ }
1471
+
1472
+ set maxFramesInFlight(n) {
1473
+ // A raised limit needs no wakeup: the gate was shut because fences are
1474
+ // outstanding, and the next reply runs what it held back.
1475
+ this._maxFramesInFlight = framesInFlightLimit(n);
1476
+ }
1477
+
1386
1478
  /**
1387
1479
  * Minimum ms between paced frames, and between blits.
1388
1480
  *
@@ -1636,15 +1728,24 @@ export default class Window extends Drawable {
1636
1728
  for (const [name, ev] of pending) this.emit(name, ev);
1637
1729
  }
1638
1730
 
1731
+ /**
1732
+ * Fence the frame that just went out: one request with a reply, answered
1733
+ * once the server has read everything before it.
1734
+ *
1735
+ * Every frame gets its own, so `fences` is exactly the number of frames the
1736
+ * server has not confirmed. Nothing here refuses one past the limit — the
1737
+ * callers only send a frame while the gate is open, and a frame sent some
1738
+ * other way is still better counted than left unfenced, where the gate
1739
+ * would open with it unconfirmed.
1740
+ */
1639
1741
  _armFence() {
1640
1742
  if (!this._frameSyncEnabled) return;
1641
1743
  const f = this._frame;
1642
- if (f.inFlight) return;
1643
1744
  const start = performance.now();
1644
1745
  safeRelease(this.X, () => {
1645
- f.inFlight = true;
1746
+ f.fences++;
1646
1747
  this.X.GetInputFocus(() => {
1647
- f.inFlight = false;
1748
+ f.fences--;
1648
1749
  this.frameLatency = performance.now() - start;
1649
1750
  this._frameEnded();
1650
1751
  });
@@ -2139,8 +2240,9 @@ export default class Window extends Drawable {
2139
2240
  * DOM-style requestAnimationFrame: `cb(now)` runs on this window's next
2140
2241
  * paced frame — one per vertical blank where the display is the clock (see
2141
2242
  * _clockOnPresent), otherwise at most once per `frameInterval` ms — and
2142
- * always with at most one frame outstanding, so animation loops adapt to
2143
- * the output's rate and to connection latency instead of flooding either.
2243
+ * always with a bounded number of frames outstanding (one on the display's
2244
+ * clock, `maxFramesInFlight` on the fence), so animation loops adapt to the
2245
+ * output's rate and to connection latency instead of flooding either.
2144
2246
  * Returns an id for cancelAnimationFrame().
2145
2247
  */
2146
2248
  requestAnimationFrame(cb) {
@@ -2159,9 +2261,12 @@ export default class Window extends Drawable {
2159
2261
 
2160
2262
  /**
2161
2263
  * Whether a blit this window already owes is still waiting to go out —
2162
- * because the last frame is unanswered (its fence unreplied, or its present
2163
- * not yet on the display), or because a present is deferred behind the
2164
- * minimum inter-blit interval.
2264
+ * because the frames already sent are unanswered (as many fences unreplied
2265
+ * as `maxFramesInFlight` allows, or a present not yet on the display), or
2266
+ * because a present is deferred behind the minimum inter-blit interval.
2267
+ *
2268
+ * So it reads false with a frame in flight when the limit allows another:
2269
+ * a blit drawn then goes out at once, which is the question this answers.
2165
2270
  *
2166
2271
  * The one bit of frame-clock state worth publishing, because it is the
2167
2272
  * difference between the two ways a toolkit can answer a discrete input.
@@ -2188,16 +2293,17 @@ export default class Window extends Drawable {
2188
2293
  * asked for both gets the unpaced behaviour those options ask for.
2189
2294
  */
2190
2295
  frameInFlight() {
2191
- return this._frame.inFlight || this._frame.presentInFlight || this._presentPending;
2296
+ return this._fencesFull() || this._frame.presentInFlight || this._presentPending;
2192
2297
  }
2193
2298
 
2194
2299
  _present() {
2195
2300
  if (!this._backing) return;
2196
2301
  const f = this._frame;
2197
2302
  const wait = this._presentWait();
2198
- if (f.inFlight || f.presentInFlight || wait > 0) {
2199
- // the server hasn't confirmed the previous frame yet, the display hasn't
2200
- // shown it, or the last blit was too recent — defer, leaving a wakeup
2303
+ if (this._fencesFull() || f.presentInFlight || wait > 0) {
2304
+ // the server hasn't confirmed enough of the frames already sent, the
2305
+ // display hasn't shown the last one, or the last blit was too recent —
2306
+ // defer, leaving a wakeup
2201
2307
  this._deferPresent(wait);
2202
2308
  return;
2203
2309
  }
@@ -2252,7 +2358,7 @@ export default class Window extends Drawable {
2252
2358
  if (!this._presentPending && !this._dirty) return; // someone blitted it
2253
2359
  const again = this._presentWait();
2254
2360
  if (again > 0) return this._deferPresent(again); // a paced frame re-stamped
2255
- if (f.inFlight || f.presentInFlight) return; // the frame's end is closer
2361
+ if (this._fencesFull() || f.presentInFlight) return; // the frame's end is closer
2256
2362
  this._presentNow();
2257
2363
  if (!f.presentInFlight) this._armFence();
2258
2364
  }, wait);
@@ -2333,15 +2439,25 @@ export default class Window extends Drawable {
2333
2439
  });
2334
2440
  }
2335
2441
 
2442
+ /**
2443
+ * Would a blit go out through the Present extension right now, rather than
2444
+ * as CopyArea? Not before the extensions have answered, and not once the
2445
+ * window has left the path (see _presentAbandoned).
2446
+ */
2447
+ _blitsThroughPresent() {
2448
+ return (
2449
+ !!this._presentExt && !!this._fixesExt && !!this._updateRegion && !this._presentAbandoned()
2450
+ );
2451
+ }
2452
+
2336
2453
  /**
2337
2454
  * The Present form of the blit. Returns false when it did not happen, so
2338
2455
  * the caller falls back to CopyArea — which is also what runs before the
2339
2456
  * extensions have answered.
2340
2457
  */
2341
2458
  _presentWithExtension(rects) {
2459
+ if (!this._blitsThroughPresent()) return false;
2342
2460
  const P = this._presentExt;
2343
- if (!P || !this._fixesExt || !this._updateRegion) return false;
2344
- if (this._presentAbandoned()) return false;
2345
2461
  safeRelease(this.X, () => {
2346
2462
  this._fixesExt.SetRegion(
2347
2463
  this._updateRegion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.11.1",
3
+ "version": "8.12.0",
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",