diff --git a/coordination/claims/M00-QAR-FOV-SWEEP-001.md b/coordination/claims/M00-QAR-FOV-SWEEP-001.md new file mode 100644 index 0000000..c63bd35 --- /dev/null +++ b/coordination/claims/M00-QAR-FOV-SWEEP-001.md @@ -0,0 +1,78 @@ +# M00-QAR-FOV-SWEEP-001 — Empirical camera FOV sweep + + + +## 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` diff --git a/docs/RENDER_BASELINE.md b/docs/RENDER_BASELINE.md index 04ead79..383b938 100644 --- a/docs/RENDER_BASELINE.md +++ b/docs/RENDER_BASELINE.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. + +## 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. diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 4c3224d..f80e3fc 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -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. - 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. +- Empirical FOV sweep выявил, что checkpoint camera должна явно вызывать `make_current()`; после исправления projection ranking остаётся inconclusive из-за неизвестного manual yaw/pitch/framing reference. - D3D12 descriptor and shutdown RID/resource issues remain. - M2/WMO/material/particle/ribbon/portal parity incomplete. - Public API is mostly exported configuration rather than stable contracts. diff --git a/src/tools/capture_render_checkpoints.gd b/src/tools/capture_render_checkpoints.gd index bb84877..2539ac0 100644 --- a/src/tools/capture_render_checkpoints.gd +++ b/src/tools/capture_render_checkpoints.gd @@ -22,6 +22,7 @@ func _capture_async() -> void: var measure_seconds := float(_arg(args, "--measure", str(manifest.get("default_measure_seconds", 3.0)))) var revision := _arg(args, "--revision", "worktree") var cache_state := _arg(args, "--cache-state", "existing") + var camera_fov_override := float(_arg(args, "--camera-fov", str(manifest.get("camera_fov", 62.0)))) var headless := DisplayServer.get_name().to_lower() == "headless" var dry_run := args.has("--dry-run") or headless var viewport: Array = manifest.get("viewport", [1280, 900]) @@ -55,7 +56,7 @@ func _capture_async() -> void: var camera := Camera3D.new() camera.name = "CheckpointCamera" camera.current = true - camera.fov = float(manifest.get("camera_fov", 62.0)) + camera.fov = camera_fov_override camera.far = 50000.0 camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0])) (world as Node3D).add_child(camera) @@ -65,6 +66,7 @@ func _capture_async() -> void: get_root().add_child(world) await process_frame await process_frame + camera.make_current() var player := world.get_node_or_null("ThirdPersonPlayer") as Node3D if player != null: @@ -82,6 +84,7 @@ func _capture_async() -> void: "created_utc": Time.get_datetime_string_from_system(true, true), "dry_run": dry_run, "cache_state": cache_state, + "camera_fov": camera_fov_override, "environment": _environment_metadata(), "comparison_budgets": manifest.get("comparison_budgets", {}), "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])) _orient_camera_without_roll(camera, _vector3(checkpoint.get("target", [0.0, 0.0, 0.0]))) + camera.make_current() if player != null: player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0]))) _set_sky_time(world, float(checkpoint.get("time_hours", 13.0))) diff --git a/src/tools/compare_render_checkpoints.gd b/src/tools/compare_render_checkpoints.gd index 1adb8a6..fbb785d 100644 --- a/src/tools/compare_render_checkpoints.gd +++ b/src/tools/compare_render_checkpoints.gd @@ -56,7 +56,7 @@ func _parse_arguments(raw_arguments: PackedStringArray) -> Dictionary: parsed.self_test = true index += 1 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} if index + 1 >= raw_arguments.size(): 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) candidate_directory = ProjectSettings.globalize_path(candidate_directory) var reference_files := _reference_image_file_names(reference_directory) + var only_filter := String(options.get("only", "")).to_lower() var results: Array[Dictionary] = [] var failed_count := 0 var missing_count := 0 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 checkpoint_name := file_name.get_basename() 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") for directory_path in [reference_directory, identical_directory, changed_directory]: 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) reference_image.fill(Color(0.25, 0.5, 0.75, 1.0)) var changed_image := reference_image.duplicate() @@ -225,5 +229,21 @@ func _run_self_test() -> int: if not identical_report.passed or changed_report.passed: push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: expected identical pass and changed failure") 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") 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)