From 4e973795f6c9fb38ec925d400ea5f94ab86eb649 Mon Sep 17 00:00:00 2001 From: slyphax Date: Sun, 25 Oct 2026 00:43:26 +0200 Subject: [PATCH] =?UTF-8?q?Malen:=20echter=20weicher=20=C3=9Cbergang=20am?= =?UTF-8?q?=20Pinselrand=20(Misch-Schicht=20statt=20Streuung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sly 25.10.: „weich“ soll ein Textur-Verlauf am Pinselrand sein, nicht fleckig; gerade Strichkanten wirkten weiter hart (1-m-Übergang). - Format (optional, alte Karten gültig): materials_b_r8_b64 (zweites Material je Punkt) + materials_w_r8_b64 (Anteil 0–255); ohne Mischung fallen die Felder weg. V15 prüft Länge/IDs/Paarigkeit. - TerrainField.apply_material_brush_mix: innen voll, außen Smoothstep-Anteil; nur verstärken, nie abschwächen; Gras drübermalen mischt zurück. - Shader: Top-2 aus 4 Nachbarpunkten × (a, b) mit Anteilen → nur noch 2 Albedo-Fetches; Warp bleibt. - Pinsel/Live-Vorschau, Spiegeln, Symmetrie-Analyse/-Anwenden, Resize, Anheben, Undo (terrain-Snapshot) tragen die Misch-Schicht mit. - Streu-Rand (vorheriger Commit) entfernt. - Tests: test_mal_fein 30/30 (Verlauf fällt weich, nie abschwächen, zurückmischen, Kodierung), Editor-Suite unverändert grün bis auf die bekannten Umgebungs-Tests; Kette Editor → Server → Client per run_map_download_e2e 15/15 mit gemischter Karte. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/editor/symmetry_ops.gd | 17 +++++- src/editor/terrain_tool.gd | 39 ++++++++++--- src/svmap/map_document.gd | 13 ++++- src/svmap/svmap_io.gd | 71 +++++++++++++++++++++++ src/svmap/terrain_field.gd | 82 +++++++++++++++++++-------- src/viewport/map_view_builder.gd | 6 ++ src/viewport/terrain_contour.gdshader | 71 ++++++++++++++++++++--- src/viewport/terrain_mesh.gd | 36 +++++++++++- tests/probe_fein_export.gd | 8 ++- tests/probe_mal_weich_render.gd | 13 ++++- tests/test_mal_fein.gd | 62 +++++++++++++------- tests/test_terrain_mirror.gd | 4 +- 12 files changed, 349 insertions(+), 73 deletions(-) diff --git a/src/editor/symmetry_ops.gd b/src/editor/symmetry_ops.gd index 4aebe3f..117f404 100644 --- a/src/editor/symmetry_ops.gd +++ b/src/editor/symmetry_ops.gd @@ -480,13 +480,18 @@ static func _analyze_terrain(doc: Dictionary, mode: int, an: Dictionary) -> void var mats := SvmapIO.decode_materials(terrain) if mres < 2 or mats.size() < mres * mres: return + var mix := SvmapIO.decode_mix(terrain) # weiche Ränder: b + Anteil mitvergleichen + var mb: PackedByteArray = mix[0] + var mw: PackedByteArray = mix[1] var mcells: Array = an["material_cells"] for j in mres: for i in mres: var mc := SymmetryCore.mirror_cell(mode, i, j, mres) if not (i < mc.x or (i == mc.x and j < mc.y)): continue - if mats[j * mres + i] != mats[mc.y * mres + mc.x]: + var ia := j * mres + i + var ib := mc.y * mres + mc.x + if mats[ia] != mats[ib] or mb[ia] != mb[ib] or mw[ia] != mw[ib]: mcells.append(Vector2i(i, j)) if mcells.size() <= MAX_EXAMPLES: _push_dev(an, "Boden-Material (%d,%d)" % [i, j]) @@ -685,14 +690,22 @@ static func _apply_materials(doc: Dictionary, mode: int, an: Dictionary, var mats := SvmapIO.decode_materials(terrain) if mres < 2 or mats.size() < mres * mres: return + var mix := SvmapIO.decode_mix(terrain) + var mb: PackedByteArray = mix[0] + var mw: PackedByteArray = mix[1] for cell in cells: var cc: Vector2i = cell var mc := SymmetryCore.mirror_cell(mode, cc.x, cc.y, mres) var src := cc if not quelle_hoch else mc var dst := mc if not quelle_hoch else cc - mats[dst.y * mres + dst.x] = mats[src.y * mres + src.x] + var di := dst.y * mres + dst.x + var si := src.y * mres + src.x + mats[di] = mats[si] + mb[di] = mb[si] + mw[di] = mw[si] c["terrain_cells"] = int(c["terrain_cells"]) + 1 terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) + SvmapIO.encode_mix(terrain, mb, mw) static func _apply_spawns(doc: Dictionary, mode: int, an: Dictionary, diff --git a/src/editor/terrain_tool.gd b/src/editor/terrain_tool.gd index 1b942a3..54a2a2f 100644 --- a/src/editor/terrain_tool.gd +++ b/src/editor/terrain_tool.gd @@ -45,6 +45,8 @@ var _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_ # Mal-Strich schreibt live hinein. var _res := 0 var _mres := 0 # Raster der Material-Schicht (feines Raster, Karte 24.10.; alt = _res) +var _mix_b := PackedByteArray() # zweites Material je Punkt (weiche Ränder, Karte 25.10.) +var _mix_w := PackedByteArray() # Anteil des zweiten Materials 0–255 var _sx := 0.0 var _sz := 0.0 var _hmin := 0.0 @@ -126,6 +128,7 @@ func _refresh_from_doc() -> bool: # des Strichs auf alles-Gras zurück (Sand/Fels „verschwinden" live). _mats = SvmapIO.decode_materials(terrain) _mres = SvmapIO.material_resolution(terrain) + _load_mix(terrain) return _heights.size() >= _res * _res @@ -143,6 +146,24 @@ func set_radius(m: float) -> void: _refresh_ring_mesh() +## Misch-Schicht (Karte 25.10.) aus dem terrain-Block — fehlt sie, Nullen. +func _load_mix(terrain: Dictionary) -> void: + var mix := SvmapIO.decode_mix(terrain) + _mix_b = mix[0] + _mix_w = mix[1] + + +## Materialpunkt als ein int (a | b<<8 | w<<16) — Spiegeln kopiert alle drei. +func _punkt(idx: int) -> int: + return int(_mats[idx]) | (int(_mix_b[idx]) << 8) | (int(_mix_w[idx]) << 16) + + +func _setze_punkt(idx: int, v: int) -> void: + _mats[idx] = v & 0xFF + _mix_b[idx] = (v >> 8) & 0xFF + _mix_w[idx] = (v >> 16) & 0xFF + + ## Regler „Kante: hart ↔ weich“ (nur Malen). func set_weichheit(w: float) -> void: brush_weichheit = clampf(w, 0.0, 1.0) @@ -232,6 +253,7 @@ func _begin_stroke(mode: int, material_id: int = -1) -> void: if mode == TerrainField.Mode.PAINT: _mats = SvmapIO.decode_materials(main.doc.get("terrain", {})) _mres = SvmapIO.material_resolution(main.doc.get("terrain", {})) + _load_mix(main.doc.get("terrain", {})) _refresh_ring_mesh() main.call("_set_status", "%s läuft — Maustaste gedrückt halten. Beim Loslassen wird der ganze Strich EIN Rückgängig-Schritt (Strg+Z)." % _stroke_label()) @@ -253,6 +275,8 @@ func _process(delta: float) -> void: # 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). + if view_builder.has_method("set_terrain_mix"): + view_builder.call("set_terrain_mix", _mix_b, _mix_w) view_builder.call("update_terrain", _heights, _mats, _splat_rect) _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 return @@ -302,6 +326,7 @@ func _end_stroke() -> void: var terrain: Dictionary = main.doc.get("terrain", {}) if _stroke_mode == TerrainField.Mode.PAINT: terrain["materials_r8_b64"] = SvmapIO.encode_materials(_mats) + SvmapIO.encode_mix(terrain, _mix_b, _mix_w) main.history.commit("Boden gemalt (%s)" % SvmapIO.MATERIAL_IDS[clampi(_stroke_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)], before, main.history.snapshot(["terrain"])) else: @@ -455,8 +480,8 @@ func _paint_step(x: float, z: float) -> bool: var snap := {} if _sym_active() and _mats.size() >= _mres * _mres: snap = _snap_mats(_brush_region_r(g, _mres)) - var changed := TerrainField.apply_material_brush(_mats, _mres, _sx, _sz, - x, z, brush_radius, _stroke_material, brush_weichheit) + var changed := TerrainField.apply_material_brush_mix(_mats, _mix_b, _mix_w, _mres, + _sx, _sz, x, z, brush_radius, _stroke_material, brush_weichheit) if changed and not snap.is_empty(): _mirror_mats(snap, g) return changed @@ -511,7 +536,7 @@ func _snap_mats(rect: Rect2i) -> Dictionary: var snap := {} for z: int in range(rect.position.y, rect.end.y): for x: int in range(rect.position.x, rect.end.x): - snap[Vector2i(x, z)] = int(_mats[z * _mres + x]) + snap[Vector2i(x, z)] = _punkt(z * _mres + x) return snap @@ -533,12 +558,12 @@ func _mirror_mats(snap: Dictionary, g: Vector2) -> void: if mirror == pos: continue var midx: int = mirror.y * _mres + mirror.x - var value: int = int(_mats[idx]) + var value: int = _punkt(idx) if snap.has(mirror): if _cell_dist(mirror, g, cell_x, cell_z) < _cell_dist(pos, g, cell_x, cell_z): - value = int(_mats[midx]) - _mats[idx] = value - _mats[midx] = value + value = _punkt(midx) + _setze_punkt(idx, value) + _setze_punkt(midx, value) done[mirror] = true diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 22f1146..edc315d 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -203,12 +203,19 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float, var heights_old := SvmapIO.decode_heights(old_terrain) var mats_old := SvmapIO.decode_materials(old_terrain) var old_mres := SvmapIO.material_resolution(old_terrain) + var mix_old := SvmapIO.decode_mix(old_terrain) # weiche Ränder (Karte 25.10.) + var mb_old: PackedByteArray = mix_old[0] + var mw_old: PackedByteArray = mix_old[1] var new_mres := SvmapIO.material_resolution_for(new_res, new_sx, new_sz) var heights_new := PackedFloat32Array() heights_new.resize(new_res * new_res) var mats_new := PackedByteArray() mats_new.resize(new_mres * new_mres) + var mb_new := PackedByteArray() + mb_new.resize(new_mres * new_mres) + var mw_new := PackedByteArray() + mw_new.resize(new_mres * new_mres) var last_new := float(new_res - 1) var half_x_old := old_sx * 0.5 @@ -239,7 +246,10 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float, continue var fx := clampf(roundf((x / old_sx + 0.5) * last_mold), 0.0, last_mold) var fz := clampf(roundf((z / old_sz + 0.5) * last_mold), 0.0, last_mold) - mats_new[j * new_mres + i] = mats_old[int(fz) * old_mres + int(fx)] + var src := int(fz) * old_mres + int(fx) + mats_new[j * new_mres + i] = mats_old[src] + mb_new[j * new_mres + i] = mb_old[src] + mw_new[j * new_mres + i] = mw_old[src] var terrain := { "type": "heightmap", @@ -252,6 +262,7 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float, "material_resolution": new_mres, "materials_r8_b64": SvmapIO.encode_materials(mats_new), } + SvmapIO.encode_mix(terrain, mb_new, mw_new) # bounds wie create_new auf die neue Größe — Y-Grenzen vom alten doc # behalten (Resize ändert nur die Grundfläche, nicht die Luft). diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index cfed04c..a7398dc 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -291,6 +291,7 @@ static func _validate_terrain(terrain: Dictionary) -> PackedStringArray: break elif not (mats is String): errors.append("V15: materials_r8_b64 muss ein Base64-String sein (ist %s)." % str(mats)) + errors.append_array(_validate_mix(terrain, mres_i)) # V20 (Karte t_28fdef03): Wasser-Level (optional). errors.append_array(_validate_wasser(terrain)) return errors @@ -608,6 +609,72 @@ static func decode_materials(terrain: Dictionary) -> PackedByteArray: return out +## V15b (Karte 25.10., weiche Übergänge): optionale Misch-Schicht — +## materials_b_r8_b64 (zweites Material je Punkt) + materials_w_r8_b64 (Anteil +## des zweiten Materials, 0–255). Nur BEIDE oder KEINE; Länge material_resolution². +static func _validate_mix(terrain: Dictionary, mres: int) -> PackedStringArray: + var errors := PackedStringArray() + var hb := terrain.has("materials_b_r8_b64") + var hw := terrain.has("materials_w_r8_b64") + if not hb and not hw: + return errors + if hb != hw: + errors.append("V15: materials_b_r8_b64 und materials_w_r8_b64 gibt es nur zusammen.") + return errors + var b = terrain["materials_b_r8_b64"] + var w = terrain["materials_w_r8_b64"] + if not (b is String) or not (w is String): + errors.append("V15: Misch-Schicht muss aus Base64-Strings bestehen.") + return errors + var braw := Marshalls.base64_to_raw(b as String) + var wraw := Marshalls.base64_to_raw(w as String) + if braw.size() != mres * mres or wraw.size() != mres * mres: + errors.append("V15: Misch-Schicht hat %d/%d Bytes, erwartet je %d (= material_resolution²)." % [ + braw.size(), wraw.size(), mres * mres]) + return errors + for v in braw: + if int(v) >= MATERIAL_IDS.size(): + errors.append("V15: materials_b_r8_b64 enthält unbekannte Material-ID %d." % int(v)) + break + return errors + + +## Misch-Schicht dekodieren: [b, w] (je material_resolution², fehlt sie → Nullen). +static func decode_mix(terrain: Dictionary) -> Array: + var n := material_resolution(terrain) + var b := PackedByteArray() + var w := PackedByteArray() + b.resize(maxi(n * n, 0)) + w.resize(maxi(n * n, 0)) + var bs = terrain.get("materials_b_r8_b64", "") + var ws = terrain.get("materials_w_r8_b64", "") + if bs is String and ws is String and (bs as String) != "" and (ws as String) != "": + var braw := Marshalls.base64_to_raw(bs as String) + var wraw := Marshalls.base64_to_raw(ws as String) + if braw.size() == b.size() and wraw.size() == w.size(): + for i in braw.size(): + if int(braw[i]) < MATERIAL_IDS.size(): + b[i] = braw[i] + w = wraw + return [b, w] + + +## Misch-Schicht schreiben — ganz ohne Mischung (alle w = 0) fallen die Felder +## weg (Datei bleibt so klein wie vorher, alte Leser unberührt). +static func encode_mix(terrain: Dictionary, b: PackedByteArray, w: PackedByteArray) -> void: + var any := false + for v in w: + if v != 0: + any = true + break + if not any: + terrain.erase("materials_b_r8_b64") + terrain.erase("materials_w_r8_b64") + return + terrain["materials_b_r8_b64"] = encode_materials(b) + terrain["materials_w_r8_b64"] = Marshalls.raw_to_base64(w) + + ## Raster der Material-Schicht (feines Raster, Karte 24.10.) — ohne Feld ## gilt das Höhenraster (alte Maps). static func material_resolution(terrain: Dictionary) -> int: @@ -658,8 +725,12 @@ static func upgrade_material_layer(terrain: Dictionary) -> bool: if cur >= target: return false var mats := resample_materials(decode_materials(terrain), cur, target) + var mix := decode_mix(terrain) + var mb := resample_materials(mix[0], cur, target) + var mw := resample_materials(mix[1], cur, target) terrain["material_resolution"] = target terrain["materials_r8_b64"] = encode_materials(mats) + encode_mix(terrain, mb, mw) return true diff --git a/src/svmap/terrain_field.gd b/src/svmap/terrain_field.gd index b9e2cc6..41db5c9 100644 --- a/src/svmap/terrain_field.gd +++ b/src/svmap/terrain_field.gd @@ -97,17 +97,9 @@ static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, si ## die Vertex-Interpolation des Meshes glättet die Grenze optisch. ## mats = dekodierte Material-Schicht (PackedByteArray, res² Einträge). static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float, size_z: float, - cx: float, cz: float, radius: float, material_id: int, weichheit: float = 0.0) -> bool: + cx: float, cz: float, radius: float, material_id: int) -> bool: if res < 2 or radius <= 0.0 or mats.size() < res * res: return false - # Weiche Kante (Karte 25.10., Sly: Regler „hart ↔ weich“): Die Schicht kennt - # pro Punkt nur EIN Material — deshalb wird der Rand gestreut ausgedünnt: - # im äußeren Anteil `weichheit` des Radius bekommt ein Punkt das Material - # nur mit fallender Wahrscheinlichkeit. Der Zufall ist ein fester Hash je - # Punkt (deterministisch: gleicher Strich = gleiches Ergebnis, Nachmalen - # füllt nicht auf). Der Shader macht aus der Streuung einen weichen Verlauf. - var w := clampf(weichheit, 0.0, 1.0) - var inner := radius * (1.0 - w) var gx := (cx / size_x + 0.5) * float(res - 1) var gz := (cz / size_z + 0.5) * float(res - 1) var r_cells_x := radius / (size_x / float(res - 1)) @@ -123,9 +115,7 @@ static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float, while x <= x1: var dx := (float(x) - gx) * (size_x / float(res - 1)) var dz := (float(z) - gz) * (size_z / float(res - 1)) - var d2 := dx * dx + dz * dz - if d2 < radius * radius and (w <= 0.0 or d2 <= inner * inner \ - or punkt_hash(x, z) < rand_schwelle((sqrt(d2) - inner) / maxf(radius - inner, 0.0001))): + if dx * dx + dz * dz < radius * radius: var idx := z * res + x if mats[idx] != material_id: mats[idx] = material_id @@ -135,18 +125,62 @@ static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float, return changed -## Deterministischer Zufall 0..1 je Rasterpunkt (Streu-Rand der weichen Kante). -static func punkt_hash(x: int, z: int) -> float: - var h := (x * 73856093) ^ (z * 19349663) - h = (h ^ (h >> 13)) * 1274126177 - h = h ^ (h >> 16) - return float(h & 0xFFFF) / 65535.0 - - -## Malwahrscheinlichkeit am Rand: t = 0 (Innenkante) → 1, t = 1 (Außenkante) → 0. -static func rand_schwelle(t: float) -> float: - var u := clampf(t, 0.0, 1.0) - return 1.0 - u * u * (3.0 - 2.0 * u) +## Malen MIT weichem Rand (Karte 25.10., Sly: „weicher Übergang der Texturen +## am Rand des Malwerkzeugs“). Jeder Punkt kann zwei Materialien mischen: +## a = mats (Hauptschicht), b = mix_b, Anteil von b = mix_w/255. Innerhalb +## radius·(1−weichheit) wird voll gemalt, außen fällt der Anteil des neuen +## Materials weich auf 0 (Smoothstep). Ein Punkt wird nur verstärkt, nie +## abgeschwächt (Nachmalen am Rand macht ihn kräftiger, nicht schwächer). +## Mischung bleibt zweiteilig: das neue Material + das bisher stärkste andere. +static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArray, + mix_w: PackedByteArray, res: int, size_x: float, size_z: float, + cx: float, cz: float, radius: float, material_id: int, weichheit: float) -> bool: + if res < 2 or radius <= 0.0 or mats.size() < res * res \ + or mix_b.size() < res * res or mix_w.size() < res * res: + return false + var soft := clampf(weichheit, 0.0, 1.0) + var inner := radius * (1.0 - soft) + var cell_x := size_x / float(res - 1) + var cell_z := size_z / float(res - 1) + var gx := (cx / size_x + 0.5) * float(res - 1) + var gz := (cz / size_z + 0.5) * float(res - 1) + var x0 := maxi(int(floor(gx - radius / cell_x)) - 1, 0) + var x1 := mini(int(ceil(gx + radius / cell_x)) + 1, res - 1) + var z0 := maxi(int(floor(gz - radius / cell_z)) - 1, 0) + var z1 := mini(int(ceil(gz + radius / cell_z)) + 1, res - 1) + var changed := false + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): + var dx := (float(x) - gx) * cell_x + var dz := (float(z) - gz) * cell_z + var d := sqrt(dx * dx + dz * dz) + if d >= radius: + continue + var f := 1.0 + if d > inner: + var t := clampf((d - inner) / maxf(radius - inner, 0.0001), 0.0, 1.0) + f = 1.0 - t * t * (3.0 - 2.0 * t) + var fb := int(roundf(f * 255.0)) + if fb <= 0: + continue + var idx := z * res + x + var a := int(mats[idx]) + var b := int(mix_b[idx]) + var w := int(mix_w[idx]) + var anteil := (255 - w if a == material_id else 0) + (w if b == material_id else 0) + if fb <= anteil: + continue + if fb >= 255: + mats[idx] = material_id + mix_b[idx] = material_id + mix_w[idx] = 0 + else: + var andere := a if a != material_id else b + mats[idx] = andere + mix_b[idx] = material_id + mix_w[idx] = fb + changed = true + return changed ## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index ece8775..a1a1d58 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -201,6 +201,12 @@ func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedB terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats, splat_rect_cells) +## Misch-Schicht für das nächste Live-Update (Malen mit weichem Rand, Karte 25.10.). +func set_terrain_mix(mix_b: PackedByteArray, mix_w: PackedByteArray) -> void: + if terrain_view != null and is_instance_valid(terrain_view): + terrain_view.call("set_mix", mix_b, mix_w) + + ## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und ## Auswahl bleiben bestehen, kein Flackern der ganzen Szene. func rebuild_terrain_from_doc(data: Dictionary) -> void: diff --git a/src/viewport/terrain_contour.gdshader b/src/viewport/terrain_contour.gdshader index 0d9dd06..d168f14 100644 --- a/src/viewport/terrain_contour.gdshader +++ b/src/viewport/terrain_contour.gdshader @@ -41,6 +41,10 @@ uniform float texturen_an = 0.0; // 1.0 = Texturen aktiv (vom Mesh-Code gesetz // Punkten — weiche Übergänge über genau einen Rasterabstand, egal wie fein. // 4 Albedo- + 4 Normal-Fetches + 4 texelFetch, keine Branches. uniform sampler2D material_map : filter_nearest, repeat_disable; +// Misch-Schicht (Karte 25.10., weiche Pinselränder): je Punkt ein zweites +// Material (b) und dessen Anteil (w, 0–255). Ohne Mischung: alles 0. +uniform sampler2D material_b_map : filter_nearest, repeat_disable; +uniform sampler2D material_w_map : filter_nearest, repeat_disable; uniform float material_res = 2.0; // Kantenlänge der Material-Karte (Punkte) uniform float map_size_x = 1.0; // Terrain-Kantenlänge in Weltmetern uniform float map_size_z = 1.0; @@ -80,6 +84,59 @@ float mat_id(ivec2 p) { return clamp(floor(texelFetch(material_map, p, 0).r * 255.0 + 0.5), 0.0, layer_count - 1.0); } +float mat_b(ivec2 p) { + return clamp(floor(texelFetch(material_b_map, p, 0).r * 255.0 + 0.5), 0.0, layer_count - 1.0); +} + +// Top-2-Mischung (Karte 25.10.): 4 Nachbarpunkte × (a, b) = 8 Kandidaten mit +// Gewicht (bilinear × Punkt-Anteil). Gleiche IDs werden aufsummiert, die +// zwei stärksten Materialien gewinnen. Rückgabe: (id1, id2, Anteil id1). +// Danach nur 2 Albedo- + 2 Normal-Fetches. +vec3 top2(vec2 g) { + vec2 gi = min(floor(g), vec2(max(material_res - 2.0, 0.0))); + vec2 f = clamp(g - gi, 0.0, 1.0); + ivec2 p = ivec2(gi); + ivec2 q[4] = ivec2[4](p, p + ivec2(1, 0), p + ivec2(0, 1), p + ivec2(1, 1)); + float cw[4] = float[4]((1.0 - f.x) * (1.0 - f.y), f.x * (1.0 - f.y), (1.0 - f.x) * f.y, f.x * f.y); + float ids[8]; + float ws[8]; + for (int k = 0; k < 4; k++) { + float wb = texelFetch(material_w_map, q[k], 0).r; + ids[2 * k] = mat_id(q[k]); + ws[2 * k] = cw[k] * (1.0 - wb); + ids[2 * k + 1] = mat_b(q[k]); + ws[2 * k + 1] = cw[k] * wb; + } + float id1 = ids[0]; + float s1 = -1.0; + for (int i = 0; i < 8; i++) { + float s = 0.0; + for (int j = 0; j < 8; j++) { + s += ids[j] == ids[i] ? ws[j] : 0.0; + } + if (s > s1) { + s1 = s; + id1 = ids[i]; + } + } + float id2 = id1; + float s2 = 0.0; + for (int i = 0; i < 8; i++) { + if (ids[i] == id1) { + continue; + } + float s = 0.0; + for (int j = 0; j < 8; j++) { + s += ids[j] == ids[i] ? ws[j] : 0.0; + } + if (s > s2) { + s2 = s; + id2 = ids[i]; + } + } + return vec3(id1, id2, s1 / max(s1 + s2, 0.00001)); +} + void fragment() { vec3 col = COLOR.rgb; // --- Textur (t_ab408127): Material-ID aus Vertex-Alpha, Worldspace-UV --- @@ -90,18 +147,14 @@ void fragment() { // 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). - // Karte 25.10.: bilinear pro Pixel über die 4 Materialpunkte (Spiegel - // zum Client-Shader terrain_splat.gdshader). + // Karte 25.10.: Top-2 aus 4 Nachbarpunkten inkl. Misch-Schicht + // (Spiegel zum Client-Shader terrain_splat.gdshader). float m1 = max(material_res - 1.0, 1.0); vec2 g = clamp(warp(clamp(map_uv, 0.0, 1.0) * m1), vec2(0.0), vec2(m1)); - vec2 gi = min(floor(g), vec2(max(material_res - 2.0, 0.0))); - vec2 f = clamp(g - gi, 0.0, 1.0); - ivec2 p = ivec2(gi); + vec3 t2 = top2(g); vec2 uv = world_pos.xz / max(tile_size, 0.05); - vec3 alb = texture(albedo_array, vec3(uv, mat_id(p))).rgb * (1.0 - f.x) * (1.0 - f.y) - + texture(albedo_array, vec3(uv, mat_id(p + ivec2(1, 0)))).rgb * f.x * (1.0 - f.y) - + texture(albedo_array, vec3(uv, mat_id(p + ivec2(0, 1)))).rgb * (1.0 - f.x) * f.y - + texture(albedo_array, vec3(uv, mat_id(p + ivec2(1, 1)))).rgb * f.x * f.y; + vec3 alb = mix(texture(albedo_array, vec3(uv, t2.y)).rgb, + texture(albedo_array, vec3(uv, t2.x)).rgb, t2.z); col = alb * col; // Tönung/Shading multipliziert (wie Client) } // --- Konturlinien (unverändert, Karte t_3ebe4086) --- diff --git a/src/viewport/terrain_mesh.gd b/src/viewport/terrain_mesh.gd index 763c0a6..179306b 100644 --- a/src/viewport/terrain_mesh.gd +++ b/src/viewport/terrain_mesh.gd @@ -94,6 +94,14 @@ 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: @@ -178,6 +186,9 @@ func rebuild_from_doc(data: Dictionary) -> void: # 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) @@ -321,13 +332,24 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0 # 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: + 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: @@ -406,8 +428,20 @@ func _build_splat(mat: ShaderMaterial, mats: PackedByteArray, res: int, _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)) diff --git a/tests/probe_fein_export.gd b/tests/probe_fein_export.gd index dd81f31..095db50 100644 --- a/tests/probe_fein_export.gd +++ b/tests/probe_fein_export.gd @@ -13,10 +13,12 @@ func _initialize() -> void: SvmapIO.upgrade_material_layer(t) var mres := SvmapIO.material_resolution(t) var mats := SvmapIO.decode_materials(t) - for i in 40: - TerrainField.apply_material_brush(mats, mres, float(t["size_x_m"]), float(t["size_z_m"]), - -20.0 + float(i), 5.0, 0.6, 2) + var mix := SvmapIO.decode_mix(t) + for i in 40: # weicher Fels-Pfad (Misch-Schicht, Karte 25.10.) + TerrainField.apply_material_brush_mix(mats, mix[0], mix[1], mres, float(t["size_x_m"]), + float(t["size_z_m"]), -20.0 + float(i), 5.0, 3.0, 2, 0.8) t["materials_r8_b64"] = SvmapIO.encode_materials(mats) + SvmapIO.encode_mix(t, mix[0], mix[1]) var sv := SvmapIO.save_to_file(out, doc) print("PROBE ", "OK" if bool(sv["ok"]) else "FAIL %s" % str(sv["errors"]), " mres=", mres) quit(0 if bool(sv["ok"]) else 1) diff --git a/tests/probe_mal_weich_render.gd b/tests/probe_mal_weich_render.gd index 3fee8f6..aa9c2de 100644 --- a/tests/probe_mal_weich_render.gd +++ b/tests/probe_mal_weich_render.gd @@ -20,13 +20,20 @@ func _run() -> void: heights.resize(res * res) var mats := PackedByteArray() mats.resize(mres * mres) - for i in 60: # schräger 2-m-Pfad + Kurve + var mb := PackedByteArray() + mb.resize(mres * mres) + var mw := PackedByteArray() + mw.resize(mres * mres) + var weich := float(OS.get_environment("PROBE_WEICH")) if OS.get_environment("PROBE_WEICH") != "" else 0.0 + var radius := float(OS.get_environment("PROBE_R")) if OS.get_environment("PROBE_R") != "" else 2.0 + for i in 60: # schräger Pfad + Kurve var t := float(i) / 59.0 - TerrainField.apply_material_brush(mats, mres, size, size, - -15.0 + 30.0 * t, -10.0 + 8.0 * sin(t * 3.0), 2.0, 1) + TerrainField.apply_material_brush_mix(mats, mb, mw, mres, size, size, + -15.0 + 30.0 * t, -10.0 + 8.0 * sin(t * 3.0), radius, 1, weich) var view := Node3D.new() view.set_script(load("res://src/viewport/terrain_mesh.gd")) root.add_child(view) + view.call("set_mix", mb, mw) view.call("update_heights", heights, res, size, size, mats) view.call("set_contours", false) var cam := Camera3D.new() diff --git a/tests/test_mal_fein.gd b/tests/test_mal_fein.gd index 9d00133..a5bf803 100644 --- a/tests/test_mal_fein.gd +++ b/tests/test_mal_fein.gd @@ -129,32 +129,50 @@ func _only_checksum(errs: PackedStringArray) -> bool: return true -## F8 (25.10.): Kante hart ↔ weich. 0 = voller Kreis, weich = Innenkern voll, -## Rand gestreut, Ausdünnung nimmt nach außen zu, deterministisch. +## F8 (25.10.): Kante hart ↔ weich = ECHTER Verlauf (Misch-Schicht b + Anteil w). func _f8_weich() -> void: var r := 1025 - var hart := PackedByteArray() - hart.resize(r * r) - TerrainField.apply_material_brush(hart, r, 1024.0, 1024.0, 0.0, 0.0, 10.0, 1, 0.0) - var weich := PackedByteArray() - weich.resize(r * r) - TerrainField.apply_material_brush(weich, r, 1024.0, 1024.0, 0.0, 0.0, 10.0, 1, 0.8) - var weich2 := PackedByteArray() - weich2.resize(r * r) - TerrainField.apply_material_brush(weich2, r, 1024.0, 1024.0, 0.0, 0.0, 10.0, 1, 0.8) - var n_h := _count(hart) - var n_w := _count(weich) - _check(n_w < n_h and n_w > n_h / 4, "F8 weich malt weniger Punkte als hart (%d vs %d)" % [n_w, n_h]) - _check(weich == weich2, "F8 deterministisch (gleicher Strich = gleiches Ergebnis)") - # Ring-Dichten: innen (d<2) voll, Mitte (4..6) teils, außen (8..10) dünn - var d_in := _ring(weich, r, 0.0, 2.0) - var d_mid := _ring(weich, r, 4.0, 6.0) - var d_out := _ring(weich, r, 8.0, 10.0) - _check(d_in > 0.99 and d_mid > d_out and d_mid < 1.0 and d_out < 0.5, - "F8 Dichte fällt nach außen (innen %.2f, Mitte %.2f, Rand %.2f)" % [d_in, d_mid, d_out]) + var a := PackedByteArray() + a.resize(r * r) + var b := PackedByteArray() + b.resize(r * r) + var w := PackedByteArray() + w.resize(r * r) + TerrainField.apply_material_brush_mix(a, b, w, r, 1024.0, 1024.0, 0.0, 0.0, 10.0, 1, 0.8) + var c := (r - 1) / 2 + var mitte := c * r + c + _check(a[mitte] == 1 and w[mitte] == 0, "F8 Pinselmitte voll Sand") + var anteil := func(i: int) -> float: + return (float(255 - w[i]) / 255.0 if a[i] == 1 else 0.0) + (float(w[i]) / 255.0 if b[i] == 1 else 0.0) + var s4: float = anteil.call(c * r + c + 4) + var s7: float = anteil.call(c * r + c + 7) + var s9: float = anteil.call(c * r + c + 9) + _check(s4 > s7 and s7 > s9 and s9 > 0.0 and s4 < 1.0, + "F8 Sand-Anteil fällt weich nach außen (4 m %.2f, 7 m %.2f, 9 m %.2f)" % [s4, s7, s9]) + _check(anteil.call(c * r + c + 11) == 0.0, "F8 außerhalb des Radius unberührt") + # Nachmalen schwächer am Rand darf nichts abschwächen + var vorher := w.duplicate() + TerrainField.apply_material_brush_mix(a, b, w, r, 1024.0, 1024.0, 3.0, 0.0, 10.0, 1, 0.8) + _check(anteil.call(c * r + c + 7) >= s7, "F8 Nachmalen verstärkt, schwächt nie ab") + # Rückmalen mit Gras (Strg+Klick) am Rand mischt zurück + var vor4: float = anteil.call(c * r + c + 4) + TerrainField.apply_material_brush_mix(a, b, w, r, 1024.0, 1024.0, 10.0, 0.0, 10.0, 0, 0.8) + var nach4: float = anteil.call(c * r + c + 4) + _check(nach4 < vor4 and nach4 > 0.0, "F8 Gras drübermalen mischt Sand zurück (%.2f → %.2f)" % [vor4, nach4]) + # Kodierung: ohne Mischung keine Felder, mit Mischung gültig + var doc := SvmapDocument.create_new("Mix", 1024.0, "mittag") + var t: Dictionary = doc["terrain"] + t["materials_r8_b64"] = SvmapIO.encode_materials(a) + SvmapIO.encode_mix(t, b, w) + _check(t.has("materials_w_r8_b64") and SvmapIO._validate_core(doc).is_empty(), + "F8 Misch-Schicht gespeichert und gültig") + var leer := PackedByteArray() + leer.resize(r * r) + SvmapIO.encode_mix(t, leer, leer) + _check(not t.has("materials_w_r8_b64"), "F8 ohne Mischung keine Zusatzfelder") var eins := PackedByteArray() eins.resize(r * r) - TerrainField.apply_material_brush(eins, r, 1024.0, 1024.0, 0.3, 0.2, 1.0, 1, 0.0) + TerrainField.apply_material_brush(eins, r, 1024.0, 1024.0, 0.3, 0.2, 1.0, 1) _check(_count(eins) >= 1 and _count(eins) <= 5, "F8 1-m-Pinsel trifft %d Punkte" % _count(eins)) diff --git a/tests/test_terrain_mirror.gd b/tests/test_terrain_mirror.gd index 52f4f3f..2d00dd3 100644 --- a/tests/test_terrain_mirror.gd +++ b/tests/test_terrain_mirror.gd @@ -233,6 +233,7 @@ func _test_half_stroke_all_modes() -> void: var ttool_p = parts_p[0] var doc_p: Dictionary = parts_p[3] ttool_p.call("set_paint_material", 1) # Sand statt Default-Gras + ttool_p.set("brush_weichheit", 0.0) # harte Kante: Vergleich mit apply_material_brush _stroke(ttool_p, -100.0, -60.0, TerrainField.Mode.PAINT, 0.3) var mats_p := _decode_mats(doc_p) var sand := 0 @@ -413,12 +414,13 @@ func _test_off_mode_unchanged() -> void: var ttool_p = parts_p[0] var doc_p: Dictionary = parts_p[3] ttool_p.call("set_paint_material", 1) # Sand statt Default-Gras + ttool_p.set("brush_weichheit", 0.0) # harte Kante: Vergleich mit apply_material_brush _stroke(ttool_p, -100.0, -60.0, TerrainField.Mode.PAINT, 0.3) var ref_mats := PackedByteArray() ref_mats.resize(RES * RES) for i in int(ceil(0.3 / 0.05)): TerrainField.apply_material_brush(ref_mats, RES, SIZE, SIZE, - -100.0, -60.0, RADIUS, 1, float(ttool_p.get("brush_weichheit"))) + -100.0, -60.0, RADIUS, 1) var ref_mats_b64 := SvmapIO.encode_materials(ref_mats) _check(str(doc_p["terrain"]["materials_r8_b64"]) == ref_mats_b64, "AUS/Malen: Material-Schicht byte-identisch zur direkten Anwendung")