scopedvr-map-editor/tests/probe_terrain_relief_render.gd
Hermes (godot-vr) ffb27645d4 Terrain-Relief lesbar machen (t_3ebe4086) — Sly-Befund 17.10. "Erhoehungen/Vertiefungen kaum erkennbar, alles knallgruen und hell".
1) Hoehen-Faktor kontrastreicher kalibriert: S-Kurve t*t*(3-2t) statt linear, Spanne 0.72..1.15 -> 0.58..1.28 (Senken deutlich dunkler, Huegel deutlich heller).
2) Pseudo-Licht (Hillshade) in den Vertex-Farben: Terrain-Normale x feste Sonnenrichtung RELIEF_SUN=(0.45,0.75,0.5) (seitlich-hinten fuer den Default-Blick), Licht-Faktor 0.62..1.22, Gesamtklemme 0.40..1.35. Formel/Konstanten IDENTISCH zum Match-Client (svmap_terrain.gd im scopedvr-client-Repo) — relief_color() static, Material-Farben unangetastet.
3) Editor-only Extra: Hoehenlinien alle 1 m + kraeftige 5-m-Indexlinie (terrain_contour.gdshader, UV2.x = Vertex-Hoehe, fwidth-stabil, unshaded). Der Quest-3-Vertrag "kein Custom-Shader" gilt fuer das SPIEL — der Editor darf teurer sein (DECISIONS-Konvention); der Match-Client bleibt StandardMaterial3D. Flach-Map-Schutz: Linien erst ab 2 m Gesamtrelief, UI-Toggle "≈ Hoehenlinien" (Default AN, nach jedem Rebuild re-synchronisiert).
Material unshaded: das gebackene Shading ist exakt das sichtbare (kein Doppel-Shading mit dem Viewport-Sonnenlicht), deterministisch fuer die Render-Probe.

Tests: test_terrain +19 Relief-Checks (99 gesamt): Tal/Huegel-Delta > 0.10 UND > 1.2x alte Kalibrierung, Hang-zur-Sonne heller, Stufen-Kanten-Delta > 0.08 UND > 1.2x alt, Materialfarben (Gras/Sand/Fels) unterscheidbar, Alpha=1.0, Kontur-Toggle + Flach-Map-Schutz. Suite 11 Suiten ALL OK: test_ux 137, shortcuts 198, editor_svmap 31, terrain 99, library 94, glb_view 28, spawns 52, testrun 14, bauen 87, gui_smoke 68, rmb_vorrang 32. (Hinweis: gui_smoke-F5 schlug in DIESEM Workspace nur wegen des scopedvr-client-Symlinks neben dem Editor auf — TestRun.find_client findet den Client dann wirklich; ohne Symlink = Standard-Layout: 68/68.)
Renderprobe (xvfb, opengl3): tests/probe_terrain_relief_render.gd — Stufen-Delta 0.149 (>0.10), Hoehenlinien Pixel-Diff 0.167, Huegel/Senke-Fenster-Delta 0.225 — ALL OK 3/3; PNGs im Scratch (terrain_relief_stufe.png, terrain_relief_demo.png).
Housekeeping: fehlende .png.import-Sidecars der M2-Natur-Thumbs nachgereicht.
2026-10-17 18:05:20 +00:00

