← All WebGL effects

Thin Film Wash

Colour computed from film thickness at three wavelengths, so the sequence is interference and not a hue ramp.

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

306 lines · react + three
"use client";

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

/**
 * Thin Film Wash — the colour is a thickness, not a palette.
 *
 * Iridescence on a soap bubble or an oil slick is interference. Light
 * reflecting off the top of a very thin film and light reflecting off
 * the bottom travel different distances; where that difference is a
 * whole number of wavelengths the two add, and where it is half a
 * wavelength they cancel. Since the difference is fixed but the
 * wavelength is not, each colour cancels at a different thickness — and
 * the film shows whichever colours survived.
 *
 * Which is why the usual approach fails so recognisably. Rotating a hue
 * through a rainbow gives you every colour in order, evenly, forever;
 * interference gives you a particular sequence with particular gaps,
 * repeating faster as the film thickens. That specific sequence is what
 * the eye recognises as oil on water, and no hue ramp produces it.
 *
 * So this shader carries a thickness field and computes the path
 * difference from it, then asks three times — once each at the
 * wavelength of red, green and blue — how much of that colour came
 * back. There is no palette in the file. The colours are consequences.
 *
 * Single file, react and three only. No textures, no add-ons.
 */

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

const VARIANTS = {
  subtle: { driftRate: 0.05, fringeCount: 3, filmOpacity: 0.3, thicknessScale: 0.85 },
  default: { driftRate: 0.12, fringeCount: 5, filmOpacity: 0.5, thicknessScale: 1.2 },
  playful: { driftRate: 0.28, fringeCount: 9, filmOpacity: 0.72, thicknessScale: 1.75 },
} as const;

const PALETTES = {
  night: {
    base: [0.04, 0.045, 0.062],
    lift: 0.0,
    css: "linear-gradient(160deg, #14161f 0%, #0b0d14 60%, #08090f 100%)",
  },
  day: {
    base: [0.9, 0.905, 0.92],
    lift: 1.0,
    css: "linear-gradient(160deg, #e9eaef 0%, #f2f3f6 60%, #f6f7f9 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 uFringes;
  uniform float uOpacity;
  uniform float uThickness;
  uniform float uInk;
  uniform vec3  uBase;

  // Wavelengths in nanometres, near enough. These three numbers are the
  // only thing in the file that decides what colour anything is, and
  // they are physical constants rather than design choices.
  const vec3 LAMBDA = vec3(612.0, 549.0, 464.0);

  /** How thick the film is here, in nanometres. */
  float filmThickness(vec2 p, float t, float fringes) {
    float h = sin(p.x * fringes * 1.7 + t * 0.61)
            + sin(p.y * fringes * 2.1 - t * 0.43) * 0.8
            + sin((p.x * 0.7 + p.y * 1.3) * fringes * 1.15 + t * 0.29) * 0.6
            + sin((p.x * 1.9 - p.y * 0.8) * fringes * 2.7 - t * 0.17) * 0.3;
    h /= 2.7;
    // Kept inside the range where the fringes are broad enough to read
    // as a surface. Letting the thickness run wider is physically fine
    // and visually worthless: past a few hundred nanometres the orders
    // pile up, every pixel differs from its neighbour, and interference
    // that is technically correct arrives on screen as rainbow soup.
    return 380.0 + h * 210.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) * uThickness;

    float d = filmThickness(p, uTime, uFringes);

    // Angle through the film. Straight on at the centre, more oblique
    // towards the corners — which is why the fringes crowd at the
    // edges, exactly as they do on a real bubble.
    vec2 fromCentre = vec2(vUv.x - 0.5, vUv.y - 0.5) * 2.0;
    float cosTheta = 1.0 / sqrt(1.0 + dot(fromCentre, fromCentre) * 0.45);

    // Optical path difference: twice the thickness, through a film with
    // a refractive index around that of soapy water.
    float opd = 2.0 * 1.34 * d * cosTheta;

    // Reflection off the denser medium flips the phase, which is the
    // extra half-wavelength — and the reason a very thin film is dark
    // rather than white. Dropping it is the commonest way to get this
    // wrong, and it inverts every colour in the sequence.
    vec3 phase = 6.2831853 * opd / LAMBDA + 3.1415927;
    vec3 reflected = 0.5 + 0.5 * cos(phase);

    // Thin films are faint head-on and brighten towards grazing — a
    // Schlick approximation, one line. It is also what keeps this
    // usable: the middle of the frame stays close to the base colour,
    // so the sheen washes in from the edges and whatever is written
    // across the centre can still be read.
    float fresnel = 0.06 + 0.94 * pow(1.0 - cosTheta, 3.0);
    float strength = uOpacity * (0.10 + 0.90 * fresnel) * 0.62;

    vec3 film = reflected * strength;

    vec3 lit = uBase + film;
    vec3 inked = uBase - film * 0.55;
    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 ThinFilmWashProps = {
  variant?: Variant;
  height?: string;
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
};

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

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

    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 {
      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));
    host.insertBefore(canvas, host.firstChild);

    const uniforms = {
      uResolution: { value: new THREE.Vector2(1, 1) },
      uTime: { value: 0 },
      uFringes: { value: 5 },
      uOpacity: { value: 0.5 },
      uThickness: { value: 1.2 },
      uInk: { value: palette.lift },
      uBase: { value: new THREE.Vector3(...palette.base) },
    };
    uniforms.uFringes.value = cfg.fringeCount;
    uniforms.uOpacity.value = cfg.filmOpacity;
    uniforms.uThickness.value = cfg.thicknessScale;

    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);
    };

    const onLost = (event: Event) => {
      event.preventDefault();
      stop();
    };
    const onRestored = () => {
      resize();
      start();
    };
    canvas.addEventListener("webglcontextlost", onLost);
    canvas.addEventListener("webglcontextrestored", onRestored);

    if (reduced) {
      // A still film is still iridescent — the fringes are a property
      // of the thickness, and the thickness does not need to move.
      uniforms.uTime.value = 0.8;
      draw();
    } else {
      start();
    }

    return () => {
      stop();
      observer.disconnect();
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      geometry.dispose();
      material.dispose();
      renderer.dispose();
      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 ThinFilmWash;

About this effect

For something meant to be looked at once and remembered — a launch banner, a new version, a first-run screen — rather than behind a surface someone works in all day. Iridescence is interference: light bouncing off the top of a very thin film and light bouncing off the bottom travel different distances, and where that difference is a whole number of wavelengths the two add, where it is half a wavelength they cancel. The difference is fixed but the wavelength is not, so each colour cancels at a different thickness and the film shows whatever survived. This is why rotating a hue through a rainbow never passes for oil on water: a hue ramp gives every colour in order, evenly, forever, and interference gives a particular sequence with particular gaps that repeats faster as the film thickens. That sequence is the thing the eye recognises. So the file carries a thickness field, computes the optical path difference from it — including the half-wavelength the phase flip adds, which is what makes a very thin film dark rather than white, and the commonest thing to leave out — then asks three times, at the wavelength of red, green and blue, how much came back. There is no palette in the file. Every colour is a consequence.

Release bannerFirst-run screenFeature announcementLanding hero

Related effects