Dither field
A moving gradient with pixelated shading.
Customize
import {Scene, Video} from "odori";
import {DitherField} from "../components/dither-field/dither-field";
<Video>
<Scene id="dither-field" duration="6s">
<DitherField
pixelSize={6}
levels={4}
speed={0.3}
/>
</Scene>
</Video>Installation
pnpm odori add component dither-fieldnpx odori add component dither-fieldbunx odori add component dither-fieldEdit the source in videos/components/dither-field/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/dither-field/dither-field.tsx
import {Fill, useBrand, useDesignScale, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* A two-colour gradient drifting under ordered dither, so the blend is made of
* visible pixels rather than a smooth ramp.
*
* The point of quantizing is the texture: a 4x4 Bayer matrix turns the ramp
* into the crosshatch every 1-bit display used, and at a chunky pixel size it
* reads as intentional print rather than as a rendering fault. The pixel size
* is the control worth reaching for — small is grain, large is mosaic.
*
* Pixel size is a design length, scaled outside the shader, so the chunks stay
* the same apparent size in a vertical or square cut.
*/
export type DitherFieldProps = {
/** Two colours the dither trades between. Defaults to the brand. */
colors?: [string, string];
/** Side of one dither pixel, in design pixels. */
pixelSize?: number;
/** Quantization steps between the two colours. 2 is pure 1-bit. */
levels?: number;
/** How fast the gradient drifts through the grid. */
speed?: 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: "dither-field",
uniforms: {
px: numberUniform(6),
levels: numberUniform(4),
speed: numberUniform(0.3),
low: vec3Uniform([0, 0, 0]),
high: vec3Uniform([1, 1, 1]),
},
fragmentShader: `
uniform float px;
uniform float levels;
uniform float speed;
uniform vec3 low;
uniform vec3 high;
/* The classic 4x4 Bayer thresholds, offset to the centre of each step so the
extremes of the ramp still reach both colours. */
const float bayer[16] = float[16](
0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0);
void main() {
/* Everything is sampled at the centre of its chunk, so the picture is made
of whole pixels rather than a dither over a smooth image. */
vec2 cell = floor(gl_FragCoord.xy / px);
vec2 uv = (cell + 0.5) * px / resolution;
uv.x *= resolution.x / resolution.y;
float t = seconds * speed;
/* The ground being dithered: a diagonal ramp with two slow swells riding it.
Deliberately simple — the texture is the subject, not the field. */
float g = dot(uv, normalize(vec2(0.8, 1.0))) * 0.55;
g += sin(uv.x * 2.1 - t) * 0.16 + sin(uv.y * 1.7 + t * 0.8) * 0.14;
g = clamp(g * 0.5 + 0.35, 0.0, 1.0);
float threshold = (bayer[int(mod(cell.x, 4.0)) + int(mod(cell.y, 4.0)) * 4] + 0.5) / 16.0;
float steps = max(levels - 1.0, 1.0);
float q = floor(g * steps + threshold) / steps;
odoriColour = vec4(mix(low, high, clamp(q, 0.0, 1.0)), 1.0);
}`,
});
export const DitherField = ({colors, pixelSize = 6, levels = 4, speed = 0.3}: DitherFieldProps) => {
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const [low, high] = colors ?? [brand.colors.background, brand.colors.accent];
return (
<EffectSurface
effects={[
field({px: Math.max(1, pixelSize * scale), levels, speed, low: hexToRgb(low), high: hexToRgb(high)}),
]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/dither-field/dither-field.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DitherField} from "./dither-field";
export default defineComponentPreview({
title: "Dither field",
category: "Backgrounds",
description: "A moving gradient with pixelated shading.",
component: DitherField,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
pixelSize: {type: "number", defaultValue: 6, min: 2, max: 24, step: 1},
levels: {type: "number", defaultValue: 4, min: 2, max: 8, step: 1},
speed: {type: "number", defaultValue: 0.3, min: 0, max: 1.5, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "One bit", props: {levels: 2, pixelSize: 10}},
{name: "Newsprint", props: {colors: ["#101012", "#3a3f4a"], pixelSize: 4, levels: 6}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DitherField} from "../components/dither-field/dither-field";
<Video>
<Scene id="dither-field" duration="6s">
<DitherField
pixelSize={6}
levels={4}
speed={0.3}
/>
</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 dither holds one still gradient
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsTwo colours the dither trades between. Defaults to the brand. | [string, string] | — | — |
pixelSizeSide of one dither pixel, in design pixels. | number | 6 | — |
levelsQuantization steps between the two colours. 2 is pure 1-bit. | number | 4 | — |
speedHow fast the gradient drifts through the grid. | number | 0.3 | — |
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.Dust motesSoft particles drifting across the frame.Gel washBlurred bands of color over a dark background.Glass causticsShifting patterns of light beneath water.