Free shaders · CC0 · Godot 4.7

Pixelate Shader

Turn the whole screen, 2D and 3D, into chunky pixels with one ColorRect, with optional colour steps and dithering for a retro palette.

Drop it into your project

  1. Copy pixelate.gdshader, pixelate.gdshader.uid and pixelate_material.tres into your project, for example into res://shaders/. Keep the .uid file next to the shader. The material finds the shader by that ID, so it works in any folder you choose.
  2. Open the scene that stays loaded while you play, usually your main scene. Add a CanvasLayer as a child of its root and leave Layer at 1. Add a ColorRect as a child of the CanvasLayer.
  3. With the ColorRect selected, open the anchor preset menu in the 2D editor toolbar and choose Full Rect, so it covers the viewport at any window size. In the Inspector, set Mouse > Filter to Ignore. A ColorRect stops mouse clicks by default, and a full-screen one would block every button under it.
  4. Drag pixelate_material.tres into the ColorRect's CanvasItem > Material slot. The ColorRect's own colour does not matter: the shader replaces it with the screen.
  5. Move any UI that should stay sharp to a CanvasLayer with Layer 2 or higher. Everything drawn before the ColorRect is pixelated: the 3D scene, the default 2D canvas, lower layers, and nodes above the ColorRect in its own layer.
  6. Tune the look under the material's Shader Parameters. To change it while the game runs, call set_shader_parameter() on the ColorRect's material with the parameter name and the new value, the way demo.gd does.

Parameters

NameTypeDefaultWhat it does
pixel_sizeint (1 to 32)4Width and height of one block, in pixels of the viewport the ColorRect draws into. Every pixel of a block copies the screen pixel at the block's centre. 1 turns pixelation off. With the default stretch mode or canvas_items, viewport pixels are window pixels, so blocks look smaller next to your game on a bigger window. The pitfalls below have the fix.
palette_stepsint (0 to 16)0Levels per colour channel. 0 keeps every colour. At 5, each of red, green and blue can only be 0, 0.25, 0.5, 0.75 or 1, which gives 125 colours in all. 1 counts as 2, so the picture never collapses to a single colour.
ditherboolfalseOff rounds every channel to the nearest level, so gradients turn into flat bands. On gives each block in a 4 by 4 group its own rounding threshold, so a colour between two levels becomes a fixed pattern of both. Does nothing while palette_steps is 0.

How it works

A screen-space shader works on a picture of what is already on screen instead of on one sprite. The ColorRect covers the whole viewport, reads that picture through a screen texture and draws it again in big square blocks. Colour steps and dithering are an optional second pass on the colour each block gets.

Reading the screen in Godot 4

Godot 3 had a built-in called SCREEN_TEXTURE. Godot 4 removed it. Instead you declare your own sampler2D uniform with the hint_screen_texture hint, and Godot fills it with a copy of everything drawn on screen before this node. The other two hints on the line matter for this effect. filter_nearest makes a sample return one exact screen pixel instead of a blend of four. repeat_disable makes a sample past the right or bottom edge repeat the last pixel instead of wrapping round to the other side, which the last row and column of blocks need when the screen size is not a multiple of pixel_size.

gdshader
uniform sampler2D screen_texture : hint_screen_texture, filter_nearest, repeat_disable;

Snapping to a grid of blocks

FRAGCOORD.xy is the position of the pixel being drawn, in pixels of the viewport, counted from the top-left. Divide it by pixel_size and round down, and you get the block the pixel belongs to: with pixel_size 4, pixels 0 to 3 are block 0, pixels 4 to 7 are block 1, and so on. Every pixel of a block then reads the same screen pixel, the one in the middle of the block, so the whole block gets one colour. The grid is counted in screen pixels, so blocks are the same size all over the screen, on 2D and 3D alike.

The middle needs care. With pixel_size 4, the centre of block 0 is at x = 2.0, which is the line between pixel 1 and pixel 2, and a GPU may pick either side. In a test against a CPU reference, sampling exactly there gave hundreds of wrong pixels along sharp edges. The shader rounds the centre down to a whole pixel and adds 0.5, so it always samples the middle of one pixel and every renderer agrees. SCREEN_PIXEL_SIZE is the size of one pixel in UV units, one divided by the viewport size, and turns that pixel position into a UV. textureLod at level 0 reads the full-size copy, the way Godot's own screen-reading docs do it.

