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:
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:
However, looking at the distance field values reveals a problem:
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:
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:
Then, remapping with domain repetition produces a correct distance field:
draw(tooth.remap_xyz(x % 1, y, z))
Sure enough, applying the same technique fixes the cabin's roof:
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!