Projects bibleweb Docs atmosphere-procedural-generation.md

Procedural Generation Techniques for Browser-Based Atmospheric Scenes

Last modified March 28, 2026

Procedural Generation Techniques for Browser-Based Atmospheric Scenes

Date: 2026-03-29 Context: Porting a MonoGame C# desktop app to SvelteKit. The desktop app generates all visual elements procedurally — mountains, water, sky, moon/sun, city silhouettes, campfire. This document covers how to port each of those systems to browser Canvas 2D.


1. Noise Functions in JavaScript

The foundation of all terrain and texture generation is coherent noise. Two libraries dominate:

simplex-noise (recommended)

npm install simplex-noise
import { createNoise2D } from 'simplex-noise';
import alea from 'alea'; // seedable PRNG

const prng = alea('my-scene-seed');
const noise2D = createNoise2D(prng);

// Returns values in [-1, 1]
const val = noise2D(x * 0.005, y * 0.005);

simplex-noise v4+ is tree-shakeable, TypeScript-native, and accepts any () => number PRNG — making it directly compatible with seeded generators. It runs at ~10M queries/sec in V8 and produces no grid artifacts (unlike classic Perlin).

noisejs / josephg

npm install noisejs

Older but still widely used. Provides both 2D Perlin and Simplex. No built-in seed support — you must call noise.seed(n) with a numeric seed.

Fractal Brownian Motion (fBm) — multi-octave noise

Single-octave noise looks blobby. For realistic mountains, add octaves with decreasing amplitude:

function fbm(noise2D: (x: number, y: number) => number, x: number, y: number): number {
  let value = 0;
  let amplitude = 0.5;
  let frequency = 1.0;
  const octaves = 6;
  const lacunarity = 2.0; // frequency multiplier per octave
  const gain = 0.5;       // amplitude multiplier per octave

  for (let i = 0; i < octaves; i++) {
    value += amplitude * noise2D(x * frequency, y * frequency);
    frequency *= lacunarity;
    amplitude *= gain;
  }
  return value; // roughly in [-1, 1]
}

Key parameters:

  • Octaves — detail layers (6–8 is typical for mountains)
  • Lacunarity — how fast frequency grows (2.0 = doubles each octave)
  • Gain/Persistence — how fast amplitude shrinks (0.5 = halves each octave)
  • Scale — the base frequency multiplier (low = large features, high = fine detail)

Performance note: Each added octave roughly doubles cost. For a 1280px-wide mountain silhouette generated once at startup, 6 octaves is fine. For real-time per-pixel noise on a 1920×1080 canvas every frame, you need WebGL.


2. Terrain and Mountain Silhouettes

Core approach: sample noise along a 1D horizontal strip

Mountain silhouettes are a height function y = f(x), not a full 2D heightmap. Sample noise at each pixel x-coordinate, map to a canvas y-position, then fill downward with fillRect or a filled path:

function drawMountainLayer(
  ctx: CanvasRenderingContext2D,
  noise2D: ReturnType<typeof createNoise2D>,
  width: number,
  height: number,
  baseY: number,       // vertical center of this layer
  amplitude: number,   // height variation in pixels
  noiseScale: number,  // horizontal stretch of noise
  color: string
) {
  ctx.beginPath();
  ctx.moveTo(0, height);

  for (let x = 0; x <= width; x++) {
    const n = fbm(noise2D, x * noiseScale, 0);
    const y = baseY + n * amplitude;
    ctx.lineTo(x, y);
  }

  ctx.lineTo(width, height);
  ctx.closePath();
  ctx.fillStyle = color;
  ctx.fill();
}

Atmospheric depth fade (haze/fog)

Draw 3–5 mountain layers from back to front. Each successive layer is:

  • Lighter (blended toward the sky color)
  • Shorter amplitude (mountains further away appear smoother)
  • Lower on screen (closer to horizon means higher y-value)
