Aurora Borealis - Day 20
Advanced LevelThe Northern Lights appear as glowing curtains of light that wave and undulate across the sky. We simulate this effect using multiple overlapping "curtains", each with its own movement properties.
// Initialize multiple curtain layers
const CURTAIN_COUNT = 5;
let curtains = [];
function initCurtains() {
for (let i = 0; i < CURTAIN_COUNT; i++) {
curtains.push({
// Horizontal offset for layering
offset: i * 150,
// Phase offset for wave desynchronization
phase: Math.random() * Math.PI * 2,
// Wave properties
amplitude: Math.random() * 30 + 20,
frequency: Math.random() * 0.003 + 0.002,
speed: Math.random() * 0.5 + 0.3,
// Vertical undulation
verticalWave: Math.random() * 0.002 + 0.001,
// Color shifting phase
colorPhase: Math.random() * Math.PI * 2
});
}
}
Each curtain has slightly different movement parameters. When overlapped with transparency, this creates the illusion of depth and complexity seen in real auroras. The random phase offsets ensure curtains don't move in sync.
Aurora curtains undulate in complex patterns. We combine multiple sine waves to create natural-looking wave motion in both horizontal and vertical directions.
// For each vertical band in the curtain
for (let i = 0; i < bandCount; i++) {
const x = i * bandWidth;
const curtainTime = time * settings.windSpeed * curtain.speed;
// Horizontal wave - makes the curtain sway side to side
const horizontalWave = Math.sin(
i * curtain.frequency * 100 + // Spatial frequency
curtainTime + // Time-based motion
curtain.phase // Phase offset
) * curtain.amplitude * settings.waveHeight;
// Vertical wave - makes top/bottom edges undulate
const verticalWave = Math.sin(
i * curtain.verticalWave * 100 +
curtainTime * 0.7 // Slower vertical motion
) * auroraHeight * 0.3 * settings.waveHeight;
// Calculate band position
const bandTopY = baseY + horizontalWave + verticalWave;
const bandBottomY = baseY + auroraHeight + horizontalWave;
}
Combining waves at different frequencies (spatial and temporal) creates organic-looking motion. The key is slight frequency variations between curtain layers.
Auroras shift through colors as you move across the curtain. We smoothly interpolate between palette colors based on position and time.
// Color palettes for different aurora types
const palettes = {
classic: [
{ r: 34, g: 197, b: 94 }, // Green
{ r: 16, g: 185, b: 129 }, // Teal
{ r: 52, g: 211, b: 153 } // Emerald
],
purple: [
{ r: 168, g: 85, b: 247 }, // Purple
{ r: 139, g: 92, b: 246 }, // Violet
{ r: 192, g: 132, b: 252 } // Light Purple
]
};
// Calculate color index (0-1 range, wrapping)
const colorIndex = (
(i / bandCount) + // Position across curtain
curtainTime * 0.1 + // Slow color drift over time
index * 0.2 // Offset per curtain layer
) % 1;
// Find two adjacent colors to blend
const color1Index = Math.floor(colorIndex * colors.length) % colors.length;
const color2Index = (color1Index + 1) % colors.length;
const colorMix = (colorIndex * colors.length) % 1;
// Linear interpolation between colors
const color = {
r: colors[color1Index].r +
(colors[color2Index].r - colors[color1Index].r) * colorMix,
g: colors[color1Index].g +
(colors[color2Index].g - colors[color1Index].g) * colorMix,
b: colors[color1Index].b +
(colors[color2Index].b - colors[color1Index].b) * colorMix
};
Linear interpolation (lerp) between RGB values creates smooth color transitions. The modulo operator (%) ensures colors wrap around seamlessly, creating a continuous gradient across the aurora.
Each vertical slice of the aurora uses a gradient that fades from transparent at top and bottom to bright in the middle, mimicking how real auroras glow brightest along the curtain's center.
// Create vertical gradient for each band
const gradient = ctx.createLinearGradient(x, bandTopY, x, bandBottomY);
// Intensity variation - pulsing brightness
const localIntensity = (
Math.sin(i * 0.1 + curtainTime * 0.3) * 0.5 + 0.5
) * settings.intensity;
// Aurora fade effect - bright in middle, fades at edges
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0)`);
gradient.addColorStop(0.2, `rgba(${r}, ${g}, ${b}, ${localIntensity * 0.3})`);
gradient.addColorStop(0.4, `rgba(${r}, ${g}, ${b}, ${localIntensity * 0.6})`);
gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, ${localIntensity * 0.4})`);
gradient.addColorStop(0.8, `rgba(${r}, ${g}, ${b}, ${localIntensity * 0.15})`);
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
// Draw the band
ctx.fillStyle = gradient;
ctx.fillRect(x - bandWidth/2, bandTopY, bandWidth + 1, bandBottomY - bandTopY);
Adding 1px to band width (bandWidth + 1) prevents visible gaps between adjacent bands caused by floating-point rounding.
A starry sky backdrop adds to the atmosphere. Each star has its own twinkle cycle using a sine wave with random phase offset.
// Initialize stars with random properties
function initStars() {
for (let i = 0; i < STAR_COUNT; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * height * 0.7, // Upper 70% only
size: Math.random() * 2 + 0.5,
brightness: Math.random(),
twinkleSpeed: Math.random() * 0.02 + 0.01,
twinkleOffset: Math.random() * Math.PI * 2
});
}
}
// Draw twinkling stars
function drawStars() {
stars.forEach(star => {
// Calculate twinkle using sine wave
const twinkle = Math.sin(
time * star.twinkleSpeed + star.twinkleOffset
) * 0.5 + 0.5; // Map -1..1 to 0..1
const alpha = star.brightness * twinkle * 0.8 + 0.2;
// Draw star
ctx.beginPath();
ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.fill();
// Add glow for brighter stars
if (star.size > 1.5) {
const glowGradient = ctx.createRadialGradient(
star.x, star.y, 0,
star.x, star.y, star.size * 3
);
glowGradient.addColorStop(0, `rgba(255,255,255,${alpha*0.3})`);
glowGradient.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = glowGradient;
ctx.fill();
}
});
}
Rendering hundreds of gradient bands every frame can be costly. Here are key optimizations used in the aurora simulation.
// 1. Reduce band count for performance
const bandCount = 60; // Balance between smoothness and speed
// 2. Use devicePixelRatio for sharp rendering
function resizeCanvas() {
canvas.width = container.offsetWidth * window.devicePixelRatio;
canvas.height = container.offsetHeight * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
// 3. Pre-calculate expensive values outside loops
const baseY = height * 0.15;
const auroraHeight = height * 0.5;
const bandWidth = width / bandCount + 5;
// 4. Limit star count and only render in visible area
y: Math.random() * height * 0.7 // Stars only where visible
// 5. Use requestAnimationFrame for smooth animation
function animate() {
// Clear and redraw
ctx.clearRect(0, 0, width, height);
drawBackground();
drawStars();
drawAurora();
drawHorizon();
time += 1;
requestAnimationFrame(animate);
}
60 bands provides a good balance - fewer bands look blocky, more bands slow down mobile devices. Test on your target devices and adjust as needed.
Drawing order (background -> stars -> aurora -> horizon) ensures proper visual layering. The aurora is drawn with transparency, so stars show through faintly, adding depth.