← EasyTool.me Blog

Procedural Sky, Sunsets & Planets: A Complete Guide to Rendering the Sky with Atmospheric Scattering

Published: 2026-05-13 Reading: 15 min 3D Graphics · Shaders · Tutorial

Have you ever stared at a sunset in a video game and wondered — why is the sky blue during the day but red at sunset? Why do planets in space games have that ethereal glow around their edges? The answer lies in atmospheric scattering, and in this guide we'll show you how to render all of it programmatically using JavaScript and shaders.

This article is based on Maxime Heckel's excellent blog post "On Rendering the Sky, Sunsets, and Planets" (HN 392 points), which demonstrates how to build a real-time sky renderer from scratch. We'll dive deep into the physics, the math, and the code — from Rayleigh scattering that paints our sky blue to Mie scattering that sets sunsets ablaze with orange and red.

Why Procedural Sky Rendering Matters

In 3D graphics, the sky isn't just a background image. A procedurally generated sky changes dynamically with the sun's position, creating realistic day-night cycles, sunrise glows, and atmospheric depth. This is critical for:

Unlike using a static skybox texture, procedural generation adapts in real-time to any lighting condition, giving you infinite variety without pre-baked assets.

The Physics of Sky Color: Why the Sky Is Blue

The sky gets its color from atmospheric scattering — the interaction between sunlight and particles in Earth's atmosphere. There are two primary types you need to understand:

Rayleigh Scattering: The Blue Sky Effect

Rayleigh scattering occurs when light interacts with particles much smaller than the light's wavelength — in Earth's case, air molecules (N₂ and O₂). The key insight: scattering intensity is proportional to 1/λ⁴ (wavelength to the fourth power). This means blue light (shorter wavelength, ~450nm) scatters about 16x more than red light (longer wavelength, ~650nm).

When the sun is overhead, sunlight travels through a relatively thin layer of atmosphere. Blue light scatters in all directions, filling the sky with blue. Red light passes through mostly unscattered — hence the sun appears yellowish-white during midday.

In code, Rayleigh scattering is modeled as:

// Rayleigh scattering coefficient per wavelength
const rayleighBeta = (lambda) => {
  const n = 1.00029; // refractive index of air
  const N = 2.504e25; // molecular number density
  const pn = 0.035; // depolarization factor
  const lambdaM = lambda * 1e-9; // convert nm to meters
  return (8 * Math.PI ** 3 * (n ** 2 - 1) ** 2 * (6 + 3 * pn)) /
         (3 * N * lambdaM ** 4 * (6 - 7 * pn));
};

Mie Scattering: Why Sunsets Are Red

Mie scattering happens when light interacts with particles roughly the same size as the wavelength — aerosols, dust, water droplets. Unlike Rayleigh scattering, Mie scattering is much less wavelength-dependent (roughly λ⁻¹). This means it scatters all colors more evenly, creating a white-ish haze around the sun.

But here's the sunset trick: when the sun is low on the horizon, sunlight travels through much more atmosphere — up to 40x more air mass than at noon. Blue light gets Rayleigh-scattered away long before reaching your eyes. Red and orange light, with their longer wavelengths, survive the journey. This is why sunsets blaze with warm colors while the sky above fades to deep blue-purple.

Building a Procedural Sky Renderer

Now let's put this physics into practice. Heckel's renderer uses Three.js with custom vertex and fragment shaders written in GLSL. The core pipeline follows these steps:

  1. Represent the sky as a large sphere around the camera
  2. For each fragment, compute the view direction intersecting the atmosphere
  3. Integrate scattering along the view ray (in-scattering + out-scattering)
  4. Combine Rayleigh (blue) and Mie (white/red) contributions based on sun angle
  5. Add ground color and stars

Step 1: The Sky Sphere

The simplest approach is a large sphere surrounding the scene, with normals pointing inward. In Three.js:

const skyGeo = new THREE.SphereGeometry(400, 64, 40);
const skyMat = new THREE.ShaderMaterial({
  vertexShader: skyVertexShader,
  fragmentShader: skyFragmentShader,
  uniforms: {
    sunDirection: { value: new THREE.Vector3(0, 0.5, -1).normalize() },
    rayleighCoefficient: { value: 0.0025 },
    mieCoefficient: { value: 0.001 },
    turbidity: { value: 2.0 }
  },
  side: THREE.BackSide
});
const sky = new THREE.Mesh(skyGeo, skyMat);

Step 2: Atmospheric Scattering in the Fragment Shader

The fragment shader is where the magic happens. Here's the core scattering computation:

// GLSL — fragment shader
uniform vec3 sunDirection;
uniform float rayleighCoefficient;
uniform float mieCoefficient;
uniform float turbidity;

varying vec3 vWorldPosition;

const float PI = 3.14159265359;

// Rayleigh phase function (dipole scatter)
float rayleighPhase(float cosTheta) {
  return (3.0 / (16.0 * PI)) * (1.0 + cosTheta * cosTheta);
}

// Mie phase function (Henyey-Greenstein)
float miePhase(float cosTheta, float g) {
  return (3.0 / (8.0 * PI)) * ((1.0 - g * g) * (1.0 + cosTheta * cosTheta)) /
         ((2.0 + g * g) * pow(1.0 + g * g - 2.0 * g * cosTheta, 1.5));
}