const layers = [
  { baseY: 0.55, amp: 120, scale: 0.003, color: '#2a1a3e' }, // far, dark purple
  { baseY: 0.65, amp: 90,  scale: 0.004, color: '#3d2a50' }, // mid
  { baseY: 0.70, amp: 70,  scale: 0.006, color: '#5a3d6b' }, // near, lighter
];

layers.forEach(layer => {
  drawMountainLayer(ctx, noise2D, W, H,
    layer.baseY * H, layer.amp, layer.scale, layer.color);
});

This fake atmospheric perspective is nearly free — just lerp color toward a sky/haze color as you go further back. A utility:

function lerpColor(a: string, b: string, t: number): string {
  // parse hex, lerp each channel, return hex
}

One-time baking: Generate mountain layers onto an OffscreenCanvas at startup, then ctx.drawImage(offscreen, 0, 0) each frame. Mountains never change unless the scene seed changes.


3. Water Rendering

Wave animation via sine curves

For stylized water (not physically simulated), a sum of sine waves with different periods and phases gives convincing motion:

function drawWater(ctx: CanvasRenderingContext2D, t: number, W: number, H: number) {
  const waterY = H * 0.72; // horizon for water
  ctx.beginPath();
  ctx.moveTo(0, H);

  for (let x = 0; x <= W; x++) {
    const wave =
      Math.sin(x * 0.02 + t * 1.5) * 6 +
      Math.sin(x * 0.05 + t * 2.3) * 3 +
      Math.sin(x * 0.008 + t * 0.8) * 10;
    ctx.lineTo(x, waterY + wave);
  }

  ctx.lineTo(W, H);
  ctx.lineTo(0, H);
  ctx.closePath();

  const grad = ctx.createLinearGradient(0, waterY, 0, H);
  grad.addColorStop(0, 'rgba(20, 30, 60, 0.9)');
  grad.addColorStop(1, 'rgba(5, 10, 30, 1.0)');
  ctx.fillStyle = grad;
  ctx.fill();
}

t is performance.now() / 1000 — time in seconds.

Moonlight pillar on water

Draw a vertical strip of semi-transparent light from the moon's x-position down to the water bottom, with a radial gradient to make it soft:

function drawMoonReflection(
  ctx: CanvasRenderingContext2D,
  moonX: number,
  waterY: number,
  H: number
) {
  const grad = ctx.createLinearGradient(moonX, waterY, moonX, H);
  grad.addColorStop(0, 'rgba(255, 240, 180, 0.35)');
  grad.addColorStop(0.4, 'rgba(255, 240, 180, 0.15)');
  grad.addColorStop(1, 'rgba(255, 240, 180, 0.0)');

  ctx.save();
  ctx.globalCompositeOperation = 'screen'; // additive blend — light on dark water
  ctx.fillStyle = grad;
  ctx.fillRect(moonX - 40, waterY, 80, H - waterY);
  ctx.restore();
}

Foam

Foam at the waterline: draw a thin strip of semi-transparent white sine-wave path slightly above the water surface path, with low alpha (0.1–0.2). For animated foam, offset the phase with t:

// Same loop as drawWater but y slightly higher and fillStyle = 'rgba(255,255,255,0.12)'

4. Sky Gradients

Static multi-stop gradient

Canvas's createLinearGradient supports unlimited stops:

function drawSky(ctx: CanvasRenderingContext2D, W: number, H: number, preset: 'sunset' | 'night') {
  const grad = ctx.createLinearGradient(0, 0, 0, H * 0.75);

  if (preset === 'sunset') {
    grad.addColorStop(0.0, '#1a0a2e'); // deep purple at top
    grad.addColorStop(0.4, '#c2185b'); // rose-magenta mid
    grad.addColorStop(0.7, '#ff6f00'); // amber-orange
    grad.addColorStop(1.0, '#ffcc02'); // gold at horizon
  } else {
    grad.addColorStop(0.0, '#050510'); // near-black
    grad.addColorStop(0.5, '#1a0535'); // deep purple
    grad.addColorStop(1.0, '#3d1a0a'); // rust-orange near horizon
  }

  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H * 0.75);
}

