Godot pixel-art shaders from scratch #1 — the pipeline, and the one bit of math that makes pixels
floor() is basically the whole trick
Search for a command to run...
floor() is basically the whole trick
No comments yet. Be the first to comment.
Not a native English speaker — corrections welcome!

▶ Play with the interactive version Every concept here as a live, in-browser demo — open it and drag the sliders yourself.
I'm teaching myself Godot 3D pixel-art shaders and building interactive explainers as I go. This is part 1.
The goal, a few posts from now: a 3D pixel-art shader that works for both perspective and orthographic cameras, from one codebase. Outlines. Water. God rays. Bloom. The kind of look you see in games that render "real" 3D geometry but make it read as pixel art.
I don't know how to do most of that yet. That's the point of the series — I'm learning it from the ground up and writing down what actually happened, not a polished summary after the fact.
This first post is the boring-but-necessary part: what a shader even is, the pipeline that turns a 3D scene into a pixel-art image, and the one piece of math — quantization — that does most of the actual work.
I didn't invent any of this. I fell down this rabbit hole after reading David Holland's 3D Pixel Art Rendering — the idea that you can render real 3D geometry and still make it read as pixel art kind of broke my brain. I didn't want to just copy a finished shader; I wanted to rebuild each piece from scratch until I actually understood why it works. That's what this series is.
People and work this stands on:
If you're new to this, honestly go read and watch their stuff first — they're the real teachers here. This series is just me working through it out loud, in vanilla Godot, writing down what actually happened (mistakes included). Corrections welcome.
A shader has (at least) two stages. The vertex stage takes a mesh vertex and decides where it lands on screen. The fragment stage takes a pixel and decides what color it is.
Both are tiny functions. The part that took me a while to really internalize: the GPU runs the fragment function on thousands of pixels at the same time, and each run knows nothing about the others.
So the mental model I keep coming back to is:
I am one pixel. I don't know what color my neighbor is. I can only read my own coordinate and sample textures.
No loops over "all pixels," no shared state between pixels (within a single pass, at least). If I want a pixel to react to its neighbors — for an outline, for example — I have to bring that information in some other way (usually by sampling a texture at nearby coordinates, which is its own topic for a later post).
This is also why a lot of shader code looks weirdly restrictive compared to normal programming. You can't just say "loop over the image and find the edges." You have to ask: what can this one pixel figure out using only its own coordinate and whatever textures it's allowed to sample? Everything downstream gets built out of that constraint.
A coordinate is only ever meaningful relative to an origin and a set of axes — that's an agreement, not a fact about the universe. Godot's shaders hand you a few different ones, and mixing them up was my first real point of confusion.
https://vimeo.com/1216818626/9baf06b0c3
This distinction matters more than it sounds like it should, because SCREEN_UV is the seed for basically all post-processing — outlines, screen-space effects, anything that needs to know "where am I on the final image" instead of "where am I on this specific object."
If pixel art has a mathematical heart, it's this one line:
floor(x * n) / n
Read it left to right: multiply x up by n, floor it (drop everything after the decimal point), divide back down by n. In plain words: force a lower resolution onto a value that was smooth.
There's an identity that goes with it, and it's the thing that made quantization click for me:
x = floor(x) + fract(x)
Any number is exactly its floor (the "staircase" part) plus its fract (the "sawtooth" part, always between 0 and 1). This is always true, not an approximation — floor throws information away, fract holds exactly what floor threw away, and adding them back gives you the original value with zero error.
Here's what that looks like plotted. Top: a smooth ramp against the quantized staircase for n = 8. Bottom: the floor and fract pieces separately, and their sum landing exactly back on the original line.

Small enough to paste into a new .gdshader file and run:
shader_type canvas_item;
uniform int steps = 8;
void fragment() {
// UV goes 0..1 across the surface.
// Each pixel only knows its own UV — nothing about its neighbors.
float q = floor(UV.x * float(steps)) / float(steps);
COLOR = vec4(vec3(q), 1.0);
}
Attach it to a ColorRect or a Sprite2D and you get a horizontal grayscale gradient — except it's banded, not smooth. Bump steps up and the bands get thinner; push it high enough and it looks smooth again (you've just made n large enough that the human eye can't tell the difference). That one floor() call is the heart of the pixel-art look.
The math above is one line. Getting an actual 3D scene to look pixel-art in Godot took me an afternoon of tripping over UI defaults I didn't know existed. Here's the real pipeline, and the three things that went wrong for me in order.
The actual approach: render the 3D scene into a SubViewport at a low internal resolution (I used 640×360), then display that render on a full-screen TextureRect with the Nearest texture filter, scaled up by an integer factor to fill the window.

Problem 1 — Full Rect anchoring did nothing.
I dropped a TextureRect, set its anchor preset to Full Rect expecting it to fill the window. It didn't move. I stared at the inspector for way too long before realizing the issue wasn't the anchor at all — it was the root node. My scene root was a Node2D, and anchors only do anything inside a Control-based layout tree. The fix was to make the root a plain Node and set the TextureRect's Layout Mode to Anchors directly (and keep it out of a Container parent — a Container will override the manual anchor values right back).
Problem 2 — The rendered viewport was just... not there.
Once the TextureRect was actually filling the screen, it showed nothing. Black, or empty. This turned out to be a camera problem, not a rendering-pipeline problem — the Camera3D inside the SubViewport wasn't positioned to see anything in the scene (default position, nothing nearby, wrong facing direction). Not a subtle bug, just an easy one to chase in the wrong place first, because I assumed the SubViewport setup itself was broken.
Problem 3 — The upscale was blurry.
Once I could see the scene, the pixel-art look wasn't there — everything looked like a low-res image stretched and smoothed, which is exactly what it was. The TextureRect's Texture Filter was on the default, Linear. Switching it to Nearest was the whole fix. Nearest just holds each low-res pixel as a hard-edged block instead of blending it into its neighbors, and that's what actually reads as "pixel art" instead of "blurry."
https://vimeo.com/1216818627/90959bdcb7
None of these three were hard once I understood them. All three cost me real time because the failure mode gave no error — just a UI element quietly doing nothing, or a picture quietly looking wrong. That seems to be a pattern with shader and rendering work in general, at least so far: the code compiles, nothing crashes, and the bug is just "the picture isn't what I expected." No stack trace to follow, so the only way through is to isolate one variable at a time — root node type, camera transform, filter mode — and check each in turn.
So: a shader is a tiny per-pixel function running in parallel with no memory of its neighbors, UV vs SCREEN_UV is about what the coordinate is glued to, and floor(x*n)/n is the one line of math that turns "smooth" into "pixel art." The rest of this post was really just the practical plumbing to get that math onto a real 3D scene.
Next up: toon lighting — dot products, Lambertian shading, and dithering to fake more color depth than the quantized palette actually has. If any of the math or the Godot specifics above is wrong or could be explained better, corrections welcome — I'm still figuring this out.