Ink bloom
Slow, overlapping waves in two colors.
Customize
import {Scene, Video} from "odori";
import {InkBloom} from "../components/ink-bloom/ink-bloom";
<Video>
<Scene id="ink-bloom" duration="6s">
<InkBloom
scale={1.5}
speed={0.2}
contrast={0.4}
/>
</Scene>
</Video>Installation
pnpm odori add component ink-bloomnpx odori add component ink-bloombunx odori add component ink-bloomEdit the source in videos/components/ink-bloom/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/ink-bloom/ink-bloom.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* Classic plasma: four sine fields travelling in different directions, summed
* and mapped onto two brand colours.
*
* The demoscene version ran hot and fast; this one is tuned as a ground. The
* interference blobs are large, the phases crawl, and the palette defaults to
* background-into-accent so the pattern reads as weather behind the content
* rather than the content itself. Contrast is the control worth having: at 0
* the sum stays a soft wash, at 1 the midpoint hardens into cell walls.
*
* Every phase is a function of `seconds`, so a longer cut keeps travelling
* instead of looping, and nothing here samples anything random at all.
*/
export type InkBloomProps = {
/** Two colours the plasma sits between. Defaults to the brand. */
colors?: [string, string];
/** Size of the interference blobs — higher is busier. */
scale?: number;
/** How fast the fields travel through each other. */
speed?: number;
/** 0 is a soft wash, 1 is hard-walled cells. */
contrast?: 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 field = defineShaderEffect({
name: "ink-bloom",
uniforms: {
scale: numberUniform(1.5),
speed: numberUniform(0.2),
contrast: numberUniform(0.4),
low: vec3Uniform([0.04, 0.05, 0.09]),
high: vec3Uniform([0.45, 0.55, 0.85]),
},
fragmentShader: `
uniform float scale;
uniform float speed;
uniform float contrast;
uniform vec3 low;
uniform vec3 high;
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
uv.x *= resolution.x / resolution.y;
vec2 p = uv * scale * 3.0;
float t = seconds * speed;
/* Four travelling waves: two axis-aligned, one diagonal, one radial around
a centre that itself wanders. Incommensurate frequencies keep the sum
from ever visibly repeating. */
float v = sin(p.x * 1.1 + t);
v += sin(p.y * 0.83 - t * 0.7);
v += sin((p.x + p.y) * 0.71 + t * 1.3);
vec2 centre = vec2(sin(t * 0.43), cos(t * 0.31)) * 1.7;
v += sin(length(p - centre) * 1.27 - t * 0.9);
/* The sum of four unit sines lands in [-4, 4]; fold it to [0, 1] and let
contrast push the midpoint toward a hard edge. */
float n = v * 0.125 + 0.5;
n = mix(n, smoothstep(0.38, 0.62, n), contrast);
odoriColour = vec4(mix(low, high, clamp(n, 0.0, 1.0)), 1.0);
}`,
});
export const InkBloom = ({colors, scale = 1.5, speed = 0.2, contrast = 0.4}: InkBloomProps) => {
const brand = useBrand();
const {width, height} = useVideo();
const [low, high] = colors ?? [brand.colors.background, brand.colors.accent];
return (
<EffectSurface effects={[field({scale, speed, contrast, low: hexToRgb(low), high: hexToRgb(high)})]}>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/ink-bloom/ink-bloom.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {InkBloom} from "./ink-bloom";
export default defineComponentPreview({
title: "Ink bloom",
category: "Backgrounds",
description: "Slow, overlapping waves in two colors.",
component: InkBloom,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
scale: {type: "number", defaultValue: 1.5, min: 0.5, max: 5, step: 0.25},
speed: {type: "number", defaultValue: 0.2, min: 0, max: 1, step: 0.05},
contrast: {type: "number", defaultValue: 0.4, min: 0, max: 1, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "Cells", props: {contrast: 1, scale: 2.5}},
{name: "Lava", props: {colors: ["#1a0505", "#ff6b35"], scale: 1, speed: 0.12}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {InkBloom} from "../components/ink-bloom/ink-bloom";
<Video>
<Scene id="ink-bloom" duration="6s">
<InkBloom
scale={1.5}
speed={0.2}
contrast={0.4}
/>
</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
- 180 frames · 6s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the interference pattern holds still
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsTwo colours the plasma sits between. Defaults to the brand. | [string, string] | — | — |
scaleSize of the interference blobs — higher is busier. | number | 1.5 | — |
speedHow fast the fields travel through each other. | number | 0.2 | — |
contrast0 is a soft wash, 1 is hard-walled cells. | number | 0.4 | — |
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.