@tool
class_name TerrainGenerator extends StaticBody3D
## Generates a subdivided plane with low-frequency noise displacement.
## Attach to a StaticBody3D — creates MeshInstance3D + CollisionShape3D children.
@export var terrain_size: float = 500.0:
set(v):
terrain_size = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var subdivisions: int = 128:
set(v):
subdivisions = clampi(v, 2, 256)
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export_group("Macro Terrain (large features)")
@export var macro_height: float = 60.0:
set(v):
macro_height = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var macro_frequency: float = 0.003:
set(v):
macro_frequency = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var macro_octaves: int = 2:
set(v):
macro_octaves = clampi(v, 1, 4)
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export_group("Detail Noise (surface variation)")
@export var detail_height: float = 4.0:
set(v):
detail_height = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var detail_frequency: float = 0.03:
set(v):
detail_frequency = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var detail_octaves: int = 3:
set(v):
detail_octaves = clampi(v, 1, 6)
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export_group("General")
@export var noise_seed: int = 42:
set(v):
noise_seed = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var flat_center_radius: float = 3.0:
set(v):
flat_center_radius = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export_group("Scattered Cubes")
@export var cube_count: int = 12:
set(v):
cube_count = clampi(v, 0, 50)
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var cube_min_size: float = 9.0:
set(v):
cube_min_size = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var cube_max_size: float = 18.0:
set(v):
cube_max_size = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export_subgroup("Small Scattered Cubes")
@export var small_cube_count: int = 40:
set(v):
small_cube_count = clampi(v, 0, 200)
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var small_cube_min_size: float = 2.0:
set(v):
small_cube_min_size = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var small_cube_max_size: float = 8.0:
set(v):
small_cube_max_size = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var cube_seed: int = 7:
set(v):
cube_seed = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export_group("Material")
@export var terrain_color: Color = Color(0.3, 0.42, 0.25, 1.0):
set(v):
terrain_color = v
if Engine.is_editor_hint() and is_inside_tree():
_rebuild()
@export var rebuild: bool = false:
set(v):
if v:
_rebuild()
rebuild = false
var _mesh_instance: MeshInstance3D = null
var _collision: CollisionShape3D = null
func _ready() -> void:
# Small delay to ensure physics world is ready
call_deferred("_rebuild")
func _rebuild() -> void:
# Macro noise — big hills, valleys, mountains
var macro_noise := FastNoiseLite.new()
macro_noise.seed = noise_seed
macro_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
macro_noise.frequency = macro_frequency
macro_noise.fractal_octaves = macro_octaves
macro_noise.fractal_lacunarity = 2.0
macro_noise.fractal_gain = 0.5
# Detail noise — surface bumps and texture
var detail_noise := FastNoiseLite.new()
detail_noise.seed = noise_seed + 1
detail_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
detail_noise.frequency = detail_frequency
detail_noise.fractal_octaves = detail_octaves
detail_noise.fractal_lacunarity = 2.0
detail_noise.fractal_gain = 0.5
var half: float = terrain_size / 2.0
var step: float = terrain_size / float(subdivisions)
var vert_count: int = (subdivisions + 1) * (subdivisions + 1)
var tri_count: int = subdivisions * subdivisions * 2
# Arrays for SurfaceTool
var positions := PackedVector3Array()
var normals := PackedVector3Array()
var uvs := PackedFloat32Array()
positions.resize(vert_count)
normals.resize(vert_count)
# Generate vertex positions with noise displacement
for z_i in range(subdivisions + 1):
for x_i in range(subdivisions + 1):
var idx: int = z_i * (subdivisions + 1) + x_i
var x: float = -half + x_i * step
var z: float = -half + z_i * step
# Two-layer noise: macro for landforms + detail for surface
var y: float = macro_noise.get_noise_2d(x, z) * macro_height \
+ detail_noise.get_noise_2d(x, z) * detail_height
# Flatten center area so character spawns on level ground
var dist_from_center: float = Vector2(x, z).length()
if dist_from_center < flat_center_radius:
y = 0.0
elif dist_from_center < flat_center_radius + 2.0:
var blend: float = (dist_from_center - flat_center_radius) / 2.0
y *= smoothstep(0.0, 1.0, blend)
positions[idx] = Vector3(x, y, z)
# Compute normals from cross products of adjacent vertices
for z_i in range(subdivisions + 1):
for x_i in range(subdivisions + 1):
var idx: int = z_i * (subdivisions + 1) + x_i
var p: Vector3 = positions[idx]
# Get neighbors (clamped to edges)
var left: Vector3 = positions[z_i * (subdivisions + 1) + maxi(x_i - 1, 0)]
var right: Vector3 = positions[z_i * (subdivisions + 1) + mini(x_i + 1, subdivisions)]
var up: Vector3 = positions[maxi(z_i - 1, 0) * (subdivisions + 1) + x_i]
var down: Vector3 = positions[mini(z_i + 1, subdivisions) * (subdivisions + 1) + x_i]
var dx: Vector3 = right - left
var dz: Vector3 = down - up
normals[idx] = dz.cross(dx).normalized()
# Build indices
var indices := PackedInt32Array()
indices.resize(tri_count * 3)
var ii: int = 0
for z_i in range(subdivisions):
for x_i in range(subdivisions):
var tl: int = z_i * (subdivisions + 1) + x_i
var tr: int = tl + 1
var bl: int = (z_i + 1) * (subdivisions + 1) + x_i
var br: int = bl + 1
# Triangle 1 (CCW winding for Godot)
indices[ii] = tl; ii += 1
indices[ii] = tr; ii += 1
indices[ii] = bl; ii += 1
# Triangle 2
indices[ii] = tr; ii += 1
indices[ii] = br; ii += 1
indices[ii] = bl; ii += 1
# Build ArrayMesh
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = positions
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_INDEX] = indices
# UVs
var uv_array := PackedVector2Array()
uv_array.resize(vert_count)
for z_i in range(subdivisions + 1):
for x_i in range(subdivisions + 1):
var idx: int = z_i * (subdivisions + 1) + x_i
uv_array[idx] = Vector2(float(x_i) / subdivisions, float(z_i) / subdivisions)
arrays[Mesh.ARRAY_TEX_UV] = uv_array
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
# Material
var mat := StandardMaterial3D.new()
mat.albedo_color = terrain_color
mesh.surface_set_material(0, mat)
# Apply to MeshInstance3D child
if _mesh_instance == null:
_mesh_instance = _find_or_create("TerrainMesh", "MeshInstance3D") as MeshInstance3D
_mesh_instance.mesh = mesh
# Collision from mesh
if _collision == null:
_collision = _find_or_create("TerrainCollision", "CollisionShape3D") as CollisionShape3D
var shape := ConcavePolygonShape3D.new()
shape.set_faces(mesh.get_faces())
_collision.shape = shape
# Scatter large cubes at macro peaks
_spawn_cubes(macro_noise, detail_noise)
func _spawn_cubes(macro_n: FastNoiseLite, detail_n: FastNoiseLite) -> void:
# Remove old cubes
for child in get_children():
if child.name.begins_with("Cube_") or child.name.begins_with("SmCube_"):
child.queue_free()
var rng := RandomNumberGenerator.new()
rng.seed = cube_seed
var half: float = terrain_size / 2.0
var sample_step: float = 8.0
# --- Large cubes at peaks, embedded 5-20m into terrain ---
var peaks: Array[Vector3] = []
var x: float = -half + 10.0
while x < half - 10.0:
var z: float = -half + 10.0
while z < half - 10.0:
if Vector2(x, z).length() < flat_center_radius + 10.0:
z += sample_step
continue
var h: float = macro_n.get_noise_2d(x, z) * macro_height
var h_l: float = macro_n.get_noise_2d(x - sample_step, z) * macro_height
var h_r: float = macro_n.get_noise_2d(x + sample_step, z) * macro_height
var h_u: float = macro_n.get_noise_2d(x, z - sample_step) * macro_height
var h_d: float = macro_n.get_noise_2d(x, z + sample_step) * macro_height
if h > h_l and h > h_r and h > h_u and h > h_d and h > macro_height * 0.2:
var full_h: float = h + detail_n.get_noise_2d(x, z) * detail_height
peaks.append(Vector3(x, full_h, z))
z += sample_step
x += sample_step
_shuffle(peaks, rng)
var count: int = mini(cube_count, peaks.size())
for i in range(count):
var peak: Vector3 = peaks[i]
var cube_size: float = rng.randf_range(cube_min_size, cube_max_size)
var embed: float = rng.randf_range(5.0, 20.0)
_create_cube("Cube_%d" % i, cube_size,
Vector3(peak.x, peak.y + cube_size * 0.5 - embed, peak.z),
Vector3(rng.randf_range(0, TAU), rng.randf_range(0, TAU), rng.randf_range(0, TAU)),
rng)
# --- Small cubes scattered everywhere, embedded 5-25% of their size ---
for i in range(small_cube_count):
var sx: float = rng.randf_range(-half + 5.0, half - 5.0)
var sz: float = rng.randf_range(-half + 5.0, half - 5.0)
if Vector2(sx, sz).length() < flat_center_radius + 5.0:
continue
var terrain_y: float = macro_n.get_noise_2d(sx, sz) * macro_height \
+ detail_n.get_noise_2d(sx, sz) * detail_height
var cube_size: float = rng.randf_range(small_cube_min_size, small_cube_max_size)
var embed_frac: float = rng.randf_range(0.05, 0.25)
var embed_depth: float = cube_size * embed_frac
_create_cube("SmCube_%d" % i, cube_size,
Vector3(sx, terrain_y + cube_size * 0.5 - embed_depth, sz),
Vector3(rng.randf_range(0, TAU), rng.randf_range(0, TAU), rng.randf_range(0, TAU)),
rng)
func _create_cube(cube_name: String, cube_size: float, pos: Vector3, rot: Vector3, rng: RandomNumberGenerator) -> void:
var body := StaticBody3D.new()
body.name = cube_name
var box_mesh := BoxMesh.new()
box_mesh.size = Vector3(cube_size, cube_size, cube_size)
var mesh_inst := MeshInstance3D.new()
mesh_inst.mesh = box_mesh
var cube_mat := StandardMaterial3D.new()
cube_mat.albedo_color = Color(
rng.randf_range(0.3, 0.6),
rng.randf_range(0.3, 0.6),
rng.randf_range(0.3, 0.6))
box_mesh.material = cube_mat
var col := CollisionShape3D.new()
var box_shape := BoxShape3D.new()
box_shape.size = Vector3(cube_size, cube_size, cube_size)
col.shape = box_shape
body.position = pos
body.rotation = rot
add_child(body)
body.add_child(mesh_inst)
body.add_child(col)
func _shuffle(arr: Array[Vector3], rng: RandomNumberGenerator) -> void:
for i in range(arr.size() - 1, 0, -1):
var j: int = rng.randi_range(0, i)
var tmp: Vector3 = arr[i]
arr[i] = arr[j]
arr[j] = tmp
func _find_or_create(child_name: String, type_name: String) -> Node:
var existing: Node = get_node_or_null(child_name)
if existing:
# Disown so Godot won't serialize the generated mesh/shape into .tscn
existing.owner = null
return existing
var node: Node
match type_name:
"MeshInstance3D":
node = MeshInstance3D.new()
"CollisionShape3D":
node = CollisionShape3D.new()
node.name = child_name
add_child(node)
# Do NOT set owner — prevents Godot from serializing the generated
# ArrayMesh into the .tscn (saves ~1 MiB). Mesh is regenerated in _ready().
return node