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.

đ 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
PhysicsServer3Dalready provides the physics and a baked container would duplicate it. Calling it from code is refused with a message. - Switching to
SERVERSremoves an existing<Manager>_Collisionscontainer, since keeping it would duplicate the physics.Ctrl+Zdoes not bring it back â switch back toMESH_NODESand 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 StaticBody3Dstops 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,LAZYandNONEdo nothing inMESH_NODES. They decide when the SERVERS backend creates itsPhysicsServer3Dbodies.MESH_NODEShas no such step: its collision comes fromStaticBody3Dnodes you create yourself with Bake Live Collisions, and it exists from the moment you bake it, whateverRuntime Collisionis 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
LAZYsomething 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
Works perfectly. Many thanks for this terrain builder. I have tried many other ones but they all have no option for low poly style.
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.
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.