# Godot pixel-art shaders from scratch #1 — the pipeline, and the one bit of math that makes pixels

> Not a native English speaker — corrections welcome!

![Quantization: raising n turns a smooth gradient into pixel bands — floor(x·n)/n, with the floor + fract decomposition below](https://cdn.hashnode.com/res/hashnode/image/upload/v1786300875024/933e095c-f114-498a-9a93-8fbb9fe19943.gif)

> ▶ **Play with the interactive version**
> Every concept here as a live, in-browser demo — [open it and drag the sliders yourself](https://passingg.github.io/wiseframe-shader-demos/).

I'm teaching myself Godot 3D pixel-art shaders and building interactive explainers as I go. This is part 1.

## What I'm building (series intro)

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.

## Where this comes from (credit where it's due)

I didn't invent any of this. I fell down this rabbit hole after reading David Holland's [3D Pixel Art Rendering](https://www.davidhol.land/articles/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:

- **David Holland — [3D Pixel Art Rendering](https://www.davidhol.land/articles/3d-pixel-art-rendering/)** — the article that started it all: outlines, camera snapping, water, volumetrics, the whole map. He builds on a custom engine; I'm redoing it in stock Godot 4.7, which is exactly where most of my "wait, how do I do *this* part?" moments come from.
- **[t3ssel8r](https://www.youtube.com/@t3ssel8r)** — pretty much defined this look for a lot of us: orthographic + low resolution + camera snapping, and shell-based volumetrics.
- **[Roystan](https://roystan.net/articles/outline-shader/)** — clear, patient outline and toon-water tutorials. Unity, but the ideas carry straight over.
- **[godotshaders.com outline shader](https://godotshaders.com/shader/3d-pixel-art-outline-highlight-post-processing-shader/)** — a concrete Godot reference for the outline pass.

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.

## The mental model: a shader is a per-pixel function

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.

## Coordinates: UV vs SCREEN_UV

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.

- **UV** is a 0..1 coordinate glued to the *surface* of the mesh. If the object moves or rotates, the UV-based pattern moves and rotates with it — like a texture printed on the object.
- **SCREEN_UV** is a 0..1 coordinate glued to the *screen*. If the object moves, a SCREEN_UV-based pattern stays put — the object is more like a window you're looking at the screen-space pattern through.

%[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."

## The one bit of math: quantization

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.

![Quantization: smooth ramp vs floor(x*n)/n, and the floor+fract decomposition](https://cdn.hashnode.com/res/hashnode/image/upload/v1786300875631/2dfba480-2143-41f2-be2a-5118d748867e.png)

## My first shader (Godot 4.x)

Small enough to paste into a new `.gdshader` file and run:

```glsl
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.

## Where I actually got stuck (the pipeline in practice)

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.

![Pipeline: Scene to SubViewport 640x360 to TextureRect (Nearest) to Window 1920x1080 (integer 3x scale)](https://cdn.hashnode.com/res/hashnode/image/upload/v1786300875836/e760867a-ea6c-4b92-a32b-f95d76f1dee0.png)

**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.

## Wrap-up / next

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.
