Description
Changelog
Reviews (3)

Low Poly Terrain Builder

An intuitive, optimized, and robust 3D terrain sculpting tool tailored for creating organic low-poly landscapes inside the Godot 4 editor.

💬 Bugs, requests and ideas

Found something broken, missing a feature, or have an idea for the tool? Please open an issue:

https://github.com/78sForge/LowPolyTerrainBuilder

Bug reports are most useful with your Godot version, the renderer you are on, whether the terrain uses the MESH_NODES or the SERVERS backend, and the steps that reproduce it.

✅ Update information for v1.1.0 (The Server Backend Update):

Version 1.1.0 adds a second, node-free rendering and physics pipeline alongside the classic one, together with runtime collision control and a world-space height query. Key additions:

  • Backend Selector: A new 'terrain_backend' option at the top of the inspector picks the pipeline.
  • Node-Free Pipeline: SERVERS registers chunks straight with RenderingServer and PhysicsServer3D.
  • Lossless Switching: Both backends read the same data, so switching back and forth alters nothing.
  • Full Node Parity: Transform, scale, visibility and world membership are mirrored explicitly.
  • Group Compatibility: Raycasts still answer 'collider.is_in_group("Wall")' without any glue code.
  • Collision Policy: 'runtime_collision' offers PREBUILT (default), LAZY and NONE.
  • Retained Colliders: LAZY parks chunks leaving the radius, bounded by 'collision_retain_limit'.
  • Culling Targets: Assign nodes to 'collision_cull_targets' and the manager drives the culling.
  • Collider Overlay: 'collision_debug_draw' shows live colliders that Godot itself cannot display.
  • Runtime Height Query: 'get_height_at_world_coords(x, z)' resolves in O(1) with no physics query.
  • Chunk Grid Overlay: 'show_chunk_grid' replaces the per-chunk labels and stays legible at any zoom.
  • Chunk Activation Undo: Activate Chunk and Deactivate Chunk strokes are now undoable.
  • Brush Handling: The ring hides outside the viewport, and its reach is no longer capped.
  • Brush Readout: The caption lists the falloff value, and only the settings the active tool reads.
  • Modifier Preview: Holding Shift recolours and recaptions the brush to the tool it will perform.
  • Held-Button Sculpting: Holding the mouse button keeps sculpting without moving the cursor.
  • Frame-Paced Strokes: The brush applies once per frame instead of once per motion event.
  • Resting Strokes: A stroke held on one spot applies at reduced strength instead of full force.
  • Brush Silhouette: A coloured outline over a barely tinted disc keeps the terrain readable.
  • Visible Chunk Markers: Deactivated chunks read through other terrain, just like the grid does.
  • Adjustable Brush Opacity: Two Editor Settings sliders, for terrain the default washes out on.
  • Culling Toggle: 'enable_collision_culling' hides the targets and radius where they are unused.
  • Configurable Shortcuts: Brush tools ship unassigned to avoid clashing with Godot's viewport keys.
  • Shared Geometry Factory: One stateless builder guarantees identical meshes across both backends.
  • Uncached Triangle Soups: Colliders and picking avoid the permanent cache inside 'get_faces()'.
  • Leaner Chunks: Height windows and preview resources are no longer duplicated per chunk.
  • Layer Picker Fix: 'collision_layer' now uses the 3D physics layers instead of the 2D ones.
  • Setting Persistence: Inspector-hidden backend settings survive saving and reloading a scene.
  • Child Node Safety: Nodes parented to the manager are no longer destroyed on scene start.
  • Water Shader: Correct Compatibility-renderer depth and periodic noise without low-speed jitter.
  • Cleanup: Removed the unused non-Delaunay mesh generator and the per-chunk 3D labels.

When switching an existing terrain to SERVERS: the baked '_Collisions' container is removed and the bake button is hidden, because bodies are created at runtime instead. Raycast hits then resolve to the manager rather than to a StaticBody3D.

Low Poly Terrain Demo


