Warp tunnel
Streaks of light rushing past a vanishing point.
Customize
import {Scene, Video} from "odori";
import {WarpTunnel} from "../components/warp-tunnel/warp-tunnel";
<Video>
<Scene id="warp-tunnel" duration="4s">
<WarpTunnel
speed={1}
density={220}
/>
</Scene>
</Video>Installation
pnpm odori add component warp-tunnelnpx odori add component warp-tunnelbunx odori add component warp-tunnelEdit the source in videos/components/warp-tunnel/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/warp-tunnel/warp-tunnel.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* Star lines rushing past the camera, for the shot before the shot.
*
* Every streak is a seeded star: its angle and depth come from the hash, its
* length from how fast it is passing, and the whole field pours toward or
* away from a vanishing point. Drive `approach` from the scene and the jump
* lands exactly on a cut, which is what this is for: two seconds of velocity
* in front of a title.
*
* Deterministic like everything else: frame 40 is the same streaks at the
* same lengths on every render, because the stars are functions of the hash
* and the clock rather than a particle system with a memory.
*/
export type WarpTunnelProps = {
/** Streak colour and the deep end. Defaults to brand foreground over background. */
colors?: [string, string];
/** Travel speed. Negative pulls backwards. */
speed?: number;
/** How many streaks share the frame. */
density?: number;
/** Where the vanishing point sits, as fractions of the frame. */
centre?: [number, 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 tunnel = defineShaderEffect({
name: "warp-tunnel",
uniforms: {
speed: numberUniform(1),
density: numberUniform(220),
centreX: numberUniform(0.5),
centreY: numberUniform(0.5),
streak: vec3Uniform([1, 1, 1]),
deep: vec3Uniform([0.02, 0.02, 0.05]),
},
fragmentShader: `
uniform float speed;
uniform float density;
uniform float centreX;
uniform float centreY;
uniform vec3 streak;
uniform vec3 deep;
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
vec2 aspect = vec2(resolution.x / resolution.y, 1.0);
vec2 offset = (uv - vec2(centreX, centreY)) * aspect;
float radius = length(offset);
float angle = atan(offset.y, offset.x);
float t = seconds * speed;
vec3 colour = deep * (1.0 - radius * 0.6);
/* Each star is one slot around the circle. Its depth pours toward the
camera with time; brightness and length grow as it closes in. */
for (float layer = 0.0; layer < 3.0; layer += 1.0) {
float slots = density * (1.0 - layer * 0.25);
float slot = floor(angle / 6.2831853 * slots + layer * 17.0);
float jitter = hash(vec2(slot, layer * 31.0));
float lane = (slot + 0.5 + (jitter - 0.5) * 0.8) / slots * 6.2831853;
float across = abs(sin(angle - lane)) * radius;
float depth = fract(jitter * 7.31 - t * (0.25 + layer * 0.2) * sign(speed == 0.0 ? 1.0 : speed));
float near = 1.0 - depth;
float head = 0.08 + near * 0.9;
float tail = head - (0.05 + near * 0.35);
float along = smoothstep(tail, head, radius) * smoothstep(head + 0.02, head, radius);
float width = mix(0.0008, 0.004, near);
float line = smoothstep(width, 0.0, across);
colour += streak * line * along * (0.25 + near * 0.75);
}
colour += (hash(gl_FragCoord.xy + frame) - 0.5) * 0.02;
odoriColour = vec4(colour, 1.0);
}`,
});
export const WarpTunnel = ({colors, speed = 1, density = 220, centre = [0.5, 0.5]}: WarpTunnelProps) => {
const brand = useBrand();
const {width, height} = useVideo();
const [bright, dark] = colors ?? [brand.colors.foreground, brand.colors.background];
return (
<EffectSurface
effects={[
tunnel({
speed,
density,
centreX: centre[0],
centreY: centre[1],
streak: hexToRgb(bright),
deep: hexToRgb(dark),
}),
]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/warp-tunnel/warp-tunnel.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {WarpTunnel} from "./warp-tunnel";
export default defineComponentPreview({
title: "Warp tunnel",
category: "Backgrounds",
description: "Streaks of light rushing past a vanishing point.",
component: WarpTunnel,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
speed: {type: "number", defaultValue: 1, min: -3, max: 3, step: 0.1},
density: {type: "number", defaultValue: 220, min: 60, max: 400, step: 10},
},
examples: [
{name: "Brand", props: {}},
{name: "Reverse", props: {speed: -0.8}},
{name: "Off centre", props: {centre: [0.3, 0.6], density: 300}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {WarpTunnel} from "../components/warp-tunnel/warp-tunnel";
<Video>
<Scene id="warp-tunnel" duration="4s">
<WarpTunnel
speed={1}
density={220}
/>
</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
- 120 frames · 4s
- Minimum duration
- 24 frames · 0.8s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the streaks hold as a star chart
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsStreak colour and the deep end. Defaults to brand foreground over background. | [string, string] | — | — |
speedTravel speed. Negative pulls backwards. | number | 1 | — |
densityHow many streaks share the frame. | number | 220 | — |
centreWhere the vanishing point sits, as fractions of the frame. | [number, 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.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.