gdshader
vec2 block = floor(FRAGCOORD.xy / size);
vec2 centre_pixel = floor((block + 0.5) * size);
vec2 uv = (centre_pixel + 0.5) * SCREEN_PIXEL_SIZE;
vec3 rgb = textureLod(screen_texture, uv, 0.0).rgb;

Colour steps

With palette_steps above 0, each colour channel is rounded to a fixed set of levels. With n steps the levels are 0, 1/(n - 1), 2/(n - 1) and so on up to 1, so 5 steps give 0, 0.25, 0.5, 0.75 and 1. The shader multiplies the channel by n - 1, adds a threshold, rounds down and divides back. With a threshold of 0.5 that is plain rounding to the nearest level. It is a cut per channel, not a hand-picked palette, so 5 steps allow 125 colours. A smooth gradient only passes through a few of them, so it turns into flat bands. That is the look in the "dither off" part of the preview.

gdshader
float steps = float(max(palette_steps, 2) - 1);
rgb = clamp(floor(rgb * steps + threshold) / steps, 0.0, 1.0);

Ordered dithering with a Bayer matrix

Dithering swaps the fixed 0.5 threshold for one that changes from block to block. The 4 by 4 Bayer matrix holds every number from 0 to 15 once, laid out so that numbers close in value sit far apart. The shader takes the entry for the block's place in its 4 by 4 group and turns it into a threshold between 0 and 1. A colour a quarter of the way from one level to the next rounds up in 4 blocks of every 16 and down in the other 12, spread evenly, and from a little distance your eye reads the mix as the colour in between. There are 17 such mixes between any two levels, so a gradient climbs in fine patterned steps instead of a few hard bands.

gdshader
ivec2 cell = ivec2(mod(block, 4.0));
threshold = (BAYER[cell.y * 4 + cell.x] + 0.5) / 16.0;

Why ordered dither beats random noise

You could use random noise as the threshold. It breaks up bands too, but it is worse in a game. If the noise changes every frame, every flat area fizzes and crawls even when nothing on screen moves. If the noise is fixed, it clumps: random values put several high thresholds side by side in some places and leave gaps in others, so flat colours look blotchy. The Bayer threshold depends only on where the block is on screen. A still picture gives exactly the same blocks every frame, so only the things that move change. And the thresholds are spread evenly in every 4 by 4 group, so each in-between colour becomes a tidy, regular pattern, the look older consoles and PC games are known for.

Keeping blocks the same size when the window changes

With the canvas_items stretch mode, Godot draws the game bigger on a bigger window, but pixel_size still counts window pixels. This script on the ColorRect multiplies the size by the stretch scale whenever the viewport changes size. In a test at 2560 by 1440 with a 1280 by 720 base size, it set pixel_size to 8 and the blocks were 8 window pixels wide, the same size next to the game as 4 at 1280 by 720.

gdscript
extends ColorRect

@export_range(1, 32) var base_pixel_size := 4


func _ready() -> void:
	get_viewport().size_changed.connect(_fit)
	_fit()


func _fit() -> void:
	var factor := get_viewport().get_stretch_transform().get_scale().x
	material.set_shader_parameter("pixel_size", maxi(1, roundi(base_pixel_size * factor)))

The other way: render small and scale up

This shader is a post-process. The game renders at full resolution and the ColorRect turns the finished picture into blocks. It works on top of 2D, 3D or both, you can change it while the game runs, and nothing else in the project changes. The price is that the game still renders every pixel at full size, and because only the centre pixel of a block counts, small details flicker as they cross block centres.

The other way renders the game at a low resolution and scales it up. Put the game under a SubViewportContainer, turn on its stretch property and set stretch_shrink to 4. The SubViewport then renders at a quarter of the container's size: 320 by 180 for a 1280 by 720 container, which is 57,600 pixels instead of 921,600. Set the container's Texture > Filter to Nearest, or the scaled-up picture comes out blurry. For the whole game at once, Project Settings > Display > Window > Stretch > Mode set to viewport does the same for the root viewport, and Scale Mode set to integer keeps every game pixel the same size on screen. Every block is then a real game pixel: sprites, lines and text snap to the low-resolution grid and nothing flickers between block centres.

