Spiral arm
A galaxy under differential rotation: the core outruns the rim and the arms wind as it turns.
Customize
import {Scene, Video} from "odori";
import {SpiralArm} from "../components/spiral-arm/spiral-arm";
<Video>
<Scene id="spiral-arm" duration="10s">
<SpiralArm
arms={2}
twist={4.2}
speed={1}
density={0.7}
/>
</Scene>
</Video>Installation
pnpm odori add component spiral-armnpx odori add component spiral-armbunx odori add component spiral-armEdit the source in videos/components/spiral-arm/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/spiral-arm/spiral-arm.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* A galaxy, turning the way a real one does.
*
* The detail that sells it is differential rotation: the centre completes a
* turn while the rim has barely moved, so the arms wind tighter as the shot
* runs instead of sweeping round like a wheel. Everything else follows from
* that — the arms are a cosine in polar coordinates, wound by the logarithm of
* the radius, which is the shape a spiral galaxy actually has.
*
* Stars are hashed from their own cell rather than stored, so the same frame
* draws the same sky twice and two export workers meeting at a chunk boundary
* agree on every one of them.
*/
export type SpiralArmProps = {
/** Core and arms. Defaults to the brand's lightest token on its darkest. */
colors?: [string, string];
/** How many arms wind out of the core. */
arms?: number;
/** How tightly they wind. Low is open, high is a whirlpool. */
twist?: number;
/** How fast the galaxy turns. */
speed?: number;
/** How much dust the arms carry, 0 to 1. */
density?: 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];
};
/** Space is dark under any brand, so the ground is the darker token. */
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 galaxy = defineShaderEffect({
name: "spiral-arm",
uniforms: {
arms: numberUniform(2),
twist: numberUniform(4.2),
speed: numberUniform(1),
density: numberUniform(0.7),
light: vec3Uniform([1, 1, 1]),
ground: vec3Uniform([0, 0, 0]),
},
fragmentShader: `
uniform float arms;
uniform float twist;
uniform float speed;
uniform float density;
uniform vec3 light;
uniform vec3 ground;
/* hash() comes from the prelude: integer PCG, exact on every backend, so the
same frame draws the same stars wherever it is rendered. */
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
vec2 p = (uv - 0.5) * vec2(resolution.x / resolution.y, 1.0) * 2.0;
float r = length(p);
float a = atan(p.y, p.x);
float t = seconds * speed;
/* Differential rotation. The inner disc runs away from the rim, which is
what winds the arms as the shot plays rather than spinning them rigidly. */
float spin = t * (0.55 / (r + 0.32));
float field = cos(arms * (a + spin) - log(r + 0.08) * twist);
float arm = pow(max(field, 0.0), 2.8) * exp(-r * 1.85);
vec3 colour = ground;
/* The disc, then the core on top of it: a bulge bright enough to blow out
in the middle is the difference between a galaxy and a pinwheel. */
colour += light * arm * density * 0.85;
colour += light * exp(-r * 5.6) * 1.15;
/* Stars, denser inside the arms because that is where they form. */
vec2 cell = floor(uv * 260.0);
float roll = hash(cell);
float star = step(0.9965 - arm * 0.02, roll);
float twinkle = 0.55 + 0.45 * sin(t * 2.6 + roll * 31.0);
colour += light * star * twinkle * (0.30 + arm * 0.8);
odoriColour = vec4(colour, 1.0);
}`,
});
export const SpiralArm = ({colors, arms = 2, twist = 4.2, speed = 1, density = 0.7}: SpiralArmProps) => {
const brand = useBrand();
const {width, height} = useVideo();
const [ground, light] = colors
? [colors[1], colors[0]]
: tones(brand.colors.background, brand.colors.foreground);
return (
<EffectSurface
effects={[
galaxy({arms, twist, speed, density, light: hexToRgb(light), ground: hexToRgb(ground)}),
]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/spiral-arm/spiral-arm.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {SpiralArm} from "./spiral-arm";
export default defineComponentPreview({
title: "Spiral arm",
category: "Backgrounds",
description: "A galaxy under differential rotation: the core outruns the rim and the arms wind as it turns.",
component: SpiralArm,
canvas: {width: 1920, height: 1080, duration: "10s"},
controls: {
arms: {type: "number", defaultValue: 2, min: 1, max: 6, step: 1},
twist: {type: "number", defaultValue: 4.2, min: 1, max: 10, step: 0.2},
speed: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
density: {type: "number", defaultValue: 0.7, min: 0, max: 1, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "Whirlpool", props: {arms: 2, twist: 8.5, density: 0.9}},
{name: "Open four", props: {arms: 4, twist: 2.4, speed: 0.6}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {SpiralArm} from "../components/spiral-arm/spiral-arm";
<Video>
<Scene id="spiral-arm" duration="10s">
<SpiralArm
arms={2}
twist={4.2}
speed={1}
density={0.7}
/>
</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
- 300 frames · 10s
- Minimum duration
- 60 frames · 2s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the galaxy holds still
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsCore and arms. Defaults to the brand's lightest token on its darkest. | [string, string] | — | — |
armsHow many arms wind out of the core. | number | 2 | — |
twistHow tightly they wind. Low is open, high is a whirlpool. | number | 4.2 | — |
speedHow fast the galaxy turns. | number | 1 | — |
densityHow much dust the arms carry, 0 to 1. | number | 0.7 | — |
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.