Thin Film Wash
Colour computed from film thickness at three wavelengths, so the sequence is interference and not a hue ramp.
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.
"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.
Related effects
- Molten MirrorA four-number room sampled through a bent view direction, so every highlight is a reflection rather than a painted one.
- Plasma BloomThree fields multiplied rather than added, so light appears only where all three agree and most of the frame stays dark.
- Aurora CurtainRays that arrive where a sampled sheet folds end-on, so no line of code decides where a ray belongs.