Shadertoy has scenes with shadows, reflections and fog, and there is not a single triangle in the source. I wanted to know how that works. You need one function that tells you, for every point in space, how far away the nearest surface is. Almost everything else falls out of that.
Drag to orbit the camera, scroll to zoom. A sphere, a rounded box and a torus float above the floor and merge when they meet. The steps chip swaps the picture for the number of steps per pixel, blend k sets how softly the shapes run into each other, and max steps is the step budget.
The whole idea in one loop
Every pixel sends a ray out of the camera. At its current point the shader asks the scene for the distance to the nearest surface and walks exactly that far. Then it asks again.
float t = 0.0;
for (int i = 0; i < uMaxSteps; i++) {
float d = map(ro + rd * t);
if (d < HIT_EPS + HIT_EPS_SLOPE * t) return t; // hit
t += d;
if (t > TMAX) return -1.0; // too far away
}
return -1.0; // out of steps
This is safe because the distance is the radius of the largest sphere around the point that is guaranteed to be empty. A step of exactly that length cannot skip anything. The test shows it nicely: a sphere of radius 1 sits at z = 5, the ray starts at the origin. The first call returns 4, the ray is at the surface, the second call returns 0. Two calls, done. A ray at x = 0.995 skims just inside the silhouette of the same sphere and needs 28 steps, because every distance is tiny.
Shapes are formulas
A sphere is the length of the vector minus the radius. The torus is hardly more: first the distance to the ring in the xz plane, then the distance to the tube.
inline float sdSphere(Vec3 p, float radius) { return length(p) - radius; }
inline float sdTorus(Vec3 p, float ring, float tube) {
const float qx = std::sqrt(p.x * p.x + p.z * p.z) - ring;
return std::sqrt(qx * qx + p.y * p.y) - tube;
}
Inside, the values are negative. The test checks that with numbers I can work out by hand: a sphere of radius 1 at distance 3 gives 2, at the center -1. For the box it matters whether the nearest point is on a face, an edge or a corner, which gives 2, the square root of 2 and the square root of 3.
Merging is done by a function that gives a soft transition instead of min(a, b):
inline float smin(float a, float b, float k) {
if (k <= 0.0F) return std::fmin(a, b);
const float h = std::fmax(k - std::fabs(a - b), 0.0F) / k;
return std::fmin(a, b) - h * h * k * 0.25F;
}
If the two values are further apart than k, you get the normal minimum. Otherwise the result dips below it by up to k / 4, which makes the bulge where sphere and torus meet. The test checks exactly these bounds on a grid, and smin(1, 1, 1) has to be 0.75. With blend k at 0.0 it is the hard minimum again.
Heatmap
The steps chip colors every pixel by its step count, from paper over teal to orange at 48. Dark means the budget ran out before the ray got anywhere.
In the middle of a surface it takes a few steps. At silhouettes it turns orange, because the ray crawls along the surface there. The floor towards the horizon becomes an orange band for the same reason. With 64 steps a thin dark line stays at the horizon, with 128 it disappears, with 16 the floor breaks off as a circle and the rest turns into sky.
Shadows and occlusion come for free
The normal is the gradient of the field. The shader takes four lookups on the corners of a tetrahedron, which needs fewer calls than six lookups along the axes. The same field gives the shadow: from the hit point a second ray runs towards the light, and on the way the shader remembers the smallest ratio of distance to the field over distance traveled.
float res = 1.0;
float t = 0.02;
for (int i = 0; i < 40; i++) {
float d = map(ro + rd * t);
res = min(res, 8.0 * d / t);
t += clamp(d, 0.02, 0.5);
if (res < 0.001 || t > 12.0) break;
}
A ray that just passes a sphere gets a value between 0 and 1, and that value becomes the soft edge. The 8 controls the hardness. For occlusion the shader samples five points along the normal:
for (int i = 0; i < 5; i++) {
float h = 0.01 + 0.12 * float(i) / 4.0;
occ += (h - map(p + n * h)) * weight;
weight *= 0.95;
}
In the open, the distance at each of these points is exactly as big as the height h, so the difference is 0. If something stands next to it, the distance is smaller and the point gets darker. It is still expensive: per pixel that is up to 64 steps, plus up to 40 for the shadow, 4 for the normal and 5 for the occlusion, each with three distance functions.
Pitfalls
A fixed hit threshold is too strict when pixels far away are bigger than the threshold. So I let it grow with the distance (HIT_EPS + HIT_EPS_SLOPE * t). For a very shallow ray creeping towards the floor from a height of 0.3, I need 39 steps instead of 78. I recomputed those numbers with the same algorithm, the test states that the growing threshold needs fewer steps.
The whole trick depends on the function never promising too much distance. The scene keeps that promise, also with the smooth union and the rotations, the test compares 12,000 random point pairs for it. If you stretch a shape or bend the surface with a sine, you overestimate the distance and the ray jumps through thin objects. The usual fix is to shorten the step by a factor below 1.
At the horizon the floor costs the most, see the heatmap. The fog (exp(-0.0025 * t * t)) makes sure you barely see the gaps there.
The upper bound of the loop is a uniform. That works in WebGL2, in GLSL ES 1.00 it would have had to be a constant. For weak graphics cards the res chip renders into a texture half the size and scales it up. I measured 60 frames per second on an Apple GPU, and with SwiftShader (pure software) 34 at 960 by 540 and 60 again at half the resolution. There is no anti-aliasing, the edges are stair-stepped.
You can find the code in the repo under ray-marching. The project Game Dev Snippets collects all demos of the series.