Files
Cinderwild/scripts/world/terrain.gd

584 lines
22 KiB
GDScript

extends Node3D
## Terrain generator.
##
## One heightmap chunk built from deterministic noise: a walkable valley of
## rolling hills with a lake, enclosed by a ring of steep mountains that opens on
## one side to a sheer sea cliff. The whole chunk is an island: its edge sinks
## into ocean so the end of the mesh is never visible.
##
## The rim is the world boundary: mountain slopes are too steep to walk up
## (> floor_max_angle), and an invisible wall stands partway up them, or just
## inside the cliff edge on the sea side, so nobody can climb out or fall off. Chunk
## streaming and biomes come in later milestones (docs/ARCHITECTURE.md §4).
##
## Heights are computed once into a grid that feeds the render mesh, the
## collision shape and `sample_height()`, so props and the player sit exactly on
## the visible surface.
##
## Collision is a HeightMapShape3D. Do NOT swap it for a concave trimesh: those
## are one-sided and the player fell straight through them from above.
signal ready_built ## emitted once the mesh + collision exist
@export var size: int = 320 ## chunk is size x size metres
@export var resolution: int = 256 ## grid cells per side
@export var height_scale: float = 9.0 ## rolling-hill amplitude in the valley
@export var noise_seed: int = 1337
@export_group("Boundary")
@export var play_radius: float = 100.0 ## where the mountain rim starts rising
@export var rim_width: float = 30.0 ## horizontal run of the rim's steep face
@export var mountain_height: float = 58.0
@export var rim_warp: float = 16.0 ## how irregular the rim's outline is
@export_range(0.2, 0.9) var wall_frac: float = 0.45 ## invisible wall, as a fraction up the rim
@export_group("Sea cliff")
@export var sea_angle_deg: float = -75.0 ## direction of the coast (atan2(z, x); -90 = straight -Z)
@export var sea_half_width_deg: float = 38.0 ## full-sea arc either side of sea_angle
@export var sea_fade_deg: float = 22.0 ## headland transition back to mountains
@export var sea_level: float = -24.0
@export var cliff_top: float = 0.5 ## height the coast rises/falls to at the edge
@export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius
@export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer)
@export_group("Water & snow")
@export var water_level: float = -2.6
@export var lake_center: Vector2 = Vector2(14.0, -46.0)
@export var lake_radius: float = 24.0
@export var snow_height: float = 40.0
@export_group("Colours")
@export var grass_lush: Color = Color(0.25, 0.40, 0.15)
@export var grass_dry: Color = Color(0.42, 0.49, 0.21)
var built: bool = false
var min_height: float = 0.0
var max_height: float = 0.0
var step: float = 1.0
var half: float = 0.0
var material: ShaderMaterial
var noise_tex: NoiseTexture2D ## shared by every world shader (see common.gdshaderinc)
var normal_tex: NoiseTexture2D ## detail normal map for ground and water
var _noise: FastNoiseLite
var _detail: FastNoiseLite
var _ridge: FastNoiseLite
var _warp: FastNoiseLite
var _wall_ring: Array[Vector3] = []
var _heights := PackedFloat32Array()
var _normals := PackedVector3Array()
func _ready() -> void:
# On a server, its seed; in a saved world, that world's seed; otherwise
# keep the exported default.
var net := get_node_or_null("/root/Net")
var saves := get_node_or_null("/root/Saves")
if net != null and net.state == "connected" and net.world_info.has("seed"):
noise_seed = int(net.world_info.seed)
worldlet_id = String(net.world_info.get("worldlet", worldlet_id))
elif saves != null and saves.has_world():
noise_seed = saves.world_seed()
# (worlds from before worldlets have none: they're the Valley)
worldlet_id = String(saves.read_meta(saves.current_id).get("worldlet", "valley"))
var why := use_worldlet(worldlet_id)
if not why.is_empty():
push_error("[terrain] %s: using the Valley" % why)
use_worldlet("valley")
_build()
# --- height function --------------------------------------------------------
func _ensure_noise() -> void:
if _noise != null:
return
_noise = FastNoiseLite.new()
_noise.seed = noise_seed
_noise.noise_type = FastNoiseLite.TYPE_PERLIN
_noise.frequency = 0.012
_noise.fractal_octaves = 4
_detail = FastNoiseLite.new()
_detail.seed = noise_seed + 1
_detail.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_detail.frequency = 0.06
_detail.fractal_octaves = 2
_ridge = FastNoiseLite.new()
_ridge.seed = noise_seed + 2
_ridge.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_ridge.frequency = 0.022
_ridge.fractal_octaves = 4
_warp = FastNoiseLite.new()
_warp.seed = noise_seed + 3
_warp.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_warp.frequency = 0.03
## Radial offset that makes the rim wobble instead of being a perfect circle.
## Sampled around a circle, so it depends only on the angle from the centre.
func _rim_offset(x: float, z: float) -> float:
var ang := atan2(z, x)
return _warp.get_noise_2d(cos(ang) * 50.0, sin(ang) * 50.0) * rim_warp
## "Rim distance": distance from the centre plus the wobble. The rim starts at
## play_radius and the invisible wall stands at _wall_d() in this same measure.
func _rim_d(x: float, z: float) -> float:
return sqrt(x * x + z * z) + _rim_offset(x, z)
func _wall_d() -> float:
return play_radius + rim_width * wall_frac
func _cliff_d() -> float:
return play_radius + cliff_offset
## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between
## (the headlands). Depends only on the angle from the centre.
func _sea_w(x: float, z: float) -> float:
var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI))
return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff)
## Boundary threshold in rim distance: partway up the mountains, or just
## inside the cliff edge on the sea side.
func _wall_threshold(x: float, z: float) -> float:
return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z))
# --- worldlets (docs/WORLDLETS_DESIGN.md, W0) ---------------------------------------
# A worldlet's terrain is a list of building blocks applied in order
# (data/worldlets/<id>.json "terrain"). Each block takes the ground so far and
# returns it changed; "hills" starts it. A worldlet can only combine these.
const BLOCKS := ["hills", "rim", "sea_cliff", "lake", "ocean_edge"]
var worldlet_id := "valley"
var worldlet: Dictionary = {} ## the definition, as loaded
var _blocks: Array = [] ## its terrain blocks, in order
var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it)
var _detail_k := 1.4
## The definition of worldlet `id` (data/worldlets/<id>.json), or {} if none.
static func load_worldlet(id: String) -> Dictionary:
var p := "res://data/worldlets/%s.json" % id
if not FileAccess.file_exists(p):
return {}
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(p))
return d if d is Dictionary else {}
## Every worldlet there is: [{id, name, description}].
static func worldlets() -> Array:
var out: Array = []
for f in DirAccess.get_files_at("res://data/worldlets"):
if f.ends_with(".json"):
var d := load_worldlet(f.get_basename())
if not d.is_empty():
out.append({"id": String(d.id), "name": String(d.get("name", d.id)), "description": String(d.get("description", ""))})
return out
## Takes on worldlet `id`: its size and every block's numbers. Returns ""
## on success, else what's wrong (an unknown worldlet or block keeps the old
## settings, so the game still has an island).
func use_worldlet(id: String) -> String:
var d := load_worldlet(id)
if d.is_empty():
return "no worldlet '%s'" % id
for b in d.get("terrain", []):
if not (b is Dictionary) or not String(b.get("type", "")) in BLOCKS:
return "worldlet '%s': unknown terrain block %s" % [id, str(b)]
worldlet_id = id
worldlet = d
size = int(d.get("size", size))
resolution = int(d.get("resolution", resolution))
_blocks = d.get("terrain", [])
for b in _blocks:
match String(b.type):
"hills":
height_scale = float(b.get("height_scale", height_scale))
_detail_k = float(b.get("detail", _detail_k))
"rim":
play_radius = float(b.get("play_radius", play_radius))
rim_width = float(b.get("rim_width", rim_width))
mountain_height = float(b.get("mountain_height", mountain_height))
rim_warp = float(b.get("rim_warp", rim_warp))
wall_frac = float(b.get("wall_frac", wall_frac))
"sea_cliff":
sea_angle_deg = float(b.get("angle_deg", sea_angle_deg))
sea_half_width_deg = float(b.get("half_width_deg", sea_half_width_deg))
sea_fade_deg = float(b.get("fade_deg", sea_fade_deg))
sea_level = float(b.get("sea_level", sea_level))
cliff_top = float(b.get("cliff_top", cliff_top))
cliff_offset = float(b.get("cliff_offset", cliff_offset))
cliff_drop_width = float(b.get("drop_width", cliff_drop_width))
"lake":
var c: Array = b.get("center", [lake_center.x, lake_center.y])
lake_center = Vector2(float(c[0]), float(c[1]))
lake_radius = float(b.get("radius", lake_radius))
water_level = float(b.get("water_level", water_level))
snow_height = float(d.get("climate", {}).get("snowline", snow_height))
return ""
func _has_block(t: String) -> bool:
for b in _blocks:
if String(b.type) == t:
return true
return false
func _height_fn(x: float, z: float) -> float:
_ensure_noise()
if _blocks.is_empty():
use_worldlet(worldlet_id)
var land := 0.0
for b in _blocks:
match String(b.type):
"hills":
land = _blk_hills(x, z)
"rim":
land = _blk_rim(x, z, land)
"sea_cliff":
land = _blk_sea_cliff(x, z, land)
"lake":
land = _blk_lake(x, z, land)
"ocean_edge":
land = _blk_ocean_edge(x, z, land)
return land
## Rolling hills: the ground everything else is built on.
func _blk_hills(x: float, z: float) -> float:
var valley := _noise.get_noise_2d(x, z) * height_scale
valley += _detail.get_noise_2d(x, z) * _detail_k
_valley = valley
return valley
## The mountain rim: rises from play_radius, stepped into ledges.
func _blk_rim(x: float, z: float, land: float) -> float:
var valley := _valley
var d := _rim_d(x, z)
var t := smoothstep(play_radius, play_radius + rim_width, d)
if t > 0.0:
var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z))
var m := pow(t, 1.4) * mountain_height * (0.65 + 0.55 * ridge)
m += maxf(d - (play_radius + rim_width), 0.0) * 0.35 * (0.5 + ridge)
# Stepped ledges so the faces read as layered cliffs, not smooth ramps.
var band := 6.0
var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band
m = lerpf(m, stepped, 0.45 * t)
land = valley * (1.0 - t * 0.6) + m
return land
## The sea side of the rim: the valley eases to a cliff top, then drops sheer.
func _blk_sea_cliff(x: float, z: float, land: float) -> float:
var valley := _valley
var d := _rim_d(x, z)
# Sea side: the valley eases to a cliff top, then drops sheer to the seabed.
# The edge sits 1 m past the invisible wall at every angle, including the
# headlands where the wall moves outward. (With the edge fixed at _cliff_d(),
# the headlands dropped away INSIDE the wall: pits you could fall into, with
# lake water hanging over them. tests/test_terrain_edges.gd guards this.)
var sw := _sea_w(x, z)
if sw > 0.0:
var edge := _wall_threshold(x, z) + 1.0
var top := cliff_top + _detail.get_noise_2d(x * 0.5, z * 0.5) * 1.5
var coast := lerpf(valley, top, smoothstep(play_radius - 45.0, edge, d))
var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0
coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d))
land = lerpf(land, coast, sw)
return land
## A lake basin: the ground blends down to a bed below the waterline.
func _blk_lake(x: float, z: float, land: float) -> float:
var ld := Vector2(x - lake_center.x, z - lake_center.y).length()
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0
return lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld))
## Ocean all round the chunk edge, so the world is an island and the edge of
## the mesh is always hidden under deep water.
func _blk_ocean_edge(x: float, z: float, land: float) -> float:
var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z))
return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e)
# --- build ------------------------------------------------------------------
func _build() -> void:
_ensure_noise()
step = float(size) / float(resolution)
half = size * 0.5
var n := resolution + 1
_heights.resize(n * n)
min_height = INF
max_height = -INF
for iz in range(n):
for ix in range(n):
var hv := _height_fn(ix * step - half, iz * step - half)
_heights[iz * n + ix] = hv
min_height = minf(min_height, hv)
max_height = maxf(max_height, hv)
_normals.resize(n * n)
for iz in range(n):
for ix in range(n):
var hl := _heights[iz * n + maxi(ix - 1, 0)]
var hr := _heights[iz * n + mini(ix + 1, resolution)]
var hd := _heights[maxi(iz - 1, 0) * n + ix]
var hu := _heights[mini(iz + 1, resolution) * n + ix]
_normals[iz * n + ix] = Vector3(hl - hr, 2.0 * step, hd - hu).normalized()
_make_textures()
_build_mesh(n)
_build_collision(n)
_build_boundary()
_build_water()
built = true
ready_built.emit()
func _make_textures() -> void:
var fn := FastNoiseLite.new()
fn.seed = noise_seed + 10
fn.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
fn.frequency = 0.008
fn.fractal_octaves = 5
noise_tex = NoiseTexture2D.new()
noise_tex.width = 512
noise_tex.height = 512
noise_tex.seamless = true
noise_tex.noise = fn
var fn2 := FastNoiseLite.new()
fn2.seed = noise_seed + 11
fn2.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
fn2.frequency = 0.02
fn2.fractal_octaves = 4
normal_tex = NoiseTexture2D.new()
normal_tex.width = 512
normal_tex.height = 512
normal_tex.seamless = true
normal_tex.as_normal_map = true
normal_tex.bump_strength = 6.0
normal_tex.noise = fn2
func _build_mesh(n: int) -> void:
var verts := PackedVector3Array()
var uvs := PackedVector2Array()
verts.resize(n * n)
uvs.resize(n * n)
for iz in range(n):
for ix in range(n):
var i := iz * n + ix
verts[i] = Vector3(ix * step - half, _heights[i], iz * step - half)
uvs[i] = Vector2(float(ix) / resolution, float(iz) / resolution)
var idx := PackedInt32Array()
idx.resize(resolution * resolution * 6)
var k := 0
for iz in range(resolution):
for ix in range(resolution):
var i00 := iz * n + ix
var i10 := i00 + 1
var i01 := i00 + n
var i11 := i01 + 1
# Winding chosen so the top face is the front face in Godot.
idx[k] = i00; idx[k + 1] = i11; idx[k + 2] = i01
idx[k + 3] = i00; idx[k + 4] = i10; idx[k + 5] = i11
k += 6
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_NORMAL] = _normals
arrays[Mesh.ARRAY_TEX_UV] = uvs
arrays[Mesh.ARRAY_INDEX] = idx
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
material = ShaderMaterial.new()
material.shader = preload("res://shaders/terrain.gdshader")
material.set_shader_parameter("noise_tex", noise_tex)
material.set_shader_parameter("normal_tex", normal_tex)
material.set_shader_parameter("water_level", water_level)
material.set_shader_parameter("snow_height", snow_height)
material.set_shader_parameter("grass_lush", grass_lush)
material.set_shader_parameter("grass_dry", grass_dry)
var mi := MeshInstance3D.new()
mi.name = "TerrainMesh"
mi.mesh = mesh
mi.material_override = material
add_child(mi)
func _build_collision(n: int) -> void:
var hm := HeightMapShape3D.new()
hm.map_width = n
hm.map_depth = n
hm.map_data = _heights
var body := StaticBody3D.new()
body.name = "TerrainBody"
add_child(body)
var col := CollisionShape3D.new()
col.shape = hm
body.add_child(col)
# HeightMapShape3D samples are 1 unit apart; scale to match the mesh.
body.scale = Vector3(step, 1.0, step)
## Invisible wall ringing the valley partway up the mountain slopes, plus four
## world-boundary planes at the chunk edges as a last line of defence.
func _build_boundary() -> void:
var body := StaticBody3D.new()
body.name = "Boundary"
add_child(body)
var segs := 128
var pts: Array[Vector3] = []
for i in range(segs):
var ang := TAU * i / segs
var dir := Vector3(cos(ang), 0.0, sin(ang))
# Solve for the radius whose rim distance equals the wall threshold.
var r := _wall_threshold(dir.x, dir.z) - _rim_offset(dir.x, dir.z)
pts.append(dir * r)
_wall_ring = pts
for i in range(segs):
var a := pts[i]
var b := pts[(i + 1) % segs]
var mid := (a + b) * 0.5
var length := a.distance_to(b) + 0.6
var box := BoxShape3D.new()
box.size = Vector3(length, 400.0, 1.5)
var cs := CollisionShape3D.new()
cs.shape = box
var along := (b - a).normalized()
var seg_basis := Basis(along, Vector3.UP, along.cross(Vector3.UP))
cs.transform = Transform3D(seg_basis, Vector3(mid.x, 100.0, mid.z))
body.add_child(cs)
var planes := [
Plane(Vector3.RIGHT, -half), Plane(Vector3.LEFT, -half),
Plane(Vector3.BACK, -half), Plane(Vector3.FORWARD, -half),
]
for p: Plane in planes:
var wb := WorldBoundaryShape3D.new()
wb.plane = p
var cs := CollisionShape3D.new()
cs.shape = wb
body.add_child(cs)
## Two water bodies: inland water (lake and ponds) as a disc clipped to the
## boundary ring, so it can't hang in the air past the sea cliff; and an ocean
## plane out to the horizon at sea level.
func _build_water() -> void:
var verts := PackedVector3Array([Vector3.ZERO])
for p in _wall_ring:
verts.append(p)
var count := verts.size()
var normals := PackedVector3Array()
var tangents := PackedFloat32Array()
var uvs := PackedVector2Array()
for v in verts:
normals.append(Vector3.UP)
tangents.append_array([1.0, 0.0, 0.0, 1.0])
uvs.append(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5))
var idx := PackedInt32Array()
for i in range(1, count):
idx.append_array([0, i, i % (count - 1) + 1])
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_TANGENT] = tangents
arrays[Mesh.ARRAY_TEX_UV] = uvs
arrays[Mesh.ARRAY_INDEX] = idx
var disc := ArrayMesh.new()
disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
_add_water("Water", disc, water_level, {})
var ocean := PlaneMesh.new()
ocean.size = Vector2(8000.0, 8000.0)
_add_water("Ocean", ocean, sea_level, {
"shallow_col": Color(0.16, 0.44, 0.50),
"deep_col": Color(0.02, 0.10, 0.20),
"clarity": 6.0,
})
func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void:
var mat := ShaderMaterial.new()
mat.shader = preload("res://shaders/water.gdshader")
mat.set_shader_parameter("noise_tex", noise_tex)
mat.set_shader_parameter("normal_tex", normal_tex)
for k in params:
mat.set_shader_parameter(k, params[k])
var mi := MeshInstance3D.new()
mi.name = label
mi.mesh = mesh
mi.material_override = mat
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
mi.position = Vector3(0.0, y, 0.0)
add_child(mi)
# --- queries ----------------------------------------------------------------
func in_bounds(x: float, z: float) -> bool:
return absf(x) < half - step and absf(z) < half - step
## True inside the walkable valley. A positive margin shrinks the area, a
## negative one extends it past the invisible wall.
func inside_boundary(x: float, z: float, margin: float = 0.0) -> bool:
_ensure_noise()
return _rim_d(x, z) < _wall_threshold(x, z) - margin
## How strongly (x, z) lies on the sea side of the rim (0..1).
func sea_weight(x: float, z: float) -> float:
_ensure_noise()
return _sea_w(x, z)
## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim
## distance `past_edge` beyond the cliff edge (negative = inland of it).
func coast_point(angle_deg: float, past_edge: float) -> Vector2:
_ensure_noise()
var dir := Vector2(cos(deg_to_rad(angle_deg)), sin(deg_to_rad(angle_deg)))
var r := _cliff_d() + past_edge - _rim_offset(dir.x, dir.y)
return dir * r
## Surface height at (x, z), interpolated on the same triangles as the mesh.
func sample_height(x: float, z: float) -> float:
if _heights.is_empty():
return _height_fn(x, z)
var n := resolution + 1
var gx := clampf((x + half) / step, 0.0, resolution - 0.0001)
var gz := clampf((z + half) / step, 0.0, resolution - 0.0001)
var ix := int(gx)
var iz := int(gz)
var fx := gx - ix
var fz := gz - iz
var h00 := _heights[iz * n + ix]
var h10 := _heights[iz * n + ix + 1]
var h01 := _heights[(iz + 1) * n + ix]
var h11 := _heights[(iz + 1) * n + ix + 1]
if fx > fz:
return h00 + (h10 - h00) * fx + (h11 - h10) * fz
return h00 + (h11 - h01) * fx + (h01 - h00) * fz
## Smoothed surface normal at (x, z).
func sample_normal(x: float, z: float) -> Vector3:
if _normals.is_empty():
return Vector3.UP
var n := resolution + 1
var gx := clampf((x + half) / step, 0.0, resolution - 0.0001)
var gz := clampf((z + half) / step, 0.0, resolution - 0.0001)
var ix := int(gx)
var iz := int(gz)
var fx := gx - ix
var fz := gz - iz
var a := _normals[iz * n + ix].lerp(_normals[iz * n + ix + 1], fx)
var b := _normals[(iz + 1) * n + ix].lerp(_normals[(iz + 1) * n + ix + 1], fx)
return a.lerp(b, fz).normalized()
## Nearest dry, gentle spot to `near` (spiral search), for spawning.
func find_spawn(near: Vector2) -> Vector3:
for ring in range(0, 30):
var r := ring * 2.0
var count := maxi(1, ring * 6)
for i in range(count):
var ang := TAU * i / count
var x := near.x + cos(ang) * r
var z := near.y + sin(ang) * r
var h := sample_height(x, z)
if h > water_level + 1.2 and sample_normal(x, z).y > 0.93 and inside_boundary(x, z, 15.0):
return Vector3(x, h, z)
return Vector3(near.x, sample_height(near.x, near.y), near.y)