Rain glass
Content seen through a rained-on pane: seeded droplets refract it, the glass between them hazes it.
Customize
import {Scene, Video} from "odori";
import {RainGlass} from "../components/rain-glass/rain-glass";
<Video>
<Scene id="rain-glass" duration="8s">
<RainGlass
drops={80}
refraction={0.55}
slide={1}
haze={2.5}
/>
</Scene>
</Video>Installation
pnpm odori add component rain-glassnpx odori add component rain-glassbunx odori add component rain-glassEdit the source in videos/components/rain-glass/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/rain-glass/rain-glass.tsx
import type {ReactNode} from "react";
import {EffectSurface, defineShaderEffect, numberUniform} from "odori/effects";
/**
* Whatever you wrap, seen through a rained-on window.
*
* Two things sell the illusion and both are cheap. Seeded droplets refract:
* inside each one the picture is sampled offset and slightly magnified, the
* way a bead of water actually bends light. And the pane between drops blurs
* a little and runs, so the content underneath stays readable but reads as
* behind glass.
*
* The drops slide on the frame clock, each at its own seeded pace. Set
* `slide` to 0 for a still pane, which is a mood in itself.
*/
export type RainGlassProps = {
children: ReactNode;
/** How many droplets share the frame. */
drops?: number;
/** How hard a droplet bends the picture behind it. */
refraction?: number;
/** How fast drops run down the pane. 0 holds them still. */
slide?: number;
/** Blur of the pane between drops, in pixels. */
haze?: number;
};
const glass = defineShaderEffect({
name: "rain-glass",
uniforms: {
drops: numberUniform(80),
refraction: numberUniform(0.55),
slide: numberUniform(1),
haze: numberUniform(2.5),
},
fragmentShader: `
uniform float drops;
uniform float refraction;
uniform float slide;
uniform float haze;
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
vec2 aspect = vec2(resolution.x / resolution.y, 1.0);
/* The pane: a faint blur, cheap at four taps, plus a slow vertical smear. */
vec2 blur = haze / resolution;
vec4 pane = (tex(uv + vec2(blur.x, 0.0)) + tex(uv - vec2(blur.x, 0.0)) +
tex(uv + vec2(0.0, blur.y)) + tex(uv - vec2(0.0, blur.y))) * 0.25;
/* Droplets live on a jittered grid. Each cell holds at most one, with a
seeded size, a seeded lane, and its own pace down the pane. */
float cells = sqrt(drops) * 1.4;
vec4 colour = pane;
vec2 field = uv * aspect;
for (float around = 0.0; around < 9.0; around += 1.0) {
vec2 neighbour = vec2(mod(around, 3.0) - 1.0, floor(around / 3.0) - 1.0);
vec2 cell = floor(field * cells) + neighbour;
float seed = hash(cell * 1.73 + 11.0);
if (seed < 0.45) continue;
/* The droplet's centre in the same aspect-corrected space as the pixel:
a seeded lane across the cell, and a seeded pace down the pane. */
float pace = (0.02 + seed * 0.06) * slide;
float lane = 0.2 + hash(cell + 3.0) * 0.6;
float down = slide > 0.0 ? fract(0.5 + hash(cell + 7.0) - seconds * pace) : 0.5;
vec2 centre = (cell + vec2(lane, down)) / cells;
float size = (0.12 + hash(cell + 5.0) * 0.25) / cells;
vec2 toDrop = field - centre;
float d = length(toDrop);
float inside = smoothstep(size, size * 0.7, d);
if (inside <= 0.0) continue;
/* Refract: sample the sharp picture squeezed toward the drop's centre
and pushed past it, which is what a lens that small does. */
vec2 bent = uv - (toDrop / aspect) * refraction * 3.0;
vec4 through = tex(clamp(bent, 0.0, 1.0));
/* A bead reads even over a flat ground: a bright lower rim where light
exits, a faint shadow at the top, and a small specular above centre. */
float rim = smoothstep(size * 0.7, size * 0.95, d) * (0.25 + 0.35 * smoothstep(0.0, -size * 0.5, toDrop.y));
float spec = smoothstep(size * 0.35, 0.0, length(toDrop - vec2(-size * 0.25, size * 0.3))) * 0.18;
colour = mix(colour, through * 1.06 + rim + spec, inside);
}
odoriColour = colour;
}`,
});
export const RainGlass = ({children, drops = 80, refraction = 0.55, slide = 1, haze = 2.5}: RainGlassProps) => (
<EffectSurface effects={[glass({drops, refraction, slide, haze})]}>{children}</EffectSurface>
);
videos/components/rain-glass/rain-glass.preview.tsx
import {Fill, useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {RainGlass} from "./rain-glass";
const Behind = () => {
const brand = useBrand();
return (
<Fill
style={{
alignItems: "center",
background: `linear-gradient(135deg, ${brand.colors.surface}, ${brand.colors.background})`,
color: brand.colors.foreground,
display: "flex",
fontFamily: brand.typography.display ?? brand.typography.sans,
fontSize: 170,
fontWeight: 700,
justifyContent: "center",
}}
>
Night bus
</Fill>
);
};
export default defineComponentPreview({
title: "Rain glass",
category: "Motion/Treatments",
description: "Content seen through a rained-on pane: seeded droplets refract it, the glass between them hazes it.",
component: RainGlass,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
drops: {type: "number", defaultValue: 80, min: 20, max: 200, step: 5},
refraction: {type: "number", defaultValue: 0.55, min: 0, max: 1.5, step: 0.05},
slide: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
haze: {type: "number", defaultValue: 2.5, min: 0, max: 8, step: 0.5},
},
examples: [
{name: "Running", props: {children: <Behind />}},
{name: "Still pane", props: {children: <Behind />, slide: 0, haze: 4}},
{name: "Light drizzle", props: {children: <Behind />, drops: 30, refraction: 0.35}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {RainGlass} from "../components/rain-glass/rain-glass";
<Video>
<Scene id="rain-glass" duration="8s">
<RainGlass
drops={80}
refraction={0.55}
slide={1}
haze={2.5}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Motion
- Recommended duration
- 240 frames · 8s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set slide to 0 and the drops hold still on the pane
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
children | ReactNode | — | Yes |
dropsHow many droplets share the frame. | number | 80 | — |
refractionHow hard a droplet bends the picture behind it. | number | 0.55 | — |
slideHow fast drops run down the pane. 0 holds them still. | number | 1 | — |
hazeBlur of the pane between drops, in pixels. | number | 2.5 | — |
Related components
All componentsCamera stageAn animated camera that moves through your scene.Cell revealTiles slide into place to reveal your content.Charged borderA moving glow around a rounded frame.Dissolve revealA noise threshold sweeps across the frame, so content arrives as a torn edge.Gradient fieldA looping gradient in your brand colors.Halftone revealContent revealed through expanding dots.