Skip to content
← Back to Blog 3d-modeling

Procedural Mesh Generation at Runtime in Godot 4: Build Dynamic 3D Assets with ArrayMesh

By BitSoul3D5 min read324 views

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.

Procedural Mesh Generation at Runtime in Godot 4: Build Dynamic 3D Assets with ArrayMesh

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.

Understanding ArrayMesh and SurfaceTool — illustrated

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.

SituationCall order
Flat-shaded geometrygenerate_normals() then commit()
Normal-mapped surfacegenerate_normals() then generate_tangents() then commit()
Hand-authored normalsSet manually per vertex, skip generate_normals()
Smoothed meshCall 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.

UV Mapping and Normals for Procedural Meshes — illustrated

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

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.

Tags: nature

Skip the modeling — download it instead

A free BitSoul3D account gets you 2 GLB downloads every month for personal use plus 25 one-time AI Engine credits, no card required. PBR-textured GLB downloads with a full 3D preview before you buy, for Unreal, Unity, Godot or Blender — OBJ and 3D-printable STL come with any purchase or paid plan.

Browse 1,051 models — from $4.99 → or start free (2 downloads a month)