void main() {
  vec3 viewDir = normalize(vWorldPosition - cameraPosition);
  float cosTheta = dot(viewDir, sunDirection);
  
  // Scattering coefficients
  vec3 rayleighScattering = vec3(
    rayleighCoefficient / pow(0.650, 4.0),  // Red
    rayleighCoefficient / pow(0.570, 4.0),  // Green
    rayleighCoefficient / pow(0.475, 4.0)   // Blue
  );
  
  // Mie scattering (roughly wavelength-independent)
  float mieScattering = mieCoefficient;
  
  // Optical depth (simplified — path length through atmosphere)
  float rayleighDepth = exp(-turbidity * 0.5);
  float mieDepth = exp(-turbidity * 0.3);
  
  // Final color
  vec3 color = rayleighPhase(cosTheta) * rayleighScattering * rayleighDepth
             + miePhase(cosTheta, 0.76) * mieScattering * mieDepth;
  
  gl_FragColor = vec4(color, 1.0);
}

Step 3: The Sunset Effect

To get realistic sunsets, we need a multi-scattering approximation. A single-scattering model (one bounce of light) gives a blue sky but loses the warm sunset colors. Heckel's approach adds a secondary scattering term that captures red-orange light scattered forward through the atmosphere:

// Sunset contribution — secondary scattering
vec3 sunsetGlow = vec3(1.0, 0.6, 0.2) * 
                  exp(-3.0 * (1.0 - cosTheta)) * 
                  (1.0 - exp(-turbidity * 2.0));

color += sunsetGlow * 0.5;

This creates the characteristic warm glow near the horizon when the sun is low. The key parameters to tune: turbidity (aerosol density — higher = more red sunsets), sunAngle (below 10° = sunset mode), and the balance between Rayleigh and Mie coefficients.

Rendering Planets

Now let's add planets to our scene. A convincing planet needs three layers:

  1. Textured sphere — the planet body with procedural or image-based textures
  2. Atmosphere glow — a transparent shell with scattering effects
  3. Shadow — the phase effect (the dark side of the planet)

Planet Body with Shaders

You can create impressive planet textures procedurally using noise functions:

// Fragment shader for procedural planet texture
uniform float seed;

// Simplex noise or value noise for terrain
float terrain(vec3 p) {
  float n = snoise(p * 0.5 + seed);
  n += snoise(p * 1.0 + seed) * 0.5;
  n += snoise(p * 2.0 + seed) * 0.25;
  return n;
}

void main() {
  vec3 p = normalize(vPosition);
  float height = terrain(p);
  
  // Color palette based on height
  vec3 ocean = vec3(0.1, 0.2, 0.5);
  vec3 land = vec3(0.2, 0.5, 0.1);
  vec3 mountain = vec3(0.4, 0.3, 0.2);
  
  vec3 color = mix(ocean, land, smoothstep(0.0, 0.3, height));
  color = mix(color, mountain, smoothstep(0.4, 0.6, height));
  
  gl_FragColor = vec4(color, 1.0);
}

Atmospheric Halo

The planet's atmosphere is rendered as a slightly larger sphere around the planet. The shader uses rim lighting — scattering is strongest at the edges where light passes through more atmosphere:

// Atmosphere halo shader
float rim = 1.0 - max(0.0, dot(viewDir, normal));
float atmosphere = pow(rim, 3.0) * 0.8;

vec3 atmosColor = mix(vec3(0.5, 0.7, 1.0), vec3(1.0, 0.5, 0.2), 
                       dot(viewDir, lightDir) * 0.5 + 0.5);

gl_FragColor = vec4(atmosColor, atmosphere);

This gives planets that characteristic blue or orange rim glow you see in space images, depending on the atmospheric composition.

Star Background: Noise-Generated Starfield

No planet scene is complete without stars. Instead of a static texture, generate stars procedurally with a hash-based noise function:

// Star generation using hash noise
float stars(vec3 p) {
  vec3 i = floor(p * 200.0);
  vec3 f = fract(p * 200.0);
  
  float star = hash(i); // hash-based random
  return step(0.997, star); // sparse — only brightest spots
}

void main() {
  vec3 color = stars(vPosition) * vec3(1.0);
  // Add twinkling variation
  color *= 0.5 + 0.5 * sin(time * 2.0 + hash(i) * 6.28);
  gl_FragColor = vec4(color, 1.0);
}

The key is making stars sparse (only ~0.3% of random locations become a star) and variable in brightness. Add slight twinkling for a living night sky.

Putting It All Together: Real-Time Rendering with JavaScript

Here's the complete setup for a real-time sky and planet renderer using Three.js:

import * as THREE from 'three';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Sky
const sky = createSky(/* sunDirection, turbidity */);
scene.add(sky);

// Planet
const planet = createPlanet(/* radius, atmosphere color */);
planet.position.set(5, 0, -10);
scene.add(planet);

// Stars
const starField = createStars();
scene.add(starField);

// Animation loop
function animate() {
  requestAnimationFrame(animate);
  
  // Rotate the sky as sun moves
  updateSky(sky, sunAngle);
  // Rotate the planet
  planet.rotation.y += 0.002;
  
  renderer.render(scene, camera);
}

animate();

Check out the original article by Maxime Heckel for the complete interactive demo and all source code.

Performance Optimization Tips

Atmospheric scattering is computationally expensive. Here are optimization strategies for real-time rendering:

Conclusion

Rendering the sky is one of those problems where a little physics goes a long way. By understanding Rayleigh and Mie scattering, you can create stunning real-time skies, realistic sunsets, and convincing planetary atmospheres — all with procedural generation and a few hundred lines of shader code.

The techniques covered here — multi-scattering approximation, phase functions, optical depth integration, and procedural noise — form the foundation of modern sky rendering in everything from indie games to AAA titles. Start with a simple sky sphere, add scattering, and gradually build up to full planet rendering with atmospheric halos and starfields.

Ready to build your own sky? The full source code, interactive demos, and detailed explanations are available in Maxime Heckel's original post (HN 392 points).