Skip to main content
GameDev.net gamedev.net

Analytic Ray-Traced Spheres

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

Use in your engine

Run the shader to adjust these controls.

This project is the smallest honest ray tracer. For every pixel we shoot one ray, intersect it against a short list of spheres, and shade the closest hit.

What it demonstrates

Ray construction from a camera origin plus screen UV. Analytic sphere intersection using the quadratic form. A hard-shadow ray loop, which is the seed of a real ray tracer. Phong-style diffuse plus specular on a surface normal.

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.
Main Image5
1 uniform vec3 uSunDir; // @param -1.0..1.0 = 0.42, 0.65, 0.15 direction "Sun direction"
2 uniform vec3 uSky; // @param 0.0..1.0 = 0.35, 0.55, 0.75 color "Sky"
3 uniform vec3 uGround; // @param 0.0..1.0 = 0.12, 0.10, 0.09 color "Ground"
4
5 struct Sphere {
6 vec3 center;
7 float radius;
8 vec3 color;
9 float roughness;
10 };
11
12 float hitSphere(vec3 ro, vec3 rd, vec3 c, float r) {
13 vec3 oc = ro - c;
14 float b = dot(oc, rd);
15 float d = b * b - (dot(oc, oc) - r * r);
16 if (d < 0.0) return -1.0;
17 d = sqrt(d);
18 float t = -b - d;
19 return t > 0.0 ? t : -b + d;
20 }
21
22 vec3 sceneNormal(vec3 p, Sphere s) {
23 return normalize(p - s.center);
24 }
25
26 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
27 vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
28 vec3 ro = vec3(0.0, 0.9, 2.8);
29 vec3 lookAt = vec3(0.0, 0.1, 0.0);
30 vec3 cw = normalize(lookAt - ro);
31 vec3 cu = normalize(cross(vec3(0.0, 1.0, 0.0), cw));
32 vec3 cv = cross(cw, cu);
33 vec3 rd = normalize(cw + uv.x * cu + uv.y * cv);
34
35 Sphere spheres[3];
36 spheres[0] = Sphere(vec3(-0.55, 0.35, 0.0), 0.35, vec3(0.92, 0.25, 0.25), 0.08);
37 spheres[1] = Sphere(vec3(0.35, 0.55, -0.45), 0.55, vec3(0.25, 0.62, 0.92), 0.18);
38 spheres[2] = Sphere(vec3(0.0, -100.0, 0.0), 100.0, vec3(1.0), 0.95);
39
40 vec3 sun = normalize(uSunDir);
41 vec3 col = uSky - 0.45 * uv.y;
42
43 float tMin = 1e9;
44 int hitIdx = -1;
45 for (int i = 0; i < 3; i++) {
46 float t = hitSphere(ro, rd, spheres[i].center, spheres[i].radius);
47 if (t > 0.0 && t < tMin) {
48 tMin = t;
49 hitIdx = i;
50 }
51 }
52
53 if (hitIdx >= 0) {
54 Sphere s = spheres[hitIdx];
55 vec3 p = ro + rd * tMin;
56 vec3 n = sceneNormal(p, s);
57
58 // One hard shadow ray per light, not ambient occlusion. The point is to
59 // show that a ray tracer is just a loop over geometry for every ray.
60 float shadow = 1.0;
61 for (int i = 0; i < 2; i++) {
62 if (i == hitIdx) continue;
63 float tShadow = hitSphere(p + n * 0.001, sun, spheres[i].center, spheres[i].radius);
64 if (tShadow > 0.0) {
65 shadow = 0.0;
66 break;
67 }
68 }
69
70 float diff = max(dot(n, sun), 0.0);
71 vec3 view = normalize(ro - p);
72 vec3 halfDir = normalize(sun + view);
73 float spec = pow(max(dot(n, halfDir), 0.0), mix(64.0, 4.0, s.roughness));
74 col = s.color * (uGround * 0.35 + 0.55 * diff * shadow) + vec3(0.9) * spec * shadow;
75
76 // Distance fade keeps the sky in the picture, which is why the ground
77 // sphere is huge rather than a finite plane.
78 col = mix(col, uSky - 0.45 * uv.y, smoothstep(12.0, 35.0, tMin));
79 }
80
81 fragColor = vec4(col, 1.0);
82 }
83

Learn from this shader

How it works

The image pass builds a camera basis, then loops over three spheres. For each sphere it calls hitSphere, which solves the quadratic for the nearest positive t. The sphere with the smallest t wins. The normal is the normalized vector from the sphere center to the hit point, and a second loop casts a ray toward the sun to test whether that point is in shadow.

Try changing

Move the two small spheres or change their radii. Add a fourth sphere to the array; the loop bound is fixed, so keep the count small. Raise the roughness of the blue sphere to make it shinier, or lower it to matte. Replace the hard shadow with a soft approximation by testing more shadow rays.

Using it in a game

A full game engine uses bounding hierarchies, ray-triangle intersection, and many rays per pixel. This project is the same idea with one geometry type and one ray per pixel: it is the conceptual foundation, not a drop-in replacement. The camera math is exactly what a deferred light or screen-space reflection pass uses.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...