Demo

Loading interactive demo

Click or drag on the canvas to interact. Alignment, rotation, and grid controls are not currently supported.

Credits

The interactive demo adapts infrastructure from Self-Organizing Textures (Mordvintsev et al., 2021) on Distill, published under the MIT License. The GLSL shader is re-written.

NCA models were trained using the NCA node in SideFX Houdini. Training datasets were generated in Houdini Copernicus.

The Houdini NCA source code was authored by Jakob Ringler.

Intro

The NCA nodes introduced in Houdini 22.0 handles training and exports .onnx. Here I tried to export weights from .pt, and migrate the forward pass from .py to OpenCL (for Houdini COP) and GLSL (for the WebGL demo above), and HLSL (for Unreal Engine).

I benchmarked 5,000 NCA iterations across the original COP Block loop, Houdini’s nca_core, OpenCL with COP Blocks, and the final @WRITEBACK kernel. The lower half measures parallel NCA + LPPN output with 3, 9, and 18 models.
The results produced a clear improvement in both speed and VRAM usage:

NCA performance benchmark across Houdini COP, OpenCL writeback, and parallel LPPN runs
Houdini benchmark: 5,000 NCA iterations and parallel NCA + LPPN output.

What is NCA

Houdini’s Neural Cellular Automata system trains a neural network to learn local rules, then applies those rules back to a tileable pattern. The process has two parts: NCA Core evolves the pattern, and LPPN Decode turns that pattern into a detailed image.

NCA Core

The NCA Core runs the cellular simulation on a grid. Each cell stores a small hidden state, reads its 3x3 neighbourhood, and applies the same learned update rule over and over. Because every cell only reacts to nearby information, local changes spread across the grid and gradually organize the cells into larger shapes. The shared rule keeps this process consistent across the whole grid at every step.

Put simply, NCA Core repeatedly updates each cell from its neighbours so a small cellular state can grow into the image’s overall structure.

LPPN Decode

The NCA grid is an internal representation, so it still needs to be translated into visible pixels. LPPN Decode reads the state of the matching NCA cell together with the pixel’s local position inside that cell, then converts that information into RGB. This lets a relatively small NCA grid describe a sharper, higher-resolution image without forcing the core simulation to run at full output size.

In short: LPPN Decode maps each low-resolution cell to the RGB values of its corresponding high-resolution pixels.

The Migration

Weights Export

Training produces .pt checkpoint files — one for NCA, one for LPPN. I use export_weights.py to read and write them to flat C arrays into .h files for OpenCL, .json for WebGL, and .wsh for Unreal Engine. The corresponding kernels #import or #include these at the top and see the weights as compile-time constants.

nca.pt         →  weights_from_pt()        →  W1[128*64]  B1[128]     W2[16*128]   →  model.h
lppn.decode.pt →  decode_weights_from_pt() →  L0w..L3b    WIRE_OMEGA  WIRE_SIGMA   →  model.decode.h

NCA

The model defines two layers in nca.py:

nca.py — weight definition plaintext
w1      1×1 Conv2d: 64 input → 128 output         W1 [128, 64] + B1 [128]
w2      1×1 Conv2d: 128 input → 16 output         W2 [16, 128], no bias

The shapes after squeezing: W1 is [128, 64], B1 is [128], W2 is [16, 128]. export_weights.py pulls them out:

export_weights.py python
# weights_from_pt
data = torch.load(path, map_location="cpu")
sd = data["state_dict"]
return {
    "w1": sd["w1.weight"].squeeze().numpy().astype(np.float32),  # [128, 64]
    "b1": sd["w1.bias"].numpy().astype(np.float32),              # [128]
    "w2": sd["w2.weight"].squeeze().numpy().astype(np.float32),  # [16, 128]
}

Then kernels imports the resulting weight arrays and uses W1, B1, W2 directly.

LPPN

lppn.py builds the decoder as an nn.Sequential in wire mode (I only supported wire mode not siren yet). Even indices are Conv2d (have weights); odd are Wire2DActivation — no parameters, nothing saved.

