From b48195d08d945a2aaf51e191622b9d15406d6b6d Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 12 Jul 2026 01:46:02 +0400 Subject: [PATCH] test(M00): add camera pose sweep --- .../claims/M00-QAR-CAMERA-POSE-SWEEP-001.md | 78 ++++++++++++ docs/CAMERA_POSE_SWEEP.md | 39 ++++++ docs/modules/world-renderer.md | 5 +- src/tools/capture_render_checkpoints.gd | 17 ++- tools/sweep_render_checkpoint_camera_pose.ps1 | 118 ++++++++++++++++++ 5 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 coordination/claims/M00-QAR-CAMERA-POSE-SWEEP-001.md create mode 100644 docs/CAMERA_POSE_SWEEP.md create mode 100644 tools/sweep_render_checkpoint_camera_pose.ps1 diff --git a/coordination/claims/M00-QAR-CAMERA-POSE-SWEEP-001.md b/coordination/claims/M00-QAR-CAMERA-POSE-SWEEP-001.md new file mode 100644 index 0000000..63a28b3 --- /dev/null +++ b/coordination/claims/M00-QAR-CAMERA-POSE-SWEEP-001.md @@ -0,0 +1,78 @@ +# M00-QAR-CAMERA-POSE-SWEEP-001 — Empirical camera pose sweep + + + +## Ownership + +- Target: M00 +- Program: QAR +- Owner/Agent ID: sindo-main-codex +- Branch: `work/sindo-main-codex/m00-camera-pose-sweep` +- Lease expires UTC: 2026-07-14 +- Integrator: milestone integrator + +## Outcome + +Recover reproducible checkpoint framing by sweeping bounded yaw/pitch offsets and ranking paired-image error. + +## Non-goals + +- Changing runtime player camera defaults. +- Claiming an exact original-client camera pose from perceptual score alone. +- Adding computer-vision registration or image warping. + +## Paths + +- Exclusive: camera-pose CLI and sweep orchestration +- Shared/hotspots: renderer baseline documentation +- Generated/ignored: local candidate PNGs and JSON reports + +## Contracts and data + +- Public API/events: additive camera yaw/pitch offset and single-pass capture options; new PowerShell sweep command +- Schema/format version: capture report schema remains version 1 with additive camera-offset fields +- Migration/compatibility: existing capture commands retain zero offsets and two passes +- Consumers: M00 paired-fidelity workflow + +## Dependencies + +- Requires: build 12340 reference image, capture tool, paired-image comparator +- Blocks: reproducible manual-reference framing +- External state: original screenshots remain outside Git + +## Verification + +- Commands: capture dry-run offset regression, comparator self-test, bounded local sweep when a display/reference is available, repository gates +- Fixtures: one named M00 checkpoint +- Fidelity evidence: ranked yaw/pitch candidates with explicit human-approval requirement +- Performance budget: offline diagnostic; single-pass mode avoids redundant warm capture + +## Documentation deliverables + +- Inline public API docs: capture and sweep usage +- Module specification: camera-pose data flow, verification and source map +- Data-flow diagram: updated pose-sweep flow +- Sequence/state/dependency diagrams: unchanged +- Source map/status updates: baseline workflow and findings + +## Simplicity and naming + +- Important names introduced: `camera_yaw_offset_degrees`, `camera_pitch_offset_degrees`, `single_pass` +- Simplest considered solution: bounded grid over existing capture/comparator tools +- Rejected complexity/abstractions: feature matching, optimizer framework, image transforms +- Unavoidable complexity and justification: original-client camera angles are not exposed by tested APIs +- Measured optimization evidence: single-pass mode halves captures per candidate + +## Status + +- State: ready-for-review +- Done: additive capture offsets, single-pass calibration mode, bounded grid runner, ranked JSON contract, grid-plan regression and documentation +- Next: run a coarse-to-fine real sweep with the private build 12340 reference directory, then obtain human framing approval +- Blocked by: + +## Handoff + +- Commit: this work-package commit +- Results: dry-run reported yaw 12.50/pitch -7.50; comparator self-test passed; a 3x2 plan produced six unique candidates; full seven-checkpoint M00 dry-run passed with default zero offsets +- Remaining risks: no private reference directory was available in this worktree, so real perceptual ranking and human approval remain external evidence +- Documentation updated: `docs/CAMERA_POSE_SWEEP.md`; renderer module verification, risk and source map diff --git a/docs/CAMERA_POSE_SWEEP.md b/docs/CAMERA_POSE_SWEEP.md new file mode 100644 index 0000000..84e3a97 --- /dev/null +++ b/docs/CAMERA_POSE_SWEEP.md @@ -0,0 +1,39 @@ +# Renderer Camera Pose Sweep + +This offline M00 diagnostic recovers reproducible checkpoint framing when an original-client screenshot has no recorded camera yaw or pitch. It ranks a bounded grid; it does not change renderer or player-camera defaults and does not prove the exact build 12340 camera pose without human approval. + +## Camera contract + +`capture_render_checkpoints.gd` accepts additive `--camera-yaw-offset` and `--camera-pitch-offset` values in degrees. Yaw rotates the no-roll checkpoint basis around Godot world `Vector3.UP`; pitch then rotates around the camera-local right axis. Zero offsets preserve the manifest target exactly. `--single-pass` captures only `cold_process` and is intended for pose calibration, not performance baselines. + +## Workflow + +```powershell +.\tools\sweep_render_checkpoint_camera_pose.ps1 ` + -ReferenceDirectory 'D:\private-fixtures\wow-3.3.5a-checkpoints' ` + -Checkpoint elwynn_adt_boundary ` + -YawOffsets -15,-10,-5,0,5,10,15 ` + -PitchOffsets -10,-5,0,5,10 ` + -WaitSeconds 2 +``` + +Each candidate receives its own output directory and comparison report. `ranking.json` sorts candidates by mean perceptual error and then changed-pixel ratio. Use `-PlanOnly` to validate the Cartesian grid and output paths without rendering. Run a coarse grid first, then a finer grid around the best candidate. + +```mermaid +flowchart LR + Y[Yaw offsets in degrees] --> G[Bounded Cartesian grid] + P[Pitch offsets in degrees] --> G + G --> C[Single-pass checkpoint captures] + R[Original-client reference] --> D[Perceptual comparator] + C --> D + D --> J[ranking.json] + J --> H[Human framing approval] +``` + +## Interpretation and recovery + +The smallest error is a candidate, not automatic fidelity approval. Geometry, materials, lighting and FOV can move the perceptual optimum away from the true original-client pose. Inspect the best images manually and retain the original reference outside Git. A missing matching reference or candidate is a hard error; correct the checkpoint filter or filenames and rerun. Generated PNG and JSON outputs are disposable local evidence. + +## Verification + +The headless capture dry-run must report requested yaw and pitch offsets. Comparator `--self-test` covers paired metrics. Sweep `-PlanOnly` covers deterministic grid expansion and output naming without requiring a display. A real ranked sweep requires a display and a private build 12340 reference image. diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index a2aaeef..466145c 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -195,7 +195,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m ## Verification -- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff. +- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan. - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints. - Fidelity evidence: пять локальных build 12340 reference JPG откалибровали terrain/ADT/M2/WMO/liquid viewpoints; automated paired-image metrics exist, но synthetic animation/dusk и полный human approval ещё не закрыты. - Performance budgets: M00 report records cold/warm p95 and max hitch; no final acceptance threshold yet. @@ -229,6 +229,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - 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. +- Camera-pose sweep can now rank bounded yaw/pitch grids without changing manifest defaults; perceptual ranking remains diagnostic and requires human framing approval. - D3D12 descriptor issues remain; the capture-path anonymous `RefCounted` shutdown leak is regression-covered by a clean verbose dry-run, while other RID/resource diagnostics still require independent evidence. - M2/WMO/material/particle/ribbon/portal parity incomplete. - Public API is mostly exported configuration rather than stable contracts. @@ -248,6 +249,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `tools/run_render_baseline.ps1` | Unified M00 baseline runner | | `src/tools/compare_render_checkpoints.gd` | Offline JPG/PNG paired-image perceptual metrics and JSON pass/fail report | | `src/tools/capture_render_checkpoints.gd` | Deterministic no-roll checkpoint camera, performance and visual capture | +| `tools/sweep_render_checkpoint_camera_pose.ps1` | Offline yaw/pitch capture grid and paired-error ranking | | `src/tools/verify_render_coordinate_calibration.gd` | Build 12340 camera-coordinate golden point round-trip diagnostic | | `src/tools/probe_render_terrain_height.gd` | Offline active-mesh terrain height and camera-clearance report | | `src/tools/probe_render_camera_occluders.gd` | Scene-tree placement containment and camera-to-target AABB intersection report | @@ -256,5 +258,6 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - [`../../RENDER.md`](../../RENDER.md) - [`../RENDER_BASELINE.md`](../RENDER_BASELINE.md) +- [`../CAMERA_POSE_SWEEP.md`](../CAMERA_POSE_SWEEP.md) - [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md) - [`../GODOT_BEST_PRACTICES.md`](../GODOT_BEST_PRACTICES.md) diff --git a/src/tools/capture_render_checkpoints.gd b/src/tools/capture_render_checkpoints.gd index b7b9bae..b786077 100644 --- a/src/tools/capture_render_checkpoints.gd +++ b/src/tools/capture_render_checkpoints.gd @@ -24,6 +24,9 @@ func _capture_async() -> void: var revision := _arg(args, "--revision", "worktree") var cache_state := _arg(args, "--cache-state", "existing") var camera_fov_override := float(_arg(args, "--camera-fov", str(manifest.get("camera_fov", 62.0)))) + var camera_yaw_offset_degrees := float(_arg(args, "--camera-yaw-offset", "0.0")) + var camera_pitch_offset_degrees := float(_arg(args, "--camera-pitch-offset", "0.0")) + var single_pass := args.has("--single-pass") var headless := DisplayServer.get_name().to_lower() == "headless" var dry_run := args.has("--dry-run") or headless var viewport: Array = manifest.get("viewport", [1280, 900]) @@ -86,6 +89,8 @@ func _capture_async() -> void: "dry_run": dry_run, "cache_state": cache_state, "camera_fov": camera_fov_override, + "camera_yaw_offset_degrees": camera_yaw_offset_degrees, + "camera_pitch_offset_degrees": camera_pitch_offset_degrees, "environment": _environment_metadata(), "comparison_budgets": manifest.get("comparison_budgets", {}), "cache_contract": manifest.get("cache_contract", {}), @@ -94,6 +99,8 @@ func _capture_async() -> void: } var captured := 0 var passes := ["cold_process", "warm_revisit"] + if single_pass: + passes = ["cold_process"] if dry_run: passes = ["dry_run"] @@ -114,6 +121,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]))) + _apply_camera_pose_offsets(camera, camera_yaw_offset_degrees, camera_pitch_offset_degrees) camera.make_current() if player != null: player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0]))) @@ -122,11 +130,13 @@ func _capture_async() -> void: 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" % [ + print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [ checkpoint_name, str(checkpoint.get("coverage", [])), camera.global_position, _vector3(checkpoint.get("target", [0.0, 0.0, 0.0])), + camera_yaw_offset_degrees, + camera_pitch_offset_degrees, float(checkpoint.get("time_hours", 13.0)), ]) (report["results"] as Array).append(_result_record(checkpoint, pass_name, 0.0, {}, "")) @@ -215,6 +225,11 @@ func _orient_camera_without_roll(camera: Camera3D, target_position: Vector3) -> camera.global_basis = Basis(right, corrected_up, -forward) +func _apply_camera_pose_offsets(camera: Camera3D, yaw_offset_degrees: float, pitch_offset_degrees: float) -> void: + camera.global_rotate(Vector3.UP, deg_to_rad(yaw_offset_degrees)) + camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(pitch_offset_degrees)) + + func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: float, metrics: Dictionary, sha256: String) -> Dictionary: return { "name": checkpoint.get("name", "checkpoint"), diff --git a/tools/sweep_render_checkpoint_camera_pose.ps1 b/tools/sweep_render_checkpoint_camera_pose.ps1 new file mode 100644 index 0000000..0e38fe6 --- /dev/null +++ b/tools/sweep_render_checkpoint_camera_pose.ps1 @@ -0,0 +1,118 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ReferenceDirectory, + [Parameter(Mandatory = $true)] + [string]$Checkpoint, + [string]$GodotPath, + [string]$Output = "user://render_camera_pose_sweep", + [double[]]$YawOffsets = @(-10.0, 0.0, 10.0), + [double[]]$PitchOffsets = @(-10.0, 0.0, 10.0), + [double]$CameraFov = 62.0, + [double]$WaitSeconds = 2.0, + [double]$MeasureSeconds = 0.1, + [switch]$PlanOnly +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +if (-not $GodotPath) { + $godotCommand = Get-Command godot -ErrorAction SilentlyContinue + if ($godotCommand) { + $GodotPath = $godotCommand.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" +} +if (-not (Test-Path -LiteralPath $ReferenceDirectory)) { + throw "Reference directory not found: $ReferenceDirectory" +} + +$invariantCulture = [Globalization.CultureInfo]::InvariantCulture +$cameraFovText = $CameraFov.ToString($invariantCulture) +$waitSecondsText = $WaitSeconds.ToString($invariantCulture) +$measureSecondsText = $MeasureSeconds.ToString($invariantCulture) +$absoluteOutput = if ($Output.StartsWith("user://")) { + Join-Path $env:APPDATA "Godot\app_userdata\OpenWC\$($Output.Substring(7))" +} else { + [IO.Path]::GetFullPath((Join-Path $repoRoot $Output)) +} +$ranking = [Collections.Generic.List[object]]::new() + +Push-Location $repoRoot +try { + foreach ($yawOffset in $YawOffsets) { + foreach ($pitchOffset in $PitchOffsets) { + $yawText = $yawOffset.ToString("0.###", $invariantCulture) + $pitchText = $pitchOffset.ToString("0.###", $invariantCulture) + $candidateName = "yaw_$($yawText.Replace('-', 'n').Replace('.', '_'))__pitch_$($pitchText.Replace('-', 'n').Replace('.', '_'))" + $candidateOutput = "$Output/$candidateName" + $comparisonReport = Join-Path $absoluteOutput "$candidateName.json" + + if ($PlanOnly) { + $ranking.Add([pscustomobject]@{ + yaw_offset_degrees = $yawOffset + pitch_offset_degrees = $pitchOffset + camera_fov_degrees = $CameraFov + candidate_directory = $candidateOutput + }) + continue + } + + & $GodotPath --path . --script res://src/tools/capture_render_checkpoints.gd -- ` + --output $candidateOutput --only $Checkpoint --single-pass ` + --camera-fov $cameraFovText ` + --camera-yaw-offset $yawText --camera-pitch-offset $pitchText ` + --wait $waitSecondsText --measure $measureSecondsText + if ($LASTEXITCODE -ne 0) { + throw "Capture failed for yaw=$yawText pitch=$pitchText" + } + + & $GodotPath --headless --path . --script res://src/tools/compare_render_checkpoints.gd -- ` + --reference $ReferenceDirectory --candidate $candidateOutput ` + --only $Checkpoint --output $comparisonReport + if ($LASTEXITCODE -notin @(0, 1)) { + throw "Comparison failed for yaw=$yawText pitch=$pitchText" + } + + $comparison = Get-Content -Raw -LiteralPath $comparisonReport | ConvertFrom-Json + if ($comparison.compared_count -lt 1) { + throw "No paired image was compared for yaw=$yawText pitch=$pitchText" + } + $meanError = ($comparison.results | Measure-Object -Property mean_perceptual_error -Average).Average + $changedRatio = ($comparison.results | Measure-Object -Property changed_pixel_ratio -Average).Average + $ranking.Add([pscustomobject]@{ + yaw_offset_degrees = $yawOffset + pitch_offset_degrees = $pitchOffset + camera_fov_degrees = $CameraFov + mean_perceptual_error = $meanError + changed_pixel_ratio = $changedRatio + candidate_directory = $candidateOutput + comparison_report = $comparisonReport + }) + } + } + + $rankedCandidates = if ($PlanOnly) { + @($ranking) + } else { + @($ranking | Sort-Object mean_perceptual_error, changed_pixel_ratio) + } + $rankingPath = Join-Path $absoluteOutput "ranking.json" + $rankingDocument = [ordered]@{ + schema_version = 1 + checkpoint = $Checkpoint + reference_directory = [IO.Path]::GetFullPath($ReferenceDirectory) + candidates = $rankedCandidates + } + New-Item -ItemType Directory -Force -Path $absoluteOutput | Out-Null + $rankingDocument | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 -LiteralPath $rankingPath + $rankedCandidates | Format-Table yaw_offset_degrees, pitch_offset_degrees, mean_perceptual_error, changed_pixel_ratio + $completionKind = if ($PlanOnly) { "plan" } else { "ranking" } + Write-Output "Camera pose sweep $completionKind completed: $rankingPath" +} finally { + Pop-Location +}