← Cellular Automata From First Principles

Generate Textures with Local Rules

Cellular automata do not need to represent a literal physical system.

They can also be used as visual machines.

The same ingredients we have used throughout the book:

local state
neighborhood perception
shared update
repetition

can generate masks, growth patterns, surface variation and animation.

The evaluation question changes.

Instead of asking:

Is this physically accurate?

we ask:

Does this local process produce useful, controllable visual structure?

That still demands more than “it looks interesting” — and this chapter’s metrics section holds it to that standard.

This is also a fork in the procedural-generation tradition worth naming. The dominant lineage builds texture from noise functions — Perlin’s 1985 “An Image Synthesizer” founded it — composing band-limited randomness into shape. The lineage this chapter follows builds texture from iteration: structure emerges from repeated local operations instead. Reaction-diffusion textures (Turk 1991) sit squarely in the second lineage, which makes Chapter 13’s reaction-diffusion a texture chapter in disguise.


Start with raw scalar noise

import numpy as np

rng = np.random.default_rng(42)

texture = rng.random(
    (160, 160)
)

Raw noise contains variation but little coherent structure.

A local process can create spatial correlation.


Smooth and sharpen locally

def neighborhood_mean(grid):
    total = np.zeros_like(grid)

    for dy in (-1, 0, 1):
        for dx in (-1, 0, 1):
            total += np.roll(
                np.roll(
                    grid,
                    dy,
                    axis=0,
                ),
                dx,
                axis=1,
            )

    return total / 9.0

This is the same 3×3 mean used for terrain smoothing in the previous chapter, renamed for its new job — one operator, two applications, which is exactly the reuse the book wants to make visible.

Smoothing:

def smooth(
    grid,
    amount=0.20,
):
    return (
        (1 - amount) * grid
        + amount
        * neighborhood_mean(grid)
    )

Sharpening:

def sharpen(
    grid,
    amount=0.15,
):
    mean = neighborhood_mean(grid)

    return np.clip(
        grid
        + amount * (grid - mean),
        0.0,
        1.0,
    )

Now alternate the two:

for _ in range(30):
    texture = smooth(texture)
    texture = sharpen(texture)

(Verified: stays finite with measurable contrast gain; the clip keeps sharpening bounded.)

The result is not magic.

It is competition between:

homogenization
and
contrast amplification

The same competition motif as reaction-diffusion — smoothing versus a difference-creating process — with cheaper ingredients and no chemistry.


Convert continuous structure into a mask

mask = texture > 0.52

(Observed coverage ≈ 0.41 on the reference seed — a number, not an impression.)

The mask can later be interpreted as:

corrosion
moss
cracks
damage
cloud coverage
paint wear
biome edge

The generator does not need to know the final semantic label.


Grow material from sparse seeds

growth = (
    rng.random((160, 160))
    < 0.01
).astype(np.uint8)

Count neighbors and allow growth only near existing material.

Then add a small decay probability:

def growth_decay_step(
    grid,
    rng,
    grow_p=0.08,
    decay_p=0.015,
):
    neighbors = np.zeros_like(grid, dtype=np.uint8)
    material = (grid == 1).astype(np.uint8)

    for dy in (-1, 0, 1):
        for dx in (-1, 0, 1):
            if dy == 0 and dx == 0:
                continue
            neighbors += np.roll(
                np.roll(material, dy, axis=0), dx, axis=1
            )

    next_grid = grid.copy()

    grow = (
        (grid == 0)
        & (neighbors >= 2)
        & (rng.random(grid.shape) < grow_p)
    )
    decay = (
        (grid == 1)
        & (rng.random(grid.shape) < decay_p)
    )

    next_grid[grow] = 1
    next_grid[decay] = 0

    return next_grid

Now the system contains competing creation and removal processes.

A small gallery of local texture-generation mechanisms

The figure compares:

raw noise
local smooth/sharpen dynamics
thresholded mask
growth + decay

That comparison is more useful than presenting four unrelated pretty images because every panel exposes a different mechanism. The full pipeline behind them:

    flowchart LR
    N[noise seed] --> E[evolve: smooth/sharpen or growth/decay]
    E --> S[structure: mask or material field]
    S --> M[measure: coverage, contrast, change]
    M --> R[render / search: keep, reject, refine]
  

