Create a Procedural Three.js Film

Create a cinematic countryside scene with procedural geometry in Three.js.

Introduction

30 Second Summary

You'll recreate the feeling of a movie's opening shot as a countryside scene that unfolds live in your browser.

In this project, you will build a browser-rendered countryside scene with Three.js. A timed camera sequence turns the generated world into a short film with titles plus a fade.

What You'll Build

You'll open Google Chrome to watch a misty countryside shot unfold through a smooth camera glide.

By the end of this project, you'll have:

  • A live generated landscape that renders in your browser instead of playing as a prerecorded video.
  • Procedural terrain shaped by calculated heights applied across the landscape.
  • A cinematic sequence where the camera glides through warm countryside scenery. Opening titles introduce the shot. A timed fade closes it.
  • Secret Mission: An optional challenge to push your filmmaking skills further.

Are There Any Prerequisites?

You can start without prior coding or 3D experience. The project guides you through setting up every required development tool on your Mac.

Before We Start

Before you begin, commit to the short countryside opening shot you want to create. Picture the warm, misty feeling you want your browser-rendered film to capture.

Install the Tools and Launch the Starter

Your browser can display the finished film. You need a safe workspace for building each part of the shot.

In this step, you will install Visual Studio Code. You will also set up Node.js, npm, and Vite before launching the starter page.

In this step, get ready to:
  • Install Visual Studio Code with Terminal access.
  • Install Node.js 24.19.0 LTS with npm.
  • Create the Vite starter project with pinned dependencies.
Install Visual Studio Code

Visual Studio Code gives you one place to edit the project files. Its Terminal command lets you open the current folder directly in the editor.

  • Visit the official Visual Studio Code setup page.
  • Download the Universal build for macOS.
  • Open the downloaded .dmg file.
  • Drag Visual Studio Code.app into your Applications folder.
  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Enter to launch Visual Studio Code.
  • Open the Command Palette from the top menu.
  • Run Shell Command: Install 'code' command in PATH.
  • Close Terminal if it is running.
  • Press Cmd+Space to open Spotlight again.
  • Type Terminal into Spotlight.
  • Press Enter to restart Terminal with the updated path.

Can't Install the Terminal Command?

Make sure Visual Studio Code is inside your Applications folder. Reopen the editor before running the Command Palette action again.

Ask for help with installing the code command on macOS.

Verify Node.js and npm

Node.js runs the development tools behind your project. The 24.19.0 LTS release satisfies Vite's runtime requirements.

  • Check the installed Node.js version with npm by running these commands:
node --version
npm --version

What Do These Commands Check?

  • The first command reports the Node.js runtime version.
  • The second command confirms that npm is available for installing project packages.

✔️ I see version 24.19.0

Node.js is ready when Terminal shows v24.19.0. npm is ready when the next line contains its version number.

ⓧ I see an older version

Your current Node.js version is below the version used by this project. Install the pinned LTS release before continuing.

  • Visit the official Node.js download page.
  • Download the macOS installer for Node.js 24.19.0 LTS.
  • Open the downloaded installer.
  • Complete the installation prompts.
  • Close Terminal.
  • Reopen Terminal through Spotlight.
  • Verify the new installation by running these commands:
node --version
npm --version

What Should I See?

Terminal should now show v24.19.0 for Node.js. The next line should contain an npm version number.

Still Seeing the Older Version?

Close every Terminal window before opening a fresh one. This reloads the command path after installation.

Ask for help with finding the newer Node.js installation.

ⓧ Command not found

Node.js is not available in Terminal yet. The official installer adds Node.js plus npm to your Mac.

  • Visit the official Node.js download page.
  • Download the macOS installer for Node.js 24.19.0 LTS.
  • Open the downloaded installer.
  • Complete the installation prompts.
  • Close Terminal.
  • Reopen Terminal through Spotlight.
  • Verify the installation by running these commands:
node --version
npm --version

What Should I See?

Terminal should show v24.19.0 for Node.js. The next line should contain an npm version number.

Commands Still Missing?

Restart Terminal after the installer finishes. An older Terminal session may not see the newly installed commands.

Ask for help with making Node.js available in Terminal.

Create and launch the Vite starter

The starter creates a working browser project before you build the countryside. You will add Three.js as an exact dependency so every scene API matches this guide.

  • Move to your Desktop by running this command:
cd ~/Desktop

What Does This Command Do?

