test(M00): add empirical checkpoint FOV sweep
Work-Package: M00-QAR-FOV-SWEEP-001 Agent: sindo-main-codex Tests: comparator filter self-test, FOV capture dry-run, bounded real sweep, repository gates Fidelity: projection sweep remains inconclusive without reproducible reference yaw and pitch
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
# M00-QAR-FOV-SWEEP-001 — Empirical camera FOV sweep
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M00-QAR-FOV-SWEEP-001:sindo-main-codex:2026-07-14 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M00
|
||||||
|
- Program: QAR
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m00-fov-sweep`
|
||||||
|
- Lease expires UTC: 2026-07-14
|
||||||
|
- Integrator: milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Support a bounded empirical FOV sweep when build 12340 does not expose a readable camera FOV CVar.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Claiming exact WoW FOV from a single manually aimed screenshot.
|
||||||
|
- Changing runtime player camera defaults.
|
||||||
|
- Automating yaw/pitch registration or image warping.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: FOV/filter CLI additions in checkpoint tools
|
||||||
|
- Shared/hotspots: renderer baseline documentation
|
||||||
|
- Generated/ignored: local sweep PNGs and JSON reports
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API/events: additive `--camera-fov` and comparator `--only` CLI options
|
||||||
|
- Schema/format version: unchanged
|
||||||
|
- Migration/compatibility: existing commands retain defaults
|
||||||
|
- Consumers: M00 fidelity workflow
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: build 12340 reference JPG and paired-image comparator
|
||||||
|
- Blocks: empirical projection ranking
|
||||||
|
- External state: original screenshots remain outside Git
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: synthetic comparator test, capture dry-run with override, bounded real sweep
|
||||||
|
- Fixtures: `elwynn_adt_boundary` reference
|
||||||
|
- Fidelity evidence: ranked perceptual metrics with manual-direction limitation
|
||||||
|
- Performance budget: offline diagnostic
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline public API docs: CLI headers
|
||||||
|
- Module specification: verification/source map if needed
|
||||||
|
- Data-flow diagram: FOV sweep flow
|
||||||
|
- Sequence/state/dependency diagrams: not applicable
|
||||||
|
- Source map/status updates: baseline findings
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important names introduced: `camera_fov_override`, `only_filter`
|
||||||
|
- Simplest considered solution: reuse capture and comparator
|
||||||
|
- Rejected complexity/abstractions: computer vision registration framework
|
||||||
|
- Unavoidable complexity and justification: multiple captures are required because client FOV is inaccessible
|
||||||
|
- Measured optimization evidence: not applicable
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready
|
||||||
|
- Done: client FOV access audit, capture override, comparator filter/regression, dedicated-camera ownership fix and bounded sweep
|
||||||
|
- Next: integrator review; obtain reproducible yaw/pitch/zoom metadata before changing normative FOV
|
||||||
|
- Blocked by:
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: branch HEAD
|
||||||
|
- Results: corrected ranking 26=0.079588, 38=0.079633, 50=0.084353, 62=0.088360, 86=0.097993; plateau is inconclusive
|
||||||
|
- Remaining risks: build 12340 FOV is inaccessible through tested APIs; manual direction/framing dominates metrics
|
||||||
|
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
|
||||||
@@ -179,3 +179,18 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
Ни одна из пяти камер не находится внутри опубликованной scene-tree geometry. Terrain-overview segment пересекает четыре Stormwind WMO groups, large-WMO segment — три Goldshire Inn groups, waterfall segment — liquid surface; ADT boundary и dense-M2 segments не пересекают placement AABB. Поэтому прежнее визуальное впечатление «камера внутри WMO/placements» не подтверждается. Основной paired gap сейчас — неточно воспроизведённые manual look direction/target/FOV reference-кадров: например, автоматический Goldshire target направляет луч через фасад внутрь WMO, тогда как reference был вручную скадрирован на весь фасад. Probe охватывает только scene-tree MeshInstance/MultiMesh; RID-only instances не имеют доступного semantic path и явно исключены из coverage.
|
Ни одна из пяти камер не находится внутри опубликованной scene-tree geometry. Terrain-overview segment пересекает четыре Stormwind WMO groups, large-WMO segment — три Goldshire Inn groups, waterfall segment — liquid surface; ADT boundary и dense-M2 segments не пересекают placement AABB. Поэтому прежнее визуальное впечатление «камера внутри WMO/placements» не подтверждается. Основной paired gap сейчас — неточно воспроизведённые manual look direction/target/FOV reference-кадров: например, автоматический Goldshire target направляет луч через фасад внутрь WMO, тогда как reference был вручную скадрирован на весь фасад. Probe охватывает только scene-tree MeshInstance/MultiMesh; RID-only instances не имеют доступного semantic path и явно исключены из coverage.
|
||||||
|
|
||||||
|
## Empirical FOV sweep
|
||||||
|
|
||||||
|
В build 12340 `GetCVar("cameraFoV")` возвращает `nil`, а `/console cameraFoV` и `ConsoleExec("cameraFoV")` не выдают значения. Поэтому capture tool поддерживает additive `--camera-fov`, а comparator — `--only`, позволяющий ограничить sweep одним reference checkpoint.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
F[Candidate vertical FOV] --> C[Dedicated checkpoint camera]
|
||||||
|
C --> P[Filtered checkpoint PNGs]
|
||||||
|
R[One original-client reference] --> D[Perceptual comparator --only]
|
||||||
|
P --> D
|
||||||
|
D --> M[Ranked metrics]
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый sweep обнаружил capture defect: разные FOV иногда создавали одинаковые hashes, потому что scene camera могла перехватить viewport после входа в tree. Capture теперь вызывает `camera.make_current()` после добавления world и перед каждым checkpoint. После исправления ADT-boundary ranking стал: `26° → 0.079588`, `38° → 0.079633`, `50° → 0.084353`, `62° → 0.088360`, `86° → 0.097993` mean error. Plateau `26–38°` и несовпавший changed-pixel optimum показывают доминирование manual look direction/framing; эти данные не обосновывают изменение нормативного manifest FOV `62°`. Для настоящей калибровки reference capture должен сохранять воспроизводимые yaw/pitch/zoom или независимый projection fixture.
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
|||||||
- Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC.
|
- Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC.
|
||||||
- Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units.
|
- Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units.
|
||||||
- Camera-occluder probe не нашёл camera containment в пяти точках; paired mismatch локализован прежде всего в manual look direction/target/FOV calibration, с явным ограничением по RID-only geometry.
|
- Camera-occluder probe не нашёл camera containment в пяти точках; paired mismatch локализован прежде всего в manual look direction/target/FOV calibration, с явным ограничением по RID-only geometry.
|
||||||
|
- Empirical FOV sweep выявил, что checkpoint camera должна явно вызывать `make_current()`; после исправления projection ranking остаётся inconclusive из-за неизвестного manual yaw/pitch/framing reference.
|
||||||
- D3D12 descriptor and shutdown RID/resource issues remain.
|
- D3D12 descriptor and shutdown RID/resource issues remain.
|
||||||
- M2/WMO/material/particle/ribbon/portal parity incomplete.
|
- M2/WMO/material/particle/ribbon/portal parity incomplete.
|
||||||
- Public API is mostly exported configuration rather than stable contracts.
|
- Public API is mostly exported configuration rather than stable contracts.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func _capture_async() -> void:
|
|||||||
var measure_seconds := float(_arg(args, "--measure", str(manifest.get("default_measure_seconds", 3.0))))
|
var measure_seconds := float(_arg(args, "--measure", str(manifest.get("default_measure_seconds", 3.0))))
|
||||||
var revision := _arg(args, "--revision", "worktree")
|
var revision := _arg(args, "--revision", "worktree")
|
||||||
var cache_state := _arg(args, "--cache-state", "existing")
|
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 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])
|
var viewport: Array = manifest.get("viewport", [1280, 900])
|
||||||
@@ -55,7 +56,7 @@ func _capture_async() -> void:
|
|||||||
var camera := Camera3D.new()
|
var camera := Camera3D.new()
|
||||||
camera.name = "CheckpointCamera"
|
camera.name = "CheckpointCamera"
|
||||||
camera.current = true
|
camera.current = true
|
||||||
camera.fov = float(manifest.get("camera_fov", 62.0))
|
camera.fov = camera_fov_override
|
||||||
camera.far = 50000.0
|
camera.far = 50000.0
|
||||||
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
|
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
|
||||||
(world as Node3D).add_child(camera)
|
(world as Node3D).add_child(camera)
|
||||||
@@ -65,6 +66,7 @@ func _capture_async() -> void:
|
|||||||
get_root().add_child(world)
|
get_root().add_child(world)
|
||||||
await process_frame
|
await process_frame
|
||||||
await process_frame
|
await process_frame
|
||||||
|
camera.make_current()
|
||||||
|
|
||||||
var player := world.get_node_or_null("ThirdPersonPlayer") as Node3D
|
var player := world.get_node_or_null("ThirdPersonPlayer") as Node3D
|
||||||
if player != null:
|
if player != null:
|
||||||
@@ -82,6 +84,7 @@ func _capture_async() -> void:
|
|||||||
"created_utc": Time.get_datetime_string_from_system(true, true),
|
"created_utc": Time.get_datetime_string_from_system(true, true),
|
||||||
"dry_run": dry_run,
|
"dry_run": dry_run,
|
||||||
"cache_state": cache_state,
|
"cache_state": cache_state,
|
||||||
|
"camera_fov": camera_fov_override,
|
||||||
"environment": _environment_metadata(),
|
"environment": _environment_metadata(),
|
||||||
"comparison_budgets": manifest.get("comparison_budgets", {}),
|
"comparison_budgets": manifest.get("comparison_budgets", {}),
|
||||||
"cache_contract": manifest.get("cache_contract", {}),
|
"cache_contract": manifest.get("cache_contract", {}),
|
||||||
@@ -110,6 +113,7 @@ func _capture_async() -> void:
|
|||||||
|
|
||||||
camera.global_position = _vector3(checkpoint.get("camera", [0.0, 0.0, 0.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])))
|
_orient_camera_without_roll(camera, _vector3(checkpoint.get("target", [0.0, 0.0, 0.0])))
|
||||||
|
camera.make_current()
|
||||||
if player != null:
|
if player != null:
|
||||||
player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0])))
|
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)))
|
_set_sky_time(world, float(checkpoint.get("time_hours", 13.0)))
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func _parse_arguments(raw_arguments: PackedStringArray) -> Dictionary:
|
|||||||
parsed.self_test = true
|
parsed.self_test = true
|
||||||
index += 1
|
index += 1
|
||||||
continue
|
continue
|
||||||
if argument not in ["--reference", "--candidate", "--output", "--pixel-threshold", "--mean-threshold", "--changed-ratio-threshold"]:
|
if argument not in ["--reference", "--candidate", "--output", "--only", "--pixel-threshold", "--mean-threshold", "--changed-ratio-threshold"]:
|
||||||
return {"error": "unknown argument: %s" % argument}
|
return {"error": "unknown argument: %s" % argument}
|
||||||
if index + 1 >= raw_arguments.size():
|
if index + 1 >= raw_arguments.size():
|
||||||
return {"error": "missing value for %s" % argument}
|
return {"error": "missing value for %s" % argument}
|
||||||
@@ -76,10 +76,13 @@ func _compare_directories(reference_directory: String, candidate_directory: Stri
|
|||||||
reference_directory = ProjectSettings.globalize_path(reference_directory)
|
reference_directory = ProjectSettings.globalize_path(reference_directory)
|
||||||
candidate_directory = ProjectSettings.globalize_path(candidate_directory)
|
candidate_directory = ProjectSettings.globalize_path(candidate_directory)
|
||||||
var reference_files := _reference_image_file_names(reference_directory)
|
var reference_files := _reference_image_file_names(reference_directory)
|
||||||
|
var only_filter := String(options.get("only", "")).to_lower()
|
||||||
var results: Array[Dictionary] = []
|
var results: Array[Dictionary] = []
|
||||||
var failed_count := 0
|
var failed_count := 0
|
||||||
var missing_count := 0
|
var missing_count := 0
|
||||||
for file_name in reference_files:
|
for file_name in reference_files:
|
||||||
|
if not only_filter.is_empty() and not file_name.get_basename().to_lower().contains(only_filter):
|
||||||
|
continue
|
||||||
var reference_path := reference_directory.path_join(file_name)
|
var reference_path := reference_directory.path_join(file_name)
|
||||||
var checkpoint_name := file_name.get_basename()
|
var checkpoint_name := file_name.get_basename()
|
||||||
var candidate_file_names := _candidate_file_names(candidate_directory, checkpoint_name)
|
var candidate_file_names := _candidate_file_names(candidate_directory, checkpoint_name)
|
||||||
@@ -203,6 +206,7 @@ func _run_self_test() -> int:
|
|||||||
var changed_directory := root.path_join("changed")
|
var changed_directory := root.path_join("changed")
|
||||||
for directory_path in [reference_directory, identical_directory, changed_directory]:
|
for directory_path in [reference_directory, identical_directory, changed_directory]:
|
||||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(directory_path))
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(directory_path))
|
||||||
|
_clear_directory_files(directory_path)
|
||||||
var reference_image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
var reference_image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||||
reference_image.fill(Color(0.25, 0.5, 0.75, 1.0))
|
reference_image.fill(Color(0.25, 0.5, 0.75, 1.0))
|
||||||
var changed_image := reference_image.duplicate()
|
var changed_image := reference_image.duplicate()
|
||||||
@@ -225,5 +229,21 @@ func _run_self_test() -> int:
|
|||||||
if not identical_report.passed or changed_report.passed:
|
if not identical_report.passed or changed_report.passed:
|
||||||
push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: expected identical pass and changed failure")
|
push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: expected identical pass and changed failure")
|
||||||
return 1
|
return 1
|
||||||
|
if reference_image.save_jpg(reference_directory.path_join("ignored.jpg"), 1.0) != OK:
|
||||||
|
return 1
|
||||||
|
var filtered_options := options.duplicate()
|
||||||
|
filtered_options["only"] = "synthetic"
|
||||||
|
var filtered_report := _compare_directories(reference_directory, identical_directory, filtered_options)
|
||||||
|
if not filtered_report.passed or filtered_report.compared_count != 2:
|
||||||
|
push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: --only must exclude unrelated references")
|
||||||
|
return 1
|
||||||
print("RENDER_CHECKPOINT_DIFF SELF_TEST PASS")
|
print("RENDER_CHECKPOINT_DIFF SELF_TEST PASS")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_directory_files(directory_path: String) -> void:
|
||||||
|
var directory := DirAccess.open(directory_path)
|
||||||
|
if directory == null:
|
||||||
|
return
|
||||||
|
for file_name in directory.get_files():
|
||||||
|
directory.remove(file_name)
|
||||||
|
|||||||
Reference in New Issue
Block a user