The catch is coordinates. Inside the SubViewport, positions are in low-resolution pixels. At stretch_shrink 4, a click at (400, 200) on the window reaches nodes in the SubViewport as (100, 50). The container converts the events it passes on, but any position you take from the window yourself has to be divided too, and UI inside the SubViewport is pixelated with the game. Pick the shader for the look on an existing game, for a transition, or for a setting the player can switch. Pick the low-resolution viewport when the game is pixel art from the start.

pixelate.gdshader
// Pixelate
// Screen-space pixelation with optional colour steps and ordered dithering.
// Godot 4.7 · CC0 1.0 · godotlearning.com/shaders/pixelate
shader_type canvas_item;
// No 2D lights on the effect itself: it shows the screen exactly as it was drawn.
render_mode unshaded;

// Everything drawn on screen before this node. filter_nearest makes each block
// copy one screen pixel exactly. repeat_disable clamps samples past the right or
// bottom edge to the last pixel instead of wrapping round to the other side.
uniform sampler2D screen_texture : hint_screen_texture, filter_nearest, repeat_disable;

// Width and height of one block, in pixels of the viewport this node draws into.
// 1 leaves the image as it is.
uniform int pixel_size : hint_range(1, 32) = 4;
// Levels per colour channel. 0 keeps every colour. 2 to 16 limits red, green and
// blue to that many levels each (4 gives 4 x 4 x 4 = 64 colours). 1 counts as 2.
uniform int palette_steps : hint_range(0, 16) = 0;
// Ordered (Bayer 4x4) dithering before the levels are applied, so gradients turn
// into a fixed pattern of blocks instead of hard bands. No effect while
// palette_steps is 0.
uniform bool dither = false;

// The 4x4 Bayer matrix: every value from 0 to 15 once, spread so that each
// threshold sits as far as possible from the ones close to it.
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
);

void fragment() {
	float size = float(pixel_size);
	// The block this pixel belongs to, counted from the top-left of the viewport.
	vec2 block = floor(FRAGCOORD.xy / size);
	// Every pixel of the block reads the same screen pixel: the one at the block's
	// centre. Sampling the middle of that pixel, not the line between two pixels
	// (where an even block size puts its centre), gives the same answer on every GPU.
	vec2 centre_pixel = floor((block + 0.5) * size);
	vec2 uv = (centre_pixel + 0.5) * SCREEN_PIXEL_SIZE;
	vec3 rgb = textureLod(screen_texture, uv, 0.0).rgb;

	if (palette_steps > 0) {
		float steps = float(max(palette_steps, 2) - 1);
		// Without dithering every value rounds to the nearest level (threshold 0.5).
		// With it, each block in a 4x4 group rounds at its own threshold.
		float threshold = 0.5;
		if (dither) {
			ivec2 cell = ivec2(mod(block, 4.0));
			threshold = (BAYER[cell.y * 4 + cell.x] + 0.5) / 16.0;
		}
		rgb = clamp(floor(rgb * steps + threshold) / steps, 0.0, 1.0);
	}
	COLOR = vec4(rgb, 1.0);
}

