Created
June 7, 2025 14:32
-
-
Save bandinopla/497bcd86ba2bb662e0ab4cb47190748e to your computer and use it in GitHub Desktop.
ThreeJs Shader to create a Playstation 1 Type effect
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
"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