The vault background is a static image. The vault atmosphere is a live canvas layer sitting over it, drawing four animated effects every frame: god rays from the rose window, flickering lantern glows at each light source, embers drifting upward from the lanterns, and dust motes floating slowly across the scene. Together they turn a still image into something that breathes.

This post covers how the atmosphere renderer works, why it's built the way it is, and a performance problem I found after shipping it.


Why canvas

The first question is why this uses canvas at all rather than CSS animations.

The short answer: particle count. The atmosphere has 50 dust motes, up to 25 embers, 8 god rays, and however many lanterns are in the scene (currently 8). Each particle updates its position, opacity, and flicker value every frame. CSS @keyframes animations are declarative and performant for a fixed number of elements with predictable motion -- but spawning, despawning, and continuously updating 80+ independent particles with random physics would require generating and swapping CSS animations at runtime, which defeats the purpose.

Canvas with a requestAnimationFrame loop is the natural fit. The rendering model is: clear the canvas, draw everything, repeat. The particles are plain JavaScript objects mutated in place each frame. There's no virtual DOM, no reconciliation, no component re-renders. It's just a loop.

The rule of thumb: use CSS for anything that can be expressed as a transition between two states. Use canvas when you have a simulation.


The structure

VaultAtmosphere is a single <canvas> element with a useEffect that sets up the RAF loop:

export default function VaultAtmosphere() {
  const canvasRef = useRef(null)

  useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return

    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    // ...particle initialisation, draw functions, RAF loop...
    return () => {
      cancelAnimationFrame(raf)
      window.removeEventListener('resize', resize)
    }
  }, [])

  return <canvas ref={canvasRef} aria-hidden="true" style={{ position: 'absolute', inset: 0, ... }} />
}

The empty dependency array means the effect runs once on mount and the returned cleanup cancels the RAF and removes the resize listener on unmount. All particle state -- mote positions, ember life values, lantern flicker phases -- lives as plain objects inside the effect closure. None of it is React state. React has no visibility into it, which is exactly correct: the particles aren't UI state, they're animation state. Nothing in the rest of the app needs to know where a dust mote is.

The prefers-reduced-motion check at the top means the loop never starts for users who've requested reduced motion. Returning early from useEffect before setting up the RAF is the cleanest way to do this -- no loop, no canvas updates, just a static transparent canvas element.

The canvas is sized to its container on mount and on resize:

let raysDirty = true  // declared before resize -- explained below

const resize = () => {
  canvas.width  = canvas.offsetWidth
  canvas.height = canvas.offsetHeight
  offscreen.width  = canvas.width
  offscreen.height = canvas.height
  raysDirty = true
}

God rays

The god rays are a fan of gradient beams emanating from the rose window position (ROSE_WINDOW imported from vault.js). Eight rays, each with a slightly different angle, opacity, and flicker speed.

const rays = Array.from({ length: NUM_RAYS }, (_, i) => ({
  angle:   75 + (i / (NUM_RAYS - 1)) * 30,
  width:   Math.random() * 0.011 + 0.005,
  opacity: Math.random() * 0.09 + 0.05,
  phase:   Math.random() * Math.PI * 2,
  speed:   Math.random() * 0.006 + 0.003,
}))

Each ray is drawn as a trapezoid -- wide at the source, narrowing to almost nothing at the far end. The fill is a linear gradient from warm white (hsla(45, 90%, 92%, ...)) fading to transparent. A blur pass gives them their soft, diffused look.

The flicker is a sine wave: 0.6 + 0.4 * Math.sin(t * ray.speed + ray.phase). Each ray has an independent phase so they modulate out of sync. The result reads as natural light variation rather than a mechanical pulse.

The offscreen canvas

The first version of this drew the blur directly on the main canvas using ctx.filter = 'blur(8px)'. That turned out to be the single biggest CPU cost in the entire renderer -- enough to make a fan audible on an M4 MacBook Pro.

ctx.filter blur is software-rendered on the CPU. At 60fps on a large canvas, applying a blur pass to 8 gradient shapes every frame is brutal. The fix is an offscreen canvas:

const offscreen = document.createElement('canvas')
const offCtx    = offscreen.getContext('2d')

// Only redrawn every RAY_REDRAW_INTERVAL frames
function redrawRays(t, w, h) {
  offCtx.clearRect(0, 0, w, h)
  offCtx.filter = 'blur(8px)'
  // ...draw rays onto offCtx...
  offCtx.filter = 'none'
}

Then in the main tick:

rayFrameCount++
if (raysDirty || rayFrameCount >= RAY_REDRAW_INTERVAL) {
  redrawRays(t, w, h)
  raysDirty     = false
  rayFrameCount = 0
}
ctx.drawImage(offscreen, 0, 0)

The blur pass runs once on the offscreen canvas, and drawImage composites the result onto the main canvas. drawImage is GPU-accelerated -- no CPU involvement. Since the rays change slowly (subtle opacity flicker), redrawing them every 4 frames rather than every frame is imperceptible visually but eliminates roughly 75% of the blur work.

raysDirty is set to true on resize so the offscreen canvas is always redrawn immediately after the canvas dimensions change.


Lantern glows

Each lantern is a two-layer radial gradient: a large soft outer glow and a smaller bright inner core. The flicker uses two oscillators at different speeds per lantern, which gives the light a more convincing unsteady quality than a single sine wave:

const lanternState = LANTERNS.map(() => ({
  phase:  Math.random() * Math.PI * 2,
  speed:  Math.random() * 0.04 + 0.02,
  phase2: Math.random() * Math.PI * 2,
  speed2: Math.random() * 0.09 + 0.05,
}))
const flicker = 0.55 + 0.25 * Math.sin(s.phase) + 0.2 * Math.sin(s.phase2 * 2.3)

