Tinted Windows: Reconstructing Historic Dithering


  1. Victoria University of Wellington
Table of Contents
  1. Background
  2. The Algorithm
    1. Mapping into tetrahedral space 0
    2. Finding the subspace
    3. Determining the vertex colours
    4. Scaling the values
    5. Transforming the coordinates
    6. Pixel counts
    7. Projecting these pseudo-colours back
    8. Sorting the colours by decreasing darkness
    9. Filling the pattern
  3. Implementation
  4. References

With the capacity only to display a very limited palette at higher resolutions, Windows 3.x used dithering to create the effect of a wider range of solid colours. There are a number of standard dithering algorithms, but these don't match what Windows actually did. The specific approach is documented in U.S. Patent 5485558, but there are a number of errors in the description given there. This is the result of my reverse engineering to produce behaviour correct to the real system, and a practical implementation illustrating it. The algorithm takes three colour coordinates 0–255 for each of the red, green, and blue channels, and produces an 8x8 dither pattern containing up to four palette colours spread across it.

Background

Windows 3 was intended for use with VGA graphics cards, which supported 640x480 resolution with 16 distinct colours chosen from an 18-bit colour space. The standard Windows colour palette used white, black, and darker and lighter shades of red, green, yellow, blue, magenta, cyan, and grey.

Full 16-colour palette in two rows, dark above bright version of each colour.
Full 16-colour palette in two rows, dark above bright version of each colour.

To create the illusion of a broader range of colours, dithering would be used to mix pixels of different palette colours together to approximate the real colour.

Side-by-side swatches of a solid purple colour and a dithered version of the same colour.
Composite image of, left, a dithered version of a colour captured in Windows for Workgroups 3.11's colour picker running under DOSBox-Staging and, right, a solid block of the colour^.

Windows combined a selection of up to four colours drawn from 15 palette colours (light grey was excluded) to create any approximation, using an 8x8 pattern.

Zoomed-in 8x8 dither pattern using dark and bright magenta, bright blue, and grey.
Zoomed-in 8x8 dither pattern using dark and bright magenta, bright blue, and grey.

There are a number of standard dithering algorithms, but these give different (and generally worse) results than what Windows did, even with the same palette. There is a (now-expired) patent that describes the approach taken in Windows, but following the code given there doesn't produce matching results to the real system: wrong colours used, wrong proportions, and totally invalid for many colours.

A swatch of a specific light blue colour, next to the dither pattern produced by the patent code, which is green.
The result of the code in the patent for a specific colour, next to a swatch of the very different true colour.
The accurate Windows 3 dither result next to the same colour.
A solid swatch of the same colour, next to a much more similar dither pattern produced by the accurate algorithm.

The next section explores my reconstruction of the true algorithm and correction of the errors in the patent description to replicate actual behaviour.

The Algorithm

The patent was filed May 22, 1990, the release date of Windows 3.0. It's long expired and worth diving into. The whole thing is relevant but I'm going to be referring to Table 14 in particular a lot, which is a C code listing of (purportedly) the algorithm in use. The code isn't the easiest to follow because it relies almost entirely on global variables for passing arguments and return values, and most of it's unnecessarily structured in terms of matrices. It's also just wrong in several places, some of which are simple syntax errors, but others are quite annoying to trace; for the patent's own running example, the code produces a pure-grey dither pattern instead of the purplish colour it is meant to replicate. It uses "pel" for pixels and "super-pel" for the 8x8 dither pattern.

The dither pattern uses 15 colours corresponding to every combination of half, full, and zero strength in each dimension, expressed as 4-bit RGBI (the 8 bit, I, makes the colour brighter). These are numbered 0-15 with 8 unused and the bits correspond directly to channels and intensity, in increasing order of significance RGBI. The patent gives these names with 8-bit values for each channel, but these would have been squashed to 6-bit for display, and on modern displays the dark colours in particular are darker than they would have appeared on contemporary monitors; the table below shows an approximation of how these shades would have looked at the time as well as swatches of the provided RGB values for all colours.

