Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/JustADev1024/threejs-car/llms.txt

Use this file to discover all available pages before exploring further.

Dream Car never actually moves the car or the camera forward. Instead, every road marking and every piece of scenery slides toward the camera at a constant rate; once an object passes behind the viewing frustum it is teleported back to the far end of its range. Because the recycled objects are indistinguishable from new ones, the world appears to stretch on forever with zero memory growth. This technique — sometimes called a conveyor-belt loop — is cheaper than streaming new geometry and trivially simple to implement with Three.js.

Road Geometry

The drivable surface is a PlaneGeometry rotated flat and layered with two curb strips.
// Road surface
const roadPlane = new THREE.Mesh(
    new THREE.PlaneGeometry(4.5, 80),
    new THREE.MeshStandardMaterial({ color: 0x2c2e3a, roughness: 0.6 })
);
roadPlane.rotation.x = -Math.PI / 2;
roadPlane.position.y = -0.2;
roadPlane.receiveShadow = true;
scene.add(roadPlane);

// Curbs
const curbMat = new THREE.MeshStandardMaterial({ color: 0xaa9966 });

const curbLeft = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.1, 80), curbMat);
curbLeft.position.set(-2.35, -0.15, 0);
scene.add(curbLeft);

const curbRight = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.1, 80), curbMat);
curbRight.position.set(2.35, -0.15, 0);
scene.add(curbRight);

// Grass under everything
const grassPlane = new THREE.Mesh(
    new THREE.PlaneGeometry(60, 120),
    new THREE.MeshStandardMaterial({ color: 0x5c9e3a, roughness: 0.9, metalness: 0.1 })
);
grassPlane.rotation.x = -Math.PI / 2;
grassPlane.position.y = -0.3;
grassPlane.receiveShadow = true;
scene.add(grassPlane);
The road, curbs, and grass planes are static — they do not move. Only the lane markings and scenery objects are animated.

Lane Markings

27 small BoxGeometry dashes are spread along the full road length at 3-unit intervals and move backward each frame.
const lineMat = new THREE.MeshStandardMaterial({ color: 0xffdd99 });
const lines   = [];
const lineCount = 27;

for (let i = 0; i < lineCount; i++) {
    const line = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.05, 0.6), lineMat);
    line.position.set(0, -0.15, -40 + i * 3);
    scene.add(line);
    lines.push(line);
}
Each frame inside updateScenery() every line is shifted by speed units in the −Z direction. When a line’s Z coordinate drops below −40 it is snapped forward by one roadLength (80 units), placing it back at the far end of the visible range:
lines.forEach(line => {
    line.position.z -= speed;       // scroll toward camera
    if (line.position.z < -40)
        line.position.z += roadLength; // recycle to far end
});
speed = 0.12 is defined as a module-level constant at the top of the script. Increase it to make the car feel faster; decrease it to slow things down. Because wheelAngle += 0.12 in animateCar() uses the same literal, update both to keep wheel rotation in sync.

Scenery Loop

All trees, hills, stones, and grass tufts are children of a single THREE.Group called landscapeGroup. The loop logic is identical to the lane markings but uses a slightly larger recycle threshold (-48) to account for the objects’ varying depths, and resets them roadLength + 16 = 96 units ahead so they never pop into view:
const landscapeGroup = new THREE.Group();
scene.add(landscapeGroup);

// Inside updateScenery():
landscapeGroup.children.forEach(obj => {
    obj.position.z -= speed;
    if (obj.position.z < -48)
        obj.position.z += roadLength + 16;   // roadLength = 80
});

addScenery() — Populating the World

addScenery() is called once at startup. It fills landscapeGroup with four categories of objects.

Trees

Pairs of trees are placed every 4 units from Z = −35 to Z = +35 on both sides of the road (X = ±4.8). Each tree is a sub-Group containing a trunk cylinder and a cone crown.
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x8B5A2B });
const crownMat = new THREE.MeshStandardMaterial({ color: 0x5A9E4E });

