extends SceneTree ## Measures camera clearance against the active rendered terrain mesh. ## Usage: godot --path . --script res://src/tools/probe_render_terrain_height.gd -- ## [--wait 3.0] [--output user://render_terrain_height/report.json] const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" const TILE_SIZE := 533.33333 const RAY_HEIGHT := 5000.0 func _initialize() -> void: _run_async.call_deferred() func _run_async() -> void: var arguments := OS.get_cmdline_user_args() var wait_seconds := float(_argument(arguments, "--wait", "3.0")) var output_path := _argument(arguments, "--output", "user://render_terrain_height/report.json") var only_filter := _argument(arguments, "--only", "").to_lower() var require_all := arguments.has("--require-all") var manifest := _load_json(MANIFEST_PATH) if manifest.is_empty(): quit(1) return var packed_scene: PackedScene = load(String(manifest.get("scene", ""))) if packed_scene == null: push_error("TERRAIN_HEIGHT_PROBE: cannot load streaming scene") quit(1) return var world := packed_scene.instantiate() as Node3D if world == null: push_error("TERRAIN_HEIGHT_PROBE: streaming scene root is not Node3D") quit(1) return var camera := Camera3D.new() camera.current = true world.add_child(camera) world.set("camera_path", NodePath(camera.name)) world.set("debug_streaming", false) get_root().add_child(world) await process_frame await process_frame var results: Array[Dictionary] = [] for checkpoint_variant in manifest.get("checkpoints", []): if not (checkpoint_variant is Dictionary): continue var checkpoint: Dictionary = checkpoint_variant if not checkpoint.has("reference_wow_camera"): continue if not only_filter.is_empty() and not String(checkpoint.get("name", "")).to_lower().contains(only_filter): continue var camera_position := _vector3(checkpoint.get("camera", [])) camera.global_position = camera_position if world.has_method("_refresh_streaming_targets_at"): world.call("_refresh_streaming_targets_at", camera_position, true) await create_timer(maxf(0.1, wait_seconds)).timeout var terrain_sample := _sample_terrain(world, camera_position) terrain_sample["name"] = checkpoint.get("name", "checkpoint") terrain_sample["camera_y"] = camera_position.y if terrain_sample.has("terrain_height"): terrain_sample["camera_clearance"] = camera_position.y - float(terrain_sample.terrain_height) results.append(terrain_sample) var report := { "schema_version": 1, "created_utc": Time.get_datetime_string_from_system(true, true), "wait_seconds": wait_seconds, "results": results, } if not _write_json(output_path, report): quit(1) return var sampled_count := 0 for result in results: if result.has("terrain_height"): sampled_count += 1 print("TERRAIN_HEIGHT name=%s status=%s camera_y=%.3f terrain=%s clearance=%s" % [ result.get("name", "checkpoint"), result.get("status", "unknown"), float(result.get("camera_y", 0.0)), str(result.get("terrain_height", "n/a")), str(result.get("camera_clearance", "n/a")), ]) print("TERRAIN_HEIGHT_PROBE sampled=%d total=%d report=%s" % [sampled_count, results.size(), output_path]) world.queue_free() var failed := sampled_count == 0 or (require_all and sampled_count != results.size()) quit(1 if failed else 0) func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary: var tile_coordinate := Vector2i( int(floor(world_position.x / TILE_SIZE)), int(floor(world_position.z / TILE_SIZE))) var tile_states: Dictionary = world.get("_tile_states") var highest_terrain_height := -INF var intersected_tile_key := "" var ready_mesh_count := 0 for tile_y in range(tile_coordinate.y - 1, tile_coordinate.y + 2): for tile_x in range(tile_coordinate.x - 1, tile_coordinate.x + 2): var tile_key := "%d_%d" % [tile_x, tile_y] if not tile_states.has(tile_key): continue var state: Dictionary = tile_states[tile_key] var terrain_height_variant = _intersect_terrain_state(state, world_position) if terrain_height_variant == null: continue ready_mesh_count += 1 var terrain_height := float(terrain_height_variant) if terrain_height > highest_terrain_height: highest_terrain_height = terrain_height intersected_tile_key = tile_key if not is_finite(highest_terrain_height): return { "status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready", "tile": "%d_%d" % [tile_coordinate.x, tile_coordinate.y], } return { "status": "sampled", "tile": intersected_tile_key, "terrain_height": highest_terrain_height, } func _intersect_terrain_state(state: Dictionary, world_position: Vector3): var terrain_mesh: Mesh = state.get("quality_terrain_mesh", null) if terrain_mesh == null: terrain_mesh = state.get("tile_lod_mesh", null) if terrain_mesh == null: return null var triangle_mesh := terrain_mesh.generate_triangle_mesh() if triangle_mesh == null: return null var tile_root := state.get("root", null) as Node3D if tile_root == null: return null var inverse_transform := tile_root.global_transform.affine_inverse() var local_ray_origin := inverse_transform * Vector3(world_position.x, RAY_HEIGHT, world_position.z) var local_ray_direction := inverse_transform.basis * Vector3.DOWN var intersection: Dictionary = triangle_mesh.intersect_ray(local_ray_origin, local_ray_direction) if intersection.is_empty(): return null var local_hit: Vector3 = intersection.get("position", Vector3.ZERO) var world_hit := tile_root.global_transform * local_hit return world_hit.y func _vector3(value_variant) -> Vector3: if not (value_variant is Array) or value_variant.size() != 3: return Vector3.ZERO return Vector3(float(value_variant[0]), float(value_variant[1]), float(value_variant[2])) func _argument(arguments: PackedStringArray, name: String, default_value: String) -> String: var index := arguments.find(name) if index >= 0 and index + 1 < arguments.size(): return arguments[index + 1] return default_value func _load_json(path: String) -> Dictionary: var file := FileAccess.open(path, FileAccess.READ) if file == null: return {} var parsed = JSON.parse_string(file.get_as_text()) return parsed if parsed is Dictionary else {} func _write_json(path: String, value: Dictionary) -> bool: var absolute_path := ProjectSettings.globalize_path(path) if DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir()) != OK: return false var file := FileAccess.open(absolute_path, FileAccess.WRITE) if file == null: return false file.store_string(JSON.stringify(value, " ")) return true