Advanced Shader Programming
Raymarching, multi-pass rendering, shader feedback loops, and noise-based procedural textures in GLSL.
Raymarching — rendering entire 3D scenes in a fragment shader
Raymarching is a rendering technique where you cast rays from the camera into a scene described entirely by Signed Distance Functions (SDFs). Every pixel traces its own ray — no geometry required.
The fragment shader runs for each pixel. For each pixel:
- Calculate the ray direction
- March along the ray in small steps
- At each step, query the SDF — “how far am I from the nearest surface?”
- If distance < threshold: shade the surface. If steps exhausted: background colour.
// raymarcher.frag
precision highp float;
varying vec2 vUv;
uniform float u_time;
uniform vec2 u_resolution;
// ─── SDFs ───────────────────────────────────────────────
float sdSphere(vec3 p, float r) {
return length(p) - r;
}
float sdBox(vec3 p, vec3 b) {
vec3 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
// ─── Scene ──────────────────────────────────────────────
float scene(vec3 p) {
float t = u_time;
vec3 pa = vec3(cos(t) * 0.5, sin(t * 0.7) * 0.3, 0.0);
vec3 pb = vec3(cos(t * 1.3 + 2.0) * 0.4, sin(t) * 0.4, cos(t * 0.9) * 0.3);
float s1 = sdSphere(p - pa, 0.35);
float s2 = sdSphere(p - pb, 0.28);
float b = sdBox(p - vec3(0.0, -0.7, 0.0), vec3(2.0, 0.05, 2.0));
return min(smin(s1, s2, 0.2), b);
}
// ─── Normal ─────────────────────────────────────────────
vec3 calcNormal(vec3 p) {
float e = 0.0001;
return normalize(vec3(
scene(p + vec3(e, 0, 0)) - scene(p - vec3(e, 0, 0)),
scene(p + vec3(0, e, 0)) - scene(p - vec3(0, e, 0)),
scene(p + vec3(0, 0, e)) - scene(p - vec3(0, 0, e))
));
}
// ─── Raymarcher ─────────────────────────────────────────
float march(vec3 ro, vec3 rd) {
float t = 0.0;
for (int i = 0; i < 96; i++) {
float d = scene(ro + rd * t);
if (d < 0.001) return t;
if (t > 20.0) break;
t += d;
}
return -1.0;
}
// ─── Main ───────────────────────────────────────────────
void main() {
vec2 uv = (vUv - 0.5) * vec2(u_resolution.x / u_resolution.y, 1.0) * 2.0;
// Camera
vec3 ro = vec3(0.0, 0.5, 2.5);
vec3 rd = normalize(vec3(uv, -1.5));
float t = march(ro, rd);
vec3 col = vec3(0.05, 0.02, 0.08); // background
if (t > 0.0) {
vec3 p = ro + rd * t;
vec3 n = calcNormal(p);
vec3 light = normalize(vec3(1.0, 2.0, 1.5));
float diff = max(dot(n, light), 0.0);
float amb = 0.1;
float spec = pow(max(dot(reflect(-light, n), -rd), 0.0), 32.0);
col = vec3(0.4, 0.2, 0.8) * (diff + amb) + vec3(1.0) * spec * 0.5;
// Fog
col = mix(col, vec3(0.05, 0.02, 0.08), 1.0 - exp(-t * 0.08));
}
gl_FragColor = vec4(col, 1.0);
}
Multi-pass rendering
Create multiple p5.Graphics buffers and chain them:
let pass1, pass2, finalShader;
function setup() {
createCanvas(800, 600, WEBGL);
pass1 = createGraphics(800, 600, WEBGL);
pass2 = createGraphics(800, 600, WEBGL);
// Load shaders for each pass
bloomShader = loadShader('default.vert', 'bloom.frag');
gradeShader = loadShader('default.vert', 'grade.frag');
}
function draw() {
// Pass 1: render scene to buffer
pass1.shader(sceneShader);
sceneShader.setUniform('u_time', millis() / 1000);
pass1.rect(-400, -300, 800, 600);
// Pass 2: bloom on pass 1
pass2.shader(bloomShader);
bloomShader.setUniform('u_texture', pass1);
bloomShader.setUniform('u_resolution', [800, 600]);
pass2.rect(-400, -300, 800, 600);
// Final: colour grade pass 2 onto main canvas
shader(gradeShader);
gradeShader.setUniform('u_texture', pass2);
rect(-400, -300, 800, 600);
}
Shader feedback / ping-pong
Each frame’s output becomes the next frame’s input — enabling reaction-diffusion, trails, and self-modifying patterns:
let bufA, bufB;
let feedbackShader, displayShader;
let pingpong = 0;
function setup() {
createCanvas(800, 600, WEBGL);
bufA = createGraphics(800, 600, WEBGL);
bufB = createGraphics(800, 600, WEBGL);
feedbackShader = loadShader('default.vert', 'feedback.frag');
displayShader = loadShader('default.vert', 'display.frag');
}
function draw() {
let [read, write] = pingpong === 0 ? [bufA, bufB] : [bufB, bufA];
write.shader(feedbackShader);
feedbackShader.setUniform('u_prev', read);
feedbackShader.setUniform('u_time', millis() / 1000);
feedbackShader.setUniform('u_mouse', [mouseX / width, 1.0 - mouseY / height]);
write.rect(-400, -300, 800, 600);
shader(displayShader);
displayShader.setUniform('u_texture', write);
rect(-400, -300, 800, 600);
pingpong = 1 - pingpong;
}
Procedural noise textures in GLSL
Value noise, FBM, domain warping — all in the fragment shader:
// Fractional Brownian Motion (fBm) — layered noise
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float valueNoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f); // smoothstep
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 < 6; i++) {
v += a * valueNoise(p);
p = p * 2.0 + vec2(5.2, 1.3);
a *= 0.5;
}
return v;
}
// Domain warping — fold noise onto itself for organic structures
float warpedFBM(vec2 p) {
vec2 q = vec2(fbm(p), fbm(p + vec2(5.2, 1.3)));
vec2 r = vec2(fbm(p + 4.0 * q + vec2(1.7, 9.2)), fbm(p + 4.0 * q + vec2(8.3, 2.8)));
return fbm(p + 4.0 * r);
}
Key takeaways
- Raymarching renders entire 3D scenes in the fragment shader — no geometry, just SDFs
- Multi-pass rendering chains buffers: scene → bloom → grade → display
- Shader feedback (ping-pong buffers) creates self-referential, evolving patterns
- fBm (Fractional Brownian Motion) layers noise at increasing frequencies for natural textures
- Domain warping applies fBm to the input coordinates of another fBm for organic folded patterns
- Performance: reduce march steps, lower buffer resolution, use half precision (
mediump) where possible