219 lines
7.8 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Render-Probe (Karte t_3ebe4086): Relief-Lesbarkeit des Editor-Terrains.
## Rendert Stufe + Hügel/Senke top-down (unshaded Vertex-Farb-Pfad) und misst
## im Pixel-Bild:
## - Farb-Delta über der Stufen-Kante (LINKS vs. RECHTS) > Schwellwert
## - Höhenlinien sichtbar: AN vs. AUS unterscheidet sich im Bild
## PNGs landen im Scratch-Verzeichnis (Sly-Verifikation nach dem Deploy).
## Start (BRAUCHT Rendering — NICHT --headless):
## xvfb-run godot --path . --rendering-driver opengl3 -s res://tests/probe_terrain_relief_render.gd
var failures := 0
var checks := 0
var out_dir := "/opt/data/profiles/godot-vr/cache/scratch"
func _initialize() -> void:
_run()
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:
# WICHTIG: in -s-SceneTree-Scripts ist root beim _initialize NOCH NICHT im
# Baum (add_child würde outside-tree hängen, look_at wirft) — erst nach
# dem ersten Frame aufbauen.
await process_frame
await process_frame
# --- Stufe: h springt bei x=0 von 0 auf 10 m (Kante genau mittig) ---
var res := 65
var size := 320.0
var step := _step_field(res, size)
var img_step := await _render_topdown(step, res, size, true)
img_step.save_png(out_dir + "/terrain_relief_stufe.png")
print("PROBE_SAVED %s/terrain_relief_stufe.png" % out_dir)
var d := _edge_delta(img_step)
print("PROBE_STUFE_DELTA %.4f (0..1-Skala, max-Kanal-Differenz links/rechts)" % d)
_check(d > 0.10, "Stufe: Farb-Delta über der Reliefkante messbar (Δ %.3f > 0.10)" % d)
# Gleiche Szene mit AUSgeschalteten Höhenlinien: Bild muss sich ändern
# (Linien sind sichtbar) — Mean-Luminance-Unterschied.
var img_step_ohne := await _render_topdown(step, res, size, false)
img_step_ohne.save_png(out_dir + "/terrain_relief_stufe_ohne_linien.png")
print("PROBE_SAVED %s/terrain_relief_stufe_ohne_linien.png" % out_dir)
var lum_an := _mean_lum(img_step)
var lum_aus := _mean_lum(img_step_ohne)
var lum_diff := absf(lum_an - lum_aus)
print("PROBE_LUMINANZ an=%.3f aus=%.3f diff=%.4f" % [lum_an, lum_aus, lum_diff])
# Per-pixel: auf der Rampe/den Hängen sind die Linien — DORT muss sich
# das Bild deutlich ändern (Mittelwerte wären zu stark verwässert).
var px_diff := _max_pixel_diff(img_step, img_step_ohne)
print("PROBE_LINIEN_PIXELDIFF %.4f" % px_diff)
_check(px_diff > 0.05, "Höhenlinien sichtbar: Pixel-Diff mit/ohne Linien (Δ %.4f)" % px_diff)
# --- Hügel + Senke (Demo für die visuelle Sly-Verifikation) ---
var relief := _hill_pit_field(res, size)
var img_relief := await _render_topdown(relief, res, size, true)
img_relief.save_png(out_dir + "/terrain_relief_demo.png")
print("PROBE_SAVED %s/terrain_relief_demo.png" % out_dir)
# Senke (−X-Seite) muss dunkler sein als der Hügel (+X-Seite).
var d_huegel_senke := _side_delta(img_relief)
print("PROBE_HUEGEL_SENKE_DELTA %.4f" % d_huegel_senke)
_check(d_huegel_senke > 0.10, "Hügel/Senke: deutliches Farb-Delta (Δ %.3f)" % d_huegel_senke)
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
## Stufen-Feld: linke Hälfte h=0, rechte Hälfte h=10 (Kante exakt mittig).
func _step_field(res: int, size: float) -> PackedFloat32Array:
var heights := PackedFloat32Array()
heights.resize(res * res)
for z in res:
for x in res:
heights[z * res + x] = 10.0 if x > res / 2 else 0.0
return heights
## Demo-Relief: Hügel (+30 m bei +X/0) und Senke (−12 m bei −X/0), sanft.
func _hill_pit_field(res: int, size: float) -> PackedFloat32Array:
var heights := PackedFloat32Array()
heights.resize(res * res)
var half := size * 0.5
var cell := size / float(res - 1)
for z in res:
for x in res:
var wx := -half + float(x) * cell
var wz := -half + float(z) * cell
var d_hill := Vector2(wx - 80.0, wz).length()
var d_pit := Vector2(wx + 80.0, wz).length()
heights[z * res + x] = 30.0 * maxf(0.0, 1.0 - d_hill / 90.0) \
- 12.0 * maxf(0.0, 1.0 - d_pit / 90.0)
return heights
## Top-down ORTHOGONAL rendern (unshaded → Licht egal, deterministisch).
func _render_topdown(heights: PackedFloat32Array, res: int, size: float,
linien: bool) -> Image:
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)
view.call("set_contours", linien)
var cam := Camera3D.new()
root.add_child(cam)
cam.projection = Camera3D.PROJECTION_ORTHOGONAL
# Ortho-Größe so wählen, dass die Map EXAKT die Bildbreite füllt
# (KEEP_HEIGHT: size = vertikale Ausdehnung; Breite = size × Seitenverhältnis
# → kein grauer Hintergrund-Rand in der Messregion).
var vp := root.size
cam.size = size * float(vp.y) / float(vp.x)
cam.position = Vector3(0.0, size, 0.01)
cam.look_at(Vector3.ZERO, Vector3(0, 0, -1))
cam.current = true
await process_frame
await process_frame
await RenderingServer.frame_post_draw
var img := root.get_texture().get_image()
cam.queue_free()
view.free()
await process_frame
return img
## Farb-Delta zwischen linker und rechter Bildhälfte (max-Kanal, Mittelwerte).
func _edge_delta(img: Image) -> float:
var w := img.get_width()
var h := img.get_height()
var links := Vector3(0.0, 0.0, 0.0)
var rechts := Vector3(0.0, 0.0, 0.0)
var n := 0
var y0 := int(h * 0.35)
var y1 := int(h * 0.65)
for y in range(y0, y1):
for x in range(0, w):
var c := img.get_pixel(x, y)
if x < w / 2:
links += Vector3(c.r, c.g, c.b)
else:
rechts += Vector3(c.r, c.g, c.b)
n += 1
links /= float(n)
rechts /= float(n)
return maxf(absf(links.x - rechts.x),
maxf(absf(links.y - rechts.y), absf(links.z - rechts.z)))
## Hügel (Apex-Fenster rechts) vs. Senke (Grund-Fenster links).
## Bewusst NUR die zentralen Fenster um Apex/Grund messen (10 %-Radius): eine
## ganze Bildhälfte mittelt auch den Sattel dazwischen, dessen Höhe fast
## neutral ist — das Signal würde künstlich verwässert. Die Lesbarkeit, die
## Sly sieht, wird am Apex/Grund (und den Hängen dazwischen) sichtbar.
func _side_delta(img: Image) -> float:
var w := img.get_width()
var h := img.get_height()
return _window_delta(img,
Vector2(w * 0.75, h * 0.5), # Hügel-Apex (Welt x=+80 von 320)
Vector2(w * 0.25, h * 0.5), # Senken-Grund (Welt x=−80)
w * 0.10)
## Mittel-Farb-Delta zweier quadratischer Bildfenster (max-Kanal).
func _window_delta(img: Image, a: Vector2, b: Vector2, radius_px: float) -> float:
var ma := _window_mean(img, a, radius_px)
var mb := _window_mean(img, b, radius_px)
return maxf(absf(ma.x - mb.x), maxf(absf(ma.y - mb.y), absf(ma.z - mb.z)))
func _window_mean(img: Image, center: Vector2, radius_px: float) -> Vector3:
var s := Vector3.ZERO
var n := 0
var r := int(radius_px)
var cx := int(center.x)
var cy := int(center.y)
for y in range(maxi(0, cy - r), mini(img.get_height(), cy + r)):
for x in range(maxi(0, cx - r), mini(img.get_width(), cx + r)):
var c := img.get_pixel(x, y)
s += Vector3(c.r, c.g, c.b)
n += 1
return s / float(maxi(n, 1))
func _mean_lum(img: Image) -> float:
var w := img.get_width()
var h := img.get_height()
var s := 0.0
var n := 0
for y in range(0, h, 4):
for x in range(0, w, 4):
var c := img.get_pixel(x, y)
s += (c.r + c.g + c.b) / 3.0
n += 1
return s / float(n)
## Maximale pro-pixel Helligkeits-Differenz zweier Bilder (dichte Abtastung) —
## misst sichtbare Linien robust gegen Hintergrund-/Mittelwert-Verwässerung.
func _max_pixel_diff(a: Image, b: Image) -> float:
var w := mini(a.get_width(), b.get_width())
var h := mini(a.get_height(), b.get_height())
var mx := 0.0
for y in range(0, h, 2):
for x in range(0, w, 2):
var ca := a.get_pixel(x, y)
var cb := b.get_pixel(x, y)
var d := absf(ca.r - cb.r) + absf(ca.g - cb.g) + absf(ca.b - cb.b)
mx = maxf(mx, d / 3.0)
return mx