Author

Steve Trettel

Published

January 2026

1 Day 5: Exercises

1.1 Checkpoints

Quick exercises to verify understanding. A few minutes each.

Checkpoint 1: Move the Sphere

In raymarch-sphere, the sphere is at the origin. Move it to vec3(1.0, 0.5, 0.0). What changes in the code? (Just one number in sceneSDF.)

Checkpoint 2: Torus Proportions

In raymarch-torus, the torus has major radius 1.0 and minor radius 0.4. Try: - A thin ring: vec2(1.0, 0.1) - A fat donut: vec2(1.0, 0.8) - What happens when minor radius exceeds major radius?

Checkpoint 3: Add a Sphere to the Scene

In scene-multi, add a third sphere floating above the others. You’ll need one more min() in the chain.

Checkpoint 4: Change the Light

The light direction is normalize(vec3(1.0, 1.0, 1.0))—from the upper-right-front. Try: - Light from above: vec3(0.0, 1.0, 0.0) - Light from the left: vec3(-1.0, 0.5, 0.5)

Where do the specular highlights move?

Now try animating the light: - Rotate the light direction with iTime: use vec3(cos(iTime), 1.0, sin(iTime)) - Pulse the light brightness: multiply light.color by 0.5 + 0.5 * sin(iTime)

Checkpoint 5: Field of View

In generateRay, the FOV is 90°. Try 60° (telephoto) and 120° (wide angle). How does the scene change?


1.2 Explorations

Deeper exercises that build on the core concepts.

Exploration 1: Per-Object Colors

In scene-multi, everything is orange. Give each object its own color.

One approach: write a sceneColor(vec3 p) function that checks which object is closest and returns the appropriate color:

vec3 sceneColor(vec3 p) {
    float sphere = length(p - vec3(-1.5, 0.0, 0.0)) - 1.0;
    float torus = sdTorus(p - vec3(1.5, 0.0, 0.0), vec2(0.8, 0.3));
    float ground = p.y + 1.0;
    
    float d = sceneSDF(p);
    if (d == sphere) return vec3(1.0, 0.0, 0.0);  // red
    if (d == torus) return vec3(0.0, 0.7, 1.0);   // cyan
    return vec3(0.5, 0.5, 0.5);                    // gray
}

Then use hit.color = sceneColor(p) after raymarching.

Exploration 2: Two Lights

Add a second light source. Compute shading for both and sum the results:

Light light1 = Light(normalize(vec3(1.0, 1.0, 1.0)), vec3(1.0, 0.9, 0.8));
Light light2 = Light(normalize(vec3(-1.0, 0.5, 0.0)), vec3(0.2, 0.3, 0.5));

vec3 ambient = hit.color * 0.1;
vec3 color = ambient + shade(hit, light1, viewDir) + shade(hit, light2, viewDir);

A warm key light with a cool fill is a classic look.

Exploration 3: Fog

Add distance-based fog to create depth. After shading, blend toward a fog color based on distance traveled:

vec3 fogColor = vec3(0.5, 0.6, 0.7);
float fogAmount = 1.0 - exp(-t * 0.1);
color = mix(color, fogColor, fogAmount);

Adjust the 0.1 to control fog density.

Exploration 4: Materials

Our shade function hardcodes shininess at 32. Make it a parameter by extending Hit or creating a Material struct:

struct Material {
    vec3 color;
    float shininess;
};

Give the sphere a tight metallic highlight (shininess = 128) and the ground a soft plastic sheen (shininess = 8).

Exploration 5: Normal Coloring

Replace shading with normal visualization: color = normal * 0.5 + 0.5. This maps the normal components to RGB—a useful debugging tool that also looks striking.

Try variations: - abs(normal) — what changes? - vec3(normal.y * 0.5 + 0.5) — height-based coloring


1.3 Challenges

Substantial projects requiring creative problem-solving.

Challenge 1: CSG Operations

We used min for union. Implement intersection and subtraction:

  • Intersection: max(a, b) — points inside both shapes
  • Subtraction: max(a, -b) — points inside A but outside B

Build these shapes: - A cube with a spherical cavity carved out - A sphere with a cylindrical hole through it - The intersection of two overlapping spheres (a lens shape) - A cube with cylindrical holes drilled through all three axes (a dice-like frame) - A torus with a wedge cut out (like Pac-Man in 3D) - The intersection of a cube and a sphere (a rounded cube) - A hollow sphere (subtract a smaller sphere from a larger one)

