Procedural Starfield & Celestial Phenomena
Generate breathtaking night skies in Three.js — from photorealistic starfields to dreamy nebulae to dramatic celestial events.
Architecture Overview
┌──────────────────────────────────────────────────────┐
│ Night Sky Pipeline │
│ │
│ SkyController (master orchestrator) │
│ ├── time progression (sunset → night → dawn) │
│ ├── moon phase + position │
│ └── drives all layers: │
│ │
│ ┌─ Layer 1: Sky Dome ──────────────────────────┐ │
│ │ Gradient background, horizon glow, zodiacal │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Layer 2: Stars ─────────────────────────────┐ │
│ │ Points with spectral color + magnitude │ │
│ │ Twinkle, proper motion (optional) │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Layer 3: Milky Way ─────────────────────────┐ │
│ │ Textured band or procedural FBM glow │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Layer 4: Nebulae ───────────────────────────┐ │
│ │ Volumetric raymarched or billboard sprites │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Layer 5: Celestial Bodies ──────────────────┐ │
│ │ Moon (phase lit), planets, sun glow │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Layer 6: Transients ────────────────────────┐ │
│ │ Shooting stars, comets, eclipses, satellites│ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Layer 7: Deep Space (optional) ─────────────┐ │
│ │ Distant galaxies, star clusters, dust lanes │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Renderer Setup
import * as THREE from 'three';
async function createRenderer(canvas) {
let renderer, gpuAvailable = false;
try {
const WebGPU = (await import('three/addons/capabilities/WebGPU.js')).default;
if (WebGPU.isAvailable()) {
const { default: WebGPURenderer } = await import(
'three/addons/renderers/webgpu/WebGPURenderer.js'
);
renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
gpuAvailable = true;
}
} catch (e) {}
if (!renderer) {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
}
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
return { renderer, gpuAvailable };
}
Layer 1: Sky Dome
Gradient hemisphere that transitions from deep zenith to warm horizon glow, with optional light pollution and twilight blending.
function createSkyDome(radius = 500) {
const geo = new THREE.SphereGeometry(radius, 32, 16);
const material = new THREE.ShaderMaterial({
uniforms: {
zenithColor: { value: new THREE.Color(0x020010) },
midColor: { value: new THREE.Color(0x0a0a2a) },
horizonColor: { value: new THREE.Color(0x15102a) },
horizonGlow: { value: new THREE.Color(0x1a1530) },
glowStrength: { value: 0.3 },
lightPollution: { value: 0.0 }, // 0=pristine, 1=urban
moonPos: { value: new THREE.Vector3(0, 0.5, -1).normalize() },
moonGlowStr: { value: 0.15 },
},
vertexShader: `
varying vec3 vWorldDir;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldDir = normalize(worldPos.xyz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: SKY_DOME_FRAG, // See references/celestial-shaders.md
side: THREE.BackSide,
depthWrite: false,
});
return new THREE.Mesh(geo, material);
}
Layer 2: Starfield
The heart of the night sky. Each star is a Points particle with scientifically-
grounded color (spectral class → blackbody temperature), magnitude-based size/brightness,
and animated twinkle.
Star Generation
class Starfield {
constructor(scene, options = {}) {
this.scene = scene;
this.count = options.count ?? 8000;
this.radius = options.radius ?? 400;
this.minMagnitude = options.minMagnitude ?? -1.5; // Brightest (Sirius)
this.maxMagnitude = options.maxMagnitude ?? 6.5; // Faintest visible
this.twinkleSpeed = options.twinkleSpeed ?? 1.0;
this.seed = options.seed ?? 42;
this._build();
}
_build() {
const positions = new Float32Array(this.count * 3);
const starData = new Float32Array(this.count * 4); // r, g, b, magnitude
let rng = this.seed;
const random = () => { rng = (rng * 16807) % 2147483647; return rng / 2147483647; };
for (let i = 0; i < this.count; i++) {
// Uniform distribution on sphere
const theta = random() * Math.PI * 2;
const phi = Math.acos(2 * random() - 1);
positions[i * 3] = Math.sin(phi) * Math.cos(theta) * this.radius;
positions[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * this.radius;
positions[i * 3 + 2] = Math.cos(phi) * this.radius;
// Magnitude: exponential distribution (many faint, few bright)
const mag = this.minMagnitude + Math.pow(random(), 0.4) * (this.maxMagnitude - this.minMagnitude);
// Spectral color from temperature
const temp = this._randomStarTemp(random());
const col = blackbodyColor(temp);
starData[i * 4] = col.r;
starData[i * 4 + 1] = col.g;
starData[i * 4 + 2] = col.b;
starData[i * 4 + 3] = mag;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aStarData', new THREE.BufferAttribute(starData, 4));
this.material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
twinkleSpeed: { value: this.twinkleSpeed },
brightnessExp: { value: 2.5 }, // Magnitude → size exponent
baseSizePx: { value: 2.5 },
glowFalloff: { value: 0.4 },
exposure: { value: 1.0 },
},
vertexShader: STAR_VERT, // See references/celestial-shaders.md
fragmentShader: STAR_FRAG, // See references/celestial-shaders.md
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this.mesh = new THREE.Points(geometry, this.material);
this.mesh.frustumCulled = false;
this.scene.add(this.mesh);
}
// Realistic star temperature distribution
_randomStarTemp(r) {
// Weighted toward cooler stars (M/K/G), fewer hot O/B
if (r < 0.003) return 30000 + r * 5000000; // O: blue-white
if (r < 0.01) return 10000 + r * 1000000; // B: blue
if (r < 0.04) return 7500 + r * 60000; // A: white
if (r < 0.11) return 6000 + r * 15000; // F: yellow-white
if (r < 0.23) return 5200 + r * 5000; // G: yellow (Sun-like)
if (r < 0.50) return 3700 + r * 3000; // K: orange
return 2400 + r * 2600; // M: red
}
update(time) {
this.material.uniforms.time.value = time;
}
dispose() {
this.scene.remove(this.mesh);
this.mesh.geometry.dispose();
this.material.dispose();
}
}
Blackbody Color Function
Convert Kelvin temperature to RGB for accurate star colors:
function blackbodyColor(tempK) {
// Attempt a physically-grounded approximation (Tanner Helland algorithm)
const t = tempK / 100;
let r, g, b;
if (t <= 66) {
r = 255;
g = 99.4708025861 * Math.log(t) - 161.1195681661;
b = t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
} else {
r = 329.698727446 * Math.pow(t - 60, -0.1332047592);
g = 288.1221695283 * Math.pow(t - 60, -0.0755148492);
b = 255;
}
return new THREE.Color(
Math.min(Math.max(r, 0), 255) / 255,
Math.min(Math.max(g, 0), 255) / 255,
Math.min(Math.max(b, 0), 255) / 255
);
}
Star spectral classes and their temperatures/colors:
| Class | Temp (K) | Color | Fraction | Examples |
|---|---|---|---|---|
| O | 30,000+ | Blue-violet | 0.003% | Mintaka |
| B | 10,000–30,000 | Blue-white | 0.1% | Rigel, Spica |
| A | 7,500–10,000 | White | 0.6% | Sirius, Vega |
| F | 6,000–7,500 | Yellow-white | 3% | Procyon |
| G | 5,200–6,000 | Yellow | 8% | Sun, Alpha Centauri |
| K | 3,700–5,200 | Orange | 12% | Arcturus |
| M | 2,400–3,700 | Red-orange | 76% | Betelgeuse, Proxima |
Layer 3: Milky Way
A luminous band across the sky, rendered as a textured strip or procedural FBM glow on the sky dome.
function createMilkyWay(scene, radius = 450) {
const geo = new THREE.SphereGeometry(radius, 64, 32);
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
brightness: { value: 0.25 },
bandWidth: { value: 0.18 }, // Angular width of the band
bandTilt: { value: 0.4 }, // Tilt angle in radians
coreGlow: { value: 0.6 }, // Sagittarius core brightness
dustLanes: { value: 0.4 }, // Dark lane intensity
warmTint: { value: new THREE.Color(0xffe8cc) },
coolTint: { value: new THREE.Color(0xccddff) },
},
vertexShader: `
varying vec3 vWorldDir;
void main() {
vWorldDir = normalize((modelMatrix * vec4(position, 1.0)).xyz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: MILKY_WAY_FRAG, // See references/celestial-shaders.md
side: THREE.BackSide,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const mesh = new THREE.Mesh(geo, material);
scene.add(mesh);
return { mesh, material };
}
Layer 4: Nebulae
Volumetric Nebula (WebGPU — Raymarched)
Full 3D nebula rendered by marching through an emission/absorption density field.
function createVolumetricNebula(scene, options = {}) {
const {
position = new THREE.Vector3(100, 80, -200),
scale = 80,
type = 'emission', // emission | reflection | dark | planetary
color1 = new THREE.Color(0xff2266),
color2 = new THREE.Color(0x4466ff),
color3 = new THREE.Color(0x22ffaa),
} = options;
const geo = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.ShaderMaterial({
uniforms: {
cameraPos: { value: new THREE.Vector3() },
nebulaScale: { value: scale },
color1: { value: color1 },
color2: { value: color2 },
color3: { value: color3 },
density: { value: 0.5 },
brightness: { value: 1.2 },
noiseOctaves: { value: 5 },
time: { value: 0 },
nebulaType: { value: NEBULA_TYPE_MAP[type] },
},
vertexShader: NEBULA_VERT,
fragmentShader: NEBULA_FRAG, // See references/celestial-shaders.md
transparent: true,
depthWrite: false,
side: THREE.BackSide,
blending: THREE.AdditiveBlending,
});
const mesh = new THREE.Mesh(geo, material);
mesh.position.copy(position);
mesh.scale.setScalar(scale);
scene.add(mesh);
return { mesh, material };
}
const NEBULA_TYPE_MAP = { emission: 0, reflection: 1, dark: 2, planetary: 3 };
Billboard Nebula (WebGL Fallback)
Canvas-generated nebula sprite for cheaper rendering.
function createBillboardNebula(scene, options = {}) {
const size = options.size ?? 512;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
// Layer multiple radial gradients with noise displacement
const colors = options.colors ?? ['#ff2266', '#4466ff', '#22ffaa'];
const centers = [
{ x: size * 0.45, y: size * 0.5 },
{ x: size * 0.55, y: size * 0.45 },
{ x: size * 0.5, y: size * 0.55 },
];
ctx.globalCompositeOperation = 'screen';
for (let i = 0; i < colors.length; i++) {
const grad = ctx.createRadialGradient(
centers[i].x, centers[i].y, 0,
centers[i].x, centers[i].y, size * 0.4
);
grad.addColorStop(0, colors[i] + 'aa');
grad.addColorStop(0.3, colors[i] + '44');
grad.addColorStop(0.7, colors[i] + '11');
grad.addColorStop(1, colors[i] + '00');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, size, size);
}
// Add noise texture for structure
const imgData = ctx.getImageData(0, 0, size, size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const n = fbm2D(x / size * 8, y / size * 8, 5) * 0.5;
for (let c = 0; c < 3; c++) {
imgData.data[idx + c] = Math.min(255, imgData.data[idx + c] * (0.7 + n * 0.6));
}
imgData.data[idx + 3] = Math.min(255, imgData.data[idx + 3] * (0.5 + n * 0.5));
}
}
ctx.putImageData(imgData, 0, 0);
const tex = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({
map: tex, transparent: true, blending: THREE.AdditiveBlending,
depthWrite: false, opacity: options.opacity ?? 0.7,
});
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.setScalar(options.worldSize ?? 100);
sprite.position.copy(options.position ?? new THREE.Vector3(100, 80, -200));
scene.add(sprite);
return sprite;
}
function fbm2D(x, y, octaves) {
let sum = 0, amp = 1, freq = 1, max = 0;
for (let i = 0; i < octaves; i++) {
sum += (Math.sin(x * freq * 127.1 + y * freq * 311.7) * 0.5 + 0.5) * amp;
max += amp; amp *= 0.5; freq *= 2;
}
return sum / max;
}
Layer 5: Celestial Bodies
Moon
Sphere with phase-accurate lighting based on sun-moon angle.
class Moon {
constructor(scene, options = {}) {
this.scene = scene;
this.radius = options.radius ?? 8;
this.distance = options.distance ?? 350;
this.phase = options.phase ?? 0.0; // 0=new, 0.5=full, 1=new
const geo = new THREE.SphereGeometry(this.radius, 32, 32);
this.material = new THREE.ShaderMaterial({
uniforms: {
sunDir: { value: new THREE.Vector3() },
moonColor: { value: new THREE.Color(0xf5f0e0) },
shadowColor: { value: new THREE.Color(0x111115) },
craterScale: { value: 3.0 },
craterDepth: { value: 0.15 },
glowColor: { value: new THREE.Color(0xddeeff) },
glowStrength: { value: 0.3 },
},
vertexShader: MOON_VERT,
fragmentShader: MOON_FRAG, // See references/celestial-shaders.md
transparent: true,
});
this.mesh = new THREE.Mesh(geo, this.material);
// Glow sprite behind moon
this.glow = this._createGlow();
this.group = new THREE.Group();
this.group.add(this.glow);
this.group.add(this.mesh);
this.scene.add(this.group);
}
_createGlow() {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 128;
const ctx = canvas.getContext('2d');
const grad = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
grad.addColorStop(0, 'rgba(220,230,255,0.3)');
grad.addColorStop(0.3, 'rgba(200,215,255,0.1)');
grad.addColorStop(1, 'rgba(200,215,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 128, 128);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({
map: tex, transparent: true, depthWrite: false,
blending: THREE.AdditiveBlending,
});
const sprite = new THREE.Sprite(mat);
sprite.scale.setScalar(this.radius * 5);
return sprite;
}
setPhaseAndPosition(phase, elevation, azimuth) {
this.phase = phase;
// Position on sky dome
const elRad = elevation * Math.PI / 180;
const azRad = azimuth * Math.PI / 180;
this.group.position.set(
Math.cos(elRad) * Math.sin(azRad) * this.distance,
Math.sin(elRad) * this.distance,
Math.cos(elRad) * Math.cos(azRad) * this.distance
);
// Sun direction relative to moon for phase lighting
const phaseAngle = phase * Math.PI * 2;
this.material.uniforms.sunDir.value.set(
Math.sin(phaseAngle), 0.1, -Math.cos(phaseAngle)
).normalize();
}
dispose() { this.scene.remove(this.group); }
}
Layer 6: Transient Events
Shooting Stars / Meteors
Bright streak that flares and fades over 0.5–2 seconds.
class MeteorSystem {
constructor(scene, options = {}) {
this.scene = scene;
this.rate = options.rate ?? 0.15; // Meteors per second
this.skyRadius = options.skyRadius ?? 380;
this.meteors = [];
this._timer = 0;
}
_spawn() {
// Random start point on upper hemisphere
const theta = Math.random() * Math.PI * 2;
const phi = Math.random() * Math.PI * 0.4; // Upper sky
const start = new THREE.Vector3(
Math.sin(phi) * Math.cos(theta) * this.skyRadius,
Math.cos(phi) * this.skyRadius,
Math.sin(phi) * Math.sin(theta) * this.skyRadius
);
// Direction: roughly downward with lateral drift
const dir = new THREE.Vector3(
(Math.random() - 0.5) * 0.5,
-0.7 - Math.random() * 0.3,
(Math.random() - 0.5) * 0.5
).normalize();
const length = 15 + Math.random() * 30;
const end = start.clone().add(dir.clone().multiplyScalar(length));
// Build trail geometry
const positions = new Float32Array(6); // 2 points
positions[0] = start.x; positions[1] = start.y; positions[2] = start.z;
positions[3] = end.x; positions[4] = end.y; positions[5] = end.z;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.LineBasicMaterial({
color: 0xffffff, transparent: true, opacity: 1.0,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const line = new THREE.Line(geo, mat);
this.scene.add(line);
// Glow head
const headMat = new THREE.SpriteMaterial({
color: 0xffffcc, transparent: true, opacity: 0.8,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const head = new THREE.Sprite(headMat);
head.scale.setScalar(2);
head.position.copy(end);
this.scene.add(head);
const duration = 0.4 + Math.random() * 1.2;
this.meteors.push({ line, head, mat, headMat, life: duration, maxLife: duration });
}
update(dt) {
this._timer += dt;
if (this._timer > 1 / this.rate) {
this._timer = 0;
if (Math.random() < 0.7) this._spawn(); // Some variance
}
for (let i = this.meteors.length - 1; i >= 0; i--) {
const m = this.meteors[i];
m.life -= dt;
const t = m.life / m.maxLife;
// Flare then fade: peak brightness at t≈0.7
const brightness = t > 0.7 ? (1 - t) / 0.3 : t / 0.7;
m.mat.opacity = brightness;
m.headMat.opacity = brightness * 0.8;
if (m.life <= 0) {
this.scene.remove(m.line);
this.scene.remove(m.head);
m.line.geometry.dispose();
this.meteors.splice(i, 1);
}
}
}
dispose() {
for (const m of this.meteors) {
this.scene.remove(m.line);
this.scene.remove(m.head);
}
}
}
Comet
Bright nucleus with two tails — blue-white ion tail (straight, away from sun) and diffuse dust tail (curved, trailing orbit).
function createComet(scene, options = {}) {
const pos = options.position ?? new THREE.Vector3(-150, 100, -250);
const sunDir = options.sunDir ?? new THREE.Vector3(0.3, -0.5, 0.8).normalize();
const group = new THREE.Group();
group.position.copy(pos);
// Nucleus
const nucleus = new THREE.Mesh(
new THREE.SphereGeometry(1.5, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xeeeeff })
);
group.add(nucleus);
// Coma glow
const comaMat = new THREE.SpriteMaterial({
color: 0xccddff, transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const coma = new THREE.Sprite(comaMat);
coma.scale.setScalar(12);
group.add(coma);
// Ion tail (straight, anti-sun)
const ionDir = sunDir.clone().negate();
const ionPoints = [];
for (let i = 0; i < 50; i++) {
const t = i / 49;
ionPoints.push(ionDir.clone().multiplyScalar(t * 120));
}
const ionGeo = new THREE.BufferGeometry().setFromPoints(ionPoints);
const ionMat = new THREE.LineBasicMaterial({
color: 0x4488ff, transparent: true, opacity: 0.4,
blending: THREE.AdditiveBlending, depthWrite: false,
});
group.add(new THREE.Line(ionGeo, ionMat));
// Dust tail (curved, broader)
const dustDir = ionDir.clone().add(new THREE.Vector3(0.3, 0.1, 0)).normalize();
const dustPoints = [];
for (let i = 0; i < 40; i++) {
const t = i / 39;
const curve = dustDir.clone().multiplyScalar(t * 80);
curve.x += Math.sin(t * 2) * 10 * t;
curve.y += t * 5;
dustPoints.push(curve);
}
const dustGeo = new THREE.BufferGeometry().setFromPoints(dustPoints);
const dustMat = new THREE.LineBasicMaterial({
color: 0xffddaa, transparent: true, opacity: 0.25,
blending: THREE.AdditiveBlending, depthWrite: false,
});
group.add(new THREE.Line(dustGeo, dustMat));
scene.add(group);
return group;
}
Sky Controller
Master orchestrator that coordinates all layers based on time and configuration.
class NightSkyController {
constructor(scene, camera, options = {}) {
this.scene = scene;
this.camera = camera;
this.skyDome = createSkyDome();
this.scene.add(this.skyDome);
this.starfield = new Starfield(scene, {
count: options.starCount ?? 8000,
twinkleSpeed: options.twinkleSpeed ?? 1.0,
});
this.milkyWay = createMilkyWay(scene);
this.moon = new Moon(scene);
this.meteors = new MeteorSystem(scene, { rate: options.meteorRate ?? 0.1 });
this.nebulae = [];
// Time: 0=sunset, 0.5=midnight, 1=sunrise
this.nightProgress = options.startTime ?? 0.5;
}
addNebula(options) {
const nebula = createBillboardNebula(this.scene, options);
this.nebulae.push(nebula);
return nebula;
}
update(dt) {
const t = performance.now() * 0.001;
this.starfield.update(t);
this.meteors.update(dt);
// Moon position from night progress
const moonElev = Math.sin(this.nightProgress * Math.PI) * 60;
const moonAz = this.nightProgress * 180 - 90;
this.moon.setPhaseAndPosition(this.moon.phase, moonElev, moonAz);
// Milky Way rotation
this.milkyWay.mesh.rotation.y = t * 0.001;
}
setNightProgress(t) { this.nightProgress = Math.max(0, Math.min(1, t)); }
setMoonPhase(phase) { this.moon.phase = phase; }
dispose() {
this.starfield.dispose();
this.moon.dispose();
this.meteors.dispose();
this.scene.remove(this.skyDome);
this.scene.remove(this.milkyWay.mesh);
for (const n of this.nebulae) this.scene.remove(n);
}
}
Night Sky Presets
const SKY_PRESETS = {
pristineMountain: {
starCount: 12000, lightPollution: 0.0, milkyWayBrightness: 0.35,
meteorRate: 0.12, moonPhase: 0.0,
description: 'Remote mountain — maximum stars, vivid Milky Way, no moon',
},
fullMoonNight: {
starCount: 4000, lightPollution: 0.05, milkyWayBrightness: 0.08,
meteorRate: 0.05, moonPhase: 0.5,
description: 'Bright full moon washes out fainter stars and Milky Way',
},
suburbanSky: {
starCount: 2000, lightPollution: 0.4, milkyWayBrightness: 0.02,
meteorRate: 0.08, moonPhase: 0.25,
description: 'Light pollution hides faint stars, warm horizon glow',
},
meteorShower: {
starCount: 10000, lightPollution: 0.0, milkyWayBrightness: 0.3,
meteorRate: 0.8, moonPhase: 0.0,
description: 'Peak Perseids — constant streaks across a pristine sky',
},
deepSpace: {
starCount: 20000, lightPollution: 0.0, milkyWayBrightness: 0.5,
meteorRate: 0.02, moonPhase: 0.0, nebulae: true, galaxies: true,
description: 'Fantasy deep-space vista — dense stars, vivid nebulae, galaxies',
},
twilight: {
starCount: 1000, lightPollution: 0.1, milkyWayBrightness: 0.0,
meteorRate: 0.02, moonPhase: 0.4,
description: 'Early evening — only brightest stars and planets visible',
},
};
Performance Guidelines
| Layer | Cost | Draw Calls | Notes |
|---|---|---|---|
| Sky Dome | Negligible | 1 | BackSide sphere, simple gradient |
| Starfield | Low | 1 (Points) | 8K–20K points, all in vertex shader |
| Milky Way | Low | 1 | FBM in fragment on BackSide sphere |
| Nebula (billboard) | Low | 1 per nebula | Sprite, pre-baked canvas texture |
| Nebula (volumetric) | High | 1 | Raymarched — limit steps to 48 |
| Moon | Low | 2 | Sphere + glow sprite |
| Meteors | Negligible | 0–3 | Ephemeral, 1–2 lines active at a time |
| Comet | Low | 3 | Nucleus + 2 tail lines |
Total: Full night sky runs at 5–8 draw calls with additive blending everywhere.
Key optimizations:
- All star animation (twinkle) in vertex shader — zero JS per-star loops.
AdditiveBlendingon all layers — no sort order needed for transparent objects.depthWrite: falseon everything except the sky dome — celestial objects never occlude each other via depth.- Stars use
gl_PointSizewith magnitude-based scaling — one geometry, one draw call. - Nebula billboard is pre-baked to canvas — fragment shader is a single texture sample.
Common Pitfalls
- Stars visible through Moon / planets: Sky layers need explicit render order. Set
renderOrderso dome < stars < milky way < nebulae < moon. - Stars look like a flat grid: Ensure uniform sphere distribution using
acos(2r-1)for phi, not linear sampling. Linear creates polar clustering. - All stars same color: Must use blackbody temperature → RGB conversion. Even subtle color variation (warm yellow, cool blue) is critical for realism.
- Milky Way too bright / uniform: Use dust lane subtraction and core brightening near Sagittarius. The Milky Way is not a smooth band — it has structure.
- No sense of depth: Layer multiple elements at slightly different radii and let parallax from camera rotation create subtle depth. Nebulae at 450, stars at 400, dome at 500.
References
references/celestial-shaders.md— Complete GLSL vertex/fragment shaders for sky dome, stars, Milky Way, nebula raymarching, moon surface, and WGSL star compute.references/celestial-catalog.md— Nebula type profiles, deep-sky objects, constellation data format, and artistic direction for different sky moods.