Static imported assets cover 90% of what a game needs — but that last 10% often breaks pipelines. Terrain that deforms under player footsteps, modular dungeons assembled at runtime, water surfaces that ripple with real geometry: none of these work with a pre-authored GLB. Godot 4's ArrayMesh and SurfaceTool APIs give you everything you need to build, UV-map, and collide dynamic 3D geometry entirely in GDScript or C# — no external DCC tool required.
Understanding ArrayMesh and SurfaceTool
Godot 4 exposes two complementary APIs for procedural geometry. ArrayMesh is the low-level approach: you build a dictionary of typed arrays — positions, normals, UVs, indices — and call add_surface_from_arrays() to commit them as a renderable surface. SurfaceTool wraps that process in a builder pattern, letting you call add_vertex(), add_normal(), and add_uv() per-vertex before calling commit() to get a finished ArrayMesh.
Choose SurfaceTool when you are generating geometry procedurally and want clean code; choose raw ArrayMesh when you are manipulating large vertex buffers for performance — for example, streaming terrain chunks or deforming a cloth simulation.
Both paths end at the same place: a MeshInstance3D with your mesh assigned, ready to receive any StandardMaterial3D or custom shader.
func make_quad() -> ArrayMesh:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
st.set_uv(Vector2(0, 0))
st.add_vertex(Vector3(-0.5, 0, -0.5))
st.set_uv(Vector2(1, 0))
st.add_vertex(Vector3(0.5, 0, -0.5))
st.set_uv(Vector2(0, 1))
st.add_vertex(Vector3(-0.5, 0, 0.5))
st.set_uv(Vector2(1, 0))
st.add_vertex(Vector3(0.5, 0, -0.5))
st.set_uv(Vector2(1, 1))
st.add_vertex(Vector3(0.5, 0, 0.5))
st.set_uv(Vector2(0, 1))
st.add_vertex(Vector3(-0.5, 0, 0.5))
st.generate_normals()
return st.commit()
generate_normals() computes smooth face normals from your triangle winding — call it before commit() or your mesh will be pitch black in a lit scene.
![]()
Building a Procedural Terrain Patch
A height-mapped terrain patch is the canonical ArrayMesh use case. The algorithm is straightforward: lay out a grid of vertices, sample a noise function for each Y position, then stitch triangles across the grid.
func generate_terrain(size: int, resolution: int, noise: FastNoiseLite) -> ArrayMesh:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var step := float(size) / resolution
for z in range(resolution):
for x in range(resolution):
var positions := [
Vector3(x * step, noise.get_noise_2d(x, z) * 4.0, z * step),
Vector3((x+1) * step, noise.get_noise_2d(x+1, z) * 4.0, z * step),
Vector3(x * step, noise.get_noise_2d(x, z+1) * 4.0, (z+1) * step),
Vector3((x+1) * step, noise.get_noise_2d(x+1, z+1) * 4.0, (z+1) * step),
]
var uvs := [
Vector2(float(x)/resolution, float(z)/resolution),
Vector2(float(x+1)/resolution, float(z)/resolution),
Vector2(float(x)/resolution, float(z+1)/resolution),
Vector2(float(x+1)/resolution, float(z+1)/resolution),
]
for i in [0, 1, 2]:
st.set_uv(uvs[i]); st.add_vertex(positions[i])
for i in [1, 3, 2]:
st.set_uv(uvs[i]); st.add_vertex(positions[i])
st.generate_normals()
return st.commit()
Plug in any FastNoiseLite instance configured with TYPE_SIMPLEX_SMOOTH and a seed, and you get a uniquely shaped patch every time. For a 32x32 resolution, this runs in well under 1 ms on the main thread; for anything above 64x64, call it from a Thread and swap the mesh on completion to avoid frame hitches.
Key performance rule: never regenerate the ArrayMesh every frame. Rebuild only when underlying data changes — on chunk load, on player-triggered deformation, or on game-state transitions.
UV Mapping and Normals for Procedural Meshes
UVs on procedural geometry are yours to define, which is both the power and the trap. A common mistake is mapping UV coordinates to world-space X/Z directly — this works for planar surfaces but breaks texture scaling the moment mesh scale changes. The correct approach is to normalise UVs to [0, 1] over the patch, then scale the texture tiling inside the material using uv1_scale on StandardMaterial3D.
var mat := StandardMaterial3D.new()
mat.albedo_texture = preload("res://textures/ground_albedo.png")
mat.uv1_scale = Vector3(4.0, 4.0, 1.0)
mat.normal_enabled = true
mat.normal_texture = preload("res://textures/ground_normal.png")
mesh_instance.material_override = mat
For normals, SurfaceTool.generate_normals() produces flat normals per triangle by default. For terrain, call generate_tangents() after generate_normals() if you are using a normal map — tangent data is required for correct normal map orientation in Godot 4's PBR pipeline.
| Situation | Call order |
|---|---|
| Flat-shaded geometry | generate_normals() then commit() |
| Normal-mapped surface | generate_normals() then generate_tangents() then commit() |
| Hand-authored normals | Set manually per vertex, skip generate_normals() |
| Smoothed mesh | Call index() before generate_normals() to merge duplicate verts |
Calling index() before generating normals is important for smooth shading: it merges coincident vertices, so shared edges receive averaged normals rather than hard edges between every triangle.
![]()
Collision, LODs, and Performance Tips
A visible mesh with no collision is a ghost. For procedural terrain, create a StaticBody3D with a ConcavePolygonShape3D derived directly from your mesh:
func add_collision(mesh_instance: MeshInstance3D) -> void:
var static_body := StaticBody3D.new()
var collision := CollisionShape3D.new()
collision.shape = mesh_instance.mesh.create_trimesh_shape()
static_body.add_child(collision)
mesh_instance.add_child(static_body)
create_trimesh_shape() is exact but expensive for large meshes. For gameplay surfaces, prefer generating a simplified collision mesh at half the terrain resolution and using that for physics while the full-res mesh handles visuals.
Godot 4 does not yet have a first-class LOD API for custom ArrayMesh the way Unreal's Nanite does, but you can implement manual LODs: generate the same patch at resolution 64, 32, and 16; swap MeshInstance3D.mesh based on camera distance in _process(); and use VisibilityNotifier3D to pause updates on off-screen chunks entirely.
For chunk-based open worlds, maintain a pool of pre-allocated MeshInstance3D nodes and reuse them rather than instantiating new ones per chunk — Godot's renderer handles mesh swaps cheaply but node creation has overhead.
Using Marketplace Assets as Procedural Bases
Procedural geometry shines brightest when combined with high-quality authored assets. A common hybrid workflow: generate terrain geometry at runtime, but decorate it with game-ready rocks, vegetation, and props sourced from the BitSoul marketplace. GLB assets drop directly into Godot 4's import pipeline with PBR materials intact — place them as MeshInstance3D children of your procedural terrain node and they inherit the transform automatically.
For foliage and scattered props, Godot 4's MultiMesh + MultiMeshInstance3D pair with procedural generation cleanly: calculate positions on your terrain mesh using raycasts or normal sampling, pack them into a MultiMesh, and render thousands of instances in a single draw call. Browse the BitSoul marketplace for optimised low-poly vegetation packs built specifically for this workflow.
Quick procedural placement checklist
- Generate terrain mesh and collision first
- Sample surface normals at scatter points to align props to slope
- Use
MultiMeshInstance3Dfor repeating props (rocks, grass tufts) - Use individual
MeshInstance3Dfor unique hero assets - Bake lightmap UVs with
uv2_channels if using baked lighting - Target under 50k tris per terrain chunk on mid-range hardware
Procedural mesh generation feels complex until you understand the three calls that matter — begin(), add_vertex(), commit() — and then it becomes the fastest way to prototype any kind of dynamic geometry in your game. Pair it with strong authored assets from https://bitsoulhosting.com/marketplace and you have a pipeline that scales from a weekend prototype to a shipped title.
Build with real assets: grab game-ready nature 3D models on the BitSoul marketplace and drop them straight into your project.