← All notes

Three.js

Procedural car parts on sockets in Three.js

The Long Drive and Drive Beyond Horizons are why this car is a pile of parts that happen to be bolted on. It only moves once the ones that matter are fitted, and until then it just sits in the yard.

This is one HTML file on Three.js 0.186.1, WebGPU and TSL. There is no model file. The body is a side-view silhouette extruded across the width, and the engine, battery, wheels, seats and side pipes are boxes, cylinders and a tube. I use AI to write a lot of the TSL, including the rust. Which holes to leave in the body, and which parts the car is allowed to drive without, come from 25+ years of JavaScript, game development, modding and level design.

The shell

The profile is a Shape in the side view, length along X and height along Y, then extruded about a metre and a half, with a bevel so the shoulders come out rounded. The wheel arches are arcs cut up into that outline. The engine tray and the cabin are gaps in the same outline, left open so a missing engine reads as an empty bay. Close that gap to "finish" the mesh and the car stops looking like something you can work on.

The paint is a node material, and there is no texture. Two octaves of noise, one broad and one fine, plus a bias that gets rustier toward the rocker, and that mask mixes the base colour into rust. Roughness and metalness follow the same mask, so the rusty patches go dull and the paint that is left stays a bit metallic. The amount of rust is a number baked when the material is created. The body uses 0.55.

const lowPanels = smoothstep(1.1, -0.3, positionLocal.y).mul(0.30);
const mask = smoothstep(edge0, edge0.add(0.14), wear.add(lowPanels));
m.colorNode = mix(base, rust, mask);

The ground is the same idea in world space, two noise octaves between two sand colours. The parts themselves stay on plain MeshStandardMaterial, a fresh one per mesh, because the hover highlight writes emissive onto the material. One shared rubber material would light up every wheel when you point at one.

Sockets

A socket is an empty Object3D parented to the chassis, with a type on it. Installing a part parents the part's group to that socket and zeroes the local position and rotation, which is why every part maker builds in socket space, as if it were already fitted. Pulling a part off puts the group back in the scene, dropped to its rest height and rolled by its rest rotation, plus a random yaw. Wheels lie on their side in the yard. That rest pose is data, so don't "fix" it by making a loose wheel stand up.

sock.add(part.group);
part.group.position.set(0, 0, 0);
part.group.rotation.set(0, 0, 0);
part.state = 'installed';

While you are carrying a part, a ghost clone of it sits on the nearest free socket of the same type. Nearest here means the socket closest to the aim ray, so the outline lands where you are looking, and it pulses so you can see it against the paint. Click the ghost, or get the ray within about half a metre of the socket, and it fits. Click the ground and it drops where you clicked. Click a part that is already on the car and it pops off a couple of metres to the side.

What it needs to move

The checklist is the game. Four things have to be true. The side pipes are on the list too, marked optional, because a missing pipe should leave the car drivable:

  • the engine
  • the battery
  • all four wheels
  • at least one seat
function operational() {
  return installedOfType('engine') === 1
      && installedOfType('battery') === 1
      && installedOfType('wheel') === 4
      && installedOfType('seat') >= 1;
}

The headlamps only light when that function returns true. Until then WASD does nothing and the speed is held at zero, so you can't roll a bare shell around by holding the key down. Once it runs, the driving is deliberately simple: a bit of acceleration, some drag, steer only on the two front sockets, spin each fitted wheel by speed over its radius, and spin the engine pulley if the engine is actually installed. The camera eases after the chassis. There is a fence at 55 units, because the ground is a disc and I didn't want the car to drive off the edge of it.

It starts the way those games start. Three wheels and one seat are already on. The engine, the battery, the fourth wheel, the other seat and the side pipes are lying in the yard.

Three wheels and a seat are on. The engine is somewhere in the sand.

The frame is that file. Drag to orbit. Click a loose part to pick it up, then click the green outline to fit it, or click the sand to put it down. Click a fitted part and it comes off. WASD once the checklist goes green. The lamps are the tell.

WebGPU only, on Three.js 0.186.1, with no WebGL fallback. If the browser has no WebGPU, the page stops. Open it through a server, because file:// will not load the modules. php -S localhost:8000 or npx serve in the folder is enough.

If you add a part, give it a type, a socket, and a rest pose for when it is lying in the yard. Then decide whether the car is allowed to move without it. Most new parts should work like the side pipes. The silhouette is the thing to protect.