threevault
Don't know how to code? Use our AI prompt to build this yourself.
Build a "Cipher Grid" effect using React Three Fiber — a top-down view of a large grid of 3D text characters that morph between digits and dashes driven by animated Perlin noise, creating a scrolling cipher/matrix look.

Tech stack: React, Three.js, @react-three/fiber, @react-three/drei (Text3D), @react-three/postprocessing, noisejs

Scene setup:
- Black background
- Camera at [0, 20, 0] looking straight down with fov 7 (very tight/zoomed)
- Single ambient light at intensity 1

Grid layout:
- 100 columns × 40 rows of 3D text characters
- Each cell is spaced 0.05 units apart on X and 0.07 on Z, centered around the origin
- Cell scale is very small (0.0006)
- Text is rotated flat ([-PI/2, 0, 0]) so it faces the top-down camera

Character animation:
- Define a character set: ["", "-", "0", "1", "2", "3", "4", "5", "6", "7"]
- Pre-compute a TextGeometry for each character using FontLoader with a JSON font file
- Each frame, sample 3D Perlin noise at (col/30, row/30, time * 0.5) — take the absolute value
- Map the noise value (0–1) to an index into the character array and swap the mesh's geometry
- This creates flowing regions of different characters that shift organically over time

Font handling:
- Load a JSON font via Three.js FontLoader (useLoader)
- Pre-build all TextGeometry instances once in a useMemo so the per-frame update only swaps geometry references

Post-processing:
- Bloom with luminanceThreshold 0.8 and luminanceSmoothing 1
- Film grain noise at opacity 0.1
- Vignette with offset 0.1 and darkness 1.1

Performance tips:
- Pre-compute all text geometries once and reuse them — never create geometry in the render loop
- The noise function is cheap per-cell; the bottleneck is the large number of meshes (4000), so keep the geometry as simple as possible

Paste into ChatGPT, Claude, or Cursor

Cipher Grid

A flowing matrix of characters driven by Perlin noise.

Loading scene...

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.

Loading scene...
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:

  • Text3D from 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.

Loading scene...
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.

Loading scene...

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.

Loading scene...
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 waves

Add 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

Full Source

page.tsx
"use client";

import { Text3D } from "@react-three/drei";
import { Canvas, useFrame, useLoader } from "@react-three/fiber";
import {
  Bloom,
  EffectComposer,
  Noise,
  Vignette,
} from "@react-three/postprocessing";
import { Noise as PerlinNoise } from "noisejs";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { FontLoader, TextGeometry } from "three/examples/jsm/Addons.js";

// ============================================
// Constants
// ============================================

const GRID_COLS = 100;
const GRID_ROWS = 40;
const CELL_SPACING_X = 0.05;
const CELL_SPACING_Z = 0.07;
const CELL_SCALE = 0.0006;

const NOISE_SCALE = 30;
const ANIMATION_SPEED = 0.5;

const CHARACTERS = ["", "-", "0", "1", "2", "3", "4", "5", "6", "7"];

const noise = new PerlinNoise(Math.random());

// ============================================
// Page
// ============================================

export default function Page() {
  return (
    <Canvas
      gl={{ antialias: true }}
      camera={{ fov: 7, position: [0, 20, 0] }}
      style={{ height: "100vh" }}
    >
      <Scene />
    </Canvas>
  );
}

// ============================================
// Scene
// ============================================

function Scene() {
  const font = useLoader(FontLoader, "/Inter_Medium_Regular.json");

  const geometries = useMemo(() => {
    if (!font) return [];
    return CHARACTERS.map(
      (char) => new TextGeometry(char, { font, size: 100 })
    );
  }, [font]);

  return (
    <>
      <color attach="background" args={["#000"]} />
      <group>
        {[...Array(GRID_ROWS)].map((_, row) =>
          [...Array(GRID_COLS)].map((_, col) => (
            <Cell
              key={`${col}-${row}`}
              col={col}
              row={row}
              geometries={geometries}
            />
          ))
        )}
      </group>
      <ambientLight intensity={1} />
      <PostEffects />
    </>
  );
}

// ============================================
// Cell
// ============================================

interface CellProps {
  col: number;
  row: number;
  geometries: TextGeometry[];
}

function Cell({ col, row, geometries }: CellProps) {
  const meshRef = useRef<THREE.Mesh>(null);

  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,
  ];

  useFrame(({ clock }) => {
    if (!meshRef.current || geometries.length === 0) return;

    const time = clock.getElapsedTime();
    const noiseValue = Math.abs(
      noise.perlin3(
        col / NOISE_SCALE,
        row / NOISE_SCALE,
        time * ANIMATION_SPEED
      )
    );

    const index = Math.min(Math.floor(noiseValue * 10), geometries.length - 1);
    meshRef.current.geometry = geometries[index];
    meshRef.current.scale.setScalar(CELL_SCALE);
  });

  return (
    <Text3D
      ref={meshRef}
      font="/Inter_Medium_Regular.json"
      position={position}
      rotation={[-Math.PI / 2, 0, 0]}
    >
      {""}
    </Text3D>
  );
}

// ============================================
// PostEffects
// ============================================

function PostEffects() {
  return (
    <EffectComposer>
      <Bloom luminanceThreshold={0.8} luminanceSmoothing={1} />
      <Noise opacity={0.1} />
      <Vignette eskil={false} offset={0.1} darkness={1.1} />
    </EffectComposer>
  );
}