extends Node3D ## 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 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: _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 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) 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-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) ## 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))