Skip to main content
GameDev.net gamedev.net

Pixel Art Palette Filter

by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026

Use in your engine

Run the shader to adjust these controls.

What it demonstrates

A pixel art filter is two independent reductions that people usually conflate: throwing away spatial resolution, and throwing away colour resolution. This shader separates them. Pixel size controls how large a block of the frame collapses to one colour, and colour levels controls how many values each channel is allowed, with ordered dithering available to soften the difference.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
Audio sample rate, always 44100.
Common
// Shared by every pass, because the Common pass is prepended to each of them
// before compilation. The scene lives here rather than in a committed image: a
// post effect in a game runs on a frame the renderer just produced, and a
// rendered source also means the highlights are genuinely brighter than white,
// which is what a bright-pass needs in order to find anything at all.
//
// This file is identical across the post-processing seeds on purpose, so the
// effects are comparable: the only thing that differs between them is the
// processing, not the picture.
float hash11(float n) {
    return fract(sin(n * 91.7) * 43758.5453);
}

vec3 renderScene(vec2 uv, float time) {
    vec3 color = mix(vec3(0.04, 0.05, 0.10), vec3(0.30, 0.17, 0.26), uv.y + 0.5);

    // Sun: small, hard edged, and far above the displayable range so it blooms
    // hard. Deliberately no painted-on glow around it — every halo in the final
    // image has to come from the effect, or the effect demonstrates nothing.
    vec2 sunPos = vec2(0.44, 0.19);
    float sunDist = length(uv - sunPos);
    color += vec3(3.4, 2.5, 1.4) * (1.0 - smoothstep(0.052, 0.062, sunDist));

    // Distant ridge, a flat silhouette to separate sky from city.
    float ridge = -0.13 + 0.045 * sin(uv.x * 4.1 + 1.3) + 0.022 * sin(uv.x * 9.7);
    if (uv.y < ridge) {
        color = mix(color, vec3(0.09, 0.08, 0.15), 0.92);
    }

    // Near skyline with lit windows: hard edges and fine bright detail, which
    // is exactly what outline, pixelate and bloom effects need to chew on.
    float cell = 0.20;
    float column = floor((uv.x + 2.0) / cell);
    float height = -0.34 + 0.20 * hash11(column);
    if (uv.y < height) {
        color = vec3(0.045, 0.045, 0.075);
        float row = floor((uv.y + 0.5) * 26.0);
        vec2 local = fract(vec2((uv.x + 2.0) / cell * 3.0, (uv.y + 0.5) * 26.0));
        float lit = hash11(column * 13.7 + row * 7.13);
        float blink = 0.75 + 0.25 * sin(time * 1.7 + column);
        if (local.x > 0.28 && local.x < 0.72 && local.y > 0.30 && local.y < 0.72
                && lit > 0.42) {
            color = vec3(1.75, 1.35, 0.72) * blink;
        }
    }

    // A single beacon so the frame is never completely static.
    vec2 beacon = vec2(-0.62, -0.14);
    float pulse = 0.5 + 0.5 * sin(time * 2.4);
    color += vec3(2.2, 0.5, 0.4) * pulse
             * (1.0 - smoothstep(0.006, 0.016, length(uv - beacon)));
    return color;
}

vec2 sceneUv(vec2 fragCoord) {
    return (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
}
Buffer A
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    fragColor = vec4(renderScene(sceneUv(fragCoord), iTime), 1.0);
}
Main Image4
1 uniform float uPixelSize; // @param 1.0..24.0 = 10.0 "Pixel size"
2 uniform int uLevels; // @param 2..16 = 6 "Colour levels"
3 uniform bool uDither; // @param = 1 "Ordered dither"
4
5 float bayer4(vec2 pixel) {
6 // 4x4 ordered dither matrix, written as maths rather than a lookup table so
7 // there is no array indexing to go wrong on older drivers.
8 vec2 cell = mod(floor(pixel), 4.0);
9 float index = cell.y * 4.0 + cell.x;
10 float value = mod(index * 5.0 + floor(index / 4.0) * 3.0, 16.0);
11 return value / 16.0 - 0.5;
12 }
13
14 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
15 float block = max(uPixelSize, 1.0);
16 vec2 blocked = (floor(fragCoord / block) + 0.5) * block;
17 vec3 scene = texture(iChannel0, blocked / iResolution.xy).rgb;
18
19 scene = scene / (scene + 1.0);
20
21 float levels = float(uLevels);
22 float dither = uDither ? bayer4(fragCoord / block) / levels : 0.0;
23 vec3 quantised = floor(scene * levels + dither + 0.5) / levels;
24
25 fragColor = vec4(clamp(quantised, 0.0, 1.0), 1.0);
26 }
27

Inputs for this pass

  • iChannel0 Buffer A
  • iChannel1 Empty
  • iChannel2 Empty
  • iChannel3 Empty

Learn from this shader

How it works

Pixelation happens at the sample, not after it. The coordinate is snapped to its block centre before the scene is read, so every fragment in a block samples the same texel and the block is genuinely one colour; snapping afterwards averages the block and gives a blurry grid. Quantisation multiplies by the level count, rounds, and divides back. That alone bands the sky, so an optional dither adds a small offset from a repeating four by four pattern before rounding: neighbours then round in different directions and the eye reads a gradient. The dither is scaled by block size so it lands once per output pixel, and the scene is tone mapped first, because levelling an unbounded value puts nearly everything in the top band.

Try changing

Raise Pixel size for a chunkier grid, and note the lit windows survive as single blocks. Drop Colour levels to two or three with dither off to see hard banding in the sky, then turn dither on and watch the bands break into texture without any extra colours.

Using it in a game

This is a full screen pass, not a material, and it is the honest way to fake a low resolution look on a high resolution display. Real pixel art renders small and scales up with nearest filtering; snapping in the shader keeps the crisp result while letting the rest of the pipeline stay full resolution.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...