World Size Comparison

By Plump Helmet Studios on

World Size Comparison

Sometimes you're using your trusty old single core laptop, other times you're sat at your 16-core battlestation which pumps out enough heat that it _is_ your heating system. Either way, the games you play should be able to adapt to the hardware gracefully.

In our medieval settlement simulator, where the world is heavily simulated alongside your gameplay, one way we're going to address this is by allowing a range of world sizes when you're setting up your game in the first place. The default size is 480x270, or 129,600 individual sectors, that will be simulated with every passing day in the in-game world, though that can go as low as 160x90 giving 14,400 sectors, or right up to 800x450 for 360,000 sectors.

To show the effect of the size on the world generation, take a look at the below visual representations along with a breakdown of their generation*.

Smallest

Name: WorldGeneration (Smallest - 160x90 - 14,400)
------------------------------------------------------------------------
0   Variables                          0.008 ms /     0.008 ms (  0.00%)
1   GenerateElevation                 94.109 ms /    94.117 ms ( 37.37%)
2   GenerateTemperature               78.566 ms /   172.683 ms ( 31.19%)
3   GenerateRainfall                  63.666 ms /   236.349 ms ( 25.28%)
4   SectorGeneration                   4.411 ms /   240.760 ms (  1.75%)
5   UpdateSectorNeighbours             3.425 ms /   244.185 ms (  1.36%)
6   UpdateSectorBitmasks               7.668 ms /   251.853 ms (  3.04%)
7   CreateWorld                        0.002 ms /   251.855 ms (  0.00%)

Small

Name: WorldGeneration (Small - 320x180 - 57,600)
------------------------------------------------------------------------
0   Variables                          0.022 ms /     0.022 ms (  0.00%)
1   GenerateElevation                366.901 ms /   366.923 ms ( 36.08%)
2   GenerateTemperature              291.599 ms /   658.522 ms ( 28.68%)
3   GenerateRainfall                 293.972 ms /   952.494 ms ( 28.91%)
4   SectorGeneration                  17.424 ms /   969.918 ms (  1.71%)
5   UpdateSectorNeighbours            14.740 ms /   984.658 ms (  1.45%)
6   UpdateSectorBitmasks              32.178 ms /  1016.836 ms (  3.16%)
7   CreateWorld                        0.001 ms /  1016.837 ms (  0.00%)

Medium

Name: WorldGeneration (Medium - 480x270 - 129,600)
------------------------------------------------------------------------
0   Variables                          0.046 ms /     0.046 ms (  0.00%)
1   GenerateElevation                832.049 ms /   832.095 ms ( 35.82%)
2   GenerateTemperature              660.468 ms /  1492.563 ms ( 28.43%)
3   GenerateRainfall                 656.581 ms /  2149.144 ms ( 28.26%)
4   SectorGeneration                  65.737 ms /  2214.881 ms (  2.83%)
5   UpdateSectorNeighbours            33.504 ms /  2248.385 ms (  1.44%)
6   UpdateSectorBitmasks              74.693 ms /  2323.078 ms (  3.22%)
7   CreateWorld                        0.001 ms /  2323.079 ms (  0.00%)

Large

Name: WorldGeneration (Large - 640x360 - 230,400)
------------------------------------------------------------------------
0   Variables                          1.065 ms /     1.065 ms (  0.03%)
1   GenerateElevation               1497.784 ms /  1498.849 ms ( 35.52%)
2   GenerateTemperature             1203.489 ms /  2702.338 ms ( 28.54%)
3   GenerateRainfall                1201.902 ms /  3904.240 ms ( 28.50%)
4   SectorGeneration                  70.763 ms /  3975.003 ms (  1.68%)
5   UpdateSectorNeighbours            59.534 ms /  4034.537 ms (  1.41%)
6   UpdateSectorBitmasks             182.444 ms /  4216.981 ms (  4.33%)
7   CreateWorld                        0.001 ms /  4216.982 ms (  0.00%)

Largest

