From b199876ce6294137596bac383fd93d5ee2b7214e Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 10 Jul 2026 11:21:33 +0400 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RENDER.md | 2 + docs/RENDER_BASELINE.md | 76 ++++ .../streaming/streaming_world_loader.gd | 30 ++ src/tools/capture_render_checkpoints.gd | 330 ++++++++++++++---- src/tools/render_baseline_manifest.json | 97 +++++ src/tools/verify_render_baseline_manifest.gd | 83 +++++ targets/00-render-baseline.md | 30 +- tools/run_render_baseline.ps1 | 63 ++++ 8 files changed, 622 insertions(+), 89 deletions(-) create mode 100644 docs/RENDER_BASELINE.md create mode 100644 src/tools/render_baseline_manifest.json create mode 100644 src/tools/verify_render_baseline_manifest.gd create mode 100644 tools/run_render_baseline.ps1 diff --git a/RENDER.md b/RENDER.md index 7e67704..0a3dfa9 100644 --- a/RENDER.md +++ b/RENDER.md @@ -1,5 +1,7 @@ # Renderer Notes +Воспроизводимый renderer baseline для текущей цели M00, regression manifest, метрики и правила сравнения описаны в [`docs/RENDER_BASELINE.md`](docs/RENDER_BASELINE.md). Baseline фиксирует текущее поведение и известные расхождения, но не заявляет parity с WoW 3.3.5a. + Цель renderer-работы в этом проекте: добиться ощущения производительности оригинального клиента WoW 3.3.5a в Godot, без видимых фризов при переходе ADT -> ADT и без постоянного отката видимых участков к низкому качеству. Этот документ фиксирует текущее состояние рендера, сделанные оптимизации и практические правила дальнейшей работы. diff --git a/docs/RENDER_BASELINE.md b/docs/RENDER_BASELINE.md new file mode 100644 index 0000000..53318f9 --- /dev/null +++ b/docs/RENDER_BASELINE.md @@ -0,0 +1,76 @@ +# Renderer baseline M00 + +Этот документ описывает воспроизводимый baseline renderer перед архитектурной декомпозицией. Он не является заявлением о визуальной совместимости `1:1` с WoW 3.3.5a. + +## Единый запуск + +Из корня репозитория: + +```powershell +.\tools\run_render_baseline.ps1 +``` + +Скрипт последовательно проверяет headless-загрузку проекта, renderer materials, M2 unique-id dedupe, regression manifest и затем снимает checkpoint-ы. Для CI или быстрой проверки контракта без PNG: + +```powershell +.\tools\run_render_baseline.ps1 -DryRun -WaitSeconds 0.1 -MeasureSeconds 0.1 +``` + +Если `godot` отсутствует в `PATH`, скрипт использует локальный executable, указанный в `RENDER.md`. Другой executable задаётся через `-GodotPath`. + +Результат по умолчанию находится в `user://render_baseline`: + +- `report.json` — Godot, OS, CPU, GPU/backend, viewport, revision, cache inventory, cache versions, параметры checkpoint-ов и результаты; +- `__cold_process.png` — первый визит в текущем процессе; +- `__warm_revisit.png` — повторный визит в том же процессе после обхода контрольных точек. + +`cold_process` не означает очищенный Windows filesystem cache. Поле `cache_state` и полный inventory обязаны интерпретироваться вместе с результатом. Удаление или принудительная пересборка cache не входит в baseline-команду. Для отдельного запуска после контролируемой очистки безопасного локального cache следует передать осмысленную метку, например `-CacheState rebuilt-clean`; proprietary source assets команда не изменяет. + +## Контрольные точки и детерминизм + +Нормативный manifest: `res://src/tools/render_baseline_manifest.json`. Он не содержит proprietary assets и фиксирует семь обязательных классов: + +- Elwynn terrain overview; +- граница ADT около world center; +- dense M2 в Goldshire; +- Goldshire Inn как large WMO; +- Elwynn waterfall как liquid; +- синтетический native-animation probe `GryphonRoost01.m2`; +- тот же Elwynn overview в 19:00 для sky transition. + +Зафиксированы camera/target/player position, viewport, FOV, quality preset и время мира. Для temporal shader/animation кадров SHA-256 служит идентификатором артефакта, но не pass-критерием: будущая автоматическая визуальная проверка должна использовать perceptual diff и согласованный tolerance. Синтетический animation probe существует только внутри capture tool и не меняет runtime streaming scene. + +## Метрики и budget + +Каждая точка записывает load/wait time, frame time p50/p95/p99, максимальный frame hitch, static/video memory, draw calls, rendered objects и снимок streaming queues. При сравнении следующего renderer с этим baseline p95, p99, max hitch, load time и memory не должны ухудшаться более чем на 10% без отдельного принятого объяснения и обновления baseline. + +Измерение считается неполным, если snapshot показывает незавершённые очереди: это состояние остаётся в `report.json` и не должно скрываться. Значения сравниваются только на одинаковых Godot, backend, hardware/profile, cache state, viewport и manifest revision. + +## Cache contract + +Manifest фиксирует проверяемые версии и причины invalidation: + +| Cache | Version | Invalidate when | +|---|---:|---| +| baked terrain | 5 | изменились baked geometry или placement payload | +| streaming terrain | 2 | изменились streaming geometry или M2 `unique_id` payload | +| terrain splat | 1 | изменился splat resource payload | +| terrain control splat | 3 | изменились control atlas, layer map или texture array | +| WMO streaming | 2 | изменилась WMO render geometry | +| WMO builder | 2 | изменились scene transform или builder payload | +| M2 material | 2 | изменились material или custom vertex payload | + +`verify_render_baseline_manifest.gd` сравнивает таблицу с runtime constants. Shader-only refresh versions, не изменяющие serialized payload, обновляются по правилам `RENDER.md`; изменение payload требует version bump и rebake. + +## Fidelity gaps относительно 3.3.5a + +Baseline пока не имеет approved парных кадров оригинального клиента build 12340. До их появления нельзя утверждать parity. Известны следующие классы расхождений: + +- неполные M2 animation/skinning, particles и ribbons; +- приближённые M2/WMO material combiners и pass ordering; +- отсутствие настоящего WMO portal/room culling; +- упрощённые liquid depth/shore rules и texture selection; +- неполная проверка skybox/lighting transition по зонам; +- возможная смена D3D12 на Vulkan при ошибке descriptor heap — фактический backend всегда берётся из `report.json`. + +Парное сравнение с клиентом 3.3.5a должно использовать те же map position, local time и weather. Каждый gap регистрируется как terrain/material, placement, animation, lighting, liquid или culling; baseline сам по себе gap не закрывает. diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index ffa779b..618d9da 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -943,6 +943,36 @@ func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bo ]) +## Read-only machine-readable state used by renderer baseline tooling. +## Keep this free of queue mutation so checkpoint capture cannot alter runtime behavior. +func render_baseline_snapshot() -> Dictionary: + return { + "tiles": _tile_states.size(), + "loading": _tile_loading_tasks.size(), + "queues": { + "tile": _tile_load_queue.size(), + "terrain_upgrade": _terrain_upgrade_tasks.size(), + "terrain_control": _terrain_control_splat_cache_tasks.size(), + "terrain_splat": _terrain_splat_queue_size(), + "water": _water_load_queue.size() + _water_load_tasks.size(), + "detail": _detail_asset_queue.size(), + "m2_task": _m2_group_tasks.size(), + "m2_animation": _m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(), + "m2_mesh": _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), + "m2_build": _m2_build_queue.size(), + "wmo_build": _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(), + "wmo_groups": _wmo_render_build_queue.size(), + "lod_create": _tile_lod_create_queue.size(), + "lod_remove": _tile_lod_remove_queue.size(), + "chunk_create": _chunk_create_queue.size(), + "chunk_remove": _chunk_remove_queue.size(), + }, + "cache_entries": _tile_mesh_cache.size(), + "m2_active_unique_ids": _m2_unique_registry.size(), + "wmo_instances": _wmo_registry.size(), + } + + func _tick_runtime_stats(delta: float) -> void: if not runtime_stats_enabled or Engine.is_editor_hint(): return diff --git a/src/tools/capture_render_checkpoints.gd b/src/tools/capture_render_checkpoints.gd index 36ab468..017bffd 100644 --- a/src/tools/capture_render_checkpoints.gd +++ b/src/tools/capture_render_checkpoints.gd @@ -1,33 +1,7 @@ extends SceneTree -const STREAMING_SCENE := "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn" - -const CHECKPOINTS := [ - { - "name": "elwynn_waterfall_front", - "camera": Vector3(16445.0, 125.0, 26295.0), - "target": Vector3(16518.84, 68.0, 26427.27), - "player": Vector3(16518.84, 55.0, 26427.27), - }, - { - "name": "elwynn_waterfall_side", - "camera": Vector3(16670.0, 115.0, 26320.0), - "target": Vector3(16518.84, 72.0, 26427.27), - "player": Vector3(16518.84, 55.0, 26427.27), - }, - { - "name": "goldshire_inn_windows", - "camera": Vector3(16942.0, 82.0, 26503.0), - "target": Vector3(17042.27, 66.0, 26530.91), - "player": Vector3(17042.27, 58.0, 26530.91), - }, - { - "name": "goldshire_blacksmith_windows", - "camera": Vector3(16892.0, 79.0, 26562.0), - "target": Vector3(16972.46, 64.0, 26526.7), - "player": Vector3(16972.46, 58.0, 26526.7), - }, -] +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") func _initialize() -> void: @@ -36,42 +10,56 @@ func _initialize() -> void: func _capture_async() -> void: var args := OS.get_cmdline_user_args() - var output_dir := _arg(args, "--output", "user://render_checkpoints") + 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", "8.0")) - var width := int(_arg(args, "--width", "1280")) - var height := int(_arg(args, "--height", "900")) + 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 headless := DisplayServer.get_name().to_lower() == "headless" var dry_run := args.has("--dry-run") or headless + var viewport: Array = manifest.get("viewport", [1280, 900]) + get_root().size = Vector2i(maxi(16, int(viewport[0])), maxi(16, int(viewport[1]))) - get_root().size = Vector2i(maxi(16, width), maxi(16, height)) var abs_output_dir := ProjectSettings.globalize_path(output_dir) - if not dry_run: - DirAccess.make_dir_recursive_absolute(abs_output_dir) - elif headless and not args.has("--dry-run"): + 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 packed: PackedScene = load(STREAMING_SCENE) + var scene_path := String(manifest.get("scene", "")) + var packed: PackedScene = load(scene_path) if packed == null: - push_error("Cannot load streaming scene: %s" % STREAMING_SCENE) + 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" % STREAMING_SCENE) + 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 = 62.0 + camera.fov = float(manifest.get("camera_fov", 62.0)) camera.far = 50000.0 - camera.position = CHECKPOINTS[0].get("camera", Vector3.ZERO) + 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 @@ -84,51 +72,245 @@ func _capture_async() -> void: 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, + "environment": _environment_metadata(), + "comparison_budgets": manifest.get("comparison_budgets", {}), + "cache_contract": manifest.get("cache_contract", {}), + "cache_inventory": _cache_inventory(), + "results": [], + } var captured := 0 - for checkpoint_variant in CHECKPOINTS: - 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 passes := ["cold_process", "warm_revisit"] + if dry_run: + passes = ["dry_run"] - camera.global_position = checkpoint.get("camera", Vector3.ZERO) - camera.look_at(checkpoint.get("target", Vector3.ZERO), Vector3.UP) - if player != null: - player.global_position = checkpoint.get("player", checkpoint.get("target", Vector3.ZERO)) - if world.has_method("_refresh_streaming_targets_at"): - world.call("_refresh_streaming_targets_at", camera.global_position, true) + 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 - await create_timer(maxf(wait_seconds, 0.0)).timeout - if dry_run: - print("RENDER_CHECKPOINT dry_run name=%s camera=%s target=%s" % [ - checkpoint_name, - camera.global_position, - checkpoint.get("target", Vector3.ZERO), - ]) + 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])) + camera.look_at(_vector3(checkpoint.get("target", [0.0, 0.0, 0.0])), Vector3.UP) + 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 time=%.2f" % [ + checkpoint_name, + str(checkpoint.get("coverage", [])), + camera.global_position, + _vector3(checkpoint.get("target", [0.0, 0.0, 0.0])), + 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() + image.flip_y() + 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 - continue + if probe != null: + probe.queue_free() + await process_frame - await RenderingServer.frame_post_draw - - var image := get_root().get_texture().get_image() - var file_name := "%s.png" % checkpoint_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 - print("RENDER_CHECKPOINT saved %s" % abs_path) - captured += 1 + 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() if captured <= 0: - push_error("No checkpoints captured. --only filter was: %s" % only) + 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 _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(): diff --git a/src/tools/render_baseline_manifest.json b/src/tools/render_baseline_manifest.json new file mode 100644 index 0000000..6a30d7c --- /dev/null +++ b/src/tools/render_baseline_manifest.json @@ -0,0 +1,97 @@ +{ + "schema_version": 1, + "profile": "Blizzlike335", + "scene": "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn", + "map": "Azeroth", + "quality_preset": "High", + "viewport": [1280, 900], + "camera_fov": 62.0, + "default_wait_seconds": 8.0, + "default_measure_seconds": 3.0, + "comparison_budgets": { + "frame_ms_p95_max_regression_percent": 10.0, + "frame_ms_p99_max_regression_percent": 10.0, + "max_hitch_ms_max_regression_percent": 10.0, + "load_time_ms_max_regression_percent": 10.0, + "memory_bytes_max_regression_percent": 10.0, + "visual_diff": "perceptual comparison required; exact PNG hashes are evidence, not an animated-scene pass criterion" + }, + "required_coverage": [ + "terrain", + "adt_boundary", + "dense_m2", + "large_wmo", + "liquid", + "animated_m2", + "sky_transition" + ], + "cache_contract": { + "baked_terrain": {"version": 5, "invalidate": "baked geometry or placement payload changes"}, + "streaming_terrain": {"version": 2, "invalidate": "streaming geometry or M2 unique_id payload changes"}, + "terrain_splat": {"version": 1, "invalidate": "splat resource payload changes"}, + "terrain_control_splat": {"version": 3, "invalidate": "control atlas, layer map, or texture array payload changes"}, + "wmo_streaming": {"version": 2, "invalidate": "WMO render geometry payload changes"}, + "wmo_builder": {"version": 2, "invalidate": "WMO scene transform or builder payload changes"}, + "m2_material": {"version": 2, "invalidate": "M2 material or custom vertex payload changes"} + }, + "checkpoints": [ + { + "name": "elwynn_terrain_overview", + "coverage": ["terrain"], + "camera": [16680.0, 180.0, 26220.0], + "target": [16800.0, 62.0, 26400.0], + "player": [16800.0, 58.0, 26400.0], + "time_hours": 13.0 + }, + { + "name": "elwynn_adt_boundary", + "coverage": ["adt_boundary"], + "camera": [17020.0, 105.0, 26590.0], + "target": [17066.666, 62.0, 26666.666], + "player": [17060.0, 58.0, 26660.0], + "time_hours": 13.0 + }, + { + "name": "goldshire_dense_m2", + "coverage": ["dense_m2"], + "camera": [16835.0, 135.0, 26435.0], + "target": [17015.0, 62.0, 26525.0], + "player": [17015.0, 58.0, 26525.0], + "time_hours": 13.0 + }, + { + "name": "goldshire_inn_large_wmo", + "coverage": ["large_wmo"], + "camera": [16942.0, 82.0, 26503.0], + "target": [17042.27, 66.0, 26530.91], + "player": [17042.27, 58.0, 26530.91], + "time_hours": 13.0 + }, + { + "name": "elwynn_waterfall_liquid", + "coverage": ["liquid"], + "camera": [16445.0, 125.0, 26295.0], + "target": [16518.84, 68.0, 26427.27], + "player": [16518.84, 55.0, 26427.27], + "time_hours": 13.0 + }, + { + "name": "gryphon_roost_native_animation", + "coverage": ["animated_m2"], + "camera": [16980.0, 72.0, 26505.0], + "target": [17000.0, 65.0, 26525.0], + "player": [17000.0, 58.0, 26525.0], + "time_hours": 13.0, + "probe_model": "world/GENERIC/HUMAN/PASSIVE DOODADS/GryphonRoost/GryphonRoost01.m2", + "probe_scale": 1.0 + }, + { + "name": "elwynn_sky_dusk", + "coverage": ["sky_transition"], + "camera": [16680.0, 180.0, 26220.0], + "target": [16800.0, 62.0, 26400.0], + "player": [16800.0, 58.0, 26400.0], + "time_hours": 19.0 + } + ] +} diff --git a/src/tools/verify_render_baseline_manifest.gd b/src/tools/verify_render_baseline_manifest.gd new file mode 100644 index 0000000..0ae1170 --- /dev/null +++ b/src/tools/verify_render_baseline_manifest.gd @@ -0,0 +1,83 @@ +extends SceneTree + +const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" +const STREAMING_LOADER := preload("res://src/scenes/streaming/streaming_world_loader.gd") +const STREAMING_TILE := preload("res://src/resources/streaming_adt_tile.gd") +const SPLAT_TILE := preload("res://src/resources/splat_adt_tile.gd") +const CONTROL_SPLAT_TILE := preload("res://src/resources/control_splat_adt_tile.gd") +const WMO_STREAMING := preload("res://src/resources/wmo_streaming_resource.gd") +const WMO_BUILDER := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd") +const M2_BUILDER := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") + + +func _initialize() -> void: + var manifest := _load_manifest() + if manifest.is_empty(): + quit(1) + return + + var failures: Array[String] = [] + _expect(int(manifest.get("schema_version", 0)) == 1, "manifest schema must be version 1", failures) + _expect(String(manifest.get("profile", "")) == "Blizzlike335", "manifest profile must be Blizzlike335", failures) + _expect(ResourceLoader.exists(String(manifest.get("scene", ""))), "streaming scene must exist", failures) + + var covered := {} + var names := {} + for checkpoint_variant in manifest.get("checkpoints", []): + if not (checkpoint_variant is Dictionary): + failures.append("checkpoint entry must be a dictionary") + continue + var checkpoint: Dictionary = checkpoint_variant + var checkpoint_name := String(checkpoint.get("name", "")) + _expect(not checkpoint_name.is_empty(), "checkpoint name must not be empty", failures) + _expect(not names.has(checkpoint_name), "duplicate checkpoint name: %s" % checkpoint_name, failures) + names[checkpoint_name] = true + for key in ["camera", "target", "player"]: + var value = checkpoint.get(key, []) + _expect(value is Array and value.size() == 3, "%s.%s must contain three coordinates" % [checkpoint_name, key], failures) + for coverage_variant in checkpoint.get("coverage", []): + covered[String(coverage_variant)] = true + + for required_variant in manifest.get("required_coverage", []): + var required := String(required_variant) + _expect(covered.has(required), "missing checkpoint coverage: %s" % required, failures) + + var contract: Dictionary = manifest.get("cache_contract", {}) + var actual_versions := { + "baked_terrain": STREAMING_LOADER.REQUIRED_BAKED_TILE_FORMAT_VERSION, + "streaming_terrain": STREAMING_TILE.FORMAT_VERSION, + "terrain_splat": SPLAT_TILE.FORMAT_VERSION, + "terrain_control_splat": CONTROL_SPLAT_TILE.FORMAT_VERSION, + "wmo_streaming": WMO_STREAMING.FORMAT_VERSION, + "wmo_builder": WMO_BUILDER.WMO_BUILDER_FORMAT_VERSION, + "m2_material": M2_BUILDER.MATERIAL_FORMAT_VERSION, + } + for cache_name in actual_versions: + var entry: Dictionary = contract.get(cache_name, {}) + _expect(int(entry.get("version", -1)) == int(actual_versions[cache_name]), "cache version mismatch for %s" % cache_name, failures) + _expect(not String(entry.get("invalidate", "")).is_empty(), "cache invalidation rule missing for %s" % cache_name, failures) + + if not failures.is_empty(): + for failure in failures: + push_error("RENDER_BASELINE_MANIFEST: %s" % failure) + quit(1) + return + print("RENDER_BASELINE_MANIFEST PASS checkpoints=%d coverage=%d caches=%d" % [names.size(), covered.size(), actual_versions.size()]) + quit(0) + + +func _load_manifest() -> Dictionary: + var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ) + if file == null: + push_error("Cannot open render baseline manifest: %s" % MANIFEST_PATH) + return {} + var parsed = JSON.parse_string(file.get_as_text()) + if not (parsed is Dictionary): + push_error("Render baseline manifest is not a JSON object") + return {} + return parsed + + +func _expect(condition: bool, message: String, failures: Array[String]) -> void: + if not condition: + failures.append(message) diff --git a/targets/00-render-baseline.md b/targets/00-render-baseline.md index 849491e..0535918 100644 --- a/targets/00-render-baseline.md +++ b/targets/00-render-baseline.md @@ -8,14 +8,14 @@ ## Steps -- [ ] Зафиксировать версию Godot, backend, hardware/profile и cache state. -- [ ] Выбрать контрольные позиции: terrain, ADT boundary, dense M2, large WMO, liquid, animated M2, sky transition. -- [ ] Объединить существующие renderer smoke scripts в документированный запуск без изменения visual behavior. -- [ ] Снять deterministic screenshots и metadata для каждой позиции. -- [ ] Записать cold/warm показатели frame time p50/p95/p99, max hitch, memory, queues и load time. -- [ ] Зафиксировать cache format versions и правила invalidation. -- [ ] Добавить regression manifest, не содержащий proprietary assets. -- [ ] Обновить `RENDER.md` ссылкой на baseline и известные расхождения с клиентом 3.3.5a. +- [x] Зафиксировать версию Godot, backend, hardware/profile и cache state. +- [x] Выбрать контрольные позиции: terrain, ADT boundary, dense M2, large WMO, liquid, animated M2, sky transition. +- [x] Объединить существующие renderer smoke scripts в документированный запуск без изменения visual behavior. +- [x] Снять deterministic screenshots и metadata для каждой позиции. +- [x] Записать cold/warm показатели frame time p50/p95/p99, max hitch, memory, queues и load time. +- [x] Зафиксировать cache format versions и правила invalidation. +- [x] Добавить regression manifest, не содержащий proprietary assets. +- [x] Обновить `RENDER.md` ссылкой на baseline и известные расхождения с клиентом 3.3.5a. ## Fidelity evidence @@ -38,10 +38,10 @@ godot --headless --path . --script res://src/tools/capture_render_checkpoints.gd ## Evidence -- Date: -- Revision/worktree: -- Commands: -- Results: -- Fidelity comparison: -- Changed files: -- Remaining risks: +- Date: 2026-07-10 +- Revision/worktree: `93bfe11` + worktree baseline changes. +- Commands: `.\tools\run_render_baseline.ps1 -DryRun -WaitSeconds 0.1 -MeasureSeconds 0.1 -Output user://render_baseline_runner_test`; `.\tools\run_render_baseline.ps1 -Output user://render_baseline_m00_2026-07-10 -CacheState existing`. +- Results: headless load, renderer material smoke, M2 unique-id dedupe and manifest contract passed. Full run produced 14 PNGs and 14 result records for 7 cold/warm checkpoints. Actual renderer was Godot 4.6.1, Vulkan Forward+, NVIDIA RTX 5070, 1280x900, High profile, with all seven cache families present. Cold p95 range was 31.585..86.537 ms with max hitch 258.456 ms; warm p95 range was 18.824..38.085 ms with max hitch 163.363 ms. The final streaming snapshots had an empty tile queue. Report: `user://render_baseline_m00_2026-07-10/report.json`. +- Fidelity comparison: fixed positions/time/profile and current visual gaps are recorded, but no approved paired screenshots from the original WoW 3.3.5a build 12340 were available. This baseline does not claim parity. +- Changed files: `RENDER.md`, `docs/RENDER_BASELINE.md`, `src/scenes/streaming/streaming_world_loader.gd`, `src/tools/capture_render_checkpoints.gd`, `src/tools/render_baseline_manifest.json`, `src/tools/verify_render_baseline_manifest.gd`, `tools/run_render_baseline.ps1`, `targets/00-render-baseline.md`. +- Remaining risks: original-client paired comparison is still required before DONE; D3D12 descriptor heap creation failed and the measured run fell back to Vulkan; shutdown reported leaked renderer RIDs/resources; current captures intentionally preserve visible terrain/placement/material/animation gaps, including the synthetic native animated M2 probe; perceptual image diff/tolerance is documented but not automated yet. diff --git a/tools/run_render_baseline.ps1 b/tools/run_render_baseline.ps1 new file mode 100644 index 0000000..5a109ca --- /dev/null +++ b/tools/run_render_baseline.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [string]$GodotPath, + [string]$Output = "user://render_baseline", + [string]$CacheState = "existing", + [double]$WaitSeconds = 8.0, + [double]$MeasureSeconds = 3.0, + [switch]$DryRun +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +if (-not $GodotPath) { + $command = Get-Command godot -ErrorAction SilentlyContinue + if ($command) { + $GodotPath = $command.Source + } else { + $GodotPath = Join-Path $env:TEMP "godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe" + } +} +if (-not (Test-Path -LiteralPath $GodotPath)) { + throw "Godot executable not found: $GodotPath" +} + +$revision = (& git -C $repoRoot rev-parse --short HEAD).Trim() +if (-not $revision) { + $revision = "worktree" +} + +function Invoke-GodotStep { + param([string]$Name, [string[]]$Arguments) + Write-Host "[$Name] $GodotPath $($Arguments -join ' ')" + $process = Start-Process -FilePath $GodotPath -ArgumentList $Arguments -NoNewWindow -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$Name failed with exit code $($process.ExitCode)" + } +} + +Push-Location $repoRoot +try { + Invoke-GodotStep "project-load" @("--headless", "--path", ".", "--quit") + Invoke-GodotStep "render-materials" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_materials.gd") + Invoke-GodotStep "m2-unique-dedupe" @("--headless", "--path", ".", "--script", "res://src/tools/verify_m2_unique_dedupe.gd") + Invoke-GodotStep "baseline-manifest" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_baseline_manifest.gd") + + $captureArgs = @( + "--path", ".", + "--script", "res://src/tools/capture_render_checkpoints.gd", + "--", + "--output", $Output, + "--cache-state", $CacheState, + "--revision", $revision, + "--wait", $WaitSeconds.ToString([Globalization.CultureInfo]::InvariantCulture), + "--measure", $MeasureSeconds.ToString([Globalization.CultureInfo]::InvariantCulture) + ) + if ($DryRun) { + $captureArgs = @("--headless") + $captureArgs + @("--dry-run") + } + Invoke-GodotStep "capture" $captureArgs + Write-Host "Renderer baseline completed. Report: $Output/report.json" +} finally { + Pop-Location +}