1-bit dither
The whole scene in two colors, its shading carried by a Bayer pattern of ink and paper dots.
- 01GoalStylize
- 02TechniquePosterize & dither
- 03Execution1-bit dither
Parameters
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.
textureTexture2Dtexturetexturetexture Screen01 Scene, one read per block
dotDotProductdotdotdot In02 Brightness
float[16]Expressionfloat[16]float[16]float[16] In03 Bayer threshold
stepStepstepstepstep In04 On or off
mixMixmixmixmix In05 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
ditheringBanding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain. Banding & dithering Banding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain.
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 Luminance How bright a color looks, from its linear red, green and blue weighted by the eye's sensitivity to each.c / (1 + c) so the bright emissive shapes
stay under 1, and turned into one number with the luminanceHow bright a color looks, from its linear red, green and blue weighted by the eye's sensitivity to each.Y = 0.2126 R + 0.7152 G + 0.0722 B
3. Bayer threshold. Each block gets a threshold from a 4×4 Bayer matrixA small square of thresholds, tiled across the screen, ordered so that for any brightness the pixels that pass are spread as evenly as possible. It drives ordered dithering. Bayer matrix A small square of thresholds, tiled across the screen, ordered so that for any brightness the pixels that pass are spread as evenly as possible. It drives ordered dithering.threshold = (M[y % 4][x % 4] + 0.5) / 16(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
#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);}// 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
- Ordered dithering (opens in a new tab)Reference
- Dither (opens in a new tab)Reference
- Relative luminance (opens in a new tab)Reference
- gl_FragCoord (opens in a new tab)Reference