Dithered Shading Tutorial

Aeris
The BKPT
Published in
4 min readFeb 12, 2020

I hear you need a dithered fragment shader in your life. You’ve come to the right place. I’ll attempt to summarize ordered dithering for you in plain English. This particular algorithm assumes you are dithering using two colors.

At a high level, ordered dithering uses a precomputed matrix to transform your input image into an offset version of the same image. The values are then fed through a step function to choose the final values.

Threshold Map

Step one is to create the threshold map (also called a Bayer matrix) that will be applied to every square “tile” of the image. The dimensions of the matrix determine the tile size. Larger input images should use larger tile sizes. In my example, my matrix dimension (n) is 4 for a 128x128 pixel input image. If your tile size is too small, you'll notice a screen door effect in your output image. When in doubt, choose a larger n. n can be any power of 2.

To calculate your threshold map, use the following recursive formula.

Wikipedia provides the computed values up to n=8. (Note that the matrices provided by Wiki needs to have -0.5 subtracted from each element.)

My Bayer matrix with n=4 looked like this in source code.

const int bayer_n = 4;
float bayer_matrix_4x4[][bayer_n] = {
{ -0.5, 0, -0.375, 0.125 },
{ 0.25, -0.25, 0.375, - 0.125 },
{ -0.3125, 0.1875, -0.4375, 0.0625 },
{ 0.4375, -0.0625, 0.3125, -0.1875 },
};

Algorithm

Next, for every pixel in our input image, we will compute an offset color with the following formula.

Note about r: if your input image is comprised of single-channel grayscale values from 1 through z, then r should be around z. Experiment with the value to find what works for your shader.

Once you’ve calculated c', choose the final output color by checking if the offset color is greater than a threshold value. I chose my threshold value to be z/2.

My example code is below. You’ll have to adapt it to output to your shader’s frame buffer.

for (int sy = 0; sy < viewport.height; sy++) {
float orig_color = get_screen_gradient(sy);
for (int sx = 0; sx < viewport.width; sx++) {
int color_result = BLACK;
float bayer_value = bayer_matrix_4x4[sy % bayer_n][sx % bayer_n];
float output_color = orig_color + (bayer_r * bayer_value);
// Color screen blue to white
if (output_color < (NUM_VALUES / 2)) {
color_result = WHITE;
}
*PIXEL_PTR((&screen), sx, sy, 1) = color_result;
}
}

Your output should look something like the following. In my example, I’ve animated the values of r for experimentation.

To render a dithered circle like the one in the example image at the top of the post, simply have your input image be a gradient circle. And there you have it — pretty dithering. For more details, see the original paper (An optimum method for two-level rendition of continuous-tone pictures, Bayer).

Glossary

Some quick definitions of computer graphics terms. I haven’t picked up any graphics literature in a hot minute, so I personally needed a refresher on the following.

  • UV Coordinate: XYZ coordinates are 3D world space coordinates. Similarly, UVW coordinates are the same coordinates mapped to a 2D space. Think of the UV component as being analogous to XY coordinates.
  • Graphics Pipeline: At a high level is divided into three steps: application, geometry, and rasterization.
  • Rasterization: The process of converting a mathematical representation of an image or scene (like a scene tree or vector image) into an image made of discrete pixels.
  • Fragment Shader: Also known as a pixel shader. Chooses colors for individual pixels. Part of the rasterization stage in the graphics pipeline.

--

--

Aeris
The BKPT

Will probably use this blog to write about video games.