R G B IBGR Decimal Name
0 0 0 0000 0 Black
128 0 0 0001 1 Dark red
0 128 0 0010 2 Dark green
128 128 0 0011 3 Dark yellow
0 0 128 0100 4 Dark blue
128 0 128 0101 5 Dark magenta
0 128 128 0110 6 Dark cyan
128 128 128 0111 7 Grey
- - - 1000 8 (unused)
255 0 0 1001 9 Red
0 255 0 1010 10 Green
255 255 0 1011 11 Yellow
0 0 255 1100 12 Blue
255 0 255 1101 13 Magenta
0 255 255 1110 14 Cyan
255 255 255 1111 15 White

The patent uses a working example of colour (r=128, g=31, b=190), but you can choose your own to follow along with.

Pick a colour to use.

We'll use this purple/blue colour, r = 128, g = 31, b = 190, for the rest of this article.

0 1 2 3 4 5 6 7 9 10 11 12 13 14 15
The RGB colour cube with the palette colours marked and the example colour shown in place.

Mapping into tetrahedral space 0

The algorithm maps the colour coordinates into one of six tetrahedral subregions of the colour cube, using four vertices of the cube. These tetrahedrons are defined by the planes with equal values R=G, G=B, and R=G (these each cross white, black, and one other opposite pair of vertices). The tetrahedron is then divided again into four subspaces, defined by colours on the vertices. It gets into that first tetrahedron by swapping coordinates such that r >= g >= b, remembering the swaps so that they can be undone at the end. This is:

swapRB = r < b
if swapRB:
    r, b = b, r
swapGB = g < b
if swapGB:
    g, b = b, g
swapRG = r < g
if swapRG:
    r, g = g, r

The swapXY values are used later to transform colours back. For our running example, we start with (r = 128, g = 31, b = 190), so swapRB is true, swapGB is true, and swapRG is false, and for the rest of the algorithm the three values are (r = 190, g = 128, b = 31). They will be swapped back at the end.

0 1 2 3 4 5 6 7 9 10 11 12 13 14 15
Tetrahedral subregion 0, bounded by vertices 0, 9, 11, and 15, includes colours 0, 1, 3, 7, 9, 11, and 15. The running example colour is shown plotted into this space.

Finding the subspace

The subspace is determined by inspecting the coordinates. The ComputeSubspace function in the patent code is written very opaquely, but what it comes down to is:

  • You're in subspace 0 if r < 128; or
  • You're in subspace 1 if r + g < 256; or
  • You're in subspace 2 if r + b < 256; or
  • You're in subspace 3 otherwise.

For the running example, we're thus in subspace 2.

Remembering that the values are ordered r ≥ g ≥ b, this subspace calculation is essentially "how bright is this colour".

Determining the vertex colours

The four colours to use are the vertices of the subspace tetrahedron. Every subspace contains grey (the centre of the overall colour cube) and three others from those included in tetrahedral space 0. Subspace 0 is all dark colours, subspace 3 is all bright colours, and the others are in between.

  • Subspace 0 uses colours 3, 0, 1, 7
  • Subspace 1 uses colours 3, 1, 9, 7
  • Subspace 2 uses colours 3, 9, 11, 7
  • Subspace 3 uses colours 9, 7, 11, 15

This corrects subspaces 0–2 from the patent code, which lists colour 2 instead of 3 (table 3 has them correct). For the example, the colour values are 3, 9, 11, and 7. Remember, these are the colours as reflected into space 0 at the start, not the final dither colours.

0 1 2 3 4 5 6 7 9 10 11 12 13 14 15
Colour cube with the example colour's tetrahedral subspace 2 highlighted.

Scaling the values