Name: WorldGeneration (Largest - 800x450 - 360,000)
------------------------------------------------------------------------
0   Variables                          1.687 ms /     1.687 ms (  0.03%)
1   GenerateElevation               2398.563 ms /  2400.250 ms ( 35.55%)
2   GenerateTemperature             1950.626 ms /  4350.876 ms ( 28.91%)
3   GenerateRainfall                1893.593 ms /  6244.469 ms ( 28.07%)
4   SectorGeneration                 144.208 ms /  6388.677 ms (  2.14%)
5   UpdateSectorNeighbours            93.394 ms /  6482.071 ms (  1.38%)
6   UpdateSectorBitmasks             264.655 ms /  6746.726 ms (  3.92%)
7   CreateWorld                        0.000 ms /  6746.726 ms (  0.00%)

* Note that as we're still in very early development, the world generation will expand to include more elements (such as inland lakes, rivers, volcanoes, settlements, roads, paths, and who knows what else) while simultaneously becoming more efficient as we refine the process.

Please consider following us on Twitter @plump_helmet or take a look at our Patreon.

Natural precipitation

By Plump Helmet Studios on

Natural precipitation

In our unannounced title, upon starting a new game, you'll first be met with one of the most important screens, the world generator. This is the world in which you're going to spend a lot of time, whether that's in the frigid tundras of the polar north, in the dusty savannahs of the equatorial regions, or in a temperate forest somewhere in between. Either way, connecting with the in game world is important in setting the tone for the game ahead.

Terrain generation

Procedural terrain generation is so much fun to work with. That's why we wanted to show off our natural precipitation model, which uses terrain features such as mountains and hills, as well as oceans, to generate natural rainfall and therefore forests and vegetative biomes where the land is more moist.

Natural precipitation

It's easy to get lost in procedural generation. We have more features to add: inland lakes and rivers, natural erosion, volcanoes for magma forges... right?

We look forward to sharing more images in the future, such as the biomes themselves, up close and personal.

And magma forges.

Psst, for more on game development & to see more posts about our unannounced title in the making, why not follow us on Twitter @plump_helmet? Thanks.

Edge Smoothing

By Plump Helmet Studios on

Edge Smoothing

To begin, let's first admit that these textures are of potato quality. Let's just get that out there. I know. But their quick fabrication was to test a concept, of spreading a large texture over a number of smaller quads in order to reduce the repetitive nature of a 2D tiled terrain. In the picture below, you can make out the size of a quad, and each texture covers 256 of these in a 16,16 grid.

Sans smoothing

It works well. The tiling is only really evident when you zoom right out, and in reality, that won't happen "in the game", and rarely do you have so much terrain of the same type. Soil may have some rich soil mixed in, perhaps some gravel and stone, and a bit of marshy bog as well, not to mention the trees, plants, and demonic obelisks. Maybe.

The overall terrain is built in layers. In this example, there are three layers: water, sand, and soil. These layers each have their own meshes, which are generated in that order and drawn in that order too. When viewed with an orthagraphic camera, they meld into a single, unified terrain.

Below you can see the sand layer selected in the Unity editor.

Sand layer proper

With a tiled approach, where each 64 pixel block is represents a terrain type and is represented by a quad, the visuals become very blocky, and it doesn't really look good. So we need to blend these layers into each other. I attempted a few different techniques to achieve this, but in the end, without sacrificing the layered approach, it wasn't really possible (for me.)

So to blend the terrain layers, each layer checks for tiles at it's edge, and, where it finds a tile that is dissimilar, it creates a new quad with vertex-level opacity. For example, if the current tile is water and the tile to the north is sand, then the water layer will create a new quad to the north (tile x,y+1) with four vertices, the "southern" two (x,y + x+1,y) with an opacity of 1 and the "northern" two (x,y+1 + x+1,y+1) with an opacity of 0. These quads are part of the same mesh as the rest of the tiles, and therefore benefit from the same UV mapping & tiling.

Doing this gives us a border of faded quads. As the terrain layers are rendered in order, the fading quads of the layers underneath are culled and the fading quads from the above layers are visible.

Sand layer + smoothing

But how did we get to this result?

The first step is generating the map layers, but the real magic happens in CalculateEdges.

