Deep swell
Ocean waves beneath a sunlit horizon.
Customize
import {Scene, Video} from "odori";
import {DeepSwell} from "../components/deep-swell/deep-swell";
<Video>
<Scene id="deep-swell" duration="8s">
<DeepSwell
swell={0.45}
speed={1}
sun={0.5}
/>
</Scene>
</Video>Installation
pnpm odori add component deep-swellnpx odori add component deep-swellbunx odori add component deep-swellEdit the source in videos/components/deep-swell/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/deep-swell/deep-swell.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* Open water seen from just above it, to the horizon.
*
* A water surface is a sum of waves, and the thing that makes one read as
* water rather than as a ripple pattern is that the sum is uneven: several
* trains crossing at angles that never repeat inside a shot, each slower and
* longer than the last. Six of them is enough to lose the beat.
*
* The horizon does the rest. Screen height maps to distance rather than to
* space, so waves compress as they recede and the eye reads depth before it
* reads motion — and the sun sits on that same line, which is what turns a
* moving surface into a time of day.
*/
export type DeepSwellProps = {
/** Water and sky. Defaults to the brand's ground and its accent. */
colors?: [string, string];
/** Wave height. Past about 0.7 the sea stops being calm. */
swell?: number;
/** How fast the trains run. */
speed?: number;
/** Where the sun sits along the horizon, 0 left to 1 right. */
sun?: number;
};
/**
* The brand's darkest and lightest tokens, in that order.
*
* Water is dark and sky is light in every weather, so a component that took
* `background` for the sea painted a white ocean under a light brand and a
* black sky under a dark one. Asking which token is darker rather than which
* token is named background is what makes one default correct in both.
*/
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 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];
};
const swell = defineShaderEffect({
name: "deep-swell",
uniforms: {
swell: numberUniform(0.45),
speed: numberUniform(1),
sun: numberUniform(0.5),
water: vec3Uniform([0.02, 0.05, 0.08]),
sky: vec3Uniform([0.9, 0.94, 1]),
},
fragmentShader: `
uniform float swell;
uniform float speed;
uniform float sun;
uniform vec3 water;
uniform vec3 sky;
/* One wave train: a direction, a length, and a speed of its own. Kept as a
function so the height and the two samples either side of it - which is how
the normal is found - all agree by construction. */
float train(vec2 p, vec2 dir, float length, float rate, float t) {
return sin(dot(p, dir) * length + t * rate);
}
float surface(vec2 p, float t) {
float h = 0.0;
h += train(p, normalize(vec2(1.0, 0.35)), 2.30, 0.90, t) * 1.00;
h += train(p, normalize(vec2(-0.7, 0.6)), 3.70, 1.30, t) * 0.55;
h += train(p, normalize(vec2(0.25, 1.0)), 6.10, 1.70, t) * 0.30;
h += train(p, normalize(vec2(-1.0, 0.15)), 9.70, 2.30, t) * 0.16;
h += train(p, normalize(vec2(0.6, -0.8)), 15.3, 3.10, t) * 0.09;
h += train(p, normalize(vec2(1.0, 1.0)), 24.7, 4.10, t) * 0.05;
return h / 2.15;
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
float t = seconds * speed;
/* Above the horizon is sky, and the horizon sits high so the water is the
subject. The gradient is faint on purpose: a sky that competes is a sky
that dates the shot. */
float horizon = 0.72;
if (uv.y > horizon) {
float up = (uv.y - horizon) / (1.0 - horizon);
vec3 air = mix(sky * 0.55, sky * 0.24, up);
float glow = exp(-pow(abs(uv.x - sun) * 3.4, 2.0)) * (1.0 - up) * 0.5;
odoriColour = vec4(air + sky * glow, 1.0);
return;
}
/* Screen height becomes distance. The reciprocal is what compresses the
waves as they recede; without it the sea reads as wallpaper. */
float depth = horizon - uv.y;
float distance = 0.16 / max(depth, 0.004);
vec2 p = vec2((uv.x - 0.5) * distance * 2.2, distance);
/* The normal, from two samples either side. Cheaper than a derivative and
stable at the horizon, where screen-space derivatives explode. */
float e = 0.012 + distance * 0.004;
float h = surface(p, t);
float hx = surface(p + vec2(e, 0.0), t);
float hy = surface(p + vec2(0.0, e), t);
vec3 normal = normalize(vec3((h - hx) * swell, e * 2.2, (h - hy) * swell));
/* Fresnel: water is a mirror at a glancing angle and glass overhead, which
is the whole reason a sea is bright at the horizon and dark underfoot. */
vec3 view = normalize(vec3(0.0, 0.42, -1.0));
float fresnel = pow(1.0 - clamp(dot(normal, view), 0.0, 1.0), 3.4);
vec3 sunDir = normalize(vec3((sun - 0.5) * 2.0, 0.11, 1.0));
float specular = pow(max(dot(reflect(-sunDir, normal), view), 0.0), 90.0);
vec3 colour = mix(water, water * 2.4 + sky * 0.10, clamp(h * 0.5 + 0.5, 0.0, 1.0));
colour = mix(colour, sky * 0.55, fresnel * 0.75);
colour += sky * specular * 1.5;
/* Haze at the horizon line, so the water meets the sky instead of ending. */
colour = mix(colour, sky * 0.42, smoothstep(0.55, 1.0, uv.y / horizon) * 0.5);
odoriColour = vec4(colour, 1.0);
}`,
});
export const DeepSwell = ({colors, swell: height = 0.45, speed = 1, sun = 0.5}: DeepSwellProps) => {
const brand = useBrand();
const {width, height: frameHeight} = useVideo();
const [water, sky] = colors ?? tones(brand.colors.background, brand.colors.foreground);
return (
<EffectSurface
effects={[swell({swell: height, speed, sun, water: hexToRgb(water), sky: hexToRgb(sky)})]}
>
<Fill style={{background: brand.colors.background, height: frameHeight, width}} />
</EffectSurface>
);
};
videos/components/deep-swell/deep-swell.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DeepSwell} from "./deep-swell";
export default defineComponentPreview({
title: "Deep swell",
category: "Backgrounds",
description: "Ocean waves beneath a sunlit horizon.",
component: DeepSwell,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
swell: {type: "number", defaultValue: 0.45, min: 0, max: 1, step: 0.05},
speed: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
sun: {type: "number", defaultValue: 0.5, min: 0, max: 1, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "Low sun", props: {sun: 0.18, swell: 0.32, speed: 0.7}},
{name: "Heavy sea", props: {swell: 0.85, speed: 1.6}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DeepSwell} from "../components/deep-swell/deep-swell";
<Video>
<Scene id="deep-swell" duration="8s">
<DeepSwell
swell={0.45}
speed={1}
sun={0.5}
/>
</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
- 240 frames · 8s
- Minimum duration
- 60 frames · 2s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the sea holds one still surface
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsWater and sky. Defaults to the brand's ground and its accent. | [string, string] | — | — |
swellWave height. Past about 0.7 the sea stops being calm. | number | — | — |
speedHow fast the trains run. | number | 1 | — |
sunWhere the sun sits along the horizon, 0 left to 1 right. | number | 0.5 | — |
Related components
All componentsAurora veilFlowing curtains of colored light.Bead fieldA fixed grid of dots that swell as two drifting ripples cross them.Dither fieldA moving gradient with pixelated shading.Dust motesSoft particles drifting across the frame.Gel washBlurred bands of color over a dark background.Glass causticsShifting patterns of light beneath water.