Shape lattice
Circles, squares and triangles on a strict pitch, each turning a beat after the cell before it.
Customize
import {Scene, Video} from "odori";
import {ShapeLattice} from "../components/shape-lattice/shape-lattice";
<Video>
<Scene id="shape-lattice" duration="8s">
<ShapeLattice
columns={14}
speed={1}
outlines={0.45}
/>
</Scene>
</Video>Installation
pnpm odori add component shape-latticenpx odori add component shape-latticebunx odori add component shape-latticeEdit the source in videos/components/shape-lattice/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/shape-lattice/shape-lattice.tsx
import {Fill, useBrand, useDesignScale, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* A lattice of marks, each turning on its own beat.
*
* The grid is the composition and the cell is the event: circles, squares and
* triangles laid on a strict pitch, some filled and some drawn as outlines,
* every one of them rotating and breathing a fraction later than the cell
* before it. That lag is the whole effect — rotate them together and the wall
* reads as one object shaking, rotate them in sequence and it reads as a
* surface with a wave moving through it.
*
* Which mark lands in which cell is hashed from the cell's own coordinates, so
* the arrangement never changes between renders and never has to be stored.
*/
export type ShapeLatticeProps = {
/** Mark and ground. Defaults to the brand's foreground on its background. */
colors?: [string, string];
/** Marks across the frame. */
columns?: number;
/** How fast the wave crosses the lattice. */
speed?: number;
/** Share of cells drawn as outlines rather than filled, 0 to 1. */
outlines?: 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 lattice = defineShaderEffect({
name: "shape-lattice",
uniforms: {
columns: numberUniform(14),
speed: numberUniform(1),
outlines: numberUniform(0.45),
ink: vec3Uniform([1, 1, 1]),
ground: vec3Uniform([0, 0, 0]),
},
fragmentShader: `
uniform float columns;
uniform float speed;
uniform float outlines;
uniform vec3 ink;
uniform vec3 ground;
/* hash() comes from the prelude: integer PCG, exact on every backend. The
trigonometric idiom everyone reaches for drifts between graphics backends,
and a lattice whose marks change identity between two export workers is not
a lattice. */
float sdCircle(vec2 p, float r) { return length(p) - r; }
float sdBox(vec2 p, float b) {
vec2 d = abs(p) - vec2(b);
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float sdTriangle(vec2 p, float r) {
const float k = 1.7320508;
p.x = abs(p.x) - r;
p.y = p.y + r / k;
if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0;
p.x -= clamp(p.x, -2.0 * r, 0.0);
return -length(p) * sign(p.y);
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
float aspect = resolution.x / resolution.y;
vec2 p = vec2(uv.x * aspect, uv.y);
float cols = max(2.0, columns);
float cell = aspect / cols;
vec2 id = floor(p / cell);
vec2 local = fract(p / cell) - 0.5;
/* The lag that makes it a wave rather than a shudder: a cell's phase is its
distance along the diagonal, so the turn crosses the lattice. */
float t = seconds * speed;
float phase = (id.x + id.y) * 0.38;
float angle = sin(t + phase) * 1.25;
float c = cos(angle);
float s = sin(angle);
vec2 q = mat2(c, -s, s, c) * local;
float size = 0.26 + 0.09 * sin(t * 0.8 + phase);
float pick = hash(id);
float d = pick < 0.34 ? sdCircle(q, size) : pick < 0.67 ? sdBox(q, size) : sdTriangle(q, size * 1.15);
/* One pixel's worth of edge, whatever the frame's size, so the marks are
the same softness in a vertical cut as in a wide one. */
float edge = 1.4 / (resolution.y * cell);
float filled = 1.0 - smoothstep(-edge, edge, d);
float ring = 1.0 - smoothstep(0.0, edge * 2.2, abs(d + 0.028));
float mask = mix(filled, ring, step(hash(id + 7.31), outlines));
odoriColour = vec4(mix(ground, ink, mask * 0.92), 1.0);
}`,
});
export const ShapeLattice = ({colors, columns = 14, speed = 1, outlines = 0.45}: ShapeLatticeProps) => {
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const [ink, ground] = colors ?? [brand.colors.foreground, brand.colors.background];
return (
<EffectSurface
effects={[
lattice({
// Cells are a count, not a length, so the lattice keeps its pitch in
// any aspect rather than growing with the frame.
columns: Math.max(2, Math.round(columns * Math.min(1.35, Math.max(0.75, scale)))),
speed,
outlines,
ink: hexToRgb(ink),
ground: hexToRgb(ground),
}),
]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/shape-lattice/shape-lattice.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ShapeLattice} from "./shape-lattice";
export default defineComponentPreview({
title: "Shape lattice",
category: "Backgrounds",
description: "Circles, squares and triangles on a strict pitch, each turning a beat after the cell before it.",
component: ShapeLattice,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
columns: {type: "number", defaultValue: 14, min: 4, max: 40, step: 1},
speed: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
outlines: {type: "number", defaultValue: 0.45, min: 0, max: 1, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "Fine", props: {columns: 28, outlines: 0.7, speed: 0.6}},
{name: "Bold", props: {columns: 8, outlines: 0.1, speed: 1.4}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ShapeLattice} from "../components/shape-lattice/shape-lattice";
<Video>
<Scene id="shape-lattice" duration="8s">
<ShapeLattice
columns={14}
speed={1}
outlines={0.45}
/>
</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
- 60 frames · 2s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and every mark holds one angle
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsMark and ground. Defaults to the brand's foreground on its background. | [string, string] | — | — |
columnsMarks across the frame. | number | 14 | — |
speedHow fast the wave crosses the lattice. | number | 1 | — |
outlinesShare of cells drawn as outlines rather than filled, 0 to 1. | number | 0.45 | — |
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.