Three.js ยท Astronomy
โฑ ~60 min๐ŸŸก IntermediateThree.js r160 ยท WebGL

Build a 3D Solar System in Three.js

Textured planets, Keplerian orbits, an emissive Sun, rings, and UnrealBloom post-processing โ€” all wired together in a single HTML file with Three.js r160.

1

Scene, Renderer and Camera

Start with the standard Three.js boilerplate โ€” a WebGLRenderer that fills the window, a PerspectiveCamera, and a black background to simulate space.

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/controls/OrbitControls.js'; import { EffectComposer } from 'https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/postprocessing/EffectComposer.js'; import { RenderPass } from 'https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/postprocessing/UnrealBloomPass.js'; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(devicePixelRatio); renderer.setSize(innerWidth, innerHeight); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.9; document.body.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x000008); // Star field โ€” 10 000 random points const starGeo = new THREE.BufferGeometry(); const starPos = new Float32Array(30_000); for (let i = 0; i < 30_000; i++) starPos[i] = (Math.random() - 0.5) * 2000; starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3)); scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.35 }))); const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 5000); camera.position.set(0, 80, 200); window.addEventListener('resize', () => { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); });
Set renderer.toneMapping = THREE.ACESFilmicToneMapping โ€” it boosts mid-tone contrast and is what most games use. Without it bloom looks washed out.
2

Sun with Emissive Glow

The Sun emits its own light. Use a PointLight at the origin and a MeshStandardMaterial with a high emissiveIntensity so it stays bright regardless of other lights.

const loader = new THREE.TextureLoader(); // Sun const sunTex = loader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js@r160/examples/textures/planets/sun.jpg'); const sunMat = new THREE.MeshStandardMaterial({ map: sunTex, emissiveMap: sunTex, emissive: new THREE.Color(1.0, 0.7, 0.1), emissiveIntensity: 2.5, }); const sun = new THREE.Mesh(new THREE.SphereGeometry(16, 32, 32), sunMat); scene.add(sun); // Light from the Sun const sunLight = new THREE.PointLight(0xfff4cc, 4, 2000, 1.5); scene.add(sunLight); // Ambient for fill scene.add(new THREE.AmbientLight(0x111122, 1.5));
The map and emissiveMap share the same texture. The emissive channel makes the surface glow independently of the point light โ€” important when the camera is far from the light source.
3

Textured Planets and Saturn's Ring

Each planet is a SphereGeometry, grouped with its orbital pivot so rotation happens around the Sun origin.

// Planet data: [ name, radius(scene), texturePath, axialTilt, ringInner?, ringOuter? ] const PLANETS = [ { name:'Mercury', r:2.2, sma:28, period:0.24, tilt:0.034, tex:'/assets/textures/mercury.jpg' }, { name:'Venus', r:5.4, sma:52, period:0.62, tilt:177.4, tex:'/assets/textures/venus.jpg' }, { name:'Earth', r:5.7, sma:80, period:1.00, tilt:23.4, tex:'/assets/textures/earth.jpg' }, { name:'Mars', r:3.0, sma:112, period:1.88, tilt:25.2, tex:'/assets/textures/mars.jpg' }, { name:'Jupiter', r:11, sma:175, period:11.86, tilt:3.1, tex:'/assets/textures/jupiter.jpg' }, { name:'Saturn', r:9.5, sma:245, period:29.46, tilt:26.7, tex:'/assets/textures/saturn.jpg', ringInner:11.5, ringOuter:20 }, { name:'Uranus', r:7, sma:315, period:84.01, tilt:97.8, tex:'/assets/textures/uranus.jpg' }, { name:'Neptune', r:6.8, sma:375, period:164.8, tilt:28.3, tex:'/assets/textures/neptune.jpg' }, ]; const planets = PLANETS.map(p => { const pivot = new THREE.Object3D(); scene.add(pivot); const mesh = new THREE.Mesh( new THREE.SphereGeometry(p.r, 32, 32), new THREE.MeshStandardMaterial({ map: loader.load(p.tex), roughness:0.9 }) ); mesh.rotation.z = THREE.MathUtils.degToRad(p.tilt); mesh.position.x = p.sma; pivot.add(mesh); if (p.ringInner) { const ringGeo = new THREE.RingGeometry(p.ringInner, p.ringOuter, 64); // RingGeometry UVs need fixing for texture โ€” remap v to radial distance const pos = ringGeo.attributes.position; const uv = ringGeo.attributes.uv; for (let i = 0; i < pos.count; i++) { const dist = Math.sqrt(pos.getX(i)**2 + pos.getZ(i)**2); uv.setXY(i, (dist - p.ringInner)/(p.ringOuter - p.ringInner), 0); } const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ map: loader.load('/assets/textures/saturn-rings.png'), side: THREE.DoubleSide, transparent:true, opacity:0.85 })); ring.rotation.x = Math.PI / 2.2; ring.position.x = p.sma; pivot.add(ring); } return { pivot, mesh, ...p }; });
4

