The PS1 look isn't one effect. It's a stack of hardware limits: vertices that snap to a coarse grid, textures that warp because the GPU didn't correct for perspective, and a 320×240 framebuffer. Fake the first two in a spatial shader and the style starts to fall out by itself.

This post is a complete, drop-in Godot 4 shader for vertex snapping and affine texture warping, plus the one mistake that made my floors look like smeared paint. I tested the exact shader below headless in Godot 4.5.1 (Compatibility renderer). No errors, 24 frames with a moving camera.

1. Vertex snapping: the "wobble"

The PlayStation 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.

The important part: divide by w first, snap in normalized device coordinates, then multiply w back. If you snap raw clip coords, distant objects barely wobble and near ones wobble too much.

vec4 ps1_snap_clip(vec4 clip) { vec2 g = max(snap_grid, vec2(1.0)); // e.g. (160, 120) = half of 320x240 vec2 ndc = clip.xy / clip.w; ndc = floor(ndc * g + 0.5) / g; clip.xy = ndc * clip.w; return clip; } 

A snap_grid of (160, 120) matches a 320×240 framebuffer (two NDC units, -1 to 1, times half the resolution).

2. Affine texture warping

The PS1 interpolated UVs linearly in screen space, with no perspective correction. That's why textures swim and bend as you move. Modern 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() { vec4 clip = PROJECTION_MATRIX * (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)); POSITION = ps1_snap_clip(clip); v_affine = vec3(UV * POSITION.w, POSITION.w); } void fragment() { vec2 affine_uv = v_affine.xy / max(v_affine.z, 0.0001); vec2 uv = mix(UV, affine_uv, affine_amount); // 0 = modern, 1 = full PS1 ALBEDO = texture(albedo_texture, uv).rgb; } 

3. The floor-smear lesson

My first version used full affine everywhere. Walls looked great. The ground looked like smeared paint: texels streaking into each other. 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. (Real PS1 games did exactly this.) I use a PlaneMesh with subdivide_width and subdivide_depth set to 7.
  2. Lower affine per material. I default floors to about 0.2 and leave walls and props at 1.0, multiplied by a global affine_amount of 0.5.
  3. Fade it out where it hurts most: close to the camera, and on triangles whose depth changes fast across the screen.
float a = clamp(affine_amount * affine_multiplier, 0.0, 1.0); a *= smoothstep(1.5, 5.0, v_affine.z); // fade near the camera float dz = fwidth(v_affine.z) / max(v_affine.z, 0.0001); a *= 1.0 - smoothstep(0.02, 0.08, dz); // fade on steep depth vec2 uv = mix(UV, v_affine.xy / max(v_affine.z, 0.0001), a); 

You keep the swimming on walls and props, and floors stay readable. Use filter_nearest (or filter_nearest_mipmap) on the texture so it stays crunchy.

4. The complete shader

Save this as ps1_basic.gdshader and drop a ShaderMaterial on any MeshInstance3D. Set affine_multiplier to 0.2 on large floors.

shader_type spatial; render_mode vertex_lighting, cull_back, diffuse_lambert, specular_disabled; uniform sampler2D albedo_texture : source_color, filter_nearest, repeat_enable; uniform vec4 albedo_color : source_color = vec4(1.0); uniform vec2 snap_grid = vec2(160.0, 120.0); uniform float affine_amount : hint_range(0.0, 1.0) = 0.5; uniform float affine_multiplier : hint_range(0.0, 1.0) = 1.0; varying vec3 v_affine; vec4 ps1_snap_clip(vec4 clip) { vec2 g = max(snap_grid, vec2(1.0)); vec2 ndc = clip.xy / clip.w; ndc = floor(ndc * g + 0.5) / g; clip.xy = ndc * clip.w; return clip; } void vertex() { vec4 clip = PROJECTION_MATRIX * (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)); POSITION = ps1_snap_clip(clip); v_affine = vec3(UV * POSITION.w, POSITION.w); } void fragment() { float a = clamp(affine_amount * affine_multiplier, 0.0, 1.0); a *= smoothstep(1.5, 5.0, v_affine.z); float dz = fwidth(v_affine.z) / max(v_affine.z, 0.0001); a *= 1.0 - smoothstep(0.02, 0.08, dz); vec2 uv = mix(UV, v_affine.xy / max(v_affine.z, 0.0001), a); vec4 tex = texture(albedo_texture, uv); ALBEDO = tex.rgb * albedo_color.rgb; } 

render_mode vertex_lighting is the cheap bonus: Gouraud (per-vertex) lighting, which is how the PS1 lit meshes.

5. Low resolution, done properly

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

Wrap-up

Snap vertices in NDC, undo perspective correction (and back off on floors), render low-res. That's most of the look. The shader above is enough to start.

If you'd rather skip the plumbing, I packaged this plus the rest of the stack as the Godot 4 PS1 Shader Kit ($1.99, MIT): the lit and unshaded shaders, a 320×240 pixel camera, colour-depth reduction and Bayer dither, N64-style 3-point filtering, per-vertex fog, PS1/N64/Saturn presets, and two demo scenes. There isn't a separate Lite page for this kit — the shader in this post is the free starting point.

My other Godot packs, including the free Stylized VFX Lite and Pixel VFX Lite, are on my itch store. The paid ones are also in the Godot 4 Mega Bundle ($14.99).

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