I wanted to implement the PS1 aesthetic. The wobbly models, the swimming textures. Here's what I made:
While researching how people recreate this look, I found that the wobble and the texture swimming are not stylistic choices. They are two separate hardware limitations baked into the PS1 itself.
No FPU. Everything Was Integer.
The PS1 had a co-processor called the GTE (Geometry Transformation Engine) that handled all 3D math. Transforms, projections, everything. There was no floating point unit, so all of it ran on fixed-point integer arithmetic.
The GTE output integer screen-space coordinates. Vertices could only land on whole pixel positions. As geometry moved, it snapped from one integer to the next instead of gliding smoothly. That snapping is the wobble.
No Z-Buffer. No Per-Pixel Divide.
The rasterizer had no depth buffer. Z-sorting was handled by the CPU using painter's algorithm. It also had no per-pixel divide, which is what killed perspective-correct texture interpolation.
Correct UV interpolation across a triangle works like this:
result = (u/w interpolated) / (1/w interpolated)
Each UV gets divided by W at the vertices, interpolated across the triangle, then divided again by the interpolated 1/W at each pixel. That final per-pixel divide is what the PS1 rasterizer couldn't do. So it skipped W correction entirely and just interpolated UV linearly in screen space. That's affine texture mapping, and it's why textures slide and warp as the camera moves.
Implementing Vertex Snapping
I snap in NDC space and convert back to clip space before writing gl_Position. W has to stay untouched since the rasterizer still uses it downstream.
vec4 clip = uMVPMatrix * aPosition;
vec2 ndc = vec2(clip.x / clip.w, clip.y / clip.w);
float snapRes = 50.0;
vec2 snapped = floor(ndc * snapRes) / snapRes;
gl_Position = vec4(snapped.x * clip.w,
snapped.y * clip.w,
clip.z,
clip.w);
Lower snapRes means coarser snapping. I tuned it by eye rather than tying it to a fixed resolution.
Implementing Affine Texture Warping
GLSL has an interpolation qualifier called noperspective. It makes the rasterizer interpolate the attribute linearly in screen space with no W correction. Exactly what the PS1 rasterizer did.
Vertex shader:
noperspective out vec2 vTexCoords;
Fragment shader:
noperspective in vec2 vTexCoords;
I tried another approach first: dividing UV by W manually in the vertex shader.
// doesn't work
vTexCoords = aTexCoords / gl_Position.w;
The rasterizer treats it as a regular varying and perspective-corrects it anyway. The manual divide and the hardware divide cancel each other out and produce correct interpolation. There's no GL flag to disable perspective correction at runtime. The qualifier is the only way.
Two Artifacts, Two Chips
These are independent effects from two different chips. Vertex snapping is a GTE limitation. Affine warping is a rasterizer limitation. Either one works without the other. Both together give the full PS1 look.