Nailing that PlayStation 1 look in a modern engine isn’t just about low-poly models. The real secret sauce is in the post-processing: chunky colour banding, Bayer dithering, scanlines, and a half-resolution render that gets nearest-neighbour upscaled. Here’s how I built the full stack in Godot 4 for a spaceship game intro sequence — all reusable, all @tool-enabled for editor preview.
The Shader: Colour Banding + Bayer Dither + Scanlines
The shader runs as a fullscreen ColorRect in a CanvasLayer. It samples the screen texture, posterises to a fixed number of colour levels, applies ordered (Bayer) dithering, and overlays scanlines. The result is that crunchy 32-bit colour look.
shader_type canvas_item;
uniform sampler2D screen_tex : hint_screen_texture, filter_nearest;
uniform float color_levels : hint_range(2.0, 256.0) = 8.0;
uniform float dither_strength : hint_range(0.0, 2.0) = 1.0;
uniform float scanline_opacity : hint_range(0.0, 1.0) = 0.3;
uniform float contrast : hint_range(0.5, 2.0) = 1.15;
const float bayer[16] = float[](
0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0,
12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0,
3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0,
15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0
);
void fragment() {
vec4 col = textureLod(screen_tex, SCREEN_UV, 0.0);
col.rgb = (col.rgb - 0.5) * contrast + 0.5;
ivec2 px = ivec2(SCREEN_UV / SCREEN_PIXEL_SIZE);
float d = bayer[(px.x & 3) + (px.y & 3) * 4];
col.rgb += (d - 0.5) * dither_strength / color_levels;
col.rgb = floor(col.rgb * color_levels + 0.5) / max(color_levels - 1.0, 1.0);
col.rgb = clamp(col.rgb, 0.0, 1.0);
float scan = sin(SCREEN_UV.y * SCREEN_PIXEL_SIZE.y * 2.0) * 0.5 + 0.5;
col.rgb = mix(col.rgb, col.rgb * scan, scanline_opacity);
COLOR = col;
}
The CanvasLayer sits at layer 50 — below any UI at layer 100+. That way your mission objectives panel and fade overlays stay crisp while the 3D scene gets the retro treatment.
Half-Resolution Render for Chunky Pixels
A real PS1 rendered at 320×240. We don’t need to go that low, but rendering at half the window resolution and then nearest-neighbour upscaling gives you that chunky pixel look without sacrificing readability. The trick: use content_scale_mode = VIEWPORT and set content_scale_size to half the window dimensions.
extends Node
func _ready():
_apply_half_resolution()
get_tree().root.size_changed.connect(_on_size_changed)
func _apply_half_resolution():
var vp = get_viewport()
vp.content_scale_mode = 2 # VIEWPORT — nearest-neighbor upscale
vp.content_scale_size = DisplayServer.window_get_size() / 2
vp.content_scale_aspect = 4 # EXPAND — fills the window
func _on_size_changed():
_apply_half_resolution()
This dynamically adapts to any window size. Resize the window? The render resolution updates to stay at exactly half. No hardcoded 960×540 — it’s always window_size / 2. The result: pixel-perfect 2x upscale that looks like a CRT but works on any monitor.
Camera Auto-Look: Design-Time Meets Run-Time
When placing cinematic camera shot markers in the editor, you want them to automatically face the player ship — both while editing and optionally during gameplay. A @tool script on Camera3D solves this:
@tool
extends Camera3D
class_name CameraAutoLook
@export var look_target: NodePath:
set(v):
look_target = v
_resolve_target()
_teleport_look()
@export var auto_look_editor: bool = true
@export var auto_look_runtime: bool = false
func _notification(what: int) -> void:
if Engine.is_editor_hint() and auto_look_editor and what == NOTIFICATION_TRANSFORM_CHANGED:
_teleport_look()
func _teleport_look() -> void:
if _target_node and is_instance_valid(_target_node):
look_at(_target_node.global_position, Vector3.UP)
Drop this on any Camera3D, set the look_target NodePath, and the camera auto-rotates in the editor viewport every time you move it. Toggle auto_look_runtime to true and it tracks the target every frame during gameplay. Perfect for shot marker cameras that are only used as reference transforms during cutscenes.
Camera Fade Transitions
Hard camera cuts are jarring. A quick fade-to-black → snap → fade-in smooths the transition. The intro sequence uses this helper across multiple beats:
func _switch_camera_with_fade(target_cam: Camera3D, fade_duration: float = 0.25) -> void:
# Fade to black
var t := create_tween()
t.tween_property(fade_overlay, "modulate:a", 1.0, fade_duration)
await t.finished
# Snap camera
cinematic_camera.global_position = target_cam.global_position
cinematic_camera.look_at(player_ship.global_position, Vector3.UP)
# Fade back in
var t2 := create_tween()
t2.tween_property(fade_overlay, "modulate:a", 0.0, fade_duration)
await t2.finished
Called as await _switch_camera_with_fade(shot_emergency) during the mentor message beat, and again for the FTL jump. The drift and waypoint beats use smooth position tweens instead — no fade needed when the movement is already gradual.
Putting It All Together
The intro scene (intro.tscn) now has:
- PSXResolution node — dynamically sets half-res render, updates on window resize
- PSXPostProcess CanvasLayer (layer 50) — fullscreen ColorRect with the dither/banding shader
- FadeCanvas (layer 100) — clean fade overlay on top of the post-process effect
- 5 shot marker cameras — each with
CameraAutoLooktargeting the PlayerShip, updating in-editor - AudioAmbience — 1-hour ambient sci-fi track playing from frame 0
Why This Approach Works
Most “retro shader” tutorials stop at the shader code. But the real difference between a tech demo and a usable game comes from the surrounding tooling:
- No project settings changes needed — the resolution controller is a node you drop into any scene
- Editor preview works —
@toolscripts mean designers see the real look while placing cameras - Layer discipline — UI stays sharp, post-processing stays below
- Dynamic resolution — works on any monitor, not just 1080p
The full source for the shader, resolution controller, and camera tools are available in the project repository. Drop a ⭐ if you find it useful.
Leave a Reply