Skip to main content
GameDev.net gamedev.net

3D SDF Primitive Gallery

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

Once the marching loop works, adding shapes is adding distance functions. This shader gathers the four primitives most scenes are built from, a sphere, box, torus and cylinder, and puts them on a ground plane under one light. It is also the first package to use a Common pass: the camera, the march loop and the normal estimate now live there, so the Image pass contains only the scene.

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.
Common4
1 // The Common pass is prepended to the Image pass before compilation, so this
2 // is shared source rather than a second shader. The scene itself is declared
3 // here and defined in the Image pass, which is where the lesson lives.
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 sceneNormal(vec3 position, float epsilon) {
27 vec2 offset = vec2(epsilon, 0.0);
28 return normalize(vec3(
29 mapScene(position + offset.xyy) - mapScene(position - offset.xyy),
30 mapScene(position + offset.yxy) - mapScene(position - offset.yxy),
31 mapScene(position + offset.yyx) - mapScene(position - offset.yyx)));
32 }
33
34 vec3 skyColor(vec3 direction) {
35 return mix(vec3(0.05, 0.07, 0.12), vec3(0.16, 0.22, 0.34), direction.y * 0.5 + 0.5);
36 }
37
Main Image4
1 uniform int uShape; // @param 0..3 = 2 "Shape"
2 uniform float uSpin; // @param 0.0..6.28 = 0.9 "Spin offset"
3 uniform float uCameraHeight; // @param -0.4..2.5 = 0.9 "Camera height"
4
5 float sdSphere(vec3 p, float radius) {
6 return length(p) - radius;
7 }
8
9 float sdBox(vec3 p, vec3 halfSize) {
10 vec3 q = abs(p) - halfSize;
11 return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
12 }
13
14 float sdTorus(vec3 p, float major, float minor) {
15 vec2 ring = vec2(length(p.xz) - major, p.y);
16 return length(ring) - minor;
17 }
18
19 float sdCylinder(vec3 p, float height, float radius) {
20 vec2 d = abs(vec2(length(p.xz), p.y)) - vec2(radius, height);
21 return min(max(d.x, d.y), 0.0) + length(max(d, 0.0));
22 }
23
24 float mapScene(vec3 position) {
25 float angle = iTime * 0.35 + uSpin;
26 float c = cos(angle);
27 float s = sin(angle);
28 vec3 p = vec3(c * position.x - s * position.z, position.y, s * position.x + c * position.z);
29 float shape;
30 if (uShape == 0) {
31 shape = sdSphere(p - vec3(0.0, -0.20, 0.0), 0.85);
32 } else if (uShape == 1) {
33 shape = sdBox(p - vec3(0.0, -0.43, 0.0), vec3(0.62));
34 } else if (uShape == 2) {
35 shape = sdTorus(p - vec3(0.0, -0.77, 0.0), 0.75, 0.28);
36 } else {
37 shape = sdCylinder(p - vec3(0.0, -0.30, 0.0), 0.75, 0.55);
38 }
39 float ground = position.y + 1.05;
40 return min(shape, ground);
41 }
42
43 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
44 vec3 origin = vec3(0.0, uCameraHeight, -3.4);
45 vec3 direction = cameraRay(fragCoord, origin, vec3(0.0, -0.25, 0.0), 1.6);
46 float hit = marchScene(origin, direction, 20.0);
47 if (hit < 0.0) {
48 fragColor = vec4(skyColor(direction), 1.0);
49 return;
50 }
51 vec3 position = origin + direction * hit;
52 vec3 normal = sceneNormal(position, 0.0015);
53 vec3 toLight = normalize(vec3(0.7, 0.9, -0.6));
54 float diffuse = max(dot(normal, toLight), 0.0);
55 float ground = smoothstep(0.05, -0.05, position.y + 1.04);
56 vec3 albedo = mix(vec3(0.85, 0.55, 0.28), vec3(0.30, 0.32, 0.36), ground);
57 vec3 color = albedo * (0.14 + 0.86 * diffuse);
58 color = mix(skyColor(direction), color, exp(-0.012 * hit * hit));
59 fragColor = vec4(color, 1.0);
60 }
61

Learn from this shader

How it works

The Common pass is prepended to the Image pass before compilation, so the two files are one shader, not two stages. That is why it can declare the scene as a prototype and let the Image pass define it: shared machinery calls into per project content without knowing anything about it. Each primitive follows the pattern of its 2D counterpart, with the box clamping per axis distances and the torus collapsing the horizontal plane to a radius so it becomes a circle problem. The scene rotates by rotating the sample point, and the ground joins with a union. Distance fog hides the hard horizon.

Try changing

Step Shape and watch the same lighting and shadow terminator behave differently on flat faces than on curves. Move Camera height down to graze the plane, where marching costs the most steps. In a fork, replace the union with a smooth minimum so the shape melts into the floor, or return the shape index alongside the distance to give each primitive its own material.

Using it in a game

These four functions plus rounding and blending cover most props a raymarched scene needs. In a mesh renderer the same primitives are useful as analytic colliders, trigger volumes, and decal projectors, where a distance query is cheaper and more stable than intersecting geometry.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views1
Forks0

Discussion

Loading comments...