This moves Terminal into your Desktop folder. The new project folder appears there after scaffolding.

npm may ask for permission to download the Vite scaffolder. Approve that prompt to continue.

  • Create the vanilla Vite project by running this command:
npm create vite@latest a-long-expected-opening -- --template vanilla

What Does This Command Create?

Vite creates the a-long-expected-opening folder on your Desktop. The vanilla template supplies the starter page plus its development scripts.

Scaffolding Failed?

Confirm that the earlier version checks worked. A network interruption can also stop npm from downloading the scaffolder.

Ask for help with diagnosing the Vite scaffold command.

  • Enter the new project folder by running this command:
cd a-long-expected-opening

Where Is Terminal Now?

Terminal now points at the a-long-expected-opening folder. Package installations apply to this project.

  • Install the starter packages plus the pinned Three.js and Vite versions by running these commands:
npm install
npm install --save-exact three@0.185.1
npm install --save-dev --save-exact vite@8.2.1

What Do These Installs Add?

  • The first command installs the packages declared by the Vite starter.
  • The second command pins Three.js to 0.185.1.
  • The third command pins the development server to Vite 8.2.1.

Package Installation Failed?

Check that Terminal still points at the a-long-expected-opening folder. Confirm that your internet connection is active before retrying the failed command.

Ask for help with fixing the npm installation.

Your dependency versions are stored in package.json. Use these tabs to check the finished file.

✔️ Awesome, I've got everything!

Great. Your project now has the exact dependency versions required for the film scene.

ⓧ I'd like to double check the full code

{
  "name": "a-long-expected-opening",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "vite": "8.2.1"
  },
  "dependencies": {
    "three": "0.185.1"
  }
}

What Does This File Confirm?

This file confirms the project name plus its development scripts. It also pins Three.js and Vite to the versions used throughout the project.

  • Open the current project folder in Visual Studio Code by running this command:
code .

What Does This Command Do?

The dot represents the current folder. Visual Studio Code should open with a-long-expected-opening visible in its Explorer sidebar.

Code Command Not Found?

Return to Visual Studio Code's Command Palette. Run the PATH installation action again before restarting Terminal.

Ask for help with repairing the code command.

Before you run the project, predict the local address Vite will print.

  • Start the development server by running this command:
npm run dev

What Should I See?

Vite prints a local URL in Terminal. It commonly uses http://localhost:5173 when that port is available.

The server keeps using this Terminal session. Press Ctrl+C when you want to stop it.

  • Keep the development server running.
  • Copy the local URL printed in Terminal.
  • Press Cmd+Space to open Spotlight.
  • Type Google Chrome into Spotlight.
  • Press Enter to open Google Chrome.
  • Paste the local URL into Chrome's address bar.
  • Press Enter to load the starter.

Chrome shows the Vite starter page. This proves that the editor, runtime, packages, and development server work together.

Starter Page Not Loading?

Keep the Terminal session running while Chrome loads the page. Use the exact local URL printed by Vite because another process may force a different port.

Ask for help with opening the Vite starter page.

Your development workspace is ready. Next, you will replace the starter with the first flat countryside shot.

Put a Flat Countryside on Screen

Your Vite starter is running in Google Chrome. Now you can replace it with the first visible frame of your countryside film.

This draft proves the Three.js render loop works. Its flat ground deliberately exposes the missing depth that procedural terrain adds in the next step.

In this step, get ready to:
  • Build a full-screen frame for the film.
  • Render a flat green plane beneath a blue background.
  • Observe why static geometry cannot create the finished countryside.
Build the film frame

The film frame gives the renderer a full-screen home. It also includes two overlays that later control the title sequence and final fade.

  • In the VS Code Explorer sidebar, select index.html.
  • Replace the contents of index.html with this markup:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>A Long-Expected Opening</title>
  </head>
  <body>
    <div id="film">
      <div id="title-card">
        <p>A procedural picture</p>
        <h1>A Long-Expected Morning</h1>
      </div>
      <div id="fade"></div>
    </div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

What Does This Markup Do?

  • The film element holds the canvas created by Three.js.
  • The title-card element holds the opening text.
  • The fade element provides a full-screen transition layer.
  • Save index.html.
  • Return to Chrome to confirm the page contains the new opening title text.

Still Seeing the Starter Page?

Confirm that you edited index.html inside the a-long-expected-opening folder. Make sure the development server from the previous step is still running.

