Skip to content
Stylize · Posterize & ditherBeginner

1-bit dither

The whole scene in two colors, its shading carried by a Bayer pattern of ink and paper dots.

ScreenScreen / post
  1. 01GoalStylize
  2. 02TechniquePosterize & dither
  3. 03Execution1-bit dither
Test scene

Parameters

2
1.50
#211c33
#ede6cc

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. textureTexture2Dtexturetexturetexture
    Screen

    01 Scene, one read per block

  2. dotDotProductdotdotdot

    02 Brightness

  3. float[16]Expressionfloat[16]float[16]float[16]

    03 Bayer threshold

  4. stepStepstepstepstep

    04 On or off

  5. mixMixmixmixmix

    05 Ink and paper

How it works

Every pixel of the output is either ink or paper, two colors and nothing between. All the shading of the scene, the sphere’s falloff, the sky’s gradient, the checkered ground, is carried by how many of the dots are on. This is the dithering that hides banding at 8 bits, taken down to 1 bit and left in plain sight.

1. Scene, one read per block. The pass cuts the screen into blocks of Dot size × Dot size pixels and reads the scene once, at the block’s center, so every pixel of a block agrees. With a dot size above 1, each block reads as one fat pixel, the way an old low-resolution screen looked when scaled up.

2. Brightness. The color is multiplied by the Exposure, tone-mapped with c / (1 + c) so the bright emissive shapes stay under 1, and turned into one number with the luminance weights. Dithering two colors needs one value per pixel, and luminance is the one the eye reads shading from.

3. Bayer threshold. Each block gets a threshold from a 4×4 Bayer matrix, picked by the block’s position modulo 4. The matrix holds the numbers 0 to 15, each once, arranged so that the first two to switch on are far apart, the next two fill the gaps between them, and so on. (n + 0.5) / 16 turns them into 16 thresholds evenly spaced between 0 and 1. The step preview shows the tile: a fine weave of 16 grays, repeating every four blocks.

4. On or off. step(threshold, brightness) is 1 where the brightness beats the block’s threshold. A brightness of 0.25 beats 4 of the 16 thresholds, so 4 blocks in every tile switch on, spread evenly. Seen from normal distance, a quarter of the area is paper and it reads as a dark gray.

5. Ink and paper. A mix between the two colors finishes it.

Why compare in linear brightness

The threshold is compared with linear luminance, not with the sRGB value the screen stores. The dots average optically: a tile that is a quarter paper sends a quarter of the paper’s light to the eye, which is exactly what a linear value of 0.25 means. Comparing the sRGB value would turn on too many dots in the darks and wash the image out.

Good to know

  • The pattern is pinned to the screen, not to the objects, so it stays still while the scene moves under it. Many dithered games accept that; others swim the pattern with the camera to hide it.
  • Thin features break up. A one-pixel line in a mid-gray falls on alternate dots and turns dashed. Larger dots make it worse. Edge lines drawn after the dither stay solid.
  • Scaling ruins it. The pattern only works pixel for pixel. If the output is resized by a non-integer factor, the dots beat against the screen’s pixels and make moiré. Render at the final size, or scale up by whole numbers with nearest filtering.

Make it in GLSL

A post pass. Render the scene into a texture, then draw a full-screen triangle with this fragment shader, the scene bound to t_screen and u_resolution set to the output size. The pattern comes from gl_FragCoord, so the pass must draw at the size the image is shown.

Render the scene into a framebuffer with a texture color attachment (RGBA16F keeps the emissive values above 1; the shader tone-maps them). Then bind the default framebuffer, bind the texture to t_screen with clamp-to-edge wrapping, and draw one full-screen triangle with this shader.

main() fills uv from the varying v_uv and fragCoord from gl_FragCoord; the card reads only fragCoord, but the other varyings must still be written, and v_normal and v_view must not be zero (main() normalizes them). Set the two colors decoded to linear, p_pixelSize to a whole number of pixels and p_exposure to taste. The pass encodes its own output to sRGB.

For a real low-resolution look, render the scene into a small target (say 320 × 180) and run this pass at that size with a dot size of 1, then scale the result up to the screen by a whole factor with nearest filtering.

Make it in Godot

A spatial shader for a full-screen quad, reading the frame through hint_screen_texture. The Bayer matrix is a global const array, which Godot's shading language accepts as written.

