← All WebGL effects

Plasma Bloom

Three fields multiplied rather than added, so light appears only where all three agree and most of the frame stays dark.

ambientcalmfuturisticpremium1 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.

304 lines · react + three
"use client";

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

/**
 * Plasma Bloom — light only where three layers agree.
 *
 * The reason most "plasma" backgrounds look cheap is arithmetic. They
 * add their noise layers together, and a sum is bright almost
 * everywhere: every layer contributes at every pixel, the field never
 * gets out of its own way, and the result is an even glow with no
 * structure — which is exactly the look people mean when they say
 * something looks generated.
 *
 * This multiplies instead. Three fields at different scales, each
 * mostly near zero, and a product that only rises where all three
 * happen to be high at once. Coincidence is rare, so most of the frame
 * stays dark and the glow arrives in a few places with real edges. The
 * dark is not painted in and the bright spots are not placed; both are
 * what a product does that a sum cannot.
 *
 * Everything else follows from that one choice. Because the field is
 * mostly dark, it can carry a strong colour without shouting, and text
 * can sit on it without a scrim over most of the frame.
 *
 * Single file, react and three only. No textures, no add-ons.
 */

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

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

const PALETTES = {
  night: {
    deep: [0.028, 0.035, 0.056],
    core: [0.42, 0.86, 0.72],
    edge: [0.30, 0.36, 0.86],
    ink: 0,
    css: "radial-gradient(120% 90% at 30% 40%, #16203a 0%, #0a0e1a 55%, #06080f 100%)",
  },
  day: {
    deep: [0.93, 0.94, 0.96],
    core: [0.06, 0.44, 0.36],
    edge: [0.16, 0.20, 0.52],
    ink: 1,
    css: "radial-gradient(120% 90% at 30% 40%, #e8ecf4 0%, #f2f4f8 55%, #f7f8fb 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  uCells;
  uniform float  uOpacity;
  uniform float  uBloom;
  uniform vec3  uDeep;
  uniform vec3  uCore;
  uniform vec3  uEdge;

  /** One smooth field. Cheap on purpose — three of these run per pixel. */
  float layer(vec2 p, float t, float freq, float drift) {
    return 0.5 + 0.5 * sin(p.x * freq + t * drift)
                     * sin(p.y * freq * 0.87 - t * drift * 0.73)
                     + 0.0;
  }

  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 p = vec2(vUv.x * aspect, vUv.y) * uBloom;

    float a = layer(p, uTime, uCells * 1.00, 0.62);
    float b = layer(p + 11.3, uTime, uCells * 1.63, -0.44);
    float c = layer(p - 5.7, uTime, uCells * 2.71, 0.29);

    // The whole effect is this line being a product and not a sum.
    // Adding these would light every pixel a little; multiplying them
    // lights a few pixels a lot and leaves the rest alone.
    float agreement = a * b * c;

    // Expanded so the near-misses fall away rather than forming a haze.
    float glow = pow(clamp(agreement, 0.0, 1.0), 2.6);

    // The rim reads as the edge of something. It is the derivative of
    // the same field, so it cannot sit anywhere the glow is not.
    float rim = clamp(pow(agreement, 1.4) - pow(agreement, 3.2), 0.0, 1.0) * 2.4;

    vec3 light = uCore * glow + uEdge * rim * 0.9;
    float amount = uOpacity * 1.5;

    vec3 lit = uDeep + light * amount;
    vec3 inked = mix(uDeep, uCore * 0.6 + uEdge * 0.4, clamp((glow + rim) * amount, 0.0, 1.0));
    vec3 color = mix(lit, inked, uInk);

    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 PlasmaBloomProps = {
  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 PlasmaBloom({
  variant = "default",
  height = "100%",
  className,
  style,
  children,
}: PlasmaBloomProps) {
  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 },
      uCells: { value: 5 },
      uOpacity: { value: 0.52 },
      uBloom: { value: 1.2 },
      uDeep: { value: new THREE.Vector3(...palette.deep) },
      uCore: { value: new THREE.Vector3(...palette.core) },
      uEdge: { value: new THREE.Vector3(...palette.edge) },
    };
    uniforms.uCells.value = cfg.cellCount;
    uniforms.uOpacity.value = cfg.glowOpacity;
    uniforms.uBloom.value = cfg.bloomScale;

    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) {
      // The field is a still image before it is an animation: every
      // bloom and every rim is where the three layers agree right now.
      uniforms.uTime.value = 1.9;
      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 PlasmaBloom;

About this effect

For a screen someone leaves open — a long job, a model running, a sync in progress. The reason most plasma backgrounds look cheap is arithmetic: they add their layers together, and a sum is bright almost everywhere, because every layer contributes at every pixel. The field never gets out of its own way, and what arrives is an even glow with no structure, which is precisely the look people mean when they say something looks generated. This multiplies instead. Three fields at different scales, each mostly near zero, and a product that only rises where all three happen to be high at once — and coincidence is rare, so most of the frame stays dark and the glow arrives in a few places with real edges. Neither the dark nor the bright spots are placed; both are what a product does and a sum cannot. Everything else follows from that one choice: because the field is mostly dark it can carry a strong colour without shouting, and text can sit on it without a scrim over most of the frame, which is what makes it usable behind a screen that stays open for ten minutes.

Long-running jobAI processing screenSync in progressDashboard backdrop

Related effects