p5.js Expert Article 1

Advanced Shader Programming

Raymarching, multi-pass rendering, shader feedback loops, and noise-based procedural textures in GLSL.

⏱ 35 min read shaders GLSL raymarching SDF feedback procedural texture

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:

  1. Calculate the ray direction
  2. March along the ray in small steps
  3. At each step, query the SDF — “how far am I from the nearest surface?”
  4. 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