Learn Creative Coding (#40) - Raymarching: Materials and Lighting

We've been building 3D worlds from pure math for two episodes now. Episode 38 gave us the core raymarcher -- spheres, ground planes, Lambertian diffuse lighting, material IDs. Episode 39 added boolean operations, smooth blending, morphing, domain repetition, twist and bend deformations. The geometry toolkit is solid.
But the lighting? Still basically flat. One directional light, one dot product, a constant ambient term. Everything looks like it's made of the same matte clay. A red sphere and a blue sphere have different colors but the same surface quality. No shine, no shadows, no reflections. A real scene has materials that behave differently -- matte plastic absorbs light, polished metal reflects it, glass bends it. And it has shadows, because light gets blocked by objects. And it has ambient occlusion, because crevices and tight spaces are darker than exposed surfaces.
Today we fix all of that. By the end of this episode your raymarched scenes will have specular highlights, hard and soft shadows, ambient occlusion, distance fog, reflections, and the Fresnel effect. The full lighting stack, all running in a fragment shader, all from math.
Phong lighting: the classic three-component model
Lambertian diffuse gives you one piece of the puzzle -- how much light hits the surface based on its angle. But real surfaces do more than just absorb light directionally. The Phong lighting model breaks surface appearance into three components:
Ambient -- constant minimum light. Fakes indirect illumination from light bouncing off other surfaces. Without it, anything facing away from the light would be pitch black, which looks wrong.
Diffuse -- Lambert's cosine law, which we already have. max(dot(normal, lightDir), 0.0). The matte component -- how much light the surface receives based on its orientation toward the light source.
Specular -- the shiny highlight. When light bounces off a smooth surface, it creates a bright spot where the reflected light direction aligns with the view direction. Shiny surfaces have tight, bright specular highlights. Matte surfaces have broad, dim ones (or none at all).
vec3 phong(vec3 p, vec3 n, vec3 rd, vec3 lightDir, vec3 lightCol,
vec3 matColor, float shininess) {
// ambient
vec3 ambient = matColor * 0.12;
// diffuse
float diff = max(dot(n, lightDir), 0.0);
vec3 diffuse = matColor * lightCol * diff;
// specular
vec3 refl = reflect(-lightDir, n);
float spec = pow(max(dot(refl, -rd), 0.0), shininess);
vec3 specular = lightCol * spec * 0.5;
return ambient + diffuse + specular;
}
The reflect(-lightDir, n) function computes the mirror reflection of the light direction across the surface normal. Then dot(refl, -rd) measures how well that reflected direction aligns with the view direction (the ray we cast from the camera). pow(..., shininess) raises it to a power -- higher shininess = tighter, smaller highlight. A shininess of 8 gives a broad plastic sheen. A shininess of 128 gives a tiny pinpoint highlight like polished chrome.
The * 0.5 on specular is an intensity control. Without it the highlight can blow out to pure white too easily, especially on bright surfaces. Tweak this to taste.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
float sdSphere(vec3 p, float r) { return length(p) - r; }
float sdPlane(vec3 p, float h) { return p.y - h; }
vec2 scene(vec3 p) {
vec2 ground = vec2(sdPlane(p, 0.0), 0.0);
// matte sphere
vec2 s1 = vec2(sdSphere(p - vec3(-2.0, 1.0, 2.0), 1.0), 1.0);
// glossy sphere
vec2 s2 = vec2(sdSphere(p - vec3(0.0, 0.8, 1.5), 0.8), 2.0);
// shiny sphere
vec2 s3 = vec2(sdSphere(p - vec3(2.0, 1.0, 2.0), 1.0), 3.0);
vec2 res = ground;
if (s1.x < res.x) res = s1;
if (s2.x < res.x) res = s2;
if (s3.x < res.x) res = s3;
return res;
}
vec3 getNormal(vec3 p) {
float e = 0.001;
return normalize(vec3(
scene(p + vec3(e, 0, 0)).x - scene(p - vec3(e, 0, 0)).x,
scene(p + vec3(0, e, 0)).x - scene(p - vec3(0, e, 0)).x,
scene(p + vec3(0, 0, e)).x - scene(p - vec3(0, 0, e)).x
));
}
vec2 raymarch(vec3 ro, vec3 rd) {
float t = 0.0;
float id = -1.0;
for (int i = 0; i < 100; i++) {
vec3 p = ro + rd * t;
vec2 res = scene(p);
if (res.x < 0.001) { id = res.y; break; }
if (t > 80.0) break;
t += res.x;
}
return vec2(t, id);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float angle = u_time * 0.2;
vec3 ro = vec3(sin(angle) * 6.0, 3.0, cos(angle) * 6.0 - 2.0);
vec3 target = vec3(0.0, 0.8, 2.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.2 * fwd);
vec2 hit = raymarch(ro, rd);
vec3 color = vec3(0.05, 0.05, 0.1);
if (hit.x < 80.0) {
vec3 p = ro + rd * hit.x;
vec3 n = getNormal(p);
vec3 ld = normalize(vec3(1.0, 1.2, -0.5));
vec3 matColor;
float shininess;
if (hit.y < 0.5) {
// ground: checkerboard
float ch = mod(floor(p.x) + floor(p.z), 2.0);
matColor = mix(vec3(0.2), vec3(0.35), ch);
shininess = 4.0;
} else if (hit.y < 1.5) {
matColor = vec3(0.8, 0.2, 0.15); // matte red
shininess = 8.0;
} else if (hit.y < 2.5) {
matColor = vec3(0.2, 0.6, 0.3); // glossy green
shininess = 64.0;
} else {
matColor = vec3(0.3, 0.35, 0.8); // shiny blue
shininess = 256.0;
}
// phong
vec3 ambient = matColor * 0.12;
float diff = max(dot(n, ld), 0.0);
vec3 diffuse = matColor * diff;
vec3 refl = reflect(-ld, n);
float spec = pow(max(dot(refl, -rd), 0.0), shininess);
vec3 specular = vec3(1.0) * spec * 0.5;
color = ambient + diffuse + specular;
}
gl_FragColor = vec4(color, 1.0);
}
Three spheres, three different shininess values. The red one (shininess 8) has a broad, soft highlight -- matte plastic. The green one (64) has a tighter, brighter spot -- like polished wood or semi-gloss paint. The blue one (256) has a tiny intense pinpoint -- like a metal ball bearing. Same Phong equation for all three, just different exponents. The material IS the exponent.
Hard shadows: marching toward the light
Right now nothing casts shadows. The ground beneath the spheres is uniformly lit even though the spheres should be blocking the light. In a mesh-based renderer, shadows need shadow maps or shadow volumes -- complex multi-pass techniques. In a raymarcher, shadows are trivial: from the surface point, march a new ray toward the light. If it hits something before reaching the light, the point is in shadow.
float hardShadow(vec3 p, vec3 lightDir, float maxDist) {
float t = 0.02; // start slightly off the surface
for (int i = 0; i < 50; i++) {
float d = scene(p + lightDir * t).x;
if (d < 0.001) return 0.0; // hit something: full shadow
t += d;
if (t > maxDist) break;
}
return 1.0; // reached the light: no shadow
}
The t = 0.02 offset is critical. Without it, the shadow ray starts exactly on the surface where scene() returns approximately 0.0, and it immediately thinks it hit something. The offset pushes the start point just above the surface so it clears the object it's standing on. This is called "shadow acne" prevention -- same problem exists in traditional rasterized shadow maps, just solved differently.
Drop this into the shading:
float shadow = hardShadow(p, ld, 20.0);
color = ambient + (diffuse + specular) * shadow;
Only the ambient survives when the point is in shadow. Diffuse and specular are multiplied by the shadow factor (0 or 1), so they vanish in occluded areas. The ground now shows dark patches beneath the spheres. Depending on the light angle, the sphere shadows stretch and overlap. One extra raymarching loop per pixel (only for lit pixels), and your scene suddenly looks dramatically more real.
Hard shadows have sharp, crisp edges. In the real world, most light sources have some size -- they're not infinitely small points. So real shadows have soft edges, called penumbra. The area that can see ALL of the light source is fully lit. The area that can see NONE of it is fully shadowed. The area that can see PART of it gets partial shadow -- the penumbra. We can fake this.
Soft shadows: Inigo Quilez's technique
The soft shadow trick tracks how close the shadow ray passes to nearby surfaces during its march. The closer it gets without actually hitting, the darker the penumbra. Rays that barely clear an obstacle produce dark, almost-shadow. Rays that pass far from any surface produce full light.
float softShadow(vec3 p, vec3 lightDir, float mint, float maxt, float k) {
float result = 1.0;
float t = mint;
for (int i = 0; i < 50; i++) {
float d = scene(p + lightDir * t).x;
if (d < 0.001) return 0.0;
result = min(result, k * d / t);
t += d;
if (t > maxt) break;
}
return clamp(result, 0.0, 1.0);
}
The magic line is result = min(result, k * d / t). d / t is the ratio of "how far from the nearest surface" to "how far along the ray we've traveled." If we're close to a surface relative to how far we've gone, the ratio is small -- deep penumbra. If we're far from any surface, the ratio is large -- full light. The k parameter controls the softness: small k (like 4) gives wide, soft penumbras. Large k (like 32) gives narrow penumbras approaching hard shadows.
The min() across the entire march ensures the darkest point along the entire ray determines the final result. Even if the ray later moves away from surfaces, that one close approach is what casts the shadow.
This technique was invented by Inigo Quilez -- the same person behind the smooth minimum, the cosine palette, and basically half the techniques in shader art. His soft shadow function runs in the same loop as hard shadows with just two extra operations per step. The performace cost is essentially free.
float shadow = softShadow(p, ld, 0.02, 20.0, 8.0);
color = ambient + (diffuse + specular) * shadow;
Replace the hard shadow with this and suddenly the shadow edges are soft, with smooth gradients from light to dark. Objects close to the ground cast sharp-ish shadows (the ray doesn't have far to travel, so the d/t ratio doesn't change much). Objects far from the ground cast softer shadows (the ray travels further, so even a small d creates a large penumbra). This matches real-world physics where contact shadows are sharp and distant shadows are fuzzy.
Ambient occlusion: darkening the crevices
Ambient light doesn't come from one direction -- it's everywhere. But in tight spaces (crevices, corners, under objects), less ambient light reaches the surface because nearby geometry blocks it. This subtle darkening is called ambient occlusion (AO). It adds a huge amount of depth and realism to a scene.
In raymarching, AO is surprisingly cheap. Sample the SDF at a few points along the surface normal. If nearby surfaces are close (the SDF returns small values), the point is occluded:
float ambientOcclusion(vec3 p, vec3 n) {
float ao = 0.0;
float step = 0.08;
for (int i = 1; i <= 5; i++) {
float dist = step * float(i);
float d = scene(p + n * dist).x;
ao += (dist - d) / dist;
}
return 1.0 - ao * 0.2;
}
We sample at 5 points along the normal direction, at increasing distances (0.08, 0.16, 0.24, 0.32, 0.40 units). At each sample, we compare the expected distance (dist -- if nothing was nearby, the SDF should return approximately this value) with the actual distance (d). If d is much smaller than dist, there's a nearby surface blocking ambient light. We accumulate the differences and subtract from 1.0.
The * 0.2 at the end controls the AO intensity. Higher values make the darkening more agressive, lower values make it subtler. Multiply the ambient component by the AO factor:
float ao = ambientOcclusion(p, n);
vec3 ambient = matColor * 0.12 * ao;
The ground directly underneath a sphere gets darker. The junction where the sphere meets the ground gets darker. Any tight corners or narrow gaps between shapes get darker. It's like the scene suddenly has "depth" that it didn't have before. The geometric shapes feel like they exist in a physical space with volume and proximity.
Five extra SDF evaluations per pixel is the cost. Totally worth it.
Fog: depth through atmosphere
Distance fog is the cheapest trick that makes the biggest visual difference. Mix the surface color with a background color based on how far the ray traveled. Far away objects fade into the background. Close objects are crisp:
vec3 applyFog(vec3 color, float dist, vec3 fogColor) {
float fogAmount = 1.0 - exp(-0.03 * dist);
return mix(color, fogColor, fogAmount);
}
The exp(-0.03 * dist) creates exponential falloff. At distance 0, fogAmount is 0 (no fog). At distance 33 (-0.03 * 33 = -1.0, exp(-1) = 0.37), about 63% fog. At distance 66, about 86% fog. The 0.03 controls the density -- smaller values for thin atmosphere, larger for thick fog.
color = applyFog(color, hit.x, vec3(0.05, 0.05, 0.1));
One line, applied after all lighting, and suddenly your scene has atmospheric depth. Things fade into the background naturally instead of just cutting off at the max marching distance. It's the same technique we used in the domain repetition example in episode 39, but now we're applying it to properly lit scenes.
You can get creative with fog color. Match it to the background for invisible fog (just adds depth). Use a warm color for golden-hour atmosphere. Use a cold blue for underwater feeling. The fog color IS the mood of your scene.
Reflections: bouncing rays
One of the beautiful things about raymarching is that reflections are conceptually simple: when a ray hits a reflective surface, compute the reflection direction and march again. The reflected ray's color blends with the surface color:
// in the shading section, after computing base color:
if (hit.y > 2.5) {
// shiny material: add reflection
vec3 reflDir = reflect(rd, n);
vec2 reflHit = raymarch(p + n * 0.02, reflDir);
if (reflHit.x < 80.0) {
vec3 reflP = p + n * 0.02 + reflDir * reflHit.x;
vec3 reflN = getNormal(reflP);
float reflDiff = max(dot(reflN, ld), 0.0);
vec3 reflMatColor;
if (reflHit.y < 0.5) {
float ch = mod(floor(reflP.x) + floor(reflP.z), 2.0);
reflMatColor = mix(vec3(0.2), vec3(0.35), ch);
} else if (reflHit.y < 1.5) {
reflMatColor = vec3(0.8, 0.2, 0.15);
} else {
reflMatColor = vec3(0.2, 0.6, 0.3);
}
vec3 reflColor = reflMatColor * (reflDiff + 0.12);
color = mix(color, reflColor, 0.4);
}
}
The reflect(rd, n) function mirrors the incoming ray direction across the surface normal. We march a new ray from just above the surface (p + n * 0.02 to avoid self-intersection) in the reflection direction. If it hits something, we shade that hit point and blend it with the original surface color. The 0.4 blending factor controls how reflective the surface is -- 0.0 is no reflection, 1.0 is a perfect mirror.
This adds a second raymarching loop for reflective pixels. That's roughly doubling the cost for those pixels. For creative coding, that's fine. For a complex scene with many reflective surfaces, you'd want to limit reflection depth (no reflected reflections, or just one bounce). Recursive reflections are possible but each bounce adds another full raymarching pass. Two bounces is usually enough for convincing results.
The Fresnel effect: angle-dependent reflectivity
Real reflective surfaces don't reflect uniformly. Look at a glass table from straight above -- you see through it. Look at it from a sharp angle -- it reflects like a mirror. This is the Fresnel effect: reflectivity increases at grazing angles.
float fresnel(vec3 viewDir, vec3 normal, float power) {
return pow(1.0 - max(dot(-viewDir, normal), 0.0), power);
}
The dot product between the view direction and the surface normal gives the cosine of the viewing angle. Straight on (dot = 1.0), the result is 0 -- no extra reflection. At a grazing angle (dot near 0), the result approaches 1 -- maximum reflection. The power parameter controls how quickly reflectivity ramps up at shallow angles. A power of 3-5 gives a realistic look for most materials.
Use it to modulate the reflection blending factor:
float fres = fresnel(rd, n, 4.0);
color = mix(color, reflColor, fres * 0.6);
Now the center of the sphere shows mostly the base material color, but the edges shimmer with reflected light. It's a subtle effect but it makes spheres look dramatically more realistic. Glass, water, polished stone, car paint -- any smooth surface has this property. Without Fresnel, reflective surfaces look flat and uniform. With it, they look dimensional.
Putting it all together: a complete scene
Let's combine everything. Three materials (matte, glossy, reflective), soft shadows, ambient occlusion, fog, Fresnel reflections. The full lighting stack:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
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, float h) { return p.y - h; }
vec2 scene(vec3 p) {
vec2 ground = vec2(sdPlane(p, 0.0), 0.0);
// matte red sphere
vec2 s1 = vec2(sdSphere(p - vec3(-2.2, 1.0, 0.0), 1.0), 1.0);
// glossy green box
vec2 s2 = vec2(sdBox(p - vec3(0.0, 0.7, 0.0), vec3(0.7)), 2.0);
// reflective blue sphere
vec2 s3 = vec2(sdSphere(p - vec3(2.2, 1.0, 0.0), 1.0), 3.0);
vec2 res = ground;
if (s1.x < res.x) res = s1;
if (s2.x < res.x) res = s2;
if (s3.x < res.x) res = s3;
return res;
}
vec3 getNormal(vec3 p) {
float e = 0.001;
return normalize(vec3(
scene(p + vec3(e, 0, 0)).x - scene(p - vec3(e, 0, 0)).x,
scene(p + vec3(0, e, 0)).x - scene(p - vec3(0, e, 0)).x,
scene(p + vec3(0, 0, e)).x - scene(p - vec3(0, 0, e)).x
));
}
vec2 raymarch(vec3 ro, vec3 rd) {
float t = 0.0;
float id = -1.0;
for (int i = 0; i < 120; i++) {
vec3 p = ro + rd * t;
vec2 res = scene(p);
if (res.x < 0.001) { id = res.y; break; }
if (t > 80.0) break;
t += res.x;
}
return vec2(t, id);
}
float softShadow(vec3 p, vec3 ld, float mint, float maxt, float k) {
float res = 1.0;
float t = mint;
for (int i = 0; i < 50; i++) {
float d = scene(p + ld * t).x;
if (d < 0.001) return 0.0;
res = min(res, k * d / t);
t += d;
if (t > maxt) break;
}
return clamp(res, 0.0, 1.0);
}
float ambientOcclusion(vec3 p, vec3 n) {
float ao = 0.0;
float step = 0.08;
for (int i = 1; i <= 5; i++) {
float dist = step * float(i);
float d = scene(p + n * dist).x;
ao += (dist - d) / dist;
}
return clamp(1.0 - ao * 0.2, 0.0, 1.0);
}
float fresnel(vec3 rd, vec3 n, float pw) {
return pow(1.0 - max(dot(-rd, n), 0.0), pw);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float a = u_time * 0.15;
vec3 ro = vec3(sin(a) * 7.0, 3.5, cos(a) * 7.0);
vec3 target = vec3(0.0, 0.7, 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.2 * fwd);
vec2 hit = raymarch(ro, rd);
vec3 color = vec3(0.04, 0.04, 0.08);
if (hit.x < 80.0) {
vec3 p = ro + rd * hit.x;
vec3 n = getNormal(p);
vec3 ld = normalize(vec3(0.8, 1.2, -0.5));
// material properties
vec3 matColor;
float shininess;
float reflectivity = 0.0;
if (hit.y < 0.5) {
float ch = mod(floor(p.x) + floor(p.z), 2.0);
matColor = mix(vec3(0.18), vec3(0.32), ch);
shininess = 4.0;
} else if (hit.y < 1.5) {
matColor = vec3(0.75, 0.2, 0.15);
shininess = 8.0;
} else if (hit.y < 2.5) {
matColor = vec3(0.2, 0.55, 0.25);
shininess = 64.0;
} else {
matColor = vec3(0.3, 0.35, 0.8);
shininess = 256.0;
reflectivity = 0.5;
}
// phong lighting
float diff = max(dot(n, ld), 0.0);
vec3 refl = reflect(-ld, n);
float spec = pow(max(dot(refl, -rd), 0.0), shininess);
// shadows + AO
float shadow = softShadow(p, ld, 0.02, 20.0, 8.0);
float ao = ambientOcclusion(p, n);
// combine
vec3 ambient = matColor * 0.1 * ao;
vec3 lit = matColor * diff + vec3(1.0) * spec * 0.4;
color = ambient + lit * shadow;
// reflection for shiny material
if (reflectivity > 0.0) {
vec3 reflDir = reflect(rd, n);
vec2 reflHit = raymarch(p + n * 0.02, reflDir);
if (reflHit.x < 80.0) {
vec3 rp = p + n * 0.02 + reflDir * reflHit.x;
vec3 rn = getNormal(rp);
float rd2 = max(dot(rn, ld), 0.0);
vec3 rmc;
if (reflHit.y < 0.5) {
float ch = mod(floor(rp.x) + floor(rp.z), 2.0);
rmc = mix(vec3(0.18), vec3(0.32), ch);
} else if (reflHit.y < 1.5) {
rmc = vec3(0.75, 0.2, 0.15);
} else {
rmc = vec3(0.2, 0.55, 0.25);
}
vec3 reflColor = rmc * (rd2 + 0.1);
float fres = fresnel(rd, n, 4.0);
color = mix(color, reflColor, reflectivity * fres + reflectivity * 0.2);
}
}
// fog
color = mix(color, vec3(0.04, 0.04, 0.08), 1.0 - exp(-0.025 * hit.x));
}
gl_FragColor = vec4(color, 1.0);
}
This is the scene I'd encourage you to spend time with. Camera orbiting slowly. Three objects -- matte red sphere, glossy green box, reflective blue sphere -- each demonstrating different material properties. Soft shadows stretch across the checkerboard ground. Ambient occlusion darkens the contact points where objects meet the floor. The blue sphere reflects the other objects and the ground, with Fresnel making the edges more reflective than the center. Fog fades the distance into dark blue.
It's a lot of code but look at what each section does. The scene function defines geometry + material ID. The material section maps IDs to colors, shininess, and reflectivity. The lighting section computes Phong with shadows and AO. The reflection section is optional -- only for materials that want it. And fog wraps it all up at the end. Each piece is independently understandable.
Modify the scene. Add more objects. Change the materials. Make the box reflective too. Give the ground a subtle specular (wet floor look). Move the light around with sin(u_time). Every tweak changes the mood dramatically.
A note on performance
Each pixel now does significantly more work than episode 38's minimal raymarcher. The primary ray is the same 100-step march. But then:
- Normal computation: 6 SDF evaluations
- Soft shadow: up to 50 more steps (another raymarch, effectively)
- Ambient occlusion: 5 SDF evaluations
- Reflection (for reflective pixels): another full 120-step raymarch + 6 normal evaluations
For the blue sphere specifically, each pixel might do 300+ SDF evaluations. That sounds like a lot, but a modern GPU handles it fine at 60fps for simple scenes like ours. The SDF functions are cheap (length, abs, min, max -- basic arithmetic). The GPU cores just crunch through them.
Where it gets tight: complex SDF combinaitons (nested smooth booleans, heavy displacement) evaluated hundreds of times per pixel per frame. If your framerate drops, the first thing to try is reducing iteration counts -- drop the shadow march from 50 to 30, the primary march from 120 to 80. You'll lose accuracy in edge cases but gain frames. The second thing: reduce AO samples from 5 to 3. The third: remove reflections from non-critical surfaces.
For creative coding and learning, don't worry about optimization until you need to. Get the visuals right first. Optimize only when the GPU starts sweating.
Where this leads
We've built a complete lighting pipeline today. Phong shading gives surfaces material identity. Shadows ground objects in the scene. Ambient occlusion adds physical depth. Reflections and Fresnel make surfaces interact with their environment. Fog creates atmosphere.
This is the foundation that every subsequent 3D shader episode builds on. When we get to fractals in the coming episodes -- the Mandelbrot set rendered in 3D, Julia sets evolving over time -- we'll use exactly this lighting setup to make them look dramatic. The geometry gets wild, but the lighting stays the same. And post-processing effects will let us blur, bloom, and color-grade the output of scenes like these.
The combination of the geometry toolkit from episodes 38-39 and the lighting toolkit from today gives you everything you need to build genuinely impressive 3D scenes in a fragment shader. No engine, no mesh, no vertex data. Just distance functions, light directions, and the same raymarching loop we wrote two episodes ago. Except now the output actually looks like something rendered in a 3D engine :-)
Allez, wa weten we nu allemaal?
- Phong lighting splits surface appaerance into ambient (constant fill), diffuse (Lambert's cosine law), and specular (shiny highlight via reflected light direction)
- Shininess exponent controls the specular highlight size: low (8) = matte plastic, medium (64) = polished, high (256) = chrome
- Hard shadows: march a ray from the surface toward the light. If it hits anything, the point is in shadow
- Soft shadows: track how close the shadow ray passes to surfaces.
min(result, k * d / t)wherekcontrols penumbra width - Ambient occlusion: sample the SDF along the normal direction. Nearby surfaces (small SDF values) mean less ambient light reaches the point
- Distance fog:
mix(color, fogColor, 1.0 - exp(-density * distance))for exponential depth-based blending - Reflections:
reflect(rd, normal)gives the bounce direction, then march another ray and blend the result - Fresnel effect:
pow(1.0 - dot(-viewDir, normal), power)makes surfaces more reflective at grazing angles - All of these techniques stack -- each one adds a few lines and a few extra SDF evaluations, building up from flat to photorealistic
Sallukes! Thanks for reading.
X