DEV Community

Cover image for How to get the PS1 look in Godot 4 (vertex snapping, affine warping, dither)
tommy
tommy

Posted on

How to get the PS1 look in Godot 4 (vertex snapping, affine warping, dither)

The PS1 look isn't one effect. It's four or five hardware limits stacked on top of each other. Fake each one in a shader and the style falls out by itself. Here's how each piece works in Godot 4, with the actual shader code I use, plus one mistake that made my floors look broken.

Stock Godot vs PS1 wobble

1. Vertex snapping: the "wobble"

The PS1 had no sub-pixel precision, so vertices snapped to whole screen pixels. As the camera moves, geometry jitters. You can fake this by snapping the clip-space position to a coarse grid in the vertex shader:

vec4 ps1_snap_clip(vec4 clip) {
    vec2 g = max(ps1_snap_grid * 0.5, vec2(1.0)); // e.g. (320, 240) * 0.5
    vec2 ndc = clip.xy / clip.w;                  // to normalized device coords
    ndc = floor(ndc * g + 0.5) / g;               // round to the grid
    clip.xy = ndc * clip.w;                       // back to clip space
    return clip;
}

void vertex() {
    vec4 clip = PROJECTION_MATRIX * (MODELVIEW_MATRIX * vec4(VERTEX, 1.0));
    POSITION = ps1_snap_clip(clip);
}
Enter fullscreen mode Exit fullscreen mode

Divide by w first, snap, then multiply back. If you snap raw clip coords, distant objects barely wobble and near ones wobble too much.

2. Affine texture warping

The PS1 interpolated UVs linearly in screen space, with no perspective correction. That's why textures swim and bend. GPUs correct perspective for you, so you undo it: pass UV * w and w as a varying, then divide in the fragment shader.

varying vec3 v_affine;

void vertex() {
    // ...after POSITION is set:
    v_affine = vec3(UV * POSITION.w, POSITION.w);
}

void fragment() {
    vec2 affine_uv = v_affine.xy / v_affine.z;  // screen-linear = PS1-style
    vec2 uv = mix(UV, affine_uv, ps1_affine);   // 0 = modern, 1 = full PS1
}
Enter fullscreen mode Exit fullscreen mode

The floor-smear lesson

My first version used full affine everywhere. Walls looked great, but the ground looked like smeared AI slop: pixels streaking into each other. The reason is that affine error grows with how much depth a triangle covers on screen. A floor made of two huge triangles running from your feet to the horizon is the worst case.

Three fixes, in order of impact:

  1. Subdivide big planes. Smaller triangles mean less depth per triangle and less warp. (The real PS1 games did exactly this.)
  2. Lower affine per material. I default floors to about 0.12 and leave walls at 0.5.
  3. Fade it out where it hurts most: close to the camera, and on triangles whose depth changes fast across the screen:
vec2 ps1_affine_uv(vec2 uv, vec3 aff, float mult) {
    float a = clamp(ps1_affine * mult, 0.0, 1.0);
    a *= smoothstep(1.5, 5.0, aff.z);              // fade near the camera
    float dz = fwidth(aff.z) / max(aff.z, 0.0001); // steep depth across the screen?
    a *= 1.0 - smoothstep(0.02, 0.08, dz);
    return mix(uv, aff.xy / max(aff.z, 0.0001), a);
}
Enter fullscreen mode Exit fullscreen mode

You keep the swimming on walls and props, and floors stay readable. Use filter_nearest_mipmap on the texture too, so distant floors don't shimmer into lines.

3. Low resolution, done properly

Don't fake low-res with a pixelate shader. Render the 3D world into a SubViewport at 320x240 and draw it upscaled with nearest filtering. Then snapping, dithering and texture pixels all line up on the same grid. That alignment is a big part of why it reads as authentic and not as a filter.

4. Colour depth + Bayer dither

The PS1 output 15-bit colour (5 bits per channel) and dithered to hide the banding. In a fullscreen canvas_item shader on the low-res image:

const float B4[16] = float[](0.,8.,2.,10., 12.,4.,14.,6., 3.,11.,1.,9., 15.,7.,13.,5.);

ivec2 p = ivec2(floor(UV * low_res));                   // low-res pixel coords
float t = (B4[(p.x & 3) + (p.y & 3) * 4] + 0.5) / 16.0; // threshold 0..1
float levels = exp2(color_bits) - 1.0;                  // 5 bits -> 31 levels
c += (t - 0.5) * dither_strength / levels;
c = floor(clamp(c, 0.0, 1.0) * levels + 0.5) / levels;
Enter fullscreen mode Exit fullscreen mode

Tip: full-strength dither puts ugly grain on flat bright skies. I measure local contrast from four neighbouring pixels and cut the dither on flat, bright areas, keeping it in darks and gradients where it actually hides banding.

5. The finishing touches

  • Gouraud lighting: render_mode vertex_lighting; in a spatial shader. Lighting per vertex, not per pixel, very PS1.
  • Per-vertex fog: compute fog in vertex() from view distance and set the background to the same colour. That's how PS1 games hid their short draw distance.
  • Global shader parameters: put the snap grid, affine amount, fog and so on in Project Settings globals. Then one setting changes every material, and an "off" switch for before/after comparisons is trivial.

Wrap-up

Snap vertices, undo perspective correction (carefully), render low-res, quantize and dither. That's 90% of the look. Start with the snippets above, and remember to subdivide your floors.


If you'd rather skip the plumbing, I packaged all of this as the Godot 4 PS1 Shader Kit ($1.99, MIT). It has the lit and unshaded shaders, the 320x240 pixel camera, the dither post, N64 3-point filtering, PS1/N64/Saturn presets and two demo scenes. My other Godot packs are in the Godot 4 Mega Bundle ($14.99).

Disclosure: I sell the kit linked above (no affiliate links). The code was written with an AI coding assistant and tested by hand in Godot 4.5.1.

Top comments (1)

Collapse
 
lucyreeves profile image
lucy-reeves •

do you see any flicker when the snap grid aligns with moving objects, and how do you mitigate that in the shader?