Glass caustics
Shifting patterns of light beneath water.
Customize
import {Scene, Video} from "odori";
import {GlassCaustics} from "../components/glass-caustics/glass-caustics";
<Video>
<Scene id="glass-caustics" duration="9s">
<GlassCaustics
scale={6}
speed={1}
sharpness={3.2}
/>
</Scene>
</Video>Installation
pnpm odori add component glass-causticsnpx odori add component glass-causticsbunx odori add component glass-causticsEdit the source in videos/components/glass-caustics/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/glass-caustics/glass-caustics.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* The net of light thrown on the bottom of a pool.
*
* A caustic is where a curved surface folds light onto itself, so the bright
* parts are thin, closed and always moving, and the dark parts are most of the
* frame. Getting there is a domain warp: the plane is pushed through itself
* three times, then the bright net is the place where three sines nearly
* cancel. Fewer folds gives ripples on a paddling pool, more gives cut glass.
*
* `sharpness` is the control worth reaching for. Low is a soft wash that will
* sit under body text; high is a hard net that wants to be the whole shot.
*/
export type GlassCausticsProps = {
/** Light and water. Defaults to the brand's foreground on its background. */
colors?: [string, string];
/** Size of the net. Higher is finer. */
scale?: number;
/** How fast the surface moves. */
speed?: number;
/** How thin and bright the lines are. */
sharpness?: number;
};
const hexToRgb = (hex: string): [number, number, number] => {
const clean = hex.replace("#", "");
const full = clean.length === 3 ? [...clean].map((c) => c + c).join("") : clean;
const value = Number.parseInt(full.slice(0, 6), 16);
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
};
/**
* The brand's darkest and lightest tokens, in that order.
*
* Light is bright and water is dark whichever way the brand runs, so taking
* `foreground` for the net drew black lines on white under a light brand —
* a photographic negative of the thing being described. Asking which token is
* darker rather than which one is named background is what makes one default
* right in both themes.
*/
const tones = (background: string, foreground: string): [string, string] => {
const luminance = (hex: string): number => {
const [r, g, b] = hexToRgb(hex);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
return luminance(background) <= luminance(foreground)
? [background, foreground]
: [foreground, background];
};
const caustics = defineShaderEffect({
name: "glass-caustics",
uniforms: {
scale: numberUniform(6),
speed: numberUniform(1),
sharpness: numberUniform(3.2),
light: vec3Uniform([1, 1, 1]),
ground: vec3Uniform([0, 0, 0]),
},
fragmentShader: `
uniform float scale;
uniform float speed;
uniform float sharpness;
uniform vec3 light;
uniform vec3 ground;
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
uv.x *= resolution.x / resolution.y;
float t = seconds * speed;
/* Three folds. Each one pushes the plane through a sine of the other axis,
which is what turns a regular grid of ripples into a net that never quite
repeats inside a shot. */
vec2 p = uv * max(0.5, scale);
for (int i = 0; i < 3; i++) {
p += vec2(sin(p.y * 1.5 + t * 0.7), cos(p.x * 1.3 - t * 0.6)) * 0.55;
}
/* The net is the zero set of the three sines: bright exactly where they
cancel, dark everywhere else. The exponent has to be steep — at a gentle
one the bright region is most of the frame and the result reads as poured
ink rather than as light, because a caustic is thin by definition. */
float v = abs(sin(p.x) + sin(p.y) + sin(p.x + p.y)) / 3.0;
float net = pow(clamp(1.0 - v, 0.0, 1.0), max(1.0, sharpness) * 4.5);
/* A far weaker wash underneath, so the water has a floor between the lines
without becoming a second, blurrier net competing with the first. */
float wash = pow(clamp(1.0 - v, 0.0, 1.0), 3.0) * 0.12;
odoriColour = vec4(mix(ground, light, clamp(net + wash, 0.0, 1.0)), 1.0);
}`,
});
export const GlassCaustics = ({colors, scale = 6, speed = 1, sharpness = 3.2}: GlassCausticsProps) => {
const brand = useBrand();
const {width, height} = useVideo();
const [ground, light] = colors
? [colors[1], colors[0]]
: tones(brand.colors.background, brand.colors.foreground);
return (
<EffectSurface
effects={[caustics({scale, speed, sharpness, light: hexToRgb(light), ground: hexToRgb(ground)})]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/glass-caustics/glass-caustics.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {GlassCaustics} from "./glass-caustics";
export default defineComponentPreview({
title: "Glass caustics",
category: "Backgrounds",
description: "Shifting patterns of light beneath water.",
component: GlassCaustics,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
scale: {type: "number", defaultValue: 6, min: 1, max: 14, step: 0.5},
speed: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
sharpness: {type: "number", defaultValue: 3.2, min: 1, max: 8, step: 0.2},
},
examples: [
{name: "Brand", props: {}},
{name: "Under text", props: {sharpness: 1.4, scale: 4, speed: 0.5}},
{name: "Cut glass", props: {sharpness: 6.5, scale: 8}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {GlassCaustics} from "../components/glass-caustics/glass-caustics";
<Video>
<Scene id="glass-caustics" duration="9s">
<GlassCaustics
scale={6}
speed={1}
sharpness={3.2}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Backgrounds
- Recommended duration
- 270 frames · 9s
- Minimum duration
- 60 frames · 2s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the net holds one pattern
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsLight and water. Defaults to the brand's foreground on its background. | [string, string] | — | — |
scaleSize of the net. Higher is finer. | number | 6 | — |
speedHow fast the surface moves. | number | 1 | — |
sharpnessHow thin and bright the lines are. | number | 3.2 | — |
Related components
All componentsAurora veilFlowing curtains of colored light.Bead fieldA fixed grid of dots that swell as two drifting ripples cross them.Deep swellOcean waves beneath a sunlit horizon.Dither fieldA moving gradient with pixelated shading.Dust motesSoft particles drifting across the frame.Gel washBlurred bands of color over a dark background.