Add a MeshInstance3D under the Camera3D with a 2 × 2 QuadMesh, and give the QuadMesh a new ShaderMaterial with this shader. Its vertex function writes the quad straight into clip space so it covers the screen, and depth_test_disabled keeps the scene from hiding it. Set the MeshInstance3D’s Extra Cull Margin to 16384 so Godot never culls it.

For a retro resolution, render the 3D scene in a SubViewport of, say, 320 × 180, show it on a TextureRect scaled up by a whole factor with Texture Filter set to Nearest, and put the quad inside that SubViewport’s camera with Dot size set to 1. Each dot is then one pixel of the low-resolution image.

In VisualShader, the chain is a Texture2D node with Source set to Screen, a DotProduct with the luminance weights, an Expression node that holds the Bayer array and returns the threshold, a Step and a Mix into Albedo, with the Unshaded flag turned on in the shader’s Flags.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

1-bit-dither.fragGLSL
#version 300 es// Posterize & dither → 1-bit dither, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_pixelSize; // Dot size (px): 2.0 (1.0 to 8.0)uniform float p_exposure; // Exposure: 1.5 (0.25 to 4.0)uniform vec3 p_ink; // Ink: vec3(0.13, 0.11, 0.2), sRGB; upload it decoded to linearuniform vec3 p_paper; // Paper: vec3(0.93, 0.9, 0.8), sRGB; upload it decoded to linearuniform sampler2D t_screen; // the scene's color: linear HDR (RGBA16F), linear filter, clamp to edgein vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// A 4×4 Bayer matrix: each threshold from 0 to 15 appears once per tile, ordered so that for any brightness the pixels// that switch on are spread as evenly as the tile allows.const float BAYER[16] = float[16](	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);vec4 effect(Inputs i) {	// Every pixel of a block reads the block's center, so a block is one dither dot	vec2 cell = floor(i.fragCoord / p_pixelSize);	vec3 scene = texture(t_screen, (cell + 0.5) * p_pixelSize / i.resolution).rgb * p_exposure;	vec3 mapped = scene / (1.0 + scene);	float brightness = dot(mapped, vec3(0.2126, 0.7152, 0.0722));	ivec2 p = ivec2(cell) & ivec2(3);	float threshold = (BAYER[p.y * 4 + p.x] + 0.5) / 16.0;	float lit = step(threshold, brightness);	vec3 color = mix(p_ink, p_paper, lit);	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
1-bit-dither.gdshaderGodot shader
// Posterize & dither → 1-bit dither, Godot 4. From Creative Center, MIT license.shader_type spatial;// A full-screen quad over the finished frame: no lighting, no culling, never hidden by depthrender_mode unshaded, cull_disabled, depth_test_disabled;uniform float pixelSize : hint_range(1.0, 8.0, 1.0) = 2.0;uniform float exposure : hint_range(0.25, 4.0, 0.05) = 1.5;uniform vec3 ink : source_color = vec3(0.13, 0.11, 0.2);uniform vec3 paper : source_color = vec3(0.93, 0.9, 0.8);uniform sampler2D t_screen : hint_screen_texture, filter_linear, repeat_disable;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's textures start at the top-left and the effect's uv at the bottom-left: each sample flips v backvec2 u_flipUv(vec2 uv) {	return vec2(uv.x, 1.0 - uv.y);}// A 4×4 Bayer matrix: each threshold from 0 to 15 appears once per tile, ordered so that for any brightness the pixels// that switch on are spread as evenly as the tile allows.const float BAYER[16] = float[16](	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);vec4 effect(Inputs i) {	// Every pixel of a block reads the block's center, so a block is one dither dot	vec2 cell = floor(i.fragCoord / pixelSize);	vec3 scene = texture(t_screen, u_flipUv((cell + 0.5) * pixelSize / i.resolution)).rgb * exposure;	vec3 mapped = scene / (1.0 + scene);	float brightness = dot(mapped, vec3(0.2126, 0.7152, 0.0722));	ivec2 p = ivec2(cell) & ivec2(3);	float threshold = (BAYER[p.y * 4 + p.x] + 0.5) / 16.0;	float lit = step(threshold, brightness);	vec3 color = mix(ink, paper, lit);	return vec4(color, 1.0);}void vertex() {	// Put the shader on a MeshInstance3D with a 2x2 QuadMesh in front of the camera; it covers the screen wherever it is	POSITION = vec4(VERTEX.xy, 1.0, 1.0);}void fragment() {	Inputs i;	// SCREEN_UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(SCREEN_UV.x, 1.0 - SCREEN_UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading