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