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))