базовый рендер
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# Renderer Notes
|
# 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 и без постоянного отката видимых участков к низкому качеству.
|
Цель renderer-работы в этом проекте: добиться ощущения производительности оригинального клиента WoW 3.3.5a в Godot, без видимых фризов при переходе ADT -> ADT и без постоянного отката видимых участков к низкому качеству.
|
||||||
|
|
||||||
Этот документ фиксирует текущее состояние рендера, сделанные оптимизации и практические правила дальнейшей работы.
|
Этот документ фиксирует текущее состояние рендера, сделанные оптимизации и практические правила дальнейшей работы.
|
||||||
|
|||||||
@@ -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-ов и результаты;
|
||||||
|
- `<checkpoint>__cold_process.png` — первый визит в текущем процессе;
|
||||||
|
- `<checkpoint>__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 не закрывает.
|
||||||
@@ -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:
|
func _tick_runtime_stats(delta: float) -> void:
|
||||||
if not runtime_stats_enabled or Engine.is_editor_hint():
|
if not runtime_stats_enabled or Engine.is_editor_hint():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
|
|
||||||
const STREAMING_SCENE := "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn"
|
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 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),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
@@ -36,42 +10,56 @@ func _initialize() -> void:
|
|||||||
|
|
||||||
func _capture_async() -> void:
|
func _capture_async() -> void:
|
||||||
var args := OS.get_cmdline_user_args()
|
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 only := _arg(args, "--only", "").to_lower()
|
||||||
var wait_seconds := float(_arg(args, "--wait", "8.0"))
|
var wait_seconds := float(_arg(args, "--wait", str(manifest.get("default_wait_seconds", 8.0))))
|
||||||
var width := int(_arg(args, "--width", "1280"))
|
var measure_seconds := float(_arg(args, "--measure", str(manifest.get("default_measure_seconds", 3.0))))
|
||||||
var height := int(_arg(args, "--height", "900"))
|
var revision := _arg(args, "--revision", "worktree")
|
||||||
|
var cache_state := _arg(args, "--cache-state", "existing")
|
||||||
var headless := DisplayServer.get_name().to_lower() == "headless"
|
var headless := DisplayServer.get_name().to_lower() == "headless"
|
||||||
var dry_run := args.has("--dry-run") or 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)
|
var abs_output_dir := ProjectSettings.globalize_path(output_dir)
|
||||||
if not dry_run:
|
DirAccess.make_dir_recursive_absolute(abs_output_dir)
|
||||||
DirAccess.make_dir_recursive_absolute(abs_output_dir)
|
if headless and not args.has("--dry-run"):
|
||||||
elif headless and not args.has("--dry-run"):
|
|
||||||
print("RENDER_CHECKPOINT dry-run: headless display cannot capture viewport PNGs.")
|
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:
|
if packed == null:
|
||||||
push_error("Cannot load streaming scene: %s" % STREAMING_SCENE)
|
push_error("Cannot load streaming scene: %s" % scene_path)
|
||||||
quit(1)
|
quit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
var world = packed.instantiate()
|
var world = packed.instantiate()
|
||||||
if not (world is Node3D):
|
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)
|
quit(1)
|
||||||
return
|
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()
|
var camera := Camera3D.new()
|
||||||
camera.name = "CheckpointCamera"
|
camera.name = "CheckpointCamera"
|
||||||
camera.current = true
|
camera.current = true
|
||||||
camera.fov = 62.0
|
camera.fov = float(manifest.get("camera_fov", 62.0))
|
||||||
camera.far = 50000.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 as Node3D).add_child(camera)
|
||||||
world.set("camera_path", NodePath("CheckpointCamera"))
|
world.set("camera_path", NodePath("CheckpointCamera"))
|
||||||
world.set("debug_streaming", true)
|
world.set("debug_streaming", true)
|
||||||
|
world.set("runtime_stats_enabled", true)
|
||||||
get_root().add_child(world)
|
get_root().add_child(world)
|
||||||
await process_frame
|
await process_frame
|
||||||
await process_frame
|
await process_frame
|
||||||
@@ -84,51 +72,245 @@ func _capture_async() -> void:
|
|||||||
if visual != null:
|
if visual != null:
|
||||||
visual.visible = false
|
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
|
var captured := 0
|
||||||
for checkpoint_variant in CHECKPOINTS:
|
var passes := ["cold_process", "warm_revisit"]
|
||||||
var checkpoint: Dictionary = checkpoint_variant
|
if dry_run:
|
||||||
var checkpoint_name := String(checkpoint.get("name", "checkpoint"))
|
passes = ["dry_run"]
|
||||||
if not only.is_empty() and not checkpoint_name.to_lower().contains(only):
|
|
||||||
continue
|
|
||||||
|
|
||||||
camera.global_position = checkpoint.get("camera", Vector3.ZERO)
|
for pass_name in passes:
|
||||||
camera.look_at(checkpoint.get("target", Vector3.ZERO), Vector3.UP)
|
for checkpoint_variant in checkpoints:
|
||||||
if player != null:
|
if not (checkpoint_variant is Dictionary):
|
||||||
player.global_position = checkpoint.get("player", checkpoint.get("target", Vector3.ZERO))
|
continue
|
||||||
if world.has_method("_refresh_streaming_targets_at"):
|
var checkpoint: Dictionary = checkpoint_variant
|
||||||
world.call("_refresh_streaming_targets_at", camera.global_position, true)
|
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
|
var probe := _create_probe(checkpoint, dry_run)
|
||||||
if dry_run:
|
if probe != null:
|
||||||
print("RENDER_CHECKPOINT dry_run name=%s camera=%s target=%s" % [
|
(world as Node3D).add_child(probe)
|
||||||
checkpoint_name,
|
probe.global_position = _vector3(checkpoint.get("target", [0.0, 0.0, 0.0]))
|
||||||
camera.global_position,
|
probe.scale = Vector3.ONE * float(checkpoint.get("probe_scale", 1.0))
|
||||||
checkpoint.get("target", Vector3.ZERO),
|
|
||||||
])
|
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
|
captured += 1
|
||||||
continue
|
if probe != null:
|
||||||
|
probe.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
await RenderingServer.frame_post_draw
|
var report_path := abs_output_dir.path_join("report.json")
|
||||||
|
var report_file := FileAccess.open(report_path, FileAccess.WRITE)
|
||||||
var image := get_root().get_texture().get_image()
|
if report_file == null:
|
||||||
var file_name := "%s.png" % checkpoint_name
|
push_error("Cannot write render baseline report: %s" % report_path)
|
||||||
var abs_path := abs_output_dir.path_join(file_name)
|
quit(1)
|
||||||
var err := image.save_png(abs_path)
|
return
|
||||||
if err != OK:
|
report_file.store_string(JSON.stringify(report, " "))
|
||||||
push_error("Failed to save checkpoint %s to %s (err=%d)" % [checkpoint_name, abs_path, err])
|
report_file.close()
|
||||||
quit(1)
|
print("RENDER_BASELINE report=%s results=%d" % [report_path, captured])
|
||||||
return
|
|
||||||
print("RENDER_CHECKPOINT saved %s" % abs_path)
|
|
||||||
captured += 1
|
|
||||||
|
|
||||||
world.queue_free()
|
world.queue_free()
|
||||||
if captured <= 0:
|
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)
|
quit(1)
|
||||||
return
|
return
|
||||||
quit(0)
|
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:
|
func _arg(args: PackedStringArray, name: String, default_value: String) -> String:
|
||||||
var idx := args.find(name)
|
var idx := args.find(name)
|
||||||
if idx >= 0 and idx + 1 < args.size():
|
if idx >= 0 and idx + 1 < args.size():
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -8,14 +8,14 @@
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
- [ ] Зафиксировать версию Godot, backend, hardware/profile и cache state.
|
- [x] Зафиксировать версию Godot, backend, hardware/profile и cache state.
|
||||||
- [ ] Выбрать контрольные позиции: terrain, ADT boundary, dense M2, large WMO, liquid, animated M2, sky transition.
|
- [x] Выбрать контрольные позиции: terrain, ADT boundary, dense M2, large WMO, liquid, animated M2, sky transition.
|
||||||
- [ ] Объединить существующие renderer smoke scripts в документированный запуск без изменения visual behavior.
|
- [x] Объединить существующие renderer smoke scripts в документированный запуск без изменения visual behavior.
|
||||||
- [ ] Снять deterministic screenshots и metadata для каждой позиции.
|
- [x] Снять deterministic screenshots и metadata для каждой позиции.
|
||||||
- [ ] Записать cold/warm показатели frame time p50/p95/p99, max hitch, memory, queues и load time.
|
- [x] Записать cold/warm показатели frame time p50/p95/p99, max hitch, memory, queues и load time.
|
||||||
- [ ] Зафиксировать cache format versions и правила invalidation.
|
- [x] Зафиксировать cache format versions и правила invalidation.
|
||||||
- [ ] Добавить regression manifest, не содержащий proprietary assets.
|
- [x] Добавить regression manifest, не содержащий proprietary assets.
|
||||||
- [ ] Обновить `RENDER.md` ссылкой на baseline и известные расхождения с клиентом 3.3.5a.
|
- [x] Обновить `RENDER.md` ссылкой на baseline и известные расхождения с клиентом 3.3.5a.
|
||||||
|
|
||||||
## Fidelity evidence
|
## Fidelity evidence
|
||||||
|
|
||||||
@@ -38,10 +38,10 @@ godot --headless --path . --script res://src/tools/capture_render_checkpoints.gd
|
|||||||
|
|
||||||
## Evidence
|
## Evidence
|
||||||
|
|
||||||
- Date:
|
- Date: 2026-07-10
|
||||||
- Revision/worktree:
|
- Revision/worktree: `93bfe11` + worktree baseline changes.
|
||||||
- Commands:
|
- 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:
|
- 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:
|
- 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:
|
- 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:
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user