Tileset
The previous page bakes a whole string into one atlas laid out for reading. Change the layout to a grid instead — one character per cell — and the atlas becomes a lookup table: pass an integer index, get a glyph. That turns a fragment shader into a tile-based renderer, with everything the term suggests: partition the screen into cells, decide independently what each cell shows, and let the choice function itself become a visual layer.
A new @tileset NAME "CHARS" directive builds it. The engine measures
CHARS, allocates a 2048² atlas divided into 8×8 cells of 256 pixels each,
draws each character at 90% cell height (matching single-glyph rendering, so
neighboring glyphs pack tightly on screen), then runs the same 2D
Felzenszwalb distance transform over the whole canvas. Monospace glyph
widths are ~60% of font size, so even at 90% cell height every character
sits inside a margin comfortably wider than SPREAD — each field saturates
at 0 well before hitting a neighbor, so cell A and cell B stay independent
even though they share one texture.
Two more companion uniforms come with it: <NAME>Grid (a vec2 of grid
dimensions) and <NAME>Count (the number of filled cells), auto-set by the
engine when the shader declares them. The shader uses them to decode an
integer character index into an (atlasCol, atlasRow) pair and then samples
the atlas at cell-local UV, so the same LINEAR-filter + fwidth-threshold
math keeps every glyph sharp regardless of screen cell density.
The tileset is the shape layer. What character sits where — the choice function — is a second layer we can layer any pattern onto:
- a hash of
(cell, floor(iTime * rate))gives a shuffled flicker; - with a per-cell random rate (below), each cell ticks on its own clock — some flip several times a second, others hold for seconds at a time — and the grid reads as living text rather than a single global shuffle;
- a hash of
(cell + iTime * drift)gives a smooth character-space drift; floor(length(cell − center) − iTime * speed)indexed into the set gives radiating rings;floor((cell.y + iTime * fallSpeed[cell.x]) mod length)gives the Matrix rain (with real glyphs now, not procedural SDFs).
A third layer — brightness, color, hue — can then modulate on top: this page adds a slow diagonal sine wave that lights the grid unevenly, so the choice of character AND the choice of highlight are two independent textures over the same lookup.
Shown here via #+attr_diff as a changeset from the previous Hello, world!
page. The fit-contain math is gone; grid indexing and a hash-based character
choice per cell are in.
--- hello-world-shader
+++ letters-tileset-shader
@@ -1,33 +1,42 @@
-// @sdf iGlyph "Hello, world!"
-uniform sampler2D iGlyph;
-uniform float iGlyphAspect; // atlas width / height, auto-set by the engine
+// @tileset iTiles "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!,@#$%^&*()-+="
+uniform sampler2D iTiles;
+uniform vec2 iTilesGrid; // (cols, rows) laid out in the atlas
+uniform float iTilesCount; // number of cells actually filled
+
+float hash(vec2 p){
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
+}
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;
+ // Partition the screen into fixed 40-pixel square cells. Row and column
+ // indices come from the integer part of fragCoord/CELL_PX; the local UV
+ // inside the cell is the fractional part.
+ const float CELL_PX = 40.0;
+ vec2 cell = floor(fragCoord / CELL_PX);
+ vec2 uvInCell = fract(fragCoord / CELL_PX);
- // fit-contain the atlas rectangle inside the screen with a 10% margin.
- // For "Hello, world!" the atlas is much wider than the screen, so this
- // resolves as fit-to-width — the string spans 90% of the reading column
- // and sits as a slim horizontal band centered vertically.
- float scrAspect = iResolution.x / iResolution.y;
- vec2 fit = iGlyphAspect > scrAspect
- ? vec2(0.9 * scrAspect, 0.9 * scrAspect / iGlyphAspect)
- : vec2(0.9 * iGlyphAspect, 0.9);
- vec2 gUV = uv / fit + 0.5;
-
- // 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;
- }
-
- // same fwidth-scaled threshold as before — no zoom, but any future scaling
- // or panning of this shader will keep the edge one output pixel wide
- float aa = fwidth(mask);
+ // Choice function: pick a character index per cell, on a *per-cell* random
+ // period so each cell ticks on its own clock — some flip several times a
+ // second, others hold for seconds at a time. Swap the hash for any
+ // (cell, time) function to get radiating rings, falling scrolls, waves…
+ float period = 0.15 + hash(cell + vec2(1.7)) * 2.5;
+ float phase = hash(cell + vec2(4.3)) * period;
+ float stepT = floor((iTime + phase) / period);
+ float idx = floor(hash(cell + vec2(stepT * 0.317)) * iTilesCount);
+
+ // Decode idx → atlas (col, row), sample the atlas at cell-local UV.
+ // LINEAR filtering + SDF threshold keeps each glyph one-pixel sharp.
+ vec2 atlasCell = vec2(mod(idx, iTilesGrid.x), floor(idx / iTilesGrid.x));
+ vec2 atlasUV = (atlasCell + uvInCell) / iTilesGrid;
+ float mask = texture2D(iTiles, atlasUV).a;
+ float aa = fwidth(mask);
float glyph = smoothstep(0.5 - aa, 0.5 + aa, mask);
- vec3 col = mix(vec3(0.0), iAccent, glyph);
+ // Highlight layer: a slow diagonal sine over the grid modulates the fill
+ // brightness. Independent of the choice function, composable on top.
+ float wave = 0.5 + 0.5 * sin(cell.x * 0.15 + cell.y * 0.08 - iTime * 1.5);
+
+ vec3 col = mix(vec3(0.0), iAccent, glyph) * (0.3 + 0.7 * wave);
fragColor = vec4(col, 1.0);
}
|
|
// @sdf iGlyph "Hello, world!"
uniform sampler2D iGlyph;
uniform float iGlyphAspect; // atlas width / height, auto-set by the engine
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-contain the atlas rectangle inside the screen with a 10% margin.
// For "Hello, world!" the atlas is much wider than the screen, so this
// resolves as fit-to-width — the string spans 90% of the reading column
// and sits as a slim horizontal band centered vertically.
float scrAspect = iResolution.x / iResolution.y;
vec2 fit = iGlyphAspect > scrAspect
? vec2(0.9 * scrAspect, 0.9 * scrAspect / iGlyphAspect)
: vec2(0.9 * iGlyphAspect, 0.9);
vec2 gUV = uv / fit + 0.5;
// 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;
}
// same fwidth-scaled threshold as before — no zoom, but any future scaling
// or panning of this shader will keep the edge one output pixel wide
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);
}