struct Camera {
center: vec2<f32>,
zoom: f32,
fade: f32,
viewport: vec2<f32>,
_pad1: vec2<f32>,
};
@group(0) @binding(0) var<uniform> cam: Camera;
// Half-width of the antialiasing ramp, in pixels. The quad is grown by this
// much on each side so the coverage falloff has geometry to run on; without
// the growth the ramp would be clipped by the stroke's own hard boundary and
// the edge would stay as jagged as before.
const AA_PAD: f32 = 1.0;
// Never rasterize a stroke thinner than this many pixels. Below roughly half a
// pixel a line stops being a continuous run of partially-covered pixels and
// starts breaking into a dotted crawl that shimmers as the camera moves. We
// hold the width at the floor and scale alpha down by however much we widened,
// which keeps the apparent ink density right while killing the shimmer.
const MIN_HALF_PX: f32 = 0.5;
// unit quad in (t, s): t along the edge 0..1, s across -1..1
var<private> CORNERS: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2<f32>(0.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
);
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) opacity: f32,
// Signed distance across the stroke in PIXELS, interpolated. Pixels (not the
// old normalized -1..1) because the AA ramp must be one pixel wide at every
// zoom, and only a pixel-space coordinate makes that a constant.
@location(2) across_px: f32,
// The stroke's half-width in pixels, after the sub-pixel floor. Flat: it is
// constant per edge, and interpolating it would bend the ramp.
@location(3) @interpolate(flat) half_px: f32,
};
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@location(0) a: vec2<f32>,
@location(1) b: vec2<f32>,
@location(2) color: vec4<f32>,
@location(3) width: f32,
@location(4) opacity: f32,
) -> VsOut {
let c = CORNERS[vi];
let d = b - a;
let len = length(d);
let dir = select(vec2<f32>(1.0, 0.0), d / len, len > 1e-5);
let normal = vec2<f32>(-dir.y, dir.x);
let half_px_true = width * 0.5 * cam.zoom;
let half_px = max(half_px_true, MIN_HALF_PX);
// Only ever fades (never brightens): half_px_true > MIN_HALF_PX leaves this
// clamped at 1.0, so ordinary strokes are untouched.
let thin_fade = min(half_px_true / MIN_HALF_PX, 1.0);
// Grow across by the ramp, converting the pixel pad back to world units so
// the expansion is a constant pixel width at any zoom.
let out_px = half_px + AA_PAD;
let world = mix(a, b, c.x) + normal * (out_px / cam.zoom) * c.y;
let rel = (world - cam.center) * cam.zoom;
let clip = vec2<f32>(rel.x / (cam.viewport.x * 0.5), rel.y / (cam.viewport.y * 0.5));
var out: VsOut;
out.clip = vec4<f32>(clip, 0.0, 1.0);
out.color = color;
out.opacity = opacity * thin_fade;
out.across_px = c.y * out_px;
out.half_px = half_px;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
// Box-filter coverage: the fraction of a one-pixel-wide sample straddling
// this fragment that falls inside the stroke. Exact for a straight edge —
// which every segment of a line quad is — and costs two adds and a clamp.
let coverage = clamp(in.half_px - abs(in.across_px) + 0.5, 0.0, 1.0);
if (coverage <= 0.0) { discard; }
return vec4<f32>(in.color.rgb, in.color.a * in.opacity * coverage * cam.fade);
}