diff --git a/assets/textures/terrain/.gdignore b/assets/textures/terrain/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/assets/textures/terrain/terrain_arrays.res b/assets/textures/terrain/terrain_arrays.res index d3fcec4..7dd16dd 100644 Binary files a/assets/textures/terrain/terrain_arrays.res and b/assets/textures/terrain/terrain_arrays.res differ diff --git a/library/terrain_arrays.gd b/library/terrain_arrays.gd index 6509e61..255eb6c 100644 --- a/library/terrain_arrays.gd +++ b/library/terrain_arrays.gd @@ -16,3 +16,141 @@ class_name TerrainArrays @export var albedo_images: Array[Image] = [] @export var normal_images: Array[Image] = [] @export var tile_size := 1.0 # Meter pro Kachel (Worldspace-Tiling) + + +# ============================ SPLAT-GEWICHTS-ABLEITUNG ====================== +# Karte t_7a0a6b79 (weiche Übergänge): der alte Shader pickte pro Pixel GENAU +# EINE Textur-Schicht über die Vertex-Alpha (floor+0.5-Snap) — an Dreieck- +# kanten sprang die GPU-interpolierte Alpha hart zwischen Material-IDs +# (blockartige, dreieckstreppige Grenzen). NEU: aus der Zell-Schicht +# (materials_r8, res²) wird eine SPLAT-GEWICHTS-TEXTUR abgeleitet — je Texel +# die Top-2-Zell-IDs mit bilinearen Kernel-Gewichten: +# R = id0 (0..N-1), G = w0, B = id1, A = w1 (w0 + w1 = 1 normalisiert) +# Der Shader sampelt die Splat-Textur (FILTER_NEAREST — IDs dürfen NICHT +# bilinear vermischt werden; die Weichheit steckt IN den Gewichten) und mischt +# die zwei Layer: mix(tex(id1), tex(id0), w0). Datenformat UNVERÄNDERT — die +# Zell-Schicht bleibt die Quelle der Wahrheit. +# +# Texel-Dichte: SPLAT_TEXELS_PER_CELL Texel je Terrain-Zelle (4 × res−1), +# als Zweierpotenz geklemmt auf [SPLAT_SIDE_MIN, SPLAT_SIDE_MAX] — beim Malen +# kann dann ein kleiner Sub-Rect neu abgeleitet werden (Live-Pfad). +const SPLAT_TEXELS_PER_CELL := 4 +const SPLAT_SIDE_MIN := 256 +const SPLAT_SIDE_MAX := 1024 + + +## Splat-Textur-Kantengröße für ein res×res-Zellraster (Zweierpotenz). +static func splat_side_for(res: int) -> int: + var target := maxi(1, res - 1) * SPLAT_TEXELS_PER_CELL + var side := SPLAT_SIDE_MIN + while side < target: + side *= 2 + return mini(side, SPLAT_SIDE_MAX) + + +## Splat-Gewichts-Bytes (RGBA8, row-major) für die Zell-Schicht ableiten. +## rect = Texel-Rechteck (position/size im Bild) — leer = ganzes Bild. +## Bilinearer Kernel über die 4 umliegenden Zellen: an einer Zellgrenze rampen +## die Gewichte weich 0→1 (Weichheit = eine Zellbreite, wie in der Spec). +## Test-Vertrag: identische Formel in Client UND Editor (Spiegel-Datei). +static func build_splat_bytes(mats: PackedByteArray, res: int, side: int, + rect: Rect2i = Rect2i()) -> PackedByteArray: + var out := PackedByteArray() + if res < 2 or side < 1 or mats.size() < res * res: + return out + var rx := 0 + var ry := 0 + var rw := side + var rh := side + if rect.size.x > 0 and rect.size.y > 0: + rx = clampi(rect.position.x, 0, side - 1) + ry = clampi(rect.position.y, 0, side - 1) + rw = clampi(rect.size.x, 1, side - rx) + rh = clampi(rect.size.y, 1, side - ry) + out.resize(rw * rh * 4) + var last := float(res - 1) + # Achsen-Gewichte einmal vorrechnen (hängen nur von der Texel-Spalte/Zeile ab): + var ix0 := PackedInt32Array() + var ix1 := PackedInt32Array() + var fx := PackedFloat32Array() + ix0.resize(rw) + ix1.resize(rw) + fx.resize(rw) + for i in rw: + var t := (float(rx + i) + 0.5) / float(side) * last + var i0 := clampi(int(floorf(t)), 0, res - 1) + ix0[i] = i0 + ix1[i] = mini(i0 + 1, res - 1) + fx[i] = t - float(i0) + var iz0 := PackedInt32Array() + var iz1 := PackedInt32Array() + var fz := PackedFloat32Array() + iz0.resize(rh) + iz1.resize(rh) + fz.resize(rh) + for j in rh: + var t := (float(ry + j) + 0.5) / float(side) * last + var j0 := clampi(int(floorf(t)), 0, res - 1) + iz0[j] = j0 + iz1[j] = mini(j0 + 1, res - 1) + fz[j] = t - float(j0) + var ofs := 0 + for j in rh: + var wz0 := 1.0 - fz[j] + var wz1 := fz[j] + var row0 := iz0[j] * res + var row1 := iz1[j] * res + for i in rw: + var wx0 := 1.0 - fx[i] + var wx1 := fx[i] + # ≤ 4 Zellen mit bilinearen Gewichten; Duplikat-IDs zusammenziehen: + var ids := PackedInt32Array() + var ws := PackedFloat32Array() + var cells := PackedInt32Array([ + row0 + ix0[i], row0 + ix1[i], row1 + ix0[i], row1 + ix1[i]]) + var weights := [wx0 * wz0, wx1 * wz0, wx0 * wz1, wx1 * wz1] + for c in 4: + var id := int(mats[cells[c]]) + var w: float = weights[c] + var found := false + for k in ids.size(): + if ids[k] == id: + ws[k] += w + found = true + break + if not found and w > 0.0: + ids.append(id) + ws.append(w) + # Top-2 IDs nach Gewicht (Fenster von max. 4 Kandidaten): + var id0 := ids[0] + var w0 := ws[0] + var id1 := id0 + var w1 := 0.0 + for k in range(1, ids.size()): + if ws[k] > w0: + id1 = id0 + w1 = w0 + id0 = ids[k] + w0 = ws[k] + elif ws[k] > w1 or id1 == id0: + id1 = ids[k] + w1 = ws[k] + # Normalisieren (w0 + w1 = 1) und 8-bit-packen: + var wsum := maxf(w0 + w1, 0.0001) + var b0 := clampi(roundi(w0 / wsum * 255.0), 0, 255) + out.encode_u8(ofs, clampi(id0, 0, 255)) + out.encode_u8(ofs + 1, b0) + out.encode_u8(ofs + 2, clampi(id1, 0, 255)) + out.encode_u8(ofs + 3, 255 - b0) + ofs += 4 + return out + + +## Ein Splat-Texel dekodieren (für Tests): → [id0, w0, id1, w1] (w0 ∈ 0..1). +static func decode_splat_texel(bytes: PackedByteArray, texel_ofs: int) -> Array: + return [ + int(bytes.decode_u8(texel_ofs)), + float(bytes.decode_u8(texel_ofs + 1)) / 255.0, + int(bytes.decode_u8(texel_ofs + 2)), + float(bytes.decode_u8(texel_ofs + 3)) / 255.0, + ] diff --git a/src/editor/terrain_tool.gd b/src/editor/terrain_tool.gd index 3f1a8a0..8b318be 100644 --- a/src/editor/terrain_tool.gd +++ b/src/editor/terrain_tool.gd @@ -239,7 +239,11 @@ func _process(delta: float) -> void: if _stroke_mode == TerrainField.Mode.PAINT: var painted := _paint_step(_cursor.x, _cursor.z) if painted: - view_builder.call("update_terrain", _heights, _mats) + # Karte t_7a0a6b79: Malen lebt im Sub-Rect — update_terrain bekommt + # das betroffene Zellrechteck (inkl. Spiegelzellen) und leitet nur + # die Splat-Region neu ab; das Mesh selbst bleibt unangetastet + # (Vertex-Farben hängen NICHT vom Material ab). + view_builder.call("update_terrain", _heights, _mats, _splat_rect) _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 return var amount: float @@ -424,6 +428,9 @@ func _height_step(x: float, z: float, amount: float) -> bool: ## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie. func _paint_step(x: float, z: float) -> bool: var g := _world_to_grid(x, z) + # VOR dem Pinsel: Zellrect des Fußabdrucks merken (deckt Spiegelzellen mit) + # — der Live-Splat-Refresh (t_7a0a6b79) leitet genau diese Region neu ab. + _splat_rect = _splat_region(g) var snap := {} if _sym_active() and _mats.size() >= _res * _res: snap = _snap_mats(_brush_region(g)) @@ -558,3 +565,27 @@ func terrain_mesh_color(id: int) -> Color: return Color(0.45, 0.44, 0.42) # Fels _: return Color(0.30, 0.45, 0.22) # Gras + + +# ------------------------------------------------ Live-Splat-Sub-Rect (Karte +# t_7a0a6b79): Zellrechteck des letzten Mal-Schritts für das Live-Update der +# Splat-Gewichtstextur. Am Dateiende angefügt (Karten-Regel). + +## Zellrect des letzten Mal-Schritts (Pinsel-Fußabdruck ∪ gespiegelte +## Schwesterzellen) — vom Stroke-Code als 3. Parameter an update_terrain +## (view_builder) weitergereicht. +var _splat_rect := Rect2i() + + +## Zellrect des Pinsel-Fußabdrucks (∪ Spiegelzellen): dieselben Region- +## Grenzen wie apply_material_brush, mit dem SYM_REGION_MARGIN-Rand — der +## deckt auch die Kernel-Nachbarschaft der Splat-Ableitung mit ab. +func _splat_region(g: Vector2) -> Rect2i: + var rect := _brush_region(g) + var sym := _sym_mode() + if sym == SymmetryCore.Mode.OFF: + return rect + var a := SymmetryCore.mirror_cell(sym, rect.position.x, rect.position.y, _res) + var b := SymmetryCore.mirror_cell(sym, rect.end.x - 1, rect.end.y - 1, _res) + return rect.merge(Rect2i(mini(a.x, b.x), mini(a.y, b.y), + absi(a.x - b.x) + 1, absi(a.y - b.y) + 1)) diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 063fd6a..ece8775 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -192,9 +192,13 @@ func _build_wasser(data: Dictionary) -> void: ## mats (optional) = Material-Schicht (Karte t_603ec1a8, Malen-Modus) — ## leer = terrain_mesh verwendet die zuletzt gesehene Schicht weiter (Cache, ## Karte t_55ed041a); nur bei NIE gesehener Schicht (Legacy-v1) gilt Gras. -func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void: +## splat_rect_cells (optional, Karte t_7a0a6b79) = Zellrechteck des Mal- +## Schritts (inkl. Spiegelzellen) — terrain_mesh leitet nur die Splat-Region +## neu ab (Sub-Rect-Update, kein Voll-Rebuild pro Mal-Frame). +func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray(), + splat_rect_cells: Rect2i = Rect2i()) -> void: if terrain_view != null and is_instance_valid(terrain_view): - terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats) + terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats, splat_rect_cells) ## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und diff --git a/src/viewport/terrain_contour.gdshader b/src/viewport/terrain_contour.gdshader index 8dd145b..a87fccb 100644 --- a/src/viewport/terrain_contour.gdshader +++ b/src/viewport/terrain_contour.gdshader @@ -28,10 +28,24 @@ uniform float layer_count = 20.0; uniform float tile_size = 1.0; uniform float texturen_an = 0.0; // 1.0 = Texturen aktiv (vom Mesh-Code gesetzt) +// Splat-Gewichtstextur (Karte t_7a0a6b79 — weiche Übergänge): je Texel die +// Top-2-Zell-IDs + bilineare Gewichte, von TerrainArrays.build_splat_bytes +// abgeleitet (Spiegel-Datei, identische Formel im Client). R=id0, G=w0, +// B=id1, A=w1 (w0+w1=1). FILTER_NEAREST: IDs dürfen NICHT bilinear vermisch +// werden — die Weichheit steckt IN den Gewichten, nicht in der Filterung. +// Ohne source_color-Hint: raw-Bytes, id = round(kanal × 255). +uniform sampler2D splat_map : filter_nearest, repeat_disable; +uniform float map_size_x = 1.0; // Terrain-Kantenlänge in Weltmetern +uniform float map_size_z = 1.0; + varying vec3 world_pos; +varying vec2 map_uv; void vertex() { world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + // UV 0..1 über die Map (Index 0 = −X/−Z-Ecke, konventionstreu zum Mesh-Bau). + map_uv = vec2((world_pos.x + 0.5 * map_size_x) / max(map_size_x, 0.001), + (world_pos.z + 0.5 * map_size_z) / max(map_size_z, 0.001)); } void fragment() { @@ -41,10 +55,16 @@ void fragment() { // Normal-Map würde ohne Licht ohnehin nichts zeigen — die Relief-Optik // kommt aus dem gebackenen Hillshade in COLOR.rgb). if (texturen_an > 0.5) { - float mat_idx = clamp(floor(COLOR.a * layer_count + 0.5), 0.0, layer_count - 1.0); + // Top-2-Splat-Blend (t_7a0a6b79): die Weichheit steckt in den Gewich- + // ten der Splat-Textur — an Zellgrenzen rampen die IDs weich (bilinear + // über die 4 umliegenden Zellen), im Flachbereich w0=1 (reines id0). + vec4 sp = texture(splat_map, map_uv); + float id0 = clamp(floor(sp.r * 255.0 + 0.5), 0.0, layer_count - 1.0); + float id1 = clamp(floor(sp.b * 255.0 + 0.5), 0.0, layer_count - 1.0); vec2 uv = world_pos.xz / max(tile_size, 0.05); - vec4 alb = texture(albedo_array, vec3(uv, mat_idx)); - col = alb.rgb * col; // Tönung/Shading multipliziert (wie Client) + vec4 alb0 = texture(albedo_array, vec3(uv, id0)); + vec4 alb1 = texture(albedo_array, vec3(uv, id1)); + col = mix(alb1.rgb, alb0.rgb, sp.g) * col; // Tönung/Shading multipliziert (wie Client) } // --- Konturlinien (unverändert, Karte t_3ebe4086) --- float h = UV2.x; diff --git a/src/viewport/terrain_mesh.gd b/src/viewport/terrain_mesh.gd index ab7054e..e0b5929 100644 --- a/src/viewport/terrain_mesh.gd +++ b/src/viewport/terrain_mesh.gd @@ -85,6 +85,16 @@ 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 + func _ready() -> void: _ensure_mesh_instance() @@ -165,6 +175,9 @@ func rebuild_from_doc(data: Dictionary) -> void: 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 # 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) @@ -182,7 +195,8 @@ func rebuild_from_doc(data: Dictionary) -> void: ## 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()) -> void: + size_z: float = 0.0, mats: PackedByteArray = PackedByteArray(), + splat_rect_cells: Rect2i = Rect2i()) -> void: if res < 2: res = _res if size_x <= 0.0: @@ -296,6 +310,20 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0 _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) + if splat_rect_cells.size.x > 0 and _splat_tex != null and _splat_res == res: + _refresh_splat_region(splat_rect_cells) + elif _splat_tex == null or _splat_res != res: + _build_splat(shm, mats, res) + ## Höhenlinien-Uniform an die aktuelle Situation anpassen (Pref ∧ Relief ≥ 2 m). func _apply_contour_state(span_ok: bool) -> void: @@ -327,7 +355,12 @@ static func relief_color(mat_id: int, h: float, hmin: float, hmax: float, 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)) - var f := clampf(hf * lf, SHADE_MIN, SHADE_MAX) + # 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), @@ -342,3 +375,45 @@ func triangle_count() -> int: 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). +func _build_splat(mat: ShaderMaterial, mats: PackedByteArray, res: int) -> void: + if res < 2: + _splat_tex = null + return + var side := TerrainArrays.splat_side_for(res) + var bytes := TerrainArrays.build_splat_bytes(mats, res, side) + if bytes.size() != side * side * 4: + return + _splat_image = Image.create_from_data(side, side, false, Image.FORMAT_RGBA8, bytes) + _splat_side = side + _splat_res = res + _splat_tex = ImageTexture.create_from_image(_splat_image) + if mat != null: + mat.set_shader_parameter("splat_map", _splat_tex) + + +## Live-Pfad beim Malen (t_7a0a6b79): NUR die betroffene Region der Splat- +## Textur neu ableiten (dieselben Region-Grenzen wie apply_material_brush, +## Zellrechteck inkl. Spiegelzellen; hier in Texel umgerechnet). Die Gewichte +## brauchen den Kernel-Umfeld-Zugriff auf die GESAMTE Schicht (_mats_cache). +func _refresh_splat_region(cell_rect: Rect2i) -> void: + if _splat_image == null or _splat_res < 2: + return + var last := float(_splat_res - 1) + var tpc := float(_splat_side) / last # Texel je Zelle + var tx0 := clampi(int(floorf(float(cell_rect.position.x) * tpc)) - 1, 0, _splat_side - 1) + var ty0 := clampi(int(floorf(float(cell_rect.position.y) * tpc)) - 1, 0, _splat_side - 1) + var tx1 := clampi(int(ceilf(float(cell_rect.end.x) * tpc)) + 1, tx0 + 1, _splat_side) + var ty1 := clampi(int(ceilf(float(cell_rect.end.y) * tpc)) + 1, ty0 + 1, _splat_side) + var rect := Rect2i(tx0, ty0, tx1 - tx0, ty1 - ty0) + var bytes := TerrainArrays.build_splat_bytes(_mats_cache, _splat_res, _splat_side, rect) + if bytes.size() != rect.size.x * rect.size.y * 4: + return + var sub := Image.create_from_data(rect.size.x, rect.size.y, false, Image.FORMAT_RGBA8, bytes) + _splat_image.blit_rect(sub, Rect2i(0, 0, rect.size.x, rect.size.y), rect.position) + _splat_tex.update(_splat_image) diff --git a/tests/probe_arrays_load.gd.uid b/tests/probe_arrays_load.gd.uid new file mode 100644 index 0000000..cd07dad --- /dev/null +++ b/tests/probe_arrays_load.gd.uid @@ -0,0 +1 @@ +uid://bi0m3dxhvagy diff --git a/tests/test_terrain_mirror.gd b/tests/test_terrain_mirror.gd index 6107ba9..c48a26d 100644 --- a/tests/test_terrain_mirror.gd +++ b/tests/test_terrain_mirror.gd @@ -84,10 +84,13 @@ class ToolBuilder: var last_heights := PackedFloat32Array() var last_mats := PackedByteArray() var calls := 0 + var last_splat_rect := Rect2i() # t_7a0a6b79: Live-Splat-Sub-Rect - func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void: + func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray(), + splat_rect: Rect2i = Rect2i()) -> void: last_heights = heights last_mats = mats + last_splat_rect = splat_rect calls += 1 @@ -456,6 +459,17 @@ func _test_live_rebuild_inputs() -> void: var paint_mats: PackedByteArray = builder.get("last_mats") _check(_count_asym_mats(paint_mats, SymmetryCore.Mode.POINT) == 0, "Live-Mal-Frame: Mesh-Input-Material bereits gespiegelt") + # Live-Splat-Sub-Rect (t_7a0a6b79): das Rechteck muss Mal-Zelle UND die + # gespiegelte Schwesterzelle abdecken (der Sub-Rect-Refresh leitet genau + # diese Region neu ab — sonst bliebe die Spiegelseite optisch alt). + var splat_rect: Rect2i = builder.get("last_splat_rect") + var painted_cell := _world_to_cell(Vector2(-100.0, -60.0)) + var mirrored_cell := SymmetryCore.mirror_cell( + SymmetryCore.Mode.POINT, painted_cell.x, painted_cell.y, RES) + _check(splat_rect.size.x > 0 and splat_rect.has_point(Vector2i(painted_cell.x, painted_cell.y)) + and splat_rect.has_point(Vector2i(mirrored_cell.x, mirrored_cell.y)), + "Live-Mal-Frame: Splat-Sub-Rect deckt Mal-Zelle (%d,%d) + Spiegelzelle (%d,%d)" % [ + painted_cell.x, painted_cell.y, mirrored_cell.x, mirrored_cell.y]) ttool.call("_end_stroke") _check(int(tmain.get("commits")) == 2, "Zwei Striche → zwei Commits (Undo-Vertrag bleibt)") diff --git a/tests/test_terrain_splat.gd b/tests/test_terrain_splat.gd new file mode 100644 index 0000000..8987ea1 --- /dev/null +++ b/tests/test_terrain_splat.gd @@ -0,0 +1,169 @@ +extends SceneTree +## Splat-Gewichts-Ableitung (Karte t_7a0a6b79 — weiche Übergänge). +## Diese Datei ist SPIEGEL-IDENTISCH in Client UND Editor +## (client/tests/test_terrain_splat.gd ↔ editor/tests/test_terrain_splat.gd): +## beide Suiten rechnen dieselben Referenzwerte gegen dieselbe +## TerrainArrays.build_splat_bytes-Formel — grün in beiden Repos beweist die +## identische Formel beider Seiten (Spiegel-Datei-Vertrag, wie terrain_arrays.gd). +## Ausführen: godot --headless --path . -s res://tests/test_terrain_splat.gd +## Exit 0 = alles grün. +## +## Referenz-Fall (exakt rechenbar, Zweierpotenz-Koordinaten): +## 4×4 Zellen, alles ID 2 außer Zelle (2,2) = 7; side = 8. +## Texel (6,6): t = 6.5/8·3 = 2.4375 (exakt) → fx = fz = 0.4375 (exakt) +## → w(id2) = 0.80859375, w(id7) = 0.19140625 → Bytes [2, 206, 7, 49]. + +var failures := 0 +var checks := 0 + + +func _init() -> void: + _run() + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) + + +func _run() -> void: + _test_side_for() + _test_uniform_region() + _test_reference_texel() + _test_boundary_ramp() + _test_sub_rect_equality() + _test_three_meeting_cells() + + +func _test_side_for() -> void: + print("Testgruppe: splat_side_for (Texel-Dichte, Zweierpotenz, geklemmt)") + _check(TerrainArrays.splat_side_for(2) == 256, "res 2 → 256 (Minimum)") + _check(TerrainArrays.splat_side_for(33) == 256, "res 33 → 256 (Ziel 128 < 256)") + _check(TerrainArrays.splat_side_for(65) == 256, "res 65 → 256 (Ziel 256)") + _check(TerrainArrays.splat_side_for(129) == 512, "res 129 → 512 (Ziel 512)") + _check(TerrainArrays.splat_side_for(257) == 1024, "res 257 → 1024 (Ziel 1024)") + _check(TerrainArrays.splat_side_for(513) == 1024, "res 513 → 1024 (Maximum-Klemme)") + + +func _test_uniform_region() -> void: + print("Testgruppe: Einmaterial-Fläche → eine ID, w0 = 1") + var mats := PackedByteArray() + mats.resize(4 * 4) + mats.fill(2) + var bytes := TerrainArrays.build_splat_bytes(mats, 4, 8) + _check(bytes.size() == 8 * 8 * 4, "Bytes: 8² Texel × 4 Kanäle") + var t := TerrainArrays.decode_splat_texel(bytes, 0) + _check(t[0] == 2 and t[1] == 1.0 and t[2] == 2 and t[3] == 0.0, + "Eck-Texel: id0=2, w0=1, id1=2 (Platzhalter), w1=0") + + +func _test_reference_texel() -> void: + print("Testgruppe: Referenz-Texel (exakt rechenbar, Spiegel-Vertrag)") + # VERTEX-Konvention: die Material-Schicht indiziert MESH-VERTEXE (res², + # row-major wie die Heightmap). Texel (6,6), res 4, side 8: + # t = 6.5/8·3 = 2.4375 (exakt) → zwischen Vertex 2 und 3, fx = fz = 0.4375. + # Vertex (2,2) trägt ID 7 → w(7) = 0.5625² = 0.31640625 (exakt), + # w(2) = 0.68359375 → 8-bit: 174 + 81 = 255. + var mats := PackedByteArray() + mats.resize(4 * 4) + mats.fill(2) + mats[2 * 4 + 2] = 7 + var bytes := TerrainArrays.build_splat_bytes(mats, 4, 8) + var t := TerrainArrays.decode_splat_texel(bytes, (6 * 8 + 6) * 4) + _check(t[0] == 2, "Referenz-Texel (6,6): id0 = 2 (got %d)" % t[0]) + _check(t[1] > 0.67 and t[1] < 0.70, "Referenz-Texel (6,6): w0 ≈ 0.6836 (got %.4f)" % t[1]) + _check(t[2] == 7, "Referenz-Texel (6,6): id1 = 7 (got %d)" % t[2]) + _check(t[3] > 0.30 and t[3] < 0.33, "Referenz-Texel (6,6): w1 ≈ 0.3164 (got %.4f)" % t[3]) + # 8-bit-Vertrag: w0 + w1 ≈ 1 (Bytes 174 + 81 = 255). + var raw := (bytes.decode_u8((6 * 8 + 6) * 4 + 1) + + bytes.decode_u8((6 * 8 + 6) * 4 + 3)) + _check(raw == 255, "Referenz-Texel (6,6): Gewichts-Bytes summieren auf 255 (got %d)" % raw) + + +func _test_boundary_ramp() -> void: + print("Testgruppe: Zellgrenze → weiche Rampe (keine Snap-Kante)") + # 5×5 VERTEXE: x < 3 → id 1, sonst id 3. Grenze zwischen Vertex 2 und 3 — + # die bilineare Rampe kreuzt 0.5 genau bei t = 2.5 (Mitte zwischen den + # Vertex-Positionen); Rampenbreite = ein Zell-Abstand (wie in der Spec). + var res := 5 + var mats := PackedByteArray() + mats.resize(res * res) + for z in res: + for x in res: + mats[z * res + x] = 1 if x < 3 else 3 + var side := 64 + var bytes := TerrainArrays.build_splat_bytes(mats, res, side) + # Texel 39: t = 2.46875 → fx = 0.469 → w(id1) = 0.531; Texel 40: t = 2.53125 + # → w(id1) = 0.469 — die dominante ID wechselt EXAKT an der Mitte. + var t39 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 39) * 4) + var t40 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 40) * 4) + _check(t39[0] == 1 and t39[2] == 3, "Grenz-Texel 39: IDs {1, 3} (got %d/%d)" % [t39[0], t39[2]]) + _check(t40[0] == 3 and t40[2] == 1, "Grenz-Texel 40: IDs {3, 1} (gewechselt, got %d/%d)" % [t40[0], t40[2]]) + _check(absf(t39[1] - 0.53125) < 0.004, "Grenz-Texel 39: w0 ≈ 0.531 (got %.4f)" % t39[1]) + _check(absf(t40[1] - 0.53125) < 0.004, "Grenz-Texel 40: w0 ≈ 0.531 (got %.4f)" % t40[1]) + _check(absf((t39[1] + t40[3]) - 1.0) < 0.01, "Grenz-Texel 39/40 spiegeln sich an t = 2.5") + # Weit weg von der Grenze: reine Seite (w0 = 1): + var t8 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 8) * 4) + _check(t8[0] == 1 and t8[1] > 0.999, "Flachbereich links: w0 = 1 (got %.4f)" % t8[1]) + # Kein Snap: das gewichtete ID-Feld (w0·id0 + w1·id1) steigt über die + # Grenze monoton und ohne Ein-Texel-Sprung (> 0.3 wäre ein Snap-Muster). + var prev := -1.0 + var ok_ramp := true + for tx in range(34, 46): + var t := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + tx) * 4) + var blended: float = float(t[0]) * t[1] + float(t[2]) * t[3] + if prev >= 0.0 and (blended < prev - 0.0001 or blended - prev > 0.3): + ok_ramp = false + prev = blended + _check(ok_ramp, "Rampe über die Grenze monoton, ohne Ein-Texel-Sprung") + + +func _test_sub_rect_equality() -> void: + print("Testgruppe: Sub-Rect-Ableitung == Voll-Ableitung (Live-Pfad-Vertrag)") + var res := 8 + var mats := PackedByteArray() + mats.resize(res * res) + for i in mats.size(): + mats[i] = (i * 7 + 3) % 5 # deterministisches Muster mit 5 IDs + var side := 32 + var full := TerrainArrays.build_splat_bytes(mats, res, side) + var same := true + for rect: Rect2i in [Rect2i(0, 0, 16, 8), Rect2i(8, 8, 12, 20), Rect2i(24, 0, 8, 32), Rect2i(5, 3, 7, 9)]: + var part := TerrainArrays.build_splat_bytes(mats, res, side, rect) + if part.size() != rect.size.x * rect.size.y * 4: + same = false + break + for j in rect.size.y: + for i in rect.size.x: + var fofs := ((rect.position.y + j) * side + rect.position.x + i) * 4 + for c in 4: + if part[j * rect.size.x * 4 + i * 4 + c] != full[fofs + c]: + same = false + _check(same, "Sub-Rect-Bytes byte-identisch zum Voll-Bild (4 Rechtecke)") + + +func _test_three_meeting_cells() -> void: + print("Testgruppe: >2 IDs am Treffpunkt → Top-2-Gewichte (Spec-Vertrag)") + # 2×2-Zellen-Beispiel: Zelle (0,0)=1, (1,0)=2, (0,1)=3, (1,1)=4; res 2, side 8. + # Texel (3,3): t = 3.5/8·1 = 0.4375 → fx = fz = 0.4375. + # w: id1 0.3164, id2 0.2461, id3 0.2461, id4 0.1914 → Top-2 = {1, 2} + # (Gleichstand 2/3: kleinerer Index, deterministisch); id4 (0.1914) wird + # bewusst verworfen (Zweikant-Fälle). Normalisiert: 0.3164/0.5625 = 0.5625. + var mats := PackedByteArray([1, 2, 3, 4]) + var bytes := TerrainArrays.build_splat_bytes(mats, 2, 8) + var t := TerrainArrays.decode_splat_texel(bytes, (3 * 8 + 3) * 4) + _check(t[0] == 1 and t[1] > 0.55 and t[1] < 0.58, + "Treffpunkt: id0=1, w0 ≈ 0.5625 (normalisiert, got %.4f)" % t[1]) + _check(t[2] == 2, "Treffpunkt: id1 = 2 (zweitgrößter, Gleichstand → kleinerer Index, got %d)" % t[2]) + _check(t[3] > 0.42 and t[3] < 0.45, "Treffpunkt: w1 ≈ 0.4375 (got %.4f)" % t[3]) + _check(t[1] + t[3] > 0.99, "Treffpunkt: Top-2-Gewichte normalisiert (Summe %.4f)" % (t[1] + t[3]))