ntk 8.6.0 → 8.7.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/index.js CHANGED
@@ -51,6 +51,7 @@ import { DEFAULT_MASK_POLICY } from './maskcluster.js';
51
51
  import {
52
52
  DEFAULT_SHADOW_POLICY,
53
53
  blurCoverage,
54
+ blurScale,
54
55
  gaussianKernel1d,
55
56
  shadowReach,
56
57
  shadowSigma
@@ -286,9 +287,12 @@ export {
286
287
  // caller drawing shapes ntk's shadow properties never see. `blurCoverage`
287
288
  // *bakes* the two separable passes into a surface's pixels — unlike
288
289
  // `picture().setBlurFilter()`, whose kernel the server re-runs on every
289
- // composite, which a cached shadow pays for once per frame
290
+ // composite, which a cached shadow pays for once per frame — and runs a
291
+ // wide one at reduced scale, which `blurScale` names for a caller that
292
+ // would rather draw its shape small in the first place (issue #338)
290
293
  DEFAULT_SHADOW_POLICY,
291
294
  blurCoverage,
295
+ blurScale,
292
296
  gaussianKernel1d,
293
297
  shadowReach,
294
298
  shadowSigma,
package/lib/shadow.js CHANGED
@@ -33,17 +33,31 @@
33
33
  // cached copy composites as a plain mask rather than re-running a kernel on
34
34
  // every frame.
35
35
  //
36
+ // ## Why a wide one runs small
37
+ //
38
+ // Two passes still cost `2 * taps * w * h`, and both grow with sigma: the
39
+ // ten shadows on react-x11's configurator come to 118M multiply-accumulates,
40
+ // which is 596ms of a first paint on software RENDER, and 73% of it is two
41
+ // wide ones (issue #338). A gaussian carries no detail finer than about σ/2
42
+ // px, so that work is spent resolving what the result cannot hold — past
43
+ // σ 8 the coverage is shrunk by 2 or 4 first, blurred at `sigma / scale`,
44
+ // and resolved back, which is `scale` off the kernel and `scale²` off the
45
+ // area for a difference of three levels of 8-bit alpha at worst.
46
+ // `blurScale` picks it, `scaleSigma` / `maxScale` bound it, and the surface
47
+ // that comes back is the size it always was, still with no filter on it.
48
+ //
36
49
  // ## What of this is public
37
50
  //
38
- // The bake and the maths around it — `blurCoverage`, `shadowSigma`,
39
- // `shadowReach`, `gaussianKernel1d` and `DEFAULT_SHADOW_POLICY` — are
40
- // re-exported from `lib/index.js` (issue #335). A toolkit that draws its own
41
- // shapes (react-x11 paints a `<box>`'s `boxShadow` itself, because the
42
- // rounded rect is a path it already has and the result goes through its own
43
- // paint cache) needs exactly this and nothing else: the alternative on the
44
- // public surface, `picture().setBlurFilter()`, sets a k×k filter that the
45
- // server re-runs on *every* composite, which is invisible until someone
46
- // profiles a real display. The surfaces and the cache below stay private —
51
+ // The bake and the maths around it — `blurCoverage`, `blurScale`,
52
+ // `shadowSigma`, `shadowReach`, `gaussianKernel1d` and
53
+ // `DEFAULT_SHADOW_POLICY` — are re-exported from `lib/index.js` (issues
54
+ // #335 and #338). A toolkit that draws its own shapes (react-x11 paints a
55
+ // `<box>`'s `boxShadow` itself, because the rounded rect is a path it
56
+ // already has and the result goes through its own paint cache) needs exactly
57
+ // this and nothing else: the alternative on the public surface,
58
+ // `picture().setBlurFilter()`, sets a k×k filter that the server re-runs on
59
+ // *every* composite, which is invisible until someone profiles a real
60
+ // display. The surfaces and the cache below stay private —
47
61
  // they are the 2d context's bookkeeping, not a primitive.
48
62
  import { Surface } from './surface.js';
49
63
 
@@ -61,11 +75,26 @@ import { Surface } from './surface.js';
61
75
  * - `maxPixels` — the largest coverage surface built for one shadow. Beyond
62
76
  * it the shadow is dropped rather than turning one drawing into a
63
77
  * multi-megabyte allocation; the drawing itself is unaffected.
78
+ * - `scaleSigma` — the σ a reduced-scale blur is not allowed to fall below.
79
+ * A gaussian carries no detail finer than about σ/2 px, so a wide one does
80
+ * not need full resolution to resolve it: past twice this, coverage is
81
+ * shrunk by the largest power of two that keeps σ/scale at or above it,
82
+ * blurred there, and resolved back — `scale` off the kernel and `scale²`
83
+ * off the area, which is where a first paint's time goes (issue #338).
84
+ * What the shrink costs is set by that reduced σ and not by the ratio: 4
85
+ * holds the difference from an exact blur inside three levels of 8-bit
86
+ * alpha, where 3 is worth four levels and 2 is worth seven.
87
+ * - `maxScale` — how far the shrink may go whatever the floor allows, so a
88
+ * `maxSigma`-wide blur cannot resample its way down to a thumbnail.
89
+ * `maxScale: 1` blurs everything at full resolution, which is what 8.6
90
+ * did, and is the setting for a caller that needs the exact kernel.
64
91
  */
65
92
  export const DEFAULT_SHADOW_POLICY = {
66
93
  cacheBytes: 4 << 20,
67
94
  maxSigma: 32,
68
- maxPixels: 8 << 20
95
+ maxPixels: 8 << 20,
96
+ scaleSigma: 4,
97
+ maxScale: 4
69
98
  };
70
99
 
71
100
  /** the policy for one app, merged over the defaults */
@@ -123,6 +152,130 @@ export function gaussianKernel1d(sigma, reach = shadowReach(sigma)) {
123
152
  return values;
124
153
  }
125
154
 
155
+ /**
156
+ * The scale a blur of this sigma is run at: 1, 2 or 4 by default, meaning
157
+ * "shrink the coverage by this much, blur at `sigma / scale`, resolve back".
158
+ *
159
+ * A gaussian is a low-pass filter — it carries nothing finer than about σ/2
160
+ * px — so resolving a wide one at full resolution spends most of its time on
161
+ * detail the result cannot hold. Shrinking first takes `scale` off the
162
+ * kernel and `scale²` off the area it runs over: at σ 21 over 552×396, k = 4
163
+ * turns 55.5M multiply-accumulates into 0.9M, which on a software-RENDER
164
+ * server (XQuartz) is most of a first paint (issue #338).
165
+ *
166
+ * The scale is a power of two so that each shrink is an exact 2×2 average,
167
+ * and it is capped both by `maxScale` and by the σ floor `scaleSigma`, which
168
+ * is what keeps the reduced blur wide enough to still be a gaussian — the
169
+ * difference from an exact blur is set by that reduced σ, and stays inside
170
+ * three levels of 8-bit alpha at the 4 the policy defaults to. σ under
171
+ * `2 * scaleSigma` gets 1: below that the two resampling composites cost
172
+ * more than the kernel they save.
173
+ *
174
+ * Exported for a caller that draws its own shapes and can therefore skip the
175
+ * shrink entirely: draw the shape into a surface `1 / scale` the size (its
176
+ * padding scaled with it), blur at `sigma / scale`, and composite through a
177
+ * `1 / scale` picture transform. See docs/surface.md#baking-a-blur.
178
+ */
179
+ export function blurScale(sigma, policy = DEFAULT_SHADOW_POLICY) {
180
+ const floor = policy.scaleSigma ?? DEFAULT_SHADOW_POLICY.scaleSigma;
181
+ const max = policy.maxScale ?? DEFAULT_SHADOW_POLICY.maxScale;
182
+ if (!(sigma > 0) || !(floor > 0) || !(max > 1)) return 1;
183
+ let scale = 1;
184
+ while (scale * 2 <= max && sigma / (scale * 2) >= floor) scale *= 2;
185
+ return scale;
186
+ }
187
+
188
+ /**
189
+ * The smallest a shrunk coverage surface is allowed to get on either side.
190
+ *
191
+ * Nothing a shadow builds comes near it — a blur wide enough to be scaled at
192
+ * all pads by 24px a side — but `blurCoverage` takes any a8 surface, and a
193
+ * hand-built sliver shrunk to a couple of pixels would come back as a smear
194
+ * rather than a blur.
195
+ */
196
+ const MIN_SCALED_SIDE = 16;
197
+
198
+ /** the scale `blurCoverage` will actually use: the policy's (or the
199
+ * caller's), snapped down to a power of two and to what the surface can be
200
+ * shrunk to without losing its shape */
201
+ function resolveScale(shape, sigma, requested) {
202
+ const wanted =
203
+ requested === undefined ? blurScale(sigma, shadowPolicyOf(shape.app)) : requested;
204
+ let scale = 2 ** Math.floor(Math.log2(wanted));
205
+ const side = Math.min(shape.width, shape.height);
206
+ while (scale > 1 && side / scale < MIN_SCALED_SIDE) scale /= 2;
207
+ return scale;
208
+ }
209
+
210
+ /**
211
+ * Shrink coverage by exactly 2, server-side, destroying the input.
212
+ *
213
+ * The transform maps a destination pixel centre to `2 * (i + 0.5)` in the
214
+ * source, which lands the bilinear sample exactly between two texels in each
215
+ * axis: the 2×2 box average, with no weight of the original left out. Which
216
+ * is why the scale is a power of two — one bilinear tap of a 4× shrink would
217
+ * sample every fourth texel and simply not see the pixels in between.
218
+ */
219
+ function halveCoverage(surface, R) {
220
+ const width = Math.max(1, Math.ceil(surface.width / 2));
221
+ const height = Math.max(1, Math.ceil(surface.height / 2));
222
+ const small = new Surface(surface.app, { width, height, format: 'a8' });
223
+ const picture = surface.picture();
224
+ picture.setFilter('bilinear');
225
+ R.SetPictureTransform(picture.id, [2, 0, 0, 0, 2, 0, 0, 0, 1]);
226
+ R.Composite(R.PictOp.Src, picture.id, 0, small.picture().id, 0, 0, 0, 0, 0, 0, width, height);
227
+ surface.destroy();
228
+ return small;
229
+ }
230
+
231
+ /**
232
+ * Resolve shrunk coverage back to `width` × `height`, destroying the input.
233
+ *
234
+ * Bilinear again, and this is where the error of the whole scheme lives: the
235
+ * blurred coverage is reconstructed linearly between samples `scale` px
236
+ * apart, and what a straight line misses between two samples of a gaussian
237
+ * goes as the curvature there — `1 / σ'²`, in the reduced surface's own
238
+ * sigma. So the bound is a property of the σ the blur ran at, which is what
239
+ * `scaleSigma` pins: three alpha levels at σ' 4, seven at σ' 2 (measured
240
+ * against Xorg; a server whose bilinear rounds less is a level better).
241
+ *
242
+ * It happens once, into an ordinary surface: what comes back carries no
243
+ * filter and no transform, so every composite of it afterwards is a plain
244
+ * mask, exactly as it was before the shrink existed.
245
+ */
246
+ function expandCoverage(small, width, height, scale, R) {
247
+ const out = new Surface(small.app, { width, height, format: 'a8' });
248
+ const picture = small.picture();
249
+ picture.setFilter('bilinear');
250
+ R.SetPictureTransform(picture.id, [1 / scale, 0, 0, 0, 1 / scale, 0, 0, 0, 1]);
251
+ R.Composite(R.PictOp.Src, picture.id, 0, out.picture().id, 0, 0, 0, 0, 0, 0, width, height);
252
+ small.destroy();
253
+ return out;
254
+ }
255
+
256
+ /** the two separable passes themselves, at whatever scale they are run —
257
+ * `shape` in, blurred copy out, input destroyed */
258
+ function bakeBlur(shape, sigma, R) {
259
+ const { app, width, height } = shape;
260
+ const kernel = gaussianKernel1d(sigma);
261
+ const scratch = new Surface(app, { width, height, format: 'a8' });
262
+ const out = new Surface(app, { width, height, format: 'a8' });
263
+
264
+ const pass = (src, dst, params) => {
265
+ src.picture().setFilter('convolution', params);
266
+ // Src, not Over: each pass replaces the destination, which was cleared
267
+ // to transparent on creation. Over would accumulate the sharp copy's
268
+ // coverage under the blurred one and give the shadow a hard core.
269
+ R.Composite(R.PictOp.Src, src.picture().id, 0, dst.picture().id, 0, 0, 0, 0, 0, 0, width, height);
270
+ };
271
+ pass(shape, scratch, [kernel.length, 1, ...kernel]);
272
+ pass(scratch, out, [1, kernel.length, ...kernel]);
273
+
274
+ shape.destroy();
275
+ scratch.destroy();
276
+ return out;
277
+ }
278
+
126
279
  /**
127
280
  * Blur an a8 coverage surface, returning a new one holding the result.
128
281
  *
@@ -138,8 +291,16 @@ export function gaussianKernel1d(sigma, reach = shadowReach(sigma)) {
138
291
  * bakes instead. `shape` must be a coverage surface with the blur's reach as
139
292
  * padding on all four sides, or the result ends in a straight line where the
140
293
  * kernel ran out of pixels; see docs/surface.md.
294
+ *
295
+ * A wide blur does not run at full resolution. Past σ 8 the coverage is
296
+ * shrunk by a power of two, blurred at `sigma / scale` and resolved back —
297
+ * `scale³` off the work, for a difference of at most three levels of 8-bit
298
+ * alpha (issue #338). `scale` overrides that: `{ scale: 1 }` is the exact
299
+ * path 8.6 took, and a larger one is snapped down to a power of two. Which
300
+ * scale the policy picks is `blurScale(sigma)`; the size of what comes back
301
+ * never changes with it.
141
302
  */
142
- export function blurCoverage(shape, sigma) {
303
+ export function blurCoverage(shape, sigma, { scale } = {}) {
143
304
  if (!(sigma > 0) || !Number.isFinite(sigma)) {
144
305
  throw new Error(
145
306
  `blurCoverage: sigma must be a finite number above 0, got ${sigma}. ` +
@@ -155,26 +316,23 @@ export function blurCoverage(shape, sigma) {
155
316
  'the blur runs on alpha, so an argb32 surface would lose its colour.'
156
317
  );
157
318
  }
158
- const app = shape.app;
159
- const R = app.display.Render;
319
+ if (scale !== undefined && (!Number.isFinite(scale) || scale < 1)) {
320
+ throw new Error(
321
+ `blurCoverage: scale must be a finite number of 1 or more, got ${scale}. ` +
322
+ 'It is how much the coverage is shrunk before the blur runs on it, ' +
323
+ 'so 1 is "blur at full resolution"; anything else is snapped down to ' +
324
+ 'a power of two. Leave it out to take the scale from the app\'s ' +
325
+ 'shadowPolicy (scaleSigma / maxScale).'
326
+ );
327
+ }
328
+ const R = shape.app.display.Render;
160
329
  const { width, height } = shape;
161
- const kernel = gaussianKernel1d(sigma);
162
- const scratch = new Surface(app, { width, height, format: 'a8' });
163
- const out = new Surface(app, { width, height, format: 'a8' });
164
-
165
- const pass = (src, dst, params) => {
166
- src.picture().setFilter('convolution', params);
167
- // Src, not Over: each pass replaces the destination, which was cleared
168
- // to transparent on creation. Over would accumulate the sharp copy's
169
- // coverage under the blurred one and give the shadow a hard core.
170
- R.Composite(R.PictOp.Src, src.picture().id, 0, dst.picture().id, 0, 0, 0, 0, 0, 0, width, height);
171
- };
172
- pass(shape, scratch, [kernel.length, 1, ...kernel]);
173
- pass(scratch, out, [1, kernel.length, ...kernel]);
330
+ const k = resolveScale(shape, sigma, scale);
331
+ if (k === 1) return bakeBlur(shape, sigma, R);
174
332
 
175
- shape.destroy();
176
- scratch.destroy();
177
- return out;
333
+ let small = shape;
334
+ for (let step = k; step > 1; step /= 2) small = halveCoverage(small, R);
335
+ return expandCoverage(bakeBlur(small, sigma / k, R), width, height, k, R);
178
336
  }
179
337
 
180
338
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.6.0",
3
+ "version": "8.7.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",