Terrain-Malen: weiche, organische Kanten statt 1-m-Pixelblöcke

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) <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-25 00:23:18 +02:00
parent 69668eb250
commit 40ca5ab6ba
4 changed files with 124 additions and 38 deletions

View file

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

View file

@ -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;

View file

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

View file

@ -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 -- <out.png>
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)