Surface
August 26, 2026 · View on GitHub
A Surface is something you draw once and composite many times: a
pixmap, its XRender Picture, and enough of the
Image shape that ctx.drawImage takes it as a source.
import { createClient, Surface, SvgView } from 'ntk';
const icon = new SvgView(null).setSvg(iconMarkup);
const surface = new Surface(app, { width: 20, height: 20 });
surface.render((ctx) => icon.draw(ctx, 0, 0, 20, 20));
for (const { x, y } of cells) ctx.drawImage(surface, x, y); // one composite each
Image already does this for decoded PNG/JPEG pixels uploaded from the
client. A Surface is the same contract for pixels the server drew, so
nothing crosses the wire but the composite — which is the point when the
drawing is expensive to produce and cheap to copy. Rendering an icon means
walking a parsed document, building path geometry, flattening it and running
the stroker; compositing the result is one request.
Coverage surfaces
format: 'a8' stores coverage instead of colour. Drawn through
drawImage, the surface becomes the mask and the context's current
fillStyle becomes the source:
const mask = new Surface(app, { width: 20, height: 20, format: 'a8' });
mask.render((ctx) => icon.draw(ctx, 0, 0, 20, 20, { color: '#fff' }));
ctx.fillStyle = theme.fg; ctx.drawImage(mask, x, y);
ctx.fillStyle = theme.accent; ctx.drawImage(mask, x, y + 24); // same surface
One rendered copy then serves every colour it is ever asked for, so a hover, a disabled state and a theme change all reuse it instead of each needing their own. This is the trick the glyph cache already runs on text, applied to arbitrary drawings — and it is a quarter of the storage, one byte per pixel instead of four.
Not every drawing qualifies: a document with two colours in it, or a
gradient, has colours of its own that a mask cannot carry.
SvgView.paintKind
answers that question for SVG documents.
globalAlpha still applies — it folds into the source colour rather than the
mask, since the mask slot is taken.
Baking a blur
blurCoverage(coverage, sigma) blurs an a8 surface and hands back a new one
with the blur in its pixels:
import { blurCoverage, shadowReach, shadowSigma } from 'ntk';
const sigma = shadowSigma(blurRadius); // a canvas/CSS blur is a diameter
const pad = shadowReach(sigma); // how far coverage can spread: ceil(3σ)
const shape = new Surface(app, {
width: w + 2 * pad,
height: h + 2 * pad,
format: 'a8'
});
shape.render((c) => {
c.fillStyle = '#fff'; // white on transparent: every pixel is its own alpha
c.roundRect(pad, pad, w, h, radius);
c.fill();
});
const blurred = blurCoverage(shape, sigma); // `shape` is destroyed
ctx.fillStyle = shadowColor;
ctx.drawImage(blurred, x - pad, y - pad); // an ordinary masked composite
Shadows are built exactly this way, and if the four
ctx.shadow* properties cover what you need, reach for those first — they do
the padding, the clipping and the caching too. blurCoverage is for a caller
that draws its own shapes and keeps its own paint cache (a widget toolkit
drawing a box-shadow, say) and only wants the blur.
A wide blur runs small. Past σ 8 blurCoverage shrinks the coverage by
2 or 4 first, blurs at sigma / scale, and resolves the result back to the
size you handed it — scale off the kernel and scale² off the area it runs
over, so scale³ off the work. A gaussian carries no detail finer than about
σ/2 px, which is why this is nearly free visually: the difference from an
exact blur is at most three levels of 8-bit alpha, on a shape whose own edges
are already soft. It is worth a great deal in time — the two widest shadows
on react-x11's configurator, 55.5M and 31.2M multiply-accumulates, become
0.9M and 4.0M — which measured against XQuartz's software RENDER is 1.24s of
a first paint against 37ms (issue #338; scripts/bench-shadow-blur.mjs is
the measurement, on whatever server you point it at).
Nothing about the call changes: the surface that comes back is the size it always was, and carries no filter, so compositing it is the same plain mask it was before. What changes is tunable per app:
app.shadowPolicy = { scaleSigma: 4, maxScale: 4 }; // the defaults
blurCoverage(shape, sigma, { scale: 1 }); // or per call: exact
scaleSigma is the σ a reduced-scale blur may not fall below — the scale is
the largest power of two keeping sigma / scale at or above it, so σ under
2 * scaleSigma is not shrunk at all. That floor is what bounds the error,
and it is the reduced σ that matters rather than how far you came down to it:
three alpha levels at σ 4, four at 3, seven at 2. maxScale: 1 (or
{ scale: 1 }) blurs everything at full resolution.
Drawing small yourself is better still, when you can: blurScale(sigma)
names the scale, so a caller that owns the geometry can draw the shape into a
surface 1 / scale the size — padding scaled with it — blur at
sigma / scale, and composite through a 1 / scale picture transform, which
XRender resamples in the same pass it was already compositing. That skips the
shrink, the full-size allocation and the resolve; the cost is that the mask
is resampled on every composite rather than once.
const scale = blurScale(sigma);
const small = new Surface(app, {
width: Math.ceil((w + 2 * pad) / scale),
height: Math.ceil((h + 2 * pad) / scale),
format: 'a8'
});
small.render((c) => {
c.scale(1 / scale, 1 / scale);
c.fillStyle = '#fff';
c.roundRect(pad, pad, w, h, radius);
c.fill();
});
const blurred = blurCoverage(small, sigma / scale); // already small: scale 1
ctx.drawImage(blurred, x - pad, y - pad, blurred.width * scale, blurred.height * scale);
Bake, don't filter. The other way to blur a picture is
picture().setBlurFilter(size, sigma) — and a picture's filter is a property
of the picture, so the server re-runs the whole kernel every time it is
composited. Blur once, cache the surface, draw it each frame, and you have
bought a k×k convolution per frame forever: a 61×61 kernel over a 489×134
surface is 244M multiply-accumulates per draw (issue #335). blurCoverage
runs two separable 1d passes once — 2k multiplies per pixel instead of k² —
and the surface it returns carries no filter at all, so compositing it costs
what compositing any mask costs.
The rest of what is easy to get wrong:
- Pad by the blur's full reach on all four sides. A convolution samples
outside the picture, where RepeatNone reads transparent, so a shape drawn
flush to the edge ends in a straight line where the kernel ran out of
pixels.
shadowReach(sigma)is that padding — the gaussian truncated at 3σ, which leaves 0.3% of its weight outside, below one step of 8-bit coverage sigmais not a blur radius. A canvasshadowBlurand a CSSbox-shadowblur are diameters: σ is half of them.shadowSigma(blur)does that halving and appliesmaxSigmafromapp.shadowPolicy, the cap that keeps a kernel (and the request carrying it) from growing without bound- The input is destroyed — the sharp copy has no use afterwards, and
keeping it alive would double what a cache holds. The returned surface is
the caller's, to
destroy()when its cache evicts it - A blurred shadow only reaches full
shadowColorwhere the shape casting it is wide compared with σ; the numbers, and how to write a test that does not assert an exact colour, are in How strong a shadow gets
gaussianKernel1d(sigma[, reach]) is exported alongside them for a caller
running the passes itself — normalized over the truncated kernel, which is
what keeps a flat interior at full coverage.
Scrolling and panning: copyWithin
A widget that keeps its content in a retained surface — a terminal grid, a log view, a minimap, a panning chart — scrolls the way terminals always have: copy the band that survives the shift, then repaint only the sliver the shift exposed.
// scroll the whole grid up one 18px row
if (grid.copyWithin({ x: 0, y: 0, width: grid.width, height: grid.height }, 0, -18)) {
drawRow(lastRow); // only the newly exposed row
} else {
drawAllRows(); // nothing survived the shift
}
ctx.drawImage(grid, 0, 0);
surface.copyWithin(src, dx, dy) shifts the pixels of src
({x, y, width, height}, surface coordinates) by (dx, dy) in place,
server-side — one CopyArea of the surviving band. The overlap is safe:
pixmap contents cannot be occluded, and the server fetches the source region
before storing. The copy is issued in-order with whatever the caller draws
next on the same connection, and goes out with a shared
graphicsExposures: 0 GC — one per app and depth, created on first use —
so it never emits exposure events.
Returns false — having done nothing, so the caller just repaints src as
it would have anyway — when dx/dy are fractional (a sub-pixel shift
changes every pixel) or both zero, when nothing of src survives the shift
after clamping to the surface, or on a destroyed surface.
This is wnd.scrollRegion
for an offscreen surface, minus the damage bookkeeping — a pixmap has no
backing store or present path, so compositing the surface afterwards is the
caller's normal job.
API
new Surface(app, { width, height, format })—formatis'argb32'(default) or'a8'. Sizes must be positive integers. Contents start transparentsurface.width/surface.height/surface.format/surface.depthsurface.bytes— server-side storage, which is what a cache budgets against:w * h * 4for argb32,w * hfor a8surface.render(fn)— callfn(ctx)with a 2d context on the surface, in surface-local coordinates where(0, 0)is its top-left corner. The context is created for the call and destroyed after itsurface.getContext('2d')— a context the caller owns, and owes adestroy(). Use this instead ofrender()for many draws into one surfacesurface.clear()— reset every pixel to transparentsurface.copyWithin(src, dx, dy)→ boolean — shift the pixels ofsrcin place by an integer(dx, dy), server-side; see Scrolling and panningsurface.picture(app)— the server-side Picture, mirroringImage.picture(app). Throws for a different connection- a surface is also what
ctx.createPatterntiles: drawn once, then repeated across a fill by the server. A background grid, a checkerboard or a hatch is a tile-sized surface and one composite, instead of a pane-sized coverage mask per frame surface.destroy()/Symbol.dispose— free the pixmap and the picture. Both carry finalizers, so a dropped surface still releases
The blur primitives, imported from ntk itself rather than hung off a
surface (see Baking a blur):
blurCoverage(coverage, sigma[, { scale }])→Surface— blur an a8 surface into a new a8 surface of the same size, as two separable passes run once. The input is destroyed; the output carries no filter, so compositing it is an ordinary masked composite. A wide blur is run at reduced scale (above);scaleoverrides the policy's choice and1is the exact kernel. Throws whensigmais not a finite number above zero, whenscaleis under 1, or when the surface is not'a8'blurScale(sigma[, policy])→ number — the power-of-two scale that blur runs at: 1 (full resolution), 2 or 4. For a caller sizing its own coverage surfaceshadowSigma(blur[, policy])→ number — the σ a canvas/CSS blur diameter names, which is half of it, capped at the policy'smaxSigmashadowReach(sigma)→ number —ceil(3σ): the kernel's half-width, and therefore the padding a coverage surface needs on each sidegaussianKernel1d(sigma[, reach])→ number[] — the normalized 1d kernel,2 * reach + 1taps wideDEFAULT_SHADOW_POLICY— the defaultsapp.shadowPolicymerges over (cacheBytes,maxSigma,maxPixels,scaleSigma,maxScale; see Shadows)
Drawing sources in general
drawImage does not check types: it takes anything that knows its own size
and can hand over a Picture.
ctx.drawImage({ width, height, picture: (app) => somePicture }, x, y);
That is the whole contract — width, height, picture(app), plus an
optional format: 'a8' to be treated as coverage. A caller keeping its own
cache of rendered things can satisfy it without ntk knowing the type.
Context lifetime
Contexts are not free: each one holds a GC and a Picture (fill colours are
cached on the App and shared by every context on the connection, so a
colour costs its one server object no matter how many contexts use it). A
context bound to a window normally lives as long as the window, so this
rarely mattered — but creating them per surface makes it matter, which is
why RenderingContext2d now has destroy() (and Symbol.dispose).
Surface.render() calls it for you.
using ctx = surface.getContext('2d'); // or ctx.destroy() when done