Windfoil: Closed-Form Coverage for Real-Time and Differentiable Vector Graphics
Matt DesLauriers
cs.GR, cs.CV
2026-10-02
Windfoil computes the box-filtered winding of quadratic Bezier contours in closed form: coverage error about 13× below Skia, and 3.2 ms per Kodak fitting step.
A vector fill is a winding test. Cast a ray to the right and count signed boundary crossings. One test per pixel turns curves and diagonals into stairs. Antialiasing is usually a second system: multisampling, an approximate coverage, or a distance field baked into a texture. GPU rasterizers that have to hold a frame rate often flatten curves to line segments, or estimate coverage from a handful of rays.
Differentiable rendering gets stuck on the same test. The inside/outside answer is constant almost everywhere, so the gradient is zero and control points do not move. DiffVG switches to Monte Carlo samples or a smooth approximation. Bézier Splatting replaces each curve with a cloud of Gaussians. The coverage used while fitting is then a different model from the one Skia uses after export.
Windfoil evaluates the box-filtered winding number of closed quadratic Bézier contours in closed form, and that same value is what both the display and the backward pass see.
A shape is one or more closed, directed chains of quadratic Béziers. Around each pixel centre is an axis-aligned box, one pixel on a side unless a softer edge is requested. Average the winding number inside the box, apply the fill rule, multiply by opacity, and composite in painter's order.
Green's theorem turns the area integral into a boundary integral. On a thin horizontal strip, an upward crossing adds the width from the left edge to the curve, and a downward crossing subtracts it. Each quadratic is split on the CPU at its x and y extrema into at most three pieces, monotone on both axes. A pixel keeps only the pieces that meet its vertical span and solves a quadratic for the left and right edge crossings. Mass to the left contributes nothing. Mass to the right contributes full width times height. The piece that actually overlaps the box is a polynomial with a closed-form integral.
The fill rule runs after the average. When winding changes by at most one inside the footprint, that matches filtering an already filled shape. Self-crossings and overlapping contours diverge, and the bad pixels are the ones whose footprint covers the conflict. Nonzero fill clamps the absolute value at 1. Even-odd fill measures distance to the nearest even integer.
The display path is a fragment shader. Monotone pieces are filed into horizontal row bands, and a fragment reads only the bands it touches. One band axis is enough, because the integral already sweeps horizontally. Slug keeps both a horizontal and a vertical ray. Once a shape's box falls under about four pixels in width and height, the shader switches to a precomputed ink profile.
The differentiable path cannot cull with quads, so it bins the scene into tiles and walks likely shapes in draw order. The forward pass composites coverage. The default loss is mean squared error. The backward pass is an analytic vector-Jacobian product that returns gradients for control points, colours, and opacities. An outside objective can drive the same pass if it supplies an image-space gradient. The CLIP sketches do this with a Python server and cosine distance. Curve preprocessing stays on the CPU and reruns every step as the geometry moves. With a positive filter width, coverage changes continuously as the curves move.
The corpus reference is a binary raster of the raw curves, box-downsampled at 24×. Slug here is a WebGPU port of the public shaders, not the commercial game build. Skia is reached through a Node binding. The minification shortcut is off.
| Method | Mean absolute coverage error | Versus Windfoil |
| Windfoil | 1.2×10⁻⁴ | Same range as the reference's sampling error |
| Slug | 1.1×10⁻³ | about 9× |
| Skia | 1.6×10⁻³ | about 13× |
In Figure 2, edges of the Lato @ stay close to the reference. A self-crossing rosette does not: the vertical seam where winding cancels is drawn as a hole by Windfoil and by Slug, while Skia and Figma keep it. That crop reference is a 64×64 point sample per pixel, separate from the 24× downsample used for the table.
Against Slug, magnified frames are 1.2–2.6× faster, mid-zoom frames are 1.2–2.8× slower, and shapes under about four device pixels are 5–6× faster. On an M2 MacBook Air, a few thousand glyphs stay inside a 60 fps budget at every zoom tested. One stress scene is the Ghostscript tiger: 304 overlapping shapes and about 22,000 quadratics.
Fitting ran on an RTX 2000 Ada. Windfoil uses Node.js and Dawn over Vulkan. Both baselines use PyTorch and CUDA. Windfoil and DiffVG share the initial geometry, colour, opacity, and eight quadratic segments per shape. Bézier Splatting keeps its own initialisation. DiffVG optimises at 2×2 samples per pixel and is scored at 8×8. PSNR is converted from mean squared error. Times exclude setup and file output.
| Setup | Windfoil | DiffVG | Bézier Splatting |
| Kodak mean, 768×512, 512 shapes, 800 steps | 26.60 dB, 2.57 s, 3.2 ms/step | 26.41 dB, 269 s, 336 ms/step | 22.80 dB, 23.0 s, 29 ms/step |
| Färlev, 512×288, 4096 shapes, 800 steps | 26.77 dB, 10.1 s | 21.31 dB, 1658 s | 22.61 dB, 132 s |
| Färlev, 4096×2304, 256 shapes, 60 s | 20.96 dB, 2421 steps | 19.96 dB, 34 steps | 19.77 dB, 893 steps |
| Färlev, 512×288, 4096 shapes, 10k steps | 27.88 dB, 149 s | not run | 23.02 dB, 1683 s |
Parity speed-up divides the baseline's full optimise time by the time Windfoil first matches that final score. On Kodak the median is 243× versus DiffVG on the 20 of 24 images where the score is reached. The other four never catch DiffVG. Versus Bézier Splatting it is 196× on all 24. At 4096 shapes and 800 steps the gaps are +5.46 dB and +4.16 dB. On the 10,000-step schedule, 512 shapes gain only 0.53 dB and 4096 shapes gain 4.86 dB. At 4096×2304 and 800 steps, 10,000 shapes reach 23.23 dB in 65.6 s and 50,000 shapes reach 25.46 dB in 176 s. The baselines were not run at those counts.
Figure 4 gives each engine 300 seconds from process launch, startup included. On Färlev with 512 shapes, 21.4 dB arrives at 1.0 s, 38 s, and 94 s. The runs end at 22.0, 21.8, and 21.5 dB after about 115k, 1,500, and 10.8k steps. CLIP sketches are reported only as a lower loss than DiffVG at 64, 128, and 256 shapes, with additional runs at 50,000 and 100,000 shapes. No loss values are printed. Print export was tiled up to 30,000×30,000. Pixels do not exchange coverage, so tile borders do not grow a seam.
Design tools need a crisp realtime rasterizer. Vectorisation and text-to-sketch need gradients on control points. Those used to be two approximations, and the edge the loss trained was not the edge that landed on screen. Here both are the same box filter.
On Kodak, 512 shapes and 800 steps are 0.19 dB above DiffVG. That is a tie. The step time, 3.2 ms against 336 ms, is what makes the fit interactive in a browser. At 4096 shapes DiffVG falls to 21.31 dB. The gap shows up when the scene gets large. Bézier Splatting's 29 ms step is still about nine times slower on that Kodak setup, and it does not share the initialisation, so the extra 3.80 dB is not a pure comparison of integrals.
Filter width is an input. Annealing from a wide kernel to a single pixel is coarse-to-fine fitting. Tent and cubic B-spline kernels are quadrature on top of the box integral. They cost more and are there to cut moiré in dense line work. One WebGPU codebase runs in a browser, in Node via Dawn, and in Deno.
Filling after the average equals a box filter of the filled shape only when winding changes by at most one inside the pixel. Self-crossing seams become holes. Figure 2 shows one. Extra samples inside the fragment, or cleaning the contours first, can shrink that error. The reported numbers do neither. The footprint is an axis-aligned box: exact under translation and axis-aligned scale, approximate under rotation, shear, and perspective. Filtering each shape before compositing also disagrees with filtering the flattened image where edges meet.
Only closed quadratic contours are native. Strokes and cubic Béziers have to be converted first. The differentiable renderer has no clipping masks, gradients, or textures. The non-box kernels are fixed-tap quadrature. Gradients accumulate with fixed-point integer atomics, which makes the sum independent of order and also quantises it, with a risk of overflow. Preprocessing is still on the CPU. The 1.2×10⁻⁴ figure turns the minification guard off. The Slug numbers come from a reimplementation. Every fitting run is a single seed, and the table does not claim statistical significance. The residual already sits next to the reference's own sampling noise, so the 13× is against Skia, not against a perfect ground truth.
Fifty thousand shapes at 4096×2304 take 176 seconds for 800 steps. The abstract's interactive rates for tens of thousands of shapes are not that 4K setting. Section 8 says the closed form was first proposed by Claude Code (Fable) during a roughly one-hour directed search, then implemented and checked by the author. The literature pass did not find the same combination.