// Iterate over every map tile, starting at bottom left,
// and going left to right, bottom to top.
for (var y = 0; y < mapHeight; y++)
{
    for (var x = 0; x < mapWidth; x++)
    {
        if (_mapTiles[x, y].TerrainType != _type) continue;

        _meshData.AddVertex(x, y, 0);
        _meshData.AddVertex(x, y + 1, 0);
        _meshData.AddVertex(x + 1, y + 1, 0);
        _meshData.AddVertex(x + 1, y, 0);
        _meshData.AddQuadTriangles();
        _meshData.AddQuadColors();
        _meshData.AddUV(CalculateUV(x, y));
        _meshData.AddUV(CalculateUV(x, y + 1));
        _meshData.AddUV(CalculateUV(x + 1, y + 1));
        _meshData.AddUV(CalculateUV(x + 1, y));

        CalculateEdges(x, y);
    }
}

In CalculateEdges we iterate over all directions, cardinal (N,E,S,W) as well as ordinal (NE,SE,SW,NE), checking whether the tile in these directions are a different type than the current tile, and when they are, we create the faded quad as per these directions.

// Iterate over all directions, check whether an edge tile should
// be generated for this direction, and if so, generate one.
List<Direction> list = new List<Direction>();
Array directions = Enum.GetValues(typeof(Direction));
foreach(Direction direction in (Direction[])directions)
{
    switch (direction)
    {
        case Direction.North:
            if (y + 1 < mapHeight && _mapTiles[x, y + 1].TerrainType != _type)
                list.Add(Direction.North);
            break;

        case Direction.NorthEast:
            if (y + 1 < mapHeight && x + 1 < mapWidth && _mapTiles[x + 1, y + 1].TerrainType != _type)
                list.Add(Direction.NorthEast);
            break;

        case Direction.East:
            if (x + 1 < mapWidth && _mapTiles[x + 1, y].TerrainType != _type)
                list.Add(Direction.East);
            break;

        case Direction.SouthEast:
            if (y - 1 >= 0 && x + 1 < mapWidth && _mapTiles[x + 1, y - 1].TerrainType != _type)
                list.Add(Direction.SouthEast);
            break;

        case Direction.South:
            if (y - 1 >= 0 && _mapTiles[x, y - 1].TerrainType != _type)
                list.Add(Direction.South);
            break;

        case Direction.SouthWest:
            if (y - 1 >= 0 && x - 1 >= 0 && _mapTiles[x - 1, y - 1].TerrainType != _type)
                list.Add(Direction.SouthWest);
            break;

        case Direction.West:
            if (x - 1 >= 0 && _mapTiles[x - 1, y].TerrainType != _type)
                list.Add(Direction.West);
            break;

        case Direction.NorthWest:
            if (y + 1 < mapHeight && x - 1 >= 0 && _mapTiles[x - 1, y + 1].TerrainType != _type)
                list.Add(Direction.NorthWest);
            break;
    }
}

Finally, once we have a list of directions where an edge tile is needed, we generate them.

list.ForEach(delegate(Direction direction) {
    GenerateEdge(x, y, direction);
});

The GenerateEdge method makes a copy of the tiles current x,y coordinates and for the direction it needs to generate an edge for, it either adds or subtracts a value from the copied x and/or y values, and also rather importantly, defines which vertices are opaque and which are not.

int dx = x;
int dy = y;

// Calculate relative position as well as the vertex alpha.
switch (direction)
{
    case Direction.North:
        dy += 1;
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 1);
        break;

    case Direction.NorthEast:
        dx += 1;
        dy += 1;
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        break;

    case Direction.East:
        dx += 1;
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        break;

    case Direction.SouthEast:
        dx += 1;
        dy -= 1;
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        break;

    case Direction.South:
        dy -= 1;
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 0);
        break;

    case Direction.SouthWest:
        dx -= 1;
        dy -= 1;
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 0);
        break;

    case Direction.West:
        dx -= 1;
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 1);
        _meshData.AddColor(1, 1, 1, 1);
        break;

    case Direction.NorthWest:
        dx -= 1;
        dy += 1;
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 0);
        _meshData.AddColor(1, 1, 1, 1);
        break;
}