The flicker value scales both the glow radius and the alpha. When the lantern is dim, it's physically smaller and less bright -- it mimics the behaviour of a real flame.

The colour stop strings are pre-built as module-level constants rather than templated inside the draw call. Allocating a new string on every frame for every colour stop across 8 lanterns is unnecessary GC pressure. The alpha values still vary per frame, but the base colour strings are stable:

const LANTERN_OUTER_0 = 'hsla(38, 90%, 75%,'
// used as: `${LANTERN_OUTER_0} ${(0.06 * flicker).toFixed(3)})`

Embers

Embers are short-lived particles that spawn near lanterns, drift upward with some horizontal randomness, and fade out as their life value decreases.

function spawnEmber(w, h) {
  const src = LANTERNS[Math.floor(Math.random() * LANTERNS.length)]
  return {
    x: px(src.x, w) + (Math.random() - 0.5) * 6,
    y: px(src.y, h) + (Math.random() - 0.5) * 4,
    speedY: -(Math.random() * 0.6 + 0.2),
    life: 1.0,
    decay: Math.random() * 0.008 + 0.004,
    // ...
  }
}

The ember pool is pre-allocated at MAX_EMBERS = 25. When an ember's life drops to 0, it's replaced with a fresh one via embers[i] = spawnEmber(w, h). This avoids array growth and garbage collection pressure during the animation loop -- the array is a fixed size from initialisation to teardown.

The life value drives opacity: const a = e.life * 0.7 * flicker. Embers start at full brightness and fade linearly, with the flicker adding a small organic variation.


Dust motes

Dust motes are slower and longer-lived than embers. Their positions are stored in 0-1 percentage space rather than pixels, which means they survive viewport resizes without any recalculation:

const motes = Array.from({ length: 50 }, () => ({
  x:      Math.random(),        // 0-1, multiplied by canvas width at draw time
  y:      Math.random(),
  speedX: (Math.random() - 0.5) * 0.00015,
  speedY: -(Math.random() * 0.0003 + 0.00006),
  // ...
}))

When drawing: ctx.arc(m.x * w, m.y * h, m.r, 0, Math.PI * 2). The conversion to pixels happens at the last moment. Motes wrap around the canvas edges -- if a mote drifts above the top (y < -0.002), it reappears at the bottom (y = 1.002). The scene is continuous.

Embers use pixel coordinates because they spawn at specific lantern positions (which are themselves percentage-defined but converted to pixels on spawn). The key difference: embers have a short lifespan and get respawned when they die, so pixel coordinates don't accumulate error over time. Motes persist indefinitely, so percentage coordinates are the safe choice.


The main loop

let raf
let t = 0

const tick = () => {
  const w = canvas.width
  const h = canvas.height

  ctx.clearRect(0, 0, w, h)
  t++

  rayFrameCount++
  if (raysDirty || rayFrameCount >= RAY_REDRAW_INTERVAL) {
    redrawRays(t, w, h)
    raysDirty     = false
    rayFrameCount = 0
  }
  ctx.drawImage(offscreen, 0, 0)

  drawLanterns(w, h)
  drawEmbers(w, h)
  drawMotes(w, h)

  raf = requestAnimationFrame(tick)
}
tick()

w and h are cached at the top of each tick from canvas.width and canvas.height. The original version used W() and H() helper functions (() => canvas.width) called repeatedly inside the draw functions. Caching them at tick level is marginally more efficient and cleaner to pass through explicitly.

The draw order matters: rays go first (below everything), then lanterns, then embers, then motes. Embers render in front of the lantern glows because they're physical particles rising from the flame, not diffuse glow. Motes are on top because they're in the foreground air, closer to the viewer than the scene itself.

t is an incrementing tick counter. Using a counter rather than performance.now() keeps the animation frame-rate-relative -- the flicker looks the same at 60fps as at 120fps. For gameplay physics you'd want wall clock time. For a purely aesthetic effect, frame count is fine.


The temporal dead zone bug

After the offscreen canvas refactor shipped, the vault crashed immediately with:

ReferenceError: Cannot access 'raysDirty' before initialization
    at resize (VaultAtmosphere.jsx:33)

The cause: raysDirty was declared with let after the resize function that closed over it. resize() was then called immediately after its declaration -- before let raysDirty had been evaluated.

This is the temporal dead zone. let and const declarations are hoisted to the top of their block (the useEffect callback) but not initialised until the interpreter reaches the declaration. Accessing them before that point throws a ReferenceError. It's distinct from var, which is hoisted and initialised to undefined, meaning var raysDirty before the declaration would have silently returned undefined rather than crashing.

The fix is simple -- declare raysDirty and rayFrameCount before resize:

let raysDirty     = true
let rayFrameCount = 0

const resize = () => {
  // ...
  raysDirty = true  // now safe -- raysDirty is already initialised
}
resize()

Worth knowing for any RAF loop: initialise all mutable state before defining functions that close over it. The closure captures the binding, not the value at declaration time, so the order of declaration relative to use matters.


Improvement notes

The canvas zIndex: 1 inline style means it sits above the SVG hotspot layer if they ever overlap. This is currently fine because the atmosphere is visually soft and the hotspots' pointer events work at the SVG layer level. But if the atmosphere ever gets denser effects in specific areas, this ordering could create visual conflicts worth revisiting.

The t counter overflows at Number.MAX_SAFE_INTEGER, which at 60fps would take approximately 4.7 million years. Reasonably safe to leave as-is.