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.
219 lines
7.8 KiB
GDScript
219 lines
7.8 KiB
GDScript
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
|