Advanced Shader Programming
Build a raymarched scene with multi-pass rendering, ping-pong buffers, and Phong lighting entirely inside a GLSL fragment shader.
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.