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.
The foundation of all terrain and texture generation is coherent noise. Two libraries dominate:
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).
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.
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:
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.
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();
}
Draw 3–5 mountain layers from back to front. Each successive layer is:
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.
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.
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 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)'
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);
}
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.
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);
}
Two approaches for campfire:
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.
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.
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();
}
| 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 |
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).
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);
}
Math.random() is not seedable in JavaScript. Use a PRNG that accepts a seed.
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);
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;
}
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);
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.
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 |
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
}
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.
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.beginPath...fill() for an entire mountain silhouette is faster than per-pixel fillRect calls.CanvasGradient objects — they are expensive to create.requestAnimationFrame. Never setInterval. rAF pauses when the tab is hidden, saving CPU.devicePixelRatio only for text and crisp edges. Atmospheric backgrounds can render at 0.5× native resolution and upscale — the blur is imperceptible.| 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) |