extends SceneTree ## Reports published scene-tree geometry around calibrated renderer cameras. ## Usage: godot --headless --path . --script res://src/tools/probe_render_camera_occluders.gd -- ## [--wait 3.0] [--output user://render_camera_occluders/report.json] const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" const MAX_REPORTED_INTERSECTIONS := 20 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_camera_occluders/report.json") var only_filter := _argument(arguments, "--only", "").to_lower() var manifest := _load_json(MANIFEST_PATH) var packed_scene: PackedScene = load(String(manifest.get("scene", ""))) if packed_scene == null: push_error("CAMERA_OCCLUDER_PROBE: cannot load streaming scene") quit(1) return var world := packed_scene.instantiate() as Node3D var camera := Camera3D.new() camera.name = "OccluderProbeCamera" 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 var checkpoint_name := String(checkpoint.get("name", "checkpoint")) if not only_filter.is_empty() and not checkpoint_name.to_lower().contains(only_filter): continue var camera_position := _vector3(checkpoint.get("camera", [])) var target_position := _vector3(checkpoint.get("target", [])) 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 geometry_nodes: Array[Node3D] = [] _collect_geometry_nodes(world, geometry_nodes) var containing: Array[Dictionary] = [] var intersecting: Array[Dictionary] = [] for geometry_node in geometry_nodes: var world_aabb := _geometry_world_aabb(geometry_node) if world_aabb.size.is_zero_approx(): continue var record := _geometry_record(geometry_node, world_aabb, camera_position) if world_aabb.has_point(camera_position): containing.append(record) var intersection = world_aabb.intersects_segment(camera_position, target_position) if intersection != null: record["intersection_distance"] = camera_position.distance_to(intersection as Vector3) intersecting.append(record) intersecting.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return float(a.intersection_distance) < float(b.intersection_distance)) if intersecting.size() > MAX_REPORTED_INTERSECTIONS: intersecting.resize(MAX_REPORTED_INTERSECTIONS) var result := { "name": checkpoint_name, "geometry_node_count": geometry_nodes.size(), "camera_containing_geometry": containing, "segment_intersecting_geometry": intersecting, } results.append(result) print("CAMERA_OCCLUDERS name=%s geometry=%d containing=%d segment=%d" % [ checkpoint_name, geometry_nodes.size(), containing.size(), intersecting.size()]) var report := { "schema_version": 1, "created_utc": Time.get_datetime_string_from_system(true, true), "wait_seconds": wait_seconds, "coverage": "scene_tree_only; RenderingServer RID-only instances are excluded", "results": results, } if not _write_json(output_path, report): quit(1) return world.queue_free() quit(0 if not results.is_empty() else 1) func _collect_geometry_nodes(node: Node, output: Array[Node3D]) -> void: if node is MeshInstance3D: var mesh_instance := node as MeshInstance3D if mesh_instance.mesh != null and not mesh_instance.name.begins_with("TileLOD"): output.append(mesh_instance) elif node is MultiMeshInstance3D: var multimesh_instance := node as MultiMeshInstance3D if multimesh_instance.multimesh != null: output.append(multimesh_instance) for child in node.get_children(): _collect_geometry_nodes(child, output) func _geometry_world_aabb(node: Node3D) -> AABB: if node is MeshInstance3D: return node.global_transform * (node as MeshInstance3D).mesh.get_aabb() if node is MultiMeshInstance3D: return node.global_transform * (node as MultiMeshInstance3D).multimesh.get_aabb() return AABB() func _geometry_record(node: Node3D, world_aabb: AABB, camera_position: Vector3) -> Dictionary: var node_path := String(node.get_path()) var category := "geometry" if node_path.contains("/M2s/") or node is MultiMeshInstance3D: category = "m2" elif node_path.contains("/WMOs/"): category = "wmo" return { "category": category, "node_path": node_path, "distance_to_center": camera_position.distance_to(world_aabb.get_center()), "aabb_position": _vector3_array(world_aabb.position), "aabb_size": _vector3_array(world_aabb.size), } 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 _vector3_array(value: Vector3) -> Array[float]: return [value.x, value.y, value.z] 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