[readonly] markdown buffer
How I Built a PS1 Pipes Screensaver Behind a Blog
I wanted a background for the blog that felt like Unix, BSD and an old workstation left running overnight.
The obvious answer was pipes.
It sounded like a small visual detail: draw some cylinders, add elbows, move a camera through them and snap the result to something resembling PlayStation graphics.
Then the pipes started turning into sausages.
A pipe has to grow like a pipe
The first problem was not rendering. It was time.
If a new pipe appears at full length, the scene looks populated but nothing feels alive. If every new pipe begins with one short segment, detached cylinders sit in space for several seconds.
The compromise was to preserve visible growth while prioritising the shortest pipes. The scene maintains several active paths, but only a subset grows on each update. Those slots go to the pipes with the fewest segments.
return pipes
.sort((a, b) => a.segmentCount - b.segmentCount)
.slice(0, growingPipeCount);
New pipes quickly become recognisable without popping into existence fully formed. Once established, they compete normally for growth.
Short abandoned paths are removed as well. A failed path should disappear, not leave an orphaned sausage floating in the distance.
new pathgets growth slots firstestablished paths
The camera needs to know where it is going
Randomly changing the camera direction created a drunken screensaver. Worse, pipes could grow into a space the camera would occupy a few seconds later.
The camera now follows a route planned well ahead of the visible position. Path nodes receive slowly changing yaw, pitch and roll targets, and Catmull–Rom interpolation turns those nodes into a gentle curve.
Yaw and pitch can roam widely, but they change at roughly ten degrees per minute. Roll is free to continue through a complete barrel roll. The camera always faces along the route rather than looking sideways away from the scene.
Planning ahead also gives pipe generation a protected corridor. Before accepting a segment, the generator samples it against the future camera path and rejects anything inside the clearance radius.
The camera does not dodge pipes. Pipes grow around somewhere the camera has already decided to be.
Depth sorting was the wrong fight
The early renderer built faces in JavaScript and tried to draw them in a sensible order.
That worked until pipes crossed, elbows overlapped or a large face passed close to the camera. Triangles appeared through one another because sorting whole objects cannot correctly resolve every intersection between their faces.
Moving the main renderer to WebGL gave the scene a real depth buffer. Pipe bodies, caps and elbow bridges are triangulated once, sent to the GPU and tested per fragment.
That still required consistent face winding and outward normals. A depth buffer cannot rescue an inside-out mesh.
The Canvas renderer remains as a fallback, but WebGL owns the intended result.
The PS1 wobble happens after projection
Low polygon counts make a scene retro. They do not make it look specifically PlayStation-like.
The movement comes from snapping projected vertex positions to a virtual 480-pixel-high display inside the vertex shader:
vec2 virtualPosition =
(projected * 0.5 + 0.5) * virtualResolution;
vec2 snappedPosition = floor(virtualPosition + 0.5);
As the camera moves, each triangle vertex remains on one virtual pixel until it crosses the next boundary. The geometry appears to twitch and bend even though its world position is unchanged.
This is deliberately an impression rather than a complete PlayStation renderer. It recreates the useful visual fault without emulating an entire graphics pipeline.
Performance came from rebuilding less
The scene is capped at 30 frames per second because the effect does not benefit from pretending to be a modern 144 Hz game.
The more important optimisation was separating camera movement from geometry changes. The camera can move every frame using uniforms. A pipe mesh is rebuilt only when that particular path grows or disappears.
Mesh construction is also divided across small time budgets so one burst of new geometry does not stall a frame. Device pixel ratio is capped, old segments are removed behind the camera, and the total segment count has a ceiling.
The result is not “draw fewer pipes”. It is “do less work for the pipes already there”.
The scene keeps moving because most frames reuse geometry already on the GPU.
The bugs became the style
The final effect came from treating the screensaver as a moving system rather than a static model.
Pipes need a lifecycle. The camera needs a future. Geometry needs real depth. Performance depends on knowing what changed. The PlayStation look needs carefully chosen imprecision.
That last part is my favourite.
Modern browsers work very hard to make motion smooth and geometry correct. Making this scene feel old meant deciding exactly where it should be wrong.