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.
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.
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.
Every Three.js simulation needs three things:
<canvas> using WebGL.
Replace the comment // Your code goes here with:
// ── 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.
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.
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
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.
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.
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.
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();
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.
Browse the simulations built with exactly this approach — bigger particle counts, physics engines, and GLSL shaders.
Boids Simulation →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.
Write, run and tweak Three.js code directly in your browser — no setup required.
Open Playground → View Simulation ↗