Glyph rain
Columns of glowing, falling characters.
Customize
import {Scene, Video} from "odori";
import {GlyphRain} from "../components/glyph-rain/glyph-rain";
<Video>
<Scene id="glyph-rain" duration="8s">
<GlyphRain
columns={56}
speed={1}
brightness={0.8}
/>
</Scene>
</Video>Installation
pnpm odori add component glyph-rainnpx odori add component glyph-rainbunx odori add component glyph-rainEdit the source in videos/components/glyph-rain/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/glyph-rain/glyph-rain.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* Columns of falling glyphs, drawn by the shader itself.
*
* No font is involved: each cell's glyph is a five-by-five bitmask picked by
* the hash and flipped a few times a second, which is what keeps the rain
* identical on every machine. A column's head is bright, its tail decays,
* and every column falls at its own seeded speed.
*
* It reads instantly as "systems at work", which makes it a background for
* developer product video the way a city street is a background for film.
* Keep `brightness` low under text; the head glow is the part that competes.
*/
export type GlyphRainProps = {
/** Glyph colour and ground. Defaults to brand accent over background. */
colors?: [string, string];
/** How many columns across the frame. */
columns?: number;
/** Fall speed. */
speed?: number;
/** Overall intensity, 0 to 1. */
brightness?: 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];
};
const rain = defineShaderEffect({
name: "glyph-rain",
uniforms: {
columns: numberUniform(56),
speed: numberUniform(1),
brightness: numberUniform(0.8),
ink: vec3Uniform([0.5, 1.0, 0.6]),
ground: vec3Uniform([0.01, 0.02, 0.01]),
},
fragmentShader: `
uniform float columns;
uniform float speed;
uniform float brightness;
uniform vec3 ink;
uniform vec3 ground;
/*
* A glyph is 25 bits of hash painted into a 5x5 cell. Integer bit tests are
* exact in GLSL ES 3.00, so the same cell shows the same mark everywhere.
*/
float glyph(vec2 inCell, float seed) {
vec2 grid = floor(inCell * 5.0);
float bit = grid.y * 5.0 + grid.x;
uint mask = odoriPcg(uint(seed * 1024.0) + 7u);
bool on = ((mask >> uint(bit)) & 1u) == 1u;
/* Thin margins so glyphs read as marks rather than as one solid block. */
vec2 f = fract(inCell * 5.0);
float body = step(0.15, f.x) * step(f.x, 0.92) * step(0.1, f.y) * step(f.y, 0.95);
return on ? body : 0.0;
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
float aspect = resolution.x / resolution.y;
float column = floor(uv.x * columns);
float columnSeed = hash(vec2(column, 3.7));
float fall = seconds * speed * (0.35 + columnSeed * 0.9);
float rows = columns / aspect;
float y = uv.y * rows + fall * rows * 0.4;
float row = floor(y);
vec2 inCell = vec2(fract(uv.x * columns), fract(y));
/* The glyph flips a few times a second, seeded by cell and tick. */
float tick = floor(seconds * (2.0 + columnSeed * 4.0));
float seed = hash(vec2(column * 57.0 + row, tick));
float mark = glyph(inCell, seed);
/* Head position wraps down the column; brightness decays behind it. */
float head = fract(columnSeed * 9.17 + fall * 0.4);
float distanceBehind = fract(head - uv.y);
float tail = pow(1.0 - distanceBehind, 3.0);
float isHead = smoothstep(0.03, 0.0, distanceBehind);
vec3 colour = ground + ink * mark * tail * brightness + vec3(1.0) * mark * isHead * brightness * 0.7;
colour += (hash(gl_FragCoord.xy + frame) - 0.5) * 0.015;
odoriColour = vec4(colour, 1.0);
}`,
});
export const GlyphRain = ({colors, columns = 56, speed = 1, brightness = 0.8}: GlyphRainProps) => {
const brand = useBrand();
const {width, height} = useVideo();
const [ink, ground] = colors ?? [brand.colors.accent, brand.colors.background];
return (
<EffectSurface
effects={[rain({columns, speed, brightness, ink: hexToRgb(ink), ground: hexToRgb(ground)})]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/glyph-rain/glyph-rain.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {GlyphRain} from "./glyph-rain";
export default defineComponentPreview({
title: "Glyph rain",
category: "Backgrounds",
description: "Columns of glowing, falling characters.",
component: GlyphRain,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
columns: {type: "number", defaultValue: 56, min: 20, max: 120, step: 2},
speed: {type: "number", defaultValue: 1, min: 0.2, max: 3, step: 0.1},
brightness: {type: "number", defaultValue: 0.8, min: 0.1, max: 1, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "Under text", props: {brightness: 0.35, speed: 0.6}},
{name: "Dense terminal", props: {columns: 100, colors: ["#7ce7c6", "#04120a"]}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {GlyphRain} from "../components/glyph-rain/glyph-rain";
<Video>
<Scene id="glyph-rain" duration="8s">
<GlyphRain
columns={56}
speed={1}
brightness={0.8}
/>
</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
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed near 0 and the rain slows to a drip
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsGlyph colour and ground. Defaults to brand accent over background. | [string, string] | — | — |
columnsHow many columns across the frame. | number | 56 | — |
speedFall speed. | number | 1 | — |
brightnessOverall intensity, 0 to 1. | number | 0.8 | — |
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.