Two lineages produce such textures, with different trade-offs:

ApproachMechanismVisual effectLimitation
Noise-based (Perlin lineage)composed band-limited noisecontrollable multi-scale detailno local dynamics to interrogate
Iterative (this chapter)repeated local rulesevolving structure with measurable behaviorcostlier; needs search + validation

Separate hidden state from rendered appearance

A richer texture system might store:

state = np.zeros(
    (160, 160, 3),
    dtype=np.float32,
)

with:

channel 0 = material
channel 1 = moisture
channel 2 = damage

The internal state can drive local updates.

A separate render function can convert it to RGB:

def render(state):
    material = state[..., 0]
    moisture = state[..., 1]
    damage = state[..., 2]

    rgb = np.stack(
        [
            material * (1 - damage),
            material * (
                1 - 0.5 * damage
            ),
            material * (
                1 - moisture
            ),
        ],
        axis=-1,
    )

    return np.clip(
        rgb,
        0.0,
        1.0,
    )

This separation becomes extremely important later.

A cell can carry information needed for local computation without every channel having a direct visual interpretation. Chapter 39 will make hidden channels the entire model; this is their first appearance, in a visual setting where the distinction is easy to see.


Animate the process, not only the result

A final frame may hide the interesting dynamics.

Store intermediate states:

frames = []

for _ in range(200):
    frames.append(
        growth.copy()
    )

    growth = growth_decay_step(
        growth,
        rng,
    )

Now the visual artifact can show:

nucleation
growth
competition
decay
reorganization

rather than only the endpoint.

For some chapters later in the book, animation will be more informative than a static PNG.


Measure useful visual properties

Aesthetic quality is partly subjective.

But we can still expose measurable properties.

Coverage

def coverage(mask):
    return float(mask.mean())

Mean local contrast

def mean_local_contrast(grid):
    return float(
        np.mean(
            np.abs(
                grid
                - neighborhood_mean(grid)
            )
        )
    )

Temporal change

def frame_change(a, b):
    return float(
        np.mean(
            np.abs(
                a.astype(float)
                - b.astype(float)
            )
        )
    )

Those metrics let us express design constraints such as:

coverage near 45%
moderate local contrast
non-zero but bounded animation rate

Search rather than hand-tune forever

candidates = []

for seed in range(100):
    rng = np.random.default_rng(seed)

    grid = (
        rng.random((128, 128))
        < 0.01
    ).astype(np.uint8)

    for _ in range(80):
        grid = growth_decay_step(
            grid,
            rng,
            grow_p=0.08,
            decay_p=0.015,
        )

    score = abs(
        coverage(grid) - 0.45
    )

    candidates.append(
        (score, seed, grid)
    )

candidates.sort(
    key=lambda x: x[0]
)

The system is programmable at two levels:

local rule
    -> produces candidate

evaluation/search
    -> chooses candidate

That is the bridge into Part III.


What the richer worlds taught us

We used one local-computation viewpoint to build:

Life-like rule families and multi-state rules
stochastic spreading
forest fire
traffic
diffusion
reaction-diffusion
predator-prey dynamics
caves
terrain
textures

The semantics changed dramatically.

The computational skeleton did not:

local state
local perception
shared transition
repeated update
measurement

We now know how to build cellular worlds.

The next question is harder:

How do we tell which worlds are dynamically interesting?

In Part III we will measure density, activity, spatial variation, entropy, recurrence and sensitivity, then use those measurements to search rule space systematically.


Research

  • Turk, G. — Generating Textures on Arbitrary Surfaces Using Reaction-Diffusion (SIGGRAPH ‘91). The proof that this chapter’s lineage is industrially real: reaction-diffusion iteration generating usable surface textures, the direct descendant of Chapter 13’s Gray-Scott patterns. Read it to see where local-iteration textures beat noise functions — and what production use demands. https://faculty.cc.gatech.edu/~turk/my_papers/reaction_diffusion.pdf

  • Perlin, K. — An Image Synthesizer (Computer Graphics 19(3), 1985). The other lineage: texture from composed band-limited noise rather than from iteration. Understand it to know what local-CA textures offer that noise functions do not — and vice versa. The contrast sharpens both. https://doi.org/10.1145/325165.325247