lppn.0    L0    Conv2d(18→44)    →    L0w, L0b
lppn.1    —     Wire2DActivation →    no parameters
lppn.2    L1    Conv2d(22→44)    →    L1w, L1b
lppn.3    —     Wire2DActivation →    no parameters
lppn.4    L2    Conv2d(22→44)    →    L2w, L2b
lppn.5    —     Wire2DActivation →    no parameters
lppn.6    L3    Conv2d(22→3)     →    L3w, L3b

export_weights.py reads only the even ones, plus two scalars #define WIRE_OMEGA / #define WIRE_SIGMA from the architecture dict:

export_weights.py python
# decode_weights_from_pt
return {
    "L0w": sq("lppn.0.weight"),  # [44, 18]
    "L0b": sq("lppn.0.bias"),    # [44]
    "L1w": sq("lppn.2.weight"),  # [44, 22]
    "L1b": sq("lppn.2.bias"),
    "L2w": sq("lppn.4.weight"),
    "L2b": sq("lppn.4.bias"),
    "L3w": sq("lppn.6.weight"),  # [3, 22]
    "L3b": sq("lppn.6.bias"),    # [3]
    "wire_omega": float(arch["wire_omega"]),
    "s0":         float(arch["s_0"]),
}

Forward Pass: NCA

The NCA core is the part that evolves the hidden cellular state. Each iteration runs the same small rule everywhere on the grid: read a cell’s local neighbourhood, turn that into a learned update, then apply the update only to a random subset of cells.

current state[16]
  -> Perception: read each cell's 3x3 neighbourhood with fixed filters
  -> Network: run the 64 perception values through W1/ReLU/W2
  -> Stochastic update: apply the 16-channel delta to about half the cells
  -> next state[16]

Perception

Each cell needs to know what’s around it. nca.py applies four fixed 3x3 filters to each channel:

nca.py source code python
self.register_buffer('ident', torch.tensor([[0.0,0.0,0.0],
                                            [0.0,1.0,0.0],
                                            [0.0,0.0,0.0]]))
self.register_buffer('sobel_x', torch.tensor([[-1.0,0.0,1.0],
                                              [-2.0,0.0,2.0],
                                              [-1.0,0.0,1.0]]))
# Split Laplacian into x and y components
self.register_buffer('lap_x', torch.tensor([[0.5,0.0,0.5],
                                            [2.0,-6.0,2.0],
                                            [0.5,0.0,0.5]]))
# lap_y is transpose of lap_x
self.register_buffer('lap_y', torch.tensor([[0.5,2.0,0.5],
                                            [0.0,-6.0,0.0],
                                            [0.5,2.0,0.5]]))

In opencl, perchannel_conv is unfolded into an explicit 3x3 accumulation. Each of the 16 state channels is processed by the identity, Sobel X, Sobel Y, and Laplacian filters, packing the 4 x 16 responses into perc[64]. The filter weights become compile-time constants:

state[16 ch]
    │  4 filters * 16 ch, 3x3 neighborhood
    ├── identity  → perc[c*4+0]
    ├── sobel_x   → perc[c*4+1]
    ├── sobel_y   → perc[c*4+2]
    └── laplacian → perc[c*4+3]
                  = perc[64]
nca: filter constants + perception loop opencl
__constant float SOBEL_X[9] = {-1,0,1, -2,0,2, -1,0,1};
__constant float SOBEL_Y[9] = {-1,-2,-1, 0,0,0, 1,2,1};
__constant float LAP[9]     = { 1,2,1, 2,-12,2, 1,2,1};

float perc[64];
for (int i=0; i<64; i++) perc[i] = 0.0f;

for (int dy=-1; dy<=1; dy++)
for (int dx=-1; dx<=1; dx++) {
    int nx = ((gx + dx) % W + W) % W;
    int ny = ((gy + dy) % H + H) % H;
    // load neighbor n[16] from state layers at (nx, ny) ...
    int k  = (dy+1)*3 + (dx+1);
    float ki = (dx==0 && dy==0) ? 1.0f : 0.0f;
    for (int c=0; c<16; c++) {
        perc[c*4+0] += ki         * n[c];  // identity
        perc[c*4+1] += SOBEL_X[k] * n[c];
        perc[c*4+2] += SOBEL_Y[k] * n[c];
        perc[c*4+3] += LAP[k]     * n[c];
    }
}

