scopedvr-map-editor/tests/probe_terrain_relief_render.gd
2026-11-01 19:34:05 +01:00

219 lines
7.8 KiB
GDScript
Raw Permalink 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://core/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