three.js scene setup
Builds the base every three.js game needs: module loading, the scene, camera and renderer,
one render loop, resize handling, and a resolution budget. Patterns target r187. Read
the installed three version first; addons, defaults and deprecations move every release.
When to use
- Bootstrapping a three.js scene, fixing a black or blank canvas, making the canvas
responsive, setting up the loop, adding
OrbitControls, or choosing betweenWebGLRendererandWebGPURenderer. package.jsondepends onthree, or code doesimport * as THREE from 'three'.- Not for models and animation (
threejs-gltf-loading), surface look, lights and shadows (threejs-materials-lighting), or React Three Fiber (@react-three/fiber) components, whose<Canvas>owns the renderer and loop. For 2D, usepixijs-renderingor Phaser.
Workflow
- Read
threefrompackage.jsonor the import map. An existing project keeps its release unless the user asks to upgrade. - Resolve
threeandthree/addons/with a bundler or an import map; addons live underthree/addons/.... - Create the scene, camera and renderer, with the camera moved back from the origin and the pixel ratio capped (Pattern 2).
- Prove the pipeline with an unlit mesh first (
MeshNormalMaterial). Only then switch to lit materials and add lights. - Drive everything from
renderer.setAnimationLoopwith aTimerand a clamped delta. Keep game state in plain objects and copy it onto meshes each frame. Meshes then stay a view, and state can be saved and tested without a GPU. - Handle resize (aspect, projection matrix, renderer size) and add
OrbitControlswhile developing. - Verify with Verify.
Patterns
1. Import map (no bundler)
<canvas id="c"></canvas>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
}
}
</script>
<script type="module" src="./main.js"></script>
With Vite or webpack, npm i three and the same imports resolve without a map.
2. Scene, camera and renderer
import * as THREE from 'three';
const canvas = document.querySelector('#c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); // starting budget; profile on target hardware
renderer.setSize(window.innerWidth, window.innerHeight);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x101018);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(3, 2, 5);
camera.lookAt(0, 0, 0);
const cube = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial());
scene.add(cube);
Object.assign(window, { renderer, scene }); // for the smoke check and console debugging
3. Render loop with Timer
const timer = new THREE.Timer(); // replaces the deprecated THREE.Clock (r183+)
timer.connect(document); // optional: no huge delta after a hidden tab
renderer.setAnimationLoop(() => {
timer.update(); // use performance.now(), like visibility reset
const dt = Math.min(Math.max(timer.getDelta(), 0), 0.1); // seconds; bound stalls and negative deltas
cube.rotation.x += dt; // frame-rate independent
cube.rotation.y += dt * 0.7;
renderer.render(scene, camera);
});
// renderer.setAnimationLoop(null); // stop the loop
// timer.dispose(); // remove visibility listener when tearing down
Call update() with no argument when the timer is connected. Visibility reset samples
performance.now(), so passing the loop's timestamp can produce a negative delta right after
a hidden tab returns. The clamp also protects a fixed-step accumulator; the 0.1 s upper bound
drops time after a stall on purpose, so tune it to the game's catch-up policy.
4. Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix(); // required after changing aspect
renderer.setSize(window.innerWidth, window.innerHeight);
});
5. OrbitControls
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// In the loop, before render: controls.update(); (required every frame with damping)
6. WebGPURenderer, only when needed
Stay on WebGLRenderer by default. Switch when the project needs TSL node materials or
compute shaders; WebGPURenderer falls back to a WebGL 2 backend where WebGPU is missing.
// Official WebGPU examples map both "three" and "three/webgpu" to
// build/three.webgpu.js, plus "three/tsl" to build/three.tsl.js.
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); // starting budget; profile on target hardware
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
await renderer.init(); // required before direct render()/compute()
renderer.setAnimationLoop(() => renderer.render(scene, camera));
Scenes, cameras, built-in Mesh*Materials and most addons carry over. Custom GLSL
(ShaderMaterial, onBeforeCompile) does not.
Verify
npm run build(ornpx tsc --noEmitwith@types/threematching thethreeversion) passes.- Smoke-test in a headless browser. With the dev server running and
window.rendererexposed (Pattern 2), runnode smoke.mjs <url>afternpm i -D playwrightandnpx playwright install chromium. Without browser automation, ask the user to open the page, then report the console output and whether the cube is visible and turning.
// smoke.mjs: fails on page errors; checks that frames are drawn and survive a context loss.
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.goto(process.argv[2]);
await page.waitForFunction(() => window.renderer?.info.render.calls > 0, null, { timeout: 15000 });
const state = await page.evaluate(() => ({
drawCalls: window.renderer.info.render.calls,
triangles: window.renderer.info.render.triangles,
geometries: window.renderer.info.memory.geometries,
canvas: [window.renderer.domElement.width, window.renderer.domElement.height],
}));
await page.screenshot({ path: 'smoke.png' });
await page.evaluate(async () => { // WebGLRenderer only: lose and restore the context
if (!window.renderer.forceContextLoss) return;
window.renderer.forceContextLoss();
await new Promise((r) => setTimeout(r, 200));
window.renderer.forceContextRestore();
await new Promise((r) => setTimeout(r, 500));
});
await page.screenshot({ path: 'smoke-restored.png' });
await browser.close();
console.log(JSON.stringify({ ...state, errors }, null, 2));
process.exit(errors.length ? 1 : 0);
Expect drawCalls above 0, a canvas size other than 0 × 0, and no errors. Open both
screenshots: the second must still show the scene, or something holds GPU objects that the
restore did not rebuild.
Gotchas
Failed to resolve module specifier "three"→ no import map or bundler. Map both"three"and"three/addons/"; the addons entry must end with/.- Black canvas, no errors → the camera sits inside or behind the object, or a lit
material has no light. Move the camera back and test with
MeshNormalMaterial. - Stretched view after resize → the renderer was resized but
camera.aspectandupdateProjectionMatrix()were not updated. - Slow or hot on HiDPI and phones → a pixel ratio of 2 draws four times the pixels of 1, so a cap of 2 does nothing on a 2× screen. Start at 1.25 to 1.5 and offer a resolution setting; adaptive resolution is in the reference.
- Simulation freezes after the tab returns → in r186 and r187 a loop timestamp passed to
timer.update(time)can precede the visibility reset and give a negative delta. Callupdate()with no argument and clamp as in Pattern 3. THREE.Clock: This module has been deprecated→ since r183; useTHREE.Timer.- Old tutorials use
<script src="three.min.js">orexamples/js/...→examples/jswas removed in r148 andbuild/three.min.jsin r161. Use modules and an import map. - OrbitControls feel dead → with
enableDampingon,controls.update()must run every frame. - Canvas goes black after a GPU reset or many tabs → the WebGL context was lost.
WebGLRendererhandleswebglcontextlostand rebuilds its own state on restore; code that keeps raw GL objects or skips the loop while lost does not recover. Test withforceContextLoss()/forceContextRestore()as in Verify. .render() called before the backend is initialized(WebGPURenderer) →await renderer.init()before a directrender()/compute(), or render only fromsetAnimationLoop, which initialises first.Material "ShaderMaterial" is not compatible, oronBeforeCompileis ignored (WebGPURenderer) → it renders node materials only. Port the effect to TSL or stay onWebGLRenderer.
References
- Read
references/scene-graph.mdfor coordinate conventions, parenting withGroup,OrthographicCamerafor 2.5D, disposing geometries, materials and textures between levels, stopping the loop, render-on-demand, and adaptive resolution.
Related skills
threejs-materials-lighting: lights, shadows, PBR and environment maps.threejs-gltf-loading: loading models and playing their animations.camera-systems: follow, orbit and first-person camera design.fps-shooter: a 3D genre that builds on these skills.
Version notes
Last verified: 2026-11-11 against three r187 (npm [email protected], published 2026-10-30;
@types/[email protected]).
Timer.update()is unchanged between r186 and r187, so the negative-delta note holds.- The r187 WebGPU examples still map
threeandthree/webgputobuild/three.webgpu.js. - Sources: r187 release notes, Migration Guide, r187 Timer.js, r187 responsive rendering manual, WebGLRenderer.js context handling.