Skip to content

Instantly share code, notes, and snippets.

@bandinopla
Created June 7, 2025 14:32
Show Gist options
  • Save bandinopla/497bcd86ba2bb662e0ab4cb47190748e to your computer and use it in GitHub Desktop.
Save bandinopla/497bcd86ba2bb662e0ab4cb47190748e to your computer and use it in GitHub Desktop.
ThreeJs Shader to create a Playstation 1 Type effect
/**
"by/composed by": https://x.com/bandinopla
Based on article by Roman Liutikov: https://romanliutikov.com/blog/ps1-style-graphics-in-threejs
Modifications done with DeepSeek: https://chat.deepseek.com/
*/
import * as THREE from 'three';
export class PS1Material extends THREE.ShaderMaterial {
constructor({ map, color }: { map: THREE.Texture, color:THREE.ColorRepresentation }) {
super({
uniforms: THREE.UniformsUtils.merge([
THREE.UniformsLib['fog'],
THREE.UniformsLib['lights'],
{
resolution: { value: new THREE.Vector2(320 , 240 ) },
map: { value: map },
color: { value: new THREE.Color( color) },
uColorDepth: { value: 16 },
uDitherScale: { value: 1 },
}
]),
vertexShader: `
uniform vec2 resolution;
varying vec2 vUv;
varying float vInvW;
varying vec3 vLighting; // Lighting computed here
#include <common>
#include <skinning_pars_vertex>
#include <fog_pars_vertex>
#include <lights_pars_begin> // Brings in light uniforms
void main() {
vUv = uv;
// Compute world normal (for lighting)
vec3 worldNormal = normalize(normalMatrix * normal);
// Skinning transformations
#include <skinbase_vertex>
#include <begin_vertex>
#include <skinning_vertex>
// View position for fog
vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0);
// Initialize lighting with ambient
vLighting = ambientLightColor;
// Directional lights (PS1 typically had 1-2)
#if NUM_DIR_LIGHTS > 0
for (int i = 0; i < NUM_DIR_LIGHTS; i++) {
vec3 lightDir = -directionalLights[i].direction;
float diff = max(dot(worldNormal, lightDir), 0.0);
vLighting += directionalLights[i].color * diff;
}
#endif
// Simple point light support
#if NUM_POINT_LIGHTS > 0
vec3 worldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;
for (int i = 0; i < NUM_POINT_LIGHTS; i++) {
vec3 viewPos = mvPosition.xyz; // already in view space
vec3 lightDir = normalize(pointLights[i].position - viewPos); // light in view space
float diff = max(dot(worldNormal, lightDir), 0.0);
float dist = distance(pointLights[i].position, viewPos);
float attenuation = 1.0 / (pointLights[i].distance + pointLights[i].decay * dist);
vLighting += pointLights[i].color * diff * attenuation;
}
#endif
vFogDepth = -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
// Affine mapping prep
vInvW = 1.0 / gl_Position.w;
// PS1-style pixelation
vec4 pos = gl_Position;
pos.xyz /= pos.w;
pos.xy = floor(resolution * pos.xy) / resolution;
pos.xyz *= pos.w;
gl_Position = pos;
}
`,
fragmentShader: `
uniform sampler2D map;
uniform float uColorDepth;
uniform float uDitherScale;
varying vec2 vUv;
varying float vInvW;
varying vec3 vNormal;
uniform vec3 color;
//varying vec3 vViewPosition;
varying vec3 vLighting;
varying vec4 vWorldPosition;
#include <common>
#include <fog_pars_fragment>
#include <lights_pars_begin> // Light uniforms
#include <lights_lambert_pars_fragment> // Diffuse lighting
vec4 RGBtoYUV(vec4 rgba) {
vec4 yuva;
yuva.r = rgba.r * 0.2126 + 0.7152 * rgba.g + 0.0722 * rgba.b;
yuva.g = (rgba.b - yuva.r) / 1.8556;
yuva.b = (rgba.r - yuva.r) / 1.5748;
yuva.a = rgba.a;
yuva.gb += 0.5;
return yuva;
}
vec4 YUVtoRGB(vec4 yuva) {
yuva.gb -= 0.5;
return vec4(
yuva.r * 1.0 + yuva.g * 0.0 + yuva.b * 1.5748,
yuva.r * 1.0 + yuva.g * -0.187324 + yuva.b * -0.468124,
yuva.r * 1.0 + yuva.g * 1.8556 + yuva.b * 0.0,
yuva.a);
}
float ditherChannelError(float col, float colMin, float colMax)
{
float range = abs(colMin - colMax);
float aRange = abs(col - colMin);
return aRange / range;
}
const float dither0[8] = float[8](0.0, 32.0, 8.0, 40.0, 2.0, 34.0, 10.0, 42.0);
const float dither1[8] = float[8](48.0, 16.0, 56.0, 24.0, 50.0, 18.0, 58.0, 26.0);
const float dither2[8] = float[8](12.0, 44.0, 4.0, 36.0, 14.0, 46.0, 6.0, 38.0);
const float dither3[8] = float[8](60.0, 28.0, 52.0, 20.0, 62.0, 30.0, 54.0, 22.0);
const float dither4[8] = float[8](3.0, 35.0, 11.0, 43.0, 1.0, 33.0, 9.0, 41.0);
const float dither5[8] = float[8](51.0, 19.0, 59.0, 27.0, 49.0, 17.0, 57.0, 25.0);
const float dither6[8] = float[8](15.0, 47.0, 7.0, 39.0, 13.0, 45.0, 5.0, 37.0);
const float dither7[8] = float[8](63.0, 31.0, 55.0, 23.0, 61.0, 29.0, 53.0, 21.0);
float dither8x8(vec2 position, float scale, float brightness)
{
int x = int(mod(position.x / scale, 8.0));
int y = int(mod(position.y / scale, 8.0));
float limit = 0.0;
if (x < 8) {
float d;
if (x == 0) {
d = dither0[y];
} else if (x == 1) {
d = dither1[y];
} else if (x == 2) {
d = dither2[y];
} else if (x == 3) {
d = dither3[y];
} else if (x == 4) {
d = dither4[y];
} else if (x == 5) {
d = dither5[y];
} else if (x == 6) {
d = dither6[y];
} else if (x == 7) {
d = dither7[y];
}
limit = (d + 1.0) / 64.0;
}
return brightness < limit ? 0.0 : 1.0;
}
vec4 ditherAndPosterize(vec2 position, vec4 color, float colorDepth, float
ditherScale)
{
vec4 yuv = RGBtoYUV(color);
vec4 col1 = floor(yuv * colorDepth) / colorDepth;
vec4 col2 = ceil(yuv * colorDepth) / colorDepth;
yuv.x = mix(col1.x, col2.x, dither8x8(position, ditherScale, ditherChannelError(yuv.x, col1.x, col2.x)));
yuv.y = mix(col1.y, col2.y, dither8x8(position, ditherScale, ditherChannelError(yuv.y, col1.y, col2.y)));
yuv.z = mix(col1.z, col2.z, dither8x8(position, ditherScale, ditherChannelError(yuv.z, col1.z, col2.z)));
return YUVtoRGB(yuv);
}
void main() {
//vec2 uv = vUv * gl_FragCoord.w;
vec2 uv = vUv * vInvW;
vec2 uvPerspective = uv / vInvW;
// Snap to pixel grid for that PS1 look
vec2 texelSize = vec2(1.0) / vec2(textureSize(map, 0));
uvPerspective = floor(uvPerspective / texelSize) * texelSize;
vec4 texColor = vec4(color, 1.0);
#ifdef USE_MAP
texColor = texture2D(map, uvPerspective);
#endif
//------------------------------------------------
vec3 litColor = texColor.rgb * vLighting;
vec4 color;
// Calculate fog factor
#ifdef USE_FOG
#ifdef FOG_EXP2
float fogFactor = 1.0 - exp(- fogDensity * fogDensity * vFogDepth * vFogDepth);
#else
float fogFactor = smoothstep(fogNear, fogFar, vFogDepth);
#endif
color = vec4(mix(litColor.rgb, fogColor, fogFactor), texColor.a);
#else
color = litColor;
#endif
gl_FragColor = ditherAndPosterize(gl_FragCoord.xy, color, uColorDepth, uDitherScale); // Basic white color
}
`,
fog: true,
lights: true,
defines: {
USE_MAP: '' // enables the #ifdef
},
})
}
};
/*
USE:
let n = new PS1Material({
map: mapTexture,
color: baseColor // If no map is used, it will use this as flat color...
});
n.defines.USE_MAP = !!mapTexture
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment