ntk 8.11.1 → 8.12.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.
- package/README.md +2 -1
- package/lib/renderingcontext_2d.js +221 -38
- package/lib/window.js +137 -21
- 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
|
|
109
|
+
`frameSync`, `maxFramesInFlight`, `coalesceEvents`) and the raw uncoalesced
|
|
110
|
+
event stream.
|
|
110
111
|
|
|
111
112
|
## Resource management
|
|
112
113
|
|
|
@@ -94,6 +94,51 @@ function intersectBox(a, b) {
|
|
|
94
94
|
return { x, y, w: right - x, h: bottom - y };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether no two of a flat `[x, y, w, h, …]` list of whole-pixel rectangles
|
|
99
|
+
* share a pixel — touching edges do not. A sweep along x holding the
|
|
100
|
+
* rectangles it is inside as disjoint y spans in order, so a list that is
|
|
101
|
+
* disjoint, which is the list this is asked about, costs a binary search
|
|
102
|
+
* per rectangle: a chart's per-column spans, an occupancy grid's cells.
|
|
103
|
+
*/
|
|
104
|
+
function disjointRects(rects) {
|
|
105
|
+
const n = rects.length / 4;
|
|
106
|
+
if (n < 2) return true;
|
|
107
|
+
// events: where a rectangle starts and where it ends, ends first at a tie
|
|
108
|
+
const events = [];
|
|
109
|
+
for (let i = 0; i < n; i++) {
|
|
110
|
+
events.push([rects[i * 4], 1, i], [rects[i * 4] + rects[i * 4 + 2], 0, i]);
|
|
111
|
+
}
|
|
112
|
+
events.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
113
|
+
// the y spans the sweep is inside, sorted by their top, as rectangle ids
|
|
114
|
+
const active = [];
|
|
115
|
+
const top = (i) => rects[i * 4 + 1];
|
|
116
|
+
const bottom = (i) => rects[i * 4 + 1] + rects[i * 4 + 3];
|
|
117
|
+
const find = (y) => {
|
|
118
|
+
let lo = 0;
|
|
119
|
+
let hi = active.length;
|
|
120
|
+
while (lo < hi) {
|
|
121
|
+
const mid = (lo + hi) >> 1;
|
|
122
|
+
if (top(active[mid]) < y) lo = mid + 1;
|
|
123
|
+
else hi = mid;
|
|
124
|
+
}
|
|
125
|
+
return lo;
|
|
126
|
+
};
|
|
127
|
+
for (const [, starts, i] of events) {
|
|
128
|
+
if (!starts) {
|
|
129
|
+
let at = find(top(i));
|
|
130
|
+
while (active[at] !== i) at++;
|
|
131
|
+
active.splice(at, 1);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const at = find(top(i));
|
|
135
|
+
if (at > 0 && bottom(active[at - 1]) > top(i)) return false;
|
|
136
|
+
if (at < active.length && top(active[at]) < bottom(i)) return false;
|
|
137
|
+
active.splice(at, 0, i);
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
97
142
|
/** The device-space ink bounds of a flattened path, unclamped. */
|
|
98
143
|
function polysInk(polys) {
|
|
99
144
|
let minX = Infinity;
|
|
@@ -1854,42 +1899,47 @@ class RenderingContext2d {
|
|
|
1854
1899
|
* well as the AddTraps: two requests become one, and the one that is left
|
|
1855
1900
|
* touches only the bounding box.
|
|
1856
1901
|
*/
|
|
1857
|
-
_uploadCoverage(job, b, edges) {
|
|
1902
|
+
_uploadCoverage(job, b, out, edges) {
|
|
1858
1903
|
const rasterizer = this.window.app.rasterizer;
|
|
1859
1904
|
if (!rasterizer) return false;
|
|
1860
1905
|
if (routeRaster(b.w, b.h, edges, this.window.app.rasterPolicy) !== "local")
|
|
1861
1906
|
return false;
|
|
1862
1907
|
|
|
1908
|
+
// Rasterized over the whole box, as always, so a drawing that clips
|
|
1909
|
+
// split across passes — a pan's strips, a full repaint — gets the same
|
|
1910
|
+
// coverage in every one of them, byte for byte. Only `out`, the part the
|
|
1911
|
+
// clip lets through, goes on the wire (issue #372).
|
|
1863
1912
|
const coverage = rasterizer.rasterize({ ...job, width: b.w, height: b.h });
|
|
1864
1913
|
if (!coverage) return false;
|
|
1865
1914
|
|
|
1866
1915
|
// X wants scanlines padded to 4 bytes; the rasterizer contract is
|
|
1867
|
-
// unpadded rows. When the
|
|
1868
|
-
// power-of-two icon box is — the
|
|
1869
|
-
// rasterizer's own bytes, with no
|
|
1870
|
-
|
|
1916
|
+
// unpadded rows. When the part sent is the whole box and its width is
|
|
1917
|
+
// already a multiple of 4 — which every power-of-two icon box is — the
|
|
1918
|
+
// coverage goes out as a view over the rasterizer's own bytes, with no
|
|
1919
|
+
// copy at all.
|
|
1920
|
+
const stride = (out.w + 3) & ~3;
|
|
1871
1921
|
let data;
|
|
1872
|
-
if (stride === b.w) {
|
|
1922
|
+
if (out.w === b.w && out.h === b.h && stride === b.w) {
|
|
1873
1923
|
data = Buffer.isBuffer(coverage)
|
|
1874
1924
|
? coverage
|
|
1875
1925
|
: Buffer.from(coverage.buffer, coverage.byteOffset, coverage.length);
|
|
1876
1926
|
} else {
|
|
1877
|
-
data = Buffer.alloc(stride *
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
);
|
|
1927
|
+
data = Buffer.alloc(stride * out.h);
|
|
1928
|
+
const left = out.x - b.x;
|
|
1929
|
+
const top = out.y - b.y;
|
|
1930
|
+
for (let y = 0; y < out.h; ++y) {
|
|
1931
|
+
const from = coverage.byteOffset + (top + y) * b.w + left;
|
|
1932
|
+
Buffer.from(coverage.buffer, from, out.w).copy(data, y * stride);
|
|
1883
1933
|
}
|
|
1884
1934
|
}
|
|
1885
1935
|
this.X.PutImage(
|
|
1886
1936
|
2,
|
|
1887
1937
|
this.fillMaskDrawable.id,
|
|
1888
1938
|
this._maskGC(),
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1939
|
+
out.w,
|
|
1940
|
+
out.h,
|
|
1941
|
+
out.x,
|
|
1942
|
+
out.y,
|
|
1893
1943
|
0,
|
|
1894
1944
|
8,
|
|
1895
1945
|
data,
|
|
@@ -1982,30 +2032,29 @@ class RenderingContext2d {
|
|
|
1982
2032
|
*/
|
|
1983
2033
|
_paintThroughMask(b, job, { edges, src, op, alpha, server }) {
|
|
1984
2034
|
const R = this.Render;
|
|
1985
|
-
//
|
|
1986
|
-
//
|
|
1987
|
-
// and
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
1990
|
-
//
|
|
2035
|
+
// Everything is bounded to the part of the box the clip lets through:
|
|
2036
|
+
// the clip's rectangle, or the extents of a stack with a path in it —
|
|
2037
|
+
// the coverage rasterized and uploaded, the clip applied, the composite.
|
|
2038
|
+
// A stroke across a rounded pane used to upload its whole box through
|
|
2039
|
+
// the mask to paint a few rows of it (issue #372). The mask content
|
|
2040
|
+
// outside `out` is stale by the same argument as outside `b`, and a box
|
|
2041
|
+
// the clip rejects outright needs no coverage at all. A stack whose mask
|
|
2042
|
+
// was dropped by restore() may still hold a poly, and the mask comes
|
|
2043
|
+
// back on demand. A region clip needs nothing here: the picture carries
|
|
2044
|
+
// it, and it applies to the composite below like any other.
|
|
2045
|
+
const clip = this._hasPolyClip ? this._clipExtents() : this._clipRect();
|
|
1991
2046
|
let out = b;
|
|
1992
|
-
if (
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
} else {
|
|
1996
|
-
const cr = this._clipRect();
|
|
1997
|
-
if (cr) {
|
|
1998
|
-
out = intersectBox(b, cr);
|
|
1999
|
-
if (!out) return false;
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2047
|
+
if (clip) {
|
|
2048
|
+
out = intersectBox(b, clip);
|
|
2049
|
+
if (!out) return false;
|
|
2002
2050
|
}
|
|
2003
|
-
if (
|
|
2051
|
+
if (this._hasPolyClip) this._requireClipMask();
|
|
2052
|
+
if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, out, edges)) {
|
|
2004
2053
|
R.FillRectangles(
|
|
2005
2054
|
R.PictOp.Src,
|
|
2006
2055
|
this.fillMask.id,
|
|
2007
2056
|
[0, 0, 0, 0],
|
|
2008
|
-
[
|
|
2057
|
+
[out.x, out.y, out.w, out.h],
|
|
2009
2058
|
);
|
|
2010
2059
|
server();
|
|
2011
2060
|
}
|
|
@@ -3009,9 +3058,12 @@ class RenderingContext2d {
|
|
|
3009
3058
|
* A solid-colour fillStyle under an identity transform and a rectangular
|
|
3010
3059
|
* (or absent) clip compiles the whole list into a single
|
|
3011
3060
|
* `Render.FillRectangles`, where N `fillRect` calls cost N composites.
|
|
3012
|
-
*
|
|
3013
|
-
*
|
|
3014
|
-
*
|
|
3061
|
+
* Under a clip that is not a rectangle, rectangles that do not overlap
|
|
3062
|
+
* still cost three requests however many there are
|
|
3063
|
+
* (`_fillRectsThroughClip`). Gradient/Picture styles, transforms, and
|
|
3064
|
+
* overlapping rectangles or `copy` under a non-rectangular clip fall back
|
|
3065
|
+
* to that `fillRect` loop, so the answer is always right and only the
|
|
3066
|
+
* request count varies.
|
|
3015
3067
|
*/
|
|
3016
3068
|
fillRects(rects) {
|
|
3017
3069
|
if (!rects || !rects.length || this.globalAlpha <= 0) return;
|
|
@@ -3045,12 +3097,13 @@ class RenderingContext2d {
|
|
|
3045
3097
|
if (
|
|
3046
3098
|
!matIsIdentity(this._m) ||
|
|
3047
3099
|
!isPlainColor(this._fillStyle) ||
|
|
3048
|
-
this._hasPolyClip
|
|
3100
|
+
(this._hasPolyClip && !this._fillRectsThroughClip(flat))
|
|
3049
3101
|
) {
|
|
3050
3102
|
for (let i = 0; i < flat.length; i += 4)
|
|
3051
3103
|
this._fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
|
|
3052
3104
|
return;
|
|
3053
3105
|
}
|
|
3106
|
+
if (this._hasPolyClip) return;
|
|
3054
3107
|
if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
|
|
3055
3108
|
const R = this.Render;
|
|
3056
3109
|
if (clip) this._setPictureClip(clip);
|
|
@@ -3073,6 +3126,102 @@ class RenderingContext2d {
|
|
|
3073
3126
|
this._markDirty();
|
|
3074
3127
|
}
|
|
3075
3128
|
|
|
3129
|
+
/**
|
|
3130
|
+
* `fillRects` under a clip that is not a rectangle — a rounded card, a
|
|
3131
|
+
* path: every rectangle's coverage into the scratch a8 mask with one
|
|
3132
|
+
* `FillRectangles`, `globalAlpha` and the clip applied to it once each,
|
|
3133
|
+
* and the style composited through it once. One rectangle at a time is a
|
|
3134
|
+
* composite through the clip each, and a chart's marks inside a rounded
|
|
3135
|
+
* box a zoom had put off the pixel grid were sixty thousand of them a
|
|
3136
|
+
* frame (issue #374).
|
|
3137
|
+
*
|
|
3138
|
+
* Only where that is the loop's picture. The rectangles must not overlap:
|
|
3139
|
+
* the loop blends a pixel once per rectangle over it, at the clip's
|
|
3140
|
+
* coverage each time, so where two overlap on the clip's antialiased edge
|
|
3141
|
+
* it is darker than one blend through the mask — and where they do not,
|
|
3142
|
+
* every pixel is blended once either way, whatever the colour's alpha.
|
|
3143
|
+
* And the operator must leave the destination alone where the mask is
|
|
3144
|
+
* zero: `copy` clears every pixel of its box the mask misses, the gaps
|
|
3145
|
+
* between the rectangles here. The rectangles reach the mask as the wire
|
|
3146
|
+
* carries every box, in whole pixels truncated toward zero, which is what
|
|
3147
|
+
* `_fillRect`'s composites are cut to as well.
|
|
3148
|
+
*
|
|
3149
|
+
* @returns {boolean} whether the rectangles were drawn here
|
|
3150
|
+
*/
|
|
3151
|
+
_fillRectsThroughClip(flat) {
|
|
3152
|
+
const R = this.Render;
|
|
3153
|
+
const op = this._op();
|
|
3154
|
+
if (!this._maskBounded(op)) return false;
|
|
3155
|
+
const rects = [];
|
|
3156
|
+
let x0 = Infinity;
|
|
3157
|
+
let y0 = Infinity;
|
|
3158
|
+
let x1 = -Infinity;
|
|
3159
|
+
let y1 = -Infinity;
|
|
3160
|
+
for (let i = 0; i < flat.length; i += 4) {
|
|
3161
|
+
const x = Math.trunc(flat[i]);
|
|
3162
|
+
const y = Math.trunc(flat[i + 1]);
|
|
3163
|
+
const w = Math.trunc(flat[i + 2]);
|
|
3164
|
+
const h = Math.trunc(flat[i + 3]);
|
|
3165
|
+
if (w <= 0 || h <= 0) continue;
|
|
3166
|
+
rects.push(x, y, w, h);
|
|
3167
|
+
if (x < x0) x0 = x;
|
|
3168
|
+
if (y < y0) y0 = y;
|
|
3169
|
+
if (x + w > x1) x1 = x + w;
|
|
3170
|
+
if (y + h > y1) y1 = y + h;
|
|
3171
|
+
}
|
|
3172
|
+
if (!disjointRects(rects)) return false;
|
|
3173
|
+
if (!prepareStyle(this._backgroundPicture, this._m)) return true;
|
|
3174
|
+
// the box they cover, on the surface: nothing outside it is sampled
|
|
3175
|
+
const bx = Math.max(0, x0);
|
|
3176
|
+
const by = Math.max(0, y0);
|
|
3177
|
+
const bw = Math.min(this.width, x1) - bx;
|
|
3178
|
+
const bh = Math.min(this.height, y1) - by;
|
|
3179
|
+
if (!(bw > 0 && bh > 0)) return true;
|
|
3180
|
+
this._ensureFillMask();
|
|
3181
|
+
const mask = this.fillMask.id;
|
|
3182
|
+
R.FillRectangles(R.PictOp.Src, mask, [0, 0, 0, 0], [bx, by, bw, bh]);
|
|
3183
|
+
// stay under the server's maximum request size
|
|
3184
|
+
const chunk = 10000 * 4;
|
|
3185
|
+
for (let i = 0; i < rects.length; i += chunk) {
|
|
3186
|
+
R.FillRectangles(
|
|
3187
|
+
R.PictOp.Src,
|
|
3188
|
+
mask,
|
|
3189
|
+
[0, 0, 0, this.globalAlpha < 1 ? this.globalAlpha : 1],
|
|
3190
|
+
i === 0 && rects.length <= chunk ? rects : rects.slice(i, i + chunk),
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
R.Composite(
|
|
3194
|
+
R.PictOp.In,
|
|
3195
|
+
this._requireClipMask().id,
|
|
3196
|
+
0,
|
|
3197
|
+
mask,
|
|
3198
|
+
bx,
|
|
3199
|
+
by,
|
|
3200
|
+
0,
|
|
3201
|
+
0,
|
|
3202
|
+
bx,
|
|
3203
|
+
by,
|
|
3204
|
+
bw,
|
|
3205
|
+
bh,
|
|
3206
|
+
);
|
|
3207
|
+
R.Composite(
|
|
3208
|
+
op,
|
|
3209
|
+
this._backgroundPicture.id,
|
|
3210
|
+
mask,
|
|
3211
|
+
this._dst(),
|
|
3212
|
+
bx,
|
|
3213
|
+
by,
|
|
3214
|
+
bx,
|
|
3215
|
+
by,
|
|
3216
|
+
bx,
|
|
3217
|
+
by,
|
|
3218
|
+
bw,
|
|
3219
|
+
bh,
|
|
3220
|
+
);
|
|
3221
|
+
this._markDirty();
|
|
3222
|
+
return true;
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3076
3225
|
strokeRect(x, y, w, h) {
|
|
3077
3226
|
if (this._shadowed()) this._shadowOfRect(x, y, w, h, true);
|
|
3078
3227
|
const m = this._m;
|
|
@@ -3531,6 +3680,40 @@ class RenderingContext2d {
|
|
|
3531
3680
|
return { x, y, w, h };
|
|
3532
3681
|
}
|
|
3533
3682
|
|
|
3683
|
+
/**
|
|
3684
|
+
* The box the clip stack lets anything through, in device pixels on the
|
|
3685
|
+
* surface: the intersection of its rectangles and of its paths' bounding
|
|
3686
|
+
* boxes. Null when the stack holds no shape — region entries are the
|
|
3687
|
+
* picture's, as in `_clipRect` — and an empty box when it holds a path
|
|
3688
|
+
* that lands nowhere. What a drawing through the a8 mask is bounded to
|
|
3689
|
+
* (issue #372). Cached against the stack, which is replaced on every
|
|
3690
|
+
* change rather than edited.
|
|
3691
|
+
*/
|
|
3692
|
+
_clipExtents() {
|
|
3693
|
+
if (this._extentsOf === this._clips) return this._extents;
|
|
3694
|
+
let out = null;
|
|
3695
|
+
for (const entry of this._clips) {
|
|
3696
|
+
if (entry.region) continue;
|
|
3697
|
+
const box = entry.rect ?? (entry.bbox ??= this._polysBBox(entry.polys));
|
|
3698
|
+
const next = box && (out ? intersectBox(out, box) : box);
|
|
3699
|
+
if (!next) {
|
|
3700
|
+
out = { x: 0, y: 0, w: 0, h: 0 };
|
|
3701
|
+
break;
|
|
3702
|
+
}
|
|
3703
|
+
out = next;
|
|
3704
|
+
}
|
|
3705
|
+
if (out) {
|
|
3706
|
+
const x = Math.max(0, Math.min(out.x, this.width));
|
|
3707
|
+
const y = Math.max(0, Math.min(out.y, this.height));
|
|
3708
|
+
const w = Math.max(0, Math.min(out.x + out.w, this.width) - x);
|
|
3709
|
+
const h = Math.max(0, Math.min(out.y + out.h, this.height) - y);
|
|
3710
|
+
out = { x, y, w, h };
|
|
3711
|
+
}
|
|
3712
|
+
this._extentsOf = this._clips;
|
|
3713
|
+
this._extents = out;
|
|
3714
|
+
return out;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3534
3717
|
/**
|
|
3535
3718
|
* Replace the clip stack and re-derive what kinds of entry are in it.
|
|
3536
3719
|
*
|
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
|
-
|
|
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
|
|
1352
|
-
* flight:
|
|
1353
|
-
*
|
|
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 ||
|
|
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 (
|
|
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.
|
|
1746
|
+
f.fences++;
|
|
1646
1747
|
this.X.GetInputFocus(() => {
|
|
1647
|
-
f.
|
|
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
|
|
2143
|
-
*
|
|
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
|
|
2163
|
-
*
|
|
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.
|
|
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 (
|
|
2199
|
-
// the server hasn't confirmed the
|
|
2200
|
-
// shown
|
|
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 (
|
|
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,
|