Ben Traje
← Back to unity

Procedural Mesh Generation in Unity: Building a Heightmap Terrain in Start()

09 Sep 26 (1d ago)

Generating and deforming a custom mesh purely in Start() is an efficient, practical technique for terrain, dynamic liquid ripples, or stylized level foundations.

Because the generation runs once during initialization, the CPU cost is negligible—you are effectively baking a custom procedural mesh on the fly rather than keeping an expensive CPU calculation alive in Update().

Here is a clean C# implementation that generates a customizable plane, samples elevation values directly from a grayscale texture using bilinear interpolation, and avoids common memory and index-limit pitfalls.

The Generation Strategy

Building a plane from scratch requires three core elements:

  1. Vertices & UVs: An $(N+1) \times (N+1)$ spatial grid of coordinates where the $Y$ position is driven by pixel brightness.
  2. Triangles: Clockwise winding index arrays defining quad faces split into two triangles each.
  3. Lighting & Bounds Data: Computing normals so directional lighting and shadows interact realistically with elevation changes.

(y+1, x) [root + gridSize + 1] -------- (y+1, x+1) [root + gridSize + 2]
|      \                         |
|        \   Tri 2               |
|  Tri 1   \                     |
|            \                   |
(y, x)   [root] ----------------------- (y, x+1)   [root + 1]

The Procedural Heightmap Script

Attach this script to an empty GameObject. It will automatically configure a MeshFilter, MeshRenderer, and optional MeshCollider.

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

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class ProceduralHeightmapTerrain : MonoBehaviour
{
    [Header("Grid Dimensions")]
    [Tooltip("Number of quads along each axis.")]
    [Range(1, 500)]
    public int gridSize = 100;
    public float cellSize = 1f;
    public float heightMultiplier = 5f;

    [Header("Elevation Source")]
    [Tooltip("Requires 'Read/Write Enabled' in the Texture Import Settings.")]
    public Texture2D heightMap;

    [Header("Collision")]
    public bool generateCollider = true;

    void Start()
    {
        GenerateTerrainMesh();
    }

    void GenerateTerrainMesh()
    {
        Mesh mesh = new Mesh
        {
            name = "Procedural_Terrain_Mesh"
        };

        // Allow meshes exceeding 65,535 vertices (essential for grids >= 255)
        mesh.indexFormat = IndexFormat.UInt32;

        int totalVertices = (gridSize + 1) * (gridSize + 1);
        List<Vector3> vertices = new List<Vector3>(totalVertices);
        List<Vector2> uvs = new List<Vector2>(totalVertices);
        List<int> triangles = new List<int>(gridSize * gridSize * 6);

        // 1. Build Vertices & Normalized UVs
        for (int y = 0; y <= gridSize; y++)
        {
            for (int x = 0; x <= gridSize; x++)
            {
                float u = (float)x / gridSize;
                float v = (float)y / gridSize;

                float xPos = x * cellSize;
                float zPos = y * cellSize;
                float yPos = 0f;

                // Sample texture with smooth bilinear filtering
                if (heightMap != null)
                {
                    Color pixel = heightMap.GetPixelBilinear(u, v);
                    yPos = pixel.grayscale * heightMultiplier;
                }

                vertices.Add(new Vector3(xPos, yPos, zPos));
                uvs.Add(new Vector2(u, v));
            }
        }

        // 2. Build Triangles (Clockwise winding for upward-facing normals)
        for (int y = 0; y < gridSize; y++)
        {
            for (int x = 0; x < gridSize; x++)
            {
                int root = y * (gridSize + 1) + x;

                // Triangle 1
                triangles.Add(root);
                triangles.Add(root + gridSize + 1);
                triangles.Add(root + 1);

                // Triangle 2
                triangles.Add(root + 1);
                triangles.Add(root + gridSize + 1);
                triangles.Add(root + gridSize + 2);
            }
        }

        // 3. Assign Buffers
        mesh.SetVertices(vertices);
        mesh.SetUVs(0, uvs);
        mesh.SetTriangles(triangles, 0);

        // 4. Recalculate Geometry Data
        mesh.RecalculateNormals();
        mesh.RecalculateBounds();

        // 5. Apply to Components
        GetComponent<MeshFilter>().mesh = mesh;

        if (generateCollider)
        {
            MeshCollider collider = GetComponent<MeshCollider>();
            if (collider == null)
            {
                collider = gameObject.AddComponent<MeshCollider>();
            }
            collider.sharedMesh = mesh;
        }
    }
}

3 Critical Workflow Requirements

1. Enable Read/Write on the Texture

heightMap.GetPixelBilinear() reads raw texture pixel data from system RAM. By default, Unity uploads textures straight to GPU VRAM and discards the system memory copy.

  • Select your texture asset in the Project window.
  • In the Inspector, check Read/Write.
  • Click Apply. Failing to do this throws an UnityException: Texture '${name}' is not readable.

2. The 65k Vertex Ceiling (IndexFormat.UInt32)

By default, Unity meshes use a 16-bit index buffer (IndexFormat.UInt16), capping the total vertex count to $65,535$.

  • If your gridSize is 255 or higher, $(255 + 1) \times (255 + 1) = 65,536$ vertices.
  • Setting mesh.indexFormat = IndexFormat.UInt32; raises the vertex ceiling into the millions, preventing unexpected mesh corruptions on dense grids.

3. Normal Recalculation & Lighting

mesh.RecalculateNormals() is essential after manipulating $Y$ offsets. Without it, the vertex normals remain pointed straight up (Vector3.up), which results in flat, shaded lighting that makes elevated peaks look completely flat despite the physical displacement.