Writing a Real-time Ordered Dithering Shader for Gunship
I wrote Gunship: Tactical Munitions Capitalism over the course of 9 days for The Linux Game Jam, 2018. Having it in my head to work alone, and being a programmer with little artistic ability to speak of, I made the decision to use a novel art style early on. Which is why, idly watching a video of a neat indie game called WORLD OF HORROR before the event, I was struck by inspiration:
I was going to rip off the art style, and I was going to do it in real-time, with a shader.
WORLD OF HORROR uses an effect called ordered dithering to simulate a full greyscale palette with only black-and-white. Ordered dithering is used with manga line work in place of the usual shading technique from manga, screentoning, which operates under similar principles, giving the game the look of a monochrome PC-98 visual novel (I don’t think those ever actually existed).
The result is pretty rad.

I suspect that ordered dithering was chosen for WORLD OF HORROR specifically because of its recognisable ubiquity in old-school computer graphics. In the days of necessity, when a severely limited colour palette wasn’t just a style choice, it was a fairly common method of approximating continous colour when 16 (or even two) colours wouldn’t cut it. It was easy to implement and cheap to compute, even if it did tend to come with characteristic hatching artefacts (which would later allow me to identify the effect by name – viewed as a detriment to accuracy circa 1984, I’m sure, but a bold aesthetic choice by pretentious hipsters like myself in the year 2018).
Being the man of science I am, the name naturally led me to Wikipedia. The article’s complex, but these parts stood out:
The algorithm achieves dithering by applying a threshold map M on the pixels displayed, causing some of the pixels to be rendered at a different color, depending on how far in between the color is of available color entries.
The algorithm renders the image normally, but for each pixel, it adds a value from the threshold map, causing the pixel’s value to be quantized one step higher if it exceeds the threshold. For example, in monochrome rendering, if the value of the pixel (scaled into the 0—9 range if using a 3×3 matrix) is less than the number in the corresponding cell of the matrix, plot that pixel black, otherwise, plot it white.
I applied this last part pretty much as it was written. The process:
Each pixel value is mapped in the range of 0–16 (15 is the upper limit for values in the 4×4 threshold map I used). If the pixel value is less than the corresponding value in the threshold map (accessed as a circular buffer with the pixel coordinates), it’s plotted black; otherwise, it’s plotted white – this is the quantization step mentioned. To quantize a colour is to reduce it to one from a smaller set, which for anything more complex than black-and-white would involve choosing a new colour from a discrete list. Given that our system only outputs two colours, the quoted simplification works fine.
From the method, writing the effect is fairly simple.
The example code below is a simplification of what I wrote for Gunship, so it’s for LÖVE2D; don’t let this intimidate you—LÖVE uses GLSL 1.20 with a different entry point, some redefined types and some handy built-in variables, so if you know GLSL adapting this shouldn’t be too hard.
main.lua
With LÖVE, everything is rendered to a canvas (think: render to texture) with the dimensions of the window, for post-processing with our dithering effect.
local canvas, shader
function love.load()
canvas = love.graphics.newCanvas()
shader = love.graphics.newShader('shader.frag')
end
function love.draw()
love.graphics.setCanvas(canvas)
-- Clear the canvas to white each frame
love.graphics.clear(1, 1, 1)
-- Draw normally; e.g.:
love.graphics.push('all')
local intensity = 0.5+0.5 * math.sin(2*math.pi*1/4*love.timer.getTime() + 3*math.pi/2)
love.graphics.setColor(intensity, intensity, intensity)
love.graphics.translate(love.graphics.getWidth() / 2, love.graphics.getHeight() / 2)
love.graphics.rotate(2*math.pi*1/16*love.timer.getTime())
love.graphics.rectangle('fill', -200, -200, 400, 400)
love.graphics.pop()
-- /e.g.
The canvas is then rendered using our dithering shader.
love.graphics.setCanvas() -- Draw to screen
love.graphics.setShader(shader)
love.graphics.draw(canvas)
end
shader.frag
In the shader, the pixel colour is first sampled from the Image
(sampler2D
) of the drawable
being rendered, multiplied by the LÖVE2D drawing colour in the color
argument (set by
love.graphics.setColor()
).
vec4 effect(vec4 color, Image texture, vec2 textureCoords, vec2 screenCoords) {
vec4 pixelColor = Texel(texture, textureCoords) * color;
This shader dithers greyscale images to black-and-white, so the red, green and blue channels are averaged out to a greyscale intensity before being used. We could get lazy, and, assuming all input is already greyscale, just use the red channel (or green, or blue), but doing it this way will work with coloured inputs as well.
float intensity = (pixelColor.r + pixelColor.g + pixelColor.b) / 3.0;
The threshold map is defined.
mat4 thresholdMap = mat4( 0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0);
And a threshold is retrieved for the current pixel, using the screen coordinates provided to the
effect function (thresholdMap
is accessed as a circular buffer, using the mod
function).
ivec2 thresholdMapCoords = ivec2(mod(screenCoords, 4.0));
float threshold = thresholdMap[thresholdMapCoords.x][thresholdMapCoords.y];
Our pixel intensity value is scaled in the range 0–16, and checked against the threshold – if it’s greater, the pixel is made white, otherwise, black.
pixelColor.rgb = (intensity * 16.0 > threshold) ? vec3(1.0) : vec3(0.0);
The pixel colour is returned, and the transformation is complete.
return pixelColor;
}
Now, before you run off and implement ordered dithering in your game or application, as a public service announcement, I’d like to ask you to consider what I believe is an important caveat of the technique; ordered dithering is quickly ruined by any sort of image scaling whatsoever. Scaled, images produced with the effect develop tartan-style checkerboard patterns, giving flat shades the appearance of having colour gradients, and generally ruining the aesthetic very quickly. (You can see this for yourself–drag the gif and watch what happens.) In a world dominated by social media, where good online exposure is 90% of success, the way your product is represented on a 240p Twitch stream cannot be ignored. Or, to put it another way, be mindful of this for the same reason you won’t catch me making a game with confetti in it anytime soon, either.