Challenge 2: Infinite Repetition

The mod function can repeat space infinitely:

float sceneSDF(vec3 p) {
    vec3 spacing = vec3(4.0);
    vec3 q = mod(p + spacing * 0.5, spacing) - spacing * 0.5;
    return length(q) - 1.0;  // Infinite grid of spheres
}

Experiment with: - Different spacing in different directions - Repeating only in 2D (an infinite floor of objects) - Mixing repeated and non-repeated objects

Challenge 3: Smooth Blending

Sharp min creates hard edges where objects meet. For organic shapes, use smooth minimum:

float smin(float a, float b, float k) {
    float h = max(k - abs(a - b), 0.0) / k;
    return min(a, b) - h * h * k * 0.25;
}

The parameter k controls blend radius. Create a “metaball” effect with two spheres that merge smoothly as they approach each other.

Challenge 4: Hard Shadows

To check if a point is in shadow, raymarch from the hit point toward the light. If you hit something before reaching the light, you’re in shadow:

float shadow(vec3 origin, vec3 lightDir, float maxDist) {
    float t = 0.02;  // Start slightly off surface
    for (int i = 0; i < 50; i++) {
        float d = sceneSDF(origin + lightDir * t);
        if (d < 0.001) return 0.0;  // In shadow
        t += d;
        if (t > maxDist) break;
    }
    return 1.0;  // Lit
}

Multiply your diffuse term by the shadow result.

Challenge 5: Soft Shadows

Hard shadows have sharp edges. For soft shadows, track how close the shadow ray came to occluders:

float softShadow(vec3 origin, vec3 lightDir, float maxDist, float k) {
    float result = 1.0;
    float t = 0.02;
    for (int i = 0; i < 50; i++) {
        float d = sceneSDF(origin + lightDir * t);
        if (d < 0.001) return 0.0;
        result = min(result, k * d / t);
        t += d;
        if (t > maxDist) break;
    }
    return result;
}

The parameter k controls shadow softness—smaller k gives softer shadows.


1.4 Project: Algebraic Varieties

An algebraic variety is the zero set of a polynomial—a surface defined by \(f(x, y, z) = 0\). These surfaces have been studied for centuries, and some are strikingly beautiful.

Distance Estimation

We can’t compute an exact SDF for a general polynomial, but we can estimate the distance. Near the surface, the function value \(f(\mathbf{p})\) is approximately proportional to distance, with the gradient telling us the rate of change:

\[d \approx \frac{|f(\mathbf{p})|}{|\nabla f(\mathbf{p})|}\]

This isn’t exact, but it’s good enough for raymarching:

vec3 gradient(vec3 p) {
    float eps = 0.001;
    return vec3(
        polynomial(p + vec3(eps, 0, 0)) - polynomial(p - vec3(eps, 0, 0)),
        polynomial(p + vec3(0, eps, 0)) - polynomial(p - vec3(0, eps, 0)),
        polynomial(p + vec3(0, 0, eps)) - polynomial(p - vec3(0, 0, eps))
    ) / (2.0 * eps);
}

float sceneSDF(vec3 p) {
    float f = polynomial(p);
    vec3 g = gradient(p);
    return 0.5 * abs(f) / length(g);  // Factor of 0.5 for safety
}

The normal is just the normalized gradient: normalize(gradient(p)).

WarningSingularities

Near singular points where \(\nabla f = 0\), the distance estimate becomes unreliable. You may see artifacts at these locations—this is expected.

Bounding Volume

Algebraic varieties can extend to infinity. For efficient raymarching, first check a bounding sphere:

float sceneSDF(vec3 p) {
    float bounds = length(p) - 2.0;
    if (bounds > 0.01) return bounds;
    
    // Inside bounds: use distance estimate
    float f = polynomial(p);
    vec3 g = gradient(p);
    return 0.5 * abs(f) / length(g);
}

Your Task

Build a shader that renders an algebraic variety. Your shader should:

  1. Implement distance estimation for at least one variety
  2. Use a bounding volume for efficiency
  3. Include proper lighting with surface normals
  4. Allow rotation via mouse (use orbitCamera)

Extensions: - Add a second colored light - Implement soft shadows - Try multiple varieties and find your favorite - Animate the rotation automatically with iTime