Tutorial Beginner ⏱ 30 min · Updated June 2026

Your First 3D Simulation in 30 Minutes

Renderer, scene, camera, animation loop, particles — all from a blank HTML file. No npm. No build tools. Just a CDN link to Three.js r160 and a text editor.

Your progress 0 / 5 steps
1
Step one

HTML Skeleton & Three.js CDN

Create a new file called index.html anywhere on your computer. No server required — the browser can open it directly from the filesystem.

Paste this boilerplate. The only external dependency is Three.js served from the jsDelivr CDN as an ES module.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>My First Simulation</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { background: #000; overflow: hidden; }
    canvas { display: block; }
  </style>
</head>
<body>
  <script type="module">
    import * as THREE from
      'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.min.js';

    // Your code goes here
    console.log('Three.js loaded:', THREE.REVISION);
  </script>
</body>
</html>

Open the file in a modern browser (Chrome, Firefox or Edge). Open the DevTools console (F12) — you should see "Three.js loaded: 160". That confirms the import works.

ES Modules & file://

Modern browsers allow ES module imports from CDN even when you open the file with file://. If you get a CORS error, start a tiny local server: python -m http.server 8080 or use the VS Code Live Server extension.

Checkpoint: Console shows "Three.js loaded: 160". The page is black and empty.
2
Step two

Scene, Camera and Renderer

Every Three.js simulation needs three things:

Replace the comment // Your code goes here with:

index.html — inside <script type="module">
// ── Scene ──────────────────────────────────────────────────
const scene = new THREE.Scene();
scene.background = new THREE.Color('#050813'); // dark blue-black

// ── Camera ─────────────────────────────────────────────────
// PerspectiveCamera(fov, aspect, near, far)
const camera = new THREE.PerspectiveCamera(
  60,                                   // field of view in degrees
  window.innerWidth / window.innerHeight, // aspect ratio
  0.1,                                  // near clipping plane
  1000                                  // far clipping plane
);
camera.position.set(0, 0, 5); // pull the camera back along Z

// ── Renderer ────────────────────────────────────────────────
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // cap at 2× for performance
document.body.appendChild(renderer.domElement); // add <canvas> to the page

// ── Resize handler ──────────────────────────────────────────
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// Render one frame to confirm the camera sees the dark scene
renderer.render(scene, camera);

Reload the browser. The page is now dark blue — rendered by WebGL. No geometry yet, but the pipeline is working.

What you built

A WebGL 2 context, a perspective camera sitting 5 units back on the Z axis, and a renderer that fills the viewport. The black frame is the empty scene.

3
Step three

Your First Geometry

In Three.js, visible objects are called Meshes. A Mesh combines:

Add a glowing sphere and a simple point light after the resize handler:

// ── Geometry ────────────────────────────────────────────────
const geometry = new THREE.SphereGeometry(
  1,    // radius
  32,   // widthSegments (more = smoother)
  16    // heightSegments
);

const material = new THREE.MeshStandardMaterial({
  color:     0x4f88f8,   // hex colour (blue)
  roughness: 0.3,
  metalness: 0.6,
  emissive:  0x1a2a6c,   // slight self-glow
});

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// ── Lighting ────────────────────────────────────────────────
// MeshStandardMaterial needs lights to be visible
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);

const pointLight = new THREE.PointLight(0x60a5fa, 80, 20);
pointLight.position.set(3, 4, 3);
scene.add(pointLight);

renderer.render(scene, camera); // update the single static frame
Why MeshStandardMaterial?

It uses Physically-Based Rendering (PBR) — the same shading model as game engines and 3D software like Blender. For a quick preview with no lighting, use MeshBasicMaterial instead (it ignores lights).

Reload. You should see a shaded blue sphere in the centre of the screen. Next we'll make it move.

Checkpoint: A shaded, slightly glowing blue sphere appears in the centre of a dark blue viewport.
4
Step four

Animation Loop

Static renders are only the beginning. To animate, we replace the single renderer.render() call with a loop driven by requestAnimationFrame.

Why requestAnimationFrame? It synchronises your code with the display's refresh rate (typically 60 Hz), pauses automatically when the tab is hidden (saving battery), and avoids screen tearing.

Remove the last renderer.render(scene, camera); line and add:

// ── Clock (for dt-based animation) ──────────────────────────
const clock = new THREE.Clock();