Network

perc[64] goes through two layers — w1 expands to 128 with ReLU, w2 collapses to 16 with no bias.

perc[64]
    │  W1[128*64] + B1  →  fmax(., 0)

h[128]
    │  W2[16*128]  (no bias)

delta[16]
nca: w1 + ReLU + w2 opencl
float h[128];
for (int hi=0; hi<128; hi++) {
    float sum = B1[hi];
    for (int i=0; i<64; i++) sum += W1[hi*64 + i] * perc[i];
    h[hi] = fmax(sum, 0.0f); // ReLU
}

float delta[16];
for (int c=0; c<16; c++) {
    float sum = 0.0f;
    for (int hi=0; hi<128; hi++) sum += W2[c*128 + hi] * h[hi];
    delta[c] = sum;
}

Stochastic Update

Not every cell applies its delta — about half skip each step. The randomness in nca.py uses a sin-based hash:

nca.py — simple_hash + update mask python
def simple_hash(self, x):
    x = torch.sin(x * 0.10873) * 43758.5453
    x = x - torch.floor(x)
    x = torch.sin(x * 12.9898 + 78.233) * 43758.5453
    return x - torch.floor(x)

# in forward():
uniform = self.random_uniform(current_seed + 2.71828182846, b, 1, h, w)
udpate_mask = (uniform + update_rate).floor() * update_map  # ~50% pass
x = x + y * udpate_mask

Ported to OpenCL:

nca.ocl — nca_hash + conditional update opencl
float nca_hash(float x) {
    x = sin(x * 0.10873f) * 43758.5453f;
    x = x - floor(x);
    x = sin(x * 12.9898f + 78.233f) * 43758.5453f;
    return x - floor(x);
}

float combined = @seed + 2.71828182846f + (float)gx * 0.1031f + (float)gy * 0.0973f;
float rng = nca_hash(combined);
if (rng >= 0.5f)
    for (int c=0; c<16; c++) s[c] += delta[c];

forward pass: LPPN

lppn.ocl runs at output resolution — one thread per output pixel, not per NCA cell. Each thread finds its NCA cell, computes where it sits inside it, and decodes the cell state to RGB.

Coordinates

lppn.py computes local coordinates — where within the cell does this pixel land, normalized to [-1, 1]:

lppn.py — create_local_coordinates python
cell_w = output_w / nca_w
x = torch.arange(output_w, dtype=torch.float32) + 0.5
x_in_cell = (x % cell_w) - cell_w / 2
u = 2.0 * x_in_cell / cell_w   # [-1, 1]
# same for v

In OpenCL, per-pixel:

lppn: (u, v) + cell lookup opencl
float cell = scale_f;
float pu = 2.0f * (fmod((float)gx + 0.5f, cell) - cell * 0.5f) / cell;
float pv = 2.0f * (fmod((float)gy + 0.5f, cell) - cell * 0.5f) / cell;
int sx = (int)((float)gx / scale_f);  // NCA cell coords
int sy = (int)((float)gy / scale_f);
// load state at (sx, sy) → 16 floats

Then inp[18] = [pu, pv, 16 state channels]. In lppn.py, this is torch.cat([pe_batch, h0, h1, h2, h3], dim=1).

  • pe_batch: two local-coordinate channels (pu, pv) for each output pixel.
  • h0, h1, h2, h3: four groups of four channels, together holding the 16 NCA state values.
  • dim=1: concatenate along the channel dimension.

So:

2 coordinate channels + 4 x 4 state channels = 18 channels

In OpenCL:

inp[0]     = pu
inp[1]     = pv
inp[2..17] = 16 NCA state values

Wire2DActivation

The activation function between each Conv layer. lppn.py:

