
Learn Creative Coding (#34) - Shader Patterns: Stripes, Grids, and Repetition
Learn Creative Coding (#34) - Shader Patterns: Stripes, Grids, and Repetition

Last episode we drew circles, boxes, rounded rectangles, and weird blobby eye things using signed distance functions. We had smoothstep for anti-aliased edges, boolean operations for combining shapes, and smin for that gorgeous organic merging effect. All per-pixel. All pure math.
But every example was one shape, or maybe a few shapes, placed deliberately at specific positions. Real generative art rarely works like that. You want patterns. Fields of shapes. Infinite grids of repeating elements. Stripes that tile across the canvas. Checkerboards. Polka dots. The kind of visual density that would take a thousand draw calls in Canvas but on the GPU comes from one line of math.
The key to all of it is one GLSL function: fract().
What fract() actually does
fract(x) returns the fractional part of x. So fract(2.7) is 0.7. fract(5.3) is 0.3. fract(0.9) is 0.9. Basically it strips off everything before the decimal point and gives you just the remainder.
Why is this useful? Because if you apply fract() to coordinates that go from 0 to, say, 10 -- the output repeats 10 times. Each time the input crosses an integer boundary, the fractional part resets to zero and starts over. One cycle of 0-to-1, ten times. That's repetition. That's tiling.
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// repeat 8 times across the canvas
float stripe = fract(uv.x * 8.0);
gl_FragColor = vec4(vec3(stripe), 1.0);
}
Eight vertical gradient bands. Each one goes from black (0) to white (1) and resets. That's fract at work. Every time uv.x * 8.0 passes an integer, the fractional part wraps and we get a new band. One multiplication, one function call, infinite repetition.
To turn those gradients into hard stripes, add step():
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float stripe = step(0.5, fract(uv.x * 8.0));
gl_FragColor = vec4(vec3(stripe), 1.0);
}
step(0.5, fract(...)) returns 0 when the fractional part is below 0.5 and 1 when it's above. Hard black/white stripes. Eight of them. The 8.0 controls frequency -- change it to 20 and you get twenty stripes. Change it to 3 and you get three.
This is the fundamental building block for every pattern in this episode. Multiply, fract, threshold.
Controlling stripe width
Equal-width black and white stripes aren't that interesting. What if you want thin white lines on a black background? Adjust the threshold:
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float f = fract(uv.x * 12.0);
float stripe = smoothstep(0.42, 0.44, f) - smoothstep(0.56, 0.58, f);
gl_FragColor = vec4(vec3(stripe), 1.0);
}
Two smoothstep calls: one turns on at 0.43 and the other turns off at 0.57. The difference creates a band that's "on" only between those two points. That's a thin stripe -- about 14% of each cell width. Change those threshold values and the stripe gets thinner or wider.
The reason I'm using smoothstep instead of step is anti-aliasing. step gives pixel-perfect crisp edges which look jagged on anything that isn't perfectly axis-aligned. smoothstep with a tiny range (0.42 to 0.44, that's about 1-2 pixels of transition) gives smooth edges. Same trick from episode 33 with SDFs.
Horizontal and diagonal stripes
Vertical stripes use uv.x. Horizontal stripes use uv.y. Diagonal stripes? Use both:
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// diagonal stripes -- angle depends on the x/y ratio
float diagonal = fract((uv.x + uv.y) * 10.0);
float stripe = step(0.5, diagonal);
gl_FragColor = vec4(vec3(stripe), 1.0);
}
uv.x + uv.y gives you lines at 45 degrees. The iso-value lines of x + y = constant are diagonal from top-left to bottom-right. Applying fract to that sum gives you repeating diagonal bands. Change the ratio and the angle changes: uv.x * 2.0 + uv.y gives shallower diagonals. uv.x + uv.y * 3.0 gives steeper ones.
For arbitrary angle rotation, use a proper rotation matrix like we set up in episode 33:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 rotate(vec2 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return vec2(p.x * c - p.y * s, p.x * s + p.y * c);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// rotate the UV coordinates, then stripe on x
vec2 ruv = rotate(uv, u_time * 0.3);
float stripe = step(0.5, fract(ruv.x * 10.0));
gl_FragColor = vec4(vec3(stripe), 1.0);
}
Rotating stripes. The pattern slowly spins. We're not rotating the stripes -- we're rotating the coordinate space and then applying the same stripe logic. Same "transform the space, not the shape" principle from the SDF episode. Works for everything.
The checkerboard
Checkerboards are one of the most classic patterns in generative art. And the math is beautifully simple:
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float n = 8.0; // grid size
float checker = mod(floor(uv.x * n) + floor(uv.y * n), 2.0);
gl_FragColor = vec4(vec3(checker), 1.0);
}
floor(uv.x * n) snaps the x coordinate to integer cells. Same for y. Adding them together and taking mod 2 gives you 0 or 1 depending on whether the sum is even or odd. Even cells are black, odd cells are white. Checkerboard.
Why does this work? Think about it cell by cell. Cell (0,0): 0+0=0, mod 2 = 0, black. Cell (1,0): 1+0=1, mod 2 = 1, white. Cell (0,1): 0+1=1, mod 2 = 1, white. Cell (1,1): 1+1=2, mod 2 = 0, black. The neighbors always alternate. That's the modular arithmetic doing the work.
Add some color and smoothstep for smoother cells:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float n = 10.0;
float checker = mod(floor(uv.x * n + 0.5) + floor(uv.y * n + 0.5), 2.0);
vec3 col1 = vec3(0.12, 0.12, 0.18);
vec3 col2 = vec3(0.85, 0.55, 0.2);
// pulse brightness with time
col2 *= 0.8 + 0.2 * sin(u_time * 1.5);
vec3 color = mix(col1, col2, checker);
gl_FragColor = vec4(color, 1.0);
}
Dark blue-gray and warm orange checkerboard, with the orange cells gently pulsing. The + 0.5 offsets center the pattern. And yeah, you can rotate a checkerboard too -- apply the rotate() function to uv before the floor/mod math and the whole grid tilts. A rotated checkerboard is a diamond pattern, basically.
Polka dots
Dots on a grid. For each cell, compute the distance from the pixel to the cell center. If the distance is less than some radius, draw a dot:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float cellSize = 0.12;
vec2 cell = fract(uv / cellSize + 0.5) - 0.5;
float d = length(cell) * cellSize;
float radius = 0.03 + 0.01 * sin(u_time * 2.0);
float dot = smoothstep(radius + 0.003, radius, d);
vec3 bg = vec3(0.05, 0.05, 0.1);
vec3 fg = vec3(0.9, 0.3, 0.5);
vec3 color = mix(bg, fg, dot);
gl_FragColor = vec4(color, 1.0);
}
fract(uv / cellSize + 0.5) - 0.5 is the same cell-space trick from episode 33's repetition section. It maps every pixel to a local coordinate within its cell, centered at (0,0). length(cell) * cellSize gives the actual distance to the cell center in world space. Compare that to a radius -- if closer than the radius, it's inside a dot.
The sin(u_time * 2.0) makes all dots pulse in sync. But what if you want them to pulse in a wave? Use the global uv coordinates (not the cell-local ones) to offset the animation:
float radius = 0.03 + 0.01 * sin(u_time * 3.0 + uv.x * 20.0 + uv.y * 15.0);
Now the pulsing ripples across the dot grid like a wave. Global coordinate meets local pattern. That combination is incredibly powerful for adding variety to otherwise uniform repetition.
Hex grid offset
Regular grids are squares. But you can make hexagonal-ish patterns by offsetting every other row by half a cell:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float cellSize = 0.1;
vec2 scaled = uv / cellSize;
// offset every other row by 0.5
float row = floor(scaled.y + 0.5);
float offset = mod(row, 2.0) * 0.5;
vec2 cell = fract(vec2(scaled.x + offset, scaled.y) + 0.5) - 0.5;
float d = length(cell) * cellSize;
float dot = smoothstep(0.035, 0.03, d);
vec3 bg = vec3(0.03, 0.04, 0.08);
vec3 fg = vec3(0.3, 0.8, 0.6);
// color variation per cell
float cellId = row * 17.0 + floor(scaled.x + offset + 0.5) * 13.0;
float hueShift = sin(cellId) * 0.3;
fg = vec3(0.3 + hueShift, 0.8 - hueShift * 0.5, 0.6 + hueShift * 0.3);
vec3 color = mix(bg, fg, dot);
gl_FragColor = vec4(color, 1.0);
}
mod(row, 2.0) * 0.5 adds a half-cell offset to even rows. That's it. That's the only difference from a regular grid. But visually it changes everything -- the dots now tile in a honeycomb-ish pattern instead of a square grid. This is the arrangement you see in halftone printing, molecular structures, and about half the generative art on fxhash.
The cellId trick deserves explanation. We need each dot to have a unique-ish number so we can vary its color. row * 17.0 + col * 13.0 (where col is the floor of the shifted x) creates a different number for each cell. The 17 and 13 are coprime (no common factors) so the pattern doesn't visibily repeat for a while. Then sin(cellId) maps that to a pseudo-random-looking value between -1 and 1. Not truly random -- sin of large numbers oscillates fast enough to look random at human scales. Good enough for color variation. For proper randomness you'd use a hash function, but sin(big_number) is the quick-and-dirty shader trick you'll see everywhere.
Moire patterns
Here's where things get psychedelic. Take two stripe patterns with slightly different frequencies or angles. Overlay them (multiply). The interference between the two frequencies creates large-scale wavy patterns called moire:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 rotate(vec2 p, float a) {
return vec2(p.x*cos(a) - p.y*sin(a), p.x*sin(a) + p.y*cos(a));
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// first set of stripes
float s1 = sin(uv.x * 60.0) * 0.5 + 0.5;
// second set, slightly rotated
vec2 ruv = rotate(uv, 0.15 + sin(u_time * 0.2) * 0.05);
float s2 = sin(ruv.x * 60.0) * 0.5 + 0.5;
// multiply creates interference pattern
float moire = s1 * s2;
vec3 color = vec3(moire * 0.7, moire * 0.5, moire * 0.9);
gl_FragColor = vec4(color, 1.0);
}
Two sets of 60 vertical stripes, one slightly rotated. The multiplication creates these beautiful sweeping curves across the canvas -- they're not drawn anywhere in the code. They emerge from the interaction between two simple patterns. Add u_time to the rotation angle and the moire pattern slowly breathes, the curves drifting and morphing.
Moire is one of those phenomena where the math is simple but the visual result feels impossibly complex. Bridget Riley built an entire career on this -- her black and white op art paintings from the 1960s are essentially hand-painted moire interference. Same math we just wrote in six lines of GLSL.
You can get moire from any two overlapping periodic patterns. Two grids at different scales. Two circular patterns centered at different points. Two checkerboards at slightly different angles. The more similar the patterns, the larger and slower the interference waves.
Combining patterns
Individual patterns are nice but the real magic is layering them. Multiply two patterns and one masks the other. Add them for brightness accumulation. Use mix() for weighted blending:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// horizontal stripes
float hStripes = smoothstep(0.48, 0.5, fract(uv.y * 15.0));
// diagonal stripes
float dStripes = smoothstep(0.48, 0.5, fract((uv.x + uv.y) * 12.0));
// radial gradient for masking
float radial = 1.0 - smoothstep(0.0, 0.45, length(uv));
// combine: diagonal where radial is strong, horizontal outside
float pattern = mix(hStripes, dStripes, radial);
// color it
float hue = u_time * 0.2 + uv.y * 2.0;
vec3 col;
col.r = sin(hue) * 0.4 + 0.5;
col.g = sin(hue + 2.094) * 0.35 + 0.4;
col.b = sin(hue + 4.189) * 0.4 + 0.5;
vec3 color = col * pattern;
// subtle background so black areas aren't pure void
color += vec3(0.02, 0.02, 0.04);
gl_FragColor = vec4(color, 1.0);
}
Three layers: horizontal stripes as the base, diagonal stripes in the center, blended with a radial gradient as the mask. The mix(hStripes, dStripes, radial) does the work -- where radial is 1 (center), you see diagonal stripes. Where radial is 0 (edges), you see horizontal stripes. The transition between them creates a smooth zone where both patterns blend. Add the time-shifted hue and you get a slowly color-shifting, spatially-varied stripe pattern.
This mix-based composition is how you build complex shader art without the complexity becoming unmanageable. Each individual pattern is simple. The composition is just mix calls with masks. You can stack as many layers as you want.
Interactive pattern explorer
Let's wire up the mouse to control pattern parameters. This is where shaders become genuinely fun to play with:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;
vec2 rotate(vec2 p, float a) {
return vec2(p.x*cos(a) - p.y*sin(a), p.x*sin(a) + p.y*cos(a));
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
vec2 mouse = u_mouse / u_resolution;
// mouse.x controls stripe frequency (5 to 40)
float freq = mix(5.0, 40.0, mouse.x);
// mouse.y controls rotation angle (0 to PI)
float angle = mouse.y * 3.14159;
vec2 ruv = rotate(uv, angle);
float stripe = step(0.5, fract(ruv.x * freq));
// add a second layer at fixed freq for moire when frequencies approach
float s2 = step(0.5, fract(uv.x * 20.0));
float pattern = stripe * 0.7 + s2 * 0.3;
vec3 color = vec3(pattern * 0.8, pattern * 0.5, pattern * 0.9);
gl_FragColor = vec4(color, 1.0);
}
Move the mouse left-right to change stripe frequency. Move it up-down to rotate them. When the rotating stripes approach the same frequency as the fixed background stripes, moire interference appears. Move past that point and it dissapears again. It's like tuning a radio dial -- there's a sweet spot where the patterns resonate.
This kind of interactive exploration is how I discover ideas for finished pieces. I'll set up a parametric shader like this, wave the mouse around until something looks interesting, and then freeze those parameter values for the final version. The shader is both the tool and the medium.
Animating patterns
Static patterns are fine for prints, but on screen you want movement. The simplest animation: add u_time to the UV coordinates before fract():
float scrolling = step(0.5, fract(uv.x * 10.0 - u_time * 0.5));
That -u_time * 0.5 makes the stripes scroll to the right. Positive time offset = scroll right, negative = scroll left. The speed is controlled by the multiplier (0.5 means half a cell per second).
But scrolling is just the start. You can modulate frequency with time, rotate with time, scale with time:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// frequency oscillates between 8 and 20
float freq = 14.0 + 6.0 * sin(u_time * 0.5);
// rotation oscillates
float angle = sin(u_time * 0.3) * 0.5;
float c = cos(angle);
float s = sin(angle);
vec2 ruv = vec2(uv.x * c - uv.y * s, uv.x * s + uv.y * c);
// checkerboard with animated frequency
float checker = mod(floor(ruv.x * freq + 0.5) + floor(ruv.y * freq + 0.5), 2.0);
// color shifts with time
vec3 col1 = vec3(0.05, 0.05, 0.1);
vec3 col2 = vec3(
sin(u_time * 0.4) * 0.3 + 0.6,
sin(u_time * 0.4 + 2.0) * 0.25 + 0.4,
sin(u_time * 0.4 + 4.0) * 0.3 + 0.5
);
vec3 color = mix(col1, col2, checker);
gl_FragColor = vec4(color, 1.0);
}
A checkerboard that breathes -- the grid density oscillates, the rotation sways, the colors shift. Everything is driven by sin(u_time * ...) with different frequencies and phases so nothing loops in sync. That de-synchronization is what makes it feel organic instead of mechanical. Same principle we used in episode 16 with easing and lerp, just applied to pattern parameters.
A creative exercise: the gradient transition
Here's a challenge. Build a single shader that transitions smoothly between three different patterns based on vertical position:
- Top third: vertical stripes
- Middle third: checkerboard
- Bottom third: polka dots
The transitions between zones should be smooth (use smoothstep to blend). Each zone should have its own color scheme. And the whole thing should animate.
Here's my approach:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// zone weights
float wStripes = smoothstep(-0.1, 0.1, uv.y + 0.15);
float wChecker = 1.0 - smoothstep(-0.15, 0.15, abs(uv.y)) * 0.0 - 1.0;
// actually simpler: weight for each zone
float top = smoothstep(0.0, 0.1, uv.y - 0.1); // >0.1 = stripes
float bottom = smoothstep(0.0, 0.1, -(uv.y + 0.1)); // <-0.1 = dots
float middle = 1.0 - top - bottom;
// stripes
float stripes = step(0.5, fract(uv.x * 15.0 - u_time * 0.3));
// checkerboard
float n = 12.0;
float checker = mod(floor(uv.x * n + 0.5) + floor(uv.y * n + 0.5), 2.0);
// dots
float cellSize = 0.08;
vec2 cell = fract(uv / cellSize + 0.5) - 0.5;
float d = length(cell) * cellSize;
float dots = smoothstep(0.025, 0.02, d);
// combine
float pattern = stripes * top + checker * middle + dots * bottom;
// color per zone
vec3 stripCol = vec3(0.8, 0.4, 0.2); // warm orange
vec3 checkCol = vec3(0.3, 0.7, 0.5); // teal
vec3 dotCol = vec3(0.6, 0.3, 0.8); // purple
vec3 fg = stripCol * top + checkCol * middle + dotCol * bottom;
vec3 bg = vec3(0.04, 0.04, 0.06);
vec3 color = mix(bg, fg, pattern);
gl_FragColor = vec4(color, 1.0);
}
Three distinct patterns, three color schemes, smooth transitions at the boundaries. The top, middle, and bottom weights are mutually exclusive and sum to approximately 1.0 everywhere. Each pattern is computed independently and then weighted. The color follows the same weighting. The result is a canvas split into three visual zones that bleed into each other at the edges.
Is the transition zone perfect? No. The checkerboard and dots don't align at the boundary, so the blend zone looks a bit chaotic. In a real piece, you might add extra logic to fade each pattern's opacity at its boundary. Or lean into the chaos -- the messy transition zone has its own character.
Op art and the Bridget Riley connection
I mentioned Bridget Riley earlier with the moire patterns. She deserves more than a passing mention because what she was doing in the 1960s is literally the same thing we're doing here. Her painting "Fall" (1963) is vertical black and white curves with carefully controlled spacing -- the curves compress and expand to create an illusion of 3D depth. That's exactly sin(uv.x * freq + offset) with a spatially varying frequency.
Victor Vasarely is the other name. His "Vega" series from the 1970s -- grids of colored shapes that bulge and warp to create 3D illusions -- is literally a distorted checkerboard. You could recreate any Vasarely painting by taking a checkerboard shader and applying a lens distortion to the UV coordinates:
// Vasarely-esque bulging grid
vec2 center = uv;
float bulge = 1.0 + 0.4 * (1.0 - length(center) * 2.5);
vec2 warped = center * bulge;
float checker = mod(floor(warped.x * 10.0 + 0.5) + floor(warped.y * 10.0 + 0.5), 2.0);
The bulge factor increases toward the center, stretching the checkerboard cells larger in the middle and compressing them at the edges. It creates a convincing 3D sphere illusion from a flat 2D pattern. Same technique Vasarely spent years refining by hand with rulers and templates. We did it in four lines.
I'm not saying the GLSL version IS art the way their paintings are. The craft, the materiality, the intention behind decades of careful work -- that's not replicated by four lines of code. But the mathematical principles are identical. And understanding that connection makes the code feel less like engineering and more like a creative act. You're working in the same visual language as artists who shaped an entire movement.
Where patterns lead
Everything in this episode -- fract, floor, mod, step -- these are the vocabulary for domain repetition. And domain repetition is the foundation for procedural texture, noise visualization, fractal detail, and essentially everything in shader art that looks complex but comes from simple rules repeated across space.
Next episode we're taking noise to the GPU. We implemented Perlin noise in JavaScript back in episode 12. On the GPU it's a different beast -- it runs in parallel for every pixel, you can layer octaves for fractal detail, and you can combine it with the pattern techniques from today to create organic, natural-looking textures. Noise plus fract plus SDFs is where shader art starts looking truly otherworldly :-)
't Komt erop neer...
fract(x)returns the fractional part -- applied to coordinates, it creates repeating cells from a single definitionstep(0.5, fract(uv.x * n))makes hard stripes,smoothstepmakes anti-aliased ones- Diagonal stripes:
fract((uv.x + uv.y) * n), or rotate UV coordinates first for arbitrary angles - Checkerboard:
mod(floor(uv.x * n) + floor(uv.y * n), 2.0)-- modular arithmetic does all the work - Polka dots: compute distance to cell center via
fract(), threshold against a radius - Hex offset:
mod(row, 2.0) * 0.5shifts every other row, turning a square grid into honeycomb - Moire patterns emerge from overlaying two similar periodic patterns -- interference creates large-scale waves
- Combine patterns with multiply (masking), add (accumulation), or
mix()(weighted blending) - Animate by adding
u_timeto coordinates (scrolling), frequency (breathing), or rotation angle (spinning) - Bridget Riley and Victor Vasarely were doing this exact math by hand in the 1960s --
fractandmodare op art in code form
Sallukes! Thanks for reading.
X
Estimated Payout
$0.16
Discussion
No comments yet. Be the first!