Help me find why my updated index.html is not appearing in the browser.

The stylesheet turns the document into a borderless film frame. Each layer fills the browser so later animation can control the whole composition.

  • In the VS Code Explorer sidebar, select src/style.css.
  • Replace its contents with this first group of layout rules:
:root {
  font-family: Georgia, "Times New Roman", serif;
  color: #f7edce;
  background: #10140e;
}

* {
  box-sizing: border-box;
}

html,
body,
#film {
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
}

canvas {
  display: block;
  width: 100%;
  height: 100%;
}

How Does the Frame Fill the Screen?

  • The page and film container use the full browser width.
  • The same elements use the full browser height.
  • The canvas stretches across the available frame.
  • Save src/style.css.
  • Return to Chrome to confirm the page reaches every edge of the browser window.

Seeing Space Around the Page?

Check that the html, body, and #film selector includes margin: 0. Confirm that the stylesheet remains imported by the project.

Help me diagnose why my film frame does not fill the browser window.

  • Add these shared overlay rules beneath the canvas rules in src/style.css:
#title-card,
#fade {
  position: fixed;
  inset: 0;
  pointer-events: none;
}

#title-card {
  z-index: 2;
  display: grid;
  place-content: center;
  gap: 0.5rem;
  padding: 2rem;
  text-align: center;
  text-shadow: 0 2px 18px rgba(20, 12, 4, 0.75);
  opacity: 0;
}

How Do the Overlays Work?

  • Both overlays stay fixed across the viewport.
  • The title uses a centered grid layout.
  • Its starting opacity keeps the title hidden until the cinematic timeline controls it.
  • Save src/style.css.
  • Inspect the page in Chrome to confirm the title no longer covers the frame.

Can You Still See the Title?

Check that opacity: 0 sits inside the #title-card rule. A missing closing brace can place the declaration outside its selector.

Help me find why the title card remains visible after I set its opacity to zero.

  • Add the final typography and fade rules beneath the title-card rule:
#title-card p {
  margin: 0;
  font-size: 0.7rem;
  letter-spacing: 0.32em;
  text-transform: uppercase;
}

#title-card h1 {
  margin: 0;
  font-size: clamp(2rem, 7vw, 5.5rem);
  font-weight: 400;
}

#fade {
  z-index: 3;
  background: #10140e;
  opacity: 1;
}

Why Is the Frame Dark?

The fade layer begins fully opaque so the eventual film sequence can open from darkness. You will temporarily hide this layer when checking the flat countryside.

  • Save src/style.css.
  • Return to Chrome to confirm a dark layer now covers the full frame.

Does the Fade Miss Part of the Screen?

Confirm that #fade shares the fixed positioning rule with #title-card. Check that its inset value remains 0.

Help me diagnose why the fade overlay does not cover the full browser frame.

✔️ Awesome, I've got everything!

Great. Double-check that you saved index.html and src/style.css.

ⓧ I'd like to double check the full code

Your complete index.html should match this file.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>A Long-Expected Opening</title>
  </head>
  <body>
    <div id="film">
      <div id="title-card">
        <p>A procedural picture</p>
        <h1>A Long-Expected Morning</h1>
      </div>
      <div id="fade"></div>
    </div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

The complete stylesheet exceeds the code-block limit. Scroll up to confirm that src/style.css contains the frame rules first.

  • Confirm the shared overlay rules appear after the canvas rules.
  • Confirm the title typography rules appear after the shared overlay rules.
  • Confirm the fade rule appears last.
Create the flat Three.js scene

A scene graph organizes the camera, light, and terrain as connected objects. The first scene stays deliberately simple so you can verify each core part.

  • In the VS Code Explorer sidebar, select src/main.js.
  • Replace its contents with the imports and scene setup below:
import * as THREE from 'three';
import './style.css';

const film = document.querySelector('#film');
const titleCard = document.querySelector('#title-card');
const fade = document.querySelector('#fade');

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x91b8c8);

What Does This Setup Create?

  • The imports load Three.js and your film styling.
  • The element references connect JavaScript to the frame and overlays.
  • The scene provides the container for every rendered object.
  • Save src/main.js.
  • Check Chrome to confirm the page reloads without a JavaScript error screen.

Seeing an Import Error?

Confirm that the Three.js dependency remains listed in package.json. Check that the stylesheet path includes ./.

Help me diagnose an import error in my Three.js Vite project.