Keplerian Orbital Paths

Draw elliptical orbit lines and animate planets using Kepler's period. Scale 1 Earth year to 1 real second so the solar system is always moving.

// Draw orbit ellipse (circular for simplicity โ€” set eccentricity for accuracy) function addOrbitLine(sma) { const curve = new THREE.EllipseCurve(0, 0, sma, sma, 0, Math.PI * 2); const pts = curve.getPoints(256).map(p => new THREE.Vector3(p.x, 0, p.y)); const geo = new THREE.BufferGeometry().setFromPoints(pts); scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color: 0x334155, transparent:true, opacity:0.5 }))); } planets.forEach(p => addOrbitLine(p.sma)); // Animation loop const YEAR_SECS = 1.0; // 1 Earth year = 1 second of screen time let last = performance.now(); function animate() { requestAnimationFrame(animate); const now = performance.now(); const dt = Math.min((now - last) / 1000, 0.05); last = now; planets.forEach(p => { // Angular velocity: ฯ‰ = 2ฯ€ / T (T in years) p.pivot.rotation.y += (Math.PI * 2 / p.period) * dt * YEAR_SECS; p.mesh.rotation.y += dt * 0.5; // self-rotation }); sun.rotation.y += dt * 0.1; controls.update(); composer.render(); } animate();
Kepler's 3rd law: Tยฒ โˆ aยณ. The period ratio is baked into p.period (Earth-years). Multiply by YEAR_SECS to control animation speed.
5

OrbitControls, Labels and Speed Slider

Add OrbitControls for mouse navigation and a simple HTML overlay with a speed slider and planet names.

const controls = new OrbitControls(camera, renderer.domElement); controls.minDistance = 20; controls.maxDistance = 1500; controls.enableDamping = true; controls.dampingFactor = 0.05; // --- Speed slider UI --- const slider = document.createElement('input'); slider.type = 'range'; slider.min = '0'; slider.max = '5'; slider.step = '0.01'; slider.value = '1'; Object.assign(slider.style, { position:'fixed', bottom:'24px', left:'50%', transform:'translateX(-50%)', width:'200px', accentColor:'#22c55e', zIndex:'10' }); document.body.appendChild(slider); // --- Labels using CSS2DRenderer --- import { CSS2DRenderer, CSS2DObject } from 'https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/renderers/CSS2DRenderer.js'; const labelRenderer = new CSS2DRenderer(); labelRenderer.setSize(innerWidth, innerHeight); Object.assign(labelRenderer.domElement.style, { position:'fixed',top:'0',left:'0',pointerEvents:'none' }); document.body.appendChild(labelRenderer.domElement); planets.forEach(p => { const div = document.createElement('div'); div.textContent = p.name; div.style.cssText = 'color:#94a3b8;font-size:11px;font-family:sans-serif;text-shadow:0 1px 3px #000'; const label = new CSS2DObject(div); label.position.set(0, p.r + 2, 0); p.mesh.add(label); });
6

UnrealBloom Post-Processing

Wire up EffectComposer with UnrealBloomPass so the Sun and other bright objects get a soft glow halo.

const composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); const bloom = new UnrealBloomPass( new THREE.Vector2(innerWidth, innerHeight), 1.2, // strength 0.4, // radius 0.85 // threshold โ€” only pixels brighter than this glow ); composer.addPass(bloom); // Handle window resize for the composer too window.addEventListener('resize', () => { composer.setSize(innerWidth, innerHeight); }); // Replace renderer.render() with composer.render() in the loop // (already done in Step 4 โ€” composer.render() inside animate()) // --- Tip: selective bloom --- // To make only the Sun bloom (not dim planets): // 1. Render the scene twice โ€” once with bloom, once clean // 2. Use layers: sun.layers.enable(1); bloom pass only renders layer 1 // See Three.js selective-bloom example for full setup
Threshold matters: set threshold near 1.0 so only the emissive Sun exceeds it. If you set it to 0 the entire scene blooms and everything looks foggy. Combine with ACESFilmicToneMapping for the best result.