Animated sky transitions

Animate between two gradient configurations by interpolating stop colors with t in [0, 1]:

const stops = lerpGradientStops(sunsetStops, nightStops, transitionProgress);

Pre-calculate transition frames into an array of gradient configs at startup if the transition is slow (e.g., 30-second sunset). Bake each frame to an OffscreenCanvas — then the animation is just drawImage switching between pre-rendered frames.

Atmospheric scattering approximation

True Rayleigh scattering is expensive. A convincing approximation: at the horizon, add a thin radial gradient "glow" in a warm orange/yellow, fading upward. This simulates light scattering through atmosphere without per-pixel calculation.

function drawHorizonGlow(ctx: CanvasRenderingContext2D, W: number, horizonY: number) {
  const grad = ctx.createRadialGradient(W / 2, horizonY, 0, W / 2, horizonY, W * 0.8);
  grad.addColorStop(0, 'rgba(255, 120, 20, 0.4)');
  grad.addColorStop(1, 'rgba(255, 120, 20, 0.0)');
  ctx.fillStyle = grad;
  ctx.fillRect(0, horizonY - 100, W, 200);
}

5. Procedural Fire and Flame

Two approaches for campfire:

Approach A: DOOM fire algorithm (pixel-based)

The PSX DOOM fire works on a pixel buffer. A bottom row is initialized to max heat. Each frame, each pixel propagates upward with slight horizontal drift:

const W = 64, H = 64;
const firePixels = new Uint8Array(W * H);
// Initialize bottom row to 255
for (let x = 0; x < W; x++) firePixels[(H - 1) * W + x] = 255;

function updateFire() {
  for (let y = 1; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const src = y * W + x;
      const decay = Math.floor(Math.random() * 3);
      const dst = (y - 1) * W + ((x - decay + 1 + W) % W);
      firePixels[dst] = Math.max(0, firePixels[src] - decay);
    }
  }
}

// Map heat value 0-255 to palette: black → red → orange → yellow → white
function renderFire(ctx: CanvasRenderingContext2D, offX: number, offY: number) {
  const img = ctx.createImageData(W, H);
  for (let i = 0; i < W * H; i++) {
    const heat = firePixels[i];
    img.data[i * 4 + 0] = Math.min(255, heat * 3);       // R
    img.data[i * 4 + 1] = Math.max(0, heat * 2 - 255);   // G
    img.data[i * 4 + 2] = 0;                              // B
    img.data[i * 4 + 3] = heat > 0 ? 255 : 0;            // A
  }
  ctx.putImageData(img, offX, offY);
}

This is fast and produces authentic-looking "tongue" flames. Scale it up with imageSmoothingEnabled = false for a pixel-art campfire.

Approach B: Particle flame tongues

For 3 dynamic flame tongues (matching the source app), use a particle system:

interface Particle {
  x: number; y: number;
  vx: number; vy: number;
  life: number; maxLife: number;
  size: number;
}

function spawnParticle(cx: number, baseY: number): Particle {
  return {
    x: cx + (Math.random() - 0.5) * 10,
    y: baseY,
    vx: (Math.random() - 0.5) * 0.8,
    vy: -(Math.random() * 2 + 1),
    life: 1.0,
    maxLife: 1.0,
    size: Math.random() * 8 + 4,
  };
}

