| name | procedural-starfield |
| description | Generate stunning procedural night skies, starfields, nebulae, and celestial phenomena in Three.js using WebGPU compute with WebGL2 fallback. Covers scientifically-grounded starfields with spectral color temperature, magnitude-based brightness, and twinkling; volumetric nebulae with emission/absorption/reflection types; the Milky Way band; constellations with optional line overlays; planets and moons with phase lighting; shooting stars and meteor showers; aurora integration; eclipses (solar and lunar); comets with ion/dust tails; galaxies as deep-sky backdrop objects; and a full sky dome controller with time-of-night progression, moon phases, and horizon glow. Triggers: "procedural starfield", "night sky", "star rendering", "nebula", "deep space", "milky way", "constellation", "shooting star", "meteor shower", "celestial", "space background", "starbox", "skybox stars", "galaxy background", "moon phases", "comet", "eclipse", "space scene", "star shader".
|
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 },
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,
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;
this.maxMagnitude = options.maxMagnitude ?? 6.5;
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);
let rng = this.seed;
const random = () => { rng = (rng * 16807) % 2147483647; return rng / 2147483647; };
for (let i = 0; i < this.count; i++) {
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;
const mag = this.minMagnitude + Math.pow(random(), 0.4) * (this.maxMagnitude - this.minMagnitude);
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 },
baseSizePx: { value: 2.5 },
glowFalloff: { value: 0.4 },
exposure: { value: 1.0 },
},
vertexShader: STAR_VERT,
fragmentShader: STAR_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this.mesh = new THREE.Points(geometry, this.material);
this.mesh.frustumCulled = false;
this.scene.add(this.mesh);
}
_randomStarTemp(r) {
if (r < 0.003) return 30000 + r * 5000000;
if (r < 0.01) return 10000 + r * 1000000;
if (r < 0.04) return 7500 + r * 60000;
if (r < 0.11) return 6000 + r * 15000;
if (r < 0.23) return 5200 + r * 5000;
if (r < 0.50) return 3700 + r * 3000;
return 2400 + r * 2600;
}
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) {
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 },
bandTilt: { value: 0.4 },
coreGlow: { value: 0.6 },
dustLanes: { value: 0.4 },
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,
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',
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,
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');
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);
}
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;
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,
transparent: true,
});
this.mesh = new THREE.Mesh(geo, this.material);
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;
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
);
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;
this.skyRadius = options.skyRadius ?? 380;
this.meteors = [];
this._timer = 0;
}
_spawn() {
const theta = Math.random() * Math.PI * 2;
const phi = Math.random() * Math.PI * 0.4;
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
);
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));
const positions = new Float32Array(6);
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);
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();
}
for (let i = this.meteors.length - 1; i >= 0; i--) {
const m = this.meteors[i];
m.life -= dt;
const t = m.life / m.maxLife;
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);
const nucleus = new THREE.Mesh(
new THREE.SphereGeometry(1.5, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xeeeeff })
);
group.add(nucleus);
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);
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));
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 = [];
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);
const moonElev = Math.sin(this.nightProgress * Math.PI) * 60;
const moonAz = this.nightProgress * 180 - 90;
this.moon.setPhaseAndPosition(this.moon.phase, moonElev, moonAz);
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.
AdditiveBlending on all layers — no sort order needed for transparent objects.
depthWrite: false on everything except the sky dome — celestial objects never occlude each other via depth.
- Stars use
gl_PointSize with 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
renderOrder so 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.