PS1 Post-processing

This shader is meant to be used with the PS1 Shader. It gives your game that PS1 dithered look. Works in editor, too!

Usage:
Make a MeshInstance, give it a QuadMesh, make the size 2 by 2, set the Extra Cull Margin to the highest possible value, apply a material with this shader to it.

Known issues:
Doesn’t support transparent objects.

Uniforms:
Color Depth – Reduce color depth to X bits per color. The PS1 supported many modes, some of them being 24 bit (8 bits per color) and 15 bit (5 bits per color), the latter (5 bits per color) used in majority of 3D games and being the default.
Dithering – Apply the “dotted” look that gives an illusion of more colors.
Resolution Scale – Reduce screen resolution.

Shader code
shader_type spatial;
render_mode unshaded, shadows_disabled, depth_test_disable, depth_draw_never;

uniform int color_depth : hint_range(1, 8) = 5;
uniform bool dithering = true;
uniform int resolution_scale = 4;

int dithering_pattern(ivec2 fragcoord) {
	const int pattern[] = {
		-4, +0, -3, +1, 
		+2, -2, +3, -1, 
		-3, +1, -4, +0, 
		+3, -1, +2, -2
	};
	
	int x = fragcoord.x % 4;
	int y = fragcoord.y % 4;
	
	return pattern[y * 4 + x];
}

void vertex() {
	POSITION = vec4(VERTEX, 1.0);
}

void fragment() {
	ivec2 uv = ivec2(FRAGCOORD.xy / float(resolution_scale));
	vec3 color = texelFetch(SCREEN_TEXTURE, uv * resolution_scale, 0).rgb;
	
	// Convert from [0.0, 1.0] range to [0, 255] range
	ivec3 c = ivec3(round(color * 255.0));
	
	// Apply the dithering pattern
	if (dithering) {
		c += ivec3(dithering_pattern(uv));
	}
	
	// Truncate from 8 bits to color_depth bits
	c >>= (8 - color_depth);

	// Convert back to [0.0, 1.0] range
	ALBEDO = vec3(c) / float(1 << color_depth);
}
Tags
ps1, retro
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from Mighty Duke

PS1 Shader

Mandelbrot Set

Related shaders

Stylized shadows, not a post processing

Shaped Glow Post-Processing Effect

3D Pixel art outline & highlight Shader (Post-processing/object)

guest

16 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Zorochase
Zorochase
2 years ago

Awesome shader! One problem though, it doesn’t support transparency at all. Transparent objects disappear at certain angles and are not affected by the reduced resolution and dithering.

Last edited 2 years ago by Zorochase
Cyanide
Cyanide
2 years ago

It works, but I’m not convinced 8 bit color is the mode used by most PS1 games, least of all Metal Gear Solid.

https://en.wikipedia.org/wiki/PlayStation_technical_specifications

  • Mode 4: 4-bit CLUT (16 colors)
  • Mode 8: 8-bit CLUT (256 colors)
  • Mode 15: 15-bit direct (32,768 colors)
  • Mode 24: 24-bit (16,777,216 colors)

I suspect Mode 15 was the most common.

Transparency could work with with this if it was suface shader rather than a post process shader.

https://menacingmecha.itch.io/godot-psx-style-demo

Here is someone else’s attempt at it – it has many problems of its own, which I’ve been trying to fix, but it does support transparency.

freddles
freddles
2 years ago

any chance of a GLES2 compatible version? it’s kinda weird to use a higher-quality renderer to make my graphics look worse

Exuin
Exuin
2 years ago

Wait, this isn’t a canvasitem shader…

admin
Admin
admin
1 year ago
Reply to  Exuin

Changed the category to Spatial. Better late than never 🙂

Guessy
Guessy
1 year ago

I managed to get transparent objects (and shadows) working by converting this into a canvas item shader as per here. Conversion’s pretty painless: change the shader_type, comment out the render_mode and vertex function and switch SCREEN_TEXTURE for TEXTURE. Then plonk the material on a ViewportContainer. Tada.

The Viewport will need to have matching Shadow Atlas size for shadows to show up properly, but it does work!

comment image

Only downside is it no longer works in Editor.

Last edited 1 year ago by Guessy
Lemin
Lemin
1 year ago
Reply to  Guessy

What did you change the albedo line near the end to? Albedo doesn’t exist on canvasitem shaders and I can’t for the life of me figure out what i’m meant to put there instead.

EDIT: Found it. Change ALBEDO to COLOR.rgb

Last edited 1 year ago by Lemin
godotuser342432134
godotuser342432134
11 months ago
Reply to  Lemin

thanks you legend

CoffeeMoog
CoffeeMoog
9 months ago

Not sure if anyone else has made this mistake, but be sure to resize the quad mesh by clicking the mesh preview window and change the size to 2. Don’t change the size in the transform menu, otherwise only a small portion of the center of the viewport will be affected by the post-processing.