function drawParticleFire(
  ctx: CanvasRenderingContext2D,
  particles: Particle[],
  dt: number
) {
  ctx.save();
  ctx.globalCompositeOperation = 'screen';
  for (const p of particles) {
    p.life -= dt * 1.2;
    p.x += p.vx;
    p.y += p.vy;
    const t = 1 - p.life / p.maxLife;

    // Color: yellow → orange → red as particle ages
    const r = 255;
    const g = Math.round(200 * (1 - t));
    const b = 0;
    const a = p.life * 0.8;

    const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
    grad.addColorStop(0, `rgba(${r},${g},${b},${a})`);
    grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
    ctx.fillStyle = grad;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.restore();
}

Use globalCompositeOperation = 'screen' so overlapping particles add up to brighter yellows without blowing out to white.


6. Light and Glow Effects

Radial gradient glow — moon, sun, windows

function drawGlow(
  ctx: CanvasRenderingContext2D,
  cx: number, cy: number,
  innerR: number, outerR: number,
  color: string,
  alpha: number
) {
  const grad = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
  grad.addColorStop(0, color.replace(')', `, ${alpha})`).replace('rgb', 'rgba'));
  grad.addColorStop(1, 'rgba(0,0,0,0)');

  ctx.save();
  ctx.globalCompositeOperation = 'screen';
  ctx.fillStyle = grad;
  ctx.fillRect(cx - outerR, cy - outerR, outerR * 2, outerR * 2);
  ctx.restore();
}

Key composite operations

Operation Use case
screen Additive light: glow, fire, bloom. Light on dark backgrounds.
multiply Shadow/darkening overlays
overlay Contrast-boosting light shafts
lighter Pure additive — can blow out; use for sparks

Light shafts (crepuscular rays)

Draw semi-transparent elongated triangles radiating from the sun position, rotated to various angles. Use low alpha (0.03–0.08 per shaft) and screen blend:

function drawLightShaft(
  ctx: CanvasRenderingContext2D,
  sunX: number, sunY: number,
  angle: number, length: number, width: number
) {
  ctx.save();
  ctx.translate(sunX, sunY);
  ctx.rotate(angle);
  ctx.globalCompositeOperation = 'screen';
  ctx.globalAlpha = 0.05;

  const grad = ctx.createLinearGradient(0, 0, 0, length);
  grad.addColorStop(0, 'rgba(255, 200, 100, 0.8)');
  grad.addColorStop(1, 'rgba(255, 200, 100, 0.0)');

  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(-width / 2, length);
  ctx.lineTo(width / 2, length);
  ctx.closePath();
  ctx.fillStyle = grad;
  ctx.fill();
  ctx.restore();
}

Draw 8–12 shafts with slightly random angles and widths. Animate by slowly oscillating each shaft's opacity with Math.sin(t * 0.3 + i).

Moon disc with craters

  1. Fill a circle with a pale cream/silver color.
  2. Draw darker circles (craters) at seeded random positions within the moon disc. Use the scene seed so craters are consistent.
  3. Apply a radial gradient glow on top with screen blend.
function drawMoon(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, prng: () => number) {
  // Base disc
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.fillStyle = '#f0e8c8';
  ctx.fill();

  // Craters
  ctx.globalAlpha = 0.3;
  for (let i = 0; i < 8; i++) {
    const cr = (prng() * 0.3 + 0.05) * r;
    const ca = prng() * Math.PI * 2;
    const cd = prng() * r * 0.75;
    ctx.beginPath();
    ctx.arc(cx + Math.cos(ca) * cd, cy + Math.sin(ca) * cd, cr, 0, Math.PI * 2);
    ctx.fillStyle = '#c8b890';
    ctx.fill();
  }
  ctx.globalAlpha = 1;

  // Glow
  drawGlow(ctx, cx, cy, r * 0.8, r * 3, 'rgb(240, 220, 150)', 0.15);
}

7. Seed-Based Generation

Math.random() is not seedable in JavaScript. Use a PRNG that accepts a seed.

Mulberry32 (zero-dependency, fast)

function mulberry32(seed: number): () => number {
  return function () {
    seed |= 0;
    seed = seed + 0x6d2b79f5 | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Usage:
const prng = mulberry32(hashString('genesis-1'));
const noise2D = createNoise2D(prng);

String seed → numeric hash

function hashString(s: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

Alea (via npm)

The alea package is a well-tested, string-seedable PRNG designed to work with simplex-noise:

npm install alea
import alea from 'alea';
const prng = alea('scene-seed-42');
const noise2D = createNoise2D(prng);

Strategy for per-book scenes

Map Bible book IDs or names to seeds:

const sceneSeed = `${bookId}-${chapterId}`;
const prng = alea(sceneSeed);

This gives each book a unique but reproducible sky, mountain topology, and moon crater layout.


8. Performance Strategy: When to Bake vs Animate

The hybrid model

Split scene elements into three tiers:

Tier Elements Update rate Technique
Static Mountains, sky gradient, moon/sun, city silhouette, craters Once at startup Bake to OffscreenCanvas
Semi-static Moonlight pillar, horizon glow, light shafts Slow oscillation (~1Hz) Re-render periodically or pre-bake frames
Animated Water waves, fire particles, foam Every frame Draw directly on main canvas

OffscreenCanvas baking

const bgCanvas = new OffscreenCanvas(W, H);
const bgCtx = bgCanvas.getContext('2d')!;

// Called once:
function bakeBackground(seed: string) {
  const prng = alea(seed);
  const noise2D = createNoise2D(prng);
  drawSky(bgCtx, W, H, 'night');
  drawMountains(bgCtx, noise2D, W, H);
  drawMoon(bgCtx, W * 0.7, H * 0.2, 40, prng);
  drawCitysilhouette(bgCtx, noise2D, W, H, prng);
}

// Called every frame:
function renderFrame(ctx: CanvasRenderingContext2D, t: number) {
  ctx.drawImage(bgCanvas, 0, 0); // paste static background
  drawWater(ctx, t, W, H);       // animate water
  drawFire(ctx, t, W, H);        // animate fire
}

OffscreenCanvas in a Web Worker

For scenes with complex generation (many octaves, large canvases), offload baking to a Web Worker:

// worker.ts
import { createNoise2D } from 'simplex-noise';
import alea from 'alea';

self.onmessage = ({ data: { seed, W, H } }) => {
  const canvas = new OffscreenCanvas(W, H);
  const ctx = canvas.getContext('2d')!;
  const prng = alea(seed);
  // ... draw everything ...
  const bitmap = canvas.transferToImageBitmap();
  self.postMessage({ bitmap }, [bitmap]);
};

// main thread:
worker.onmessage = ({ data: { bitmap } }) => {
  bakedBackground = bitmap; // ImageBitmap, drawable with ctx.drawImage
};

This keeps baking off the main thread — the UI never stutters during generation.

Key performance rules

  1. Never call getImageData/putImageData per-frame on large canvases. Pixel-level fire should use a small (64×64) buffer, then scale it up with drawImage + imageSmoothingEnabled = false.
  2. Batch path drawing. One beginPath...fill() for an entire mountain silhouette is faster than per-pixel fillRect calls.
  3. Avoid gradient re-creation per frame. Cache CanvasGradient objects — they are expensive to create.
  4. Limit noise calls per frame. Animated water uses sine functions, not noise. Noise is only evaluated during baking.
  5. Use requestAnimationFrame. Never setInterval. rAF pauses when the tab is hidden, saving CPU.
  6. Canvas resolution scaling. Render at devicePixelRatio only for text and crisp edges. Atmospheric backgrounds can render at 0.5× native resolution and upscale — the blur is imperceptible.

Mapping to the Source App's Elements

Source element Browser technique Baked?
Mountain silhouettes (multi-layer depth fade) fBm simplex noise path + color lerp Yes
Water waves, foam Sine sum path animation No (animated)
Water reflections, moonlight pillar Radial/linear gradient + screen blend No (animated)
Sky gradient (sunset, night) createLinearGradient multi-stop Yes
Terrain/shore clearing Noise-based path with flat section Yes
Moon with craters and glow Seeded random circles + radial gradient Yes
Sun disc with halo and light shafts Filled circle + gradient glow + triangles screen Yes (shafts animate lightly)
Ancient city silhouettes Seeded random rect buildings + lit-window dots Yes
Campfire (3 flame tongues) Particle system with screen blend No (animated)

Sources