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:
- Vertices & UVs: An $(N+1) \times (N+1)$ spatial grid of coordinates where the $Y$ position is driven by pixel brightness.
- Triangles: Clockwise winding index arrays defining quad faces split into two triangles each.
- 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
gridSizeis 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.