XSCREENSAVER / 3D PIPES

[readonly] markdown buffer

How I Built a PS1 Pipes Screensaver Behind a Blog

Jul 27, 2026 · 12 min read

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.

traceview://pipes-growth
growth queue · shortest first 1 segment3 segments6 segments

new pathgets growth slots firstestablished paths

The shortest new paths receive growth slots first, so they become recognisable pipes before established paths continue expanding.

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.

traceview://pipes-camera
preplanned routeprotected corridorpipe growth
The camera advances along a gently curving route planned beyond the visible scene. Pipe segments may grow outside the protected corridor, never through it.

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.

traceview://pipes-depth
whole-object sortingone order
front wrong
orange is drawn last at both crossings
depth bufferper fragment
front front
the visible pipe changes at the second crossing
Sorting whole pipe objects chooses one draw order for both crossings. A depth buffer resolves each visible fragment independently, so the front pipe can change at the second crossing.

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.

traceview://pipes-wobble
modern projectioncontinuous
sub-pixel positions
virtual 480psnapped
vertices hold, then jump
Continuous projected movement looks modern. Snapping the same vertices to a virtual 480p display makes each triangle hold its position and then jump.

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”.

traceview://pipes-performance
30 fps frame budget33.3ms each
01camera02camera03camera04grow05camera06camera07camera08camera09camera10grow11camera12camera
every frameupdate camera uniforms
frames 04 + 10rebuild one dirty pipe mesh
avoidedrebuild every pipe every frame

The scene keeps moving because most frames reuse geometry already on the GPU.

Camera uniforms update every frame. Pipe geometry is rebuilt only on the frames where a path changes, and only for the dirty pipe.

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.