Letter A (SDF)
The previous letter A renders sharply at a fit-to-screen zoom but softens the
moment you look closer: Canvas2D’s rasterizer produces a coverage ramp only
one texel wide along the boundary, and once the texture is magnified past
1:1 that ramp spreads across many output pixels — a blur that no
fwidth-scaled threshold can rescue.
The fix is to change what the texture stores. Instead of coverage, put a
signed distance field there: every texel holds the (clipped) signed distance
in pixels to the nearest glyph edge — negative inside the letter, positive
outside, zero on the outline. Because distance is a smooth linear function
of position — not a sharp step — linear texture filtering gives back a
faithful in-between value at any sample point, and fwidth reports a
derivative that scales cleanly with zoom. Threshold at zero (which we still
encode as alpha 0.5, matching the raster mask’s semantics), and the edge
lands on the same one-output-pixel-wide AA band regardless of magnification.
The distance field is baked once per glyph, on the CPU, at the moment the
texture is uploaded — a new @sdf literate directive that the engine
recognizes alongside @glyph. The engine rasterizes the character exactly as
before, then post-processes with a 2D Felzenszwalb–Huttenlocher distance
transform: two O(N) sweeps of the 1D transform (down columns, then across
rows) give exact squared Euclidean distance. Do it twice — on the mask and on
its inverse — subtract, and you have a signed field. Pack it into 8-bit alpha
with a spread of ±24 pixels around the boundary, so the interesting
near-edge transition is well-resolved and anything beyond it saturates.
Shown here via #+attr_diff as a changeset from the previous letter A —
the shader’s thresholding line is unchanged; only the source changes from
@glyph to @sdf, and a cinematic zoom-and-pan tour is added on top so the
edge crispness is visible under heavy magnification. @loop wraps iTime to
the tour’s period so the animation repeats seamlessly.
--- letter-a-shader
+++ letter-a-sdf-shader
@@ -1,25 +1,37 @@
-// @glyph iGlyph "A"
+// @sdf iGlyph "A"
+// @loop 12.0
uniform sampler2D iGlyph;
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
// aspect-corrected coords centered on the middle; y in <-0.5, 0.5>
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
- // fit the square glyph texture into a 0.9-wide box centered on the screen
- vec2 gUV = uv / 0.9 + 0.5;
+ // one 12-second loop: an eased ping-pong zoom from a wide shot (0.6×) to
+ // a close inspection (7×) and back, panned on a Lissajous 1:2 so the
+ // camera visits the apex, both legs, and the crossbar over one cycle.
+ // Pan is scaled by 1/zoom so a fixed sweep tracks the same visible
+ // fraction of the texture regardless of magnification.
+ float t = iTime * 6.2831853 / 12.0;
+ float zoom = mix(0.6, 7.0, 0.5 - 0.5 * cos(t));
+ vec2 pan = vec2(sin(t) * 0.35, sin(t * 2.0) * 0.20) / zoom;
- // read the glyph alpha; outside the texture there is no letter
+ // apply zoom + pan, then map into the glyph's [0,1] atlas domain — dividing
+ // by zoom shrinks the visible slice, so higher zoom = tighter close-up
+ vec2 gUV = uv / (0.9 * zoom) + 0.5 + pan;
+
+ // read the SDF alpha; outside the texture there is no letter
float mask = 0.0;
if(gUV.x >= 0.0 && gUV.x <= 1.0 && gUV.y >= 0.0 && gUV.y <= 1.0){
mask = texture2D(iGlyph, gUV).a;
}
- // re-antialias at output resolution: threshold the coverage ramp with a
- // pixel-footprint-scaled smoothstep so the edge stays crisp at any zoom
+ // same fwidth-scaled threshold as before — but the texture now stores a
+ // distance field, so `fwidth(mask)` scales with zoom and the smoothstep
+ // window collapses back to one output pixel regardless of magnification
float aa = fwidth(mask);
float glyph = smoothstep(0.5 - aa, 0.5 + aa, mask);
vec3 col = mix(vec3(0.0), iAccent, glyph);
fragColor = vec4(col, 1.0);
}
|
|
// @glyph iGlyph "A"
uniform sampler2D iGlyph;
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
// aspect-corrected coords centered on the middle; y in <-0.5, 0.5>
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
// fit the square glyph texture into a 0.9-wide box centered on the screen
vec2 gUV = uv / 0.9 + 0.5;
// read the glyph alpha; outside the texture there is no letter
float mask = 0.0;
if(gUV.x >= 0.0 && gUV.x <= 1.0 && gUV.y >= 0.0 && gUV.y <= 1.0){
mask = texture2D(iGlyph, gUV).a;
}
// re-antialias at output resolution: threshold the coverage ramp with a
// pixel-footprint-scaled smoothstep so the edge stays crisp at any zoom
float aa = fwidth(mask);
float glyph = smoothstep(0.5 - aa, 0.5 + aa, mask);
vec3 col = mix(vec3(0.0), iAccent, glyph);
fragColor = vec4(col, 1.0);
}