// ── Animation loop ──────────────────────────────────────────
function animate() {
  requestAnimationFrame(animate); // schedule next frame

  const t = clock.getElapsedTime(); // seconds since start

  // Rotate the sphere
  mesh.rotation.y = t * 0.6;
  mesh.rotation.x = t * 0.2;

  // Gentle bob up and down
  mesh.position.y = Math.sin(t) * 0.3;

  renderer.render(scene, camera);
}

animate(); // kick off the loop

The sphere now rotates continuously and bobs up and down. Notice how clock.getElapsedTime() gives smooth, frame-rate-independent motion.

Frame-rate independence

Using elapsed time (t) rather than a raw counter ensures the animation speed stays the same at 30 fps, 60 fps, or 144 fps. For physics you'd use the delta (clock.getDelta()) to advance the simulation by the exact number of milliseconds since the last frame.

Checkpoint: The sphere rotates and bobs smoothly at ~60 fps. No stutter, no blinking.
5
Step five

1 000 Moving Particles

A single sphere is a toy example. Real simulations need thousands of objects. Creating a separate Mesh per particle would kill the framerate because each mesh means a separate draw call to the GPU. The solution is Points — one draw call for all particles.

Replace everything from geometry onwards with:

const COUNT = 1000;
const positions  = new Float32Array(COUNT * 3); // [x,y,z, x,y,z, …]
const velocities = new Float32Array(COUNT * 3); // per-particle velocity

// Seed random positions in a cube −5 → +5
for (let i = 0; i < COUNT; i++) {
  positions[i * 3    ] = (Math.random() - 0.5) * 10;
  positions[i * 3 + 1] = (Math.random() - 0.5) * 10;
  positions[i * 3 + 2] = (Math.random() - 0.5) * 10;

  velocities[i * 3    ] = (Math.random() - 0.5) * 0.02;
  velocities[i * 3 + 1] = (Math.random() - 0.5) * 0.02;
  velocities[i * 3 + 2] = (Math.random() - 0.5) * 0.02;
}

const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
  'position',
  new THREE.BufferAttribute(positions, 3) // 3 floats per vertex
);

const material = new THREE.PointsMaterial({
  size:        0.06,
  color:       0x818cf8,   // indigo
  transparent: true,
  opacity:     0.85,
  sizeAttenuation: true     // size scales with depth
});

const points = new THREE.Points(geometry, material);
scene.add(points);

// ── Lighting (optional for Points — they ignore it) ──────────
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

// ── Clock ───────────────────────────────────────────────────
const clock = new THREE.Clock();

// ── Animation loop ──────────────────────────────────────────
function animate() {
  requestAnimationFrame(animate);

  const posArr = geometry.attributes.position.array;

  for (let i = 0; i < COUNT; i++) {
    const xi = i * 3;

    // Integrate position
    posArr[xi    ] += velocities[xi    ];
    posArr[xi + 1] += velocities[xi + 1];
    posArr[xi + 2] += velocities[xi + 2];

    // Bounce off the cube walls
    for (let axis = 0; axis < 3; axis++) {
      if (Math.abs(posArr[xi + axis]) > 5) {
        velocities[xi + axis] *= -1; // reverse direction
      }
    }
  }

  // Tell Three.js positions changed — IMPORTANT!
  geometry.attributes.position.needsUpdate = true;

  // Slowly rotate the whole cloud
  points.rotation.y += 0.001;

  renderer.render(scene, camera);
}

animate();
needsUpdate = true

After modifying the typed array directly, you must set geometry.attributes.position.needsUpdate = true to tell the renderer to re-upload the buffer to the GPU. Forgetting this gives you frozen or flickering particles.

You now have 1 000 indigo particles bouncing inside an invisible cube, all in a single draw call. Open DevTools → Performance to confirm the framerate stays at 60 fps.

🎉
Done! 1 000 bouncing particles, one draw call, ~60 fps. You just built your first simulation!

✨ See it in action

Browse the simulations built with exactly this approach — bigger particle counts, physics engines, and GLSL shaders.

Boids Simulation →

Next Steps

You have a working Three.js simulation. Here is the natural learning path:

Or jump straight to reading the SPH Fluids article for the maths behind particle simulations.

🛠

Experiment in Playground

Write, run and tweak Three.js code directly in your browser — no setup required.

Open Playground → View Simulation ↗