Then, like before, it's just a case of defining the quad.

// Add vertex, triangle, and UV data
_meshData.AddVertex(dx, dy, 0);
_meshData.AddVertex(dx, dy + 1, 0);
_meshData.AddVertex(dx + 1, dy + 1, 0);
_meshData.AddVertex(dx + 1, dy, 0);
_meshData.AddQuadTriangles();
_meshData.AddUV(CalculateUV(dx, dy));
_meshData.AddUV(CalculateUV(dx, dy + 1));
_meshData.AddUV(CalculateUV(dx + 1, dy + 1));
_meshData.AddUV(CalculateUV(dx + 1, dy));

The last part, which is essential, is the shader. We define the color fixed4 color : COLOR parameter in appdata and v2f, and when it comes time to manipulate each pixel, we multiply the texel by the color return tex2D(_MainTex, i.uv) * i.color.

Shader "2D/VertexBlend"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" }
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                fixed4 color : COLOR;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                fixed4 color : COLOR;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.color = v.color;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return tex2D(_MainTex, i.uv) * i.color;
            }
            ENDCG
        }
    }
}

It's not a complex operation. In essence, it's actually quite simple, but almost always the simplest solution is the best, and in this case, once you understand the simple solution, is hard to understand how it could ever have been so tricky.

Thanks for reading, and why not follow us on Twitter @plump_helmet for more on game development & our unannounced title?

Coloured Cube

By Plump Helmet Studios on

Coloured Cube

Using a custom shader (assigned to a custom material), we are able to colour a cube with the vertex colour data provided to the mesh object.

VertexColour (Shader)

Shader "Custom/VertexColour"
{
  Properties
  {
    _Color ("Main Color", Color) = (1,1,1,1)
    _SpecColor ("Spec Color", Color) = (1,1,1,1)
    _Emission ("Emmisive Color", Color) = (0,0,0,0)
    _Shininess ("Shininess", Range (0.01, 1)) = 0.7
    _MainTex ("Base (RGB)", 2D) = "white" {}
  }

  SubShader
  {
    Pass
    {
      Material
      {
        Shininess [_Shininess]
        Specular [_SpecColor]
        Emission [_Emission]
      }

      ColorMaterial AmbientAndDiffuse
      Lighting On
      SeparateSpecular On

      SetTexture [_MainTex]
      {
        Combine texture * primary, texture * primary
      }

      SetTexture [_MainTex]
      {
        constantColor [_Color]
        Combine previous * constant DOUBLE, previous * constant
      }
    }
  }

  Fallback "VertexLit", 1
}

ColouredCube

using UnityEngine;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class ColouredCube : MonoBehaviour
{
    Mesh mesh;
    Vector3[] vertices;
    int[] triangles;
    Color32[] colors32;

    void Awake()
    {
        mesh = GetComponent<MeshFilter>().mesh;
        vertices = new Vector3[] {
            new Vector3(0, 0, 0), // front bottom left
            new Vector3(1, 0, 0), // front bottom right
            new Vector3(1, 1, 0), // front top right
            new Vector3(0, 1, 0), // front top left
            new Vector3(0, 1, 1), // back top left
            new Vector3(1, 1, 1), // back top right
            new Vector3(1, 0, 1), // back bottom right
            new Vector3(0, 0, 1)  // back bottom left
        };
        triangles = new int[] {
            0, 2, 1, // front
            0, 3, 2,
            0, 7, 4, // left
            0, 4, 3,
            5, 4, 7, // back
            5, 7, 6,
            1, 2, 5, // right
            1, 5, 6,
            2, 3, 4, // top
            2, 4, 5,
            0, 6, 7, // bottom
            0, 1, 6
        };

        colors32 = new Color32[] {
            new Color32(0, 255, 0, 255), // front bottom left
            new Color32(0, 240, 0, 255), // front bottom right
            new Color32(0, 225, 0, 255), // front top right
            new Color32(0, 210, 0, 255), // front top left
            new Color32(0, 195, 0, 255), // back top left
            new Color32(0, 180, 0, 255), // back top right
            new Color32(0, 165, 0, 255), // back bottom right
            new Color32(0, 150, 0, 255)  // back bottom left
        };
    }

    void Start()
    {
        mesh.Clear();
        mesh.vertices = vertices;
        mesh.triangles = triangles;
        mesh.colors32 = colors32;
        mesh.RecalculateNormals();
    }
}