The camera defines the visible framing. The renderer converts the scene into pixels on a canvas.

  • Add the camera and renderer setup beneath the scene background line:
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  180,
);
camera.position.set(18, 12, -46);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
film.prepend(renderer.domElement);

How Does the View Reach the Page?

  • The perspective camera creates a film-like view of the 3D world.
  • Its position raises the viewpoint above the future terrain.
  • The renderer sizes its canvas to the browser window.
  • The canvas is inserted inside film.
  • Save src/main.js.
  • Inspect the page structure in Chrome to confirm a canvas now appears inside #film.

Canvas Missing from the Frame?

Check that document.querySelector('#film') matches the identifier in index.html. Confirm that film.prepend(renderer.domElement) appears after the renderer setup.

Help me diagnose why the Three.js canvas is missing from the film element.

A hemisphere light softly illuminates the whole landscape. The terrain begins as one flat rectangular mesh.

  • Add the light and terrain setup beneath the renderer setup:
const skyLight = new THREE.HemisphereLight(0xcfe7ff, 0x40512c, 2.2);
scene.add(skyLight);

const terrain = new THREE.Mesh(
  new THREE.PlaneGeometry(120, 150),
  new THREE.MeshStandardMaterial({
    color: 0x5e7f3c,
    roughness: 0.96,
    metalness: 0,
  }),
);
terrain.rotation.x = -Math.PI / 2;
terrain.position.z = 18;
scene.add(terrain);

What Makes the Countryside?

  • The hemisphere light supplies soft color from above and below.
  • The plane geometry creates the ground surface.
  • The standard material gives the terrain its muted green color.
  • The rotation lays the plane flat beneath the camera.
  • Save src/main.js.
  • Check Chrome to confirm the page reloads without a JavaScript error screen.

Seeing a Geometry Error?

Confirm that the terrain uses PlaneGeometry and MeshStandardMaterial. Check every closing parenthesis around the Mesh constructor.

Help me find a syntax or geometry error in my terrain mesh.

Render and inspect the flat draft

The render loop draws the current scene every frame. The resize handler keeps the camera projection aligned with the browser window.

  • Add the animation and resize functions beneath the terrain setup:
function animate() {
  renderer.render(scene, camera);
}

function resize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}

window.addEventListener('resize', resize);
renderer.setAnimationLoop(animate);

How Does the Scene Stay Live?

  • The animation loop repeatedly renders the scene from the camera.
  • The resize function updates the camera aspect ratio.
  • The resize listener keeps the canvas matched to the browser window.
  • Save src/main.js.
  • Return to the Terminal panel from earlier to confirm the Vite development server remains active.

Seeing a Blank Page or Error?

Confirm that renderer.setAnimationLoop(animate) appears after the animate function. Check that the active terminal has not returned to a normal command prompt.

Help me diagnose why my Three.js animation loop is not rendering the scene.

Before you reveal the scene, what shape and level of detail do you expect from one unmodified plane?

  • Refresh the local Vite page in Chrome.
  • Open Chrome DevTools with Cmd+Option+I on macOS.
  • Select the #fade element in the page structure.
  • Temporarily disable its opacity: 1 declaration in the styles panel.

Chrome shows a flat green plane against a blue background. There are no hills, path pieces, trees, or homes.

That lifeless result is intentional. It proves the renderer works while exposing the need for procedural depth and repeated scenery.

  • Resize the Chrome window to test the responsive camera.
  • Confirm the canvas continues filling the complete browser frame.

Cannot See the Green Plane?

Confirm that the temporary browser change disables the fade layer only. Check that the camera remains at 18, 12, -46 and points toward the scene origin.

Help me diagnose why my flat green Three.js terrain is not visible against the blue scene background.

✔️ Awesome, I've got everything!

Great. Your flat countryside proves the scene, camera, renderer, lighting, and render loop work together.

ⓧ I'd like to double check the full code

The complete src/main.js exceeds the code-block limit. Scroll up to confirm each chunk appears in this order.

  • Confirm the imports, element references, scene, and background appear first.
  • Confirm the camera and renderer setup appears next.
  • Confirm the hemisphere light and terrain mesh follow the renderer.
  • Confirm the animation loop and resize handler appear last.

Your first rendered shot works, but the empty plane cannot suggest a lived-in countryside. Next, you will grow rolling hills and layer scenery along a winding path.

Grow the Procedural Hills, Path, and Trees

