DEV Community

Cover image for Procedural Structures in Unity
Chris
Chris

Posted on

Procedural Structures in Unity


Description

This article is mostly for my future reference and to keep track of my progress. While it is mainly meant as a type of code-journey post, I did my best to also make it usable as a tutorial. Hope you enjoy and squeeze some useful information from here!

-

I've been working on a multiplayer project using Unity's new networking system, Netcode. I've gotten to the point I to start working on the levels and objectives. :(

My initial thought was, I want to generate some basic rooms because I suck at level design, and apparently doing about 500x the work making the computer create something for me, is somehow easier then just taking the time to build my own level.


1. Creating a 2D Grid

First, I create an empty MonoBehaviour Script and name it "MapGenerator":


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MapGenerator : MonoBehaviour
{
}

Enter fullscreen mode Exit fullscreen mode

I then give it a few private variables, and give them the "SerializeField" header to customize the grid in the inspector:

    [SerializeField] private int _width = 7;
    [SerializeField] private int _height = 7;
    [SerializeField] private int _depth = 7;
    [SerializeField] private float _gridSpacing = 2.5f;
Enter fullscreen mode Exit fullscreen mode

_width & _height (and later _depth) just give us the number of "chunks" in the grid, the _grideSpacing (as redundant as it might be to mention) is the distance between each chunk.

I wanted to start with just using "OnDrawGizmosSelected" to make it easy to debug, so I also made a totally necessary function to turn these two lines into one:


private void DrawSphereAt(Vector3 position, Color sphereColor, float size = 1f)
{
     Gizmos.color = sphereColor;
     Gizmos.DrawSphere(position, size);
}
Enter fullscreen mode Exit fullscreen mode

Completely from my own problem solving skills- and not the many hours of procedural generation video tutorials and articles I've watched or read in the past-
I whip up this Generate2DGridGizmos function (ignore the variable name mixup):


private void Generate2DGridGizmos(Vector3 start)
    {
        var tempPos = start;
        var gizmoColor = Color.yellow; 

        for (int i = 0; i < _width; i++)
        {
            // Draw row
            DrawSphereAt(tempPos, gizmoColor);

            // Draw col
            var rowPos = tempPos;
            for (int z = 0; z < _height; z++)
            {
                if (!(z == 0)) { DrawSphereAt(rowPos,gizmoColor); }
                var nZ = rowPos.z += _gridSpacing;
                rowPos = new Vector3(tempPos.x, tempPos.y, nZ);
            }

            // Continue iter
            var nX = tempPos.x += _gridSpacing;
            tempPos = new Vector3(nX, tempPos.y, tempPos.z);
        }

    }
Enter fullscreen mode Exit fullscreen mode

Now, just tossing a method call inside OnCallGizmosSelected():

void OnDrawGizmosSelected()
{        
    Generate2DGridGizmos(_startPos);
}
Enter fullscreen mode Exit fullscreen mode

I can then attach the script in a scene object to get the results:

Image description

As fun as the gizmos are, I figured it's not very useful as is.
So I go ahead and duplicate the Generate2DGridGizmos() method and modify it so that it returns a Vector3 List of our grid.

    private List<Vector3> Generate2DGrid(Vector3 start, float spacing = 2.5f, int width = 10, int height = 10)
    {
        Vector3 tempPos = start;
        List<Vector3> coordList = new List<Vector3>();

        for (int i = 0; i < width; i++)
        {
            // Column
            coordList.Add(new Vector3(tempPos.x, tempPos.y, tempPos.z));

            // Row
            var rowPos = tempPos;
            for (int z = 0; z < height; z++)
            {
                if (!(z == 0))
                {
                    coordList.Add(new Vector3(rowPos.x, rowPos.y, rowPos.z));
                }
                var nZ = rowPos.z += spacing;
                rowPos = new Vector3(tempPos.x, tempPos.y, nZ);
            }

            // Continue it 
            var nX = tempPos.x += spacing;
            tempPos = new Vector3(nX, tempPos.y, tempPos.z);

        }

        return coordList;
    }
Enter fullscreen mode Exit fullscreen mode

I also create a new private variable to store these for the whole class:
[SerializeField] private List<Vector3> _coordinates;

This is then assigned in Start():


 private void Start()
    {
        _startPos = transform.position; // Set the starting position to the location of the gameobject
        if (!_parent) _parent = gameObject.transform; // Keep the scene tidy 
        _coordinates = Generate2DGrid(_start, 2.5f, _width,_height);
    }
Enter fullscreen mode Exit fullscreen mode

This way, instead of the gizmos being the grid that's generated each call, I can just generate the grid ahead of time and then draw it in OnDrawGizmosSelected():