Ksigon
Ksigon
9 months ago

can you do a gles 2 version? Please!

Firerabbit
Firerabbit
7 months ago
Reply to  Ksigon

shader_type canvas_item;

uniform int color_depth : hint_range(1, 8) = 5;
uniform bool dithering = true;
uniform int resolution_scale = 4;

const mat4 pattern = mat4(
vec4(-4, +0, -3, +1),
vec4(+2, -2, +3, -1),
vec4(-3, +1, -4, +0),
vec4(+3, -1, +2, -2)
);

int dithering_pattern(ivec2 fragcoord) {
int x = fragcoord.x % 4;
int y = fragcoord.y % 4;

vec4 pattern_vec4;

if (y == 0) pattern_vec4 = pattern[0];
else if (y == 1) pattern_vec4 = pattern[1];
else if (y == 2) pattern_vec4 = pattern[2];
else if (y == 3) pattern_vec4 = pattern[3];

float pattern_int;

if (x == 0) pattern_int = pattern_vec4.x;
else if (x == 1) pattern_int = pattern_vec4.y;
else if (x == 2) pattern_int = pattern_vec4.z;
else if (x == 3) pattern_int = pattern_vec4.w;

return int(pattern_int);
}

void fragment() {
ivec2 uv = ivec2(FRAGCOORD.xy / float(resolution_scale));
vec4 color = texelFetch(SCREEN_TEXTURE, uv * resolution_scale, 0);

// Convert from [0.0, 1.0] range to [0, 255] range
ivec4 c = ivec4(round(color * 255.0));

// Apply the dithering pattern
if (dithering) {
c += ivec4(dithering_pattern(uv));
}

// Truncate from 8 bits to color_depth bits
c >>= (8 – color_depth);

// Convert back to [0.0, 1.0] range
COLOR = vec4(c) / float(1 << color_depth);
}
This is a version for GLES2. Apply this shader to a full-screen ColorRect.

Last edited 7 months ago by Firerabbit
Firerabbit
Firerabbit
7 months ago
Reply to  Firerabbit
// This Version is working in GLES2, but it will need some further improvements
// You can not change the resolution.

shader_type canvas_item;

uniform int color_depth : hint_range(1, 8) = 5;
uniform bool dithering = true;

const mat4 pattern = mat4(
    vec4(-4, +0, -3, +1),
    vec4(+2, -2, +3, -1),
    vec4(-3, +1, -4, +0),
    vec4(+3, -1, +2, -2)
);


int dithering_pattern(ivec2 fragcoord) {
    int x = fragcoord.x % 4;
    int y = fragcoord.y % 4;

    vec4 pattern_vec4;

    if (y == 0) pattern_vec4 = pattern[0];
    else if (y == 1) pattern_vec4 = pattern[1];
    else if (y == 2) pattern_vec4 = pattern[2];
    else if (y == 3) pattern_vec4 = pattern[3];

    float pattern_int;

    if (x == 0) pattern_int = pattern_vec4.x;
    else if (x == 1) pattern_int = pattern_vec4.y;
    else if (x == 2) pattern_int = pattern_vec4.z;
    else if (x == 3) pattern_int = pattern_vec4.w;

    return int(pattern_int);
}

void fragment() {
    vec2 pixel_size = 1.0 / SCREEN_PIXEL_SIZE;
    vec3 color = texture(SCREEN_TEXTURE, round(SCREEN_UV * pixel_size) / pixel_size).rgb;
    
    // Convert from [0.0, 1.0] range to [0, 255] range
    ivec3 c = ivec3(round(color * 255.0));
    
    if (dithering) {
        c += ivec3(dithering_pattern(ivec2(FRAGCOORD.xy)));
    }
    
    // Truncate from 8 bits to color_depth bits
    c = c / int(pow(2.0, float(8 - color_depth)));
    
    // Convert back
    c = c * int(pow(2.0, float(8 - color_depth)));
    
    COLOR.rgb = vec3(c) / 255.0;
    
}
dragon1freak
dragon1freak
6 months ago

I was able to get this in Godot 4 pretty easily, it also seems to work with transparency and shadows as a spatial shader. Just change depth_test_disable to depth_test_disabled and make sure you place the shader in the mesh instances material, not the Geometry Instance overrides, and make sure Flip Faces is set to On. Everything else should be the same as above. For transparency, make sure your material uses Alpha Scissor and it should work with the shader. You can still convert it to a canvas shader using the steps shown by Guessy’s comment.

breather
breather
4 months ago

Howdy, I used this shader extensively in my game, Synesthesia.

It only appears in the desktop versions due to graphical constraints. It was vital to forming the aesthetic for the game. Thank you for sharing this shader with the community.

Here’s a trailer if anyone wants to see the shader in
action: https://www.youtube.com/watch?v=v2BZei_-wUs

Last edited 4 months ago by breather