godot-3d-world-building
3D World Building
Expert guidance for level design with GridMaps, CSG, and environmental setup.
NEVER Do
- NEVER forget to bake GridMap navigation — GridMaps don't auto-generate navigation meshes. Use EditorPlugin or manual NavigationRegion3D.
- NEVER use CSG for final game geometry — CSG is for prototyping. Convert to static meshes for performance (use "Bake CSG Mesh" in editor).
- NEVER scale GridMap cell size after placing tiles — Changing
cell_sizedoesn't update existing tiles, causing misalignment. Set it once at the start. - NEVER use MeshLibrary without collision shapes — Items without collision spawn visual-only geometry that players fall through.
- NEVER enable volumetric fog without DirectionalLight3D — Volumetric fog requires at least one light to scatter. No lights = no visible fog.
- NEVER animate CSG nodes during gameplay — Moving a CSG node within another forces the CPU to recalculate the boolean geometry, causing significant performance drops.
- NEVER place generic logic nodes in a GridMap — GridMap is highly optimized only for meshes, navigation, and collision. It is not a general-purpose system for placing arbitrary node structures on a grid.
- NEVER use non-manifold meshes in CSG — If you import a custom mesh for CSGMesh3D, it must be manifold (closed, no self-intersections, no interior faces, no negative volume). Non-manifold meshes will break the CSG algorithm and are completely unsupported.
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
collision_gen.gd
Automatic collision shape generation from meshes. Use when importing models without collision or for procedural geometry.
gridmap_runtime_builder.gd
Runtime GridMap tile placement with batch operations and auto-navigation baking.
csg_bake_tool.gd
EditorScript to bake CSG geometry to static meshes with proper materials and collision. Use when finalizing level prototypes.
safe_csg_baking.gd
Expert technique for safe CSG baking. Awaits the end of the frame before extracting baked meshes to avoid empty data.
lod_manager.gd
Level-of-detail switching based on camera distance. Manages mesh swapping and visibility for large outdoor scenes.
occlusion_setup.gd
OccluderInstance3D configuration for manual occlusion culling. Use for indoor levels with many rooms.
GridMap Fundamentals
Setup Workflow
# 1. Create MeshLibrary resource (editor)
# Scene → New Inherits Scene → Create Grid-aligned meshes
# Scene → Convert To → MeshLibrary...
# 2. Assign to GridMap
extends GridMap
func _ready() -> void:
mesh_library = load("res://tilesets/dungeon_library.tres")
cell_size = Vector3(2, 2, 2) # Must match library cell size
Cell Manipulation
# gridmap_builder.gd
extends GridMap
# Place cell
func place_tile(grid_pos: Vector3i, tile_index: int) -> void:
set_cell_item(grid_pos, tile_index)
# Get cell
func get_tile(grid_pos: Vector3i) -> int:
return get_cell_item(grid_pos) # Returns index or INVALID_CELL_ITEM (-1)
# Remove cell
func remove_tile(grid_pos: Vector3i) -> void:
set_cell_item(grid_pos, INVALID_CELL_ITEM)
# Rotate cell (0-23, see GridMap.ROTATION_* constants)
func place_rotated(grid_pos: Vector3i, tile_index: int, orientation: int) -> void:
set_cell_item(grid_pos, tile_index, orientation)
Coordinate Conversion
# World position ↔ Grid coordinates
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
var camera := get_viewport().get_camera_3d()
var from := camera.project_ray_origin(event.position)
var to := from + camera.project_ray_normal(event.position) * 1000
var space := get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(from, to)
var result := space.intersect_ray(query)
if result:
var world_pos: Vector3 = result.position
var grid_pos := local_to_map(to_local(world_pos))
place_tile(grid_pos, 0) # Place tile at clicked position
# Grid → World
func get_cell_center(grid_pos: Vector3i) -> Vector3:
return to_global(map_to_local(grid_pos))
MeshLibrary Creation
Collision Setup
# tile_scene.tscn (before converting to MeshLibrary)
# Root: Node3D
# ├─ MeshInstance3D (visual)
# └─ StaticBody3D (collision)
# └─ CollisionShape3D
# CRITICAL: StaticBody3D must be sibling/child for GridMap to detect collision
Item Metadata
# Access MeshLibrary item data
func get_tile_name(tile_index: int) -> String:
return mesh_library.get_item_name(tile_index)
# Custom metadata (stored in MeshLibrary resource)
# Use item_set_name() in editor script to organize
CSG (Constructive Solid Geometry)
Boolean Operations
CSG Combiner3D
├─ CSGBox3D (Operation: Union) # Base room
├─ CSGBox3D (Operation: Subtraction) # Door cutout
└─ CSGSphere3D (Operation: Intersection) # Rounded corner
CSG Brush Types
# CSGBox3D - Room primitives
var room := CSGBox3D.new()
room.size = Vector3(10, 5, 10)
# CSGCylinder3D - Pillars
var pillar := CSGCylinder3D.new()
pillar.radius = 0.5
pillar.height = 5.0
# CSGSphere3D - Domes
var dome := CSGSphere3D.new()
dome.radius = 3.0
dome.radial_segments = 16
dome.rings = 8
# CSGPolygon3D - Extruded 2D shapes
var arch := CSGPolygon3D.new()
arch.polygon = PackedVector2Array([
Vector2(-1, 0), Vector2(-1, 2), Vector2(1, 2), Vector2(1, 0)
])
arch.depth = 0.5
CSG Performance
# ❌ BAD: Use CSG at runtime (slow)
func _ready() -> void:
var csg := CSGBox3D.new()
add_child(csg) # Recalculates mesh every frame
# ✅ GOOD: Bake to MeshInstance3D (editor only)
# Select CSG node → Mesh → Bake Mesh Instance
# Then delete CSG node
# ✅ ALSO GOOD: Use CSG for level editor, bake on export
WorldEnvironment Setup
Sky Configuration
# world_env.gd
extends WorldEnvironment
func _ready() -> void:
var env := Environment.new()
environment = env
# Procedural sky
env.background_mode = Environment.BG_SKY
var sky := Sky.new()
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = Color(0.4, 0.6, 1.0) # Blue
sky_mat.sky_horizon_color = Color(0.8, 0.9, 1.0) # Lighter
sky_mat.ground_bottom_color = Color(0.2, 0.2, 0.1)
sky_mat.sun_angle_max = 30.0
sky.sky_material = sky_mat
env.sky = sky
HDRI Skybox
# For realistic lighting
var env := environment
env.background_mode = Environment.BG_SKY
var sky := Sky.new()
var panorama := PanoramaSkyMaterial.new()
panorama.panorama = load("res://hdri/sunset.hdr") # Equirectangular HDR image
sky.sky_material = panorama
env.sky = sky
# Sky contribution to ambient light
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_sky_contribution = 1.0
Fog & Atmosphere
Exponential Fog
extends WorldEnvironment
func _ready() -> void:
var env := environment
env.fog_enabled = true
env.fog_mode = Environment.FOG_MODE_EXPONENTIAL
env.fog_density = 0.01 # 0.0-1.0
env.fog_light_color = Color(0.9, 0.95, 1.0) # Blueish
env.fog_light_energy = 1.0
Depth Fog
# Distance-based fog
env.fog_enabled = true
env.fog_mode = Environment.FOG_MODE_DEPTH
env.fog_depth_begin = 50.0 # Start distance
env.fog_depth_end = 200.0 # End distance (fully opaque)
env.fog_depth_curve = 1.0 # Falloff curve
Volumetric Fog
# Requires DirectionalLight3D for scattering
env.volumetric_fog_enabled = true
env.volumetric_fog_density = 0.05
env.volumetric_fog_albedo = Color(0.9, 0.9, 1.0)
env.volumetric_fog_emission = Color.BLACK
env.volumetric_fog_gi_inject = 1.0 # How much GI affects fog
# Performance settings
env.volumetric_fog_temporal_reprojection_enabled = true
env.volumetric_fog_detail_spread = 2.0
Level Streaming / LOD
GridMap Chunking
# level_streamer.gd - Load/unload GridMap chunks based on player position
extends Node3D
@export var chunk_size := 32 # Grid cells per chunk
@export var load_radius := 2 # Chunks to keep loaded
var loaded_chunks := {} # Vector2i → GridMap
func _process(delta: float) -> void:
var player_pos := get_player_position()
var player_chunk := Vector2i(
int(player_pos.x / (chunk_size * cell_size.x)),
int(player_pos.z / (chunk_size * cell_size.z))
)
# Load nearby chunks
for x in range(-load_radius, load_radius + 1):
for z in range(-load_radius, load_radius + 1):
var chunk_coord := player_chunk + Vector2i(x, z)
if chunk_coord not in loaded_chunks:
load_chunk(chunk_coord)
# Unload distant chunks
for chunk_coord in loaded_chunks.keys():
var dist := chunk_coord.distance_to(player_chunk)
if dist > load_radius:
unload_chunk(chunk_coord)
func load_chunk(coord: Vector2i) -> void:
var gridmap := GridMap.new()
gridmap.mesh_library = preload("res://library.tres")
add_child(gridmap)
loaded_chunks[coord] = gridmap
# TODO: Load chunk data from file/database
# gridmap.set_cell_item(...)
func unload_chunk(coord: Vector2i) -> void:
var gridmap: GridMap = loaded_chunks[coord]
gridmap.queue_free()
loaded_chunks.erase(coord)
Procedural Generation
Random Dungeon with GridMap
# dungeon_generator.gd
extends GridMap
enum Tile { FLOOR, WALL, DOOR }
func generate_room(pos: Vector3i, size: Vector3i) -> void:
# Fill with floor
for x in range(size.x):
for z in range(size.z):
set_cell_item(pos + Vector3i(x, 0, z), Tile.FLOOR)
# Add walls
for x in range(size.x):
set_cell_item(pos + Vector3i(x, 0, 0), Tile.WALL) # North
set_cell_item(pos + Vector3i(x, 0, size.z - 1), Tile.WALL) # South
for z in range(size.z):
set_cell_item(pos + Vector3i(0, 0, z), Tile.WALL) # West
set_cell_item(pos + Vector3i(size.x - 1, 0, z), Tile.WALL) # East
func _ready() -> void:
generate_room(Vector3i(0, 0, 0), Vector3i(10, 1, 10))
Edge Cases
GridMap Cells Not Colliding
# Problem: MeshLibrary items lack collision
# Solution: Ensure StaticBody3D + CollisionShape3D in source scene
# Verify in code:
var item_shapes := mesh_library.get_item_shapes(tile_index)
if item_shapes.is_empty():
push_error("Tile %d has no collision!" % tile_index)
CSG Mesh Flickering
# Problem: Z-fighting between overlapping CSG operations
# Solution: Add small offset (0.001) to prevent exact overlap
var box := CSGBox3D.new()
box.size = Vector3(10, 5, 10)
var cutout := CSGBox3D.new()
cutout.operation = CSGShape3D.OPERATION_SUBTRACTION
cutout.size = Vector3(2, 3, 2.002) # Slightly larger depth
Expert Techniques & Optimizations
1. Spatially Partitioning MultiMeshes
The major drawback of MultiMesh is that individual instances cannot be frustum or occlusion culled; the entire cluster is drawn based on the bounding box of the MultiMeshInstance3D. To solve this, partition your thousands of objects into several regional MultiMeshInstance3D nodes so the engine can cull entire regions at once.
Reference
- Master Skill: godot-master