Your flat countryside proves that the Three.js render loop works. Now you can turn that basic plane into a landscape with visible depth.Three.js

The flat draft cannot suggest a lived-in countryside. This step uses procedural geometry, repeated scenery, and calculated terrain heights to build the world without downloaded 3D assets.

In this step, get ready to:
  • Displace the terrain vertices to form rolling hills.
  • Build a winding path from repeated circles.
  • Generate trees and round-door homes across the landscape.
Shape the rolling terrain

A subdivided plane contains enough vertices to form hills. The terrainHeight() function calculates a height for each vertex using overlapping sine waves.

  • In src/main.js, locate the block that starts with const camera = new THREE.PerspectiveCamera(.
  • Select through the existing scene.add(terrain); line.
  • Replace the selected block with this procedural terrain code:
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 180);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
film.prepend(renderer.domElement);

scene.add(new THREE.HemisphereLight(0xcfe7ff, 0x40512c, 2.2));

function terrainHeight(x, z) {
  return Math.sin(x * 0.12) * 1.7 + Math.cos(z * 0.09) * 1.4 + Math.sin((x + z) * 0.055) * 1.1;
}

const terrainGeometry = new THREE.PlaneGeometry(120, 150, 70, 90);
terrainGeometry.rotateX(-Math.PI / 2);
const terrainPositions = terrainGeometry.getAttribute('position');
for (let i = 0; i < terrainPositions.count; i += 1) {
  const x = terrainPositions.getX(i);
  const z = terrainPositions.getZ(i);
  terrainPositions.setY(i, terrainHeight(x, z));
}
terrainPositions.needsUpdate = true;
terrainGeometry.computeVertexNormals();
const terrain = new THREE.Mesh(terrainGeometry, new THREE.MeshStandardMaterial({ color: 0x5e7f3c, roughness: 0.96, metalness: 0 }));
terrain.position.z = 18;
scene.add(terrain);

How does vertex displacement work?

  • The extra plane segments provide a grid of vertices that the code can move vertically.
  • The three waves inside terrainHeight() overlap to produce varied slopes.
  • The needsUpdate flag tells the renderer that the position data changed.
  • Recomputed vertex normals help light follow the new slopes.
  • Save src/main.js.
  • Switch back to Chrome to inspect the updated ground.

The green plane now rises into rolling hills. Its surface has varied slopes instead of one flat level.

Still Seeing Flat Ground?

Check that the plane uses 70 width segments plus 90 height segments. Confirm that terrainPositions.needsUpdate is set after the loop.

Ask for help checking the terrain replacement.

Lay the winding path

A path gives the landscape a visual route. Overlapping circles follow a sine curve while terrainHeight() keeps every piece just above the ground.

  • In src/main.js, find the scene.add(terrain); line.
  • Add this path code directly below that line:
const pathMaterial = new THREE.MeshStandardMaterial({ color: 0xcab98c, roughness: 1, metalness: 0 });
for (let z = -48; z <= 75; z += 1.45) {
  const x = Math.sin(z * 0.075) * 8;
  const pathPiece = new THREE.Mesh(new THREE.CircleGeometry(2.2, 12), pathMaterial);
  pathPiece.rotation.x = -Math.PI / 2;
  pathPiece.position.set(x, terrainHeight(x, z - 18) + 0.08, z);
  pathPiece.scale.x = 1.35;
  scene.add(pathPiece);
}

What Does the Path Loop Do?

  • The loop places path pieces from the foreground into the distance.
  • The sine calculation shifts each piece sideways to create the winding route.
  • The height calculation follows the hills while the small offset prevents flickering where surfaces overlap.
  • Save src/main.js.
  • Return to Chrome to inspect the new route.

A pale path now snakes across the green hills. Its overlapping pieces form one continuous route.

Path Missing or Flickering?

Confirm that each circle rotates by -Math.PI / 2. Check that its height includes the 0.08 offset.

Ask for help tracing the path positions.

Populate the countryside

Repeated low-poly objects make the landscape feel inhabited. A deterministic random function gives the trees varied positions while preserving the same composition after every refresh.

  • In src/main.js, locate the closing brace of the path loop.
  • Add the tree generator directly below the path loop:
