Dithering

This is part of the WebGL image processing series and it relies on information in previous articles. See all articles here.

It is designed to be used on desktop.

We'll improve our thresholding algorithms from the previous article by replacing block colors with patterns that, from a distance, look like a shade of a color. Here's an example of rendering a horizontal gradient with different dithering effects we'll cover in this article:

Dithering patterns on a gradient

Ordered Dithering

We use an algorithm called ordered dithering to create this effect. We can define our patterns with a dither matrix such as:

[0.20.80.60.4]\begin{bmatrix} 0.2 & 0.8 \\ 0.6 & 0.4 \end{bmatrix}

Each pixel of our image is checked against this matrix, repeating for the size of the image. For the matrix above the first pixel on the top row will be thresholded to the value 0.2. Anything above 0.2 turns white and anything below turns black. The second pixel by 0.8 and the third pixel will loop back to the first value in our matrix 0.2. The second pixel row will start from 0.6. This will repeat for the whole image.

This 2x2 matrix can give us 5 different thresholds, as shown in the image below.

The data for patterns is normally stored as integers, and we use this equation to create the threshold values.

[0321]\begin{bmatrix} 0 & 3 \\ 2 & 1 \end{bmatrix}
di,j=1+Mi,j1+m×nd_{i,j} = \frac{1 + M_{i,j}}{1 + m \times n}

Here is our GLSL code that will take our dither_matrix, convert it into threshold data, and output our result:

const int dither_matrix_2x2[4] = int[](
   0,  3,  
   2,  1
);
float dither2x2(vec2 uv, float luma) {
  float dither_amount = 2.0;
  int x = int(mod(uv.x, dither_amount));
  int y = int(mod(uv.y, dither_amount));
  int index = x + y * int(dither_amount);
  float limit = (float(dither_matrix_2x2[index]) + 1.0) / (1.0 + 4.0);
  return luma < limit ? 0.0 : 1.0;
}

Patterns

The beauty of this algorithm is that we can easily expand on it with different patterns and matrix sizes for a number of different results:

2x2 Bayer ordered dither matrix

[0321]\begin{bmatrix} 0 & 3 \\ 2 & 1 \end{bmatrix}

4x4 Bayer ordered dither matrix

[0821012414631119157135]\begin{bmatrix} 0 & 8 & 2 & 10 \\ 12 & 4 & 14 & 6 \\ 3 & 11 & 1 & 9 \\ 15 & 7 & 13 & 5 \end{bmatrix}

8x8 Bayer ordered dither matrix

[0328402341042481656245018582612444361446638602852206230542233511431339415119592749175725154773913455376331552361295321]\begin{bmatrix} 0 & 32 & 8 & 40 & 2 & 34 & 10 & 42 \\ 48 & 16 & 56 & 24 & 50 & 18 & 58 & 26 \\ 12 & 44 & 4 & 36 & 14 & 46 & 6 & 38 \\ 60 & 28 & 52 & 20 & 62 & 30 & 54 & 22 \\ 3 & 35 & 11 & 43 & 1 & 33 & 9 & 41 \\ 51 & 19 & 59 & 27 & 49 & 17 & 57 & 25 \\ 15 & 47 & 7 & 39 & 13 & 45 & 5 & 37 \\ 63 & 31 & 55 & 23 & 61 & 29 & 53 & 21 \end{bmatrix}

8x8 cluster matrix

[2410122635474937802144559615122641643576353302018283341553934464836251113274458605091315425662522375173240543831211929]\begin{bmatrix} 24 & 10 & 12 & 26 & 35 & 47 & 49 & 37 \\ 8 & 0 & 2 & 14 & 45 & 59 & 61 & 51 \\ 22 & 6 & 4 & 16 & 43 & 57 & 63 & 53 \\ 30 & 20 & 18 & 28 & 33 & 41 & 55 & 39 \\ 34 & 46 & 48 & 36 & 25 & 11 & 13 & 27 \\ 44 & 58 & 60 & 50 & 9 & 1 & 3 & 15 \\ 42 & 56 & 62 & 52 & 23 & 7 & 5 & 17 \\ 32 & 40 & 54 & 38 & 31 & 21 & 19 & 29 \end{bmatrix}

This 8x8 cluster dot matrix mimics the halftoning techniques used by newspapers.

Color Halftone

We can produce an even more interesting effect by taking each RGB channel independently instead of using our grayscale value.

void main() {
  // load texture and set texel as outColor

  // get image grayscale
  vec4 luma = vec4(0.299, 0.587, 0.114, 0);
  float grayscale = dot(outColor, luma);

  outColor = vec4(
    outColor.r * dither(gl_FragCoord.xy, grayscale),
    outColor.g * dither(gl_FragCoord.xy, grayscale),
    outColor.b * dither(gl_FragCoord.xy, grayscale),
    1.0
  );
}
[0328402341042481656245018582612444361446638602852206230542233511431339415119592749175725154773913455376331552361295321]\begin{bmatrix} 0 & 32 & 8 & 40 & 2 & 34 & 10 & 42 \\ 48 & 16 & 56 & 24 & 50 & 18 & 58 & 26 \\ 12 & 44 & 4 & 36 & 14 & 46 & 6 & 38 \\ 60 & 28 & 52 & 20 & 62 & 30 & 54 & 22 \\ 3 & 35 & 11 & 43 & 1 & 33 & 9 & 41 \\ 51 & 19 & 59 & 27 & 49 & 17 & 57 & 25 \\ 15 & 47 & 7 & 39 & 13 & 45 & 5 & 37 \\ 63 & 31 & 55 & 23 & 61 & 29 & 53 & 21 \end{bmatrix}

Halftone with Duotone

We can also add color to our image by reusing our duotone function from the duotone article in this series. Take the grayscale halftone image we produced earlier and pass into our duotone (mix) function to add a gradient map to it.

[0328402341042481656245018582612444361446638602852206230542233511431339415119592749175725154773913455376331552361295321]\begin{bmatrix} 0 & 32 & 8 & 40 & 2 & 34 & 10 & 42 \\ 48 & 16 & 56 & 24 & 50 & 18 & 58 & 26 \\ 12 & 44 & 4 & 36 & 14 & 46 & 6 & 38 \\ 60 & 28 & 52 & 20 & 62 & 30 & 54 & 22 \\ 3 & 35 & 11 & 43 & 1 & 33 & 9 & 41 \\ 51 & 19 & 59 & 27 & 49 & 17 & 57 & 25 \\ 15 & 47 & 7 & 39 & 13 & 45 & 5 & 37 \\ 63 & 31 & 55 & 23 & 61 & 29 & 53 & 21 \end{bmatrix}
void main() {
  // make image grayscale
  vec4 luma = vec4(0.299, 0.587, 0.114, 0.0);
  float grayscale = dot(outColor, luma);
  
  outColor = vec4(
    vec3(dither(gl_FragCoord.xy, grayscale)),
    1.0
  );

  vec4 lowcolor = vec4(0.141, 0.031, 0.318, 1.0);
  vec4 highcolor = vec4(0.957, 0.239, 0.122, 1.0);
  outColor = mix(lowcolor, highcolor, outColor);
}

In the next article, we'll be exploring how to create a vignette effect.

Feedback and suggestions

Have a suggestion or want to show me your work?
Get in touch at via email or Twitter @maximmcnair.