Skip to content
pixi-silk
GitHub repository
Architecture

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:

AttributeHolds
aBoundslocal bounds, including the stroke outset and three sigma of blur
aShape0 to aShape2the shape’s parameters
aMetaprimitive type, flags, stroke width, blur sigma
aFill, aStrokepremultiplied colours
aGrad, aPaintgradient geometry, kind, atlas row and extend mode
aDashdash, gap, offset and stroke alignment
aFxspread, 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