How it works
One instanced quad per primitive, an exact signed distance per pixel, coverage from screen-space derivatives and dithered OKLab gradient ramps.
Updated pixi-silk 1.0.1View as Markdown
This page walks through the shader, from the instance buffer to the colour of one pixel. You do not need it to use pixi-silk. Read it when you want to know why an edge, a join or a gradient looks the way it does. Anti-aliasing compared sets the approach next to MSAA and the other options.
One instance per primitive#
SilkGraphics is a Pixi Mesh that draws an instanced unit quad. Each primitive you add is one
instance of 44 floats, eleven vec4 attributes:
| Attribute | Holds |
|---|---|
aBounds | local bounds, including the stroke outset and three sigma of blur |
aShape0 to aShape2 | the shape’s parameters |
aMeta | primitive type, flags, stroke width, blur sigma |
aFill, aStroke | premultiplied colours |
aGrad, aPaint | gradient geometry, kind, atlas row and extend mode |
aDash | dash, gap, offset and stroke alignment |
aFx | spread, progressive blur, and the object index when batched |
The vertex shader expands the quad to the bounds plus an anti-aliasing margin. The margin is in device pixels. It uses the smallest singular value of the local-to-pixel Jacobian, so it stays right at any zoom and for rotated or skewed objects.
Exact distance, analytic coverage#
For every pixel, the fragment shader computes the signed distance d from the pixel centre to the
shape, negative inside. Each primitive kind has its own exact function: rounded boxes with
per-corner radii and superellipse corners, ellipses, arcs, sectors, capsules, hearts, stars,
triangles and free paths. The shader turns the distance into coverage with a box filter one device
pixel wide:
float px = sqrt(abs(dFdx(p).x * dFdy(p).y - dFdx(p).y * dFdy(p).x)); // one device pixel, in local units
float coverage = clamp(0.5 - d / px, 0.0, 1.0);
The one detail that matters is px. It comes from screen-space derivatives, so the filter is one
device pixel wide at 1000x zoom, when rotated, and inside filters and render textures. With blur,
the box filter becomes a gaussian, 0.5 - 0.5 * erf(d / (sigma * sqrt(2))). spread moves the
distance before the blur, which grows or shrinks the shape.
Fill and stroke in one pass#
A primitive with both a fill and a stroke is shaded once. The shader computes the coverage of the whole shape and of the stroke band, and splits each pixel into stroke and fill outside the stroke. No anti-aliased fill edge shows through an inner stroke.
Paths#
A stroked path is one distance field. Its segments sit in a float texture, and the shader takes the
nearest of their segments, caps and joins, the way Canvas 2D defines a stroke. Joins are miter (the
default, cut to a bevel past miterLimit), bevel or round. Every pixel is shaded once, so a
translucent path never blends twice where it overlaps itself.
Long subpaths are split into chunks of 32 segments. Each chunk also checks the segments of other chunks that come near it, found on the CPU. A pixel goes to the chunk with the nearest segment, so it is shaded once even where the path crosses itself.
A filled path stores its edges the same way. The distance is the nearest edge, and the sign comes from even-odd crossings. Tapered strokes keep one instance per segment, with the exact distance to a capsule whose ends have different radii.
Unions and shape operations#
union: true on a fill or stroke stores every shape of the path as a record in the path texture.
The shader takes the nearest distance, so overlapping shapes paint as one.
Shape operations use the same records, each with an op. The shader folds
them on a small stack. The minimum of two distances is a union and the maximum an intersection.
max(a, -b) subtracts b from a, and max(min(a, b), -max(a, b)) excludes. All four are exact on
the outline.
Areas#
Chart areas are split into vertical columns that tile exactly. Shared column edges get no anti-aliasing margin. Only the top and bottom edges do, so a translucent fill has no seams.
Hairlines#
A stroke thinner than minStrokePx (one device pixel by default) is drawn at that width, with its
alpha scaled down by the ratio. The amount of ink stays the same, and thin lines never break into
dots.
Gradients#
Stops are interpolated premultiplied, in OKLab by default, and baked into a shared 256x256 RGBA16F
atlas, one reference-counted row per ramp. The shader adds plus or minus half an 8-bit step of
interleaved gradient noise to gradients and blurs. That removes banding and leaves exact 8-bit
colours alone. dither: 0 turns it off.
Batching#
With enableSilkBatching(), consecutive objects copy their instances into one buffer. Each
instance carries its object index in aFx.w. The vertex shader reads that object’s matrix, tint and
path texel base from a small table texture. Performance covers when a batch
breaks.
Hit testing#
The same distance functions run on the CPU. containsPoint tests the real shape, holes and stroke
widths included. Interaction and motion shows it in use.
WebGPU#
The WGSL shader is a line-for-line port of the GLSL one. Renderers lists the small differences.
API reference: SilkGraphics, SilkGeometry