Skip to main content
GameDev.net gamedev.net

Calculating Surface Normals

by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026

Use in your engine

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.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
Audio sample rate, always 44100.
Common1
1 // Camera and marching only. Normals are deliberately absent from the shared
2 // pass here, because deriving them is this project's lesson and belongs in the
3 // Image pass where it can be read.
4 float mapScene(vec3 position);
5
6 vec3 cameraRay(vec2 fragCoord, vec3 origin, vec3 target, float lens) {
7 vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
8 vec3 forward = normalize(target - origin);
9 vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward));
10 vec3 up = cross(forward, right);
11 return normalize(forward * lens + right * uv.x + up * uv.y);
12 }
13
14 float marchScene(vec3 origin, vec3 direction, float maxDistance) {
15 float travelled = 0.0;
16 for (int i = 0; i < 96; i++) {
17 vec3 position = origin + direction * travelled;
18 float dist = mapScene(position);
19 if (dist < 0.001) return travelled;
20 travelled += dist;
21 if (travelled > maxDistance) break;
22 }
23 return -1.0;
24 }
25
26 vec3 skyColor(vec3 direction) {
27 return mix(vec3(0.05, 0.07, 0.12), vec3(0.16, 0.22, 0.34), direction.y * 0.5 + 0.5);
28 }
29
Main Image6
1 uniform float uEpsilon; // @param 0.0002..0.05 = 0.0015 "Sample epsilon"
2 uniform int uViewMode; // @param 0..2 = 1 "View mode"
3 uniform float uBlend; // @param 0.0..0.5 = 0.28 "Shape blend"
4
5 float sminPoly(float a, float b, float k) {
6 k = max(k, 0.0001);
7 float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
8 return mix(b, a, h) - k * h * (1.0 - h);
9 }
10
11 float mapScene(vec3 position) {
12 float angle = iTime * 0.3;
13 float c = cos(angle);
14 float s = sin(angle);
15 vec3 p = vec3(c * position.x - s * position.z, position.y, s * position.x + c * position.z);
16 float ball = length(p - vec3(0.0, 0.35, 0.0)) - 0.62;
17 vec2 ring = vec2(length(p.xz) - 0.80, p.y + 0.30);
18 float torus = length(ring) - 0.22;
19 return sminPoly(ball, torus, uBlend);
20 }
21
22 vec3 fieldGradient(vec3 position, float epsilon) {
23 vec2 offset = vec2(epsilon, 0.0);
24 return vec3(
25 mapScene(position + offset.xyy) - mapScene(position - offset.xyy),
26 mapScene(position + offset.yxy) - mapScene(position - offset.yxy),
27 mapScene(position + offset.yyx) - mapScene(position - offset.yyx)) / (2.0 * epsilon);
28 }
29
30 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
31 vec3 origin = vec3(0.0, 0.55, -3.1);
32 vec3 direction = cameraRay(fragCoord, origin, vec3(0.0, 0.05, 0.0), 1.7);
33 float hit = marchScene(origin, direction, 18.0);
34 if (hit < 0.0) {
35 fragColor = vec4(skyColor(direction), 1.0);
36 return;
37 }
38 vec3 position = origin + direction * hit;
39 vec3 gradient = fieldGradient(position, uEpsilon);
40 vec3 normal = normalize(gradient);
41
42 vec3 color;
43 if (uViewMode == 0) {
44 color = 0.5 + 0.5 * normal;
45 } else if (uViewMode == 1) {
46 vec3 toLight = normalize(vec3(0.6, 0.9, -0.6));
47 float diffuse = max(dot(normal, toLight), 0.0);
48 float specular = pow(max(dot(reflect(direction, normal), toLight), 0.0), 40.0);
49 color = vec3(0.62, 0.66, 0.74) * (0.12 + 0.88 * diffuse) + specular * 0.4;
50 } else {
51 float error = abs(length(gradient) - 1.0);
52 color = mix(vec3(0.06, 0.10, 0.16), vec3(1.0, 0.35, 0.20), clamp(error * 8.0, 0.0, 1.0));
53 }
54 fragColor = vec4(color, 1.0);
55 }
56

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.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...