← All WebGL effects

Fog Bank

Slabs composited back to front, each dimming what is behind it, so the bank has an inside rather than a surface.

ambientcalmatmosphericelegant1 draw call · full-screen shader · light · 1 context · automatic · looping
queued1 draw call · 1 context
Variant

The scene in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

316 lines · react + three
"use client";

import * as React from "react";
import * as THREE from "three";

/**
 * Fog Bank — the depth is the effect.
 *
 * Fog painted as a soft grey wash is a flat picture of fog. What makes
 * real fog read as a volume you could walk into is that it has an
 * inside: something near you moves faster than something far away, and
 * whatever is behind a dense patch is dimmer for it.
 *
 * So this marches a handful of slabs from far to near, and one depth
 * number per slab drives everything about it — how fast it drifts, how
 * large its features are, how much light it carries and how much of
 * what is behind it survives. Tying them together is the whole trick.
 * Choosing those four independently produces exactly the flat wash this
 * exists to avoid, because parallax is not decoration, it is the only
 * cue in a still frame that says one thing is in front of another.
 *
 * The occlusion is where the volume comes from: each slab multiplies
 * everything already accumulated behind it, so a dense patch really
 * does hide what is further away rather than merely being drawn on top.
 *
 * Single file, react and three only. No textures, no add-ons.
 */

type Variant = "subtle" | "default" | "playful";

const VARIANTS = {
  subtle: { driftRate: 0.05, layerCount: 3, fogOpacity: 0.32, depthScale: 0.85 },
  default: { driftRate: 0.12, layerCount: 5, fogOpacity: 0.52, depthScale: 1.2 },
  playful: { driftRate: 0.28, layerCount: 9, fogOpacity: 0.74, depthScale: 1.75 },
} as const;

const PALETTES = {
  night: {
    air: [0.045, 0.052, 0.068],
    near: [0.62, 0.66, 0.74],
    far: [0.16, 0.20, 0.30],
    ink: 0,
    css: "linear-gradient(180deg, #1b2028 0%, #0f1319 60%, #0a0d12 100%)",
  },
  day: {
    air: [0.90, 0.915, 0.935],
    near: [0.99, 0.99, 1.0],
    far: [0.72, 0.75, 0.80],
    ink: 0,
    css: "linear-gradient(180deg, #dfe4ea 0%, #eef1f4 60%, #f4f6f8 100%)",
  },
} as const;

const VERTEX = /* glsl */ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = vec4(position.xy, 0.0, 1.0);
  }
`;

const FRAGMENT = /* glsl */ `
  varying vec2 vUv;

  uniform vec2  uResolution;
  uniform float uTime;
  uniform float uInk;
  uniform float  uLayers;
  uniform float  uOpacity;
  uniform float  uDepth;
  uniform vec3  uAir;
  uniform vec3  uNear;
  uniform vec3  uFar;

  const int MAX_LAYERS = 9;

  /** One slab of vapour. Cheap, because up to nine of these run. */
  float vapour(vec2 p, float t) {
    return 0.5 + 0.5 * sin(p.x * 2.3 + t) * sin(p.y * 1.7 - t * 0.62)
               + 0.22 * sin((p.x + p.y) * 3.9 + t * 1.4);
  }

  float hash(vec2 p) {
    return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453);
  }

  void main() {
    float aspect = uResolution.x / max(uResolution.y, 1.0);
    vec2 uv = vec2(vUv.x * aspect, vUv.y);

    vec3 accumulated = vec3(0.0);
    // How much of what is behind the current slab still gets through.
    float transmittance = 1.0;

    for (int i = 0; i < MAX_LAYERS; i++) {
      float fi = float(i);
      float present = step(fi + 0.5, uLayers);

      // Far slabs first. Everything about a slab comes off this one
      // number, which is what makes it read as distance rather than as
      // a stack of unrelated sheets.
      float depth = 1.0 - fi / max(uLayers - 1.0, 1.0);

      float speed = mix(0.25, 1.0, 1.0 - depth);
      float feature = mix(0.55, 2.1, 1.0 - depth) * uDepth;
      float brightness = mix(0.35, 1.0, 1.0 - depth);

      vec2 p = uv * feature + vec2(uTime * speed * 0.35, -uTime * speed * 0.06);
      float d = clamp(vapour(p, uTime * speed) - 0.35, 0.0, 1.0);

      // Fog sits low. A bank that reaches the top of the frame reads as
      // a filter over the picture rather than as weather in it.
      d *= 1.0 - smoothstep(0.15, 0.92, vUv.y);
      d *= present * uOpacity;

      vec3 tint = mix(uFar, uNear, 1.0 - depth) * brightness;

      // Behind-to-front compositing: this slab dims everything already
      // gathered behind it, which is the line that gives the bank an
      // inside instead of a surface.
      accumulated = accumulated * (1.0 - d) + tint * d;
      transmittance *= 1.0 - d * 0.55;
    }

    vec3 color = uAir * transmittance + accumulated;
    color += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
  }
`;

/** Relative luminance of a computed rgb() colour, 0–1. */
function luminanceOf(color: string): number {
  const parts = color.match(/[\d.]+/g);
  if (!parts || parts.length < 3) return 0.9;
  const [r, g, b] = parts.slice(0, 3).map((value) => Number(value) / 255);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

export type FogBankProps = {
  variant?: Variant;
  /** Any CSS height. The effect fills whatever box it is given. */
  height?: string;
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
};

export function FogBank({
  variant = "default",
  height = "100%",
  className,
  style,
  children,
}: FogBankProps) {
  const hostRef = React.useRef<HTMLDivElement | null>(null);
  const cfg = VARIANTS[variant] ?? VARIANTS.default;

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    // A full-bleed effect paints every pixel, so it cannot mix its
    // neutrals from the host the way a component can. It reads the
    // inherited colour only to decide which way round the page is.
    const palette =
      luminanceOf(getComputedStyle(host).color) > 0.5 ? PALETTES.night : PALETTES.day;
    host.style.background = palette.css;

    const reduced =
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({
        antialias: false,
        alpha: false,
        powerPreference: "low-power",
      });
    } catch {
      // No WebGL. The CSS still above is already painted and stands in
      // for the effect, so there is nothing further to do.
      return;
    }

    const canvas = renderer.domElement;
    canvas.style.cssText =
      "position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    // First child, not last: an absolutely positioned canvas appended
    // after the content would paint over it.
    host.insertBefore(canvas, host.firstChild);

    const uniforms = {
      uResolution: { value: new THREE.Vector2(1, 1) },
      uTime: { value: 0 },
      uInk: { value: palette.ink },
      uLayers: { value: 5 },
      uOpacity: { value: 0.52 },
      uDepth: { value: 1.2 },
      uAir: { value: new THREE.Vector3(...palette.air) },
      uNear: { value: new THREE.Vector3(...palette.near) },
      uFar: { value: new THREE.Vector3(...palette.far) },
    };
    uniforms.uLayers.value = cfg.layerCount;
    uniforms.uOpacity.value = cfg.fogOpacity;
    uniforms.uDepth.value = cfg.depthScale;

    const geometry = new THREE.PlaneGeometry(2, 2);
    const material = new THREE.ShaderMaterial({
      vertexShader: VERTEX,
      fragmentShader: FRAGMENT,
      uniforms,
      depthTest: false,
      depthWrite: false,
    });
    const scene = new THREE.Scene();
    scene.add(new THREE.Mesh(geometry, material));
    const camera = new THREE.Camera();

    const resize = () => {
      const width = Math.max(1, host.clientWidth);
      const heightPx = Math.max(1, host.clientHeight);
      renderer.setSize(width, heightPx, false);
      uniforms.uResolution.value.set(width, heightPx);
    };
    resize();

    const observer = new ResizeObserver(resize);
    observer.observe(host);

    let frame = 0;
    let phase = 0;
    let last = 0;
    let running = false;

    const draw = () => renderer.render(scene, camera);

    const tick = (now: number) => {
      const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      phase += delta * cfg.driftRate * 6.0;
      uniforms.uTime.value = phase;
      draw();
      frame = requestAnimationFrame(tick);
    };

    const start = () => {
      if (running) return;
      running = true;
      last = 0;
      frame = requestAnimationFrame(tick);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(frame);
    };

    // A lost context is routine, not exceptional: a browser grants a
    // limited number and drops the oldest when a page asks for more.
    // preventDefault is what makes the restore possible at all.
    const onLost = (event: Event) => {
      event.preventDefault();
      stop();
    };
    const onRestored = () => {
      resize();
      start();
    };
    canvas.addEventListener("webglcontextlost", onLost);
    canvas.addEventListener("webglcontextrestored", onRestored);

    if (reduced) {
      // Fog at rest is still fog: every slab, its parallax and its
      // occlusion are in the frame whether or not it advances.
      uniforms.uTime.value = 2.4;
      draw();
    } else {
      start();
    }

    return () => {
      stop();
      observer.disconnect();
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      geometry.dispose();
      material.dispose();
      renderer.dispose();
      // dispose() releases three's own objects; the context itself is
      // only handed back here, and a grid of these depends on it being
      // handed back promptly.
      renderer.forceContextLoss();
      canvas.remove();
    };
  }, [cfg]);

  return (
    <div
      ref={hostRef}
      className={className}
      style={{
        position: "relative",
        width: "100%",
        height,
        overflow: "hidden",
        background: PALETTES.night.css,
        ...style,
      }}
    >
      {children}
    </div>
  );
}

export default FogBank;

About this effect

For a weather view, a quiet empty state, or the foot of a long page. Fog painted as a soft grey wash is a flat picture of fog; what makes real fog read as a volume you could walk into is that it has an inside — something near you moves faster than something far away, and whatever sits behind a dense patch is dimmer for it. So this marches a handful of slabs from far to near and lets one depth number per slab drive everything about it: drift speed, feature size, how much light it carries, and how much of what is behind it survives. Tying those together is the whole trick, and choosing them independently produces exactly the flat wash the effect exists to avoid, because parallax is not decoration — in a still frame it is the only cue that says one thing is in front of another. The volume itself comes from one line: each slab multiplies what has already accumulated behind it, so a dense patch genuinely hides what is further away instead of merely being drawn over it. The bank also stays low in the frame on purpose. Fog that reaches the top is a filter over the picture rather than weather in it.

Weather screenQuiet empty statePage footerOffline state

Related effects