scopedvr-map-core/viewport/terrain_mesh.gd
slyphax 83af25b6fb VR-Host: main läuft ohne Fenster, Modelle aus dem Projekt, Terrain in Stücken (nur Quest)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:38:42 +01:00

608 lines
24 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Node3D
class_name TerrainMeshView
## Terrain-Ansicht: baut das Höhenfeld (svmap terrain-Block) als Mesh im
## Editor-Viewport (M2, Karte t_9aba04f1).
##
## Zwei Wege:
## rebuild_from_doc(data) — nach Laden/Neu/Undo: Base64 dekodieren + Mesh neu.
## update_heights(...) — während eines Pinsel-Strichs: nur Vertices neu
## schreiben (SOFT-war das Mesh live — kein Rebuild,
## kein Flackern, res 129 ≈ 16,6k Punkte = flott).
##
## Farben (Karte t_603ec1a8): Vertex-Farben aus der Material-Schicht
## (terrain.materials_r8_b64, v2) — Gras/Sand/Fels, plus Höhen-Faktor.
##
## Relief-Lesbarkeit (Karte t_3ebe4086, Sly-Befund „Erhöhungen/Vertiefungen
## kaum erkennbar"): TWO-PUNCH im Vertex-Farben-Pfad:
## 1. Höhen-Faktor deutlich kontrastreicher: steilere S-Kurve statt linear
## und breitere Faktor-Spanne (Senken dunkler, Hügel heller).
## 2. Pseudo-Licht: Terrain-Normale × feste Sonnenrichtung (Hillshade) —
## Sonnenabgewandte Hänge werden dunkler, zugewandte heller. Das ist der
## günstigste echte Relief-Eindruck für die mobile Vertex-Farb-Pipeline
## und identisch zum Match-Client (svmap_terrain.gd, gleiche Konstanten).
##
## Editor-only Extra (gleiche Karte): Höhenlinien alle 1 m + 5-m-Indexlinie
## per Custom-Shader (terrain_contour.gdshader, UV2.x = Höhe). Quest-Vertrag
## „kein Custom-Shader" gilt fürs Spiel — der EDITOR darf teurer sein
## (DECISIONS.md-Konvention). Der Match-Client bleibt StandardMaterial3D.
## Editor-Viewport material ist UNSHADED: das gebackene Shading ist exakt
## sichtbar (kein Doppel-Shading mit dem Viewport-Sonnenlicht, deterministisch
## für die Render-Probe).
const MATERIAL_COLORS: Array[Color] = [
Color(1.0, 1.0, 1.0), # 0 = Gras — Karte t_ab408127: Textur liefert die
Color(1.0, 1.0, 1.0), # 1 = Sand — Farbe; Vertex-RGB ist nur noch Shading-
Color(1.0, 1.0, 1.0), # 2 = Fels — Multiplikator (Höhe × Hillshade),
# 20-Slot-Palette: Weiß = neutral, Albedo-Textur trägt die Färbung.
Color(1.0, 1.0, 1.0), # 3
Color(1.0, 1.0, 1.0), # 4
Color(1.0, 1.0, 1.0), # 5
Color(1.0, 1.0, 1.0), # 6
Color(1.0, 1.0, 1.0), # 7
Color(1.0, 1.0, 1.0), # 8
Color(1.0, 1.0, 1.0), # 9
Color(1.0, 1.0, 1.0), # 10
Color(1.0, 1.0, 1.0), # 11
Color(1.0, 1.0, 1.0), # 12
Color(1.0, 1.0, 1.0), # 13
Color(1.0, 1.0, 1.0), # 14
Color(1.0, 1.0, 1.0), # 15
Color(1.0, 1.0, 1.0), # 16
Color(1.0, 1.0, 1.0), # 17
Color(1.0, 1.0, 1.0), # 18
Color(1.0, 1.0, 1.0), # 19
]
# Höhen-Faktor (Material-Farbe × Faktor) — KARTE t_3ebe4086: bewusst breiter
# und mit S-Kurve (t*t*(3-2t)) kalibriert als die alte Linear-Version
# (0.72 … 1.15): Senken deutlich dunkler, Hügel deutlich heller.
const SHADING_LOW := 0.58 # Tal-Faktor
const SHADING_HIGH := 1.28 # Hügel-Faktor
# Pseudo-Licht (Hillshade): feste Sonnenrichtung, editor-seitig so gewählt,
# dass beim Default-Blick (Kamera südlich der Map, Blick Richtung −Z) die
# Formen seitlich beleuchtet wirken (Licht von rechts-hinten +X/+Z).
const RELIEF_SUN := Vector3(0.45, 0.75, 0.5)
const LIGHT_LOW := 0.62 # Hang abgewandt von der Sonne
const LIGHT_HIGH := 1.22 # Hang zugewandt
const LIGHT_T0 := 0.15 # Smoothstep-Fenster der NDOT-Kurve
const LIGHT_T1 := 0.95
const SHADE_MIN := 0.40 # Gesamtklemme (Höhen × Licht), Schutz vor Schwarz
const SHADE_MAX := 1.35
# Höhenlinien (editor-only) nur ab 2 m Gesamtrelief — auf einer flachen Map
# läge sonst ALLES auf der 0-m-Linie und die Fläche würde dunkel übermalt.
const CONTOUR_MIN_SPAN := 2.0
const CONTOUR_SHADER := "res://core/viewport/terrain_contour.gdshader"
var _mi: MeshInstance3D
## Quest: Terrain in Stücken, ein Pinselschritt schreibt nur die getroffenen
## Punkte neu (Messung 31.10.: 3 ms statt 132 ms bei Raster 257). Am PC reicht
## das ganze Mesh, dort läuft außerdem der Kontur-Shader.
static var stuecke_an := OS.has_feature("android")
## Eigenes Terrain-Material der App (Quest: der leichte Spiel-Shader).
static var material_fabrik := Callable()
const STUECK_ZELLEN := 32
var _stuecke: Array[Dictionary] = []
var _stuecke_res := 0
var _stuecke_groesse := Vector2.ZERO
var _dirty: Array[Rect2i] = []
var _hmin := 0.0
var _hmax := 1.0
var _res := 0
var _size_x := 0.0
var _size_z := 0.0
var _mats_cache := PackedByteArray() # zuletzt gesehene Material-Schicht
# (res²) — Live-Pfad-Fallback, Karte
# t_55ed041a.
var _wasser_level := INF # Karte t_28fdef03: Wasserhöhe aus dem doc
# (INF = kein Wasser — Matsch-Ring aus).
var _triangles := 0
var _contours_pref := true # UI-Toggle „Höhenlinien“ (Default AN)
var _shader: Shader
# --- Splat-Gewichtstextur (Karte t_7a0a6b79 — weiche Übergänge) -------------
# CPU-Image + GPU-Textur der Top-2-Splat-Gewichte (RGBA8: R=id0 G=w0 B=id1
# A=w1, Formel TerrainArrays.build_splat_bytes — Spiegel-Datei). Beim Malen
# wird NUR die betroffene Region neu abgeleitet (Sub-Rect-Update, siehe
# _refresh_splat_region) — kein Voll-Rebuild pro Mal-Frame.
var _splat_image: Image
var _splat_tex: ImageTexture
var _splat_side := 0 # Texel-Kantenlänge (Zweierpotenz)
var _splat_res := 0 # Zellraster-Auflösung, für die die Splat gilt
# Misch-Schicht (Karte 25.10.): zweites Material + Anteil je Punkt — eigene
# R8-Texturen neben der Material-Karte (gleiche Größe, Shader liest alle drei).
var _mix_b_cache := PackedByteArray()
var _mix_w_cache := PackedByteArray()
var _mix_b_image: Image
var _mix_w_image: Image
var _mix_b_tex: ImageTexture
var _mix_w_tex: ImageTexture
func _ready() -> void:
_ensure_mesh_instance()
func _ensure_mesh_instance() -> void:
if _mi != null and is_instance_valid(_mi):
return
_mi = MeshInstance3D.new()
_mi.material_override = _make_material()
add_child(_mi)
## Material fürs Editor-Terrain: unshaded Kontur-Shader (Vertex-Farbe als
## Albedo + Höhenlinien). Karte t_ab408127: zusätzlich die Terrain-Texturen
## (TerrainArrays.res — spiegelgleich zum Client-Pfad, aber EDITOR-EIGENE
## Kopie der Resource-Klasse, Repos sind entkoppelt). Fällt auf unshaded
## StandardMaterial3D zurück, wenn der Shader nicht ladbar ist (Robustheit —
## Terrain bleibt immer sichtbar).
func _make_material() -> Material:
if material_fabrik.is_valid():
return material_fabrik.call()
_shader = load(CONTOUR_SHADER)
if _shader != null:
var mat := ShaderMaterial.new()
mat.shader = _shader
_apply_texture_uniforms(mat)
return mat
push_warning("TerrainMesh: Kontur-Shader fehlt — Fallback StandardMaterial3D (unshaded).")
var std := StandardMaterial3D.new()
std.vertex_color_use_as_albedo = true
std.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
std.roughness = 1.0
return std
## Terrain-Texturen an den Kontur-Shader binden (t_ab408127). Quelle =
## res://assets/textures/terrain/terrain_arrays.res — vom Sync-Skript
## (tools/sync_terrain_tex.sh) aus dem Client-Klon gespiegelt, analog
## tools/sync_models.sh (models/-Ordner-Muster). Fehlt die Resource
## (frischer Editor-Klon ohne Sync), bleibt texturen_an=0 → reiner
## Vertex-Farb-Look (Fallback, kein Crash).
func _apply_texture_uniforms(mat: ShaderMaterial) -> void:
const ARRAYS_PATH := "res://assets/textures/terrain/terrain_arrays.res"
if not ResourceLoader.exists(ARRAYS_PATH):
return # Fallback-Klon: texturen_an bleibt 0 (Vertex-Farb-Look)
var arrays = load(ARRAYS_PATH)
if arrays == null or not ("albedo_images" in arrays):
return
var imgs: Array = arrays.albedo_images
if imgs.size() < 3:
return
var albedo := Texture2DArray.new()
albedo.create_from_images(imgs)
mat.set_shader_parameter("albedo_array", albedo)
mat.set_shader_parameter("layer_count", float(imgs.size()))
mat.set_shader_parameter("tile_size", 3.0) # Spiegel Client TILE_SIZE (Karte 25.10.)
mat.set_shader_parameter("texturen_an", 1.0)
## UI-Toggle „Höhenlinien“ (main.gd): ohne Rebuild an/ab.
func set_contours(on: bool) -> void:
_contours_pref = on
_apply_contour_state(_last_span >= CONTOUR_MIN_SPAN)
func contours_enabled() -> bool:
return _contours_pref
var _last_span := 0.0
## Kompletter Neuaufbau aus dem svmap-terrain-Block (inkl. Material-Schicht).
func rebuild_from_doc(data: Dictionary) -> void:
var terrain: Dictionary = data.get("terrain", {})
if terrain.is_empty():
_triangles = 0
if _mi != null:
_mi.mesh = null
_stuecke_leeren()
return
# Splat-Textur verwerfen — sie wird im update_heights frisch aus der
# (nach Undo/Load evtl. geänderten) Zell-Schicht abgeleitet.
_splat_tex = null
var mix := SvmapIO.decode_mix(data.get("terrain", {}))
_mix_b_cache = mix[0]
_mix_w_cache = mix[1]
# Wasser (Karte t_28fdef03): Höhe merken — der Matsch-Ring (Ufer-Automatik)
# mischt sich in relief_color ein. INF = kein Wasser (Legacy unverändert).
_wasser_level = SvmapWasser.level(data)
var heights := SvmapIO.decode_heights(terrain)
var mats := SvmapIO.decode_materials(terrain)
update_heights(heights, int(terrain.get("resolution", 0)),
float(terrain.get("size_x_m", 1.0)), float(terrain.get("size_z_m", 1.0)), mats)
## Mesh aus einem (neuen) Höhenfeld bauen — auch live während des Strichs.
## res/size 0 = zuletzt verwendete Werte weiterverwenden (Live-Pfad).
## mats = Material-Schicht (res² Bytes, 0=Gras/1=Sand/2=Fels); leer → die
## zuletzt gesehene Schicht (Cache) wird weiterverwendet, damit Live-Updates
## ohne explizite Schicht (z. B. Heben/Senken-Stroke) die Material-Farben
## BEHALTEN statt live auf alles-Gras zurückzufallen (Karte t_55ed041a).
## Nur wenn NIE eine Schicht gesehen wurde (Legacy-v1-Map) gilt alles Gras.
func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0,
size_z: float = 0.0, mats: PackedByteArray = PackedByteArray(),
splat_rect_cells: Rect2i = Rect2i()) -> void:
if res < 2:
res = _res
if size_x <= 0.0:
size_x = _size_x
if size_z <= 0.0:
size_z = _size_z
if _mi == null:
_ensure_mesh_instance()
if res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0:
return
_res = res
_size_x = size_x
_size_z = size_z
# Material-Schicht hat ihr eigenes (feines) Raster (Karte 24.10.) — mres
# aus der Länge (quadratisch); alte Karten: mres == res.
if mats.is_empty() and not _mats_cache.is_empty():
mats = _mats_cache
elif not mats.is_empty():
_mats_cache = mats
var mres := mat_res_of(mats, res)
if stuecke_an:
# Mal-Schritt (Rechteck gesetzt): Vertices hängen nicht am Material.
if not splat_rect_cells.has_area():
_stuecke_schreiben(heights, res, size_x, size_z, mats, mres)
_splat_aktualisieren(size_x, size_z, mats, mres)
return
var last := float(res - 1)
var positions := PackedVector3Array()
var normals := PackedVector3Array()
var colors := PackedColorArray()
var heights_uv := PackedVector2Array()
var indices := PackedInt32Array()
positions.resize(res * res)
normals.resize(res * res)
colors.resize(res * res)
heights_uv.resize(res * res)
var hmin := INF
var hmax := -INF
for v in heights:
if v < hmin:
hmin = v
if v > hmax:
hmax = v
var span := maxf(hmax - hmin, 0.0001)
_last_span = span
var cell_x := size_x / last
var cell_z := size_z / last
_apply_contour_state(span >= CONTOUR_MIN_SPAN)
var z := 0
while z < res:
var x := 0
while x < res:
var idx := z * res + x
# Index→Welt-Konvention (Karte t_234490cf): Index 0 = −X/−Z-Ecke,
# x-Index läuft MIT +WeltX, z-Index MIT +WeltZ — IDENTISCH zu
# SvmapDocument.terrain_height_at, TerrainField.height_at und dem
# Match-Client (svmap_terrain.gd: −size*0.5 + index*cell).
# Vor b7b160d…+Fix stand hier eine SPIGELUNG (+0.5 − index·cell in
# beiden Achsen) — der Renderer war damit der einzige Verbraucher
# dieser Konvention: der Pinsel-Strahl traf das Mesh an der
# punktgespiegelten Stelle (Sly-Befund „Pinsel wirkt woanders“).
positions[idx] = Vector3(
-size_x * 0.5 + float(x) * cell_x,
heights[idx],
-size_z * 0.5 + float(z) * cell_z)
# Normale aus zentralen Differenzen (Rand: einseitig geklemmt):
# n ∝ (−∂h/∂x, 1, −∂h/∂z), skaliert mit 2·cell_x·cell_z.
var hx_l := heights[z * res + maxi(x - 1, 0)]
var hx_r := heights[z * res + mini(x + 1, res - 1)]
var hz_d := heights[maxi(z - 1, 0) * res + x]
var hz_u := heights[mini(z + 1, res - 1) * res + x]
normals[idx] = Vector3(
(hx_l - hx_r) * cell_z,
2.0 * cell_x * cell_z,
(hz_d - hz_u) * cell_x).normalized()
var mat_id := clampi(SvmapIO.material_at_height_point(mats, mres, res, x, z),
0, MATERIAL_COLORS.size() - 1)
# Material-Farbe × Höhen-Kurve × Pseudo-Licht (Relief lesbar).
var farbe := relief_color(mat_id, heights[idx], hmin, hmax, normals[idx])
# Ufer-Automatik (Karte t_28fdef03): Zellen knapp unter der Wasser-
# höhe bekommen den Matsch-Ring — IDENTISCHE Formel wie der Client
# (SvmapWasser.ufer_faktor/matsch_anwenden — dieselbe Spiegel-Datei).
# Alpha (gepackte Material-ID) bleibt unangetastet (Splat-Vertrag).
if _wasser_level < INF:
farbe = SvmapWasser.matsch_anwenden(farbe,
SvmapWasser.ufer_faktor(heights[idx], _wasser_level))
colors[idx] = farbe
heights_uv[idx] = Vector2(heights[idx], 0.0)
x += 1
z += 1
z = 0
while z < res - 1:
var x := 0
while x < res - 1:
var i := z * res + x
# Winding-Order: Godot betrachtet Clockwise (von der Vorderseite
# betrachtet) als Vorderseite. Von oben gelesen muss jedes Dreieck
# IM Uhrzeigersinn laufen, sonst cullt der Renderer die komplette
# Deckfläche als Backface (Karte t_7ac70014: „dunkle Platte“).
indices.append_array([i, i + 1, i + res, i + 1, i + res + 1, i + res])
x += 1
z += 1
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = positions
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_COLOR] = colors
arrays[Mesh.ARRAY_TEX_UV2] = heights_uv # Höhe je Vertex → Kontur-Shader
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
_mi.mesh = mesh
_triangles = indices.size() / 3
_splat_aktualisieren(size_x, size_z, mats, mres)
func _splat_aktualisieren(size_x: float, size_z: float, mats: PackedByteArray, mres: int) -> void:
# --- Splat-Gewichtstextur (t_7a0a6b79): map_size + Sub-Rect-Update --------
# map_size_x/z in Weltmetern (Shader rechnet map_uv = Weltkoord. über Map).
# Mal-Stroke: splat_rect_cells = betroffenes Zellrechteck → NUR die Region
# neu ableiten. Voll-Rebuild (Load/Undo/Stroke-Ende): Textur frisch bauen.
var mat := _mi.material_override
if mat is ShaderMaterial:
var shm := mat as ShaderMaterial
shm.set_shader_parameter("map_size_x", size_x)
shm.set_shader_parameter("map_size_z", size_z)
# Material-ID-Karte (Karte 25.10.): Malen = gleiche Größe → nur Daten
# tauschen (1 Upload); sonst neu anlegen. splat_rect_cells bleibt als
# Parameter (Aufrufer-Vertrag), wird aber nicht mehr gebraucht.
if _splat_tex != null and _splat_res == mres and mats.size() == mres * mres \
and _mix_b_tex != null and _mix_b_cache.size() == mres * mres:
_splat_image.set_data(mres, mres, false, Image.FORMAT_R8, mats)
_splat_tex.update(_splat_image)
_mix_b_image.set_data(mres, mres, false, Image.FORMAT_R8, _mix_b_cache)
_mix_b_tex.update(_mix_b_image)
_mix_w_image.set_data(mres, mres, false, Image.FORMAT_R8, _mix_w_cache)
_mix_w_tex.update(_mix_w_image)
else:
_build_splat(shm, mats, mres, size_x, size_z)
## Pinsel meldet die geänderten Höhenpunkte, das nächste Update schreibt nur sie.
func markiere_hoehen(rect: Rect2i) -> void:
_dirty.append(rect)
func _stuecke_leeren() -> void:
for s in _stuecke:
(s["mi"] as Node).queue_free()
_stuecke.clear()
_stuecke_res = 0
_dirty.clear()
func _stuecke_schreiben(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
mats: PackedByteArray, mres: int) -> void:
var groesse := Vector2(size_x, size_z)
var neu_bauen := _stuecke_res != res or _stuecke_groesse != groesse
if neu_bauen:
_stuecke_leeren()
_stuecke_res = res
_stuecke_groesse = groesse
var je_seite := int(ceil(float(res - 1) / float(STUECK_ZELLEN)))
for cz in je_seite:
for cx in je_seite:
_stuecke.append(_stueck_anlegen(cx, cz, res))
_triangles = (res - 1) * (res - 1) * 2
var alles := neu_bauen or _dirty.is_empty()
if alles:
# Farbskala nur beim vollen Durchlauf neu, im Strich bleibt sie stehen.
_hmin = INF
_hmax = -INF
for v in heights:
_hmin = minf(_hmin, v)
_hmax = maxf(_hmax, v)
_last_span = maxf(_hmax - _hmin, 0.0001)
_apply_contour_state(_last_span >= CONTOUR_MIN_SPAN)
for s in _stuecke:
var ganz := Rect2i(s["x0"], s["z0"], s["x1"] - s["x0"] + 1, s["z1"] - s["z0"] + 1)
var getroffen := alles
if alles:
_stueck_punkte(s, ganz, heights, res, size_x, size_z, mats, mres)
else:
for r in _dirty:
# Normalen hängen an den Nachbarn: ein Punkt Rand mitnehmen.
var teil := r.grow(1).intersection(ganz)
if teil.has_area():
_stueck_punkte(s, teil, heights, res, size_x, size_z, mats, mres)
getroffen = true
if getroffen:
var mesh: ArrayMesh = (s["mi"] as MeshInstance3D).mesh
mesh.clear_surfaces()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, s["arrays"])
_dirty.clear()
func _stueck_anlegen(cx: int, cz: int, res: int) -> Dictionary:
var x0 := cx * STUECK_ZELLEN
var z0 := cz * STUECK_ZELLEN
var x1 := mini(x0 + STUECK_ZELLEN, res - 1)
var z1 := mini(z0 + STUECK_ZELLEN, res - 1)
var w := x1 - x0 + 1
var h := z1 - z0 + 1
var pos := PackedVector3Array()
var nor := PackedVector3Array()
var col := PackedColorArray()
var uv2 := PackedVector2Array()
var idx := PackedInt32Array()
pos.resize(w * h)
nor.resize(w * h)
col.resize(w * h)
uv2.resize(w * h)
idx.resize((w - 1) * (h - 1) * 6)
var n := 0
for z in h - 1:
for x in w - 1:
var i := z * w + x
# Wicklung wie im ganzen Mesh (update_heights).
idx[n] = i
idx[n + 1] = i + 1
idx[n + 2] = i + w
idx[n + 3] = i + 1
idx[n + 4] = i + w + 1
idx[n + 5] = i + w
n += 6
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = pos
arrays[Mesh.ARRAY_NORMAL] = nor
arrays[Mesh.ARRAY_COLOR] = col
arrays[Mesh.ARRAY_TEX_UV2] = uv2
arrays[Mesh.ARRAY_INDEX] = idx
var mi := MeshInstance3D.new()
mi.mesh = ArrayMesh.new()
mi.material_override = _mi.material_override
add_child(mi)
return {"x0": x0, "z0": z0, "x1": x1, "z1": z1, "w": w, "arrays": arrays, "mi": mi}
func _stueck_punkte(s: Dictionary, r: Rect2i, heights: PackedFloat32Array, res: int,
size_x: float, size_z: float, mats: PackedByteArray, mres: int) -> void:
var arrays: Array = s["arrays"]
var pos: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var nor: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
var col: PackedColorArray = arrays[Mesh.ARRAY_COLOR]
var uv2: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV2]
var x0: int = s["x0"]
var z0: int = s["z0"]
var w: int = s["w"]
var cell_x := size_x / float(res - 1)
var cell_z := size_z / float(res - 1)
for z in range(r.position.y, r.end.y):
for x in range(r.position.x, r.end.x):
var i := z * res + x
var v := (z - z0) * w + (x - x0)
var hv := heights[i]
pos[v] = Vector3(-size_x * 0.5 + float(x) * cell_x, hv, -size_z * 0.5 + float(z) * cell_z)
var n := Vector3(
(heights[z * res + maxi(x - 1, 0)] - heights[z * res + mini(x + 1, res - 1)]) * cell_z,
2.0 * cell_x * cell_z,
(heights[maxi(z - 1, 0) * res + x] - heights[mini(z + 1, res - 1) * res + x]) * cell_x).normalized()
nor[v] = n
var mat_id := clampi(SvmapIO.material_at_height_point(mats, mres, res, x, z),
0, MATERIAL_COLORS.size() - 1)
var farbe := relief_color(mat_id, hv, _hmin, _hmax, n)
if _wasser_level < INF:
farbe = SvmapWasser.matsch_anwenden(farbe, SvmapWasser.ufer_faktor(hv, _wasser_level))
col[v] = farbe
uv2[v] = Vector2(hv, 0.0)
arrays[Mesh.ARRAY_VERTEX] = pos
arrays[Mesh.ARRAY_NORMAL] = nor
arrays[Mesh.ARRAY_COLOR] = col
arrays[Mesh.ARRAY_TEX_UV2] = uv2
## Misch-Schicht fürs nächste update_heights (Live-Malen, Karte 25.10.).
func set_mix(mix_b: PackedByteArray, mix_w: PackedByteArray) -> void:
_mix_b_cache = mix_b
_mix_w_cache = mix_w
## Raster einer Material-Schicht aus ihrer Länge (quadratisch). Unpassend/leer
## → Höhenraster (alte Karten, Legacy ohne Schicht).
static func mat_res_of(mats: PackedByteArray, res: int) -> int:
if mats.is_empty():
return res
var r := int(roundf(sqrt(float(mats.size()))))
return r if r >= 2 and r * r == mats.size() else res
## Höhenlinien-Uniform an die aktuelle Situation anpassen (Pref ∧ Relief ≥ 2 m).
func _apply_contour_state(span_ok: bool) -> void:
if _mi == null or not is_instance_valid(_mi):
return
var mat := _mi.material_override
if mat is ShaderMaterial:
mat.set_shader_parameter("linien_an", 1.0 if (_contours_pref and span_ok) else 0.0)
## Vertex-Farbe EINER Zelle: Material × Höhen-Kurve × Pseudo-Licht.
## Static (statt inline), damit Tests die Mathematik direkt prüfen können und
## Editor/Client die IDENTISCHE Formel teilen (Konstanten spiegeln — gleiche
## Kommentare/Konstanten wie scripts/world/svmap_terrain.gd im Client-Repo).
## Alpha = Material-ID (Karte t_ab408127): m/N gepackt — der Shader (Client
## UND Editor-Vorschau) sampelt die Texture2DArray-Layer über die Alpha.
## Der Shading-Faktor wirkt NUR auf RGB (Color × float skaliert auch Alpha —
## deshalb wird die ID erst im Return gezielt gesetzt).
static func relief_color(mat_id: int, h: float, hmin: float, hmax: float,
normal: Vector3) -> Color:
var m := clampi(mat_id, 0, MATERIAL_COLORS.size() - 1)
var span := maxf(hmax - hmin, 0.0001)
var t := clampf((h - hmin) / span, 0.0, 1.0)
# Steilere Kurve statt linear: S-Kurve hebt die Enden, senkt die Mitte —
# Hügel/Senken trennen sich stärker als bei t direkt.
var t_s := t * t * (3.0 - 2.0 * t)
var hf := lerpf(SHADING_LOW, SHADING_HIGH, t_s)
# Pseudo-Licht: Normale × feste Sonnenrichtung → Hillshade.
var nd := clampf(normal.normalized().dot(RELIEF_SUN.normalized()), 0.0, 1.0)
var x := clampf((nd - LIGHT_T0) / (LIGHT_T1 - LIGHT_T0), 0.0, 1.0)
var lf := lerpf(LIGHT_LOW, LIGHT_HIGH, x * x * (3.0 - 2.0 * x))
# Ausbrennen-Fix (t_7a0a6b79): der Faktor wird auf 1.0 normalisiert. Vorher
# lief hf·lf bis 1.35 und RGBA8 klemmte auf 1.0 — ALLE Relief-Unterschiede
# oberhalb 1.0 kollabierten zu flachem Weiß (Slys „ausgebrannte Weißflächen“
# auf Sand hell/Schnee). Relative Spanne bleibt identisch (0.40…1.35 →
# 0.296…1.0), Kontrast-Verhältnis unverändert, Shading bleibt sichtbar.
var f := clampf(hf * lf, SHADE_MIN, SHADE_MAX) / SHADE_MAX
var base := MATERIAL_COLORS[m] as Color
# Kanäle auf 0..1 klemmen: der Mesh-Speicher (ARRAY_COLOR, RGBA8) clamp't
# genauso — der Helfer muss die RENDER-Wahrheit liefern (Probe-Vergleich),
# nicht die unbehandelte Mathematik.
return Color(clampf(base.r * f, 0.0, 1.0), clampf(base.g * f, 0.0, 1.0),
clampf(base.b * f, 0.0, 1.0), float(m) / float(MATERIAL_COLORS.size()))
func triangle_count() -> int:
return _triangles
func resolution() -> int:
return _res
## Splat-Gewichtstextur komplett ableiten (t_7a0a6b79). Die Zell-Schicht
## (mats, res²) bleibt die Quelle der Wahrheit — die Textur ist reine
## Ableitung (Datenformat unverändert). Unbrauchbare Schicht → alte Textur
## bleibt (Robustheit, kein Crash).
## size_x/size_z (t_5c94d098): Karten-Kantenlänge in Metern → Texel-Dichte der
## Splat (1 m/Texel bei 1024 m; vorher 2 m/Texel = sichtbare Stufen).
func _build_splat(mat: ShaderMaterial, mats: PackedByteArray, res: int,
_size_x: float = 0.0, _size_z: float = 0.0) -> void:
if res < 2:
_splat_tex = null
return
# Karte 25.10.: Material-ID-Karte statt Splat-Gewichte (weiche Übergänge
# pro Pixel im Shader, Spiegel zum Client).
_splat_image = TerrainArrays.material_image(mats, res)
_splat_side = _splat_image.get_width()
_splat_res = res
_splat_tex = ImageTexture.create_from_image(_splat_image)
# Misch-Schicht: passt sie nicht zum Raster (Legacy/leer) → Nullen.
if _mix_b_cache.size() != res * res or _mix_w_cache.size() != res * res:
_mix_b_cache = PackedByteArray()
_mix_b_cache.resize(res * res)
_mix_w_cache = PackedByteArray()
_mix_w_cache.resize(res * res)
_mix_b_image = TerrainArrays.material_image(_mix_b_cache, res)
_mix_w_image = TerrainArrays.material_image(_mix_w_cache, res)
_mix_b_tex = ImageTexture.create_from_image(_mix_b_image)
_mix_w_tex = ImageTexture.create_from_image(_mix_w_image)
if mat != null:
mat.set_shader_parameter("material_map", _splat_tex)
mat.set_shader_parameter("material_b_map", _mix_b_tex)
mat.set_shader_parameter("material_w_map", _mix_w_tex)
mat.set_shader_parameter("material_res", float(_splat_side))