Calculating Surface Normals
by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026
Run the shader to adjust these controls.
What it demonstrates
A distance field contains no normals, yet lighting needs them. The normal is the direction the field increases fastest, so it can be recovered by sampling the field either side of the surface point on each axis. This shader shows that estimate three ways: as a colour, as shading, and as an error map that exposes where the estimate is worst.
Shader inputs
void mainImage(out vec4 fragColor, in vec2 fragCoord)
Called once per pixel. Write the colour to fragColor.
-
iResolutionvec3 - Viewport size in pixels (z is the pixel aspect ratio).
-
iTimefloat - Seconds since the shader started.
-
iTimeDeltafloat - Seconds since the previous frame.
-
iFrameRatefloat - Frames per second, smoothed.
-
iFrameint - Frames rendered since the start.
-
iMousevec4 - Mouse position: xy while held, zw of the last click.
-
iDatevec4 - Year, month, day, and seconds within the day.
-
iChannel0sampler2D - Texture bound to channel 0.
-
iChannel1sampler2D - Texture bound to channel 1.
-
iChannel2sampler2D - Texture bound to channel 2.
-
iChannel3sampler2D - Texture bound to channel 3.
-
iChannelResolutionvec3[4] - Pixel size of each bound channel texture.
-
iChannelTimefloat[4] - Playback time of each channel, in seconds.
-
iSampleRatefloat - Audio sample rate, always 44100.
Common1
// Camera and marching only. Normals are deliberately absent from the shared
// pass here, because deriving them is this project's lesson and belongs in the
// Image pass where it can be read.
float mapScene(vec3 position);
vec3 cameraRay(vec2 fragCoord, vec3 origin, vec3 target, float lens) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
vec3 forward = normalize(target - origin);
vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward));
vec3 up = cross(forward, right);
return normalize(forward * lens + right * uv.x + up * uv.y);
}
float marchScene(vec3 origin, vec3 direction, float maxDistance) {
float travelled = 0.0;
for (int i = 0; i < 96; i++) {
vec3 position = origin + direction * travelled;
float dist = mapScene(position);
if (dist < 0.001) return travelled;
travelled += dist;
if (travelled > maxDistance) break;
}
return -1.0;
}
vec3 skyColor(vec3 direction) {
return mix(vec3(0.05, 0.07, 0.12), vec3(0.16, 0.22, 0.34), direction.y * 0.5 + 0.5);
}
Main Image6
uniform float uEpsilon; // @param 0.0002..0.05 = 0.0015 "Sample epsilon"
uniform int uViewMode; // @param 0..2 = 1 "View mode"
uniform float uBlend; // @param 0.0..0.5 = 0.28 "Shape blend"
float sminPoly(float a, float b, float k) {
k = max(k, 0.0001);
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
float mapScene(vec3 position) {
float angle = iTime * 0.3;
float c = cos(angle);
float s = sin(angle);
vec3 p = vec3(c * position.x - s * position.z, position.y, s * position.x + c * position.z);
float ball = length(p - vec3(0.0, 0.35, 0.0)) - 0.62;
vec2 ring = vec2(length(p.xz) - 0.80, p.y + 0.30);
float torus = length(ring) - 0.22;
return sminPoly(ball, torus, uBlend);
}
vec3 fieldGradient(vec3 position, float epsilon) {
vec2 offset = vec2(epsilon, 0.0);
return vec3(
mapScene(position + offset.xyy) - mapScene(position - offset.xyy),
mapScene(position + offset.yxy) - mapScene(position - offset.yxy),
mapScene(position + offset.yyx) - mapScene(position - offset.yyx)) / (2.0 * epsilon);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 origin = vec3(0.0, 0.55, -3.1);
vec3 direction = cameraRay(fragCoord, origin, vec3(0.0, 0.05, 0.0), 1.7);
float hit = marchScene(origin, direction, 18.0);
if (hit < 0.0) {
fragColor = vec4(skyColor(direction), 1.0);
return;
}
vec3 position = origin + direction * hit;
vec3 gradient = fieldGradient(position, uEpsilon);
vec3 normal = normalize(gradient);
vec3 color;
if (uViewMode == 0) {
color = 0.5 + 0.5 * normal;
} else if (uViewMode == 1) {
vec3 toLight = normalize(vec3(0.6, 0.9, -0.6));
float diffuse = max(dot(normal, toLight), 0.0);
float specular = pow(max(dot(reflect(direction, normal), toLight), 0.0), 40.0);
color = vec3(0.62, 0.66, 0.74) * (0.12 + 0.88 * diffuse) + specular * 0.4;
} else {
float error = abs(length(gradient) - 1.0);
color = mix(vec3(0.06, 0.10, 0.16), vec3(1.0, 0.35, 0.20), clamp(error * 8.0, 0.0, 1.0));
}
fragColor = vec4(color, 1.0);
}
Learn from this shader
How it works
Six extra scene evaluations give the central difference gradient, two per axis. Dividing by twice the epsilon turns those differences into a real derivative, which matters because a correct distance field has a gradient of unit length everywhere. That property is what the third view mode checks: it measures how far the gradient length is from one and paints the difference. The scene is a sphere blended into a torus with a smooth minimum, chosen because the blend region is exactly where the field stops being a true distance and the error becomes visible. The Common pass here carries only the camera and the march loop; the gradient stays in the Image pass because it is the subject.
Try changing
Set View mode to the error map and raise Shape blend: the fillet lights up because the smooth minimum shortens distances there. Then take Sample epsilon to its minimum and watch the whole surface turn noisy as differences fall below float precision, and to its maximum to see curvature smeared into flat facets. Both extremes are real failures, in opposite directions.
Using it in a game
Any distance based renderer needs this to shade at all, and the same trick recovers gradients from volume textures, heightfields and distance atlases. The honest catch is cost: six scene evaluations per shaded pixel, often more than the march that found the surface.
Explore the techniques
Continue with curated explanations and progressively related examples.
Discussion