Processing Expert Article 1

Advanced Shader Programming

Build a raymarched scene with multi-pass rendering, ping-pong buffers, and Phong lighting entirely inside a GLSL fragment shader.

⏱ 55 min shaders GLSL raymarching PGraphics SDF

Processing’s PShader class exposes the full OpenGL pipeline, which means you can run arbitrarily complex GLSL programs against a single full-screen quad and build entire rendering engines inside the GPU. This article covers multi-pass rendering, ping-pong feedback buffers, and a complete raymarched scene with soft shadows and ambient occlusion.

Multi-Pass Rendering with PGraphics

The core trick is treating PGraphics objects as off-screen render targets that you feed as uniforms to a subsequent pass shader.

PGraphics bufA, bufB;
PShader blurShader, compositeShader;

void setup() {
  size(1280, 720, P2D);
  bufA = createGraphics(width, height, P2D);
  bufB = createGraphics(width, height, P2D);

  blurShader      = loadShader("blur.glsl");
  compositeShader = loadShader("composite.glsl");
}

void draw() {
  // Pass 1 — render scene geometry into bufA
  bufA.beginDraw();
  bufA.background(0);
  bufA.noStroke();
  bufA.fill(255, 80, 40);
  bufA.ellipse(mouseX, mouseY, 120, 120);
  bufA.endDraw();

  // Pass 2 — blur bufA into bufB
  blurShader.set("tex",        bufA);
  blurShader.set("resolution", float(width), float(height));
  bufB.beginDraw();
  bufB.shader(blurShader);
  bufB.rect(0, 0, width, height);
  bufB.endDraw();

  // Pass 3 — composite bufA over blurred bufB
  compositeShader.set("sharp",  bufA);
  compositeShader.set("glow",   bufB);
  shader(compositeShader);
  rect(0, 0, width, height);
  resetShader();
}

Ping-Pong Buffers for Feedback Effects

Feedback requires two buffers that swap roles each frame. The previous frame’s output becomes the current frame’s input.

PGraphics[] ping = new PGraphics[2];
int write = 0, read = 1;
PShader feedbackShader;

void setup() {
  size(1280, 720, P2D);
  for (int i = 0; i < 2; i++) {
    ping[i] = createGraphics(width, height, P2D);
    ping[i].beginDraw();
    ping[i].background(0);
    ping[i].endDraw();
  }
  feedbackShader = loadShader("feedback.glsl");
}

void draw() {
  feedbackShader.set("previous",   ping[read]);
  feedbackShader.set("resolution", float(width), float(height));
  feedbackShader.set("time",       millis() / 1000.0);

  ping[write].beginDraw();
  ping[write].shader(feedbackShader);
  ping[write].rect(0, 0, width, height);
  ping[write].endDraw();

  image(ping[write], 0, 0);

  // Swap
  int tmp = write; write = read; read = tmp;
}

The feedback.glsl fragment shader reads the previous buffer with a slight zoom and rotation each frame, creating trails that drift inward:

// feedback.glsl
uniform sampler2D previous;
uniform vec2      resolution;
uniform float     time;

void main() {
  vec2 uv   = gl_FragCoord.xy / resolution;
  vec2 cent = uv - 0.5;
  float angle = 0.003;
  vec2 rotated = vec2(
    cent.x * cos(angle) - cent.y * sin(angle),
    cent.x * sin(angle) + cent.y * cos(angle)
  );
  vec2 sampleUV = rotated * 0.998 + 0.5;

  vec4 prev = texture2D(previous, sampleUV) * 0.97;
  float spot = smoothstep(0.04, 0.0,
    length(uv - vec2(0.5 + 0.3 * sin(time), 0.5 + 0.3 * cos(time * 0.7))));

  gl_FragColor = prev + vec4(spot);
}

Raymarching in a Fragment Shader

Raymarching iterates a ray from the camera through each pixel, stepping along it until the ray hits a surface defined by a signed distance function (SDF). The SDF returns a negative value inside the object, zero on the surface, and positive outside.

SDF Primitives

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdBox(vec3 p, vec3 b) {
  vec3 q = abs(p) - b;
  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

float sdPlane(vec3 p, vec3 n, float h) {
  return dot(p, normalize(n)) + h;
}

SDF Scene Composition

Boolean operations combine primitives through min (union), max (intersection), and subtraction:

float scene(vec3 p) {
  float sphere = sdSphere(p - vec3(0.0, 0.5, 0.0), 0.8);
  float box    = sdBox(p - vec3(1.8, 0.4, 0.0), vec3(0.5, 0.4, 0.5));
  float plane  = sdPlane(p, vec3(0.0, 1.0, 0.0), 0.0);

  float d = min(sphere, box);   // union of sphere and box
  d = min(d, plane);            // add ground plane
  return d;
}

Normal Estimation via Central Differences

The surface normal at a point is estimated by sampling the SDF slightly offset in each axis:

vec3 calcNormal(vec3 p) {
  const float e = 0.001;
  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))
  ));
}

Phong Lighting

Once the normal is known, standard Phong lighting applies:

vec3 phong(vec3 p, vec3 rd, vec3 lightPos, vec3 albedo) {
  vec3 n    = calcNormal(p);
  vec3 l    = normalize(lightPos - p);
  vec3 v    = -rd;
  vec3 h    = normalize(l + v);

  float diff = max(dot(n, l), 0.0);
  float spec = pow(max(dot(n, h), 0.0), 64.0);
  float amb  = 0.05;

  return albedo * (amb + diff) + vec3(spec);
}

Full Example: Raymarched Scene with Soft Shadows and AO

The Processing sketch loads a single fragment shader and passes camera and time uniforms:

PShader ray;

void setup() {
  size(1280, 720, P2D);
  ray = loadShader("rayscene.glsl");
}

void draw() {
  ray.set("resolution", float(width), float(height));
  ray.set("time",       millis() / 1000.0);
  ray.set("mouse",      float(mouseX) / width, float(mouseY) / height);
  shader(ray);
  rect(0, 0, width, height);
  resetShader();
}

The fragment shader rayscene.glsl contains the complete renderer:

uniform vec2  resolution;
uniform float time;
uniform vec2  mouse;

// --- SDFs ---
float sdSphere(vec3 p, float r) { return length(p) - r; }
float sdBox(vec3 p, vec3 b) {
  vec3 q = abs(p) - b;
  return length(max(q,0.0)) + min(max(q.x,max(q.y,q.z)),0.0);
}
float sdPlane(vec3 p) { return p.y; }

float scene(vec3 p) {
  float s = sdSphere(p - vec3(sin(time)*0.5, 0.6, 0.0), 0.55);
  float b = sdBox(p - vec3(1.5, 0.4, -0.5), vec3(0.4, 0.4, 0.4));
  return min(min(s, b), sdPlane(p));
}

// --- Normal ---
vec3 normal(vec3 p) {
  const float e = 0.0005;
  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))
  ));
}

// --- Soft shadow ---
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
  float res = 1.0;
  float t   = mint;
  for (int i = 0; i < 48; i++) {
    float h = scene(ro + rd * t);
    if (h < 0.001) return 0.0;
    res = min(res, k * h / t);
    t  += clamp(h, 0.01, 0.2);
    if (t > maxt) break;
  }
  return clamp(res, 0.0, 1.0);
}

// --- Ambient occlusion (cheap, 5 samples along normal) ---
float ao(vec3 p, vec3 n) {
  float occ = 0.0, scale = 1.0;
  for (int i = 1; i <= 5; i++) {
    float d   = 0.08 * float(i);
    float sdf = scene(p + n * d);
    occ  += (d - sdf) * scale;
    scale *= 0.7;
  }
  return clamp(1.0 - occ * 1.5, 0.0, 1.0);
}

void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * resolution) / resolution.y;

  // Camera
  float camAngle = (mouse.x - 0.5) * 6.28;
  vec3 ro = vec3(3.5 * sin(camAngle), 2.0, 3.5 * cos(camAngle));
  vec3 target = vec3(0.0, 0.5, 0.0);
  vec3 fwd = normalize(target - ro);
  vec3 rgt = normalize(cross(vec3(0,1,0), fwd));
  vec3 up  = cross(fwd, rgt);
  vec3 rd  = normalize(uv.x * rgt + uv.y * up + 1.8 * fwd);

  vec3 col = vec3(0.15, 0.18, 0.22); // sky

  // March
  float t = 0.001;
  for (int i = 0; i < 128; i++) {
    vec3 p = ro + rd * t;
    float d = scene(p);
    if (d < 0.0005) {
      vec3  n    = normal(p);
      vec3  ldir = normalize(vec3(2.0, 4.0, 1.5));
      float sha  = softShadow(p + n*0.002, ldir, 0.01, 8.0, 12.0);
      float occ  = ao(p, n);
      float diff = max(dot(n, ldir), 0.0);
      float spec = pow(max(dot(reflect(-ldir,n),-rd),0.0), 48.0);

      vec3 albedo = (p.y < 0.01) ? vec3(0.25) : vec3(0.6, 0.3, 0.9);
      col = albedo * (0.06 + diff * sha * occ)
          + vec3(0.9, 0.85, 0.7) * spec * sha;
      col = mix(col, vec3(0.15, 0.18, 0.22), clamp(t / 12.0, 0.0, 1.0));
      break;
    }
    t += d;
    if (t > 20.0) break;
  }

  col = pow(col, vec3(0.4545)); // gamma
  gl_FragColor = vec4(col, 1.0);
}

Save both files inside your sketch’s data/ folder. The shader auto-reloads in Processing 4 if you use shader() every frame; there is no need to restart the sketch to see GLSL edits when combined with a file-watcher workflow.

Notes on Shader Debugging

Processing silently falls back to the default renderer if a shader fails to compile. Always check the console for GLSL error messages. Use printShader() (not a built-in, but println(ray)) to confirm the shader object is non-null. For isolating passes, temporarily image(bufA, 0, 0) to inspect intermediate buffers before full composition.