A cautionary tale about continuity

My latest solid modeling kernel is robust enough that I'm starting to use it for actual design work. While building a sample model for a colloquium talk, I ran into an interesting edge case that I'd like to tell you about.

Right now, I don't yet have a standard library of shapes and transforms, so I'm writing everything as raw math expressions. Shapes are built up as functions of x, y, and z; it's like writing directly in assembly language.

Here's part of the cabin design:

// Build a door frame
let door_frame = max(y.abs() - 5, z - 14);
let door_frame_inner = -(door_frame + 1.5);
let door_width = max(10 - x, x - 11);
let door_frame = max(max(
    door_frame, door_frame_inner),
    door_width);
let doorknob = (x.square() + y.square() + z.square()).sqrt() - 0.6;
let door = min(door_frame, doorknob.remap_xyz(x - 10.5, y - 2, z - 6));
let cabin = max(cabin, -max(-door_frame_inner, door_width));
let cabin = min(cabin, door);

Most of the design went smoothly, but there was a problem with the roof:

cabin with a roof, with incorrect shading

Looking at the edge, you can see that this is intended to be a sawtooth shape to mimic shingles; however, it's shaded as a single flat surface.

It turns out that building a well-behaved sawtooth wave is harder than it sounds. The naive implementation is simple:

draw(y - (x % 1))

If we evaluate this expression at a bunch of points, then color pixels based on the sign of the result, we see something that looks right:

Screenshot of a black-and-white sawtooth wave

However, looking at the distance field values reveals a problem:

Screenshot of a sawtooth wave with a discontinuous distance field

The field lines completely ignore the discontinuity! In other words, the distance instantaneously goes from negative to positive, without passing through zero.

x % 1 has a jump discontinuity at exactly 1 (and 2, 3, 4, etc), so this is mathematically correct. Approaching the discontinuity from below, it tends towards a value of 1; from above, it tends towards 0.

In 3D, shading is based on surface normals, which are calculated through automatic differentiation. Because the derivative of a % b at b is undefined, we always return the gradient of a, ignoring the discontinuity.

This is what's wrong with the roof: the shingles render as a uniformly angled surface, making them basically invisible.


The fix is sneaky: we still use the modulo operator for domain repetition, but build a shifted waveform so that there is no discontinuity.

In our naive example, the repeated shape is the following:

unit tooth with bad distance field

To avoid the discontinuity, we instead build an individual tooth of the wave through constructive solid geometry:

let tooth = min(max(y - x, x - 0.5), y - x + 1);
draw(tooth)

Notice that the left and right edges of the [0, 1] range are compatible:

unit tooth with a good distance field

Then, remapping with domain repetition produces a correct distance field:

draw(tooth.remap_xyz(x % 1, y, z))

A repeated tooth with a correct distance field

Sure enough, applying the same technique fixes the cabin's roof:

cabin with a roof, with correct shading

Most of the time, modeling with distance fields is flexible and robust. Discontinuities are one of the few cases where you have to think about the actual shape of the field. If you're building tools in this vein, it's worth thinking about helping users to diagnose and debug these kinds of issues!