2026-08-15
I'm sure everyone knows about the resurgence of indie games with the PS1 aesthetic. Some say it's overdone and lazy, and I'm more of a fan of PS2 graphics myself, but that's not here nor there. It just so happens that a while ago I made a hardware rasterizer for P-chan, my PS1 emulator, so I know a thing or two about the GPU of the original PlayStation (if only I could also figure out that CD-ROM). I say this because a lot of the PS1 graphics tutorials, be it for Godot or for blender renders, are pretty much just straight up wrong in at least one aspect. One tutorial that I do like is Acerola's, but I want to go a bit more in-depth as to what the PS1 Gpu is doing, because I think it's cool and I didn't drop out of college to not write about random things I find interesting.
I use the techniques I describe in this article in my game, STAR BREW: OVEREXTRACT, please check it out on Steam :>.
CONTENT WARNING: mildly bad words
# A prelude
The PlayStation GPU works via something that's known as a fixed-function pipeline. This means it always draws polygons (not just triangles, that's not a mistake) the exact same way outside some parameters you can set in the draw call. Modern GPUs are programmable, meaning the graphics programmer can write whatever abomination to instruct the gpu into rendering a scene (modern GPUs can do a lot more than rendering however). But, what this means for us when trying to emulate the PS1 look, is that we can use the same material for every object in our game, and use instanced rendering to draw literally all 3d geometry in our game in a single draw call*.
*Godot supports instanced rendering (or batching) only in its Forwards+ renderer, and using different textures breaks batching.
# Part 1: VRAM
Everyone knows that the PS1 used low resolutions, reason being that its GPU is dogs**t. Ok, that's not the whole picture. In fact, the PS1 GPU was pretty ahead of it's time, in fact it was roughly 150% faster than its CPU in raw clock speed and it didn't have the texture limitations the N64 had (not an expert on that doe so don't quote me, and other than that the N64 GPU bodies the PS1 and it's not even close). The real problem was that the PS1 was very anemic on VRAM (sound familiar?). It boasted a whopping 1 MB of VRAM (some versions had 2), laid out as halfword (16 bit) pixels in a 1024x512 buffer, so each pixel is a rgb5 value with a 1bit mask (there's also a 24bit mode, more on that later). For comparison, the N64 GPU had 4 whole MB. The framebuffer (the area in VRAM the gpu draws to) also takes up space in that same VRAM, and you needed two for double buffering (otherwise you'd see the new frame as its being drawn and it would cause flickering). Here are some common resolutions and how much vram they use. Now double that. Around 30% of your already limited VRAM is just taken up by the framebuffer :(.
All that being said, here we get the first part of our PSX shader. We need to set our framebuffer resolution in godot to something low, I'll go with 320x240. This takes up exactly 300KB of our 1MB, so we'd be left with 700KB for other textures. Now, the PS1 Gpu had support for rendering compressed textures via CLUTs (Color Lookup Tables, more on that later), but modern GPUs don't, and they also have inifnitely more memory, so I'd say there's not much of a point in emulating this unless you really want to, since you can achieve the same visual end result without it, you don't get any performance benefits for doing it if you do, and authoring assets for this compression scheme is a pain in the ass. If you want to know how it's done anyways, you can check out the GP0 shader in P-chan.
That being said, just set your project's resolution to something on that list, and set the stretch mode to Viewport, this will render at a set resolution and upscale the image to the display. You can also use integer scaling so it only upscales to multiples of 2, meaning the pixels are always perfect squares (nice).
# Part 2: Colors!
Even though the PS1 GPU is capable displaying 24bit color, it can only draw in 16bit (rgb5, with 1 bit for masking). The 24bit mode is mostly for the FMVs, which get decoded by the MDec (macroblock decoder, cool) and copied directly to VRAM so the gpu can display it. We only care about 16bit mode here. We need to clamp the color to 5bit per channel:
vec3 to_rgb5(vec3 color) {
color.rgb *= 31.;
color.rgb = floor(color.rgb);
color.rgb /= 31.;
return color;
}
So far, our fragment shader looks like this:
void fragment() {
ALBEDO = to_rgb5(ALBEDO);
}
Let's sample a texture real quick and put it inside the albedo so we can see what we're doing.
// NEW: texture array and index
uniform sampler2D[10] textures;
instance uniform int tex_id;
void fragment() {
// NEW: nearest neighbour sampling
ivec2 uv = ivec2(UV * vec2(textureSize(textures[tex_id], 0).xy));
ALBEDO = texelFetch(textures[tex_id], uv, 0).rgb;
ALBEDO = to_rgb5(ALBEDO);
}
I don't want to deal with materials so I'm just making an array of textures and using an instance uniform to index into that array. This should keep our draw calls to a minimum, but we can run out of texture slots. We will have one draw call per different texture used, which is quite ok. That's not the point of the tutorial however. We need to map the UVs from being in range [0.0, 1.0] (floats) to [0, RESOLUTION] (ints) to use texelFetch in order to sample with nearest neighbour sampling, since the PS1 GPU does not do any texture filtering, more on that in the texutres chapter. Anyways, this is what our rgb5 colors look like:

The effect is fairly subtle, but you can definitely see it in the godot texture, it's a little blocky and yellow around the eyes. This is because we can't represent as many colors in rgb5 as rgb8.
Now, on the PS1, one can set the mask bit in the GPUSTAT Register using the GP0(0xE6) Command. The mask bit of the color the GPU draws is equal to the value in the status register. When ON, any previously drawn pixels that have the mask bit set are write protected and cannot be overwritten. You can make some pretty nifty effects with this on the original hardware, but it's pretty useless for us since we don't control the triangle draw order. On the PlayStation, triangles are depth sorted on the CPU since the GPU has no idea of depth (in fact it's entirely a 2D rasterizer!), so it just draws triangles in the order it receives them.
# Part 3: Speaking the GPU's language
I want to take some time to explain how triangles end up being drawn on the PS1 GPU. This is not terribly important for us emulating the aesthetic, but I think it's cool nonetheless, and I like comparing it to how utterly insane modern graphics APIs are, since the PS1 is dead simple by comparison. If you dont' care, feel free to skip this part, just know you're lame asf if you do (jk :>).
All of the drawing happens through the GP0 register. In this case, "register" refers to an MMIO register, or Memory Mapped Input/Output register. This is the abstraction all computers (including modern ones) use for communicating with peripherals. Basically, you just load or store to a certain address, in our case 0x1F801810, except the address is connected on the bus to a peripheral, not RAM. We can send GPU commands on the GP0 Register by writing to it, and we can receive responses by reading from it. GP0 can draw polygons made of 3 or 4 vertices (it just triangulates them internally, honestly I don't know why this is a thing, I guess triangulating on the CPU would be more expensive), it can draw rectangular sprites and it can "blit" (copy) from RAM to VRAM, from VRAM to VRAM and even from VRAM to RAM. In the latter case, that's where reading from GP0 comes in: first you initiate the vram to ram transfer, then you can receive every word (32 bit value) from the GPU's queue by reading GP0. Reading from GP0 advances the transfer, meaning a read actually mutates the visible state of memory, which is quite interesting.
So, each triangle, polygon or sprite you draw, is a draw command you send to GP0 by writing to it. Here is the PSX-SPX page for the Render Polygon command. I call the first value you send the command "header", which in our case looks like this:
bit number value meaning
31-29 001 polygon render
28 1/0 gouraud / flat shading
27 1/0 4 / 3 vertices
26 1/0 textured / untextured
25 1/0 semi-transparent / opaque
24 1/0 raw texture / modulation
23-0 rgb first color value.
That's a lot of options you can play with. Depending on the settings, the GPU will expect different data to be sent subsequently. The vertex coordinate must always be sent, the color and texture uvs are optional (depending on whether goraud shading is used and whether the triangle is textured). The simplest polygon you can draw (a flat shaded untextured triangle) is about 4 words, or 128 bits. If your scene has 100 of them, you'll spend a lot of cpu time just copying data from the CPU to the GPU, time that you really don't have. That's where DMA comes in, or Direct Memory Access. The PS1 has a DMA controller which can copy the data from RAM to the GPU automatically (and quickly at that). Under certain conditions, the CPU can keep executing while the DMA transfer is running. Sweet! I won't go into how DMA works exactly, but the gist of how to use it is this: First, we use the OTC (Ordering Table Channel) of the DMA to set up an empty linked list of 1024 entries. We add data to the nodes, we sort the list (on the CPU) based on the depth of the triangle (which we can calculate using the GTE, Geometry Transform Engine, a coprocessor on the PS1), then finally we submit the the data to the GPU via the DMA 2 Channel set in Linked-List mode. Simple, right?
# Part 4: Low hanging fruit, Dithering
Let's take a break and do something simple. Dithering. Why dither? Because we lose color precision by using rgb5, so in order to smooth out the colors, the PlayStation employed a simple, optional dither via a dither matrix. The issue is that Sony's proprietary dither matrix sucks ass and doesn't smooth colors at all, but nonetheless, we want to emulate it to get that authentic PSX look. This can just be applied at the end of the fragment shader, simple.
/// NEW: dither option per mesh
instance uniform bool use_dither;
vec3 dither(vec3 color, vec2 fragcoord) {
// PS1's dither matrix, Sony don't sue me 🙏
const int[16] DITHER = int[] (
-4, 0, -3, 1,
2, -2, 3, -1,
-3, 1, -4, 0,
3, -1, 2, -2
);
if (use_dither) {
ivec2 coord = ivec2(fragcoord) % ivec2(4);
int value = DITHER[coord.y*4 + coord.x];
float valuef = float(value);
color.rgb += vec3(valuef) / 255.;
}
return color;
}
void fragment() {
// all the old stuff...
// NEW: dither before clamping colors
ALBEDO = dither(ALBEDO, FRAGCOORD.xy);
ALBEDO = to_rgb5(ALBEDO);
}
We simply turn the pixel coordinate into an index, load a value from an array at said index, and just add that value to our final color (which is now in rgb8, ready to be displayed). What really grinds my gears is that most tutorials or most already made shaders just apply a fullscreen dither to the whole game, but that's not how it was done on original hardware and it's complete nonsense. If you have a flat color triangle, why would you ever dither it (in fact, on original hardware, you can't)? Technically, the PS1 can enable or disable dithering on a per polygon basis, instead of per mesh, but we don't really have an easy way to do that, so it is what it is. Here is what that looks like with dithering enabled for the floor, and disabled for our sphere:
As you can see, it's completely useless for it's intended purpose! But it does get us closer to that PS1 aesthetic. Make sure you dither before clamping colors. In the game jam version of Star Brew I did them in the wrong order and the dithering looked horrible, oops. I also made this mistake while writing the article, so I had to go back and redo all my screenshots -_-'.
EDITOR'S NOTE: I was a little harsh on Sony's dither matrix, but it's honestly not that bad on a CRT.
# Part 5: Vertex Colors
Because of the limited VRAM of the PlayStation, much of the rendering is done with plain vertex colors. This can get you pretty far, since the GPU has 2 shading modes: flat and goraud. Flat shading draws the entire triangle with a single color (the color of the first vertex sent). Goraud shading does the usual color interpolation between vertices. This is actualy the simplest to implement, you just read the vertex color from COLOR and use it for your albedo. Flat shading is a bit more weird because you cant get per triangle data in a shader since modern GPUs operate on many vertices at once in the entire model, not just one triangle. You can check if the current vertex is the first in a triangle by doing VERTEX_ID % 3 == 0 but even then, authoring assets that make use of this feature would be a pain. For STAR BREW, I cheated and just used a single uniform for flat shading for the entire model, meaning I couldn't do per triangle flat colors, unless I used goraud shading where every vertex has the same color. This wasn't really an issue, and I recommend you just use goraud shading all the time since the performance hit of interpolating vertex colors is literally non existent on modern GPUs.
Regardless, I don't want to bake vertex colors for all my models, so let's also add a simple flat color uniform to the shader. We also need to be able to enable or disable textures in order to see our vertex colors. In the end, this is what we get:
// NEW: uniforms, a little verbose but it helps
const int SHADING_FLAT = 0;
const int SHADING_GORAUD = 1;
instance uniform int shading: hint_enum("flat", "goraud") = 0;
instance uniform vec4 flat_color: source_color; // this will come in later
const int TEX_TEXTURED = 0;
const int TEX_UNTEXTURED = 1;
instance uniform int texturing: hint_enum("textured", "untextured") = 0;
// NEW: vertex function to override the vertex color
void vertex() {
if (shading == SHADING_FLAT) {
COLOR = flat_color;
}
}
vec3 get_albedo(vec4 vert_color, sampler2D tex, vec2 uv) {
if (texturing == TEX_UNTEXTURED) {
switch (shading) {
case SHADING_FLAT: {
return from_linear(vert_color.rgb);
}
case SHADING_GORAUD: {
return from_linear(vert_color.rgb * flat_color.rgb);
}
}
}
// this is just what we had before
ivec2 texcoord = ivec2(uv * vec2(textureSize(textures[tex_id], 0).xy));
vec3 color = texelFetch(textures[tex_id], texcoord, 0).rgb;
return color;
}
void fragment() {
// NEW: use the new albedo function
ALBEDO = get_albedo(COLOR, textures[tex_id], UV);
ALBEDO = to_rgb5(ALBEDO);
ALBEDO = dither(ALBEDO, FRAGCOORD.xy);
ALBEDO = to_linear(ALBEDO);
}
I added a flat shaded untextured cube, and a goraud shaded untextured coffee bag mesh (the one on the right) with some shading baked into the vertex colors. For goraud shading, I also use the flat_color uniform as a tint. This is so I can use the same mesh with the same vertex colors but have it have different colors. You can add any sort of effect or color you want to the COLOR in the vertex shader, since on the PS1 vertex colors are just sent in one by one by the cpu so it could apply any sort of processing to them (in fact, this is how most lighting is done on the PS1), so any effect that you can come up with that's just editing the color in the vertex shader would technically be possible on the PS1.
Now, I'd like for my coffee bag to also have it's texture that I drew, but before that...
# An interlude: Gamma correction
In the middle of writing this article I decided to switch to the Forward+ renderer in order to take advantage of instanced rendering, because why not? Well, it completely mucked up all my colors:
Turns out some Godot defaults changed, and now the gamma of my textures is all bad. I will admit that color spaces and gamma correction makes my head spin a little bit, but what I think is going on is that godot automatically converts srgb to linear color in the Compatibility renderer, but not in Forward+. Using source_color for my textures helps, but isn't what I want. This will sample my texture with linear color, but I need it to be in sRGB when coverting to rgb5 (otherwise it looks wrong), so I would have to undo the transformation like this:
ALBEDO = from_linear(ALBEDO); // <-- turn linear into sRGB
ALBEDO = to_rgb5(ALBEDO); // <-- apply my transform
ALBEDO = dither(ALBEDO, FRAGCOORD.xy);
ALBEDO = to_linear(ALBEDO); // <-- turn sRGB back into linear
This is the same as not using source_color, working in sRGB directly and turning the color into linear space myself at the end, like this:
// no more conversion here
ALBEDO = to_rgb5(ALBEDO); // <-- apply my transform
ALBEDO = dither(ALBEDO, FRAGCOORD.xy);
ALBEDO = to_linear(ALBEDO); // <-- turn sRGB back into linear
The end result is the same:
I had some issues with gamma in my emulator as well when I made it, seems I really cannot escape...
I wish I could check the checksums of the screenshots to see if the new render matches the old one, but I moved the plane a little bit in between changes and now they won't match :(, remember to use source control kids.
# Part 6: Textures, Affine mapping and modulation
## Knee deep in hardware quirks
Let's get back into things. We already sample textures for our material, but we haven't discussed how exactly the PS1 handles these. I hinted earlier that it doesn't do any sort of filtering when it samples them, which is true, but how exactly does the GPU sample textures? How do we feed our textures to the GPU on the original PlayStation? And for comparison, here's what it would look like if we used normal bilinear filtering for our textures in our material:

Anyways, to answer our question, we need to look at the draw polygon draw call. If you look back at Part 3, we can see that bit 26 of the Render Polygon command tells the gpu whether we have a textured or untextured polygon. This is importat, because if the bit is set, the gpu expects to receive a UV information for every vertex, like so:
Color xxBBGGRR - optional, only present for gouraud shading
Vertex YYYYXXXX - required, two signed 16 bits values
UV ClutVVUU or PageVVUU - optional, only present for textured polygons
It says "Clut" or "Page" because for the first vertex, you have to send the CLUT index, and for the second you have to send the Page coordinates. Now, you should probably be asking yourselves, "what the actual f*ck are you talking about?". As I've hinted before, CLUTs are the way the ps1 gpu can compress textures. They are small textures in and of themseleves, representing an array of colors. The compressed texture does not store colors anymore, it stores 4 or 8 bit indices into that array. By mapping the PS1 VRAM to a texture in a PS1 emulator like P-Chan, I can show you what both of these look like:
The part with the white background is just the framebuffer, what is usually displayed to the TV. As you can see, to the right we have the textures of the sony logo, but the colors dont match? That's because the textures are compressed, the colors are just indices into the CLUTs. Those weird colors are just what happens when you convert those indices into colors. So where are the CLUTs? I don't blame you if you dont see them, here, let me zoom in for you:
Those small color arrays beneath the framebuffer are the CLUTs. I think you can see how this saves quite a lot of storage space. Very interestingly however, it seems the Sony logo is loaded into vram twice, which makes this optimization completely useless here... The Sony BIOS is full of weird stuff like this.
What about the "Page"? Well, I don't really know why, but the PS1 gpu uses 16bit values (or coordinates, in our case) to address the VRAM when it comes to texture sampling. So we have 8 bits for the x coordinate and 8 bits for the y coordinate. This means we can address up to 256x256 pixels (which vary in actual vram size, because color depth can differ like I said earlier). But our VRAM is much bigger than 256x256, it's a whole 1024x512 pixels with 16bit color depth. So the way we get around that is by using something called a "Texpage base". Basically we split up our vram into 256x256 sections, or "Pages", hence the name, and we use the UVs as relative coordinates into said pages. The texpage base (among other settings) is set separately with a different GPU command that is not all that interesting, before we render our polygon. If you care how exactly the texture colors get turned into clut indices and how we get our final color, once again you can check out P-chan's main shader. This really is just one of those things that's harder to explain that it is in practice.
I won't implement any sort of dynamic texture quantization in the actual shader. I recommend making textures using indexed colors with a palette size of 16 (4bit) or 256 (8bit) in an external program like Aseprite or Gimp.
## Affine texture mapping
More importantly for us emulating the aesthetic, is how the gpu ends up sampling our textures. For every pixel in our triangle, it gets the interpolated UV coordinate for said pixel and uses that to address the vram and get our texture. This is how modern GPUs work as well (roughly), however, unlike modern GPUs, the PS1 has no idea about 3d depth. When you simply interpolate those coordinates across the screen without taking into account the vertex distance from the camera, you get something called affine texture mapping; a very stinky way to sample textures for 3d geometry, since they will appear distorted. To do this correctly, you need to divide the vertex's position by it's depth (distance from the camera), which is something the PS1 does not do, and sadly for us now, it's something modern GPUs do automatically, because they're built to render 3d geometry. So, in order to get affine mapping, we need to multiply our vertex position by its depth to cancel the effect of the GPU's division. However, we need to apply our mulitplication after the vertex is in clip space and godot gives us vertices in local space in the vertex shader, so we have to transform it ourselves. We can do that with this piece of code:
// NEW: we need to store the clip position for later
varying vec4 clip_pos;
void vertex() {
vec4 world_space = MODEL_MATRIX * vec4(VERTEX, 1);
vec4 clip = PROJECTION_MATRIX * VIEW_MATRIX * world_space;
vec4 vertex = clip;
VERTEX.xy = vertex.xy;
POSITION = vertex;
clip_pos = vertex;
UV = UV * vertex.w;
// ... all the old stuff vvv
}
void fragment() {
vec2 uv = UV / clip_pos.w; // NEW: reverse our perspective correction
// ... all the old stuff
}
I'm not really good enough at gpu vertex math to explain this any more coherently, so I recommend watching Acerola's video on the topic, he goes into much more detail. Regardless, here's how it looks:
The distortion is really bad here. You can't even see the Godot logo anymore. The effect gets really bad at grazing angles, which is why most PS1 games employed some kind of fixed camera perspective. Here's how the scene looks like if I move the camera a little further back:
A little better! Godot logo still looks derpy, but it is what it is. You can also reduce the effect by subdividing your mesh. In fact, most PS1 games include some sort of dynamic tessellation, in order to minimize texture distortion.
## Texture modulation
Time for my favorite feature of the PS1 Gpu, bit 24 of the draw polygon command, aka texture modulation (or blending). When texture modulation is enabled, the final color for textured polygons is obtained with the following formula:
// 8 bit color on original hardware
color = (texel * vertexColour) / vec3(128.0)
// or in floating point (roughly)
color = (texel * vertexColour) * vec3(2.0);
The floating point formula I derived makes it pretty clear what's going on: the final color is obtained by multiplying the texel color (the sampled texture color) with the interpolated vertex color and multiplying the result by 2.0, leading to a sort of overbright effect. With that, our mesh can have its texture and its baked vertex shading. Let's also add an additional modulation color tint and brightness uniform to give us more control.
Nice. Note that in order to cancel out the overbright effect, we set the modulation tint to half gray. And here's the code:
// NEW: toggle, tint and brightness for modulation
instance uniform bool use_modulation = false;
instance uniform vec4 modulate_tint: source_color = vec4(0.5, 0.5, 0.5, 1.0);
instance uniform float modulate_brightness: hint_range(0.0, 1.0, 0.1);
vec3 get_albedo(vec4 vert_color, sampler2D tex, vec2 uv) {
if (texturing == TEX_UNTEXTURED) {
// all the old code...
}
ivec2 texcoord = ivec2(uv * vec2(textureSize(textures[tex_id], 0).xy));
vec3 color = texelFetch(textures[tex_id], texcoord, 0).rgb;
// NEW: apply modulation
if (use_modulation) {
color.rgb *= 2.0 * from_linear(vert_color.rgb * modulate_tint.rgb);
color.rgb += modulate_brightness;
}
return color;
}
That's it, a pretty simple effect, but very effective.
Before we move on, the PS1 GPU has a restriction: only polygons that use goraud shading or texture modulation can use dithering, so let's implement that restriction:
vec3 dither(vec3 color, vec2 fragcoord) {
const int[16] DITHER = int[] (...);
// NEW: check for shading or modulation
if (use_dither && (shading == SHADING_GORAUD || use_modulation)) {
// ... dither
}
return color;
}
Editor's note: One small thing I forgot is that the PS1 GPU interprets pure black as transparent.
Use this snippet in the fragment shader
if (ALBEDO == vec3(0.0)) {
discard;
}# Part 7: Vertex magic
I will go over 2 quirks of the PS1's rasterizer, one is very well known and the other not so much. Let's start with the one everyone's been waiting for: vertex wobble. The PS1 had some pretty bad vertex wobble, especially at low resolutions and when polygons move slowly. Most PSX shaders apply some kind of grid snapping to vertices? This is very common in blender shaders for some reason. It's also completely wrong. PS1 vertices wobble because, as I've shown earlier, its GPU works in integer coordinates only, meaning the GPU can't do something called subpixel rendering, which is something modern GPUs do where a pixel can be partially occupied. When combined with antialiasing, that leads to very smooth subpixel movement. The PS1 will have none of that however, so we need to snap our vertices to integer coordinates, which is pretty simple:
And here's the code:
void vertex() {
vec4 vertex = clip;
// NEW: we transform the vertex (in clip space) from range [0.0, 1.0] to
// [0, RES], discard the fractional part then transform it back into [0.0, 1.0]
vertex.xy = floor(clip.xy / clip.w * VIEWPORT_SIZE.xy) / VIEWPORT_SIZE.xy * clip.w;
}
Now, something that I think is even more important than this vertex wobble, which is pretty subtle in my opinion, is the triangle depth. Modern GPUs interpolate the vertex depth across a triangle to have a smooth depth. We can check this with a simple full screen shader that samples the depth buffer.
Nice and smooth. Problem is, the PS1 GPU doesn't interpolate depth, because it has no depth information to interpolate in the first place. Triangles are simply drawn in order as sent from the cpu. This means that if 2 triangles intersect, one will just completely cover the other. I think this effect is typically attributed to vertex wobble, but it's not vertex wobble and it's way more visible in complex scenes.
We can achieve this effect by calculating the depth ourselves in the vertex shader and telling the gpu to not interpolate it, using the flat qualifier. The gpu will then choose the depth of one vertex for the entire triangle (ideally we would use an average of all the vertices but we can't easily do that).
// NEW: varying for depth, using `flat`
varying flat float depth;
void vertex() {
// NEW: calculate linear depth
depth = clip.z / clip.w;
}
void fragment() {
// set the pixel's depth value
DEPTH = depth;
}
Now our depth buffer has flat colors for each triangle. But, uh oh! Our floor is now rendering in front of our other meshes. This happens because it's using a single depth value for the entire floor, despite the fact that it covers most of the screen and has big changes in depth. This is something you would have to work around on original hardware, too. I think the shader is pretty good if it even brings back the very annoying parts of the original PlayStation. One way is to subdivide the mesh, which is the most robust solution. But let's not do that. Remember that on the PS1 vertices are ordered by the cpu, so there is nothing in the way of a game assigning a depth modifier to each mesh, and adding that to the triangle's depth. This would give artists control over the rendering order. We can add this very simply like this:
instance uniform float depth_modifier: hint_range(-10.0, 10.0, 0.1);
void vertex() {
depth = clip.z / clip.w + depth_modifier / 100.;
}
You can use whatever scale you like, I felt that this one was a little less finicky than adding directly. This way of doing it is pretty much only useful for fixed camera games or scenes and even then it doesn't always work great. If this doesn't help, you pretty much have to subdivide the mesh, which is what I'll do as well from here on out. In fact if you pop Silent Hill 1 into an emulator, then pop that emulator into your graphics debugger of choice, you can see that the floor is made up of a lot of smaller tiles, which sidesteps this issue and the affine texture mapping. Neat!

As you can see, our objects now render in front of the floor. Here is the scene in motion: I moved the objects closer to each other to demonstrate what happens when they overlap. As you can see, it's quite ugly. Nice.
# Part 8: Semi-transparency
The PlayStation 1 GPU supports drawing pixels with 4 different semi-transparency options, akin to blending modes. From PSX-SPX:
B=Back (the old pixel read from the frame buffer)
F=Front (the new semi-transparent pixel)
* 0.5 x B + 0.5 x F ;aka B/2+F/2
* 1.0 x B + 1.0 x F ;aka B+F
* 1.0 x B - 1.0 x F ;aka B-F
* 1.0 x B +0.25 x F ;aka B+F/4
Simply put, we have an average blending mode, addition, subtraction and addition with extra steps. These are fairly easy to implement. However, in godot (and in most 3D applications or engines), we have an opaque pass and a transparent pass. All materials that use the screen texture are rendered in the transparent pass and do not contribute to the screen texture at all. So we need to split our material into an opaque version and a transparent version, which entails moving all the relevant code into an include file and making multiple materials that just use that. This is not the biggest issue in the world but it's certainly annoying. I just pulled out all of the code into its own .gdshaderinc file, making sure to remove any uniforms with hint_screen_texture and added some functions for running the pipeline:
// psx.gdshaderinc
struct PipelineOutput {
vec3 albedo;
float depth;
};
struct PipelineInput {
vec2 uv;
vec4 vert_color;
vec4 fragcoord;
};
PipelineOutput pipeline_transparent(PipelineInput input, sampler2D screen_tex, vec2 screen_uv) {
vec2 uv = input.uv / clip_pos.w;
PipelineOutput output;
output.albedo = get_albedo(input.vert_color, textures[tex_id], uv);
output.albedo = to_rgb5(output.albedo);
output.albedo = apply_semi_transparency(output.albedo, screen_tex, screen_uv);
output.albedo = dither(output.albedo, input.fragcoord.xy);
output.albedo = to_linear(output.albedo);
output.depth = depth;
return output;
}
PipelineOutput pipeline_opaque(PipelineInput input) {
vec2 uv = input.uv / clip_pos.w;
PipelineOutput output;
output.albedo = get_albedo(input.vert_color, textures[tex_id], uv);
output.albedo = to_rgb5(output.albedo);
output.albedo = dither(output.albedo, input.fragcoord.xy);
output.albedo = to_linear(output.albedo);
output.depth = depth;
return output;
}
#define APPLY(o)\
ALBEDO = o.albedo;\
DEPTH = o.depth;
// psx_opaque.gdshader
shader_type spatial;
#include "psx.gdshaderinc"
void fragment() {
PipelineInput input;
input.fragcoord = FRAGCOORD;
input.uv = UV;
input.vert_color = COLOR;
PipelineOutput res = pipeline_opaque(input);
APPLY(res);
}
// psx_transparent.gdshader
shader_type spatial;
#include "psx.gdshaderinc"
uniform sampler2D screen_tex: hint_screen_texture, filter_nearest;
void fragment() {
PipelineInput input;
input.fragcoord = FRAGCOORD;
input.uv = UV;
input.vert_color = COLOR;
PipelineOutput res = pipeline_transparent(input, screen_tex, SCREEN_UV);
APPLY(res);
}
Enough bookkeeping, let's get to the actual code:
// NEW: define semi transparency modes
const int ST_NONE = 0;
const int ST_AVG = 1;
const int ST_ADD = 2;
const int ST_SUB = 3;
const int ST_ADD_x25 = 4;
instance uniform int semi_transparency: hint_enum("None", "Average", "Add", "Subtract", "Add x0.25") = 0;
// NEW: takes albedo and screen texture, returns blended result
vec3 apply_semi_transparency(vec3 albedo, sampler2D screen_tex, vec2 screen_uv) {
#define SCREEN from_linear(texelFetch(screen_tex, screen_coord, 0).rgb)
ivec2 screen_coord = ivec2(screen_uv * vec2(textureSize(screen_tex, 0)));
switch (semi_transparency) {
case ST_NONE: {
return albedo;
}
case ST_AVG: {
return SCREEN * 0.5 + albedo * 0.5;
}
case ST_ADD: {
return SCREEN + albedo;
}
case ST_SUB: {
return SCREEN - albedo;
}
case ST_ADD_x25: {
return SCREEN + albedo * 0.25;
}
}
}
// ... old code ...
output.albedo = get_albedo(input.vert_color, textures[tex_id], uv);
output.albedo = to_rgb5(output.albedo);
// NEW: apply it after we get the albedo vvv
output.albedo = apply_semi_transparency(output.albedo, screen_tex, screen_uv);
output.albedo = dither(output.albedo, input.fragcoord.xy);
output.albedo = to_linear(output.albedo);
And here's our result: Uh oh! When the donut goes over the transparent cube, it completely replaces it instead of blending nicely. This is because they both sample the same screen texture, which they themselves are not a part of. This is a limitation of forward rendering in general. I'm not aware of a way around this, conceptually at least this isn't really a problem you can solve. So yeah, you'd have to make sure your transparent meshes don't overlap.
# Part 9: Lights
At a high level, there is no single way to do lights on the PS1. Vertex colors are just sent manually and you typically just use goraud shading with modulation (see above). The way to calculate those vertex colors is up to each game (you could technically write a cpu raytracer for this and have your game run at 1 frame per hour). However, the Geometry Transform Engine (GTE), a coprocessor on the CPU has some builtin functionality for calculating basic lambertian diffuse lighting, which I imagine is what most games aiming for realism would use, maybe paired with a custom solution for point lights. The GTE also does fog calculation. The documentation on the GTE is way more sparse than the GPU, so I might make a few mistakes here. So, at a glance:
- The PS1 uses per-vertex shading, light is calculated on the cpu
- Normals are not interpolated across fragments (that would be the GPU's job).
- It supports up to 3 dynamic light sources, each with its own color, as well as fog and an ambient light color.
- Fog colors are calculated by the GTE with a few commands by using the vertex distance from the camera
## Lights: Round one
I will try to integrate the PS1's lights with Godot's light system, which obviously doesn't have the limitation of only 3 lights at once, so that's something to keep in mind when you make your scene. Let's start by calculating some basic lambert lighting in the light function:
instance uniform bool use_lights = true;
void light() {
if (use_lights && shading == SHADING_GORAUD) {
DIFFUSE_LIGHT += clamp(dot(NORMAL, LIGHT), 0.0, 1.0) * ATTENUATION * LIGHT_COLOR / PI;
}
}
The light function is a special shader function in Godot that runs for every pixel of the triangle, for every light source in the scene. Godot expects us to accumluate the light in the DIFFUSE_LIGHT built-in.
If you know a thing or two about shaders this shouldn't look too crazy. We take the dot product of the normal vector and the light's direction to get the light factor (how much light hits the surface), multiplied by the light color and the attenuation (only important for point lights), then divide by pi because that's what the godot docs say we need to do. The dot product between 2 normalized vectors is equal to the cosine of the angle between the vectors, giving us a value between -1.0 and 1.0, so we clamp it to 0.0 and 1.0, because we can't have negative light.
Now, we have a few problems. Remember, we want to calculate our colors per vertex and interpolate between them. However, the light function runs per-fragment and gives us an interpolated normal. That's fine, as long as we get identical results. Let's check that we do, using sigh math.
$$\text{let } L_0(\mathbf{p}, \mathbf{n}, \boldsymbol{\omega_i}) = \mathbf{n} \cdot \boldsymbol{\omega_i}(\mathbf{p})$$
$$\text{then, for 2 points } \mathbf{p}_1 \text{ and } \mathbf{p}_2,$$
$$\text{let } L_0^1 = L_0(\mathbf{p}_1, \mathbf{n}_1, \boldsymbol{\omega_i}) = \mathbf{n}_1 \cdot \boldsymbol{\omega_i}(\mathbf{p}_1),$$
$$\text{let } L_0^2 = L_0(\mathbf{p}_2, \mathbf{n}_2, \boldsymbol{\omega_i}) = \mathbf{n}_2 \cdot \boldsymbol{\omega_i}(\mathbf{p}_2),$$
$$\text{then } \operatorname{lerp}(L_0^1, L_0^2, t) = L_0(\operatorname{lerp}(\mathbf{n}_1, \mathbf{n}_2, t))$$
When $\boldsymbol{\omega_i}$ (which is the incoming light direction, or LIGHT in our shader) is constant, which is the case for directional lights, the equality holds true! However, for OmniLight3D (Godot's point lights), the incoming light direction varies per fragment. In practice, our lighting will look much smoother than it should. We are kind of stuck here. There is an option to use per-vertex shading instead of per-fragment, but as per the godot docs:
The light() function won't be run if the vertex_lighting render mode is enabled, or if Rendering > Quality > Shading > Force Vertex Shading is enabled in the Project Settings. (It's enabled by default on mobile platforms.)
But there is one saving grace: the fact that godot already does some basic Burley diffuse for us even without the light function, and that does run with vertex_lighting. There is a setting in Project Settings to force Lambert instead of Burley though. That's a very nice coincidence for us. In fact, godot's default lighting model here maps pretty well to what the PS1 does once we set it to force Lambert. It even clamps the dot product between the normal and the incoming light vector, which is good because, in a similar manner, the GTE saturates negative values to 0. Technically this isn't 100% accurate but it's very close. This means our use_lights uniform is now useless. Instead we need to create new versions of our materials with unshaded set. I leave this as an exercise to the reader :)
Anyways, here's how it looks:
This is so close yet not good enough. Problem is, this approach breaks our dithering (and rgb5 conversion) since it is applied after we calculate those colors. We can't dither the light value to rgb5 because the light function doesn't run anymore. As a result, everything looks way too smooth. So are we stuck?
## Lights: Round Two
What we really need is to calculate the incoming light per vertex and provide it to the vertex color in the vertex function. This would integrate perfectly with the rest of our pipeline. However, Godot doesn't give us light data in the vertex function, only in the light function. So, what I'm going to do instead is get that information myself through gdscript and pass that to our shader through a uniform. The exact data layout will look like this:
typedef enum { DIRECTIONAL, POINT } LightKind;
typedef struct {
struct {
LightKind kind; // tag for our union
int32_t visible;
int32_t range; // only for point lights
} meta;
// we either store the light's direction, or world position
// based on what kind of light it is.
union {
Vec3 direction;
Vec3 world_position;
} data;
Vec3 light_color;
} LightData;
assert(sizeof(LightData) == 24)
I think illustrating data layout in C is the simplest. Regardless, for each light we need to store what kind of light it is, whether it's visible or not, it's range (for point lights only), a Vector3 representing the light's direction or the light's position in the world, for directional lights and point lights respectively, and the light color. Directional lights have a constant incoming light direction as we established before, but for point lights we need to calculate the light direction for each vertex. You can use any data layout honestly, I chose to go with this because it's quite simple to pack and unpack into a RGBF32 texture. Godot's global uniforms don't support arrays, if they did we could send the data as a vec3[], so instead we will pack it into a texture. For each light, we will have three pixels, first one is our metadata, second is the Vector3 direction/position and the third is the light's color. Here's how we do that:
# autoload: PsxLightSystem
@tool
extends Node
const LIGHT_COUNT := 16 # you can set this to whatever you want
const BUFFER_SIZE := LIGHT_COUNT * LightData.SIZE
# we want to reuse the allocation every frame
var buffer := PackedByteArray()
func _process(_dt: float) -> void:
var lights := get_tree().get_nodes_in_group("psx_light")
buffer.resize(BUFFER_SIZE)
var cursor = 0
var light_count := 0
for light in lights:
# we define the `LightData` type to make writing easy, more on that later
var data: LightData
if light is DirectionalLight3D:
data = LightData.from_directional(light)
if light is OmniLight3D:
data = LightData.from_point(light)
if not data:
continue
light_count += 1
# `LightData` has a `write` function that takes a buffer and a cursor,
# writes into the buffer at the cursor and advances the cursor
cursor = data.write(buffer, cursor)
# create image from data
# each pixel in the image is 12 bytes,
# so we divide our buffer size by 12 to get the width
var data_image := Image.create_from_data(BUFFER_SIZE / 12, 1, false, Image.FORMAT_RGBF, buffer)
var tex := ImageTexture.create_from_image(data_image)
RenderingServer.global_shader_parameter_set("light_count", light_count)
RenderingServer.global_shader_parameter_set("lights", tex)
And on the shader side:
global uniform sampler2D lights; // <- light texture ends up here
global uniform vec4 ambient_light: source_color;
global uniform int light_count;
Our LightData class is a helper for serializing:
class LightData:
const SIZE := 12 * 3
const DIRECTIONAL := 0.0
const POINT := 1.0
var meta := Vector3.ZERO
var direction_or_world_pos := Vector3.DOWN
var light_color := Vector3.ONE
func write(buf: PackedByteArray, cursor: int) -> int:
cursor = PsxLightSystem.write_value(buf, cursor, self.meta)
cursor = PsxLightSystem.write_value(buf, cursor, self.direction_or_world_pos)
cursor = PsxLightSystem.write_value(buf, cursor, self.light_color)
return cursor
static func from_directional(light: DirectionalLight3D) -> LightData:
var data := LightData.new()
data.meta.x = DIRECTIONAL
data.meta.y = light.visible as float
data.direction_or_world_pos = light.global_basis.z
data.light_color.x = light.light_color.r
data.light_color.y = light.light_color.g
data.light_color.z = light.light_color.b
data.light_color *= light.light_energy
return data
static func from_point(light: OmniLight3D) -> LightData:
var data := LightData.new()
data.meta.x = POINT
data.meta.y = light.visible as float
data.meta.z = light.omni_range
data.direction_or_world_pos = light.global_position
data.light_color.x = light.light_color.r
data.light_color.y = light.light_color.g
data.light_color.z = light.light_color.b
data.light_color *= light.light_energy
return data
func write_value(buf: PackedByteArray, cursor: int, val: Variant) -> int:
var value := var_to_bytes(val)
for i in range(4, value.size()): # skip type tag
buf[cursor] = value[i]
cursor += 1
return cursor
Now, let's create a function in our shader that takes the vertex world position and surface normal and returns the accumulated light.
vec3 psx_light(vec3 world_vert, vec3 normal) {
// only enable for goraud shaded models
if (shading != SHADING_GORAUD) return vec3(0.0);
const int LIGHT_DIRECTIONAL = 0;
const int LIGHT_POINT = 1;
vec3 light = ambient_light.rgb;
// TODO: draw the rest of the owl
return light;
}
Then, we can sample the light data texture in a loop as if we're iterating through an array.
vec3 psx_light(vec3 world_vert, vec3 normal) {
// only enable for goraud shaded models
if (shading != SHADING_GORAUD) return vec3(0.0);
const int LIGHT_DIRECTIONAL = 0;
const int LIGHT_POINT = 1;
vec3 light = ambient_light.rgb;
int buffer_len = textureSize(lights, 0).x;
int stride = 3;
for (int i = 0; i < light_count; i += 1) {
int cursor = i*stride;
// NEW: fetch 3 pixels at the cursor
vec3 meta = texelFetch(lights, ivec2(cursor, 0), 0).rgb;
vec3 data = texelFetch(lights, ivec2(cursor+1, 0), 0).rgb;
vec3 color = texelFetch(lights, ivec2(cursor+2, 0), 0).rgb;
}
return light;
}
We need to use texelFetch instead of texture. Remember, we store actual data in the light texture, not an image, and we really don't want to apply billinear filtering to our light directions! We then extract the light type form the metadata and calculate the incoming light depending on what kind of light we're dealing with:
int type = int(meta.r);
switch (type) {
case LIGHT_DIRECTIONAL: {
vec3 dir = data;
// basic lambertian diffuse
light += clamp(dot(dir, normal), 0.0, 1.0) * color;
break;
}
case LIGHT_POINT: {
vec3 pos = data;
float range = meta.b;
float dist = distance(world_vert, pos);
// get the incoming light value by linearly interpolating
// between the light color and 0, based on the distance
// - at 0 distance, dist/range = 0 so we get our full color
// - at >=range distance, dist/range >= 1 so we get black
// we also clamp the interpolant so we don't get any extrapolation
light += mix(color, vec3(0.0), clamp(dist / range, 0.0, 1.0));
break;
}
};
Then, in the vertex shader, we calculate the light and multiply it by our existing vertex colors. You can add it instead, or overwrite vertex colors entirely, but multiplication works nicely with vertex color baked lighting, so that's what I went with:
void vertex() {
vec4 world_space = MODEL_MATRIX * vec4(VERTEX, 1);
COLOR.rgb *= psx_light(world_space.xyz, NORMAL);
}
And that's pretty much it! The full shader code also skips lights that are not visible (meta.g == 0), but that's not terribly interesting. But did my efforts here pay off? Well, I'll let you judge for yourselves.
## Fog
Many PS1 games used fog to mask the console's very limited draw distance capabilities. Fog makes it look as if the distant sights are fading due to the atmosphere, instead of reality simply cutting off 3 meters in front of the player. Pretty much all of them used the GTE to some extent to calculate a fog color. Remember that the GTE is a full blown coprocessor with its own registers and commands, of course lacking a program counter (that would be the "co" in "coprocessor", it cannot execute independently). The way you use it to calculate a fog color looks something like this:
- use the
RTPS(rotate translate perspective single) command on a vertex. You do this for every vertex regardless of fog- the command gives you the projected (on screen) vertex coordinates, but also writes the distance from the camera in the
SZregister (Screen Z)
- the command gives you the projected (on screen) vertex coordinates, but also writes the distance from the camera in the
- use the
DPCS(depth cue single) command. Given theSZwe got earlier, calculates an interpolant value between 0.0 and 1.0 (technically between 0x0 and 0x1000) called the depth cue, based on the near and far planes.- the depth cue is 0.0 when
SZis close to the near plane, and 1.0 when close to the far plane. This is functionally an inverse lerp.
- the depth cue is 0.0 when
- use any color interpolation command on the GTE to interpolate between a near and far color.
That being said, how do we use that fog color? The GPU is responsible for drawing, the GTE just gives us the color. Here we get a huge schism between two approaches (technically 3 but we don't talk about CLUT fog):
- modulation fog
- silent hill fog (though silent hill isn't the only game to use this type of fog)
Let's start with modulation fog since it's the most obvious one.
### Modulation Fog
So, the most obvious way to add fog is to simply blend the fog color with the vertex color and use texture modulation. The vertex color will then get multiplied with the texture color, giving us fog. Let's try it in a pitch black environment.

Not bad. The code isn't terrbily complicated:
// NEW: fog uniforms
global uniform float fog_far_plane: hint_range(0.0, 10.0, 0.1);
global uniform float fog_near_plane: hint_range(0.0, 10.0, 0.1);
global uniform vec4 fog_color: source_color;
global uniform bool fog_enabled = false;
global uniform int fog_kind; // foreshadowing
const int FOG_MODULATION = 0;
const int FOG_SH = 1;
instance uniform bool fog_opt_out = false;
void vertex() {
// ...everything else ^^^
// NEW: fog
if (fog_enabled && !fog_opt_out) {
float view_dist = distance(CAMERA_POSITION_WORLD, world_space.xyz);
float fog_factor = view_dist / (fog_far_plane - fog_near_plane);
COLOR.rgb = get_fog_color(COLOR.rgb, fog_factor);
}
}
Nice. Now, let's try it again in our grayish pink evironment, which is closer to Silent Hill. I'll just set the background color back to gray and set the fog color to that same color:

Uh oh! That doesn't look right. The entire image is super bright now. This is because modulation simply multiplies our vertex color with the texture color. When our vertex color is black, it multiplies by 0 and results in black, which is coincidentally what we want. But for any other color, we get the wrong effect. What we want is color mixing, aka a lerp. It just so happens that for black, multiplication and lerp are the same. But we didn't do anything wrong. This is what happens on the original hardware as well.
### Silent Hill Fog
So, how does Silent Hill get that thick, gray fog? By rendering the entire world twice. Each triangle is rendered twice: once as an untextured triangle with a color between black and the fog color and another time as a textured triangle with vertex colors blended between black and white. Both have semi transparency set, with the Add blending mode. Overlapped, they give the same effect as a lerp. This is terribly expensive to render, it effectively cuts the geometry you can render in 2, because for every polygon you do double the work. Harry isn't rendered twice, he simply gets a little tint all around. Well, so much for the famous technique touted as an "optimization" (seriously, the amount of people that think this fog is actually an optimization is insane).
We live in the future now and our GPUs can do all sorts of wacky things, so we don't need this workaround. We can simply calculate the fog factor in the vertex shader and lerp our colors in the fragment shader:
// NEW: it's now a varying
varying float fog_factor;
// in the vertex shader:
if (fog_enabled && !fog_opt_out) {
float view_dist = distance(CAMERA_POSITION_WORLD, world_space.xyz);
fog_factor = view_dist / (fog_far_plane - fog_near_plane);
if (fog_kind == FOG_MODULATION) {
COLOR.rgb = get_fog_color(COLOR.rgb, fog_factor);
} else {
fog_factor *= clip.w; // fog is technically subject to affine mapping as well
}
}
// NEW: fragment shader fog
output.albedo = get_albedo(input.vert_color, textures[tex_id], uv);
if (fog_kind == FOG_SH) {
output.albedo = get_fog_color(output.albedo, fog_factor / clip_pos.w);
// reverse perspective correction ^^^^^^^^^^^^^^^^^^^^^^^^
}
output.albedo = dither(output.albedo, input.fragcoord.xy);
Nice. But we still have one last quirk. There's way too much dithering. In the distance, even though we have a completely flat color, it still gets dithered. You don't get this type of effect on the original PS1 so we need to fix it. In order to do so, we can change our to_rgb5 function to use round instead of floor and use a fog color that is perfectly representable in rgb5. When we used floor, if we had a value that mapped perfectly to rgb5, we would get an integer value after the multiplication, e.g. 20.0, but by dithering, we would end up with a fractional value, e.g. 19.94. By using floor we go all the way down to 19.0 which would lead to the surface alternatig between the normal rgb5 color and one color darker. Anyways, here's the change:
vec3 to_rgb5(vec3 color) {
color.rgb *= 31.;
// NEW: round vvv
color.rgb = round(color.rgb);
color.rgb /= 31.;
return color;
}
And I used the following scheme to convert my color into rgb5:
(map (lambda (x) (/ (round (* x 31)) 31.0))
(list 0.631 0.521 0.807)) // these are the f32 rgb values of your color
And there you have it! Please check out Elias Daler's video on PS1 fog, it has a lot of visual explanations that I can't fit into this article and goes more in depth.
Also, I didn't want to clutter up the fog factor section, but technically the depth cue is limited to 12 bit precision, which I added to the shader but honestly there isn't a big difference.
# Closing words
That's about everything I can think of. If you think I missed anything, or you have questions, feel free to reach out. I hope you found some things about the PS1 hardware interesting. The final shader is MIT licensed and available on github. I have to say, making the lighting system was the most fun part of making this, it was also the most complicated (but surprisingly didn't take that much time).
If you liked what you read, please wishlist STAR BREW on steam.