Shader Art: How to Create Stunning Visual Effects With GLSL and WebGL
There is a form of digital art where every pixel on screen is calculated simultaneously by your GPU, running a tiny program thousands of times in parallel. This is shader art — arguably the most powerful and visually stunning medium in creative coding. From swirling plasma fields to impossible 3D geometries rendered in real time, shaders can produce effects that would take minutes to render on the CPU but run at 60 frames per second on even modest hardware.
In this article, you will learn how to write fragment shaders in GLSL (OpenGL Shading Language) and display them using WebGL. Every example is a self-contained HTML file — paste it into a file, open it in your browser, and watch the GPU paint. We start with simple color gradients and build up to raymarched 3D scenes, fractals, and generative compositions.
How Fragment Shaders Work
A fragment shader is a function that runs once for every pixel on screen. It receives the pixel's coordinates and outputs a color (RGBA). The GPU runs this function for all pixels in parallel — that is why shaders are so fast.
The basic structure of every WebGL shader art piece is:
- Create a canvas and WebGL context
- Compile a vertex shader (a simple pass-through that covers the screen)
- Compile a fragment shader (where all the art happens)
- Draw a full-screen quad and let the GPU run your fragment shader on every pixel
All the examples below share the same WebGL boilerplate. The vertex shader is always the same minimal pass-through. Only the fragment shader changes — that is where you express your creativity.
1. Gradient Color Field
Let's start with the simplest possible shader art: mapping pixel coordinates to colors. The normalized UV coordinates (0 to 1 across the canvas) become the red and green channels, with time driving the blue channel. Simple — but already producing a smoothly animated color field that would be expensive to replicate pixel-by-pixel on the CPU.
<!DOCTYPE html>
<html><head><title>Shader: Gradient Field</title></head>
<body style="margin:0;background:#000">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
c.width = 800; c.height = 600;
const gl = c.getContext('webgl');
const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `precision highp float;
uniform float t;
uniform vec2 r;
void main(){
vec2 uv = gl_FragCoord.xy / r;
float red = uv.x * (0.5 + 0.5 * sin(t));
float green = uv.y * (0.5 + 0.5 * cos(t * 0.7));
float blue = 0.5 + 0.5 * sin(t * 0.3 + uv.x * 3.0 + uv.y * 2.0);
gl_FragColor = vec4(red, green, blue, 1);
}`;
function mkS(src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s); return s;
}
const pg = gl.createProgram();
gl.attachShader(pg, mkS(vs, gl.VERTEX_SHADER));
gl.attachShader(pg, mkS(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(pg); gl.useProgram(pg);
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pl = gl.getAttribLocation(pg, 'p');
gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl, 2, gl.FLOAT, false, 0, 0);
const tL = gl.getUniformLocation(pg, 't');
const rL = gl.getUniformLocation(pg, 'r');
function draw(now) {
gl.uniform1f(tL, now * 0.001);
gl.uniform2f(rL, c.width, c.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script></body></html>
The key insight: gl_FragCoord.xy / r gives you normalized UV coordinates from (0,0) at the bottom-left to (1,1) at the top-right. By mapping these to color channels and modulating with sin(t), you get an infinitely varying color field with zero CPU cost.
2. Noise Plasma
Plasma effects were a staple of the demoscene, and shaders make them trivial. By layering multiple sine waves at different frequencies and offsets, you create organic-looking color patterns that flow and morph over time. This technique is sometimes called "oldschool plasma" — no noise functions needed, just overlapping sinusoids.
<!DOCTYPE html>
<html><head><title>Shader: Plasma</title></head>
<body style="margin:0;background:#000">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
c.width = 800; c.height = 600;
const gl = c.getContext('webgl');
const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `precision highp float;
uniform float t;
uniform vec2 r;
void main(){
vec2 uv = gl_FragCoord.xy / r;
float v = 0.0;
v += sin((uv.x * 10.0) + t);
v += sin((uv.y * 10.0) + t * 0.5);
v += sin((uv.x * 10.0 + uv.y * 10.0) + t * 0.7);
v += sin(length(uv - 0.5) * 14.0 - t * 2.0);
v *= 0.25;
vec3 col = vec3(
0.5 + 0.5 * sin(v * 3.14159 + 0.0),
0.5 + 0.5 * sin(v * 3.14159 + 2.094),
0.5 + 0.5 * sin(v * 3.14159 + 4.189)
);
gl_FragColor = vec4(col, 1);
}`;
function mkS(src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s); return s;
}
const pg = gl.createProgram();
gl.attachShader(pg, mkS(vs, gl.VERTEX_SHADER));
gl.attachShader(pg, mkS(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(pg); gl.useProgram(pg);
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pl = gl.getAttribLocation(pg, 'p');
gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl, 2, gl.FLOAT, false, 0, 0);
const tL = gl.getUniformLocation(pg, 't');
const rL = gl.getUniformLocation(pg, 'r');
function draw(now) {
gl.uniform1f(tL, now * 0.001);
gl.uniform2f(rL, c.width, c.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script></body></html>
Each sin() call creates a wave pattern. The first two create horizontal and vertical stripes. The third adds diagonal interference. The fourth creates circular ripples from the center. Multiplied together and mapped through a phase-shifted RGB palette, you get a classic plasma effect.
3. SDF Circle and Smooth Shapes
Signed Distance Functions (SDFs) are the foundation of modern shader art. An SDF tells you how far a point is from the nearest surface — negative values are inside, positive values are outside, and zero is exactly on the edge. This lets you draw perfectly smooth shapes at any resolution without aliasing.
<!DOCTYPE html>
<html><head><title>Shader: SDF Shapes</title></head>
<body style="margin:0;background:#000">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
c.width = 800; c.height = 600;
const gl = c.getContext('webgl');
const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `precision highp float;
uniform float t;
uniform vec2 r;
float sdCircle(vec2 p, float rad) { return length(p) - rad; }
float sdBox(vec2 p, vec2 b) { vec2 d = abs(p) - b; return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); }
float sdStar(vec2 p, float r, int n, float m) {
float an = 3.14159 / float(n);
float en = 3.14159 / m;
vec2 acs = vec2(cos(an), sin(an));
vec2 ecs = vec2(cos(en), sin(en));
float bn = mod(atan(p.x, p.y), 2.0 * an) - an;
p = length(p) * vec2(cos(bn), abs(sin(bn)));
p -= r * acs;
p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y);
return length(p) * sign(p.x);
}
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5 * r) / min(r.x, r.y);
vec3 col = vec3(0.05);
// Morphing shape: blend between circle, box, and star
float morph = sin(t * 0.5) * 0.5 + 0.5;
float d1 = sdCircle(uv - vec2(-0.5, 0.0), 0.25);
float d2 = sdBox(uv, vec2(0.2, 0.2));
float d3 = sdStar(uv - vec2(0.5, 0.0), 0.25, 5, 2.5);
// Glow effect using 1/distance
vec3 c1 = vec3(1.0, 0.3, 0.5) * 0.02 / (abs(d1) + 0.005);
vec3 c2 = vec3(0.3, 1.0, 0.5) * 0.02 / (abs(d2) + 0.005);
vec3 c3 = vec3(0.3, 0.5, 1.0) * 0.02 / (abs(d3) + 0.005);
// Animate positions
float a = t * 0.3;
d1 = sdCircle(uv - vec2(-0.5 * cos(a), 0.3 * sin(a)), 0.2);
d2 = sdBox(uv - vec2(0.0, -0.2 * sin(a * 1.3)), vec2(0.15 + 0.05 * sin(t)));
d3 = sdStar(uv - vec2(0.5 * cos(a * 0.7), -0.3 * cos(a)), 0.2, 5, 2.0 + sin(t) * 0.5);
c1 = vec3(1.0, 0.3, 0.5) * 0.015 / (abs(d1) + 0.003);
c2 = vec3(0.3, 1.0, 0.5) * 0.015 / (abs(d2) + 0.003);
c3 = vec3(0.3, 0.5, 1.0) * 0.015 / (abs(d3) + 0.003);
col += c1 + c2 + c3;
gl_FragColor = vec4(col, 1);
}`;
function mkS(src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s); return s;
}
const pg = gl.createProgram();
gl.attachShader(pg, mkS(vs, gl.VERTEX_SHADER));
gl.attachShader(pg, mkS(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(pg); gl.useProgram(pg);
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pl = gl.getAttribLocation(pg, 'p');
gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl, 2, gl.FLOAT, false, 0, 0);
const tL = gl.getUniformLocation(pg, 't');
const rL = gl.getUniformLocation(pg, 'r');
function draw(now) {
gl.uniform1f(tL, now * 0.001);
gl.uniform2f(rL, c.width, c.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script></body></html>
The glow effect comes from dividing a small constant by the absolute distance: 0.015 / (abs(d) + 0.003). Near the shape's edge (where d approaches 0), the value spikes to create a bright glow that falls off smoothly with distance. This technique is ubiquitous in shader art.
4. GPU Noise and Domain Warping
Noise on the GPU opens the door to organic, natural-looking patterns. Since GLSL has no built-in noise function, we implement a hash-based value noise. Then we apply domain warping — feeding noise coordinates back into themselves — to create swirling, fluid-like patterns.
<!DOCTYPE html>
<html><head><title>Shader: Domain Warping</title></head>
<body style="margin:0;background:#000">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
c.width = 800; c.height = 600;
const gl = c.getContext('webgl');
const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `precision highp float;
uniform float t;
uniform vec2 r;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1,0)), f.x),
mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), f.x),
f.y
);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
mat2 rot = mat2(cos(0.5), sin(0.5), -sin(0.5), cos(0.5));
for (int i = 0; i < 6; i++) {
v += a * noise(p);
p = rot * p * 2.0;
a *= 0.5;
}
return v;
}
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5 * r) / min(r.x, r.y) * 3.0;
// Domain warping: feed noise into itself
vec2 q = vec2(fbm(uv + t * 0.1), fbm(uv + vec2(1.7, 9.2) + t * 0.12));
vec2 rr = vec2(fbm(uv + 4.0 * q + vec2(1.7, 9.2) + t * 0.15), fbm(uv + 4.0 * q + vec2(8.3, 2.8) + t * 0.13));
float f = fbm(uv + 4.0 * rr);
// Color palette
vec3 col = mix(vec3(0.1, 0.05, 0.2), vec3(0.9, 0.3, 0.1), clamp(f * f * 2.0, 0.0, 1.0));
col = mix(col, vec3(0.0, 0.4, 0.7), clamp(length(q), 0.0, 1.0));
col = mix(col, vec3(1.0, 0.8, 0.3), clamp(length(rr.x), 0.0, 1.0));
col *= f * 1.5 + 0.3;
gl_FragColor = vec4(col, 1);
}`;
function mkS(src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s); return s;
}
const pg = gl.createProgram();
gl.attachShader(pg, mkS(vs, gl.VERTEX_SHADER));
gl.attachShader(pg, mkS(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(pg); gl.useProgram(pg);
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pl = gl.getAttribLocation(pg, 'p');
gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl, 2, gl.FLOAT, false, 0, 0);
const tL = gl.getUniformLocation(pg, 't');
const rL = gl.getUniformLocation(pg, 'r');
function draw(now) {
gl.uniform1f(tL, now * 0.001);
gl.uniform2f(rL, c.width, c.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script></body></html>
Domain warping was popularized by Inigo Quilez in his influential article on the technique. The key idea: compute noise at a point, use the result to offset the point, then compute noise again. Each layer of indirection adds organic complexity — swirls, folds, and fluid-like tendrils emerge from simple noise.
5. Raymarched 3D Sphere
Raymarching is the technique that makes impossible 3D scenes possible in a shader. Instead of using traditional 3D meshes, you define shapes as SDFs and "march" rays from the camera until they hit a surface. This example renders a reflective sphere with Phong lighting — all computed per-pixel in the fragment shader.
<!DOCTYPE html>
<html><head><title>Shader: Raymarched Sphere</title></head>
<body style="margin:0;background:#000">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
c.width = 800; c.height = 600;
const gl = c.getContext('webgl');
const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `precision highp float;
uniform float t;
uniform vec2 r;
float sdSphere(vec3 p, float s) { return length(p) - s; }
float sdPlane(vec3 p) { return p.y + 0.5; }
float scene(vec3 p) {
float sphere = sdSphere(p - vec3(0, 0.2 + 0.3 * sin(t), 0), 0.5);
float plane = sdPlane(p);
return min(sphere, plane);
}
vec3 getNormal(vec3 p) {
vec2 e = vec2(0.001, 0);
return normalize(vec3(
scene(p + e.xyy) - scene(p - e.xyy),
scene(p + e.yxy) - scene(p - e.yxy),
scene(p + e.yyx) - scene(p - e.yyx)
));
}
float march(vec3 ro, vec3 rd) {
float d = 0.0;
for (int i = 0; i < 80; i++) {
float h = scene(ro + rd * d);
if (h < 0.001 || d > 20.0) break;
d += h;
}
return d;
}
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5 * r) / min(r.x, r.y);
vec3 ro = vec3(0, 0.5, 2.5);
vec3 rd = normalize(vec3(uv, -1.5));
float d = march(ro, rd);
vec3 col = vec3(0.05, 0.05, 0.1);
if (d < 20.0) {
vec3 p = ro + rd * d;
vec3 n = getNormal(p);
vec3 lightPos = vec3(2.0 * cos(t), 2.0, 2.0 * sin(t));
vec3 lightDir = normalize(lightPos - p);
float diff = max(dot(n, lightDir), 0.0);
vec3 viewDir = normalize(ro - p);
vec3 halfDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(n, halfDir), 0.0), 32.0);
// Shadow
float shadow = 1.0;
float sd = march(p + n * 0.01, lightDir);
if (sd < length(lightPos - p)) shadow = 0.3;
// Checkerboard floor
vec3 matCol;
if (p.y < -0.49) {
float check = mod(floor(p.x * 2.0) + floor(p.z * 2.0), 2.0);
matCol = mix(vec3(0.3), vec3(0.7), check);
} else {
matCol = vec3(0.2, 0.5, 1.0);
}
col = matCol * (0.15 + diff * 0.7 * shadow) + spec * shadow * 0.5;
// Fog
col = mix(col, vec3(0.05, 0.05, 0.1), 1.0 - exp(-0.05 * d * d));
}
gl_FragColor = vec4(col, 1);
}`;
function mkS(src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s); return s;
}
const pg = gl.createProgram();
gl.attachShader(pg, mkS(vs, gl.VERTEX_SHADER));
gl.attachShader(pg, mkS(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(pg); gl.useProgram(pg);
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pl = gl.getAttribLocation(pg, 'p');
gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl, 2, gl.FLOAT, false, 0, 0);
const tL = gl.getUniformLocation(pg, 't');
const rL = gl.getUniformLocation(pg, 'r');
function draw(now) {
gl.uniform1f(tL, now * 0.001);
gl.uniform2f(rL, c.width, c.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script></body></html>
The raymarcher fires a ray from the camera through each pixel. At each step, it evaluates the scene SDF to find the shortest distance to any surface, then advances by that amount. When the distance drops below a tiny threshold (0.001), we have a hit. The normal is computed by sampling the SDF at tiny offsets — a numerical gradient. Shadows use the same marching technique: fire a ray from the hit point toward the light to check for occluders.
6. Fractal Shader (Mandelbrot Zoom)
Fractals and shaders are a match made in heaven. The Mandelbrot set requires iterating a simple formula for every pixel — exactly what GPUs excel at. This example renders a smoothly zooming Mandelbrot set with continuous coloring (no banding) and orbit trap-based palette mapping.
<!DOCTYPE html>
<html><head><title>Shader: Mandelbrot Zoom</title></head>
<body style="margin:0;background:#000">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
c.width = 800; c.height = 600;
const gl = c.getContext('webgl');
const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `precision highp float;
uniform float t;
uniform vec2 r;
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5 * r) / min(r.x, r.y);
// Zoom into an interesting region
float zoom = 1.0 + t * 0.3;
vec2 center = vec2(-0.745, 0.186);
vec2 c = center + uv / pow(2.0, zoom);
vec2 z = vec2(0);
float iter = 0.0;
const float maxIter = 200.0;
for (float i = 0.0; i < 200.0; i++) {
z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
if (dot(z, z) > 4.0) { iter = i; break; }
iter = i;
}
// Smooth coloring
float smoothIter = iter;
if (iter < maxIter - 1.0) {
smoothIter = iter - log(log(length(z))) / log(2.0);
}
float f = smoothIter / 50.0;
// Palette
vec3 col;
if (iter >= maxIter - 1.0) {
col = vec3(0);
} else {
col = 0.5 + 0.5 * cos(6.28318 * (f + vec3(0.0, 0.1, 0.2)));
}
gl_FragColor = vec4(col, 1);
}`;
function mkS(src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s); return s;
}
const pg = gl.createProgram();
gl.attachShader(pg, mkS(vs, gl.VERTEX_SHADER));
gl.attachShader(pg, mkS(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(pg); gl.useProgram(pg);
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pl = gl.getAttribLocation(pg, 'p');
gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl, 2, gl.FLOAT, false, 0, 0);
const tL = gl.getUniformLocation(pg, 't');
const rL = gl.getUniformLocation(pg, 'r');
function draw(now) {
gl.uniform1f(tL, now * 0.001);
gl.uniform2f(rL, c.width, c.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script></body></html>
The smooth coloring formula iter - log(log(length(z))) / log(2.0) eliminates the banding you see in naive iteration-count coloring. The cosine palette 0.5 + 0.5 * cos(2pi * (t + offset)) creates smooth, looping color cycles — a technique from Inigo Quilez that has become standard in shader art.
7. Kaleidoscope Shader
Kaleidoscope effects exploit angular symmetry — you fold the UV space around the center, reducing it to a single wedge, then pattern that wedge with any effect you like. The result is automatically mirrored across all segments. Combined with animated noise, you get endlessly morphing mandala-like patterns.
<!DOCTYPE html>
<html><head><title>Shader: Kaleidoscope</title></head>
<body style="margin:0;background:#000">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
c.width = 800; c.height = 600;
const gl = c.getContext('webgl');
const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `precision highp float;
uniform float t;
uniform vec2 r;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1,0)), f.x),
mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), f.x),
f.y
);
}
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5 * r) / min(r.x, r.y);
// Polar coordinates
float angle = atan(uv.y, uv.x);
float radius = length(uv);
// Kaleidoscope fold: 8 segments
float segments = 8.0;
float segAngle = 3.14159 * 2.0 / segments;
angle = mod(angle, segAngle);
angle = abs(angle - segAngle * 0.5);
// Back to cartesian in folded space
vec2 fuv = vec2(cos(angle), sin(angle)) * radius;
// Animated pattern in the folded space
fuv += t * 0.1;
float n1 = noise(fuv * 5.0 + t * 0.2);
float n2 = noise(fuv * 10.0 - t * 0.15);
float n3 = noise(fuv * 20.0 + t * 0.3);
float pattern = n1 * 0.5 + n2 * 0.3 + n3 * 0.2;
// Color based on pattern and radius
vec3 col = 0.5 + 0.5 * cos(6.28 * (pattern + radius * 2.0 + vec3(0.0, 0.33, 0.67)));
col *= smoothstep(1.5, 0.0, radius);
col += 0.03 / (abs(sin(angle * segments / 3.14159) * radius) + 0.01) * vec3(0.5, 0.7, 1.0) * 0.15;
gl_FragColor = vec4(col, 1);
}`;
function mkS(src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s); return s;
}
const pg = gl.createProgram();
gl.attachShader(pg, mkS(vs, gl.VERTEX_SHADER));
gl.attachShader(pg, mkS(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(pg); gl.useProgram(pg);
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pl = gl.getAttribLocation(pg, 'p');
gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl, 2, gl.FLOAT, false, 0, 0);
const tL = gl.getUniformLocation(pg, 't');
const rL = gl.getUniformLocation(pg, 'r');
function draw(now) {
gl.uniform1f(tL, now * 0.001);
gl.uniform2f(rL, c.width, c.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script></body></html>
The fold operation is the heart of kaleidoscope shaders: angle = mod(angle, segAngle); angle = abs(angle - segAngle * 0.5); reduces the full 360 degrees to a single wedge, then mirrors it. Everything you draw in that wedge appears in all segments. The subtle line glow at segment boundaries adds a stained-glass feel.
8. Generative Shader Composition
Our final example combines multiple techniques into a single generative composition: domain-warped noise for the background, raymarched SDF shapes with glow, polar symmetry, and animated color palettes. This is the kind of piece you see on Shadertoy's front page — layers of mathematical beauty all running in real time.
<!DOCTYPE html>
<html><head><title>Shader: Generative Composition</title></head>
<body style="margin:0;background:#000">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
c.width = 800; c.height = 600;
const gl = c.getContext('webgl');
const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `precision highp float;
uniform float t;
uniform vec2 r;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i+vec2(1,0)), f.x), mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), f.x), f.y);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
return v;
}
vec3 palette(float t) {
return 0.5 + 0.5 * cos(6.28318 * (t + vec3(0.0, 0.1, 0.2)));
}
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5 * r) / min(r.x, r.y);
vec3 col = vec3(0);
// Layer 1: Domain-warped background
vec2 q = vec2(fbm(uv * 2.0 + t * 0.05), fbm(uv * 2.0 + vec2(5.2, 1.3) + t * 0.07));
float bg = fbm(uv * 2.0 + 3.0 * q);
col += palette(bg * 0.8 + t * 0.05) * 0.3;
// Layer 2: Concentric SDF rings
float rad = length(uv);
float rings = sin(rad * 25.0 - t * 3.0) * 0.5 + 0.5;
rings *= smoothstep(1.2, 0.2, rad);
col += palette(rad * 2.0 - t * 0.1) * rings * 0.2;
// Layer 3: Rotating SDF shapes with glow
for (int i = 0; i < 5; i++) {
float fi = float(i);
float a = t * (0.2 + fi * 0.1) + fi * 1.2566;
vec2 pos = vec2(cos(a), sin(a)) * (0.3 + 0.1 * sin(t + fi));
float d = length(uv - pos) - 0.05 - 0.02 * sin(t * 2.0 + fi);
col += palette(fi * 0.2 + t * 0.05) * 0.01 / (abs(d) + 0.005);
}
// Layer 4: Polar rays
float angle = atan(uv.y, uv.x);
float rays = abs(sin(angle * 6.0 + t * 0.5));
rays = pow(rays, 8.0) * smoothstep(0.8, 0.0, rad) * 0.3;
col += vec3(0.8, 0.9, 1.0) * rays;
// Layer 5: Center glow
col += vec3(1.0, 0.8, 0.6) * 0.03 / (rad + 0.02);
// Vignette
col *= 1.0 - 0.5 * rad * rad;
// Tone mapping
col = col / (1.0 + col);
gl_FragColor = vec4(col, 1);
}`;
function mkS(src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s); return s;
}
const pg = gl.createProgram();
gl.attachShader(pg, mkS(vs, gl.VERTEX_SHADER));
gl.attachShader(pg, mkS(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(pg); gl.useProgram(pg);
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pl = gl.getAttribLocation(pg, 'p');
gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl, 2, gl.FLOAT, false, 0, 0);
const tL = gl.getUniformLocation(pg, 't');
const rL = gl.getUniformLocation(pg, 'r');
function draw(now) {
gl.uniform1f(tL, now * 0.001);
gl.uniform2f(rL, c.width, c.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script></body></html>
This composition demonstrates the layering approach that makes great shader art: each layer adds visual depth without overwhelming complexity. The tone mapping col / (1.0 + col) prevents blown-out highlights when multiple glowing elements overlap. The vignette (darkening edges) draws the eye to the center.
Essential Shader Art Techniques
After working through these examples, you have encountered the core techniques that power most shader art:
- UV manipulation — Normalizing coordinates, centering, scaling, rotating, and folding UV space
- Signed Distance Functions — Defining shapes as distance fields for crisp edges and easy Boolean operations
- Noise and FBM — Creating organic patterns with layered noise (fractional Brownian motion)
- Domain warping — Feeding noise into itself for fluid, swirling patterns
- Raymarching — Rendering 3D scenes entirely in the fragment shader using SDF ray stepping
- Cosine palettes — Smooth, parametric color cycling with
0.5 + 0.5 * cos(2pi * (t + offset)) - Glow effects — Using
intensity / (distance + epsilon)for soft light around shapes - Layering — Compositing multiple effects with additive blending and tone mapping
Where to Go From Here
Shader art is a deep rabbit hole with a welcoming community. Here are the best resources to continue your journey:
- Shadertoy — The largest collection of shader art, with thousands of examples you can edit live
- The Book of Shaders — A beautiful interactive textbook for learning GLSL step by step
- Inigo Quilez's articles — Foundational reference for SDF shapes, noise techniques, and raymarching
- How Lumitree builds shader worlds — See how these techniques are used to create 50KB micro-worlds
- Noise Texture tutorial — Dive deeper into procedural noise techniques
- Ray Tracing tutorial — Build a complete software renderer from scratch
The beauty of shader art is that it rewards both mathematical thinking and artistic intuition. A tiny change to a parameter can transform a gentle gradient into an explosive fractal. Start by modifying the examples above — change a number, add a sine wave, swap a color — and see what happens. The GPU is ready to paint.
Ready to explore more generative art? Visit Lumitree to discover unique algorithmic micro-worlds, or browse the full collection of creative coding tutorials to keep learning.