Every colour coordinate is scaled down from 0-255 to 0-64 inclusive, with 255 going to 64, 251-254 to 63, and 0-2 going to 0, which isn't quite the 6-bit mapping you'd expect. The calculation is ((n / 2) + (n % 2) / 2, or more straightforwardly the first division rounds up rather than truncating.

scale(n)=⌊⌈n2⌉2⌋
r = floor(ceil(r / 2) / 2)
g = floor(ceil(g / 2) / 2)
b = floor(ceil(b / 2) / 2)

The example's scaled-down coordinates are r = 47, g = 32, b = 8.

Transforming the coordinates

Next, a coordinate transformation and inner product is used to calculate the number of pixels that will be in each colour. The coordinates are transformed by the origin of the subspace, which is (64, 0, 0) for subspace 3 and (32, 32, 0) in the others. The net effect is that in subspace 3 the calculation uses r - 64 and in the others it uses r - 32 and g - 32. These transformed values are multiplied by values from a matrix to calculate three counts, but the matrix from the code listing has some incorrect values. The code also mixes up its tempB and tempG variables. The corrected and simplified calculations are

  • In subspace 0: [-200220002][RGB]=[V0V1V7]=[c1c2c3] V3=64-V0-V1-V7
    c1 = (r - 32) * -2
    c2 = 2 * (r - 32) - 2 * (g - 32)
    c3 = b * 2
  • In subspace 1: [-2-20200002][RGB]=[V1V9V7]=[c1c2c3] V3=64-V1-V9-V7
    c1 = (r - 32) * -2 - 2 * (g - 32)
    c2 = (r - 32) * 2
    c3 = b * 2
  • In subspace 2: [1-10110002][RGB]=[V9VBV7]=[c1c2c3] V3=64-V9-VB-V7
    c1 = r - g
    c2 = r + g - 64
    c3 = b * 2
  • In subspace 3, which isn't correct either in the patent code or in the mathematics of table 5, so you have to figure it out by fiddling: [-20001-1101][RGB]=[V7VBVF]=[c1c2c3] V9=64-V7-VB-VF
    c1 = (r - 64) * -2
    c2 = g - b
    c3 = r + b - 64
    The patent would have c3 = r + g + b - 64, which gives out-of-range results for bright colours and just wrong ones for the rest.

For the example, in subspace 2, the result is c1 = 15, c2 = 15, c3 = 16.

Pixel counts

These computed values determine the number of pixels for each specific colour. There can be between one and four colours with pixels allocated to them. A table of (colour, count) pairs is calculated as:

  • If c1 + c2 + c3 < 64, the first colour from the list gets 64 - c1 - c2 - c3 pixels, otherwise 0.
  • The other three colours are assigned c1, c2, c3 pixels respectively.

In the example, 15 + 15 + 16 = 46, so there are 18 pixels assigned to colour 3, 15 to 9, 15 to 11, and 16 to 7.

ColourCountBit pattern
3180011
9151001
11151011
7160111

Projecting these pseudo-colours back

These "colours" aren't the ones used for the final dithering output, however: instead, their bits are permuted according to the three "swap" operations determined earlier.

For each colour:

  • R corresponds to the 1 bit of the colour.
  • G corresponds to the 2 bit of the colour.
  • B corresponds to the 4 bit of the colour.
  • I corresponds to the 8 bit of the colour.

The SwapRG/SwapGB/SwapRB values chosen at the start are now used to rearrange these, in that order. That is, these actually correspond to swapping the low two bits, the 2 & 4 bits, or the 1 & 4 bits respectively, to match when the numeric red/green/blue channels were swapped at the start. The 8 bit is left as it is.

if swapRG:
    R, G = G, R
if swapGB:
    G, B = B, G
if swapRB:
    R, B = B, R
colour = R + G * 2 + B * 4 + I * 8

Since the example has swapRG = false, swapGB = true, and swapRB = true, the calculation runs like this:

OldI (8)B (4)G (2)R (1)NewCountColour
001100110101185
1001100111001512
1011101111011513
011101110111167

These are now the palette colours for the final dither pattern. The table has the same counts corresponding to the new colours.

Sorting the colours by decreasing darkness

The table is then sorted according to a predefined ordering of darkness, with the darkest first. The colours ultimately sort into this order:

  • 0
  • 4
  • 1
  • 2
  • 5
  • 6
  • 3
  • 7
  • 12
  • 9
  • 10
  • 13
  • 14
  • 11
  • 15

That is, dark blue is earlier and dark yellow is later, and the same with intense blue and intense yellow.

The patent body has a table of the sorted elements for its example, but it's in the wrong order and has the wrong counts. If you skip this step entirely, the end result looks pretty much as good of a match to the target colour; this Stack Exchange answer suggests the ordering (presumably combined with the specific Bayer matrix below) is to avoid discontinuities when transitioning between nearby colours in different (sub)spaces, but I haven't found any confirmation of that. In any case, to match the expected behaviour it's necessary.

For our example, the final order should be:

ColourCount
518
716
1215
1315

Filling the pattern

Finally, the 8x8 grid is filled with the chosen colours. Each colour appears as many times as its calculated count, spread throughout the pattern according to a pre-determined Bayer matrix. The matrix of allocation orders^ is given as:

(0328402341042481656245018582612444361446638602852206230542233511431339415119592749175725154773913455376331552361295321)

This corresponds to filling in position (x, y) in this order:

[
    (0, 0), (4, 4), (4, 0), (0, 4),
    (2, 2), (6, 6), (6, 2), (2, 6),
    (2, 0), (6, 4), (6, 0), (2, 4),
    (0, 2), (4, 6), (4, 2), (0, 6),
    (1, 1), (5, 5), (5, 1), (1, 5),
    (3, 3), (7, 7), (7, 3), (3, 7),
    (3, 1), (7, 5), (7, 1), (3, 5),
    (1, 3), (5, 7), (5, 3), (1, 7),
    (1, 0), (5, 4), (5, 0), (1, 4),
    (3, 2), (7, 6), (7, 2), (3, 6),
    (3, 0), (7, 4), (7, 0), (3, 4),
    (1, 2), (5, 6), (5, 2), (1, 6),
    (0, 1), (4, 5), (4, 1), (0, 5),
    (2, 3), (6, 7), (6, 3), (2, 7),
    (2, 1), (6, 5), (6, 1), (2, 5),
    (0, 3), (4, 7), (4, 3), (0, 7)
]

Each colour gets as many pixels as the count calculated in the colour count table, assigned in that order. For the example, that's a final pattern of:

  5   7   5  12   5  12   5  12
 12   5  13   7  13   7  13   7
  5  12   5  12   5  12   5  12
 13   7  13   7  13   7  13   7
  5  12   5  12   5   7   5  12
 13   7  13   7  13   5  13   7
  5  12   5  12   5  12   5  12
 13   7  13   7  13   7  13   7

The final pattern, compared to the solid colour, looks like this:

Zoomed-in view of the final 8x8 pattern.
Zoomed-in view of the final 8x8 pattern.

It's mostly a pretty good match, though certainly more so on modern high-resolution displays.

Example colours to try from every (sub)space
SwapSub­spaceRGBUse
RBGBRG
012210797
12222014
21791586
322217980
Y05210643
Y12514412
Y21252311
Y3199248119
Y01274587
Y11561244
Y223913105
Y3236115219
Y05566117
Y1822165
Y283122160
Y357200211
YY04612669
YY1515735
YY295156106
YY379241237
YY011771
YY18952138
YY29917178
YY3132115177
Current

Implementation

I built a web-based tool for calculating and displaying these patterns that should run in any recent browser.

Screenshot of the tool user interface
The tool also allows downloading a PNG image of the dither pattern, copying a data: URI, or copying the image itself, and charts the distribution of each colour in the pattern.

The library behind it is also available:

It tries to tread the line between paralleling the patent's code — matching the function names and structure — and being understandable. It's reasonably heavily commented.

While putting this article together I also built a (much-less-tested) Python version that's available for what it is:

The HTML version of this article also has a live implementation backing the running interactive example.

Endnotes

References

  • Weise, David N. and H-Gunter Zieber. . “Method and system for displaying color on a computer output device using dithering techniques”. United States Patent 5485558. Online.
Weise, David N. and H-Gunter Zieber. . “Method and system for displaying color on a computer output device using dithering techniques”. United States Patent 5485558.