Simple Chunk

By Plump Helmet Studios on

Simple Chunk

This simple chunk comprises of five classes: Chunk, Block, BlockSolid, BlockEmpty, and MeshData. The MeshData class holds the vertex/index data, which is instantiated in the Chunk class and passed to each Block class on render.

Chunk

This class holds a three-dimensional array of Block-derived classes, which it populates in the Awake() method. When Update() runs, it checks to see if the public boolean update is set to true, and if so, it updates & renders the blocks.

It creates a new MeshData class to hold render data for later on, which it passes to each block it renders.

using UnityEngine;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class Chunk : MonoBehaviour
{
    public static int chunkSize = 16;
    public bool update = true;

    Block[,,] blocks;
    MeshFilter meshFilter;

    void Awake()
    {
        // initialise a simple 50/50 solid/empty block array
        blocks = new Block[chunkSize, chunkSize, chunkSize];
        for (int x = 0; x < chunkSize; x++)
        {
            for (int y = 0; y < chunkSize; y++)
            {
                for (int z = 0; z < chunkSize; z++)
                {
                    Block block = (y < chunkSize / 2)
                        ? (Block)new BlockSolid()
                        : (Block)new BlockEmpty();
                    SetBlock(x, y, z, block);
                }
            }
        }
    }

    void Start()
    {
        meshFilter = GetComponent<MeshFilter>();
    }

    void Update()
    {
        if (update)
        {
            update = false;
            UpdateChunk();
        }
    }

    void UpdateChunk()
    {
        MeshData meshData = new MeshData();
        for (int x = 0; x < chunkSize; x++)
        {
            for (int y = 0; y < chunkSize; y++)
            {
                for (int z = 0; z < chunkSize; z++)
                {
                    blocks[x, y, z].Render(this, meshData, x, y, z);
                }
            }
        }
        RenderMesh(meshData);
    }

    void RenderMesh(MeshData meshData)
    {
        meshFilter.mesh.Clear();
        meshFilter.mesh.vertices = meshData.vertices.ToArray();
        meshFilter.mesh.triangles = meshData.triangles.ToArray();
        meshFilter.mesh.RecalculateNormals();
    }

    public Block GetBlock(int x, int y, int z)
    {
        if (x >= 0 && x < chunkSize &&
            y >= 0 && y < chunkSize &&
            z >= 0 && z < chunkSize)
        {
            return blocks[x, y, z];
        }
        else
        {
            return null;
        }
    }

    public void SetBlock(int x, int y, int z, Block block)
    {
        blocks[x, y, z] = block;
    }
}

Block

This class holds base functionality for a block, including the directional checks to ensure that only visible faces are rendered.

using System.Collections.Generic;
using UnityEngine;

public class Block
{
    public enum Direction { North, East, South, West, Up, Down };
    public Dictionary<Direction, Vector3> directionVectors;

    public Block()
    {
        directionVectors = new Dictionary<Direction, Vector3>() {
            { Direction.Up, new Vector3(0, 1, 0) },
            { Direction.Down, new Vector3(0, -1, 0) },
            { Direction.North, new Vector3(0, 0, 1) },
            { Direction.South, new Vector3(0, 0, -1) },
            { Direction.East, new Vector3(1, 0, 0) },
            { Direction.West, new Vector3(-1, 0, 0) }
        };
    }

    public virtual bool IsSolid(Direction direction)
    {
        return true;
    }