void OnDrawGizmosSelected()
    {
        Color col = Color.yellow;
        foreach (Vector3 _it in _coordinates)
        {
            DrawSphereAt(_it, col);
        }
    }
Enter fullscreen mode Exit fullscreen mode

2. What are walls?

My first thought was that, having something more abstract for determining corners+walls would be cool. With what I came up with, I need to always provide absolute sizes for the grid to do anything.

walls = {
NORTH = V3(xL,x,x)
EAST = V3(x,x,xM)
SOUTH = V3(xM,x,x)
WEST = V3(x,x,xL)
NW_CORNER = (xL,x,xL)
NE_CORNER = V3(xL,x,xM)
SE_CORNER = V3(xM,x,xM)
SW_CORNER = V3(xM,x,xL)
}

This was the rough version, where "xL" is the lowest value and "xM" is the max.
The idea being, I set up a method to check if the coordinate matches a wall or corner description, then the method returns the associated type.

So first I turned the above into an actual enum type:

private enum StructureType
    {
        INNER,
        NORTH_WALL,
        EAST_WALL,
        SOUTH_WALL,
        WEST_WALL,
        NW_CORNER,
        NE_CORNER,
        SE_CORNER,
        SW_CORNER
    }
Enter fullscreen mode Exit fullscreen mode

Trying to visualize the world through Cartesian Coordinates hurts my brain so it took a few more hours to actually map out the descriptions...correctly...

reference : 
            NORTH = V3(xL,x,x)
                (X == Lowest)
                (Z != Lowest) (Not North West)
                (Z != Max) (Not North East)
            EAST = V3(x,x,xM)
                (Z == Max)
                (X != Lowest) (Not North East)
                (X != Max) (Not South East)
            SOUTH = V3(xM,x,x)
                (X == Max)
                (Z != Lowest) (Not South West)
                (Z != Max) (Not South East)
            WEST = V3(x,x,xL)
                (Z == Lowest) 
                (X != Max) (Not South West)
                (X != Lowest) (Not North West)
            NW_CORNER = (xL,x,xL)
                (X == Lowest)
                (Z == Lowest)
            NE_CORNER = V3(xL,x,xM)
                (X == Lowest)
                (Z == Max)
            SE_CORNER = V3(xM,x,xM)
                (X == Max)
                (Z == Max)
            SW_CORNER = V3(xM,x,xL)
                (X == Max)
                (Z == Lowest)

Enter fullscreen mode Exit fullscreen mode

From here, I create a method for returning the correct type based on the above criteria:
private StructureType GetStructureType(Vector3 component)

Before I show the full method, I want to again mention, that the full dimensions of the grid are needed ahead of time to make this work.

So first, I have to create two more variables:

    [SerializeField] private float _maxWidthValue;
    [SerializeField] private float _maxHeightValue;

Enter fullscreen mode Exit fullscreen mode

And back inside the Generate2DGrid() method; I add variables to track the highest x and z value of the grid (lastZ & lastX):

    private List<Vector3> Generate2DGrid(Vector3 start, float spacing = 2.5f, int width = 10, int height = 10)
    {
        Vector3 tempPos = start;
        List<Vector3> coordList = new List<Vector3>();
        float lastZ = 0.0f;
        float lastX = 0.0f;

        for (int i = 0; i < width; i++)
        {
            // Column
            lastX = tempPos.x;
            coordList.Add(new Vector3(tempPos.x, tempPos.y, tempPos.z));

            // Row
            var rowPos = tempPos;
            for (int z = 0; z < height; z++)
            {
                if (!(z == 0))
                {
                    lastZ = rowPos.z;
                    coordList.Add(new Vector3(rowPos.x, rowPos.y, rowPos.z));
                }
                var nZ = rowPos.z += spacing;
                rowPos = new Vector3(tempPos.x, tempPos.y, nZ);
            }

            // Continue it 
            var nX = tempPos.x += spacing;
            tempPos = new Vector3(nX, tempPos.y, tempPos.z);

        }

        _maxWidthValue = lastZ;
        _maxHeightValue = lastX;

        return coordList;
    }
Enter fullscreen mode Exit fullscreen mode

Once the iterations are finished, the method sets the private class variables _maxWidthValue & _maxHeightValue.

