Sixteen Thresholds and the Illusion of Smooth
My slicer exports sixteen possible layer heights. Sixteen. The spectrogram I’m feeding it has 256 grey levels. You can see the problem: somewhere between frequency amplitude and plastic thickness, 240 shades of harmonic detail have to vanish.
This is the oldest trick in digital image processing. In 1973, Bryce Bayer at Kodak published a matrix — four numbers in a square, later extended to eight by eight, then sixteen by sixteen — that tells you when to round up versus down. The idea is spatial trading: you can’t represent 37% grey with a pixel that’s either on or off, but you can represent it across a region where 37% of the pixels are on. The eye blurs. The brain interpolates. Detail emerges from noise that isn’t random.
The matrix looks like this for 4×4:
0 8 2 10
12 4 14 6
3 11 1 9
15 7 13 5
Each position gets a threshold. Divide by 16, scale to your value range, compare. If the pixel brightness exceeds the threshold at that position, round up; otherwise round down. The pattern tiles infinitely without visible seams because Bayer designed it that way — maximum distance between similar values, a fractal of comparisons.
import Foundation
let bayer4x4: [[Double]] = [
[0, 8, 2, 10], [12, 4, 14, 6],
[3, 11, 1, 9], [15, 7, 13, 5]
].map { $0.map { $0 / 16.0 } }
func dither(value: Double, x: Int, y: Int, levels: Int) -> Int {
let threshold = bayer4x4[y % 4][x % 4]
let scaled = value * Double(levels - 1)
return scaled.truncatingRemainder(dividingBy: 1) > threshold
? Int(ceil(scaled)) : Int(floor(scaled))
}
// Map 0.37 grey to 8 levels at position (2,1)
print(dither(value: 0.37, x: 2, y: 1, levels: 8)) // Output: 3
use strict;
use warnings;
my @bayer = ([0,8,2,10],[12,4,14,6],[3,11,1,9],[15,7,13,5]);
sub dither {
my ($val, $x, $y, $levels) = @_;
my $thresh = $bayer[$y % 4][$x % 4] / 16;
my $scaled = $val * ($levels - 1);
my $frac = $scaled - int($scaled);
return $frac > $thresh ? int($scaled) + 1 : int($scaled);
}
# Map 0.37 grey to 8 levels at position (2,1)
print dither(0.37, 2, 1, 8), "\n"; # Output: 3
Both return 3 — three layer heights up from minimum. But run the same value at position (0,0), where the threshold is zero, and you get 3 again. Position (1,1), threshold 0.25, still 3. Only at (1,0) — threshold 0.75 — does 0.37 drop to 2. Across the tile, that spatial variation creates a texture that reads as 37% grey even though every printed spot is quantized hard.
For lithophanes this matters because the harmonic peaks aren’t uniform rectangles. A 12 dB rolloff between fundamental and third partial spans maybe four thickness levels after gamma compression. Without dithering, you get banding — visible stair-steps where the gradient should be smooth. With it, the stair-steps scatter into stipple, and the eye forgives.
My slicer doesn’t do this automatically. The greyscale-to-heightmap converter does, but only Floyd-Steinberg, which diffuses error forward in serpentine passes. Ordered dithering tiles better for repeating patterns — and a harmonic spectrum is exactly that, the same peaks appearing whistle after whistle, only the ratios shifting.