---
name: "threejs_scene_basics"
description: "Build a minimal interactive 3D scene with Three.js: renderer, scene, camera, lights, geometry with materials, OrbitControls, window-resize handling, and an animation loop. Trigger when someone asks to create a 3D scene in the browser, prototype a WebGL view, add orbit controls to a page, or get started with Three.js."
metadata: { "includeInPrompt": true }
---

# threejs-scene-basics

## Purpose

Produce a single self-contained HTML page that renders a lit, interactive 3D scene in the browser using Three.js: a perspective camera, a key light plus ambient fill, two or more meshes with standard materials, OrbitControls with damping, a resize handler, and a requestAnimationFrame loop.

Not this skill: headless-blender is "Render with Blender on a headless VM: background mode, Cycles CPU, robust frame output, and common gotchas." That skill renders stills with Blender. This one builds and views a live 3D scene in the browser. Pick this skill for client-side WebGL; pick that one for Blender output.

## Workflow

1. Scaffold one HTML file. Use a `type="importmap"` script that pins both `three` and `three/addons/` to the same version (verified working: 0.170.0 on jsDelivr).
2. Create the renderer: `new THREE.WebGLRenderer({ antialias: true })`, size it to the window, clamp pixel ratio to 2, and append its canvas to the page.
3. Create the scene and set a background color so an empty frame is still diagnosable.
4. Create a `PerspectiveCamera` (fov around 60, near 0.1, far 100) and place it a few units back and above the origin.
5. Add lights: a `DirectionalLight` as the key (position it up and to the side, intensity around 2) plus an `AmbientLight` for fill (intensity around 0.5). Standard materials render black without light, so this step is not optional.
6. Add geometry: build each mesh from a geometry (`BoxGeometry`, `SphereGeometry`, `PlaneGeometry`) paired with a `MeshStandardMaterial` (set color, roughness, metalness). Position meshes above the ground plane.
7. Add `OrbitControls` from `three/addons/controls/OrbitControls.js`, set its target near the meshes, enable damping, and call `controls.update()` once.
8. Wire the resize handler: on `window` resize, update the camera aspect ratio, call `camera.updateProjectionMatrix()`, and call `renderer.setSize()` with the new window size.
9. Start the animation loop: a `tick()` function that calls `requestAnimationFrame(tick)`, advances any motion, calls `controls.update()` (required for damping), and calls `renderer.render(scene, camera)`.
10. Verify in a real browser: open the page, confirm the meshes are visible and lit, drag to orbit, and check the console is free of errors before calling it done.

## Output Contract

- One file, `scene.html`, with no build step and no dependencies beyond the pinned CDN importmap.
- Opening it in a browser shows a lit scene with at least two meshes on a ground plane, on a non-black background.
- Dragging orbits the camera, scroll zooms, right-drag pans (OrbitControls defaults).
- Resizing the window keeps the scene correctly proportioned with no stretching.
- The browser console shows no errors.

## Operating Rules

1. Pin the Three.js version in the importmap (verified: `three@0.170.0`). Do not float `latest`; unpinned versions break the `three/addons/` path without warning.
2. Import `three` and `three/addons/` from the same pinned version. Mixing versions is the most common source of silent breakage.
3. Keep the module script as `type="module"`. OrbitControls only resolves through the importmap inside a module script.
4. Give the scene at least a directional key light and an ambient fill before judging how materials look. A black render usually means missing lights, not broken geometry.
5. Clamp `setPixelRatio` to 2. Higher values burn GPU on hidpi screens for no visible gain in a basic scene.
6. When damping is enabled, `controls.update()` must run every frame inside the loop, not just once at setup.
7. The resize handler must update all three: camera aspect, projection matrix, renderer size. Skipping the projection matrix update is what causes the stretch.
8. Test in a real browser and read the console. A page that "looks empty" with a red console error is a failed build, not a styling choice.
9. Scope is client-side scene building only: renderer, camera, lights, meshes, controls. Shaders, loaders, post-processing, and physics belong in follow-up work, not in this baseline.
10. For still renders from Blender instead of a live browser scene, use the headless-blender skill, not this one.

## Starter code

Save as `scene.html`, then open in a browser. Rendered and verified with three@0.170.0 (REVISION 170) on 2026-10-08: green cube and orange sphere on a ground plane, lit by a directional key plus ambient fill, orbitable with damping.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>threejs-scene-basics test scene</title>
<style>
  html, body { margin: 0; height: 100%; overflow: hidden; background: #111418; }
  canvas { display: block; }
</style>
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
  }
}
</script>
</head>
<body>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// 1. Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

// 2. Scene + fog-free dark background
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111418);

// 3. Camera
const camera = new THREE.PerspectiveCamera(
  60, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(3.5, 2.5, 4.5);

// 4. Lights: key directional + soft ambient
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(4, 6, 3);
scene.add(key);
scene.add(new THREE.AmbientLight(0x8899bb, 0.55));

// 5. Geometry + materials
const cube = new THREE.Mesh(
  new THREE.BoxGeometry(1.4, 1.4, 1.4),
  new THREE.MeshStandardMaterial({ color: 0x2ecc71, roughness: 0.35, metalness: 0.1 })
);
cube.position.set(-1.1, 0.9, 0);
scene.add(cube);

const sphere = new THREE.Mesh(
  new THREE.SphereGeometry(0.85, 48, 32),
  new THREE.MeshStandardMaterial({ color: 0xe67e22, roughness: 0.6, metalness: 0.0 })
);
sphere.position.set(1.2, 0.85, 0);
scene.add(sphere);

// ground plane so lighting reads clearly
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(30, 30),
  new THREE.MeshStandardMaterial({ color: 0x1d232b, roughness: 0.9 })
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);

// 6. OrbitControls
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.8, 0);
controls.enableDamping = true;
controls.update();

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

// 8. Animation loop
function tick() {
  requestAnimationFrame(tick);
  cube.rotation.y += 0.008;
  sphere.rotation.x += 0.005;
  controls.update();
  renderer.render(scene, camera);
}
tick();
</script>
</body>
</html>
```