(Note: I could modify the method so this isn't a problem, but as it is, the variables need to be set just before the new Vector is created and added to the list. Otherwise, the max value will exceed the actual coordinates because the loop will increment the x and z values once more after the final vector addition).

Here's the full method that does our StructureType checks:

private StructureType GetStructureType(Vector3 component)
    {

        /* reference : 
            NORTH = V3(xL,x,x)
                (X == Lowest)
                (Z != Lowest) (Not North West)
                (Z != Max) (Not North East)
            EAST = V3(x,x,xM)
                (Z == Max)
                (X != Lowest) (Not North East)
                (X != Max) (Not South East)
            SOUTH = V3(xM,x,x)
                (X == Max)
                (Z != Lowest) (Not South West)
                (Z != Max) (Not South East)
            WEST = V3(x,x,xL)
                (Z == Lowest) 
                (X != Max) (Not South West)
                (X != Lowest) (Not North West)

            NW_CORNER = (xL,x,xL)
                (X == Lowest)
                (Z == Lowest)
            NE_CORNER = V3(xL,x,xM)
                (X == Lowest)
                (Z == Max)
            SE_CORNER = V3(xM,x,xM)
                (X == Max)
                (Z == Max)
            SW_CORNER = V3(xM,x,xL)
                (X == Max)
                (Z == Lowest)
        */

        // Width == Max Row (Z) value
        // Height == Max Col (X) value

        float wdt = _maxWidthValue;
        float hgt = _maxHeightValue;
        var structT = StructureType.INNER; // if none of the criteria are met, it's an inner component
        var c = component; // trying to make the below code a little cleaner

        // if x value is lowest (north) if z value is NOT max (not northeast) if z value is NOT lowest (not northwest)
        if (c.x == 0 &&
            c.z != wdt &&
            c.z != 0
        ) { structT = StructureType.NORTH_WALL; }

        // if z value is MAX (east) if x value is NOT max (not southeast) if x value is NOT lowest (not northeast)
        if (c.z == wdt &&
            c.x != hgt &&
            c.x != 0) { structT = StructureType.EAST_WALL; }

        // if x value is MAX (south) if z value is NOT max (not southeast) if z value is NOT lowest (not southwest) 
        if (c.x == hgt &&
            c.z != wdt &&
            c.x != 0) { structT = StructureType.SOUTH_WALL; }

        // if z value is LOWEST (west) if x value is NOT lowest (not northwest) if x value is NOT MAX (southwest)
        if (c.z == 0 &&
            c.x > 0 &&
            c.x < hgt) { structT = StructureType.WEST_WALL; }


        // -- CORNERS --

        // if x value is LOWEST (north) if z value is lowest (northwest)
        if (c.x == 0 && c.z == 0) { structT = StructureType.NW_CORNER; }

        // if x value is LOWEST (north) if z value is MAX (northeast)
        if (c.x == 0 && c.z == wdt) { structT = StructureType.NE_CORNER; }

        // if x value is MAX (south) if z value is lowest (southwest)
        if (c.x == hgt && c.z == 0) { structT = StructureType.SW_CORNER; }

        // if x value is MAX (south) if z value is MAX (southeast)
        if (c.x == hgt && c.z == wdt) { structT = StructureType.SE_CORNER; }

        //Debug.Log("Returning :" + structT + "FOR : " + c);
        return structT;
    } 

Enter fullscreen mode Exit fullscreen mode

The easiest way for me to check that this was working correctly... was with gizmos.
So I created a new helper method that returns one of 3 colors depending on a vectors structure type.

    private Color GetStructureColor(Vector3 position)
    {
        Color aColor = Color.gray;
        StructureType structType = GetStructureType(position);

        if (structType == StructureType.NORTH_WALL ||
            structType == StructureType.SOUTH_WALL ||
            structType == StructureType.EAST_WALL ||
            structType == StructureType.WEST_WALL) { aColor = Color.red; }

        if (structType == StructureType.NE_CORNER ||
            structType == StructureType.NW_CORNER ||
            structType == StructureType.SE_CORNER ||
            structType == StructureType.SW_CORNER) { aColor = Color.green; }

        return aColor;
    }
Enter fullscreen mode Exit fullscreen mode
  • Red = Wall
  • Green = Corner
  • Gray = Inner

I then modified the Generate2DGridGizmos() to use this helper method:

    private void Generate2DGridGizmos(Vector3 start)
    {
        var tempPos = start;

        for (int i = 0; i < _width; i++)
        {
            // Draw col
            DrawSphereAt(tempPos, GetStructureColor(tempPos));

            // Draw row
            var rowPos = tempPos;
            for (int z = 0; z < _height; z++)
            {
                if (!(z == 0)) { DrawSphereAt(rowPos, GetStructureColor(rowPos)); }
                var nZ = rowPos.z += _gridSpacing;
                rowPos = new Vector3(tempPos.x, tempPos.y, nZ);
            }

            // Continue iter
            var nX = tempPos.x += _gridSpacing;
            tempPos = new Vector3(nX, tempPos.y, tempPos.z);
        }

    }
Enter fullscreen mode Exit fullscreen mode

Here's the results:

Image description

The StructureCheck assumes the lowest position value is 0, so that needs fixing, otherwise this was far more succesful then I initially thought it would be.

Here's a test where it generates actual walls:
Image description

Top comments (0)