ntk 8.5.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 +21 -0
- package/lib/picture.js +25 -0
- package/lib/shadow.js +204 -10
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -48,6 +48,14 @@ import {
|
|
|
48
48
|
setDefaultRasterizer
|
|
49
49
|
} from './rasterize.js';
|
|
50
50
|
import { DEFAULT_MASK_POLICY } from './maskcluster.js';
|
|
51
|
+
import {
|
|
52
|
+
DEFAULT_SHADOW_POLICY,
|
|
53
|
+
blurCoverage,
|
|
54
|
+
blurScale,
|
|
55
|
+
gaussianKernel1d,
|
|
56
|
+
shadowReach,
|
|
57
|
+
shadowSigma
|
|
58
|
+
} from './shadow.js';
|
|
51
59
|
import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
|
|
52
60
|
import { DEFAULT_SHARED_GLYPHS_POLICY } from './glyphdirectory.js';
|
|
53
61
|
import { warmSharedGlyphs } from './text/glyphs.js';
|
|
@@ -275,6 +283,19 @@ export {
|
|
|
275
283
|
DEFAULT_RASTER_POLICY,
|
|
276
284
|
DEFAULT_MASK_POLICY,
|
|
277
285
|
DEFAULT_SHAPE_POLICY,
|
|
286
|
+
// the shadow blur as a primitive (docs/surface.md#baking-a-blur), for a
|
|
287
|
+
// caller drawing shapes ntk's shadow properties never see. `blurCoverage`
|
|
288
|
+
// *bakes* the two separable passes into a surface's pixels — unlike
|
|
289
|
+
// `picture().setBlurFilter()`, whose kernel the server re-runs on every
|
|
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)
|
|
293
|
+
DEFAULT_SHADOW_POLICY,
|
|
294
|
+
blurCoverage,
|
|
295
|
+
blurScale,
|
|
296
|
+
gaussianKernel1d,
|
|
297
|
+
shadowReach,
|
|
298
|
+
shadowSigma,
|
|
278
299
|
// cross-process shared glyphs (docs/shared-glyphs.md): the directory-side
|
|
279
300
|
// budget, and the prewarm that makes a first paint of already-shared text
|
|
280
301
|
// rasterize and upload nothing
|
package/lib/picture.js
CHANGED
|
@@ -25,10 +25,35 @@ export default class Picture {
|
|
|
25
25
|
registry.register(this, { Render: this.Render, X, id: this.id }, this);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Set the picture's filter — a *property of the picture*, not an operation
|
|
30
|
+
* on its pixels. The server re-applies it every time the picture is
|
|
31
|
+
* sampled, so the cost is per composite and forever, not once.
|
|
32
|
+
*
|
|
33
|
+
* That is what makes it right for resampling (`'bilinear'` under a
|
|
34
|
+
* transform) and a trap for anything expensive: see `setBlurFilter`.
|
|
35
|
+
*/
|
|
28
36
|
setFilter(name, params) {
|
|
29
37
|
this.Render.SetPictureFilter(this.id, name, params);
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Hang a k×k gaussian `convolution` on the picture.
|
|
42
|
+
*
|
|
43
|
+
* **This re-convolves on every composite** — it is a filter, so the server
|
|
44
|
+
* runs the whole kernel each time the picture is drawn, and the pixels
|
|
45
|
+
* never change on the client's side of the wire. A picture blurred once and
|
|
46
|
+
* then composited each frame pays k² multiply-accumulates per pixel per
|
|
47
|
+
* frame: at radius 61 over 489×134 that is 244M per draw, which is a 1.6s
|
|
48
|
+
* hover on XQuartz and a 9s window repaint (issue #335).
|
|
49
|
+
*
|
|
50
|
+
* Reach for it when the blur really is per-draw and small. To blur
|
|
51
|
+
* something *once* and composite the result cheaply afterwards — a drop
|
|
52
|
+
* shadow, a cached soft edge — bake it instead with `blurCoverage` from
|
|
53
|
+
* ntk's entry point: two separable 1d passes (2k multiplies per pixel, not
|
|
54
|
+
* k²), run once, leaving a surface with the blur in its pixels and no
|
|
55
|
+
* filter of its own. See docs/surface.md#baking-a-blur.
|
|
56
|
+
*/
|
|
32
57
|
setBlurFilter(radius, sigma) {
|
|
33
58
|
if (radius === 0) {
|
|
34
59
|
return this.setFilter('convolution', [1, 1, 1]);
|
package/lib/shadow.js
CHANGED
|
@@ -32,6 +32,33 @@
|
|
|
32
32
|
// second pass leaves a surface with the blur already in its pixels, so the
|
|
33
33
|
// cached copy composites as a plain mask rather than re-running a kernel on
|
|
34
34
|
// every frame.
|
|
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
|
+
//
|
|
49
|
+
// ## What of this is public
|
|
50
|
+
//
|
|
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 —
|
|
61
|
+
// they are the 2d context's bookkeeping, not a primitive.
|
|
35
62
|
import { Surface } from './surface.js';
|
|
36
63
|
|
|
37
64
|
/**
|
|
@@ -48,11 +75,26 @@ import { Surface } from './surface.js';
|
|
|
48
75
|
* - `maxPixels` — the largest coverage surface built for one shadow. Beyond
|
|
49
76
|
* it the shadow is dropped rather than turning one drawing into a
|
|
50
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.
|
|
51
91
|
*/
|
|
52
92
|
export const DEFAULT_SHADOW_POLICY = {
|
|
53
93
|
cacheBytes: 4 << 20,
|
|
54
94
|
maxSigma: 32,
|
|
55
|
-
maxPixels: 8 << 20
|
|
95
|
+
maxPixels: 8 << 20,
|
|
96
|
+
scaleSigma: 4,
|
|
97
|
+
maxScale: 4
|
|
56
98
|
};
|
|
57
99
|
|
|
58
100
|
/** the policy for one app, merged over the defaults */
|
|
@@ -111,17 +153,110 @@ export function gaussianKernel1d(sigma, reach = shadowReach(sigma)) {
|
|
|
111
153
|
}
|
|
112
154
|
|
|
113
155
|
/**
|
|
114
|
-
*
|
|
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".
|
|
115
158
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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.
|
|
120
178
|
*/
|
|
121
|
-
export function
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
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;
|
|
125
260
|
const kernel = gaussianKernel1d(sigma);
|
|
126
261
|
const scratch = new Surface(app, { width, height, format: 'a8' });
|
|
127
262
|
const out = new Surface(app, { width, height, format: 'a8' });
|
|
@@ -141,6 +276,65 @@ export function blurCoverage(shape, sigma) {
|
|
|
141
276
|
return out;
|
|
142
277
|
}
|
|
143
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Blur an a8 coverage surface, returning a new one holding the result.
|
|
281
|
+
*
|
|
282
|
+
* The input is destroyed: a caller has no use for the sharp copy afterwards,
|
|
283
|
+
* and leaving it alive would double what the cache is holding. The output
|
|
284
|
+
* carries no filter of its own, so compositing it is an ordinary masked
|
|
285
|
+
* composite no matter how wide the blur was.
|
|
286
|
+
*
|
|
287
|
+
* Public, and the reason is that half of it: a *filter* is re-applied by the
|
|
288
|
+
* server on every composite, so a caller who blurs a picture with
|
|
289
|
+
* `setBlurFilter` and then caches it re-runs the kernel per frame — 244M
|
|
290
|
+
* multiply-accumulates for one 489×134 shadow at σ 10 (issue #335). This
|
|
291
|
+
* bakes instead. `shape` must be a coverage surface with the blur's reach as
|
|
292
|
+
* padding on all four sides, or the result ends in a straight line where the
|
|
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.
|
|
302
|
+
*/
|
|
303
|
+
export function blurCoverage(shape, sigma, { scale } = {}) {
|
|
304
|
+
if (!(sigma > 0) || !Number.isFinite(sigma)) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`blurCoverage: sigma must be a finite number above 0, got ${sigma}. ` +
|
|
307
|
+
'A canvas blur radius is a diameter, not a sigma — pass ' +
|
|
308
|
+
'shadowSigma(blur), which halves it and applies the policy cap. ' +
|
|
309
|
+
'σ = 0 is no blur at all: composite the coverage as it is.'
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (shape.format !== 'a8') {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`blurCoverage: needs a coverage surface, got format ${JSON.stringify(shape.format)}. ` +
|
|
315
|
+
"Draw the shape into new Surface(app, { width, height, format: 'a8' }) — " +
|
|
316
|
+
'the blur runs on alpha, so an argb32 surface would lose its colour.'
|
|
317
|
+
);
|
|
318
|
+
}
|
|
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;
|
|
329
|
+
const { width, height } = shape;
|
|
330
|
+
const k = resolveScale(shape, sigma, scale);
|
|
331
|
+
if (k === 1) return bakeBlur(shape, sigma, R);
|
|
332
|
+
|
|
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);
|
|
336
|
+
}
|
|
337
|
+
|
|
144
338
|
/**
|
|
145
339
|
* A shadow's coverage, from the cache when it has been seen before.
|
|
146
340
|
*
|