function createTree(x, z, scale) {
  const tree = new THREE.Group();
  const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.34, 2.1, 7), new THREE.MeshStandardMaterial({ color: 0x5c3b22, roughness: 1 }));
  trunk.position.y = 1.05;
  const crown = new THREE.Mesh(new THREE.ConeGeometry(1.35, 3.4, 8), new THREE.MeshStandardMaterial({ color: 0x315c31, roughness: 0.92 }));
  crown.position.y = 3.1;
  tree.add(trunk, crown);
  tree.scale.setScalar(scale);
  tree.position.set(x, terrainHeight(x, z - 18), z);
  scene.add(tree);
}

let seed = 24681357;
function random() {
  seed = (seed * 16807) % 2147483647;
  return (seed - 1) / 2147483646;
}
for (let i = 0; i < 58; i += 1) {
  const z = -42 + random() * 112;
  const pathX = Math.sin(z * 0.075) * 8;
  const side = random() > 0.5 ? 1 : -1;
  const x = pathX + side * (7 + random() * 28);
  createTree(x, z, 0.7 + random() * 0.8);
}

How Is the Forest Generated?

  • Each tree groups a cylinder trunk with a cone crown.
  • The seeded random() helper produces the same sequence whenever the page loads.
  • The placement loop keeps trees away from the center of the path.
  • Each tree uses terrainHeight() to sit on its local slope.
  • Save src/main.js.
  • Switch back to Chrome to inspect the generated forest.

Low-poly trees now occupy both sides of the route. Their positions stay consistent when the page reloads.

Trees Floating or Blocking the Path?

Confirm that the tree position calls terrainHeight(x, z - 18). Check that pathX contributes to each tree's horizontal position.

Ask for help checking the tree placement calculations.

The homes reuse the same placement idea. Each one combines a half-sphere hill with a circular door.

  • Add the home generator directly below the tree placement loop:
function createHome(x, z, scale) {
  const home = new THREE.Group();
  const hill = new THREE.Mesh(new THREE.SphereGeometry(3.4, 18, 10, 0, Math.PI * 2, 0, Math.PI / 2), new THREE.MeshStandardMaterial({ color: 0x668544, roughness: 1 }));
  hill.scale.y = 0.65;
  const door = new THREE.Mesh(new THREE.CircleGeometry(0.82, 24), new THREE.MeshStandardMaterial({ color: 0x8e4f2b, roughness: 0.85 }));
  door.position.set(0, 0.65, -3.08);
  home.add(hill, door);
  home.scale.setScalar(scale);
  home.position.set(x, terrainHeight(x, z - 18), z);
  scene.add(home);
}
createHome(-12, 9, 1.05);
createHome(15, 35, 0.82);
createHome(-18, 57, 0.72);

How Are the Homes Built?

  • The half-sphere forms a grassy mound that blends into the landscape.
  • The circular mesh creates the round door on the front.
  • Three calls place homes at fixed positions with different scales.
  • At the bottom of src/main.js, replace the existing animation function through the animation-loop line with this compact runtime block:
function animate() { renderer.render(scene, camera); }
function resize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
window.addEventListener('resize', resize);
renderer.setAnimationLoop(animate);

What Does the Runtime Block Preserve?

The animation loop keeps rendering the populated scene. The resize handler keeps the camera plus renderer aligned with the browser window.

Before you refresh, what new shapes do you expect to appear beside the winding path? Write down your prediction.

  • Save src/main.js.
  • Refresh Chrome to watch the completed procedural countryside.

The ground now rises into rolling hills. A pale path winds between low-poly trees plus three round-door homes.

Homes Missing From the Hills?

Confirm that all three createHome() calls sit after the function. Check that the door position uses a negative z value so it faces the camera.

Ask for help comparing the final scene objects.

✔️ Awesome, I've got everything!

Great. Save src/main.js so the procedural world is ready for its cinematic treatment.

ⓧ I'd like to double check the full code

The completed src/main.js exceeds the code-block limit. Scroll up to confirm these sections appear in order:

  • The existing imports plus DOM selections.
  • The scene, camera, renderer, and hemisphere light setup.
  • The terrainHeight() function plus displaced terrain geometry.
  • The pathMaterial declaration plus path loop.
  • The createTree(), random(), and tree placement blocks.
  • The createHome() function plus three home calls.
  • The animation loop plus resize handler.

Your procedural countryside now has depth, landmarks, and a route through the scene. Next, you will add warm atmosphere plus automatic camera choreography.

Turn the World into a Cinematic Shot

Your procedural countryside now has hills, a path, trees, and homes. The finished world gives you everything needed to compose a shot with Three.js.

