THREE.JS SOLAR SYSTEM
BUILD AN EDUCATIONAL 3D GAME
By Shane Brumback · Published · Advanced Three.js tutorial · about 45 minutes
Part 2 of the series: learn how educational 3D games are built — a world worth exploring, interactions that reward discovery, a learning UI that teaches at the right moment, and progress that keeps students going. We take the Beginner solar system and turn it into a full learning game: students travel to each planet, and when they are close and looking at it, a learning panel shows its picture and facts. It is the worked example from my article on creating 3D virtual educational worlds.
LIVE DEMO
The solar system behind this page is the demo. Sign in free to launch it full screen and see every educational-game system working together: explore the world, discover a planet by flying close and pointing at it, read its learning panel, and track your progress to all nine worlds. The ship is just how learners move. Spot the comet for a bonus — it is ported from my Three.js comet particle system tutorial. Every signed-in visitor gets a 3-minute free trial of the full-screen classroom; members get unlimited time.
Advanced (default): the full educational game — discovery by proximity and aim, learning panels with planet images, progress and a bonus comet. Beginner: the simpler first-version classroom scene — fly with WASD and hover planets.
▶ Play the GameThrottle: W / S · Turn: A / D · Pitch: arrow keys · Climb: E / Q · Boost: Shift · Phones: landscape, left stick pitch + yaw, right stick throttle, BOOST above it · Learn: fly close to a planet or hover it · Esc to exit
The Solar System Series
- Article: Creating 3D Virtual Educational Worlds — the design ideas.
- Beginner tutorial — orbits, WASD fly-through, hover-to-learn and a classroom login.
- Advanced tutorial — build a full educational 3D game: discovery, learning panels, progress, VFX and mobile controls.
- Play Planet Quest: Solar System Explorer — the finished game.
How to Build a Solar System in Three.js (Quick Answer)
Make a sphere for the Sun with a PointLight at its center, then put each planet mesh inside an empty Object3D pivot and rotate the pivots at different speeds to animate the orbits. Store each planet's facts in mesh.userData, use a Raycaster from the mouse to detect which planet is hovered, and show those facts in an HTML card. Move the camera with keys to fly through it, and start the loop only after a login form is submitted.
Introduction
A diagram of the solar system tells students the planet order. A 3D solar system lets them feel it: how small Mercury is next to Jupiter, how slowly Neptune crawls while Mercury races around the Sun, and how much empty space there is between worlds. That is the difference between a render and an educational world.
In plain words: we build a little space classroom. A student signs in, the planets start orbiting, they fly around like a spaceship and point at any planet to read about it. A counter tracks how many worlds they have explored until the mission is complete.
This tutorial is the hands-on companion to Creating 3D Virtual Educational Worlds with Three.js, which explains the five systems every learning world needs. Here we build all five. The tutorial and full code are free.
Frequently Asked Questions
How do I add a comet to a Three.js solar system?
Use the approach from the comet particle system tutorial: one THREE.Points cloud with a dust population and an ion population, both pushed away from the Sun, plus a glowing coma sprite. Move the whole comet group along an eccentric orbit and update the sun direction every frame so the tails always point away from the Sun.
How do I make planets orbit the Sun in Three.js?
Put each planet mesh inside an empty Object3D pivot at the center of the scene, offset the mesh by its orbit distance, and rotate the pivot a little every frame. The pivot carries the planet around the Sun. Give each pivot its own speed so inner planets move faster.
How do I show information when the mouse hovers over a planet?
Store the facts on each mesh in userData, convert the mouse position to normalized device coordinates, call raycaster.setFromCamera(pointer, camera) and intersectObjects on your planets every frame. When the first hit changes, fill an HTML info card with that object's userData and move the card next to the cursor.
How do I make a third-person spaceship in Three.js?
Build the ship as a Group, turn it with rotateY and rotateX from the keys, and move it along its forward vector (0,0,-1) rotated by its quaternion. Each frame, place the camera at an offset behind and above the ship in the ship frame and lerp toward it, then slerp the camera to look a little ahead of the ship. That gives a smooth chase camera.
How do I add a login to a Three.js educational game?
Show a login form over the canvas and only start the animation loop after it is submitted. The free code uses a simple classroom name and class code saved in localStorage, which is fine for prototypes. For real student accounts use a proper auth provider and store progress on a server.
Is the solar system to scale?
No. Sizes and distances are scaled so every planet is visible and reachable in a few seconds of flight. True scale would put Neptune thousands of Earth widths away, which makes a great classroom discussion.
Is this tutorial free?
Yes. The tutorial and the full code are free. The full-screen live demo needs a free Google sign-in and includes a 3-minute free trial; members get unlimited demo time.
What Students Learn
- The order and relative sizes of the eight planets.
- That inner planets orbit faster than outer ones — seen, not memorized.
- Key facts per planet: type, day length, year length, moons and one memorable fact.
- How vast and empty space is, something a flat diagram cannot show.
The scene teaches through discovery. Nothing lectures the student: they choose where to fly and curiosity unlocks the content. Tell students sizes and distances are scaled for readability, then ask how far away Neptune would really be. Moon counts change as new moons are confirmed, so have students check NASA's current planet pages too.
The Five Systems in This Scene
| System | Solar system implementation |
|---|---|
| World | Sun, planet spheres, orbit lines, starfield |
| Player | Keyboard flight plus drag-to-look and zoom |
| Interactive objects | Planets carrying their facts in userData, found by a Raycaster |
| Learning UI | Hover info card and an explored counter |
| Identity and progress | Classroom login and saved progress |
How It Works
- Set up the renderer, scene and camera — In plain words: We make the 3D window and a camera far enough back to see the whole solar system. ACES tone mapping keeps the bright Sun from clipping, and the pixel ratio is capped so it stays smooth on school laptops and phones.
- Keep the learning content in data — In plain words: Every planet is one plain object: size, distance, orbit speed and the facts students will discover. Teachers can edit facts here without touching any 3D code.
- Make VFX textures in code — In plain words: Small canvas drawings become glow sprites, banded gas-giant textures and cratered rocky planets, so the whole demo loads with zero image files.
- Build a glowing Sun — In plain words: A shader animates a boiling plasma surface with darker edges (limb darkening). Two additive sprites make the corona and halo, and a PointLight at the center lights every planet.
- Twinkling stars and nebula clouds — In plain words: Thousands of tinted stars twinkle in a tiny shader, and big soft colored sprites far away add nebula clouds so space never looks flat.
- Planets, atmospheres, Saturn rings and orbit trails — In plain words: Each planet rides an orbit pivot with a glowing fresnel atmosphere and a fading trail. Saturn gets a procedural multi-band ring shader with a Cassini division, crisp anti-aliased edges and the planet shadow cast across the rings. An asteroid belt sits between Mars and Jupiter.
- Add the comet — In plain words: The comet is adapted from my comet particle system tutorial (linked in the live demo card): a rocky nucleus, a glowing coma, a curved dust tail and a straight blue ion tail that always point away from the Sun, on an eccentric orbit that speeds up near the Sun. Students get a bonus when they spot it.
- Add bloom — In plain words: An UnrealBloomPass makes the Sun, engine and comet glow. The threshold is high so only bright things bloom.
- Fly a spaceship in third person — In plain words: The ship is built from simple shapes. W and S set the throttle, A and D turn, the arrow keys pitch, E and Q climb, Shift boosts. The chase camera sits behind and above the ship and smoothly follows it, the ship banks into turns, and the engine leaves a glowing exhaust trail. On touch screens, hold and drag to steer.
- Fly close to a planet to learn about it — In plain words: When the ship is near a planet, the Sun or the comet AND its nose points at it, a soft glowing square frames it and an education panel pops up with a picture of that world and its facts. Nearby worlds you are not pointing at are ignored, so panels never pop up too fast. Fly closer to arrive and count it as explored.
- Animate everything in one loop — In plain words: Every frame the orbits turn, the Sun and stars animate, the comet moves, the ship flies and the HUD updates. Delta time is capped so a slow frame never makes planets jump.
- Gate the game behind a classroom login — In plain words: In the free full code, students type a name and class code before the game starts. Their explored planets are saved in localStorage so progress survives a refresh. This is prototype auth for classrooms, not real security; for graded work use a real auth provider and save progress on a server.
Code Breakdown
Steps 1 to 11 are the exact Advanced-mode code running behind this page (Beginner mode is the simpler solar-scene-beginner.js). Step 12 is the classroom login from the full code below. Beginners: read the plain-words line. Developers: the snippet is copy-paste ready.
1. Set up the renderer, scene and camera
In plain words: We make the 3D window and a camera far enough back to see the whole solar system. ACES tone mapping keeps the bright Sun from clipping, and the pixel ratio is capped so it stays smooth on school laptops and phones.
// ---------- 1. Renderer, scene, camera ----------
const isMobile = Math.min(innerWidth, innerHeight) < 600 || matchMedia('(pointer: coarse)').matches;
const renderer = new THREE.WebGLRenderer({ antialias: !isMobile, alpha: false });
renderer.setPixelRatio(Math.min(devicePixelRatio, isMobile ? 1.5 : 2));
renderer.setSize(innerWidth, innerHeight);
renderer.domElement.id = 'renderer';
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.82; // darker, more contrast
if ((window.__solarMode || 'advanced') !== 'advanced') renderer.domElement.style.display = 'none';
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x02030a);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 3000);
camera.position.set(0, 28 * (window.__solarWorldScale || 1), 78 * (window.__solarWorldScale || 1));
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.35;
controls.maxDistance = 260;
controls.enabled = !window.__PAGE_MODE;
2. Keep the learning content in data
In plain words: Every planet is one plain object: size, distance, orbit speed and the facts students will discover. Teachers can edit facts here without touching any 3D code.
// ---------- 2. Learning content: one object per body ----------
const WS = window.__solarWorldScale || 1; // full game map = 2x the demo scene (distances, belt, comet orbit, play area)
const PLANETS = [
{ name: 'Mercury', color: 0xb1aaa0, radius: 0.4, distance: 8, speed: 4.15, type: 'Rocky planet', day: '59 Earth days', year: '88 Earth days', moons: '0', fact: 'The smallest planet and the closest to the Sun.' },
{ name: 'Venus', color: 0xe6c27a, radius: 0.9, distance: 11, speed: 1.62, type: 'Rocky planet', day: '243 Earth days', year: '225 Earth days', moons: '0', fact: 'The hottest planet: its thick carbon dioxide atmosphere traps heat.' },
{ name: 'Earth', color: 0x3a7bd5, radius: 1.0, distance: 15, speed: 1.0, type: 'Rocky planet', day: '24 hours', year: '365 days', moons: '1', fact: 'The only known planet with liquid surface water and life.' },
{ name: 'Mars', color: 0xc1440e, radius: 0.55, distance: 19, speed: 0.53, type: 'Rocky planet', day: '24.6 hours', year: '687 Earth days', moons: '2', fact: 'Home to Olympus Mons, the tallest volcano in the solar system.' },
{ name: 'Jupiter', color: 0xd8b48a, radius: 2.8, distance: 28, speed: 0.084, type: 'Gas giant', day: 'about 10 hours', year: 'about 12 Earth years', moons: '95+', fact: 'The largest planet; the Great Red Spot is a giant storm.' },
{ name: 'Saturn', color: 0xe3d3a3, radius: 2.3, distance: 38, speed: 0.034, type: 'Gas giant', ring: true, day: 'about 10.7 hours', year: 'about 29 Earth years', moons: '140+', fact: 'Its rings are made mostly of ice and rock.' },
{ name: 'Uranus', color: 0x9fd9e8, radius: 1.6, distance: 47, speed: 0.012, type: 'Ice giant', day: 'about 17 hours', year: 'about 84 Earth years', moons: '27', fact: 'It rotates on its side, tilted about 98 degrees.' },
{ name: 'Neptune', color: 0x3f5ccf, radius: 1.55, distance: 55, speed: 0.006, type: 'Ice giant', day: 'about 16 hours', year: 'about 165 Earth years', moons: '16', fact: 'It has the fastest winds measured in the solar system.' }
];
if (WS !== 1) PLANETS.forEach(p => { p.distance *= WS; });
3. Make VFX textures in code
In plain words: Small canvas drawings become glow sprites, banded gas-giant textures and cratered rocky planets, so the whole demo loads with zero image files.
// ---------- 3. VFX helpers: canvas textures for glows, planets and rings ----------
function canvasTex(w, h, draw) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
draw(c.getContext('2d'), w, h);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
function glowTex(stops) {
return canvasTex(256, 256, (g, w) => {
const r = g.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2);
stops.forEach(([o, c]) => r.addColorStop(o, c)); g.fillStyle = r; g.fillRect(0, 0, w, w);
});
}
function glowSprite(tex, size, opacity) {
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, color: 0xffffff, transparent: true, opacity,
blending: THREE.AdditiveBlending, depthWrite: false }));
sp.scale.setScalar(size); return sp;
}
function shade(hex, f) { const c = new THREE.Color(hex).multiplyScalar(f); return '#' + c.getHexString(); }
function planetTex(p) {
return canvasTex(512, 256, (g, w, h) => {
g.fillStyle = '#' + new THREE.Color(p.color).getHexString(); g.fillRect(0, 0, w, h);
if (p.type !== 'Rocky planet') { // gas / ice giants: soft latitude bands
for (let y = 0; y < h; y += 2) {
const v = Math.sin(y * 0.11) * 0.5 + Math.sin(y * 0.037 + 1.3) * 0.35 + Math.sin(y * 0.29) * 0.15;
g.fillStyle = shade(p.color, 0.82 + v * 0.22); g.globalAlpha = 0.6; g.fillRect(0, y, w, 2);
}
g.globalAlpha = 1;
if (p.name === 'Jupiter') { g.fillStyle = '#b5532f'; g.beginPath(); g.ellipse(w * 0.62, h * 0.64, 34, 15, 0, 0, Math.PI * 2); g.fill(); }
} else { // rocky worlds: mottled craters / continents
for (let i = 0; i < 900; i++) {
const r = Math.random() * 10 + 2;
g.fillStyle = shade(p.color, 0.65 + Math.random() * 0.6); g.globalAlpha = 0.25;
g.beginPath(); g.arc(Math.random() * w, Math.random() * h, r, 0, Math.PI * 2); g.fill();
}
g.globalAlpha = 1;
if (p.name === 'Earth') {
g.fillStyle = '#3f8f4a';
for (let i = 0; i < 26; i++) { g.beginPath(); g.ellipse(Math.random() * w, h * 0.2 + Math.random() * h * 0.6, 14 + Math.random() * 40, 8 + Math.random() * 22, Math.random() * 3, 0, Math.PI * 2); g.fill(); }
g.fillStyle = 'rgba(255,255,255,0.9)'; g.fillRect(0, 0, w, 14); g.fillRect(0, h - 14, w, 14);
}
}
});
}
const softDot = glowTex([[0, 'rgba(255,255,255,1)'], [0.25, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]);
4. Build a glowing Sun
In plain words: A shader animates a boiling plasma surface with darker edges (limb darkening). Two additive sprites make the corona and halo, and a PointLight at the center lights every planet.
// ---------- 4. Sun: animated plasma surface, corona glow, light ----------
const sunMat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 } },
vertexShader: 'varying vec3 vP; varying vec3 vN; varying vec3 vV; void main(){ vP=position; vN=normalize(normalMatrix*normal); vec4 mv=modelViewMatrix*vec4(position,1.0); vV=normalize(-mv.xyz); gl_Position=projectionMatrix*mv; }',
fragmentShader: [
'uniform float uTime; varying vec3 vP; varying vec3 vN; varying vec3 vV;',
'float h(vec3 p){return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453);}',
'float n(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.0-2.0*f);return mix(mix(mix(h(i),h(i+vec3(1,0,0)),f.x),mix(h(i+vec3(0,1,0)),h(i+vec3(1,1,0)),f.x),f.y),mix(mix(h(i+vec3(0,0,1)),h(i+vec3(1,0,1)),f.x),mix(h(i+vec3(0,1,1)),h(i+vec3(1,1,1)),f.x),f.y),f.z);}',
'void main(){ vec3 p=vP*0.9; float t=uTime*0.25;',
' float g=n(p*1.5+t)*0.5+n(p*3.2-t*1.3)*0.3+n(p*7.0+t*2.0)*0.2;',
' vec3 col=mix(vec3(1.0,0.35,0.02),vec3(1.0,0.85,0.35),g); col=mix(col,vec3(1.0,0.97,0.8),smoothstep(0.62,0.85,g));',
' float limb=pow(max(dot(vN,vV),0.0),0.45); col*=0.65+0.9*limb;',
' gl_FragColor=vec4(col*1.6,1.0); }'].join('\n')
});
const sun = new THREE.Mesh(new THREE.SphereGeometry(4, 64, 64), sunMat);
sun.userData = { name: 'The Sun', type: 'Star (G-type)', fact: 'It holds about 99.8 percent of the mass of the whole solar system.' };
scene.add(sun);
const corona = glowSprite(glowTex([[0, 'rgba(255,240,200,1)'], [0.18, 'rgba(255,190,80,0.8)'], [0.4, 'rgba(255,120,30,0.25)'], [1, 'rgba(255,80,0,0)']]), 11, 0.3); // corona and halo cut back hard so planets stay readable
const halo = glowSprite(glowTex([[0, 'rgba(255,170,90,0.5)'], [0.5, 'rgba(255,110,40,0.12)'], [1, 'rgba(0,0,0,0)']]), 22, 0.1);
scene.add(corona, halo);
const glow = corona;
const sunLight = new THREE.PointLight(0xfff1dd, 420, 0, 1.6);
scene.add(sunLight);
scene.add(new THREE.AmbientLight(0x223044, 0.6));
scene.add(new THREE.HemisphereLight(0x3355aa, 0x110a05, 0.25));
5. Twinkling stars and nebula clouds
In plain words: Thousands of tinted stars twinkle in a tiny shader, and big soft colored sprites far away add nebula clouds so space never looks flat.
// ---------- 5. Twinkling coloured starfield + nebula clouds ----------
const starCount = isMobile ? 2500 : 6000;
const starPos = new Float32Array(starCount * 3), starCol = new Float32Array(starCount * 3), starSeed = new Float32Array(starCount);
const tints = [[0.7, 0.8, 1.0], [1.0, 0.95, 0.85], [1.0, 0.8, 0.6], [0.8, 0.9, 1.0]];
for (let i = 0; i < starCount; i++) {
const v = new THREE.Vector3(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).normalize().multiplyScalar(500 + Math.random() * 600);
starPos.set([v.x, v.y, v.z], i * 3); starCol.set(tints[i % 4], i * 3); starSeed[i] = Math.random();
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(starCol, 3));
starGeo.setAttribute('aSeed', new THREE.BufferAttribute(starSeed, 1));
const starMat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 }, uPR: { value: renderer.getPixelRatio() } }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: 'attribute float aSeed; attribute vec3 color; uniform float uTime; uniform float uPR; varying float vA; varying vec3 vC;' +
'void main(){ vC=color; vA=0.35+0.65*(0.6+0.4*sin(uTime*(1.0+aSeed*3.0)+aSeed*60.0)); gl_PointSize=(1.0+3.0*aSeed*aSeed)*uPR; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }',
fragmentShader: 'varying float vA; varying vec3 vC; void main(){ float d=length(gl_PointCoord-0.5); gl_FragColor=vec4(vC, vA*smoothstep(0.5,0.0,d)); }'
});
scene.add(new THREE.Points(starGeo, starMat));
const nebulaCols = ['rgba(120,60,200,', 'rgba(40,140,200,', 'rgba(200,60,120,', 'rgba(40,190,170,'];
for (let i = 0; i < 7; i++) {
const c = nebulaCols[i % 4];
const neb = glowSprite(glowTex([[0, c + '0.35)'], [0.5, c + '0.1)'], [1, c + '0)']]), 380 + Math.random() * 300, 0.28);
neb.material.blending = THREE.NormalBlending;
const v = new THREE.Vector3(Math.random() * 2 - 1, (Math.random() - 0.5) * 0.8, Math.random() * 2 - 1).normalize().multiplyScalar(700);
neb.position.copy(v); scene.add(neb);
}
6. Planets, atmospheres, Saturn rings and orbit trails
In plain words: Each planet rides an orbit pivot with a glowing fresnel atmosphere and a fading trail. Saturn gets a procedural multi-band ring shader with a Cassini division, crisp anti-aliased edges and the planet shadow cast across the rings. An asteroid belt sits between Mars and Jupiter.
// ---------- 6. Planets: textures, atmospheres, rings, glowing orbit trails ----------
const ATMOS = { Venus: 0xffd27a, Earth: 0x5fb4ff, Mars: 0xff7a4a, Jupiter: 0xffd6a0, Uranus: 0x9ff3ff, Neptune: 0x5f8cff };
function atmosphere(radius, color) {
return new THREE.Mesh(new THREE.SphereGeometry(radius * 1.18, 48, 48), new THREE.ShaderMaterial({
uniforms: { uColor: { value: new THREE.Color(color) } }, transparent: true, depthWrite: false, side: THREE.BackSide, blending: THREE.AdditiveBlending,
vertexShader: 'varying vec3 vN; varying vec3 vV; void main(){ vN=normalize(normalMatrix*normal); vec4 mv=modelViewMatrix*vec4(position,1.0); vV=normalize(-mv.xyz); gl_Position=projectionMatrix*mv; }',
fragmentShader: 'uniform vec3 uColor; varying vec3 vN; varying vec3 vV; void main(){ float f=pow(1.0-abs(dot(vN,vV)),2.5); gl_FragColor=vec4(uColor*f*0.8, f); }'
}));
}
const pivots = [];
const hoverables = [sun];
const trailMats = [];
for (const p of PLANETS) {
const pivot = new THREE.Object3D();
pivot.rotation.y = Math.random() * Math.PI * 2;
scene.add(pivot);
const mat = new THREE.MeshStandardMaterial({ map: planetTex(p), roughness: 0.85, metalness: 0.0, emissive: new THREE.Color(p.color), emissiveIntensity: 0.04 });
const mesh = new THREE.Mesh(new THREE.SphereGeometry(p.radius, 48, 48), mat);
mesh.position.x = p.distance;
mesh.rotation.z = p.name === 'Uranus' ? 1.7 : 0.4;
mesh.userData = p;
pivot.add(mesh);
hoverables.push(mesh);
if (ATMOS[p.name]) { const at = atmosphere(p.radius, ATMOS[p.name]); at.position.copy(mesh.position); pivot.add(at); }
if (p.ring) {
// Saturn rings: procedural multi-band translucent shader with Cassini division and planet shadow
const rIn = p.radius * 1.24, rOut = p.radius * 2.35;
const ringMat = new THREE.ShaderMaterial({
uniforms: { uIn: { value: rIn }, uOut: { value: rOut }, uPlanet: { value: new THREE.Vector3() }, uR: { value: p.radius } },
transparent: true, depthWrite: false, side: THREE.DoubleSide,
vertexShader: 'varying vec3 vW; varying float vRad; void main(){ vRad=length(position.xy); vec4 w=modelMatrix*vec4(position,1.0); vW=w.xyz; gl_Position=projectionMatrix*viewMatrix*w; }',
fragmentShader: [
'uniform float uIn; uniform float uOut; uniform vec3 uPlanet; uniform float uR; varying vec3 vW; varying float vRad;',
'float hh(float x){return fract(sin(x*91.17)*43758.5453);}',
'float bn(float x){float i=floor(x),f=fract(x);return mix(hh(i),hh(i+1.0),f*f*(3.0-2.0*f));}',
'void main(){ float t=(vRad-uIn)/(uOut-uIn); float aa=fwidth(t)*1.5;',
' float edge=smoothstep(0.0,aa,t)*smoothstep(1.0,1.0-aa,t);',
' float d=0.55+0.45*bn(t*60.0)*0.6+0.25*bn(t*190.0);', // fine ringlets
' d*=mix(0.18,1.0,smoothstep(0.16,0.2,t));', // faint C ring
' d*=1.0-0.92*(smoothstep(0.6,0.61,t)-smoothstep(0.665,0.675,t));', // Cassini division
' d*=1.0-0.8*(smoothstep(0.885,0.89,t)-smoothstep(0.9,0.905,t));', // Encke gap
' d*=mix(1.0,0.7,smoothstep(0.68,1.0,t));', // thinner A ring
' vec3 ice=vec3(0.93,0.88,0.78), rock=vec3(0.62,0.5,0.38);',
' vec3 col=mix(rock,ice,smoothstep(0.15,0.55,t)*(0.6+0.4*bn(t*35.0)));',
' vec3 dir=normalize(-vW); vec3 oc=uPlanet-vW; float tca=dot(oc,dir);', // planet shadow on the rings
' float miss=length(oc-dir*tca); float sh=(tca>0.0)?smoothstep(uR*0.92,uR*1.02,miss):1.0;',
' col*=0.25+0.75*sh;',
' gl_FragColor=vec4(col*0.8, clamp(d,0.0,1.0)*0.85*edge); }'].join('\n')
});
const ring = new THREE.Mesh(new THREE.RingGeometry(rIn, rOut, 192, 4), ringMat);
ring.rotation.x = -Math.PI / 2.4;
ring.position.copy(mesh.position);
ring.renderOrder = 2;
ring.onBeforeRender = () => mesh.getWorldPosition(ringMat.uniforms.uPlanet.value);
pivot.add(ring);
}
// faint full orbit + bright fading trail that rides behind the planet
const pts = new THREE.EllipseCurve(0, 0, p.distance, p.distance).getPoints(160).map(v => new THREE.Vector3(v.x, 0, v.y));
scene.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x6a8fc8, transparent: true, opacity: 0.62, depthWrite: false })));
const arc = Math.min(1.6, 9 / p.distance), seg = 80, tp = [], tc = [];
const col = new THREE.Color(ATMOS[p.name] || p.color);
for (let i = 0; i <= seg; i++) {
const a = (i / seg) * arc, f = Math.pow(1 - i / seg, 1.6);
tp.push(p.distance * Math.cos(a), 0, p.distance * Math.sin(a));
tc.push(col.r * f, col.g * f, col.b * f);
}
const tg = new THREE.BufferGeometry();
tg.setAttribute('position', new THREE.Float32BufferAttribute(tp, 3));
tg.setAttribute('color', new THREE.Float32BufferAttribute(tc, 3));
const tm = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
pivot.add(new THREE.Line(tg, tm));
pivots.push({ pivot, mesh, speed: p.speed });
}
// asteroid belt between Mars and Jupiter
const beltN = isMobile ? 1200 : 2600, beltPos = new Float32Array(beltN * 3);
for (let i = 0; i < beltN; i++) {
const a = Math.random() * Math.PI * 2, r = (22 + Math.random() * 3.2 + (Math.random() - 0.5) * 1.5) * WS;
beltPos.set([Math.cos(a) * r, (Math.random() - 0.5) * 0.8, Math.sin(a) * r], i * 3);
}
const beltGeo = new THREE.BufferGeometry(); beltGeo.setAttribute('position', new THREE.BufferAttribute(beltPos, 3));
const belt = new THREE.Points(beltGeo, new THREE.PointsMaterial({ color: 0xb8a58c, size: 0.22, map: softDot, transparent: true, depthWrite: false, opacity: 0.85 }));
scene.add(belt);
// full game only: richer belt of 3D rocks in varied sizes (opts.richBelt)
if (window.__solarRichBelt) {
const rockN = isMobile ? 220 : 600;
const rockGeo = new THREE.IcosahedronGeometry(1, 0);
const rp = rockGeo.attributes.position;
for (let i = 0; i < rp.count; i++) rp.setXYZ(i, rp.getX(i) * (0.8 + Math.random() * 0.4), rp.getY(i) * (0.7 + Math.random() * 0.5), rp.getZ(i) * (0.8 + Math.random() * 0.4));
rockGeo.computeVertexNormals();
const rocks = new THREE.InstancedMesh(rockGeo, new THREE.MeshStandardMaterial({ color: 0xd8c8b0, emissive: 0x9a8a74, emissiveIntensity: 0.75, roughness: 0.85, metalness: 0.0, flatShading: true }), rockN);
const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler(), sc = new THREE.Vector3(), ps = new THREE.Vector3();
const tint = new THREE.Color();
for (let i = 0; i < rockN; i++) {
const a = Math.random() * Math.PI * 2, r = (21.5 + Math.random() * 4.2) * WS;
const k = Math.random(), size = k < 0.75 ? 0.06 + Math.random() * 0.14 : (k < 0.95 ? 0.2 + Math.random() * 0.3 : 0.5 + Math.random() * 0.6);
ps.set(Math.cos(a) * r, (Math.random() - 0.5) * 1.6, Math.sin(a) * r);
q.setFromEuler(e.set(Math.random() * 6.3, Math.random() * 6.3, Math.random() * 6.3));
sc.set(size, size * (0.6 + Math.random() * 0.5), size * (0.7 + Math.random() * 0.4));
rocks.setMatrixAt(i, m4.compose(ps, q, sc));
rocks.setColorAt(i, tint.setHSL(0.07 + Math.random() * 0.04, 0.18 + Math.random() * 0.15, 0.55 + Math.random() * 0.25));
}
belt.add(rocks);
belt.material.color.set(0xe6d6be); belt.material.opacity = 1; belt.material.size = 0.3;
}
// hover highlight: pulsing cyan ring that faces the camera
const highlight = new THREE.Mesh(new THREE.RingGeometry(1.25, 1.42, 64), new THREE.MeshBasicMaterial({ color: 0x2ad1d6, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }));
highlight.visible = false; scene.add(highlight);
7. Add the comet
In plain words: The comet is adapted from my comet particle system tutorial (linked in the live demo card): a rocky nucleus, a glowing coma, a curved dust tail and a straight blue ion tail that always point away from the Sun, on an eccentric orbit that speeds up near the Sun. Students get a bonus when they spot it.
// ---------- 6b. Comet (adapted from /tutorials/comet-particle-system.html) ----------
// Rocky nucleus + coma + dust tail and blue ion tail that always point away from the Sun,
// on an eccentric orbit that speeds up near the Sun.
const comet = (() => {
const S = 0.32; // scale of the original comet scene
const N_DUST = isMobile ? 700 : 1600, N_ION = isMobile ? 400 : 900, N = N_DUST + N_ION;
const group = new THREE.Group(); scene.add(group);
const SUN = new THREE.Vector3(1, 0, 0); // local direction toward the Sun, updated each frame
const nucleus = new THREE.Mesh(new THREE.IcosahedronGeometry(0.75, 2), new THREE.MeshStandardMaterial({ color: 0x8a8f99, roughness: 1, flatShading: true }));
const np = nucleus.geometry.attributes.position, t3 = new THREE.Vector3();
for (let k = 0; k < np.count; k++) { t3.fromBufferAttribute(np, k); t3.multiplyScalar(0.75 + Math.sin(t3.x * 5) * Math.cos(t3.y * 4) * 0.18); t3.x *= 1.3; np.setXYZ(k, t3.x, t3.y, t3.z); }
nucleus.geometry.computeVertexNormals();
nucleus.userData = { name: 'Comet', type: 'Icy small body', bonus: true, fact: 'A comet is a ball of ice and dust. Near the Sun it grows a glowing coma, a curved dust tail and a straight blue ion tail that always points away from the Sun.' };
group.add(nucleus);
const coma = glowSprite(glowTex([[0, 'rgba(220,240,255,1)'], [0.2, 'rgba(160,210,255,0.55)'], [1, 'rgba(80,140,255,0)']]), 7, 0.85);
group.add(coma);
const pos = new Float32Array(N * 3), vel = new Float32Array(N * 3), life = new Float32Array(N), maxL = new Float32Array(N), size = new Float32Array(N), type = new Float32Array(N);
for (let i = 0; i < N; i++) type[i] = i < N_DUST ? 0 : 1;
const side = new THREE.Vector3();
const rnd = (a, b) => a + Math.random() * (b - a);
function spawn(i) {
const i3 = i * 3, t = type[i];
const u = new THREE.Vector3(rnd(-1, 1), rnd(-1, 1), rnd(-1, 1)).normalize();
if (t === 0 && u.dot(SUN) < 0) u.addScaledVector(SUN, 1.2).normalize();
const r0 = rnd(0.5, 1.0);
pos[i3] = u.x * r0; pos[i3 + 1] = u.y * r0; pos[i3 + 2] = u.z * r0;
if (t === 0) { vel[i3] = (u.x * 0.05 - SUN.x * 0.08) * S; vel[i3 + 1] = (u.y * 0.05 - SUN.y * 0.08) * S; vel[i3 + 2] = (u.z * 0.05 - SUN.z * 0.08) * S; maxL[i] = rnd(160, 300); size[i] = rnd(0.6, 1.8) * S * 1.6; }
else { const s = rnd(0.35, 0.6) * S; vel[i3] = -SUN.x * s + u.x * 0.004; vel[i3 + 1] = -SUN.y * s + u.y * 0.004; vel[i3 + 2] = -SUN.z * s + u.z * 0.004; maxL[i] = rnd(90, 150); size[i] = rnd(0.5, 1.3) * S * 1.6; }
life[i] = maxL[i] * (0.2 + 0.8 * Math.random());
}
function stepOne(i) {
const i3 = i * 3, age = maxL[i] - life[i];
if (type[i] === 0) { const a = (0.0003 + age * 0.000002) * S; vel[i3] += side.x * a - SUN.x * 0.0006 * S; vel[i3 + 1] += side.y * a - SUN.y * 0.0006 * S; vel[i3 + 2] += side.z * a - SUN.z * 0.0006 * S; }
pos[i3] += vel[i3]; pos[i3 + 1] += vel[i3 + 1]; pos[i3 + 2] += vel[i3 + 2]; life[i]--;
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage));
const lifeAttr = new THREE.BufferAttribute(new Float32Array(N), 1).setUsage(THREE.DynamicDrawUsage);
g.setAttribute('aLife', lifeAttr); g.setAttribute('aSize', new THREE.BufferAttribute(size, 1)); g.setAttribute('aType', new THREE.BufferAttribute(type, 1));
for (let i = 0; i < N; i++) spawn(i);
const mat = new THREE.ShaderMaterial({
uniforms: { uScale: { value: 1 } }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: 'attribute float aLife; attribute float aSize; attribute float aType; uniform float uScale; varying float vLife; varying float vType;' +
'void main(){ vLife=aLife; vType=aType; vec4 mv=modelViewMatrix*vec4(position,1.0); float grow=aType<0.5?(1.0+2.5*(1.0-aLife)):(1.0+1.2*(1.0-aLife)); gl_PointSize=min(aSize*grow*uScale/max(-mv.z,0.1),64.0); gl_Position=projectionMatrix*mv; }',
fragmentShader: 'varying float vLife; varying float vType; void main(){ vec2 p=gl_PointCoord*2.0-1.0; float d=dot(p,p); if(d>1.0) discard;' +
' float fade=pow(vLife,0.9)*smoothstep(0.0,0.3,1.0-vLife); vec3 c; float a;' +
' if(vType<0.5){ c=mix(vec3(1.0,0.93,0.78),vec3(0.95,0.75,0.45),1.0-vLife); a=exp(-d*3.0)*0.35*fade; }' +
' else { c=mix(vec3(0.55,0.8,1.0),vec3(0.2,0.4,1.0),1.0-vLife); a=exp(-d*4.0)*0.8*fade; }' +
' gl_FragColor=vec4(c*a,a); }'
});
const tails = new THREE.Points(g, mat); tails.frustumCulled = false; group.add(tails);
// eccentric orbit, tilted out of the planet plane
const A = 46 * WS, E = 0.78, B = A * Math.sqrt(1 - E * E), tilt = new THREE.Euler(0.35, 0.6, 0.15);
let theta = 2.2;
const wp = new THREE.Vector3();
function update(dt) {
const r = A * (1 - E * E) / (1 + E * Math.cos(theta));
theta += dt * Math.min(0.25, 160 * WS * WS / (r * r)); // Kepler-ish, slow majestic pass (faster near the Sun, capped)
wp.set(r * Math.cos(theta), 0, r * Math.sin(theta)).applyEuler(tilt);
group.position.copy(wp);
SUN.copy(wp).negate().normalize();
side.crossVectors(SUN, new THREE.Vector3(0, 1, 0)).normalize();
const heat = THREE.MathUtils.clamp(30 / r, 0.35, 1.6); // brighter tail close to the Sun
coma.material.opacity = 0.5 + 0.35 * heat;
const la = lifeAttr.array;
for (let i = 0; i < N; i++) { stepOne(i); if (life[i] <= 0) spawn(i); la[i] = life[i] / maxL[i]; }
g.attributes.position.needsUpdate = true; lifeAttr.needsUpdate = true;
nucleus.rotation.y += dt * 0.6;
}
const orbitPts = [];
for (let i = 0; i <= 256; i++) { const th = i / 256 * Math.PI * 2, r = A * (1 - E * E) / (1 + E * Math.cos(th)); orbitPts.push(new THREE.Vector3(r * Math.cos(th), 0, r * Math.sin(th)).applyEuler(tilt)); }
const cOrbit = new THREE.Line(new THREE.BufferGeometry().setFromPoints(orbitPts), new THREE.LineDashedMaterial({ color: 0x5aa0e0, dashSize: 1.2, gapSize: 1.2, transparent: true, opacity: 0.5 }));
cOrbit.computeLineDistances(); scene.add(cOrbit);
hoverables.push(nucleus);
return { update, mat, nucleus };
})();
8. Add bloom
In plain words: An UnrealBloomPass makes the Sun, engine and comet glow. The threshold is high so only bright things bloom.
// ---------- 6c. Bloom post-processing ----------
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), isMobile ? 0.16 : 0.2, 0.3, 0.96);
composer.addPass(bloom);
composer.addPass(new OutputPass());
function sizeFX() {
composer.setSize(innerWidth, innerHeight);
comet.mat.uniforms.uScale.value = innerHeight * renderer.getPixelRatio() / (2 * Math.tan(camera.fov * Math.PI / 360));
}
sizeFX();
9. Fly a spaceship in third person
In plain words: The ship is built from simple shapes. W and S set the throttle, A and D turn, the arrow keys pitch, E and Q climb, Shift boosts. The chase camera sits behind and above the ship and smoothly follows it, the ship banks into turns, and the engine leaves a glowing exhaust trail. On touch screens, hold and drag to steer.
// ---------- 7. Third-person spaceship: fly to every planet ----------
// W/S throttle, A/D turn, Up/Down arrows pitch, E/Q climb, Shift boost. Touch (landscape): site-standard space controls - left stick pitch + yaw, right stick throttle, BOOST above it.
const ship = new THREE.Group();
const model = new THREE.Group(); ship.add(model);
{
const hull = new THREE.MeshStandardMaterial({ color: 0xdfe6ef, metalness: 0.6, roughness: 0.35 });
const trim = new THREE.MeshStandardMaterial({ color: 0x2ad1d6, emissive: 0x2ad1d6, emissiveIntensity: 0.8 });
const dark = new THREE.MeshStandardMaterial({ color: 0x1b2230, metalness: 0.5, roughness: 0.5 });
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.32, 1.6, 12), hull); body.rotation.x = -Math.PI / 2; model.add(body);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.7, 12), hull); nose.rotation.x = -Math.PI / 2; nose.position.z = -1.15; model.add(nose);
const cockpit = new THREE.Mesh(new THREE.SphereGeometry(0.16, 16, 12, 0, Math.PI * 2, 0, Math.PI / 2), new THREE.MeshStandardMaterial({ color: 0x66ccff, emissive: 0x114466, metalness: 0.2, roughness: 0.1 }));
cockpit.position.set(0, 0.16, -0.45); cockpit.scale.set(1, 0.8, 1.8); model.add(cockpit);
const wingGeo = new THREE.BoxGeometry(1.5, 0.05, 0.55);
for (const sx of [-1, 1]) {
const w = new THREE.Mesh(wingGeo, dark); w.position.set(sx * 0.75, -0.04, 0.3); w.rotation.y = sx * 0.25; model.add(w);
const tip = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.5), trim); tip.position.set(sx * 1.45, -0.04, 0.45); model.add(tip);
}
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.45, 0.45), dark); fin.position.set(0, 0.28, 0.6); model.add(fin);
const nozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.26, 0.2, 12, 1, true), dark); nozzle.rotation.x = Math.PI / 2; nozzle.position.z = 0.88; model.add(nozzle);
}
const engineGlow = glowSprite(glowTex([[0, 'rgba(220,250,255,1)'], [0.25, 'rgba(80,200,255,0.7)'], [1, 'rgba(0,80,255,0)']]), 1.2, 0.9);
engineGlow.position.z = 1.05; ship.add(engineGlow);
ship.visible = false;
scene.add(ship);
// engine exhaust: recycled additive particles left behind in world space
const EXN = 260, exPos = new Float32Array(EXN * 3), exLife = new Float32Array(EXN);
const exGeo = new THREE.BufferGeometry(); exGeo.setAttribute('position', new THREE.BufferAttribute(exPos, 3).setUsage(THREE.DynamicDrawUsage));
const exhaust = new THREE.Points(exGeo, new THREE.PointsMaterial({ color: 0x66d9ff, size: 0.16, map: softDot, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
exhaust.frustumCulled = false; scene.add(exhaust);
let exIdx = 0;
// glowing square that frames the planet you are near and pointing at
function reticleTexture() {
const c = document.createElement('canvas'); c.width = c.height = 256; const g = c.getContext('2d');
g.strokeStyle = '#7ff6ff'; g.lineCap = 'round';
const corners = (w, a) => { g.lineWidth = w; g.globalAlpha = a; const m = 40, L = 56, e = 256 - m;
g.beginPath();
g.moveTo(m, m + L); g.lineTo(m, m); g.lineTo(m + L, m);
g.moveTo(e - L, m); g.lineTo(e, m); g.lineTo(e, m + L);
g.moveTo(e, e - L); g.lineTo(e, e); g.lineTo(e - L, e);
g.moveTo(m + L, e); g.lineTo(m, e); g.lineTo(m, e - L);
g.stroke(); };
g.shadowColor = '#2ad1d6'; g.shadowBlur = 18; corners(6, 0.3);
g.shadowBlur = 4; corners(2.5, 0.9);
g.globalAlpha = 0.18; g.lineWidth = 2; g.strokeRect(40, 40, 176, 176); // faint full square frame
return new THREE.CanvasTexture(c);
}
const guide = new THREE.Sprite(new THREE.SpriteMaterial({ map: reticleTexture(), color: 0x9fe9ec, opacity: 0.6, transparent: true, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending }));
guide.renderOrder = 10; guide.visible = false; scene.add(guide);
const keys = {};
addEventListener('keydown', e => { keys[e.code] = true; if (document.body.classList.contains('demo-active') && e.code.startsWith('Arrow')) e.preventDefault(); });
addEventListener('keyup', e => { keys[e.code] = false; });
let touchSteer = null;
// Mobile landscape controls (site-standard space dual joystick: left pitch + yaw, right throttle, BOOST above it) write into this shared object.
const touchIn = window.__solarTouch || (window.__solarTouch = { yaw: 0, pitch: 0, throttle: 0, boost: false });
addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse' && document.body.classList.contains('demo-active') && !(e.target.closest && e.target.closest('.mg-joy, .mg-btn, .mg-corner, .mode-switch, button'))) touchSteer = { x: e.clientX, y: e.clientY, dx: 0, dy: 0 }; });
addEventListener('pointermove', e => { if (touchSteer && e.pointerType !== 'mouse') { touchSteer.dx = (e.clientX - touchSteer.x) / 80; touchSteer.dy = (e.clientY - touchSteer.y) / 80; } });
addEventListener('pointerup', () => { touchSteer = null; });
addEventListener('pointercancel', () => { touchSteer = null; });
let shipSpeed = 0, yawRate = 0, heading = 0, noseTilt = 0, wasLive = false;
const shipEuler = new THREE.Euler(0, 0, 0, 'YXZ');
const fwd = new THREE.Vector3(), camGoal = new THREE.Vector3(), lookGoal = new THREE.Vector3(), tmpQ = new THREE.Quaternion();
const startCam = camera.position.clone();
function resetShip() {
ship.position.set(0, 3, 34 * WS);
ship.quaternion.identity();
shipSpeed = 0; heading = 0; noseTilt = 0;
}
function updateFlight(dt) {
const boost = keys.ShiftLeft || keys.ShiftRight || touchIn.boost;
let thrust = THREE.MathUtils.clamp((keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0) + touchIn.throttle, -1, 1);
let yaw = THREE.MathUtils.clamp(((keys.KeyA || keys.ArrowLeft) ? 1 : 0) - ((keys.KeyD || keys.ArrowRight) ? 1 : 0) - touchIn.yaw, -1, 1);
let pitch = THREE.MathUtils.clamp((keys.ArrowDown ? 1 : 0) - (keys.ArrowUp ? 1 : 0) - touchIn.pitch, -1, 1); // left stick up = nose up
if (touchSteer) { yaw = -THREE.MathUtils.clamp(touchSteer.dx, -1, 1); pitch = -THREE.MathUtils.clamp(touchSteer.dy, -1, 1); }
const maxSpeed = (boost ? 45 : 18) * Math.sqrt(WS); // a bit faster on the bigger map
shipSpeed = THREE.MathUtils.damp(shipSpeed, thrust * maxSpeed, thrust ? 1.6 : 0.8, dt);
yawRate = THREE.MathUtils.damp(yawRate, yaw * 1.6, 6, dt);
// Level flight: W/S only change speed along the horizontal heading, so the ship never sinks or dives.
// Height changes only with E/Q (or UP), or by holding the pitch arrows; the nose levels out on release.
heading += yawRate * dt;
noseTilt = THREE.MathUtils.damp(noseTilt, pitch * 0.35, 4, dt);
ship.quaternion.setFromEuler(shipEuler.set(noseTilt, heading, 0));
fwd.set(-Math.sin(heading), 0, -Math.cos(heading));
ship.position.addScaledVector(fwd, shipSpeed * dt);
ship.position.y += ((keys.KeyE ? 1 : 0) - (keys.KeyQ ? 1 : 0) ) * 10 * dt + Math.sin(noseTilt) * Math.abs(shipSpeed) * dt;
if (ship.position.length() < 6) ship.position.setLength(6); // don't fly into the Sun
if (ship.position.length() > 240 * WS) ship.position.setLength(240 * WS);
model.rotation.z = THREE.MathUtils.damp(model.rotation.z, yawRate * 0.45, 5, dt); // bank into turns
engineGlow.scale.setScalar(0.3 + Math.abs(shipSpeed) / 70 + Math.random() * 0.08);
// exhaust particles
if (Math.abs(shipSpeed) > 0.5) for (let k = 0; k < 3; k++) {
const i3 = exIdx * 3;
const p = engineGlow.getWorldPosition(new THREE.Vector3());
exPos[i3] = p.x + (Math.random() - 0.5) * 0.2; exPos[i3 + 1] = p.y + (Math.random() - 0.5) * 0.2; exPos[i3 + 2] = p.z + (Math.random() - 0.5) * 0.2;
exLife[exIdx] = 1; exIdx = (exIdx + 1) % EXN;
}
for (let i = 0; i < EXN; i++) { exLife[i] -= dt * 1.5; if (exLife[i] <= 0) exPos[i * 3 + 1] = 1e5; }
exGeo.attributes.position.needsUpdate = true;
// third-person chase camera: behind and above the ship, smoothly following
camGoal.set(0, 1.6, 5.2).applyQuaternion(ship.quaternion).add(ship.position);
camera.position.lerp(camGoal, 1 - Math.exp(-5 * dt));
lookGoal.set(0, 0.4, -6).applyQuaternion(ship.quaternion).add(ship.position);
tmpQ.copy(camera.quaternion); camera.lookAt(lookGoal); const want = camera.quaternion.clone(); camera.quaternion.copy(tmpQ).slerp(want, 1 - Math.exp(-8 * dt));
controls.target.copy(lookGoal);
}
// Homepage drone: the ship flies itself on a slow cinematic loop between the planets (no player input).
const apA = new THREE.Vector3(), apB = new THREE.Vector3();
function apPoint(t, out) { const a = t * 0.05; const r = 26 + 9 * Math.sin(t * 0.11); return out.set(Math.cos(a) * r, 3 + 2.5 * Math.sin(t * 0.17), Math.sin(a) * r); }
function updateAutopilot(t, dt) {
apPoint(t, apA); apPoint(t + 0.6, apB);
ship.position.copy(apA); ship.lookAt(apB); ship.rotateY(Math.PI);
model.rotation.z = THREE.MathUtils.damp(model.rotation.z, -0.25 * Math.cos(t * 0.11), 2, dt);
engineGlow.scale.setScalar(0.55 + Math.random() * 0.08);
for (let k = 0; k < 2; k++) { const i3 = exIdx * 3; const p = engineGlow.getWorldPosition(new THREE.Vector3()); exPos[i3] = p.x; exPos[i3 + 1] = p.y; exPos[i3 + 2] = p.z; exLife[exIdx] = 1; exIdx = (exIdx + 1) % EXN; }
for (let i = 0; i < EXN; i++) { exLife[i] -= dt * 1.5; if (exLife[i] <= 0) exPos[i * 3 + 1] = 1e5; }
exGeo.attributes.position.needsUpdate = true;
camGoal.set(3, 2, 7.5).applyQuaternion(ship.quaternion).add(ship.position); // wide, slightly off-axis chase shot
camera.position.lerp(camGoal, 1 - Math.exp(-2 * dt));
lookGoal.set(0, 0, -14).applyQuaternion(ship.quaternion).add(ship.position);
tmpQ.copy(camera.quaternion); camera.lookAt(lookGoal); const want = camera.quaternion.clone(); camera.quaternion.copy(tmpQ).slerp(want, 1 - Math.exp(-3 * dt));
}
10. Fly close to a planet to learn about it
In plain words: When the ship is near a planet, the Sun or the comet AND its nose points at it, a soft glowing square frames it and an education panel pops up with a picture of that world and its facts. Nearby worlds you are not pointing at are ignored, so panels never pop up too fast. Fly closer to arrive and count it as explored.
// ---------- 8. Learn about planets: fly close (or hover) to explore ----------
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(9, 9);
let mouseX = 0, mouseY = 0, hovered = null, nearBody = null;
const visited = new Set();
const PLANET_COUNT = PLANETS.length + 1; // eight planets + the Sun
addEventListener('pointermove', e => {
mouseX = e.clientX; mouseY = e.clientY;
pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
});
const wp2 = new THREE.Vector3();
function infoHTML(d, arrived) {
return (arrived ? '<div style="color:#2ad1d6;font-size:12px;letter-spacing:.08em;">ARRIVED</div>' : '') +
'<h3>' + d.name + '</h3><div><b>Type:</b> ' + d.type + '</div>' +
(d.day ? '<div><b>Day:</b> ' + d.day + '</div><div><b>Year:</b> ' + d.year + '</div><div><b>Moons:</b> ' + d.moons + '</div>' : '') +
'<p>' + d.fact + '</p>';
}
function nextTarget() {
let best = null, bd = Infinity;
for (const o of hoverables) {
if (o.userData.bonus || visited.has(o.userData.name)) continue;
o.getWorldPosition(wp2); const d = wp2.distanceTo(ship.position);
if (d < bd) { bd = d; best = o; }
}
return best ? { obj: best, dist: bd } : null;
}
function updateHUD(extra) {
const hud = document.getElementById('solar-hud'); if (!hud) return;
const done = visited.size === PLANET_COUNT;
const nt = done ? null : nextTarget();
hud.textContent = 'Explored ' + visited.size + ' / ' + PLANET_COUNT +
(done ? ' - Mission complete!' : (nt ? ' - Next: ' + nt.obj.userData.name + ' ' + Math.round(nt.dist) + ' units' : '')) + (extra || '');
return nt;
}
// a small round picture of each world, painted from its own planet texture
const imgCache = new Map();
function planetImage(o) {
if (imgCache.has(o)) return imgCache.get(o);
const c = document.createElement('canvas'); c.width = c.height = 96; const g = c.getContext('2d');
g.beginPath(); g.arc(48, 48, 44, 0, Math.PI * 2); g.clip();
const src = o.material && o.material.map && o.material.map.image;
if (src) g.drawImage(src, 0, 0, src.width, src.height, -40, 0, 176, 96);
else { const rg = g.createRadialGradient(48, 48, 4, 48, 48, 46); rg.addColorStop(0, o === sun ? '#fff6d0' : '#e8fbff'); rg.addColorStop(0.5, o === sun ? '#ffb43a' : '#7fd8ff'); rg.addColorStop(1, o === sun ? '#c2410c' : '#1e3a5f'); g.fillStyle = rg; g.fillRect(0, 0, 96, 96); }
const sh = g.createRadialGradient(34, 34, 6, 48, 48, 50); sh.addColorStop(0, 'rgba(255,255,255,0.18)'); sh.addColorStop(0.6, 'rgba(0,0,0,0)'); sh.addColorStop(1, 'rgba(0,0,0,0.6)');
if (o !== sun) { g.fillStyle = sh; g.fillRect(0, 0, 96, 96); }
const url = c.toDataURL(); imgCache.set(o, url); return url;
}
let shown = null, shownArrived = false;
const aimDir = new THREE.Vector3(), toObj = new THREE.Vector3();
function bodyRadius(o) { return o === sun ? 4 : (o.userData.radius || 0.8); }
function updateHover(live) {
const info = document.getElementById('planet-info');
if (!info || !live) { if (info) info.style.display = 'none'; hovered = null; shown = null; guide.visible = false; return; }
// the world you are near AND pointing at gets the glowing square and the education panel
// BOTH rules: the world must be in range AND the ship's nose must point at it (nearby worlds you are not aiming at are ignored)
let best = null, bd = Infinity;
aimDir.set(0, 0, -1).applyQuaternion(ship.quaternion);
for (const o of hoverables) {
o.getWorldPosition(wp2);
toObj.subVectors(wp2, ship.position);
const d = toObj.length(), r = bodyRadius(o);
if (d > r * 6 + 18) continue; // 1. near enough
const ang = aimDir.angleTo(toObj.divideScalar(d || 1));
const cone = Math.atan((r * 1.3 + 0.8) / Math.max(d, 0.1)) + 0.1; // 2. pointing at it (cone grows with the planet's size on screen)
if (ang < cone && ang < bd) { bd = ang; best = o; }
}
nearBody = null;
if (best) { best.getWorldPosition(wp2); if (wp2.distanceTo(ship.position) < bodyRadius(best) * 2.2 + 3) nearBody = best; }
const obj = best;
if (obj !== hovered) {
if (hovered) { hovered.scale.setScalar(1); if (hovered.material.emissiveIntensity !== undefined) hovered.material.emissiveIntensity = hovered === comet.nucleus ? 0 : 0.04; }
hovered = obj;
if (obj && obj.material.emissiveIntensity !== undefined) obj.material.emissiveIntensity = 0.25;
}
const arrived = obj && obj === nearBody;
if (obj !== shown || arrived !== shownArrived) {
shown = obj; shownArrived = arrived;
if (obj) {
const d = obj.userData;
info.innerHTML = '<img src="' + planetImage(obj) + '" alt="' + d.name + '" width="72" height="72" style="float:right;margin:0 0 6px 10px;border-radius:50%;box-shadow:0 0 18px rgba(42,209,214,0.45);">' +
'<div style="color:#2ad1d6;font-size:12px;letter-spacing:.08em;">' + (arrived ? 'ARRIVED' : 'APPROACHING') + '</div>' + infoHTML(d, false).replace(/^/, '');
info.style.display = 'block'; info.style.left = '16px'; info.style.top = '70px';
if (arrived && !d.bonus) visited.add(d.name);
} else info.style.display = 'none';
}
// glowing square frames the world you are approaching
if (obj) {
obj.getWorldPosition(wp2);
const sz = Math.max(bodyRadius(obj) * 3, camera.position.distanceTo(wp2) * 0.05) * (1 + Math.sin(clock.elapsedTime * 4) * 0.05);
guide.position.copy(wp2); guide.scale.set(sz, sz, 1); guide.visible = true;
guide.material.opacity = arrived ? 0.85 : 0.55;
} else guide.visible = false;
}
11. Animate everything in one loop
In plain words: Every frame the orbits turn, the Sun and stars animate, the comet moves, the ship flies and the HUD updates. Delta time is capped so a slow frame never makes planets jump.
// ---------- 9. Animate ----------
const clock = new THREE.Clock();
const tmpV = new THREE.Vector3();
renderer.setAnimationLoop(() => {
if ((window.__solarMode || 'advanced') !== 'advanced') { clock.getDelta(); return; } // paused while the other mode is shown
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
for (const { pivot, mesh, speed } of pivots) {
pivot.rotation.y += speed * 0.3 * dt;
mesh.rotation.y += 0.5 * dt;
}
sun.rotation.y += 0.05 * dt;
sunMat.uniforms.uTime.value = t;
starMat.uniforms.uTime.value = t;
corona.scale.setScalar(13 + Math.sin(t * 1.3) * 0.5 + Math.sin(t * 3.7) * 0.2);
belt.rotation.y += 0.02 * dt;
comet.update(dt);
const live = document.body.classList.contains('demo-active');
if (live && !wasLive) { resetShip(); ship.visible = true; }
if (!live && wasLive) { ship.visible = false; guide.visible = false; camera.position.copy(startCam); controls.target.set(0, 0, 0); }
wasLive = live;
controls.autoRotate = !live;
controls.enabled = false; // ship flight drives the camera in the demo
const auto = !live && window.__solarAutopilot;
if (auto) { ship.visible = true; updateAutopilot(t, dt); }
if (live) updateFlight(dt);
updateHover(live);
if (live) {
updateHUD(hovered && hovered.userData.bonus ? ' - bonus: comet spotted!' : '');
}
if (false) { // the glowing square replaces the old hover ring
hovered.getWorldPosition(tmpV);
const r = hovered === sun ? 4 : (hovered.userData.radius || 0.8);
highlight.position.copy(tmpV); highlight.quaternion.copy(camera.quaternion);
highlight.scale.setScalar(r * 1.15 * (1 + 0.06 * Math.sin(t * 5)));
highlight.visible = true;
} else highlight.visible = false;
if (!live && !auto) controls.update();
composer.render();
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
sizeFX();
});
window.__scenes = window.__scenes || {};
window.__scenes['advanced'] = { renderer, controls, camera, comet, ship, keys };
if ((window.__solarMode || 'advanced') === 'advanced') window.__scene = window.__scenes['advanced'];
12. Gate the game behind a classroom login
In plain words: In the free full code, students type a name and class code before the game starts. Their explored planets are saved in localStorage so progress survives a refresh. This is prototype auth for classrooms, not real security; for graded work use a real auth provider and save progress on a server.
// ---------- 10. Classroom login (prototype auth: name + class code in localStorage) ----------
let student = null;
document.getElementById('login-form').addEventListener('submit', e => {
e.preventDefault();
student = {
name: document.getElementById('student-name').value.trim(),
classCode: document.getElementById('class-code').value.trim().toUpperCase()
};
JSON.parse(localStorage.getItem('solar-progress-' + student.name) || '[]').forEach(n => visited.add(n));
document.getElementById('login').remove();
document.getElementById('student').textContent = student.name + ' - ' + student.classCode;
document.body.classList.add('demo-active'); // start the spaceship game
});
setInterval(() => { if (student) localStorage.setItem('solar-progress-' + student.name, JSON.stringify([...visited])); }, 2000);
The Full Code
Save as index.html and open it through any local web server (for example npx serve .), or hit Launch. Three.js r160 loads from a CDN, so there are no files to download. Sign in with any name and a class code, click the scene to fly with the mouse, and aim the crosshair at a planet.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Solar System Classroom - Three.js Spaceship Educational Game</title>
<style>
body { margin: 0; overflow: hidden; background: #000; font-family: system-ui, sans-serif; color: #fff; }
#login { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
background: radial-gradient(rgba(10,20,40,.85), rgba(0,0,0,.95)); z-index: 10; }
#login form { background: #11182b; border: 1px solid #2ad1d6; padding: 2rem; border-radius: 12px; display: grid; gap: .75rem; width: 280px; }
#login input, #login button { padding: .6rem; border-radius: 6px; border: none; font-size: 1rem; }
#login button { background: #2ad1d6; color: #001; font-weight: 700; cursor: pointer; }
#login p { margin: 0; font-size: .8rem; color: #9ab; line-height: 1.4; }
#planet-info { position: fixed; pointer-events: none; display: none; max-width: 260px; z-index: 5;
background: rgba(10,16,30,.92); border: 1px solid #2ad1d6; border-radius: 10px; padding: .8rem 1rem; font-size: .9rem; }
#planet-info h3 { margin: 0 0 .4rem; color: #2ad1d6; }
#solar-hud { position: fixed; bottom: 12px; left: 12px; font-size: .85rem; background: rgba(0,0,0,.5); padding: 6px 10px; border-radius: 8px; }
#student { position: fixed; top: 12px; left: 12px; font-size: .85rem; opacity: .85; }
</style>
</head>
<body>
<div id="login">
<form id="login-form">
<h2>Mission Control</h2>
<input id="student-name" placeholder="Student name" required />
<input id="class-code" placeholder="Class code (e.g. SPACE101)" required />
<button type="submit">Launch</button>
<p>Fly: W/S throttle, A/D turn, arrow keys pitch, E/Q climb, Shift boost. Fly close to a planet (or hover it) to learn about it.</p>
</form>
</div>
<div id="student"></div>
<div id="solar-hud"></div>
<div id="planet-info"></div>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
} }
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
// ---------- 1. Renderer, scene, camera ----------
const isMobile = Math.min(innerWidth, innerHeight) < 600 || matchMedia('(pointer: coarse)').matches;
const renderer = new THREE.WebGLRenderer({ antialias: !isMobile, alpha: false });
renderer.setPixelRatio(Math.min(devicePixelRatio, isMobile ? 1.5 : 2));
renderer.setSize(innerWidth, innerHeight);
renderer.domElement.id = 'renderer';
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x02030a);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 3000);
camera.position.set(0, 28, 78);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.35;
controls.maxDistance = 260;
controls.enabled = true;
// ---------- 2. Learning content: one object per body ----------
const PLANETS = [
{ name: 'Mercury', color: 0xb1aaa0, radius: 0.4, distance: 8, speed: 4.15, type: 'Rocky planet', day: '59 Earth days', year: '88 Earth days', moons: '0', fact: 'The smallest planet and the closest to the Sun.' },
{ name: 'Venus', color: 0xe6c27a, radius: 0.9, distance: 11, speed: 1.62, type: 'Rocky planet', day: '243 Earth days', year: '225 Earth days', moons: '0', fact: 'The hottest planet: its thick carbon dioxide atmosphere traps heat.' },
{ name: 'Earth', color: 0x3a7bd5, radius: 1.0, distance: 15, speed: 1.0, type: 'Rocky planet', day: '24 hours', year: '365 days', moons: '1', fact: 'The only known planet with liquid surface water and life.' },
{ name: 'Mars', color: 0xc1440e, radius: 0.55, distance: 19, speed: 0.53, type: 'Rocky planet', day: '24.6 hours', year: '687 Earth days', moons: '2', fact: 'Home to Olympus Mons, the tallest volcano in the solar system.' },
{ name: 'Jupiter', color: 0xd8b48a, radius: 2.8, distance: 28, speed: 0.084, type: 'Gas giant', day: 'about 10 hours', year: 'about 12 Earth years', moons: '95+', fact: 'The largest planet; the Great Red Spot is a giant storm.' },
{ name: 'Saturn', color: 0xe3d3a3, radius: 2.3, distance: 38, speed: 0.034, type: 'Gas giant', ring: true, day: 'about 10.7 hours', year: 'about 29 Earth years', moons: '140+', fact: 'Its rings are made mostly of ice and rock.' },
{ name: 'Uranus', color: 0x9fd9e8, radius: 1.6, distance: 47, speed: 0.012, type: 'Ice giant', day: 'about 17 hours', year: 'about 84 Earth years', moons: '27', fact: 'It rotates on its side, tilted about 98 degrees.' },
{ name: 'Neptune', color: 0x3f5ccf, radius: 1.55, distance: 55, speed: 0.006, type: 'Ice giant', day: 'about 16 hours', year: 'about 165 Earth years', moons: '16', fact: 'It has the fastest winds measured in the solar system.' }
];
// ---------- 3. VFX helpers: canvas textures for glows, planets and rings ----------
function canvasTex(w, h, draw) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
draw(c.getContext('2d'), w, h);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
function glowTex(stops) {
return canvasTex(256, 256, (g, w) => {
const r = g.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2);
stops.forEach(([o, c]) => r.addColorStop(o, c)); g.fillStyle = r; g.fillRect(0, 0, w, w);
});
}
function glowSprite(tex, size, opacity) {
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, color: 0xffffff, transparent: true, opacity,
blending: THREE.AdditiveBlending, depthWrite: false }));
sp.scale.setScalar(size); return sp;
}
function shade(hex, f) { const c = new THREE.Color(hex).multiplyScalar(f); return '#' + c.getHexString(); }
function planetTex(p) {
return canvasTex(512, 256, (g, w, h) => {
g.fillStyle = '#' + new THREE.Color(p.color).getHexString(); g.fillRect(0, 0, w, h);
if (p.type !== 'Rocky planet') { // gas / ice giants: soft latitude bands
for (let y = 0; y < h; y += 2) {
const v = Math.sin(y * 0.11) * 0.5 + Math.sin(y * 0.037 + 1.3) * 0.35 + Math.sin(y * 0.29) * 0.15;
g.fillStyle = shade(p.color, 0.82 + v * 0.22); g.globalAlpha = 0.6; g.fillRect(0, y, w, 2);
}
g.globalAlpha = 1;
if (p.name === 'Jupiter') { g.fillStyle = '#b5532f'; g.beginPath(); g.ellipse(w * 0.62, h * 0.64, 34, 15, 0, 0, Math.PI * 2); g.fill(); }
} else { // rocky worlds: mottled craters / continents
for (let i = 0; i < 900; i++) {
const r = Math.random() * 10 + 2;
g.fillStyle = shade(p.color, 0.65 + Math.random() * 0.6); g.globalAlpha = 0.25;
g.beginPath(); g.arc(Math.random() * w, Math.random() * h, r, 0, Math.PI * 2); g.fill();
}
g.globalAlpha = 1;
if (p.name === 'Earth') {
g.fillStyle = '#3f8f4a';
for (let i = 0; i < 26; i++) { g.beginPath(); g.ellipse(Math.random() * w, h * 0.2 + Math.random() * h * 0.6, 14 + Math.random() * 40, 8 + Math.random() * 22, Math.random() * 3, 0, Math.PI * 2); g.fill(); }
g.fillStyle = 'rgba(255,255,255,0.9)'; g.fillRect(0, 0, w, 14); g.fillRect(0, h - 14, w, 14);
}
}
});
}
const softDot = glowTex([[0, 'rgba(255,255,255,1)'], [0.25, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]);
// ---------- 4. Sun: animated plasma surface, corona glow, light ----------
const sunMat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 } },
vertexShader: 'varying vec3 vP; varying vec3 vN; varying vec3 vV; void main(){ vP=position; vN=normalize(normalMatrix*normal); vec4 mv=modelViewMatrix*vec4(position,1.0); vV=normalize(-mv.xyz); gl_Position=projectionMatrix*mv; }',
fragmentShader: [
'uniform float uTime; varying vec3 vP; varying vec3 vN; varying vec3 vV;',
'float h(vec3 p){return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453);}',
'float n(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.0-2.0*f);return mix(mix(mix(h(i),h(i+vec3(1,0,0)),f.x),mix(h(i+vec3(0,1,0)),h(i+vec3(1,1,0)),f.x),f.y),mix(mix(h(i+vec3(0,0,1)),h(i+vec3(1,0,1)),f.x),mix(h(i+vec3(0,1,1)),h(i+vec3(1,1,1)),f.x),f.y),f.z);}',
'void main(){ vec3 p=vP*0.9; float t=uTime*0.25;',
' float g=n(p*1.5+t)*0.5+n(p*3.2-t*1.3)*0.3+n(p*7.0+t*2.0)*0.2;',
' vec3 col=mix(vec3(1.0,0.35,0.02),vec3(1.0,0.85,0.35),g); col=mix(col,vec3(1.0,0.97,0.8),smoothstep(0.62,0.85,g));',
' float limb=pow(max(dot(vN,vV),0.0),0.45); col*=0.65+0.9*limb;',
' gl_FragColor=vec4(col*1.6,1.0); }'].join('\n')
});
const sun = new THREE.Mesh(new THREE.SphereGeometry(4, 64, 64), sunMat);
sun.userData = { name: 'The Sun', type: 'Star (G-type)', fact: 'It holds about 99.8 percent of the mass of the whole solar system.' };
scene.add(sun);
const corona = glowSprite(glowTex([[0, 'rgba(255,240,200,1)'], [0.18, 'rgba(255,190,80,0.8)'], [0.4, 'rgba(255,120,30,0.25)'], [1, 'rgba(255,80,0,0)']]), 22, 0.9);
const halo = glowSprite(glowTex([[0, 'rgba(255,170,90,0.5)'], [0.5, 'rgba(255,110,40,0.12)'], [1, 'rgba(0,0,0,0)']]), 60, 0.7);
scene.add(corona, halo);
const glow = corona;
const sunLight = new THREE.PointLight(0xfff1dd, 420, 0, 1.6);
scene.add(sunLight);
scene.add(new THREE.AmbientLight(0x223044, 0.6));
scene.add(new THREE.HemisphereLight(0x3355aa, 0x110a05, 0.25));
// ---------- 5. Twinkling coloured starfield + nebula clouds ----------
const starCount = isMobile ? 2500 : 6000;
const starPos = new Float32Array(starCount * 3), starCol = new Float32Array(starCount * 3), starSeed = new Float32Array(starCount);
const tints = [[0.7, 0.8, 1.0], [1.0, 0.95, 0.85], [1.0, 0.8, 0.6], [0.8, 0.9, 1.0]];
for (let i = 0; i < starCount; i++) {
const v = new THREE.Vector3(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).normalize().multiplyScalar(500 + Math.random() * 600);
starPos.set([v.x, v.y, v.z], i * 3); starCol.set(tints[i % 4], i * 3); starSeed[i] = Math.random();
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(starCol, 3));
starGeo.setAttribute('aSeed', new THREE.BufferAttribute(starSeed, 1));
const starMat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 }, uPR: { value: renderer.getPixelRatio() } }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: 'attribute float aSeed; attribute vec3 color; uniform float uTime; uniform float uPR; varying float vA; varying vec3 vC;' +
'void main(){ vC=color; vA=0.35+0.65*(0.6+0.4*sin(uTime*(1.0+aSeed*3.0)+aSeed*60.0)); gl_PointSize=(1.0+3.0*aSeed*aSeed)*uPR; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }',
fragmentShader: 'varying float vA; varying vec3 vC; void main(){ float d=length(gl_PointCoord-0.5); gl_FragColor=vec4(vC, vA*smoothstep(0.5,0.0,d)); }'
});
scene.add(new THREE.Points(starGeo, starMat));
const nebulaCols = ['rgba(120,60,200,', 'rgba(40,140,200,', 'rgba(200,60,120,', 'rgba(40,190,170,'];
for (let i = 0; i < 7; i++) {
const c = nebulaCols[i % 4];
const neb = glowSprite(glowTex([[0, c + '0.35)'], [0.5, c + '0.1)'], [1, c + '0)']]), 380 + Math.random() * 300, 0.55);
neb.material.blending = THREE.NormalBlending;
const v = new THREE.Vector3(Math.random() * 2 - 1, (Math.random() - 0.5) * 0.8, Math.random() * 2 - 1).normalize().multiplyScalar(700);
neb.position.copy(v); scene.add(neb);
}
// ---------- 6. Planets: textures, atmospheres, rings, glowing orbit trails ----------
const ATMOS = { Venus: 0xffd27a, Earth: 0x5fb4ff, Mars: 0xff7a4a, Jupiter: 0xffd6a0, Uranus: 0x9ff3ff, Neptune: 0x5f8cff };
function atmosphere(radius, color) {
return new THREE.Mesh(new THREE.SphereGeometry(radius * 1.18, 48, 48), new THREE.ShaderMaterial({
uniforms: { uColor: { value: new THREE.Color(color) } }, transparent: true, depthWrite: false, side: THREE.BackSide, blending: THREE.AdditiveBlending,
vertexShader: 'varying vec3 vN; varying vec3 vV; void main(){ vN=normalize(normalMatrix*normal); vec4 mv=modelViewMatrix*vec4(position,1.0); vV=normalize(-mv.xyz); gl_Position=projectionMatrix*mv; }',
fragmentShader: 'uniform vec3 uColor; varying vec3 vN; varying vec3 vV; void main(){ float f=pow(1.0-abs(dot(vN,vV)),2.5); gl_FragColor=vec4(uColor*f*0.8, f); }'
}));
}
const pivots = [];
const hoverables = [sun];
const trailMats = [];
for (const p of PLANETS) {
const pivot = new THREE.Object3D();
pivot.rotation.y = Math.random() * Math.PI * 2;
scene.add(pivot);
const mat = new THREE.MeshStandardMaterial({ map: planetTex(p), roughness: 0.85, metalness: 0.0, emissive: new THREE.Color(p.color), emissiveIntensity: 0.04 });
const mesh = new THREE.Mesh(new THREE.SphereGeometry(p.radius, 48, 48), mat);
mesh.position.x = p.distance;
mesh.rotation.z = p.name === 'Uranus' ? 1.7 : 0.4;
mesh.userData = p;
pivot.add(mesh);
hoverables.push(mesh);
if (ATMOS[p.name]) { const at = atmosphere(p.radius, ATMOS[p.name]); at.position.copy(mesh.position); pivot.add(at); }
if (p.ring) {
// Saturn rings: procedural multi-band translucent shader with Cassini division and planet shadow
const rIn = p.radius * 1.24, rOut = p.radius * 2.35;
const ringMat = new THREE.ShaderMaterial({
uniforms: { uIn: { value: rIn }, uOut: { value: rOut }, uPlanet: { value: new THREE.Vector3() }, uR: { value: p.radius } },
transparent: true, depthWrite: false, side: THREE.DoubleSide,
vertexShader: 'varying vec3 vW; varying float vRad; void main(){ vRad=length(position.xy); vec4 w=modelMatrix*vec4(position,1.0); vW=w.xyz; gl_Position=projectionMatrix*viewMatrix*w; }',
fragmentShader: [
'uniform float uIn; uniform float uOut; uniform vec3 uPlanet; uniform float uR; varying vec3 vW; varying float vRad;',
'float hh(float x){return fract(sin(x*91.17)*43758.5453);}',
'float bn(float x){float i=floor(x),f=fract(x);return mix(hh(i),hh(i+1.0),f*f*(3.0-2.0*f));}',
'void main(){ float t=(vRad-uIn)/(uOut-uIn); float aa=fwidth(t)*1.5;',
' float edge=smoothstep(0.0,aa,t)*smoothstep(1.0,1.0-aa,t);',
' float d=0.55+0.45*bn(t*60.0)*0.6+0.25*bn(t*190.0);', // fine ringlets
' d*=mix(0.18,1.0,smoothstep(0.16,0.2,t));', // faint C ring
' d*=1.0-0.92*(smoothstep(0.6,0.61,t)-smoothstep(0.665,0.675,t));', // Cassini division
' d*=1.0-0.8*(smoothstep(0.885,0.89,t)-smoothstep(0.9,0.905,t));', // Encke gap
' d*=mix(1.0,0.7,smoothstep(0.68,1.0,t));', // thinner A ring
' vec3 ice=vec3(0.93,0.88,0.78), rock=vec3(0.62,0.5,0.38);',
' vec3 col=mix(rock,ice,smoothstep(0.15,0.55,t)*(0.6+0.4*bn(t*35.0)));',
' vec3 dir=normalize(-vW); vec3 oc=uPlanet-vW; float tca=dot(oc,dir);', // planet shadow on the rings
' float miss=length(oc-dir*tca); float sh=(tca>0.0)?smoothstep(uR*0.92,uR*1.02,miss):1.0;',
' col*=0.25+0.75*sh;',
' gl_FragColor=vec4(col*0.8, clamp(d,0.0,1.0)*0.85*edge); }'].join('\n')
});
const ring = new THREE.Mesh(new THREE.RingGeometry(rIn, rOut, 192, 4), ringMat);
ring.rotation.x = -Math.PI / 2.4;
ring.position.copy(mesh.position);
ring.renderOrder = 2;
ring.onBeforeRender = () => mesh.getWorldPosition(ringMat.uniforms.uPlanet.value);
pivot.add(ring);
}
// faint full orbit + bright fading trail that rides behind the planet
const pts = new THREE.EllipseCurve(0, 0, p.distance, p.distance).getPoints(160).map(v => new THREE.Vector3(v.x, 0, v.y));
scene.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x2a3a5c, transparent: true, opacity: 0.45 })));
const arc = Math.min(1.6, 9 / p.distance), seg = 80, tp = [], tc = [];
const col = new THREE.Color(ATMOS[p.name] || p.color);
for (let i = 0; i <= seg; i++) {
const a = (i / seg) * arc, f = Math.pow(1 - i / seg, 1.6);
tp.push(p.distance * Math.cos(a), 0, p.distance * Math.sin(a));
tc.push(col.r * f, col.g * f, col.b * f);
}
const tg = new THREE.BufferGeometry();
tg.setAttribute('position', new THREE.Float32BufferAttribute(tp, 3));
tg.setAttribute('color', new THREE.Float32BufferAttribute(tc, 3));
const tm = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
pivot.add(new THREE.Line(tg, tm));
pivots.push({ pivot, mesh, speed: p.speed });
}
// asteroid belt between Mars and Jupiter
const beltN = isMobile ? 1200 : 2600, beltPos = new Float32Array(beltN * 3);
for (let i = 0; i < beltN; i++) {
const a = Math.random() * Math.PI * 2, r = 22 + Math.random() * 3.2 + (Math.random() - 0.5) * 1.5;
beltPos.set([Math.cos(a) * r, (Math.random() - 0.5) * 0.8, Math.sin(a) * r], i * 3);
}
const beltGeo = new THREE.BufferGeometry(); beltGeo.setAttribute('position', new THREE.BufferAttribute(beltPos, 3));
const belt = new THREE.Points(beltGeo, new THREE.PointsMaterial({ color: 0xb8a58c, size: 0.22, map: softDot, transparent: true, depthWrite: false, opacity: 0.85 }));
scene.add(belt);
// hover highlight: pulsing cyan ring that faces the camera
const highlight = new THREE.Mesh(new THREE.RingGeometry(1.25, 1.42, 64), new THREE.MeshBasicMaterial({ color: 0x2ad1d6, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }));
highlight.visible = false; scene.add(highlight);
// ---------- 6b. Comet (adapted from /tutorials/comet-particle-system.html) ----------
// Rocky nucleus + coma + dust tail and blue ion tail that always point away from the Sun,
// on an eccentric orbit that speeds up near the Sun.
const comet = (() => {
const S = 0.32; // scale of the original comet scene
const N_DUST = isMobile ? 700 : 1600, N_ION = isMobile ? 400 : 900, N = N_DUST + N_ION;
const group = new THREE.Group(); scene.add(group);
const SUN = new THREE.Vector3(1, 0, 0); // local direction toward the Sun, updated each frame
const nucleus = new THREE.Mesh(new THREE.IcosahedronGeometry(0.75, 2), new THREE.MeshStandardMaterial({ color: 0x8a8f99, roughness: 1, flatShading: true }));
const np = nucleus.geometry.attributes.position, t3 = new THREE.Vector3();
for (let k = 0; k < np.count; k++) { t3.fromBufferAttribute(np, k); t3.multiplyScalar(0.75 + Math.sin(t3.x * 5) * Math.cos(t3.y * 4) * 0.18); t3.x *= 1.3; np.setXYZ(k, t3.x, t3.y, t3.z); }
nucleus.geometry.computeVertexNormals();
nucleus.userData = { name: 'Comet', type: 'Icy small body', bonus: true, fact: 'A comet is a ball of ice and dust. Near the Sun it grows a glowing coma, a curved dust tail and a straight blue ion tail that always points away from the Sun.' };
group.add(nucleus);
const coma = glowSprite(glowTex([[0, 'rgba(220,240,255,1)'], [0.2, 'rgba(160,210,255,0.55)'], [1, 'rgba(80,140,255,0)']]), 7, 0.85);
group.add(coma);
const pos = new Float32Array(N * 3), vel = new Float32Array(N * 3), life = new Float32Array(N), maxL = new Float32Array(N), size = new Float32Array(N), type = new Float32Array(N);
for (let i = 0; i < N; i++) type[i] = i < N_DUST ? 0 : 1;
const side = new THREE.Vector3();
const rnd = (a, b) => a + Math.random() * (b - a);
function spawn(i) {
const i3 = i * 3, t = type[i];
const u = new THREE.Vector3(rnd(-1, 1), rnd(-1, 1), rnd(-1, 1)).normalize();
if (t === 0 && u.dot(SUN) < 0) u.addScaledVector(SUN, 1.2).normalize();
const r0 = rnd(0.5, 1.0);
pos[i3] = u.x * r0; pos[i3 + 1] = u.y * r0; pos[i3 + 2] = u.z * r0;
if (t === 0) { vel[i3] = (u.x * 0.05 - SUN.x * 0.08) * S; vel[i3 + 1] = (u.y * 0.05 - SUN.y * 0.08) * S; vel[i3 + 2] = (u.z * 0.05 - SUN.z * 0.08) * S; maxL[i] = rnd(160, 300); size[i] = rnd(0.6, 1.8) * S * 1.6; }
else { const s = rnd(0.35, 0.6) * S; vel[i3] = -SUN.x * s + u.x * 0.004; vel[i3 + 1] = -SUN.y * s + u.y * 0.004; vel[i3 + 2] = -SUN.z * s + u.z * 0.004; maxL[i] = rnd(90, 150); size[i] = rnd(0.5, 1.3) * S * 1.6; }
life[i] = maxL[i] * (0.2 + 0.8 * Math.random());
}
function stepOne(i) {
const i3 = i * 3, age = maxL[i] - life[i];
if (type[i] === 0) { const a = (0.0003 + age * 0.000002) * S; vel[i3] += side.x * a - SUN.x * 0.0006 * S; vel[i3 + 1] += side.y * a - SUN.y * 0.0006 * S; vel[i3 + 2] += side.z * a - SUN.z * 0.0006 * S; }
pos[i3] += vel[i3]; pos[i3 + 1] += vel[i3 + 1]; pos[i3 + 2] += vel[i3 + 2]; life[i]--;
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage));
const lifeAttr = new THREE.BufferAttribute(new Float32Array(N), 1).setUsage(THREE.DynamicDrawUsage);
g.setAttribute('aLife', lifeAttr); g.setAttribute('aSize', new THREE.BufferAttribute(size, 1)); g.setAttribute('aType', new THREE.BufferAttribute(type, 1));
for (let i = 0; i < N; i++) spawn(i);
const mat = new THREE.ShaderMaterial({
uniforms: { uScale: { value: 1 } }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: 'attribute float aLife; attribute float aSize; attribute float aType; uniform float uScale; varying float vLife; varying float vType;' +
'void main(){ vLife=aLife; vType=aType; vec4 mv=modelViewMatrix*vec4(position,1.0); float grow=aType<0.5?(1.0+2.5*(1.0-aLife)):(1.0+1.2*(1.0-aLife)); gl_PointSize=min(aSize*grow*uScale/max(-mv.z,0.1),64.0); gl_Position=projectionMatrix*mv; }',
fragmentShader: 'varying float vLife; varying float vType; void main(){ vec2 p=gl_PointCoord*2.0-1.0; float d=dot(p,p); if(d>1.0) discard;' +
' float fade=pow(vLife,0.9)*smoothstep(0.0,0.3,1.0-vLife); vec3 c; float a;' +
' if(vType<0.5){ c=mix(vec3(1.0,0.93,0.78),vec3(0.95,0.75,0.45),1.0-vLife); a=exp(-d*3.0)*0.35*fade; }' +
' else { c=mix(vec3(0.55,0.8,1.0),vec3(0.2,0.4,1.0),1.0-vLife); a=exp(-d*4.0)*0.8*fade; }' +
' gl_FragColor=vec4(c*a,a); }'
});
const tails = new THREE.Points(g, mat); tails.frustumCulled = false; group.add(tails);
// eccentric orbit, tilted out of the planet plane
const A = 46, E = 0.78, B = A * Math.sqrt(1 - E * E), tilt = new THREE.Euler(0.35, 0.6, 0.15);
let theta = 2.2;
const wp = new THREE.Vector3();
function update(dt) {
const r = A * (1 - E * E) / (1 + E * Math.cos(theta));
theta += dt * Math.min(0.25, 160 / (r * r)); // Kepler-ish, slow majestic pass (faster near the Sun, capped)
wp.set(r * Math.cos(theta), 0, r * Math.sin(theta)).applyEuler(tilt);
group.position.copy(wp);
SUN.copy(wp).negate().normalize();
side.crossVectors(SUN, new THREE.Vector3(0, 1, 0)).normalize();
const heat = THREE.MathUtils.clamp(30 / r, 0.35, 1.6); // brighter tail close to the Sun
coma.material.opacity = 0.5 + 0.35 * heat;
const la = lifeAttr.array;
for (let i = 0; i < N; i++) { stepOne(i); if (life[i] <= 0) spawn(i); la[i] = life[i] / maxL[i]; }
g.attributes.position.needsUpdate = true; lifeAttr.needsUpdate = true;
nucleus.rotation.y += dt * 0.6;
}
const orbitPts = [];
for (let i = 0; i <= 256; i++) { const th = i / 256 * Math.PI * 2, r = A * (1 - E * E) / (1 + E * Math.cos(th)); orbitPts.push(new THREE.Vector3(r * Math.cos(th), 0, r * Math.sin(th)).applyEuler(tilt)); }
const cOrbit = new THREE.Line(new THREE.BufferGeometry().setFromPoints(orbitPts), new THREE.LineDashedMaterial({ color: 0x4a8fd0, dashSize: 1.2, gapSize: 1.2, transparent: true, opacity: 0.35 }));
cOrbit.computeLineDistances(); scene.add(cOrbit);
hoverables.push(nucleus);
return { update, mat, nucleus };
})();
// ---------- 6c. Bloom post-processing ----------
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), isMobile ? 0.6 : 0.8, 0.5, 0.86);
composer.addPass(bloom);
composer.addPass(new OutputPass());
function sizeFX() {
composer.setSize(innerWidth, innerHeight);
comet.mat.uniforms.uScale.value = innerHeight * renderer.getPixelRatio() / (2 * Math.tan(camera.fov * Math.PI / 360));
}
sizeFX();
// ---------- 7. Third-person spaceship: fly to every planet ----------
// W/S throttle, A/D turn, Up/Down arrows pitch, E/Q climb, Shift boost. Touch: drag to steer, hold to fly.
const ship = new THREE.Group();
const model = new THREE.Group(); ship.add(model);
{
const hull = new THREE.MeshStandardMaterial({ color: 0xdfe6ef, metalness: 0.6, roughness: 0.35 });
const trim = new THREE.MeshStandardMaterial({ color: 0x2ad1d6, emissive: 0x2ad1d6, emissiveIntensity: 0.8 });
const dark = new THREE.MeshStandardMaterial({ color: 0x1b2230, metalness: 0.5, roughness: 0.5 });
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.32, 1.6, 12), hull); body.rotation.x = -Math.PI / 2; model.add(body);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.7, 12), hull); nose.rotation.x = -Math.PI / 2; nose.position.z = -1.15; model.add(nose);
const cockpit = new THREE.Mesh(new THREE.SphereGeometry(0.16, 16, 12, 0, Math.PI * 2, 0, Math.PI / 2), new THREE.MeshStandardMaterial({ color: 0x66ccff, emissive: 0x114466, metalness: 0.2, roughness: 0.1 }));
cockpit.position.set(0, 0.16, -0.45); cockpit.scale.set(1, 0.8, 1.8); model.add(cockpit);
const wingGeo = new THREE.BoxGeometry(1.5, 0.05, 0.55);
for (const sx of [-1, 1]) {
const w = new THREE.Mesh(wingGeo, dark); w.position.set(sx * 0.75, -0.04, 0.3); w.rotation.y = sx * 0.25; model.add(w);
const tip = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.5), trim); tip.position.set(sx * 1.45, -0.04, 0.45); model.add(tip);
}
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.45, 0.45), dark); fin.position.set(0, 0.28, 0.6); model.add(fin);
const nozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.26, 0.2, 12, 1, true), dark); nozzle.rotation.x = Math.PI / 2; nozzle.position.z = 0.88; model.add(nozzle);
}
const engineGlow = glowSprite(glowTex([[0, 'rgba(220,250,255,1)'], [0.25, 'rgba(80,200,255,0.7)'], [1, 'rgba(0,80,255,0)']]), 1.2, 0.9);
engineGlow.position.z = 1.05; ship.add(engineGlow);
ship.visible = false;
scene.add(ship);
// engine exhaust: recycled additive particles left behind in world space
const EXN = 260, exPos = new Float32Array(EXN * 3), exLife = new Float32Array(EXN);
const exGeo = new THREE.BufferGeometry(); exGeo.setAttribute('position', new THREE.BufferAttribute(exPos, 3).setUsage(THREE.DynamicDrawUsage));
const exhaust = new THREE.Points(exGeo, new THREE.PointsMaterial({ color: 0x66d9ff, size: 0.16, map: softDot, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
exhaust.frustumCulled = false; scene.add(exhaust);
let exIdx = 0;
// guide arrow that points at the next unexplored planet
const guide = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.5, 10), new THREE.MeshBasicMaterial({ color: 0x2ad1d6, transparent: true, opacity: 0.85 }));
guide.visible = false; scene.add(guide);
const keys = {};
addEventListener('keydown', e => { keys[e.code] = true; if (document.body.classList.contains('demo-active') && e.code.startsWith('Arrow')) e.preventDefault(); });
addEventListener('keyup', e => { keys[e.code] = false; });
let touchSteer = null;
addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse' && document.body.classList.contains('demo-active')) touchSteer = { x: e.clientX, y: e.clientY, dx: 0, dy: 0 }; });
addEventListener('pointermove', e => { if (touchSteer) { touchSteer.dx = (e.clientX - touchSteer.x) / 80; touchSteer.dy = (e.clientY - touchSteer.y) / 80; } });
addEventListener('pointerup', () => { touchSteer = null; });
addEventListener('pointercancel', () => { touchSteer = null; });
let shipSpeed = 0, yawRate = 0, pitchRate = 0, wasLive = false;
const fwd = new THREE.Vector3(), camGoal = new THREE.Vector3(), lookGoal = new THREE.Vector3(), tmpQ = new THREE.Quaternion();
const startCam = camera.position.clone();
function resetShip() {
ship.position.set(0, 3, 34);
ship.quaternion.identity();
shipSpeed = 0;
}
function updateFlight(dt) {
const boost = keys.ShiftLeft || keys.ShiftRight;
let thrust = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0);
let yaw = ((keys.KeyA || keys.ArrowLeft) ? 1 : 0) - ((keys.KeyD || keys.ArrowRight) ? 1 : 0);
let pitch = (keys.ArrowDown ? 1 : 0) - (keys.ArrowUp ? 1 : 0);
if (touchSteer) { thrust = 1; yaw = -THREE.MathUtils.clamp(touchSteer.dx, -1, 1); pitch = -THREE.MathUtils.clamp(touchSteer.dy, -1, 1); }
const maxSpeed = boost ? 45 : 18;
shipSpeed = THREE.MathUtils.damp(shipSpeed, thrust * maxSpeed, thrust ? 1.6 : 0.8, dt);
yawRate = THREE.MathUtils.damp(yawRate, yaw * 1.6, 6, dt);
pitchRate = THREE.MathUtils.damp(pitchRate, pitch * 1.2, 6, dt);
ship.rotateY(yawRate * dt);
ship.rotateX(pitchRate * dt);
fwd.set(0, 0, -1).applyQuaternion(ship.quaternion);
ship.position.addScaledVector(fwd, shipSpeed * dt);
ship.position.y += ((keys.KeyE ? 1 : 0) - (keys.KeyQ ? 1 : 0)) * 10 * dt;
if (ship.position.length() < 6) ship.position.setLength(6); // don't fly into the Sun
if (ship.position.length() > 240) ship.position.setLength(240);
model.rotation.z = THREE.MathUtils.damp(model.rotation.z, yawRate * 0.45, 5, dt); // bank into turns
engineGlow.scale.setScalar(0.3 + Math.abs(shipSpeed) / 70 + Math.random() * 0.08);
// exhaust particles
if (Math.abs(shipSpeed) > 0.5) for (let k = 0; k < 3; k++) {
const i3 = exIdx * 3;
const p = engineGlow.getWorldPosition(new THREE.Vector3());
exPos[i3] = p.x + (Math.random() - 0.5) * 0.2; exPos[i3 + 1] = p.y + (Math.random() - 0.5) * 0.2; exPos[i3 + 2] = p.z + (Math.random() - 0.5) * 0.2;
exLife[exIdx] = 1; exIdx = (exIdx + 1) % EXN;
}
for (let i = 0; i < EXN; i++) { exLife[i] -= dt * 1.5; if (exLife[i] <= 0) exPos[i * 3 + 1] = 1e5; }
exGeo.attributes.position.needsUpdate = true;
// third-person chase camera: behind and above the ship, smoothly following
camGoal.set(0, 1.6, 5.2).applyQuaternion(ship.quaternion).add(ship.position);
camera.position.lerp(camGoal, 1 - Math.exp(-5 * dt));
lookGoal.set(0, 0.4, -6).applyQuaternion(ship.quaternion).add(ship.position);
tmpQ.copy(camera.quaternion); camera.lookAt(lookGoal); const want = camera.quaternion.clone(); camera.quaternion.copy(tmpQ).slerp(want, 1 - Math.exp(-8 * dt));
controls.target.copy(lookGoal);
}
// ---------- 8. Learn about planets: fly close (or hover) to explore ----------
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(9, 9);
let mouseX = 0, mouseY = 0, hovered = null, nearBody = null;
const visited = new Set();
const PLANET_COUNT = PLANETS.length + 1; // eight planets + the Sun
addEventListener('pointermove', e => {
mouseX = e.clientX; mouseY = e.clientY;
pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
});
const wp2 = new THREE.Vector3();
function infoHTML(d, arrived) {
return (arrived ? '<div style="color:#2ad1d6;font-size:12px;letter-spacing:.08em;">ARRIVED</div>' : '') +
'<h3>' + d.name + '</h3><div><b>Type:</b> ' + d.type + '</div>' +
(d.day ? '<div><b>Day:</b> ' + d.day + '</div><div><b>Year:</b> ' + d.year + '</div><div><b>Moons:</b> ' + d.moons + '</div>' : '') +
'<p>' + d.fact + '</p>';
}
function nextTarget() {
let best = null, bd = Infinity;
for (const o of hoverables) {
if (o.userData.bonus || visited.has(o.userData.name)) continue;
o.getWorldPosition(wp2); const d = wp2.distanceTo(ship.position);
if (d < bd) { bd = d; best = o; }
}
return best ? { obj: best, dist: bd } : null;
}
function updateHUD(extra) {
const hud = document.getElementById('solar-hud'); if (!hud) return;
const done = visited.size === PLANET_COUNT;
const nt = done ? null : nextTarget();
hud.textContent = 'Explored ' + visited.size + ' / ' + PLANET_COUNT +
(done ? ' - Mission complete!' : (nt ? ' - Next: ' + nt.obj.userData.name + ' ' + Math.round(nt.dist) + ' units' : '')) + (extra || '');
return nt;
}
function updateHover(live) {
const info = document.getElementById('planet-info');
if (!info || !live) { if (info) info.style.display = 'none'; hovered = null; return; }
// proximity: arrive at a planet by flying close to it
nearBody = null;
for (const o of hoverables) {
o.getWorldPosition(wp2);
const r = o === sun ? 4 : (o.userData.radius || 0.8);
if (wp2.distanceTo(ship.position) < r * 2.2 + 3) { nearBody = o; break; }
}
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(hoverables, false)[0];
const obj = hit ? hit.object : nearBody;
if (obj !== hovered) {
if (hovered) { hovered.scale.setScalar(1); if (hovered.material.emissiveIntensity !== undefined) hovered.material.emissiveIntensity = hovered === comet.nucleus ? 0 : 0.04; }
hovered = obj;
if (obj) {
obj.scale.setScalar(1.15);
if (obj.material.emissiveIntensity !== undefined) obj.material.emissiveIntensity = 0.35;
const d = obj.userData;
info.innerHTML = infoHTML(d, obj === nearBody);
info.style.display = 'block';
if (!d.bonus) visited.add(d.name);
} else info.style.display = 'none';
}
if (obj) {
if (obj === nearBody && !hit) { info.style.left = '16px'; info.style.top = '70px'; }
else { info.style.left = Math.min(mouseX + 16, innerWidth - 280) + 'px'; info.style.top = Math.min(mouseY + 16, innerHeight - 200) + 'px'; }
}
}
// ---------- 9. Animate ----------
const clock = new THREE.Clock();
const tmpV = new THREE.Vector3();
renderer.setAnimationLoop(() => {
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
for (const { pivot, mesh, speed } of pivots) {
pivot.rotation.y += speed * 0.3 * dt;
mesh.rotation.y += 0.5 * dt;
}
sun.rotation.y += 0.05 * dt;
sunMat.uniforms.uTime.value = t;
starMat.uniforms.uTime.value = t;
corona.scale.setScalar(22 + Math.sin(t * 1.3) * 0.9 + Math.sin(t * 3.7) * 0.4);
belt.rotation.y += 0.02 * dt;
comet.update(dt);
const live = document.body.classList.contains('demo-active');
if (live && !wasLive) { resetShip(); ship.visible = true; }
if (!live && wasLive) { ship.visible = false; guide.visible = false; camera.position.copy(startCam); controls.target.set(0, 0, 0); }
wasLive = live;
controls.autoRotate = !live;
controls.enabled = false; // ship flight drives the camera in the demo
if (live) updateFlight(dt);
updateHover(live);
if (live) {
const nt = updateHUD(hovered && hovered.userData.bonus ? ' - bonus: comet spotted!' : '');
if (nt) { nt.obj.getWorldPosition(tmpV); guide.visible = true; guide.position.set(0, 0.9, -1.2).applyQuaternion(ship.quaternion).add(ship.position); guide.lookAt(tmpV); guide.rotateX(Math.PI / 2); }
else guide.visible = false;
}
if (hovered && live) {
hovered.getWorldPosition(tmpV);
const r = hovered === sun ? 4 : (hovered.userData.radius || 0.8);
highlight.position.copy(tmpV); highlight.quaternion.copy(camera.quaternion);
highlight.scale.setScalar(r * 1.15 * (1 + 0.06 * Math.sin(t * 5)));
highlight.visible = true;
} else highlight.visible = false;
if (!live) controls.update();
composer.render();
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
sizeFX();
});
window.__scene = { renderer, controls, camera, comet, ship, keys };
// ---------- 10. Classroom login (prototype auth: name + class code in localStorage) ----------
let student = null;
document.getElementById('login-form').addEventListener('submit', e => {
e.preventDefault();
student = {
name: document.getElementById('student-name').value.trim(),
classCode: document.getElementById('class-code').value.trim().toUpperCase()
};
JSON.parse(localStorage.getItem('solar-progress-' + student.name) || '[]').forEach(n => visited.add(n));
document.getElementById('login').remove();
document.getElementById('student').textContent = student.name + ' - ' + student.classCode;
document.body.classList.add('demo-active'); // start the spaceship game
});
setInterval(() => { if (student) localStorage.setItem('solar-progress-' + student.name, JSON.stringify([...visited])); }, 2000);
</script>
</body>
</html>
Taking It Further
- Quiz mode: "Fly to the planet with the shortest year" and check the hovered planet on click.
- Time slider: bind the orbit time scale to a range input so students control time.
- Real textures: swap the colors for planet maps with TextureLoader, like in the transparent PNG textures tutorial.
- Moons: nest a moon pivot inside Earth's mesh — the same pivot trick one level deeper.
- Teacher dashboard: with real auth, send explored planets to a backend to see class progress.
- Read the design side in Creating 3D Virtual Educational Worlds with Three.js.