extends SceneTree const DEFAULT_MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" const M2_NATIVE_ANIMATED_BUILDER := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd") const SHUTDOWN_DRAIN_FRAMES := 2 func _initialize() -> void: _capture_async.call_deferred() func _capture_async() -> void: var args := OS.get_cmdline_user_args() var manifest_path := _arg(args, "--manifest", DEFAULT_MANIFEST_PATH) var manifest := _load_json(manifest_path) if manifest.is_empty(): quit(1) return var output_dir := _arg(args, "--output", "user://render_baseline") var only := _arg(args, "--only", "").to_lower() var wait_seconds := float(_arg(args, "--wait", str(manifest.get("default_wait_seconds", 8.0)))) var measure_seconds := float(_arg(args, "--measure", str(manifest.get("default_measure_seconds", 3.0)))) var revision := _arg(args, "--revision", "worktree") var cache_state := _arg(args, "--cache-state", "existing") var camera_fov_override := float(_arg(args, "--camera-fov", str(manifest.get("camera_fov", 62.0)))) var camera_yaw_offset_degrees := float(_arg(args, "--camera-yaw-offset", "0.0")) var camera_pitch_offset_degrees := float(_arg(args, "--camera-pitch-offset", "0.0")) var single_pass := args.has("--single-pass") var headless := DisplayServer.get_name().to_lower() == "headless" var dry_run := args.has("--dry-run") or headless var viewport: Array = manifest.get("viewport", [1280, 900]) var viewport_width := int(_arg(args, "--viewport-width", str(viewport[0]))) var viewport_height := int(_arg(args, "--viewport-height", str(viewport[1]))) get_root().size = Vector2i(maxi(16, viewport_width), maxi(16, viewport_height)) var abs_output_dir := ProjectSettings.globalize_path(output_dir) DirAccess.make_dir_recursive_absolute(abs_output_dir) if headless and not args.has("--dry-run"): print("RENDER_CHECKPOINT dry-run: headless display cannot capture viewport PNGs.") var scene_path := String(manifest.get("scene", "")) var packed: PackedScene = load(scene_path) if packed == null: push_error("Cannot load streaming scene: %s" % scene_path) quit(1) return var world = packed.instantiate() if not (world is Node3D): push_error("Streaming scene root is not Node3D: %s" % scene_path) quit(1) return var checkpoints: Array = manifest.get("checkpoints", []) if checkpoints.is_empty(): push_error("Render baseline manifest has no checkpoints") quit(1) return var first: Dictionary = checkpoints[0] var camera := Camera3D.new() camera.name = "CheckpointCamera" camera.current = true camera.fov = camera_fov_override camera.far = 50000.0 camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0])) (world as Node3D).add_child(camera) world.set("camera_path", NodePath("CheckpointCamera")) world.set("debug_streaming", true) world.set("runtime_stats_enabled", true) get_root().add_child(world) await process_frame await process_frame camera.make_current() var player := world.get_node_or_null("ThirdPersonPlayer") as Node3D if player != null: player.set_process(false) player.set_physics_process(false) var visual := player.get_node_or_null("Visual") as Node3D if visual != null: visual.visible = false var report := { "schema_version": 1, "manifest": manifest_path, "profile": manifest.get("profile", "Blizzlike335"), "revision": revision, "created_utc": Time.get_datetime_string_from_system(true, true), "dry_run": dry_run, "cache_state": cache_state, "camera_fov": camera_fov_override, "camera_yaw_offset_degrees": camera_yaw_offset_degrees, "camera_pitch_offset_degrees": camera_pitch_offset_degrees, "environment": _environment_metadata(), "comparison_budgets": manifest.get("comparison_budgets", {}), "cache_contract": manifest.get("cache_contract", {}), "cache_inventory": _cache_inventory(), "results": [], } var captured := 0 var passes := ["cold_process", "warm_revisit"] if single_pass: passes = ["cold_process"] if dry_run: passes = ["dry_run"] for pass_name in passes: for checkpoint_variant in checkpoints: if not (checkpoint_variant is Dictionary): continue var checkpoint: Dictionary = checkpoint_variant var checkpoint_name := String(checkpoint.get("name", "checkpoint")) if not only.is_empty() and not checkpoint_name.to_lower().contains(only): continue var probe := _create_probe(checkpoint, dry_run) if probe != null: (world as Node3D).add_child(probe) probe.global_position = _vector3(checkpoint.get("target", [0.0, 0.0, 0.0])) probe.scale = Vector3.ONE * float(checkpoint.get("probe_scale", 1.0)) camera.global_position = _vector3(checkpoint.get("camera", [0.0, 0.0, 0.0])) _orient_camera_without_roll(camera, _vector3(checkpoint.get("target", [0.0, 0.0, 0.0]))) _apply_camera_pose_offsets(camera, camera_yaw_offset_degrees, camera_pitch_offset_degrees) camera.make_current() if player != null: player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0]))) _set_sky_time(world, float(checkpoint.get("time_hours", 13.0))) if world.has_method("_refresh_streaming_targets_at"): world.call("_refresh_streaming_targets_at", camera.global_position, true) if dry_run: print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [ checkpoint_name, str(checkpoint.get("coverage", [])), camera.global_position, _vector3(checkpoint.get("target", [0.0, 0.0, 0.0])), camera_yaw_offset_degrees, camera_pitch_offset_degrees, float(checkpoint.get("time_hours", 13.0)), ]) (report["results"] as Array).append(_result_record(checkpoint, pass_name, 0.0, {}, "")) captured += 1 if probe != null: probe.queue_free() continue var load_started := Time.get_ticks_usec() await create_timer(maxf(wait_seconds, 0.0)).timeout var load_time_ms := float(Time.get_ticks_usec() - load_started) / 1000.0 var metrics := await _measure_frames(maxf(measure_seconds, 0.1), world) await RenderingServer.frame_post_draw var image := get_root().get_texture().get_image() var file_name := "%s__%s.png" % [checkpoint_name, pass_name] var abs_path := abs_output_dir.path_join(file_name) var err := image.save_png(abs_path) if err != OK: push_error("Failed to save checkpoint %s to %s (err=%d)" % [checkpoint_name, abs_path, err]) quit(1) return var sha256 := FileAccess.get_sha256(abs_path) (report["results"] as Array).append(_result_record(checkpoint, pass_name, load_time_ms, metrics, sha256)) print("RENDER_CHECKPOINT saved %s sha256=%s" % [abs_path, sha256]) captured += 1 if probe != null: probe.queue_free() await process_frame var report_path := abs_output_dir.path_join("report.json") var report_file := FileAccess.open(report_path, FileAccess.WRITE) if report_file == null: push_error("Cannot write render baseline report: %s" % report_path) quit(1) return report_file.store_string(JSON.stringify(report, " ")) report_file.close() print("RENDER_BASELINE report=%s results=%d" % [report_path, captured]) world.queue_free() # SceneTree deletion is deferred. Allow the world and its queued children # to run their shutdown paths before engine teardown. for unused_frame in SHUTDOWN_DRAIN_FRAMES: await process_frame if captured <= 0: push_error("No checkpoints selected. --only filter was: %s" % only) quit(1) return quit(0) func _measure_frames(seconds: float, world: Node) -> Dictionary: var frame_times: Array[float] = [] var deadline := Time.get_ticks_usec() + int(seconds * 1000000.0) var previous := Time.get_ticks_usec() while Time.get_ticks_usec() < deadline: await process_frame var now := Time.get_ticks_usec() frame_times.append(float(now - previous) / 1000.0) previous = now frame_times.sort() var snapshot := {} if world.has_method("render_baseline_snapshot"): snapshot = world.call("render_baseline_snapshot") return { "frames": frame_times.size(), "frame_ms_p50": _percentile(frame_times, 0.50), "frame_ms_p95": _percentile(frame_times, 0.95), "frame_ms_p99": _percentile(frame_times, 0.99), "max_hitch_ms": frame_times[-1] if not frame_times.is_empty() else 0.0, "memory_static_bytes": int(Performance.get_monitor(Performance.MEMORY_STATIC)), "video_memory_bytes": int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)), "draw_calls": int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)), "objects": int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)), "streaming": snapshot, } func _orient_camera_without_roll(camera: Camera3D, target_position: Vector3) -> void: var forward := (target_position - camera.global_position).normalized() var right := forward.cross(Vector3.UP).normalized() if right.is_zero_approx(): right = Vector3.RIGHT var corrected_up := right.cross(forward).normalized() camera.global_basis = Basis(right, corrected_up, -forward) func _apply_camera_pose_offsets(camera: Camera3D, yaw_offset_degrees: float, pitch_offset_degrees: float) -> void: camera.global_rotate(Vector3.UP, deg_to_rad(yaw_offset_degrees)) camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(pitch_offset_degrees)) func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: float, metrics: Dictionary, sha256: String) -> Dictionary: return { "name": checkpoint.get("name", "checkpoint"), "coverage": checkpoint.get("coverage", []), "pass": pass_name, "camera": checkpoint.get("camera", []), "target": checkpoint.get("target", []), "player": checkpoint.get("player", []), "time_hours": checkpoint.get("time_hours", 13.0), "load_time_ms": load_time_ms, "metrics": metrics, "png_sha256": sha256, } func _create_probe(checkpoint: Dictionary, dry_run: bool) -> Node3D: var rel_path := String(checkpoint.get("probe_model", "")) if rel_path.is_empty() or dry_run: return null if not ClassDB.class_exists("M2Loader"): push_warning("M2Loader unavailable; skipping probe model %s" % rel_path) return null var extracted_dir := "res://data/extracted" var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(rel_path)) if not FileAccess.file_exists(abs_path): push_warning("Probe model missing: %s" % abs_path) return null var loader = ClassDB.instantiate("M2Loader") var data: Dictionary = loader.call("load_m2_animated", abs_path) if data.is_empty(): push_warning("Probe model could not be parsed: %s" % rel_path) return null return M2_NATIVE_ANIMATED_BUILDER.build(data, extracted_dir) func _set_sky_time(world: Node, time_hours: float) -> void: var sky := world.get_node_or_null("WowSkyController") if sky != null: sky.set("use_system_time", false) sky.set("time_speed", 0.0) sky.set("fixed_time_hours", time_hours) func _environment_metadata() -> Dictionary: return { "godot_version": Engine.get_version_info(), "os": OS.get_name(), "os_version": OS.get_version(), "cpu": OS.get_processor_name(), "logical_cpu_count": OS.get_processor_count(), "rendering_method": RenderingServer.get_current_rendering_method(), "rendering_driver": RenderingServer.get_current_rendering_driver_name(), "video_adapter": RenderingServer.get_video_adapter_name(), "video_vendor": RenderingServer.get_video_adapter_vendor(), "video_api": RenderingServer.get_video_adapter_api_version(), "memory": OS.get_memory_info(), "viewport": [get_root().size.x, get_root().size.y], } func _cache_inventory() -> Dictionary: var paths := [ "res://data/cache/baked_terrain_v2", "res://data/cache/baked_terrain_stream_v1", "res://data/cache/terrain_splat_v1", "res://data/cache/terrain_control_splat_v3", "res://data/cache/m2_glb", "res://data/cache/wmo_tscn", "res://data/cache/wmo_render_v1", ] var inventory := {} for path in paths: var dir := DirAccess.open(path) inventory[path] = { "present": dir != null, "file_count": _count_files(path) if dir != null else 0, } return inventory func _count_files(path: String) -> int: var dir := DirAccess.open(path) if dir == null: return 0 var count := dir.get_files().size() for child in dir.get_directories(): count += _count_files(path.path_join(child)) return count func _percentile(sorted_values: Array[float], fraction: float) -> float: if sorted_values.is_empty(): return 0.0 var index := clampi(int(ceil(fraction * sorted_values.size())) - 1, 0, sorted_values.size() - 1) return sorted_values[index] func _load_json(path: String) -> Dictionary: var file := FileAccess.open(path, FileAccess.READ) if file == null: push_error("Cannot open JSON file: %s" % path) return {} var parsed = JSON.parse_string(file.get_as_text()) if not (parsed is Dictionary): push_error("JSON root must be an object: %s" % path) return {} return parsed func _vector3(value: Variant) -> Vector3: if value is Array and value.size() >= 3: return Vector3(float(value[0]), float(value[1]), float(value[2])) return Vector3.ZERO func _arg(args: PackedStringArray, name: String, default_value: String) -> String: var idx := args.find(name) if idx >= 0 and idx + 1 < args.size(): return args[idx + 1] return default_value