🚀 Key Features

  • Dynamic Chunk Management: Grid blocks are handled fluidly without cluttering scene files.
  • Organic Delaunay Topology: Creates organic triangle networks for typical landscape looks.
  • Integrated Sculpting Brushes: Includes intuitive Raise, Lower, Flatten, and Smooth tools.
  • Procedural Noise Injector: Generates seamless Perlin or Cellular landscapes instantly.
  • Ergonomic Viewport Toolbar: Adds a horizontal radio-button menu with clear SVG icons.
  • Dynamic Color Indicators: Features a colored 3D brush circle aligned to active tool colors.
  • Crisp 2D Mouse Display: Shows current tool name, radius, and strength directly at cursor.
  • Laptop-Friendly Radius Control: Scale your brush radius fluidly using comma and period keys.
  • Production-Ready GLTF Export: Exports active terrain chunks directly into a standalone asset.
  • Lossless Grid Migration: Safe resizing of terrain dimensions without losing existing data.
  • Dynamic Live Physics Baking: Generates static 3D colliders instantly for active sections.
  • Multi-Backend Stability: Fully optimized for Forward+, Mobile, and Compatibility renderers.
  • Selectable Submission Backend: Render chunks as nodes or register them with the servers.
  • Radius-Based Collision Culling: Enables physics near a target without per-collider scans.
  • Full Editor Undo: Sculpting, noise, smoothing and chunk activation all reverse with Ctrl+Z.

🧱 Terrain Backend

The Terrain Backend setting sits at the very top of the inspector and decides how chunks reach the engine. Both modes read from the same height data, so switching is lossless in either direction and can be done at any time.

MESH_NODES (default) SERVERS
Rendering One MeshInstance3D child per chunk RenderingServer instance per chunk, no nodes
Collision Baked StaticBody3D container next to the manager PhysicsServer3D static bodies, created at runtime
Node count One node per chunk Zero chunk nodes
Draw calls Identical Identical
Editor selection Chunks are clickable in the viewport Chunks are not clickable (see limitations)

SERVERS does not reduce draw calls, primitives or GPU frame time. MeshInstance3D is a thin wrapper around the very same RenderingServer calls. What it removes is the per-chunk node: memory, scene-tree transform propagation and rebuild cost. That matters at high chunk counts and it is what makes cheap streaming possible.

Frame time

Draw calls, primitives and GPU time are identical by construction, and in a stationary scene the two backends perform the same. SERVERS tends to come out slightly ahead, simply because there are no chunk nodes for the engine to carry.

There is one case where SERVERS is genuinely slower. If the manager, or any ancestor, changes its transform, every chunk instance has to be re-pushed, and that loop runs in GDScript rather than in Godot's C++ scene-graph propagation. The gap grows with the chunk count and is substantial.

The cost applies once per frame (Godot coalesces transform notifications) and only while the manager is actually moving. A stationary terrain pays nothing. If you animate a terrain of several thousand chunks, prefer MESH_NODES.

Memory

Collision, not rendering, dominates the memory profile: a concave collider costs considerably more than the chunk mesh it was built from, and the totals are driven almost entirely by how many colliders exist.

Compare like with like: SERVERS + PREBUILT against baked MESH_NODES, where it comes out a little cheaper because the collider nodes are gone. An unbaked MESH_NODES terrain has no collision at all, so it is not a fair baseline.

Colliders are built from the mesh without going through ArrayMesh.get_faces(), which caches its triangle soup inside the mesh permanently. That detail matters more than it sounds: with the cache in play a released collider gave back almost nothing, so memory under LAZY crept up towards the PREBUILT figure as a target explored the world. Built the other way, LAZY stays flat no matter how much of the terrain has been visited, and releasing genuinely returns the memory.

SERVERS mode limitations

  • Chunks cannot be picked or framed in the editor viewport, because server instances are invisible to the editor's click-selection.
  • Bake Live Collisions is hidden, since PhysicsServer3D already provides the physics and a baked container would duplicate it. Calling it from code is refused with a message.
  • Switching to SERVERS removes an existing <Manager>_Collisions container, since keeping it would duplicate the physics. Ctrl+Z does not bring it back — switch back to MESH_NODES and press Bake Live Collisions to regenerate it. Nothing is lost: the container is derived from the terrain data, not authored content.
  • The manager's child count drops to one (Terrain_Assets). This is expected, not a defect.
  • Game code that checks collider is StaticBody3D stops matching, because hits resolve to the manager. Check the collision group instead (see below).