Geometry alone still feels like a 3D exercise. Warm atmosphere, camera choreography, and synchronized overlays turn the world into a film sequence.

In this step, get ready to:
  • Create a warm sunrise atmosphere with fog, directional light, and filmic tone mapping.
  • Guide the camera along the winding path with a smooth timeline.
  • Synchronize the title and fade overlay with the camera movement.
Add warm light and haze

Fog softens distant objects to create depth. Tone mapping shapes bright colors so the scene feels closer to a photographed sunrise.

  • In the src/main.js file from earlier, find the line that sets scene.background.
  • Add the fog directly below that line by copying this code:
scene.fog = new THREE.Fog(0xb8c6ad, 28, 105);

What does the fog do?

The fog blends distant scenery into a muted green color. Its near and far distances preserve foreground detail while softening the horizon.

  • Save src/main.js.
  • Switch back to Google Chrome.

The distant hills and scenery now dissolve into a soft haze.

Is the horizon still sharp?

Confirm that the fog line sits directly after the background color. Check that the first distance is 28 and the second distance is 105.

Ask for help checking the fog setup in src/main.js.

  • Find the line that sets the renderer size in src/main.js.
  • Add filmic tone mapping directly below it by copying this code:
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;

What does tone mapping do?

Filmic tone mapping controls how the renderer displays bright light and color. The exposure value lifts the sunrise palette without washing out the landscape.

  • Save src/main.js.
  • Switch back to Chrome.

The countryside now has a brighter filmic palette with richer highlights.

  • Find the existing hemisphere light in src/main.js.
  • Add the sunrise light below it by copying this code:
const sun = new THREE.DirectionalLight(0xffd08a, 4.5);
sun.position.set(-18, 28, -12);
scene.add(sun);

What does the sunrise light do?

  • The directional light casts a warm color across every lit material.
  • Its position places the light above the landscape and off to one side.
  • Save src/main.js.
  • Switch back to Chrome.

Warm light now reaches across the misty green hills.

Does the scene still look cool?

Check that the sun is added to scene. Confirm that its color begins with 0xffd08a.

Ask for help checking the lighting setup in src/main.js.

Choreograph the camera glide

A camera route needs start and end compositions for both position and focus. A normalized timeline converts each loop into progress from zero to one.

  • In src/main.js, find the three calls to createHome().
  • Add the camera route directly below those calls by copying this code:
const cameraStart = new THREE.Vector3(18, 12, -46);
const cameraEnd = new THREE.Vector3(-7, 7.2, 35);
const lookStart = new THREE.Vector3(0, 2, -10);
const lookEnd = new THREE.Vector3(5, 2.5, 58);
const lookTarget = new THREE.Vector3();
const duration = 16;

function smoothstep(value) {
  return value * value * (3 - 2 * value);
}

How is the route defined?

  • The camera vectors define where the shot begins and ends.
  • The look vectors define where the camera points during the glide.
  • The duration sets each loop to sixteen seconds.
  • The smoothing function eases the movement near both ends of the route.
  • Find the current animate() function:
function animate() { renderer.render(scene, camera); }

What does the current loop do?

The current loop only renders the scene. It has no changing camera values, so the framing stays fixed.

  • Replace the current function with this camera-driven version:
function animate(time) {
  const seconds = time * 0.001;
  const progress = (seconds % duration) / duration;
  const travel = smoothstep(progress);

  camera.position.lerpVectors(cameraStart, cameraEnd, travel);
  lookTarget.lerpVectors(lookStart, lookEnd, travel);
  camera.lookAt(lookTarget);

  renderer.render(scene, camera);
}

How does the camera move?

  • The animation time becomes seconds before being converted into repeating progress.
  • The smoothed progress controls interpolation between the camera vectors.
  • The look target moves separately so the camera keeps facing down the path.
  • Save src/main.js.
  • Switch back to Chrome.

The camera now glides above the road while turning toward the distant countryside.

Is the camera still fixed?

Confirm that animate accepts time. Check that the camera interpolation runs before renderer.render().

Ask for help tracing the camera timeline in src/main.js.

Synchronize the title and fade

The camera already uses one normalized timeline. Reusing that progress for the overlays keeps every visual beat synchronized.

  • In src/main.js, find the camera-driven animate(time) function:
function animate(time) {
  const seconds = time * 0.001;
  const progress = (seconds % duration) / duration;
  const travel = smoothstep(progress);

  camera.position.lerpVectors(cameraStart, cameraEnd, travel);
  lookTarget.lerpVectors(lookStart, lookEnd, travel);
  camera.lookAt(lookTarget);

  renderer.render(scene, camera);
}

Where do the overlays belong?

The overlay calculations belong after the camera movement. The renderer then draws the scene with every value updated for the same frame.

  • Replace that function with the synchronized version below:
function animate(time) {
  const seconds = time * 0.001;
  const progress = (seconds % duration) / duration;
  const travel = smoothstep(progress);

  camera.position.lerpVectors(cameraStart, cameraEnd, travel);
  lookTarget.lerpVectors(lookStart, lookEnd, travel);
  camera.lookAt(lookTarget);

  const titleIn = Math.min(progress / 0.09, 1);
  const titleOut = 1 - Math.max((progress - 0.28) / 0.12, 0);
  titleCard.style.opacity = String(Math.max(0, Math.min(titleIn, titleOut)));

  const openingFade = 1 - Math.min(progress / 0.07, 1);
  const closingFade = Math.max((progress - 0.9) / 0.1, 0);
  fade.style.opacity = String(Math.max(openingFade, closingFade));

  renderer.render(scene, camera);
}

How do the overlays stay synchronized?

  • The title opacity rises near the start of each loop.
  • The title opacity falls before the camera reaches the end.
  • The fade clears at the opening before returning during the final tenth of the timeline.

✔️ Awesome, I've got everything!

Great. Save src/main.js before watching the complete sequence.

ⓧ I'd like to double check the full code

The complete src/main.js file exceeds thirty lines. Scroll up to confirm that these additions appear in order.

  • Fog appears directly after the scene background.
  • Tone mapping appears directly after the renderer size.
  • The directional sun appears directly after the hemisphere light.
  • The camera vectors and smoothstep() appear before animate(time).
  • The final animation function controls the camera, title, fade, and renderer in that order.

Before you watch, predict how the title, camera, and closing fade will divide one sixteen-second loop.

  • Save src/main.js.
  • Switch back to Chrome.
  • Reload the page.
  • Watch one complete loop.

The title fades in before disappearing. The camera glides along the road through warm haze before a dark overlay closes the shot and the sequence repeats.

Are the overlays missing or stuck?

Confirm that titleCard.style.opacity and fade.style.opacity are inside animate(time). Check that both calculations use progress.

Ask for help checking the synchronized timeline in src/main.js.

Secret mission

Add a Second Camera Beat

Turn the continuous glide into two connected camera compositions. The second beat reveals a different part of the countryside before the final fade.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • A running Vite development server.
  • The local a-long-expected-opening folder containing your procedural film project.

Keep everything running

No action needed. Choose this if you are still actively building or want to watch the film again soon.

  • Leave the development server running while you continue working.
  • Keep the a-long-expected-opening folder with your finished scene files.

Pause - I'll come back to this later

Shut down the running process to free up memory. Your project files remain ready for your next session.

  • Return to the terminal panel from earlier.
  • Press Control+C to stop the development server.
  • Close the project tab in Google Chrome.
  • Keep the a-long-expected-opening folder for your next session.

The terminal returns to its command prompt when the development server has stopped.

Delete - I don't want to use this again

Stop the running process before removing the local project. This completely removes your scene files from their current location.

  • Return to the terminal panel from earlier.
  • Press Control+C to stop the development server.
  • Close the project window in Visual Studio Code.
  • Press Cmd+Space to open macOS search.
  • Search for Finder.
  • Locate the a-long-expected-opening folder from this project.
  • Move the a-long-expected-opening folder to the Trash.
  • Confirm the folder no longer appears in its previous location.

The development server is stopped when the terminal returns to its command prompt. The project is removed when its folder disappears from the previous location.

Nice Work!

Nice Work!

You built a browser-based film scene with Three.js. Your countryside now moves through two connected camera compositions before the final fade.

You've learned how to:

  • Build a procedural countryside with rolling terrain generated through vertex displacement.
  • Compose a storybook landscape with a winding path, repeated low-poly trees, and round-door homes.
  • Create a cinematic sequence with warm lighting, fog, filmic tone mapping, opening titles, and a synchronized fade.
  • Secret Mission: Extend the timeline with a second camera beat that reveals a different composition.

Ready to quiz yourself?