Halftoning
(Originally published as a part of Shader Advent Calendar 2024)
Introduction
Halftoning is essentially binary thresholding with a pattern. You could also think of it as a monochrome dithering process.
It’s commonly used to mimic a printed look, as seen in magazines or comics.

The process is combining a gray scale image (top-left) with a pattern (bottom-left, represented by a “halftone map”). The result is a binary image (to the right).
Basic halftone example on shadertoy (applied to video)
// create grid
gridUv = mod(gridUv * gridSize., 1.0);
// for each grid-cell, just take the distance to center
// and divide by the length of half the diagonal
float halftone = length(gridUv - 0.5) / length(vec2(0.5, 0.5));
// threshold
float c = halftone < lum ? 1.0 : 0.0;
Halftone dot shapes
I think of the pattern as a grayscale image (halftone map), that defines the detail shapes for all levels of intensity.

Halftone dot shapes illustration (shadertoy)
float hatchHalftone(vec2 uv) {
return 1.0 - 2. * abs(uv.y - 0.5);
}
float squareHalftone(vec2 uv) {
return 1.0 - 2. * max(abs(uv.x - 0.5), abs(uv.y - 0.5));
}
float roundHalftone(vec2 uv) {
// just take the distance to center and divide by diagonal
return 1.0 - length(uv - 0.5) / length(vec2(0.5, 0.5));
}
The dot-shapes most commonly used in printing are: round, elliptical and square. But we can use any kind of pattern!

Preserving percieved contrast/brightness
So far we’ve totally ignored the issue with drifting contrast and brightness as we do halftoning. To get a bit more rigour, we should: 1. Do halftoning in linear color space 2. Make sure our halftone maps have uniform histograms
Having a uniform histogram just means that:
- If you threshold at 0.5, 50% of the map is filled.
- If you threshold at 0.2, 20% of the map is filled.
- and so on…
The hatched halftone map already has this property, as it kind of opens linearly like a sliding door. The square one needs a simple adjustment (squaring one of the terms), but the dot one is trickier since the dots grow and eventually start overlapping. A few years back I came up with a function that warps the dot halftone map so it’s close to uniform, but I think there is probably a much better solution…
Here is a madtealab link with 3 halftone maps, and a way to verify that they are close to uniform
It’s still too dark…

With linear processing and uniform histograms, it’s still too dark. And running out of rigour, a tried a quick hack to compensate, by adjusting the input channel in linear space, like so:
x += pow(x, ShapeLowBoostGamma) * (1. - x) * ShapeLowBoost;
Playing around a bit I concluded that the feeling of too much darkness: * increase as the dots gets bigger * decrease when the pattern has lines rather than dots * changes depending on display/platform (I should stop now…)
(But I did not try to compensate for these)
This final shadertoy version displays 5 variants, as the square and round patterns behave differently when reversed:
Halftone - 3 kinds (shadertoy)

Interesting consequences…
Let’s revisit the hatched ramp:

Now, let’s think of the hatched ramp as a white vector shape against a black background. Intuitively, if a vector shape represents a smooth transition between colors A and B, surely we can swap the colors and expect a similar result!
This is what actually happens:

The transition is now much too bright, and the end is abrupt. So I guess a shape is never really independent from the color context…
I don’t really know what’s going on, but I guess it has to do with the remarkable dynamic range of human vision. A similar effect is observed in typography, making white text on dark backgrounds appear more bold than dark text on bright backgrounds, sometimes referred to as the irradiation illusion.
I feel it makes sense that a speck of light in the dark would be more salient than absense of light in a sea of brightness. Some of it is probably due to light scattering inside the eye, and some of it is probably in the brain circuitry.