From 40ca5ab6bac5d2fa6bcbdad1e9be978c67372fee Mon Sep 17 00:00:00 2001 From: slyphax Date: Sun, 25 Oct 2026 00:23:18 +0200 Subject: [PATCH] =?UTF-8?q?Terrain-Malen:=20weiche,=20organische=20Kanten?= =?UTF-8?q?=20statt=201-m-Pixelbl=C3=B6cke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sly 25.10.: „immer noch harte Kanten beim Malen“. Ursache: die Splat-Textur hatte beim feinen 1-m-Raster genau einen Texel je Materialpunkt und wird nearest gesampelt → jede Zelle ein harter Block. - Shader liest die Material-ID-Karte (R8, ein Texel je Punkt, texelFetch) und mischt PRO PIXEL bilinear über die 4 Nachbarpunkte. - Leichter Domain-Warp (Value-Noise in Rasterzellen, eine Oktave) verwackelt die Abtaststelle → keine Rastertreppen, natürliche Kante. - Malen aktualisiert nur noch die Bytes der Karte (kein Splat-Neuberechnen). - TerrainArrays.material_image (Spiegel-Datei, identisch im Client). - Render-Probe tests/probe_mal_weich_render.gd (vorher/nachher geprüft). Co-Authored-By: Claude Opus 5.5 (1M context) --- library/terrain_arrays.gd | 13 +++++++ src/viewport/terrain_contour.gdshader | 52 ++++++++++++++++++++++---- src/viewport/terrain_mesh.gd | 44 +++++++--------------- tests/probe_mal_weich_render.gd | 53 +++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 38 deletions(-) create mode 100644 tests/probe_mal_weich_render.gd diff --git a/library/terrain_arrays.gd b/library/terrain_arrays.gd index 2f70264..331134a 100644 --- a/library/terrain_arrays.gd +++ b/library/terrain_arrays.gd @@ -206,3 +206,16 @@ static func decode_splat_texel(bytes: PackedByteArray, texel_ofs: int) -> Array: int(bytes.decode_u8(texel_ofs + 2)), float(bytes.decode_u8(texel_ofs + 3)) / 255.0, ] + + +## Material-ID-Karte für den Shader (Karte 25.10., weiche Übergänge pro Pixel): +## R8, ein Texel je Materialpunkt (mres²) — der Shader liest per texelFetch die +## 4 umliegenden IDs und mischt bilinear. Ersetzt im Rendering die Splat- +## Ableitung (build_splat_bytes bleibt für Probes/Tests). Unpassende Schicht → +## 2×2 alles Gras (Shader bleibt nie ohne Textur). +static func material_image(mats: PackedByteArray, mres: int) -> Image: + if mres < 2 or mats.size() != mres * mres: + var leer := PackedByteArray() + leer.resize(4) + return Image.create_from_data(2, 2, false, Image.FORMAT_R8, leer) + return Image.create_from_data(mres, mres, false, Image.FORMAT_R8, mats) diff --git a/src/viewport/terrain_contour.gdshader b/src/viewport/terrain_contour.gdshader index a87fccb..0d9dd06 100644 --- a/src/viewport/terrain_contour.gdshader +++ b/src/viewport/terrain_contour.gdshader @@ -34,7 +34,14 @@ uniform float texturen_an = 0.0; // 1.0 = Texturen aktiv (vom Mesh-Code gesetz // 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; +// Karte 25.10. (Sly: „immer noch harte Kanten“): statt der Splat-Textur (ein +// Texel je Materialpunkt → beim feinen 1-m-Raster harte Pixelstufen) liest der +// Shader die MATERIAL-ID-KARTE direkt (R8, ein Texel je Materialpunkt, +// material_res²) und mischt PRO PIXEL bilinear zwischen den 4 umliegenden +// 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; +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; @@ -48,6 +55,31 @@ void vertex() { (world_pos.z + 0.5 * map_size_z) / max(map_size_z, 0.001)); } +// Organische Kanten (Karte 25.10.): die Materialpunkte liegen auf einem +// 1-m-Raster — ohne Störung zeichnet die bilineare Mischung dessen Treppen +// nach. Ein leichter Domain-Warp (Value-Noise in Rasterzellen, ±0,45 Zelle) +// verwackelt die Abtaststelle, die Kante wird unregelmäßig wie echter Boden. +float hash2(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash2(i), hash2(i + vec2(1.0, 0.0)), f.x), + mix(hash2(i + vec2(0.0, 1.0)), hash2(i + vec2(1.0, 1.0)), f.x), f.y); +} + +vec2 warp(vec2 g) { + vec2 o = vec2(vnoise(g * 0.9), vnoise(g * 0.9 + vec2(17.3, 5.1))) - 0.5; + return g + o * 0.9; // eine Oktave: Quest-Budget (8 Hash statt 16) +} + +float mat_id(ivec2 p) { + return clamp(floor(texelFetch(material_map, p, 0).r * 255.0 + 0.5), 0.0, layer_count - 1.0); +} + void fragment() { vec3 col = COLOR.rgb; // --- Textur (t_ab408127): Material-ID aus Vertex-Alpha, Worldspace-UV --- @@ -58,13 +90,19 @@ 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). - 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); + // Karte 25.10.: bilinear pro Pixel über die 4 Materialpunkte (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); vec2 uv = world_pos.xz / max(tile_size, 0.05); - 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) + 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; + col = alb * 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 c7a5f68..763c0a6 100644 --- a/src/viewport/terrain_mesh.gd +++ b/src/viewport/terrain_mesh.gd @@ -318,9 +318,12 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0 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 == mres \ - and _splat_side == TerrainArrays.splat_side_for(mres, maxf(size_x, size_z)): - _refresh_splat_region(splat_rect_cells) + # 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: + _splat_image.set_data(mres, mres, false, Image.FORMAT_R8, mats) + _splat_tex.update(_splat_image) else: _build_splat(shm, mats, mres, size_x, size_z) @@ -393,39 +396,18 @@ func resolution() -> int: ## 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: + _size_x: float = 0.0, _size_z: float = 0.0) -> void: if res < 2: _splat_tex = null return - var side := TerrainArrays.splat_side_for(res, maxf(size_x, size_z)) - 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 + # 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) if mat != null: - mat.set_shader_parameter("splat_map", _splat_tex) + mat.set_shader_parameter("material_map", _splat_tex) + mat.set_shader_parameter("material_res", float(_splat_side)) -## 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_mal_weich_render.gd b/tests/probe_mal_weich_render.gd new file mode 100644 index 0000000..3fee8f6 --- /dev/null +++ b/tests/probe_mal_weich_render.gd @@ -0,0 +1,53 @@ +extends SceneTree +## Render-Probe (Karte 25.10., Sly: „immer noch harte Kanten beim Malen“): +## 256-m-Karte (feines Raster 257 = 1 m), Sandpfad mit 2-m-Pinsel, top-down +## auf 40 m Ausschnitt. Misst die größte Helligkeits-Stufe zwischen +## Nachbarpixeln quer zur Kante — weich = kleine Stufen über mehrere Pixel. +## Start (BRAUCHT Rendering): godot --path . --rendering-driver opengl3 -s res://tests/probe_mal_weich_render.gd -- + +func _initialize() -> void: + _run() + + +func _run() -> void: + await process_frame + await process_frame + var out := OS.get_cmdline_user_args()[0] if OS.get_cmdline_user_args().size() > 0 else "/tmp/mal_weich.png" + var size := float(OS.get_environment("PROBE_SIZE")) if OS.get_environment("PROBE_SIZE") != "" else 256.0 + var res := 129 + var mres := SvmapIO.material_resolution_for(res, size, size) + var heights := PackedFloat32Array() + heights.resize(res * res) + var mats := PackedByteArray() + mats.resize(mres * mres) + for i in 60: # schräger 2-m-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) + var view := Node3D.new() + view.set_script(load("res://src/viewport/terrain_mesh.gd")) + root.add_child(view) + view.call("update_heights", heights, res, size, size, mats) + view.call("set_contours", false) + var cam := Camera3D.new() + root.add_child(cam) + cam.projection = Camera3D.PROJECTION_ORTHOGONAL + cam.size = 40.0 + cam.position = Vector3(0.0, 50.0, -5.0 + 0.01) + cam.look_at(Vector3(0, 0, -5.0), Vector3(0, 0, -1)) + cam.current = true + for i in 4: + await process_frame + await RenderingServer.frame_post_draw + var img := root.get_texture().get_image() + img.save_png(out) + # Größte Nachbarpixel-Stufe (Luminanz) im Bild — harte Pixelkanten ≈ volle + # Sand↔Gras-Differenz in EINEM Pixel, weich = über ~Pixel/Meter verteilt. + var maxstep := 0.0 + for y in range(0, img.get_height(), 2): + for x in range(1, img.get_width()): + var a := img.get_pixel(x - 1, y).get_luminance() + var b := img.get_pixel(x, y).get_luminance() + maxstep = maxf(maxstep, absf(a - b)) + print("PROBE_MAXSTEP %.4f mres=%d px/m=%.1f -> %s" % [maxstep, mres, float(img.get_width()) / 40.0, out]) + quit(0)