Continuing on SOUP

Sprited DevSprited Dev
6 min read

Yesterday, we started a design document for SOUP’s pytorch implementation.

Before we start implementing any of this, I want to understand the feasibility of simulating the terrarium environment using GPU—particularly using convolutions.

Setup Recap

We will have following terrarium represented using tensor of CxDxHxW tensor.

        ──────────────────── 
      ╱                    ╱│
     ╱                    ╱ │ 32
     ────────────────────   │
    │                    │  │
    │                    │   
    │                    │ ╱ 
    │                    │╱  4
     ────────────────────    
             64

Channels will encode different informations

ChannelMeaningRange
0Air amount0–1
1Water amount0–1
2Soil amount0–1
3Tree cell presence0–1
4Leaf cluster presence0–1
5Tree ID / Identifier0–1
6Mineral concentration0–1
7Sugar (post-photosynthesis)0–1

We plan to use convolutions to (1) simulate gravity, (2) water dynamics and (3) plant growth.

Simplification 3D → 2D

To keep things simple (and sane), we're starting in 2D. We'll skip the Depth (D) axis for now and treat our world as a vertical slice of the terrarium.

That means our tensor shape will be C x H x W—where C is the number of channels (air, water, soil, etc.), and H x W is the height and width of our 2D world.

This lets us iterate faster, visualize easily, and nail down the simulation mechanics before scaling into full 3D.

Gravity Proposal

Scenario: A chunk of soil floats mid-air. We want it to "fall" to the bottom of the terrarium if there's empty space below.

┌────────────┐
│     ░░     │  <- voxel w/ soil (channel 2 = 1)
│            │
│            │
└────────────┘

To simulate this, we don’t really have to use convolutions for the time being, although using convolutions would give us more flexibility and control.

soil = tensor[:, 2:3, :, :]  # shape: 1 x H x W
air = tensor[:, 0:1, :, :]
can_fall = air[:, :, 1:, :] > 0.9  # check if tile *below* is air
soil_shifted = F.pad(soil[:, :, :-1, :], pad=(0, 0, 1, 0))  # move soil down
delta = (soil - soil_shifted) * can_fall.float()
tensor[:, 2, :, :] -= delta[:, 0]
tensor[:, 2, 1:, :] += delta[:, 0, :-1, :]

Water Dynamics

Now that we’ve got basic gravity working, it’s time to give water its proper fluidity. Water falls just like soil—but unlike soil, it also spreads sideways when blocked from falling. This gives it the juicy, flowing behavior we expect in a living terrarium.

Water wants to:

  • Fall downward if air if possible

  • Spread sideways into air if stuck

  • Conserve mass—what flows out must go somewhere

We’ll implement this in two phases:

  • A gravity pass (same logic as soil)

  • A horizontal spread pass (unique to water)

Once gravity has pulled everything down, we simulate sideways movement:

dev apply_lateral_flow(tensor, channel_idx, air_channel=0):
    water = ...
    air = ...
    water_left = ...
    water_right = ...
    delta_lr = 0.5 * (water_left + water_right - 2 * water)
    air_left = ...
    air_right = ...
    flow_left = ...
    flow_right = ...
    tensor[:, channel_idx, :, :-1] += flow_left[:, 0]
    tensor[:, channel_idx, :, 1:] += flow_right[:, 0]
    tensor[:, channel_idx, :, :] -= (flow_left[:, 0] + flow_right[:, 0])

Plant Growth

With soil falling and water flowing, it’s time to grow something. In our simulation, plants will grow as cellular automata, using simple local rules applied over a 2d grid.

For now, we’ll keep it minimal: trees, consisting of tree cells and leaf clusters, with logic driven by neighboring tiles and environmental conditions.

Each tree cell will check its surroundings and decide what to do based on:

  • What’s nearby (soil, air, other tree cells)

  • Whether it has access to water and minerals

  • Whether it’s exposed to light (air above)

  • Its internal state (e.g. is it a seedling?)

Tile Types Involved

ChannelTile TypeDescription
3Tree CellTrunk, branches, and roots
4Leaf ClusterPhotosynthetic structures
6Mineral ContentDrawn up from the soil
7SugarProduced by leaves and stored in tree

We'll start with seed expansion and basic shoot/root behavior, then build toward full nutrient cycling and intelligent trees.

def grow_tree(tensor):
    tree_cells = tensor[:, 3:4, :, :]
    soil       = tensor[:, 2:3, :, :]
    air        = tensor[:, 0:1, :, :]
    water      = tensor[:, 1:2, :, :]
    minerals   = tensor[:, 6:7, :, :]
    sugar      = tensor[:, 7:8, :, :]

    # Detect where there is already a tree cell
    presence_kernel = torch.ones((1, 1, 3, 3), device=tensor.device)
    presence_kernel[0, 0, 1, 1] = 0  # exclude center

    # Count neighboring tree cells
    neighbors = F.conv2d(tree_cells, presence_kernel, padding=1)

    # Growth targets: air or soil, near existing tree cells
    grow_target = ((air + soil) > 0.9) & (neighbors > 0)

    # Optional: only grow if there's water or mineral nearby
    hydrated = F.max_pool2d(water, 3, stride=1, padding=1) > 0.1
    mineralized = F.max_pool2d(minerals, 3, stride=1, padding=1) > 0.1

    growth_mask = grow_target & (hydrated | mineralized)

    # Add tree cells where growth happens
    tensor[:, 3:4, :, :] = torch.where(growth_mask, 1.0, tensor[:, 3:4, :, :])

GPU Parallel Processing

Every update step in our simulation — whether it’s soil settling, water flowing, or trees growing — is implemented as a batched tensor operation. This means it’s inherently parallelizable and runs efficiently on the GPU, without requiring any explicit loops.

Why this matters:

  • No CPU bottlenecks

  • No nested for loops crawling over your world

  • Every update step applies to the entire world at once

  • You can simulate large, complex ecosystems in real time

Generalization to 3D

While we’re prototyping in 2D for sanity, everything is designed to scale to 3D. The underlying logic doesn’t change—just the dimensionality of the tensors and kernels.

  • The world becomes: C x D x H x W (Channels × Depth × Height × Width)

  • We swap F.conv2d with F.conv3d, F.max_pool2d with F.max_pool3d

  • Kernels go from 2D shapes like (3, 3) to 3D shapes like (3, 3, 3)

  • Padding is updated from 2D to 3D:

      # 2D: pad=(left, right, top, bottom)
      # 3D: pad=(left, right, top, bottom, front, back)
    

All material behaviors—gravity, lateral spread, plant growth—extend naturally into 3D. It’s just a bigger, chunkier grid.

Example:

# 2D
neighbors = F.conv2d(tree_cells, kernel_2d, padding=1)

# 3D
neighbors = F.conv3d(tree_cells, kernel_3d, padding=1)

Once the rules are tested and tuned in 2D, we’ll transition to 3D with minimal changes. That’s when this simulation starts to feel like a living voxel world.

0
Subscribe to my newsletter

Read articles from Sprited Dev directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sprited Dev
Sprited Dev