Cipher Grid
A flowing matrix of characters driven by Perlin noise.
What You'll Learn
- 3D Text Rendering — Loading fonts and displaying text as 3D geometry
- Grid Layouts in 3D — Positioning hundreds of objects with simple math
- Perlin Noise Animation — Using noise to create organic, flowing motion
- Geometry Swapping — Pre-computing shapes for smooth runtime performance
- Post-processing — Adding bloom and effects for visual polish
Step 1: Render a Single Character
Before building a grid, let's render just one character. This establishes the foundation: a Canvas, a camera looking down, and a 3D text element.
import { Text3D } from "@react-three/drei";
import { Canvas, useLoader } from "@react-three/fiber";
import { FontLoader } from "three/examples/jsm/Addons.js";
function Scene() {
const font = useLoader(FontLoader, "/Inter_Medium_Regular.json");
return (
<>
<color attach="background" args={["#000"]} />
<Text3D
font="/Inter_Medium_Regular.json"
position={[-0.15, 0, 0]}
rotation={[-Math.PI / 2, 0, 0]}
scale={0.01}
>
0
<meshBasicMaterial color="white" />
</Text3D>
<ambientLight intensity={1} />
</>
);
}
export default function Step1() {
return (
<Canvas
camera={{ fov: 50, position: [0, 2, 0] }}
style={{ height: "400px" }}
>
<Scene />
</Canvas>
);
}Key points:
Text3Dfrom drei renders text as 3D geometry (not a flat texture)- We load a JSON font file with
FontLoader - The camera sits above (
position: [0, 2, 0]) looking down at the text rotation={[-Math.PI / 2, 0, 0]}lays the text flat on the XZ plane
Step 2: Create a Static Grid
Now we repeat that character across a grid. The math is simple: multiply the column/row index by spacing, then offset to center the grid.
const GRID_COLS = 20;
const GRID_ROWS = 10;
const CELL_SPACING_X = 0.05;
const CELL_SPACING_Z = 0.07;
function Cell({ col, row }: { col: number; row: number }) {
const position: [number, number, number] = [
col * CELL_SPACING_X - (GRID_COLS * CELL_SPACING_X) / 2,
0,
row * CELL_SPACING_Z - (GRID_ROWS * CELL_SPACING_Z) / 2,
];
return (
<Text3D
font="/Inter_Medium_Regular.json"
position={position}
rotation={[-Math.PI / 2, 0, 0]}
scale={0.0006}
>
0
</Text3D>
);
}The centering math:
position = index * spacing - (totalCells * spacing) / 2
This shifts the entire grid so that (0, 0) is at the center. Without this offset, the grid would start at the origin and extend only in one direction.
Rendering the grid:
{
[...Array(GRID_ROWS)].map((_, row) =>
[...Array(GRID_COLS)].map((_, col) => (
<Cell key={`${col}-${row}`} col={col} row={row} />
))
);
}We use nested arrays to create a 2D loop. Each cell gets its column and row index, which determines its position.
Step 3: Animate with Perlin Noise
Here's where the magic happens. Instead of random flickering, we use Perlin noise to determine which character each cell displays. Nearby cells get similar values, creating that smooth, flowing effect.
What is Perlin Noise?
Think of Perlin noise as "smooth randomness." Unlike pure random values (which jump erratically), Perlin noise produces values that transition gradually. Points close together in space get similar values.
We use 3D Perlin noise (perlin3), treating time as the third dimension. This means the noise field "moves" through time, creating animation.
import { Noise as PerlinNoise } from "noisejs";
const noise = new PerlinNoise(Math.random());
const CHARACTERS = ["", "-", "0", "1", "2", "3", "4", "5", "6", "7"];
useFrame(({ clock }) => {
const time = clock.getElapsedTime();
const noiseValue = Math.abs(
noise.perlin3(
col / NOISE_SCALE, // X position
row / NOISE_SCALE, // Y position
time * ANIMATION_SPEED // Time as Z
)
);
// Map noise (0-1) to character index (0-9)
const index = Math.min(Math.floor(noiseValue * 10), CHARACTERS.length - 1);
meshRef.current.geometry = geometries[index];
});Why divide by NOISE_SCALE?
Perlin noise operates on a coordinate system. Dividing by NOISE_SCALE (30) means adjacent cells sample very close points in the noise field, producing similar values. Lower values = larger patterns. Higher values = finer, more chaotic patterns.
Pre-computing Geometries
Creating geometry is expensive. Instead of building a new TextGeometry every frame, we pre-compute all possible character geometries once:
const geometries = useMemo(() => {
return CHARACTERS.map((char) => new TextGeometry(char, { font, size: 100 }));
}, [font]);Then we simply swap which geometry is assigned to each mesh. This is fast because we're just changing a reference, not creating new objects.
Step 4: Add Post-Processing
The animation works, but it looks a bit flat. Post-processing effects add atmosphere and polish.
import {
Bloom,
EffectComposer,
Noise,
Vignette,
} from "@react-three/postprocessing";
function PostEffects() {
return (
<EffectComposer>
<Bloom luminanceThreshold={0.8} luminanceSmoothing={1} />
<Noise opacity={0.1} />
<Vignette eskil={false} offset={0.1} darkness={1.1} />
</EffectComposer>
);
}Effects Breakdown
- Bloom — Adds glow to bright areas, making characters feel like they're emitting light
- Noise — Subtle film grain texture, adds organic feel
- Vignette — Darkens edges, draws focus to the center
These three effects together transform a clinical-looking grid into something with mood and atmosphere.
Experiments
Try these variations to make the effect your own:
Change the Character Set
// Binary
const CHARACTERS = ["0", "1"];
// Alphabet
const CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
// Symbols
const CHARACTERS = ["◆", "◇", "○", "●", "□", "■"];Adjust Noise Scale
const NOISE_SCALE = 10; // Smaller patterns, more chaotic
const NOISE_SCALE = 50; // Larger patterns, slower wavesAdd Color Based on Noise
Instead of white text, map the noise value to a color:
const hue = noiseValue * 0.3; // Range of colors
const color = new THREE.Color().setHSL(hue, 1, 0.5);
meshRef.current.material.color = color;Vary Animation Speed by Position
Make some areas move faster than others:
const localSpeed = ANIMATION_SPEED * (1 + row / GRID_ROWS);Key Takeaways
- Perlin noise creates organic patterns because nearby points sample similar values in the noise field
- Using time as a dimension (perlin3 with time as Z) creates smooth animation through the noise space
- Pre-computing geometries and swapping references is much faster than creating new objects each frame
- Grid centering math is
position = index * spacing - (total * spacing) / 2 - Post-processing effects can dramatically change the mood with minimal code—don't skip them