for (let i = -35; i <= 35; i += 4) {
    const leftGroup = new THREE.Group();
    const trunkL = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.5, 1), trunkMat);
    trunkL.position.y = 0.5;
    trunkL.castShadow = true;
    const crownL = new THREE.Mesh(new THREE.ConeGeometry(0.9, 1.2, 6), crownMat);
    crownL.position.y = 1.1;
    crownL.castShadow = true;
    leftGroup.add(trunkL, crownL);
    leftGroup.position.set(-4.8, -0.25, i);
    landscapeGroup.add(leftGroup);

    const rightGroup = new THREE.Group();
    const trunkR = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.5, 1), trunkMat);
    trunkR.position.y = 0.5;
    trunkR.castShadow = true;
    const crownR = new THREE.Mesh(new THREE.ConeGeometry(0.9, 1.2, 6), crownMat);
    crownR.position.y = 1.1;
    crownR.castShadow = true;
    rightGroup.add(trunkR, crownR);
    rightGroup.position.set(4.8, -0.25, i);
    landscapeGroup.add(rightGroup);
}
Tree sub-groups are added as single children of landscapeGroup. When the loop moves obj.position.z, it moves the entire sub-group (trunk + crown) atomically — no per-part bookkeeping required.

Hills

Low flattened spheres are scattered beyond X = ±2.8 in a regular grid with randomised jitter.
const hillMat = new THREE.MeshStandardMaterial({ color: 0x6a9e4b });

for (let i = -30; i <= 30; i += 6) {
    for (const side of [-1, 1]) {
        const x = side * (5 + Math.random() * 1.5);
        if (Math.abs(x) < 2.8) continue;

        const hill = new THREE.Mesh(
            new THREE.SphereGeometry(0.7, 16, 16),
            hillMat
        );
        hill.position.set(x, -0.35, i + (Math.random() - 0.5) * 3);
        hill.scale.set(1.5, 0.5, 1.5);   // flatten vertically
        hill.castShadow = true;
        landscapeGroup.add(hill);
    }
}

Stones

140 DodecahedronGeometry pebbles are randomly distributed across the full road length, skipping any position within 2.5 units of the road centre.
const stoneMat = new THREE.MeshStandardMaterial({ color: 0x888888 });

for (let i = 0; i < 140; i++) {
    const z = (Math.random() - 0.5) * 80;
    const x = (Math.random() - 0.5) * 12;
    if (Math.abs(x) < 2.5) continue;

    const stone = new THREE.Mesh(
        new THREE.DodecahedronGeometry(0.15),
        stoneMat
    );
    stone.position.set(x, -0.3, z);
    stone.scale.set(1, 0.6, 0.8);   // slightly squashed
    stone.castShadow = true;
    landscapeGroup.add(stone);
}

Grass Tufts

400 thin ConeGeometry spikes represent individual grass blades, distributed across a 12 × 90 unit strip on either side of the road.
const grassMat2 = new THREE.MeshStandardMaterial({ color: 0x5cad3a });

for (let i = 0; i < 400; i++) {
    const z = (Math.random() - 0.5) * 90;
    const x = (Math.random() - 0.5) * 12;
    if (Math.abs(x) < 2.8) continue;

    const grass = new THREE.Mesh(
        new THREE.ConeGeometry(0.1, 0.3, 3),
        grassMat2
    );
    grass.position.set(x, -0.25, z);
    grass.castShadow = false;
    landscapeGroup.add(grass);
}

Scenery Object Summary

TypeGeometryCountPlaced at
Tree trunkCylinderGeometry(0.4, 0.5, 1)36 (18 pairs)X = ±4.8
Tree crownConeGeometry(0.9, 1.2, 6)36 (18 pairs)On top of trunk
HillSphereGeometry(0.7, 16, 16) scaled (1.5, 0.5, 1.5)~20X > ±2.8
StoneDodecahedronGeometry(0.15)≤140X > ±2.5
Grass tuftConeGeometry(0.1, 0.3, 3)≤400X > ±2.8

The updateScenery Loop

The complete animation function — containing both the lane-marking and scenery recycling loops — is started once at the bottom of the script:
const speed      = 0.12;
const roadLength = 80;

function updateScenery() {
    requestAnimationFrame(updateScenery);

    lines.forEach(line => {
        line.position.z -= speed;
        if (line.position.z < -40)
            line.position.z += roadLength;
    });

    landscapeGroup.children.forEach(obj => {
        obj.position.z -= speed;
        if (obj.position.z < -48)
            obj.position.z += roadLength + 16;
    });
}
updateScenery();
Because the car stays at the world origin and the world moves instead, adding new scenery types is straightforward — create a mesh, add it to landscapeGroup, and the recycling loop handles it automatically with no extra code.

Build docs developers (and LLMs) love