    public virtual void Render(Chunk chunk, MeshData meshData, int x, int y, int z)
    {
        foreach (var directionVector in directionVectors)
        {
            Vector3 vector = directionVector.Value;
            Block block = chunk.GetBlock(x + (int)vector.x, y + (int)vector.y, z + (int)vector.z);

            if (block == null || !block.IsSolid(directionVector.Key))
            {
                RenderFace(directionVector.Key, chunk, meshData, x, y, z);
            }
        }
    }

    protected virtual void RenderFace(Direction direction, Chunk chunk, MeshData meshData, int x, int y, int z)
    {
        switch (direction)
        {
            case Direction.Up:
                meshData.AddVertex(new Vector3(x, y + 1, z + 1));
                meshData.AddVertex(new Vector3(x + 1, y + 1, z + 1));
                meshData.AddVertex(new Vector3(x + 1, y + 1, z));
                meshData.AddVertex(new Vector3(x, y + 1, z));
                break;
            case Direction.Down:
                meshData.AddVertex(new Vector3(x, y, z));
                meshData.AddVertex(new Vector3(x + 1, y, z));
                meshData.AddVertex(new Vector3(x + 1, y, z + 1));
                meshData.AddVertex(new Vector3(x, y, z + 1));
                break;
            case Direction.North:
                meshData.AddVertex(new Vector3(x + 1, y, z + 1));
                meshData.AddVertex(new Vector3(x + 1, y + 1, z + 1));
                meshData.AddVertex(new Vector3(x, y + 1, z + 1));
                meshData.AddVertex(new Vector3(x, y, z + 1));
                break;
            case Direction.South:
                meshData.AddVertex(new Vector3(x, y, z));
                meshData.AddVertex(new Vector3(x, y + 1, z));
                meshData.AddVertex(new Vector3(x + 1, y + 1, z));
                meshData.AddVertex(new Vector3(x + 1, y, z));
                break;
            case Direction.East:
                meshData.AddVertex(new Vector3(x + 1, y, z));
                meshData.AddVertex(new Vector3(x + 1, y + 1, z));
                meshData.AddVertex(new Vector3(x + 1, y + 1, z + 1));
                meshData.AddVertex(new Vector3(x + 1, y, z + 1));
                break;
            case Direction.West:
                meshData.AddVertex(new Vector3(x, y, z + 1));
                meshData.AddVertex(new Vector3(x, y + 1, z + 1));
                meshData.AddVertex(new Vector3(x, y + 1, z));
                meshData.AddVertex(new Vector3(x, y, z));
                break;
        }
        meshData.AddQuadTriangles();
    }
}

BlockSolid

This class wants to be seen.

public class BlockSolid : Block
{
    public BlockSolid() : base() { }

    public override void Render(Chunk chunk, MeshData meshData, int x, int y, int z)
    {
        base.Render(chunk, meshData, x, y, z);
    }

    public override bool IsSolid(Block.Direction direction)
    {
        return true;
    }
}

BlockEmpty

This class does not wish to be seen.

public class BlockEmpty : Block
{
    public BlockEmpty() : base() { }

    public override void Render(Chunk chunk, MeshData meshData, int x, int y, int z)
    {
        // render nothing
    }

    public override bool IsSolid(Block.Direction direction)
    {
        return false;
    }
}

MeshData

This class holds triangle and vertex data, and offers a small helper method too.

using System.Collections.Generic;
using UnityEngine;

public class MeshData
{
    public List<Vector3> vertices = new List<Vector3>();
    public List<int> triangles = new List<int>();

    public MeshData() { }

    public void AddVertex(Vector3 vertex)
    {
        vertices.Add(vertex);
    }

    public void AddTriangle(int triangle)
    {
        triangles.Add(triangle);
    }

    public void AddQuadTriangles()
    {
        triangles.Add(vertices.Count - 4);
        triangles.Add(vertices.Count - 3);
        triangles.Add(vertices.Count - 2);
        triangles.Add(vertices.Count - 4);
        triangles.Add(vertices.Count - 2);
        triangles.Add(vertices.Count - 1);
    }
}

Summary

This basic prototype shows an easy way to render a large number of voxels with a single mesh, reducing the load on the GPU. This could be expanded with a further parent class which could allow chunk/block transversal for multiple chunks.