R&D · Simulation · UE5 · ISM Rendering

Sandfall

Sandfall simulation

A falling-sand cellular automaton built in Unreal Engine 5 C++. A double-buffered 32×32 grid simulates four interacting materials — sand falls and slides, water flows with direction memory, mud forms when they meet and decays over time, and stone acts as immovable walls. Inspired by Noita. Zero per-cell actors — every living cell is an ISM instance.

Cellular Automaton Double-Buffer ISM Rendering Fixed-Timestep C++ UE5
What I Built
  • Double-buffer grid — reads from Grid[32][32], writes to GridBuffer; a single FMemory::Memcpy swap at step end. No locks, no races.
  • Per-row column shuffle — each row's X indices randomised before processing; removes left-bias that causes material drift in naive automata.
  • Water flow memoryFCell::LastDir (int8) stores last horizontal direction; tried first on the next step, preventing oscillation and creating realistic streams.
  • Mud compound material — sand + water converts both cells to Mud with a 300-step Lifetime countdown; decays to sand automatically.
  • ISM-only renderingRender() clears and rebuilds all three ISMs in one O(W×H) pass; per-material sine-wave Z-offsets give a living, breathing look.

Cell Data & Simulation Step

The entire grid is a flat 2D array of 3-field FCell structs. The loop shuffles column order per row before dispatching on ECellType.

FallingSandSimActor.cpp — Simulate()
// Minimal cell — fits in a cache line
UENUM() enum class ECellType : uint8 { Empty, Sand, Water, Stone, Mud };
USTRUCT() struct FCell {
    ECellType Type     = ECellType::Empty;
    int32     Lifetime = 0;   // mud decay counter
    int8      LastDir  = 0;   // water flow direction memory
};

void AFallingSandSimActor::Simulate()
{
    FMemory::Memcpy(GridBuffer, Grid, sizeof(Grid));  // snapshot → buffer

    for (int32 Y = 1; Y < GridHeight; ++Y)
    {
        TArray<int32> XIndices;
        for (int32 X = 0; X < GridWidth; ++X) XIndices.Add(X);
        XIndices.Sort([](int32, int32) { return FMath::RandBool(); });

        for (int32 X : XIndices)
        {
            FCell& Cell = Grid[X][Y];
            switch (Cell.Type)
            {
            case ECellType::Sand:  SimulateSand (X, Y, Cell); break;
            case ECellType::Water: SimulateWater(X, Y, Cell); break;
            case ECellType::Mud:   SimulateMud  (X, Y, Cell); break;
            }
        }
    }
    FMemory::Memcpy(Grid, GridBuffer, sizeof(Grid));  // commit buffer → grid
}
Per-Material Physics Rules

Sand falls then slides diagonally; contact with water forms Mud. Water uses LastDir to persist flow direction and suppress oscillation.

FallingSandSimActor.cpp — SimulateSand / SimulateWater
void AFallingSandSimActor::SimulateSand(int32 X, int32 Y, FCell& Cell)
{
    if      (Grid[X][Y-1].Type == ECellType::Empty)
        SwapCells(X, Y, X, Y-1);                            // fall down
    else if (Grid[X][Y-1].Type == ECellType::Water)
    {
        GridBuffer[X][Y-1] = { ECellType::Mud, 300, 0 };  // sand+water → mud
        GridBuffer[X][Y].Type = ECellType::Empty;
    }
    else if (X > 0           && Grid[X-1][Y-1].Type == ECellType::Empty)
        SwapCells(X, Y, X-1, Y-1);                          // slide left
    else if (X < GridWidth-1 && Grid[X+1][Y-1].Type == ECellType::Empty)
        SwapCells(X, Y, X+1, Y-1);                          // slide right
}

void AFallingSandSimActor::SimulateWater(int32 X, int32 Y, FCell& Cell)
{
    if (Grid[X][Y-1].Type == ECellType::Empty)
        { SwapCells(X, Y, X, Y-1); GridBuffer[X][Y].LastDir = 0; return; }

    // Try last direction first — prevents oscillation
    int8 LastDir = Cell.LastDir;
    int TryDirs[2] = { LastDir != 0 ? LastDir : -1, LastDir != 0 ? -LastDir : 1 };
    for (int Dir : TryDirs)
    {
        int32 NX = X + Dir;
        if (NX >= 0 && NX < GridWidth && Grid[NX][Y].Type == ECellType::Empty)
        {
            SwapCells(X, Y, NX, Y);
            GridBuffer[NX][Y].LastDir = Dir;  // remember for next step
            return;
        }
    }
}
ISM Render Pass + Fixed-Timestep Tick

All three ISMs are cleared and rebuilt in one O(W×H) pass each frame. Sine-wave Z-offsets per material give each element an alive bounce. The tick accumulates real DeltaTime and drains it at a fixed rate.

FallingSandSimActor.cpp — Render() + Tick()
void AFallingSandSimActor::Render()
{
    ClearInstances();
    const float T = GetWorld()->TimeSeconds;

    for (int32 Y = 0; Y < GridHeight; ++Y)
    for (int32 X = 0; X < GridWidth;  ++X)
    {
        FCell& Cell = Grid[X][Y];
        if (Cell.Type == ECellType::Empty) continue;

        float ZOff = 0.f;
        switch (Cell.Type) {
        case ECellType::Sand:
            ZOff = 2.f + FMath::Sin(T*6.f  + X*0.3f + Y*0.3f) * 10.f; break;
        case ECellType::Water:
            ZOff = 1.f + FMath::Sin(T*10.f + X*0.5f)          * 15.f; break;
        case ECellType::Mud:
            ZOff = 3.f + FMath::Sin(Cell.Lifetime*0.2f)         *  5.f; break;
        }
        FTransform Xf;
        Xf.SetLocation(FVector(X*100, Y*100, ZOff));
        switch (Cell.Type) {
        case ECellType::Sand:  SandMesh ->AddInstance(Xf); break;
        case ECellType::Water: WaterMesh->AddInstance(Xf); break;
        case ECellType::Mud:   MudMesh  ->AddInstance(Xf); break;
        }
    }
}

void AFallingSandSimActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    SimTimeAccumulator += DeltaTime;
    while (SimTimeAccumulator >= SimStepInterval)
    {
        Simulate();              // fixed-rate step
        SimTimeAccumulator -= SimStepInterval;
    }
    Render();
}
Screenshots
Sandfall screenshot 1
Sandfall screenshot 2
Sandfall simulation preview
Grid 32 × 32, double-buffered Materials Sand, Water, Stone, Mud Engine Unreal Engine 5 Language C++ Render ISM-only (zero per-cell actors) Category R&D · Simulation Source github.com/khaled71612000 ↗
Connect