extends "res://scenes/maps/map_core.gd"
## MAP HEIGHTMAP — the diffusion tiles, displaced. No dc-terrain.
##
## The tiles ARE heightmaps, so this loads one and displaces a grid by it. That
## is the whole map.
##
## WHY NOT dc-terrain. Its octree clipmap solves a volumetric problem: caves,
## overhangs, CSG destruction, a density field that needs dual contouring. A
## heightfield has none of that, and paying for it costs 33x -- a 33^3 chunk is
## 35,937 density evaluations to recover a 33x33 = 1,089-value surface -- and
## brings LOD convergence, geometric error and voxel artefacts to a problem that
## has none of them. Every difficulty in the first attempt came from the voxel
## path, not from the field.
##
## COLLISION is HeightMapShape3D, which is exactly this data structure. It is
## defined on a unit grid centred on its own origin, so the spacing comes from
## the shape node's scale rather than from the data.
const HBLK := "res://assets/terrain/diffusion_gully_30km.hblk"
## 1 = every texel (1026^2 verts). Raise to decimate if it is too heavy to
## judge; 30 m per quad at step 1 is the field's own resolution.
const STEP := 1
## Sea level for the colour ramp, in metres. The block runs -4226 .. 2680.
const WATER_Y := 0.0
var _n := 0
var _mpp := 30.0
var _ox := 0.0
var _oz := 0.0
var _h := PackedFloat32Array()
func _ready() -> void:
super._ready()
if not _load_block():
push_error("[MapHeightmap] could not load %s — nothing to show" % HBLK)
return
_build_mesh()
_build_collision()
_place_player()
## int16 metres, row = world Z, column = world X (stitch_block.py).
func _load_block() -> bool:
var f := FileAccess.open(HBLK, FileAccess.READ)
if f == null:
return false
if f.get_buffer(4).get_string_from_ascii() != "HBLK":
push_error("[MapHeightmap] %s is not an .hblk" % HBLK)
return false
var ver := f.get_32()
if ver != 1:
push_error("[MapHeightmap] .hblk version %d unsupported" % ver)
return false
_n = int(f.get_32())
_mpp = f.get_float()
_ox = f.get_float()
_oz = f.get_float()
f.seek(32)
var raw := f.get_buffer(_n * _n * 2)
f.close()
_h.resize(_n * _n)
# get_16() is unsigned; below-sea-level elevations are negative int16.
for i in range(_n * _n):
var lo := raw[i * 2]
var hi := raw[i * 2 + 1]
var v := lo | (hi << 8)
if v > 32767:
v -= 65536
_h[i] = float(v)
var lo_h := _h[0]
var hi_h := _h[0]
for v2 in _h:
lo_h = minf(lo_h, v2)
hi_h = maxf(hi_h, v2)
print("[MapHeightmap] %dx%d @ %.1f m/px, origin x %.0f z %.0f, elevation %.0f .. %.0f m"
% [_n, _n, _mpp, _ox, _oz, lo_h, hi_h])
return true
func height_at_index(ix: int, iz: int) -> float:
ix = clampi(ix, 0, _n - 1)
iz = clampi(iz, 0, _n - 1)
return _h[iz * _n + ix]
func _build_mesh() -> void:
var verts := PackedVector3Array()
var normals := PackedVector3Array()
var colors := PackedColorArray()
var indices := PackedInt32Array()
var side := (_n - 1) / STEP + 1
verts.resize(side * side)
normals.resize(side * side)
colors.resize(side * side)
var lo := 1e30
var hi := -1e30
for jz in range(side):
for ix in range(side):
var sx := ix * STEP
var sz := jz * STEP
var y := height_at_index(sx, sz)
lo = minf(lo, y)
hi = maxf(hi, y)
verts[jz * side + ix] = Vector3(_ox + sx * _mpp, y, _oz + sz * _mpp)
# Central differences on the SOURCE grid, so the normal is the field's
# gradient rather than the decimated mesh's.
var inv := 1.0 / (2.0 * _mpp * STEP)
for jz in range(side):
for ix in range(side):
var sx := ix * STEP
var sz := jz * STEP
var dx := (height_at_index(sx + STEP, sz) - height_at_index(sx - STEP, sz)) * inv
var dz := (height_at_index(sx, sz + STEP) - height_at_index(sx, sz - STEP)) * inv
normals[jz * side + ix] = Vector3(-dx, 1.0, -dz).normalized()
var y2 := verts[jz * side + ix].y
colors[jz * side + ix] = _ramp(y2, dx, dz)
indices.resize((side - 1) * (side - 1) * 6)
var k := 0
for jz in range(side - 1):
for ix in range(side - 1):
var a := jz * side + ix
var b := a + 1
var c := a + side
var d := c + 1
# WINDING, taken from Godot rather than remembered. PlaneMesh faces
# +Y and its stored normal is (0,1,0), yet cross(b-a, c-a) over its
# indices is (0,-1,0) -- the front face is the OPPOSITE sense to the
# geometric cross. The first version here wound the other way and
# rendered inside out. gate_winding.gd checks this against PlaneMesh
# so it cannot silently flip back.
indices[k] = a; indices[k + 1] = b; indices[k + 2] = c
indices[k + 3] = b; indices[k + 4] = d; indices[k + 5] = c
k += 6
var arr := []
arr.resize(Mesh.ARRAY_MAX)
arr[Mesh.ARRAY_VERTEX] = verts
arr[Mesh.ARRAY_NORMAL] = normals
arr[Mesh.ARRAY_COLOR] = colors
arr[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr)
var mi := MeshInstance3D.new()
mi.name = "TerrainMesh"
mi.mesh = mesh
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
mat.roughness = 0.95
mi.material_override = mat
add_child(mi)
print("[MapHeightmap] mesh %d verts, %d tris, elevation %.0f .. %.0f m"
% [verts.size(), indices.size() / 3, lo, hi])
## Altitude plus slope, the two attributes the lookdev found carry a heightfield.
func _ramp(y: float, dx: float, dz: float) -> Color:
var slope := clampf(sqrt(dx * dx + dz * dz), 0.0, 1.0)
if y < WATER_Y:
var t := clampf((WATER_Y - y) / 2000.0, 0.0, 1.0)
return Color(0.05, 0.12, 0.28).lerp(Color(0.02, 0.04, 0.12), t)
var a := clampf(y / 2500.0, 0.0, 1.0)
var base := Color(0.30, 0.42, 0.22).lerp(Color(0.86, 0.86, 0.88), a)
return base.lerp(Color(0.36, 0.33, 0.30), slope)
## HeightMapShape3D is defined on a UNIT grid centred on its own origin, so the
## 30 m spacing is the node's scale and the centring is its position.
func _build_collision() -> void:
var body := StaticBody3D.new()
body.name = "TerrainBody"
var cs := CollisionShape3D.new()
var shape := HeightMapShape3D.new()
shape.map_width = _n
shape.map_depth = _n
shape.map_data = _h
cs.shape = shape
cs.scale = Vector3(_mpp, 1.0, _mpp)
cs.position = Vector3(_ox + (_n - 1) * _mpp * 0.5, 0.0, _oz + (_n - 1) * _mpp * 0.5)
body.add_child(cs)
add_child(body)
print("[MapHeightmap] collision %dx%d centred at (%.0f, %.0f)"
% [_n, _n, cs.position.x, cs.position.z])
func _place_player() -> void:
var cx := _ox + (_n - 1) * _mpp * 0.5
var cz := _oz + (_n - 1) * _mpp * 0.5
var y := height_at_index((_n - 1) / 2, (_n - 1) / 2) + 3.0
var pos := Vector3(cx, y, cz)
print("[MapHeightmap] spawn %s" % str(pos.round()))
var player := get_node_or_null("Player")
if player != null and player is Node3D:
(player as Node3D).position = pos
var marker := get_node_or_null("SpawnPoint")
if marker != null and marker is Node3D:
(marker as Node3D).position = pos
var tp := get_node_or_null("TeleporterToHome")
if tp != null and tp is Node3D:
(tp as Node3D).position = pos + Vector3(6.0, 0.0, 0.0)