lppn.py — Wire2DActivation.forward python
def forward(self, x):
    z1, z2 = torch.chunk(x, 2, dim=1)   # split in half
    gabor    = torch.sin(self.omega_0 * z1) * torch.exp(-(self.s_0 * z1)**2)
    gaussian = torch.exp(-(self.s_0 * z2)**2)
    return gabor * gaussian

Wire2DActivation splits the 44 Conv outputs into two 22-value halves and pairs them by index.
For each pair, z1 drives the sine oscillation, while Gaussian terms from both z1 and z2 damp its amplitude.

Multiplying the three terms produces one value, so 44 inputs become 22 activated features.

lppn: L0: Conv(18→44) + Wire2DActivation opencl
// Conv(18→44) + bias
for (int hi = 0; hi < 44; hi++) {
    float sum = L0b[hi];
    for (int i = 0; i < 18; i++) sum += L0w[hi*18 + i] * inp[i];
    tmp[hi] = sum;
}
// Wire2DActivation: z1=tmp[0..21], z2=tmp[22..43]
for (int i = 0; i < 22; i++) {
    float z1=tmp[i], z2=tmp[i+22];
    float a=WIRE_SIGMA*z1, b=WIRE_SIGMA*z2;
    h0[i] = sin(WIRE_OMEGA*z1) * exp(-a*a) * exp(-b*b);
}

Layer Stack

L0 is the input layer.

Then h_layers=2, the model inserts two repeated hidden layers, L1 and L2;
each layer produces 44 values, then Wire2DActivation combines the two 22-value halves into 22 outputs.

L3 is the final linear readout from those 22 features directly to RGB.

inp[18]  ← [pu, pv,  state×16]
    │  L0: Conv(18→44) + Wire2D → h0[22]
    │  L1: Conv(22→44) + Wire2D → h1[22]
    │  L2: Conv(22→44) + Wire2D → h2[22]
    │  L3: Conv(22→3)

rgb[3]

in Houdini COP

┌ Block Begin
│	↓
↑ NCA OpenCL (#import nca.h -> W1, B1, W2)
│	↓		 (seed = @cop_iteration)
└ Block End

LPPN OpenCL  (#import lppn.h -> L0w..L3b, WIRE_OMEGA, WIRE_SIGMA)

Writeback

OpenCL

The Begin/End COP Blocks method above was the first version of the feedback loop. Then that loop was moved into the OpenCL kernel itself with @WRITEBACK.

The NCA pass cannot write directly into state0state3 while neighbouring cells are still reading them.
The kernel therefore separates the update into two phases. At the end of @KERNEL, each thread repacks its updated s[16] into four temporary float4 output layers:

nca.cl — pack updated state opencl
@out0.set((float4)(s[ 0], s[ 1], s[ 2], s[ 3]));
@out1.set((float4)(s[ 4], s[ 5], s[ 6], s[ 7]));
@out2.set((float4)(s[ 8], s[ 9], s[10], s[11]));
@out3.set((float4)(s[12], s[13], s[14], s[15]));

After that kernel pass finishes, @WRITEBACK copies the temporary outputs into the persistent state layers:

nca.cl — commit the next state opencl
@WRITEBACK
{
    @state0.set(@out0);
    @state1.set(@out1);
    @state2.set(@out2);
    @state3.set(@out3);
}

The writeback state becomes the input to the next internal OpenCL iteration:

old: Block Begin -> NCA OpenCL -> Block End -> repeat

new: state0..3
         -> @KERNEL
         -> out0..3
         -> @WRITEBACK
         -> state0..3 for the next internal iteration

WebGL

The WebGL version uses the same ping-pong update. Each step reads from src and writes the next state into dst;
after the draw finishes, one line swaps them so that the new state becomes the next step’s input:

ca.js — swap state buffers javascript
[this.src,this.dst]=[this.dst,this.src];

Unreal Engine Niagara Grid2D

Unreal Niagara’s built-in Grid2D simulation follows the same pattern. A Simulation Stage reads the current cell values from one Grid2D buffer and writes the updated values into another.
At the end of the stage or iteration, Niagara makes that output buffer the input for the next pass.
(Haven’t check its source code, but its behavior matches the same ping-pong update pattern.)