🎯 Collision Culling

Collision is kept loaded only around the things that need it, and there are two ways to drive that. Both work in either backend.

Assign a target. Drop your player into Collision Cull Targets in the inspector and you are done - no glue code at all. The manager follows it once per physics frame and skips the whole pass while no target has crossed a chunk boundary, so standing still costs nothing. Several targets are allowed; the loaded region is the union of their radii.

An inspector reference can only point inside the same scene, so a player spawned at runtime is registered in code instead:

terrain_manager.add_culling_target(player)
terrain_manager.remove_culling_target(player)   # on despawn

Or drive it yourself with the call the manager uses internally, for example from a level controller:

func _physics_process(_delta: float) -> void:
    if is_instance_valid(player):
        terrain_manager.update_collision_culling(player.global_position, 40.0)

For several centres at once - split-screen players, companion NPCs - the enabled set is the union of all their radii:

terrain_manager.update_collision_culling_multi(
    [player_a.global_position, player_b.global_position], 40.0
)

That is the whole API. Four things are worth knowing about it:

Positions are world space, the radius is metres. The manager converts into its own local space, so a moved, rotated or scaled terrain needs no preparation on your side.

Call it every physics frame, not sparingly. There is no internal throttling and none is needed: affected chunks are derived arithmetically from the regular grid rather than by measuring the distance to every collider, cost scales with the radius instead of the world size, and only the delta reaches the physics engine. Calling less often saves almost nothing and risks gaps.

Under LAZY the radius is lead time, not a display setting. A collider is built on first contact, inside the physics step, so the radius has to be wide enough that a chunk is finished before your player arrives. Fast movers need more than walkers.

Leave Enable Collision Culling off while driving it yourself. It only governs the automatic target-following pass; with it off the manager runs no _physics_process at all, so you do not pay for a pass you are replacing. Manual calls are unaffected by it.

One asymmetry to watch: update_collision_culling_multi() with an empty array does nothing - it does not release anything. If every target disappears and you want the collision gone, call update_collision_culling() with a far-away position, or set Runtime Collision to NONE.

Runtime Collision applies to the SERVERS backend only

PREBUILT, LAZY and NONE do nothing in MESH_NODES. They decide when the SERVERS backend creates its PhysicsServer3D bodies. MESH_NODES has no such step: its collision comes from StaticBody3D nodes you create yourself with Bake Live Collisions, and it exists from the moment you bake it, whatever Runtime Collision is set to.

This is why the setting disappears from the inspector while MESH_NODES is active - it is not missing, it simply has nothing to act on. The value you picked is still stored and comes back when you switch to SERVERS.

Culling itself is a different question and works in both backends. In MESH_NODES it toggles disabled on the baked CollisionShape3D nodes; in SERVERS it acts on the body RIDs. So a MESH_NODES terrain can absolutely use Collision Cull Targets - it just cannot choose when colliders come into existence, because baking already decided that.

runtime_collision (SERVERS only) Behaviour of update_collision_culling()
PREBUILT (default) Colliders exist everywhere; the radius only enables/disables them. Cheapest to toggle, highest memory.
LAZY A collider is built the first time a target comes near. Leaving parks it rather than destroying it, so returning is cheap. Lowest memory.
NONE No effect; there is no runtime collision.
(MESH_NODES) Setting ignored. Culling enables and disables the baked collider nodes.

Choosing between PREBUILT and LAZY (SERVERS only)

The two differ in when a collider is created, and that is a direct trade of frame time against memory.

PREBUILT builds every collider once and never touches them again. Memory is then proportional to the total number of chunks, and the per-frame cost is nil - the culling radius only flips colliders on and off, which is close to free.

LAZY builds a collider the first time a target comes near the chunk. Memory is then proportional to the region actually reached rather than to the whole world, which is what makes very large terrains feasible at all. The price is paid on first contact: a concave shape and its acceleration structure have to be constructed, and that happens inside the physics step.