Pitfalls and renderer notes

  • Your HUD and menus are pixelated too. The ColorRect pixelates everything drawn before it, and that includes UI on the default canvas or on the same CanvasLayer. Put the UI on a CanvasLayer with a higher layer number than the effect, the way the demo's caption sits on layer 2 above the effect on layer 1, or move the effect to a lower layer.
  • Order decides what is pixelated. hint_screen_texture holds what was drawn before the node: the 3D scene, the default canvas, lower layers and earlier nodes in the same layer. A node after the ColorRect in the same layer draws on top, sharp. Godot 4.7 takes a fresh copy of the screen for each higher CanvasLayer, but not for a second screen-reading node in the same layer. In a test, a second effect in the same layer read the picture from before the first one and undid it. Put stacked effects, such as this and a vignette, on separate CanvasLayers, or add a BackBufferCopy node with Copy Mode set to Viewport between them.
  • Buttons stop responding after you add the effect. A ColorRect's Mouse > Filter is Stop by default, so a full-screen one takes every click before the UI under it sees it. Set it to Ignore, as the demo does.
  • The blocks look smaller on a bigger window. pixel_size counts pixels of the viewport, and with the default stretch mode (disabled) or canvas_items, the viewport is the window. In a test with canvas_items at 2560 by 1440, the game was drawn twice as big but the blocks stayed 8 pixels wide, so they looked half as chunky. With the viewport stretch mode the viewport stays at the base size and the blocks scale with everything else. For canvas_items, use the script in "Keeping blocks the same size" above.
  • Thin lines and small details flicker or vanish. Each block keeps only the colour at its centre, so a detail thinner than a block shows only in the frames where it covers a centre. The demo's 4 pixel ground edge disappears at pixel_size 8. Draw details at least pixel_size wide, or use the low-resolution viewport.
  • The dither pattern does not move with the camera. The block grid and the Bayer pattern are fixed to the screen, so when the camera pans, the picture slides under a pattern that stays still. A smaller pixel_size or more palette_steps makes it less visible.
  • Flat areas turn into a checkerboard with dithering on. A flat colour that sits between two levels is dithered like a gradient. Use colours on the levels for areas that should stay flat: with palette_steps 5 that is 0, 0.25, 0.5, 0.75 or 1 per channel, which is what the demo's ground stripes use.
  • Screenshots differ slightly between renderers with dithering on. Under Compatibility, a block whose colour is very close to a dither threshold can round one level differently from Forward+ and Mobile. The renderer notes below have the numbers. Pixelation and colour steps without dithering came out identical on all three.
  • Cost. The shader does one texture read and a few sums per screen pixel whatever pixel_size is: 921,600 reads at 1280 by 720 and 2,073,600 at 1920 by 1080, plus one full-screen copy of the screen. A bigger pixel_size does not make it cheaper, and the game behind it still renders at full size. If you need the speed, the low-resolution viewport at stretch_shrink 4 renders 16 times fewer pixels.

The demo scene was run under Forward+, Mobile and Compatibility with no errors or warnings. In a separate test that checked every third pixel of the output against a CPU reference, Forward+ and Mobile matched exactly for every setting tried. Compatibility matched exactly for pixelation and for colour steps without dithering. With dithering on, up to 2.3% of the checked pixels came out exactly one level apart, all in blocks whose colour is very close to a dither threshold. You only see it if you compare screenshots. The demo's 3D sky is a little darker under Compatibility; that comes from its 3D lighting, not from this shader. Compatibility is the renderer web exports use.

Pairs with

Shader

Sprite Outline Shader

A crisp pixel outline for any 2D sprite, drawn outside or inside the art, that stays clean on sprite sheets and atlases.

  • Godot 4.7
  • CC0
See the shader

Questions

How do I pixelate the whole screen in Godot 4?

Add a CanvasLayer to your main scene and give it a ColorRect with the Full Rect anchor preset and Mouse > Filter set to Ignore. Give the ColorRect a ShaderMaterial with this shader. It reads the screen through a sampler2D with hint_screen_texture and draws it again in blocks of pixel_size. Put your UI on a higher CanvasLayer so it stays sharp.

Does a pixelate shader work with 3D in Godot?

Yes. The 3D scene is drawn before the 2D canvas layers, so the ColorRect's screen texture already holds the 3D picture. In the demo, the sky and the spinning ring are 3D and the balls and stripes are 2D, and one ColorRect pixelates all of them. To keep some 2D sharp, put it on a CanvasLayer above the effect.

What happened to SCREEN_TEXTURE in Godot 4?

It was removed. Declare uniform sampler2D screen_texture : hint_screen_texture, filter_nearest; and read it with textureLod(screen_texture, SCREEN_UV, 0.0). Godot fills the uniform with a copy of what was drawn before the node, which is what SCREEN_TEXTURE gave you in Godot 3.

Should I use a pixelate shader or a low-resolution viewport for pixel art?

Use the shader to give an existing game the look, for a transition, or for an option the player can switch, since it works over 2D and 3D and changes nothing else. Use a low-resolution viewport, through a SubViewportContainer with stretch_shrink or the viewport stretch mode, when the game is pixel art from the start. It renders far fewer pixels and every pixel is a real game pixel, but positions inside it are in low-resolution pixels.