Leaving the radius only parks a collider - it keeps its shape but takes part in no collision test - so returning to a chunk costs a flag flip instead of a rebuild. That matters because a target moving through the world keeps crossing chunks it has already visited. Collision Retain Limit caps how many parked colliders stay resident; beyond it the least recently parked one is genuinely released. Set it to zero to release immediately instead.

Two rules follow from that:

memory        ∝  colliders resident      (all chunks, or the reached region plus parked ones)
frame cost    ∝  chunks reached for the FIRST time per second × cost of building one collider

The second one scales with the radius perimeter times the target's speed. Enlarging collision_cull_radius therefore makes LAZY more expensive, not cheaper - it buys earlier collider availability, not less work. Denser chunks (a larger chunk_size) raise the cost of each individual build, since the shape has more triangles.

Situation Recommendation Reasoning
The whole terrain's colliders fit in memory PREBUILT Nothing is ever rebuilt, so physics time matches the node backend while the nodes themselves are gone.
The terrain is too large for that LAZY Keeping only the reachable region resident is the only way to fit, and is what the policy exists for.
Targets move slowly relative to the chunk size LAZY Few chunks enter per second, so the build cost stays negligible.
Targets move fast - vehicles, aircraft, teleports PREBUILT A fast mover reaches many new chunks per second, and each first contact pays a full build.
Consistent frame times matter more than memory PREBUILT LAZY concentrates its work: most frames build nothing, then one frame builds several colliders at once.
No gameplay collision needed at all NONE Rendering only, with no physics memory whatsoever.

If you are unsure, start with PREBUILT and only move to LAZY once memory actually becomes the constraint. PREBUILT is the default for that reason.

With LAZY something must drive the culling - either a target or your own call - otherwise the terrain has no collision whatsoever. With neither in place the manager pushes a warning two seconds in.

In MESH_NODES the call toggles disabled on the baked colliders instead, so the same setup works in both backends. Baking produces a Static_Chunk_<x>_<z> body holding a Chunk_<x>_<z>_Col shape, so a collider can be traced back to its chunk from the node name alone. Scenes baked before that naming still work; the lookup resolves shapes by type.

Collision Cull Radius is measured in metres and pre-filled with two chunk edge lengths (chunk_size * cell_size * 2). It is re-derived whenever the terrain dimensions change, but only while it still matches the previously derived value, so an override you set on purpose is never overwritten. Bigger is safer: collision has to be present before a target arrives, so fast movers need more lead than walkers. Cost grows with the area, so quadratically.

Seeing the colliders

Godot's Debug → Visible Collision Shapes cannot show SERVERS colliders. That feature is implemented inside CollisionShape3D, and this backend deliberately has no such node.

Collision Debug Draw fills the gap. It renders the same geometry the built-in view would, taken straight from Shape3D.get_debug_mesh(), as a translucent cyan wireframe:

Value Behaviour
FOLLOW_DEBUG_MENU (default) Follows Debug → Visible Collision Shapes, exactly like node colliders do.
ALWAYS Always drawn, including in a normal run.
NEVER Never drawn.

Because only chunks that actually own a collider light up, this is the direct way to watch LAZY at work: the lit region is the culling radius. Turn it off for profiling, since the wireframe adds line geom

Changelog for version v1.1.0

No changelog provided for this version.

Reviews

Recommended by Jacob - 22 July 2026

Works perfectly. Many thanks for this terrain builder. I have tried many other ones but they all have no option for low poly style.

Not recommended by Clearleaf - 22 July 2026

I tried this because there aren't other terrain systems focused completely on lofi results. Sadly the performance is very low, bringing my pc to 100fps just from rendering one chunk of default size and nothing else. It also makes the editor slow. Other terrain systems can have terrains over 4x larger with 700fps on my weak PC. The AI-ness of this is very apparent in multiple other ways.

Recommended by Chris - 26 July 2026

I tested it briefly and I had no performance problems with version 1.0.12. For me it works as expected.

Login to write a review.

Consider supporting the creators!

If you enjoyed this asset consider supporting its creator. Follow the link below.