Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad9ba7af4e | |||
| f54850718a | |||
| 5103eed014 | |||
| 9d34a47765 | |||
| aea7787b9b | |||
| 230620089f | |||
| 42fdf40282 | |||
| f8538ba2cf | |||
| d233a41ce8 | |||
| dfc10312f8 | |||
| 8e8ea32ba3 | |||
| 19df5b7968 | |||
| d467ffee7f | |||
| a4f60dcb06 |
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Воспроизводимый renderer baseline для текущей цели M00, regression manifest, метрики и правила сравнения описаны в [`docs/RENDER_BASELINE.md`](docs/RENDER_BASELINE.md). Baseline фиксирует текущее поведение и известные расхождения, но не заявляет parity с WoW 3.3.5a.
|
Воспроизводимый renderer baseline для текущей цели M00, regression manifest, метрики и правила сравнения описаны в [`docs/RENDER_BASELINE.md`](docs/RENDER_BASELINE.md). Baseline фиксирует текущее поведение и известные расхождения, но не заявляет parity с WoW 3.3.5a.
|
||||||
|
|
||||||
|
Парные checkpoint PNG можно автоматически проверить через `src/tools/compare_render_checkpoints.gd`; reference-снимки оригинального клиента остаются вне Git, а автоматический tolerance не заменяет human fidelity approval.
|
||||||
|
|
||||||
|
Paired run 2026-07-11 подтвердил крупный coordinate/placement gap: некоторые server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC. До исправления этого расхождения perceptual metrics измеряют также несовпадение композиции, а не только материалы и свет.
|
||||||
|
|
||||||
Цель renderer-работы в этом проекте: добиться ощущения производительности оригинального клиента WoW 3.3.5a в Godot, без видимых фризов при переходе ADT -> ADT и без постоянного отката видимых участков к низкому качеству.
|
Цель renderer-работы в этом проекте: добиться ощущения производительности оригинального клиента WoW 3.3.5a в Godot, без видимых фризов при переходе ADT -> ADT и без постоянного отката видимых участков к низкому качеству.
|
||||||
|
|
||||||
Этот документ фиксирует текущее состояние рендера, сделанные оптимизации и практические правила дальнейшей работы.
|
Этот документ фиксирует текущее состояние рендера, сделанные оптимизации и практические правила дальнейшей работы.
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# M00-QAR-CAMERA-OCCLUDERS-001 — Camera occluder diagnostic
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M00-QAR-CAMERA-OCCLUDERS-001:sindo-main-codex:2026-07-13 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M00
|
||||||
|
- Program: QAR
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m00-camera-occluders`
|
||||||
|
- Lease expires UTC: 2026-07-13
|
||||||
|
- Integrator: milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Report scene-tree M2/WMO geometry that contains calibrated cameras or intersects each camera-to-target segment.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Changing placements, cameras, FOV, culling, or renderer behavior.
|
||||||
|
- Claiming full coverage of RenderingServer RID-only instances.
|
||||||
|
- Implementing a production visibility or collision service.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: `src/tools/probe_render_camera_occluders.gd`
|
||||||
|
- Shared/hotspots: renderer baseline documentation
|
||||||
|
- Generated/ignored: local JSON probe reports
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API/events: headless diagnostic CLI only
|
||||||
|
- Schema/format version: report schema 1
|
||||||
|
- Migration/compatibility: none
|
||||||
|
- Consumers: M00 fidelity workflow
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: calibrated five-point manifest and streaming scene
|
||||||
|
- Blocks: placement versus camera-composition classification
|
||||||
|
- External state: local extracted/cache data
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: camera occluder probe, coordination and documentation gates
|
||||||
|
- Fixtures: five calibrated camera/target segments
|
||||||
|
- Fidelity evidence: scene-tree bounds at paired build 12340 viewpoints
|
||||||
|
- Performance budget: offline diagnostic
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline public API docs: probe header and output fields
|
||||||
|
- Module specification: verification/source map
|
||||||
|
- Data-flow diagram: occluder probe flow
|
||||||
|
- Sequence/state/dependency diagrams: synchronous diagnostic; not applicable
|
||||||
|
- Source map/status updates: baseline findings
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important names introduced: `camera_containing_geometry`, `segment_intersecting_geometry`
|
||||||
|
- Simplest considered solution: transformed published AABBs
|
||||||
|
- Rejected complexity/abstractions: GPU visibility readback or new BVH
|
||||||
|
- Unavoidable complexity and justification: RID-only geometry cannot be named by this probe
|
||||||
|
- Measured optimization evidence: not applicable
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready
|
||||||
|
- Done: five-point scene-tree AABB probe, containment/intersection classification and documentation
|
||||||
|
- Next: integrator review; calibrate reproducible reference camera direction/FOV separately
|
||||||
|
- Blocked by:
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: branch HEAD
|
||||||
|
- Results: zero containing geometry at all five cameras; expected WMO/liquid target intersections; ADT/dense segments unobstructed
|
||||||
|
- Remaining risks: RID-only instances are excluded; manual reference direction and FOV were not recorded exactly
|
||||||
|
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# M00-QAR-CAPTURE-SHUTDOWN-001 — Capture shutdown drain
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M00-QAR-CAPTURE-SHUTDOWN-001:sindo-main-codex:2026-07-14 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M00
|
||||||
|
- Program: QAR
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m00-capture-shutdown`
|
||||||
|
- Lease expires UTC: 2026-07-14
|
||||||
|
- Integrator: milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Eliminate the reproducible capture-tool ObjectDB leak by completing owned threaded resource requests and draining deferred scene deletion before process exit.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Refactoring renderer resource ownership.
|
||||||
|
- Hiding genuine RID or worker-task leaks.
|
||||||
|
- Changing capture images, metrics, or streaming behavior.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: capture tool shutdown sequence
|
||||||
|
- Shared/hotspots: renderer baseline documentation
|
||||||
|
- Generated/ignored: verbose Godot logs
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API/events: none
|
||||||
|
- Schema/format version: unchanged
|
||||||
|
- Migration/compatibility: none
|
||||||
|
- Consumers: M00 baseline runner
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: reproducible verbose leak diagnostic
|
||||||
|
- Blocks: clean M00 capture shutdown evidence
|
||||||
|
- External state: none
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: identical verbose dry-run before/after, M00 dry-run, repository gates
|
||||||
|
- Fixtures: ADT-boundary filtered capture
|
||||||
|
- Fidelity evidence: no visual behavior change
|
||||||
|
- Performance budget: blocking resource completion is restricted to shutdown; two SceneTree drain frames only
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline public API docs: shutdown rationale comment
|
||||||
|
- Module specification: recovery/known-risk update
|
||||||
|
- Data-flow diagram: unchanged
|
||||||
|
- Sequence/state/dependency diagrams: shutdown sequence documented in baseline
|
||||||
|
- Source map/status updates: baseline evidence
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important names introduced: none
|
||||||
|
- Simplest considered solution: await two SceneTree frames after `queue_free` and finish registered in-flight ResourceLoader requests during loader shutdown
|
||||||
|
- Rejected complexity/abstractions: resource registry or manual child traversal
|
||||||
|
- Unavoidable complexity and justification: deferred deletion requires frame drain
|
||||||
|
- Measured optimization evidence: not applicable
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready-for-review
|
||||||
|
- Done: one leaked RefCounted reproduced; isolation attributed it to `StreamingWorldLoader`; active threaded tile request was confirmed as the retained object; all owned threaded resource registries now finish in-progress requests before clearing; two identical verbose ADT-boundary runs and the full M00 dry-run completed without ObjectDB leak diagnostics
|
||||||
|
- Next: integrator review and merge
|
||||||
|
- Blocked by:
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: this work-package commit
|
||||||
|
- Results: verbose filtered capture returned one report and zero `Leaked instance`/`ObjectDB instances leaked` lines on two consecutive runs; `run_render_baseline.ps1 -DryRun` passed all gates and seven checkpoints
|
||||||
|
- Remaining risks:
|
||||||
|
- Documentation updated: `docs/modules/world-renderer.md` shutdown sequence, ownership contract, recovery table and known-risk status
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# M00-QAR-COORD-CALIBRATION-001 — Renderer coordinate calibration
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M00-QAR-COORD-CALIBRATION-001:sindo-main-codex:2026-07-13 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M00
|
||||||
|
- Program: QAR
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m00-coordinate-calibration`
|
||||||
|
- Lease expires UTC: 2026-07-13
|
||||||
|
- Integrator: milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Record the five observed build 12340 camera positions as non-proprietary golden coordinates and determine whether the existing WoW/Godot position formula round-trips them.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Introducing the M01 production `CoordinateMapper` contract.
|
||||||
|
- Changing terrain, placement, WMO, liquid, or streaming behavior.
|
||||||
|
- Claiming camera composition parity from coordinate round-trip alone.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: `src/tools/verify_render_coordinate_calibration.gd`
|
||||||
|
- Shared/hotspots: `src/tools/render_baseline_manifest.json`, renderer baseline documentation
|
||||||
|
- Generated/ignored: original-client screenshots and local reports
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API/events: none; headless diagnostic only
|
||||||
|
- Schema/format version: additive checkpoint calibration metadata, manifest schema remains 1
|
||||||
|
- Migration/compatibility: existing capture consumers ignore additive fields
|
||||||
|
- Consumers: M00 fidelity workflow and future M01 golden fixtures
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: five accepted original-client build 12340 viewpoints
|
||||||
|
- Blocks: diagnosis of paired camera/placement mismatch
|
||||||
|
- External state: screenshots remain outside Git
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: coordinate calibration probe, baseline manifest, coordination and documentation gates
|
||||||
|
- Fixtures: five `reference_wow_camera` values in renderer manifest
|
||||||
|
- Fidelity evidence: positions were observed in build 12340 during the paired session
|
||||||
|
- Performance budget: negligible headless arithmetic
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline public API docs: diagnostic script header
|
||||||
|
- Module specification: renderer verification/source map update
|
||||||
|
- Data-flow diagram: baseline calibration flow update
|
||||||
|
- Sequence/state/dependency diagrams: not applicable; stateless synchronous probe
|
||||||
|
- Source map/status updates: renderer module and baseline document
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important names introduced: `reference_wow_camera`, `maximum_round_trip_error`
|
||||||
|
- Simplest considered solution: direct formula probe over manifest fixtures
|
||||||
|
- Rejected complexity/abstractions: production domain mapper before M01
|
||||||
|
- Unavoidable complexity and justification: none
|
||||||
|
- Measured optimization evidence: not applicable
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready
|
||||||
|
- Done: five golden camera points, headless round-trip probe, runner integration and documentation
|
||||||
|
- Next: integrator review; diagnose terrain height/placement/composition separately
|
||||||
|
- Blocked by:
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: branch HEAD
|
||||||
|
- Results: five points passed with maximum mapping/round-trip error 0.000015
|
||||||
|
- Remaining risks: correct position arithmetic does not prove terrain height, placement or camera direction parity
|
||||||
|
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# M00-QAR-TERRAIN-HEIGHT-001 — Terrain height diagnostic
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M00-QAR-TERRAIN-HEIGHT-001:sindo-main-codex:2026-07-13 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M00
|
||||||
|
- Program: QAR
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m00-terrain-height`
|
||||||
|
- Lease expires UTC: 2026-07-13
|
||||||
|
- Integrator: milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Measure rendered terrain height and camera clearance at the five build 12340 golden checkpoints without changing renderer behavior.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Adding a runtime terrain-query API or collision system.
|
||||||
|
- Changing terrain geometry, coordinate mapping, placements, or cameras.
|
||||||
|
- Implementing the M01 CoordinateMapper.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: `src/tools/probe_render_terrain_height.gd`
|
||||||
|
- Shared/hotspots: renderer baseline documentation and runner
|
||||||
|
- Generated/ignored: local JSON probe reports
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API/events: headless diagnostic CLI only
|
||||||
|
- Schema/format version: report schema 1
|
||||||
|
- Migration/compatibility: none
|
||||||
|
- Consumers: M00 fidelity diagnosis
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: calibrated renderer manifest and active terrain meshes
|
||||||
|
- Blocks: classification of under-terrain camera gaps
|
||||||
|
- External state: local extracted/cache data
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: terrain probe, M00 dry-run, coordination and documentation gates
|
||||||
|
- Fixtures: five calibrated manifest checkpoints
|
||||||
|
- Fidelity evidence: camera clearance against rendered OpenWC terrain
|
||||||
|
- Performance budget: offline diagnostic only
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline public API docs: script CLI header
|
||||||
|
- Module specification: verification/source map
|
||||||
|
- Data-flow diagram: terrain probe flow
|
||||||
|
- Sequence/state/dependency diagrams: synchronous diagnostic; not applicable
|
||||||
|
- Source map/status updates: baseline and renderer module
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important names introduced: `terrain_height`, `camera_clearance`
|
||||||
|
- Simplest considered solution: CPU ray against already loaded mesh
|
||||||
|
- Rejected complexity/abstractions: new parser, physics collision, runtime query service
|
||||||
|
- Unavoidable complexity and justification: tile-local ray transform is required by mesh ownership
|
||||||
|
- Measured optimization evidence: not applicable
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready
|
||||||
|
- Done: active-mesh terrain probe, four clearance measurements, isolated waterfall missing-mesh confirmation and documentation
|
||||||
|
- Next: integrator review; placement/composition diagnosis for four points and tile 30_49 ownership diagnosis remain separate packages
|
||||||
|
- Blocked by:
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: branch HEAD
|
||||||
|
- Results: four cameras are 12.034..90.178 units above terrain; waterfall tile has no accessible terrain mesh after 10-second settle
|
||||||
|
- Remaining risks: mesh ray does not measure WMO/M2 occlusion; waterfall missing mesh requires streaming ownership investigation
|
||||||
|
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# M00-QAR-TILE-OWNERSHIP-001 — Waterfall terrain tile ownership
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M00-QAR-TILE-OWNERSHIP-001:sindo-main-codex:2026-07-13 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M00
|
||||||
|
- Program: QAR
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m00-waterfall-tile-ownership`
|
||||||
|
- Lease expires UTC: 2026-07-13
|
||||||
|
- Integrator: milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Identify the runtime transition that prevents waterfall tile `30_49` from exposing a terrain mesh to the height probe.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Changing streaming budgets or terrain behavior.
|
||||||
|
- Rebuilding caches or modifying extracted assets.
|
||||||
|
- Adding a production terrain query service.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: terrain probe runtime ownership diagnostics
|
||||||
|
- Shared/hotspots: renderer baseline documentation
|
||||||
|
- Generated/ignored: local probe reports and caches
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API/events: additive diagnostic JSON fields
|
||||||
|
- Schema/format version: terrain report remains schema 1
|
||||||
|
- Migration/compatibility: additive fields only
|
||||||
|
- Consumers: M00 fidelity diagnosis
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: merged terrain-height probe and local cache inventory
|
||||||
|
- Blocks: waterfall terrain ownership classification
|
||||||
|
- External state: local extracted/cache data
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: isolated waterfall probe, coordination and documentation gates
|
||||||
|
- Fixtures: checkpoint tile `30_49`
|
||||||
|
- Fidelity evidence: runtime state correlated with build 12340 waterfall viewpoint
|
||||||
|
- Performance budget: offline diagnostic
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline public API docs: diagnostic output fields
|
||||||
|
- Module specification: verification/source map if behavior changes
|
||||||
|
- Data-flow diagram: update ownership transition if needed
|
||||||
|
- Sequence/state/dependency diagrams: document observed tile transition
|
||||||
|
- Source map/status updates: baseline findings
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important names introduced: `available`, `queued_index`, `loading`, `state_present`, `mesh_source`
|
||||||
|
- Simplest considered solution: inspect existing loader registries read-only
|
||||||
|
- Rejected complexity/abstractions: new tracing framework
|
||||||
|
- Unavoidable complexity and justification: none
|
||||||
|
- Measured optimization evidence: not applicable
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready
|
||||||
|
- Done: raw/cache inventory, isolated runtime state, mesh AABB/local probe and nearby sampling diagnosis
|
||||||
|
- Next: integrator review; placement/composition remains the actual paired-camera gap
|
||||||
|
- Blocked by:
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: branch HEAD
|
||||||
|
- Results: tile 30_49 ownership and meshes are healthy; exact ray misses a triangle seam/edge, while a 2-unit offset samples terrain at 113.872
|
||||||
|
- Remaining risks: nearby estimate is diagnostic and must not become a gameplay terrain-query contract
|
||||||
|
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# M00-QAR-VISUAL-DIFF-001 — Renderer checkpoint visual diff
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M00-QAR-VISUAL-DIFF-001:sindo-main-codex:2026-07-13 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M00
|
||||||
|
- Program: QAR
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m00-checkpoint-diff`
|
||||||
|
- Lease expires UTC: 2026-07-13
|
||||||
|
- Integrator: milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Provide a deterministic, headless perceptual comparison for paired renderer checkpoint PNGs, with machine-readable results and synthetic regression coverage.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Supplying or committing original-client screenshots.
|
||||||
|
- Claiming visual parity or replacing human approval.
|
||||||
|
- Image registration, camera correction, or temporal animation matching.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: `src/tools/compare_render_checkpoints.gd`
|
||||||
|
- Shared/hotspots: `src/tools/render_baseline_manifest.json`, `tools/run_render_baseline.ps1`, `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`, `RENDER.md`
|
||||||
|
- Generated/ignored: comparison reports, diff PNGs, original-client captures
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API/events: headless CLI arguments and JSON report documented in renderer baseline docs
|
||||||
|
- Schema/format version: comparison report schema 1; baseline manifest schema remains 1
|
||||||
|
- Migration/compatibility: additive manifest budget fields
|
||||||
|
- Consumers: developers and CI
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: Godot `Image` API and M00 checkpoint naming contract
|
||||||
|
- Blocks: automated portion of original-client paired comparison
|
||||||
|
- External state: approved build 12340 screenshots remain unavailable
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: headless self-test; manifest verification; documentation and coordination gates
|
||||||
|
- Fixtures: tool-generated identical and changed 2x2 PNGs
|
||||||
|
- Fidelity evidence: algorithm is deterministic; real-client evidence remains a manual/external input
|
||||||
|
- Performance budget: offline checkpoint operation, linear in pixel count
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline public API docs: CLI usage in script header
|
||||||
|
- Module specification: update renderer verification and source map
|
||||||
|
- Data-flow diagram: update baseline documentation
|
||||||
|
- Sequence/state/dependency diagrams: not stateful or asynchronous; not applicable
|
||||||
|
- Source map/status updates: renderer module and RENDER notes
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important names introduced: `mean_perceptual_error`, `changed_pixel_ratio`
|
||||||
|
- Simplest considered solution: direct Godot Image iteration with no dependency
|
||||||
|
- Rejected complexity/abstractions: external image library, SSIM window pipeline, image registration
|
||||||
|
- Unavoidable complexity and justification: sRGB linearization avoids comparing encoded channel values directly
|
||||||
|
- Measured optimization evidence: not required for offline seven-checkpoint comparison
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready
|
||||||
|
- Done: comparator, runner integration, manifest tolerance contract, synthetic regression, five local build 12340 references, three viewpoint calibrations, corrected visual capture and first paired report
|
||||||
|
- Next: integrator review; coordinate/placement gap should be resolved before tolerance calibration
|
||||||
|
- Blocked by: real paired screenshots only for final human fidelity approval
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: branch HEAD
|
||||||
|
- Results: ten pairs compared with no missing candidates; mean error 0.0707..0.1746 and changed ratio 0.5504..0.8187; human review identified under-terrain/inside-geometry camera mismatches
|
||||||
|
- Remaining risks: coordinate/placement mismatch blocks meaningful tolerance calibration; synthetic animation, dusk and full human approval remain incomplete
|
||||||
|
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`, `RENDER.md`
|
||||||
+121
-1
@@ -24,6 +24,46 @@
|
|||||||
- `<checkpoint>__cold_process.png` — первый визит в текущем процессе;
|
- `<checkpoint>__cold_process.png` — первый визит в текущем процессе;
|
||||||
- `<checkpoint>__warm_revisit.png` — повторный визит в том же процессе после обхода контрольных точек.
|
- `<checkpoint>__warm_revisit.png` — повторный визит в том же процессе после обхода контрольных точек.
|
||||||
|
|
||||||
|
## Парное визуальное сравнение
|
||||||
|
|
||||||
|
Одобренные снимки оригинального клиента хранятся вне Git. Если каталог с ними доступен локально, единый запуск может сразу сравнить PNG с одинаковыми именами:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\tools\run_render_baseline.ps1 `
|
||||||
|
-ReferenceCheckpointDirectory 'D:\private-fixtures\wow-3.3.5a-checkpoints'
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат записывается в `user://render_baseline/visual_comparison.json`. Другой путь задаётся через `-VisualComparisonReport`. `-DryRun` несовместим с визуальным сравнением, потому что не создаёт PNG. Runner сохраняет обычный performance capture в нормативном `1280×900`, затем отдельно снимает visual candidates в `2560×1440`; разрешение можно переопределить через `-VisualComparisonWidth` и `-VisualComparisonHeight`, не меняя performance baseline.
|
||||||
|
|
||||||
|
Отдельный запуск сравнения:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
godot --headless --path . `
|
||||||
|
--script res://src/tools/compare_render_checkpoints.gd -- `
|
||||||
|
--reference 'D:\private-fixtures\wow-3.3.5a-checkpoints' `
|
||||||
|
--candidate "$env:APPDATA\Godot\app_userdata\OpenWC\render_baseline" `
|
||||||
|
--output user://render_baseline/visual_comparison.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference-каталог принимает JPG/JPEG/PNG оригинального клиента с именем checkpoint, например `goldshire_dense_m2.jpg`. Диагностические кадры с префиксом `diagnostic_` игнорируются. Один reference автоматически сопоставляется с `<checkpoint>__cold_process.png` и `<checkpoint>__warm_revisit.png` OpenWC.
|
||||||
|
|
||||||
|
Сравнение переводит sRGB в linear color space, вычисляет взвешенную ошибку яркости/цвета для каждого пикселя, среднюю ошибку кадра и долю пикселей выше локального порога. Нормативные defaults находятся в `comparison_budgets` manifest: mean error `0.015`, changed-pixel ratio `0.01`, pixel error threshold `0.04`. Любое превышение, несовпадение размеров, отсутствие пары или пустой reference-каталог возвращает ненулевой exit code.
|
||||||
|
|
||||||
|
Автоматический pass не является доказательством parity: animation phase, weather, camera alignment и intentional gaps требуют human approval. Для проверки алгоритма без proprietary данных используется:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
godot --headless --path . --script res://src/tools/compare_render_checkpoints.gd -- --self-test
|
||||||
|
```
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
R[Private approved reference PNGs] --> C[Checkpoint comparator]
|
||||||
|
N[New baseline PNGs] --> C
|
||||||
|
M[Manifest tolerance defaults] --> C
|
||||||
|
C --> J[JSON metrics and pass/fail]
|
||||||
|
J --> H[Human fidelity approval]
|
||||||
|
```
|
||||||
|
|
||||||
`cold_process` не означает очищенный Windows filesystem cache. Поле `cache_state` и полный inventory обязаны интерпретироваться вместе с результатом. Удаление или принудительная пересборка cache не входит в baseline-команду. Для отдельного запуска после контролируемой очистки безопасного локального cache следует передать осмысленную метку, например `-CacheState rebuilt-clean`; proprietary source assets команда не изменяет.
|
`cold_process` не означает очищенный Windows filesystem cache. Поле `cache_state` и полный inventory обязаны интерпретироваться вместе с результатом. Удаление или принудительная пересборка cache не входит в baseline-команду. Для отдельного запуска после контролируемой очистки безопасного локального cache следует передать осмысленную метку, например `-CacheState rebuilt-clean`; proprietary source assets команда не изменяет.
|
||||||
|
|
||||||
## Контрольные точки и детерминизм
|
## Контрольные точки и детерминизм
|
||||||
@@ -38,7 +78,7 @@
|
|||||||
- синтетический native-animation probe `GryphonRoost01.m2`;
|
- синтетический native-animation probe `GryphonRoost01.m2`;
|
||||||
- тот же Elwynn overview в 19:00 для sky transition.
|
- тот же 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.
|
Зафиксированы 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
|
## Метрики и budget
|
||||||
|
|
||||||
@@ -74,3 +114,83 @@ Baseline пока не имеет approved парных кадров ориги
|
|||||||
- возможная смена D3D12 на Vulkan при ошибке descriptor heap — фактический backend всегда берётся из `report.json`.
|
- возможная смена 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 не закрывает.
|
Парное сравнение с клиентом 3.3.5a должно использовать те же map position, local time и weather. Каждый gap регистрируется как terrain/material, placement, animation, lighting, liquid или culling; baseline сам по себе gap не закрывает.
|
||||||
|
|
||||||
|
Локальная сессия build 12340 от 2026-07-11 откалибровала три непригодные исходные позиции: dense-M2 camera была полностью закрыта кронами, large-WMO camera находилась у дымохода кузни, liquid camera попадала под terrain оригинального клиента. Manifest теперь содержит проверенные replacement camera positions. Пять approved локальных reference JPG хранятся вне Git; synthetic animated probe и dusk checkpoint всё ещё требуют отдельной процедуры.
|
||||||
|
|
||||||
|
Первый paired run после калибровки создал десять сравнений (пять reference × cold/warm), без missing pairs. Все пары ожидаемо превысили строгий tolerance: mean perceptual error `0.0707..0.1746`, changed-pixel ratio `0.5504..0.8187`. Human inspection показал, что это пока не чистая material/lighting ошибка: при тех же WoW-derived координатах OpenWC terrain-overview camera находится под terrain/placements, WMO camera — внутри таверны, liquid camera — внутри скал; ADT и dense-M2 композиции также существенно смещены. До исправления coordinate/placement mismatch эти значения являются gap evidence, а не основанием расширять tolerance.
|
||||||
|
|
||||||
|
Capture tool строит camera basis явно из target и world-up. Это исключает неоднозначный roll `look_at` при автоматической съёмке. `ViewportTexture.get_image()` сохраняется без дополнительного vertical flip для Godot 4.6.1.
|
||||||
|
|
||||||
|
## Coordinate calibration
|
||||||
|
|
||||||
|
Пять принятых build 12340 viewpoints записаны в manifest как `reference_wow_camera`. Они содержат только числовые world coordinates и не включают proprietary данные. Headless probe:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
godot --headless --path . --script res://src/tools/verify_render_coordinate_calibration.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
O[Observed build 12340 WoW XYZ] --> W[WoW to Godot formula]
|
||||||
|
W --> G[Manifest Godot camera XYZ]
|
||||||
|
G --> R[Godot to WoW round-trip]
|
||||||
|
R --> E[Maximum numeric error]
|
||||||
|
```
|
||||||
|
|
||||||
|
На пяти точках maximum mapping/round-trip error равен `0.000015`. Это исключает текущую формулу `gx = center - wy`, `gy = wz`, `gz = center - wx` как источник крупного paired mismatch. Результат не доказывает renderer parity: следующая диагностика должна отдельно проверить terrain height, placement transforms и фактическое camera direction/FOV. Production `CoordinateMapper` остаётся задачей M01; M00 probe не создаёт второй публичный coordinate contract.
|
||||||
|
|
||||||
|
## Terrain height diagnostic
|
||||||
|
|
||||||
|
Rendered terrain проверяется без нового runtime API: offline probe использует уже загруженный tile mesh, строит `TriangleMesh` и выполняет вертикальный ray в tile-local space.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
godot --headless --path . --script res://src/tools/probe_render_terrain_height.gd -- --wait 2
|
||||||
|
```
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
C[Calibrated camera XZ] --> S[Streaming tile state]
|
||||||
|
S --> M[Active terrain mesh]
|
||||||
|
M --> T[Tile-local TriangleMesh ray]
|
||||||
|
T --> H[Terrain height and camera clearance]
|
||||||
|
```
|
||||||
|
|
||||||
|
Измеренный clearance: terrain overview `89.044`, ADT boundary `44.788`, dense M2 `90.178`, large WMO `12.034`, waterfall примерно `76.128` Godot units. Следовательно, все пять камер находятся над rendered terrain; visual obstruction принадлежит placements/WMO/composition, а не terrain height.
|
||||||
|
|
||||||
|
Waterfall XZ сначала давал `no_intersection`, хотя tile `30_49` был available, полностью загружен, имел `control_splat_cache` quality mesh и LOD0 mesh, а probe находился внутри mesh AABB. Ray со смещением `2.0` units пересёк тот же mesh на высоте `113.872`; точная XZ попала на triangle seam/edge numerical miss. Probe теперь сообщает `sampled_nearby`, distance и source tile вместо ложного streaming ownership gap. `--require-all` остаётся строгим режимом для действительно неснятых точек.
|
||||||
|
|
||||||
|
## Camera occluder diagnostic
|
||||||
|
|
||||||
|
Scene-tree placement composition проверяется transformed AABB без изменения renderer:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
godot --headless --path . --script res://src/tools/probe_render_camera_occluders.gd -- --wait 3
|
||||||
|
```
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
C[Calibrated camera] --> A[Published Mesh/MultiMesh AABBs]
|
||||||
|
T[Manifest target] --> S[Camera-to-target segment]
|
||||||
|
A --> P[Camera containment test]
|
||||||
|
A --> I[Segment intersection test]
|
||||||
|
S --> I
|
||||||
|
P --> J[JSON occluder report]
|
||||||
|
I --> J
|
||||||
|
```
|
||||||
|
|
||||||
|
Ни одна из пяти камер не находится внутри опубликованной 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.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| Status | Partial |
|
| Status | Partial |
|
||||||
| Target/work package | M00 active; декомпозиция M01–M03 |
|
| Target/work package | M00 active; декомпозиция M01–M03 |
|
||||||
| Owners | Renderer workstream / milestone integrator |
|
| Owners | Renderer workstream / milestone integrator |
|
||||||
| Last verified | `93bfe11` + M00 worktree baseline, 2026-07-10 |
|
| Last verified | M00 visual-diff worktree, 2026-07-11 |
|
||||||
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
|
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -141,6 +141,9 @@ sequenceDiagram
|
|||||||
Stream->>Budget: enqueue finalize operations
|
Stream->>Budget: enqueue finalize operations
|
||||||
Budget->>Render: attach bounded terrain/M2/WMO/liquid work
|
Budget->>Render: attach bounded terrain/M2/WMO/liquid work
|
||||||
Stream->>Render: evict outside retention range
|
Stream->>Render: evict outside retention range
|
||||||
|
Stream->>Worker: shutdown: wait for WorkerThreadPool tasks
|
||||||
|
Stream->>Stream: shutdown: finish registered ResourceLoader requests
|
||||||
|
Stream->>Render: clear queues, nodes, caches and RIDs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ownership, threading and resources
|
## Ownership, threading and resources
|
||||||
@@ -149,7 +152,7 @@ sequenceDiagram
|
|||||||
- Worker tasks не должны менять SceneTree и shared Resource concurrently.
|
- Worker tasks не должны менять SceneTree и shared Resource concurrently.
|
||||||
- Parsed/grouped results передаются обратно через guarded result queues.
|
- Parsed/grouped results передаются обратно через guarded result queues.
|
||||||
- Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets.
|
- Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets.
|
||||||
- Shutdown/map switch обязан отменить/дождаться jobs и освободить RIDs; M00 всё ещё фиксирует leaked-resource risk.
|
- Shutdown/map switch обязан дождаться WorkerThreadPool jobs и зарегистрированных threaded ResourceLoader requests до очистки очередей, nodes, caches и RIDs.
|
||||||
- Cache resources считаются immutable после публикации.
|
- Cache resources считаются immutable после публикации.
|
||||||
|
|
||||||
## Errors, cancellation and recovery
|
## Errors, cancellation and recovery
|
||||||
@@ -162,7 +165,7 @@ sequenceDiagram
|
|||||||
| Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path |
|
| Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path |
|
||||||
| D3D12 descriptor exhaustion | Rendering backend error | Render failure/fallback backend | Godot error + baseline report | Dedup resources/fix settings |
|
| D3D12 descriptor exhaustion | Rendering backend error | Render failure/fallback backend | Godot error + baseline report | Dedup resources/fix settings |
|
||||||
| Teleport/map change | Focus/session transition | Old jobs become stale | Target/session generation | Cancel/drop stale results |
|
| Teleport/map change | Focus/session transition | Old jobs become stale | Target/session generation | Cancel/drop stale results |
|
||||||
| Shutdown leak | Godot leak/RID diagnostics | Resource retained | Shutdown report | Ownership cleanup before DONE |
|
| Shutdown leak | Godot leak/RID diagnostics | Resource retained | Verbose shutdown report | Drain owned worker and resource requests before clearing registries |
|
||||||
|
|
||||||
## Configuration and capabilities
|
## Configuration and capabilities
|
||||||
|
|
||||||
@@ -192,9 +195,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest.
|
- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff.
|
||||||
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints.
|
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints.
|
||||||
- Fidelity evidence: current fixed-position captures exist; approved original-client paired screenshots are still missing.
|
- 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.
|
- Performance budgets: M00 report records cold/warm p95 and max hitch; no final acceptance threshold yet.
|
||||||
- Manual diagnostics: [`../RENDER_BASELINE.md`](../RENDER_BASELINE.md) and [`../../RENDER.md`](../../RENDER.md).
|
- Manual diagnostics: [`../RENDER_BASELINE.md`](../RENDER_BASELINE.md) and [`../../RENDER.md`](../../RENDER.md).
|
||||||
|
|
||||||
@@ -222,7 +225,11 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
|||||||
- Monolithic streamer mixes planning, jobs, caches and presentation.
|
- Monolithic streamer mixes planning, jobs, caches and presentation.
|
||||||
- Direct camera path remains until M01/M03.
|
- Direct camera path remains until M01/M03.
|
||||||
- Original-client paired fidelity evidence incomplete.
|
- Original-client paired fidelity evidence incomplete.
|
||||||
- D3D12 descriptor and shutdown RID/resource issues remain.
|
- Первый 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 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.
|
- 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.
|
||||||
|
|
||||||
@@ -239,6 +246,11 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
|||||||
| `src/native/src/*_loader.cpp` | Native binary parsing |
|
| `src/native/src/*_loader.cpp` | Native binary parsing |
|
||||||
| `src/tools/build_*cache.gd`, `src/tools/bake_*cache.gd` | Offline cache generation |
|
| `src/tools/build_*cache.gd`, `src/tools/bake_*cache.gd` | Offline cache generation |
|
||||||
| `tools/run_render_baseline.ps1` | Unified M00 baseline runner |
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
## Related decisions and references
|
## Related decisions and references
|
||||||
|
|
||||||
|
|||||||
@@ -2073,7 +2073,7 @@ func _wait_for_tile_tasks() -> void:
|
|||||||
var path: String = String(pending.get("path", ""))
|
var path: String = String(pending.get("path", ""))
|
||||||
if not path.is_empty():
|
if not path.is_empty():
|
||||||
var status := ResourceLoader.load_threaded_get_status(path)
|
var status := ResourceLoader.load_threaded_get_status(path)
|
||||||
if status == ResourceLoader.THREAD_LOAD_LOADED:
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||||
ResourceLoader.load_threaded_get(path)
|
ResourceLoader.load_threaded_get(path)
|
||||||
_tile_loading_tasks.clear()
|
_tile_loading_tasks.clear()
|
||||||
|
|
||||||
@@ -2094,7 +2094,7 @@ func _wait_for_tile_tasks() -> void:
|
|||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
continue
|
continue
|
||||||
var status := ResourceLoader.load_threaded_get_status(path)
|
var status := ResourceLoader.load_threaded_get_status(path)
|
||||||
if status == ResourceLoader.THREAD_LOAD_LOADED:
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||||
ResourceLoader.load_threaded_get(path)
|
ResourceLoader.load_threaded_get(path)
|
||||||
_m2_mesh_load_requests.clear()
|
_m2_mesh_load_requests.clear()
|
||||||
_m2_mesh_finalize_queue.clear()
|
_m2_mesh_finalize_queue.clear()
|
||||||
@@ -2105,7 +2105,7 @@ func _wait_for_tile_tasks() -> void:
|
|||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
continue
|
continue
|
||||||
var status := ResourceLoader.load_threaded_get_status(path)
|
var status := ResourceLoader.load_threaded_get_status(path)
|
||||||
if status == ResourceLoader.THREAD_LOAD_LOADED:
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||||
ResourceLoader.load_threaded_get(path)
|
ResourceLoader.load_threaded_get(path)
|
||||||
_m2_animation_load_requests.clear()
|
_m2_animation_load_requests.clear()
|
||||||
_m2_animation_finalize_queue.clear()
|
_m2_animation_finalize_queue.clear()
|
||||||
@@ -2115,7 +2115,7 @@ func _wait_for_tile_tasks() -> void:
|
|||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
continue
|
continue
|
||||||
var status := ResourceLoader.load_threaded_get_status(path)
|
var status := ResourceLoader.load_threaded_get_status(path)
|
||||||
if status == ResourceLoader.THREAD_LOAD_LOADED:
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||||
ResourceLoader.load_threaded_get(path)
|
ResourceLoader.load_threaded_get(path)
|
||||||
_wmo_scene_load_requests.clear()
|
_wmo_scene_load_requests.clear()
|
||||||
|
|
||||||
@@ -2124,7 +2124,7 @@ func _wait_for_tile_tasks() -> void:
|
|||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
continue
|
continue
|
||||||
var status := ResourceLoader.load_threaded_get_status(path)
|
var status := ResourceLoader.load_threaded_get_status(path)
|
||||||
if status == ResourceLoader.THREAD_LOAD_LOADED:
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||||
ResourceLoader.load_threaded_get(path)
|
ResourceLoader.load_threaded_get(path)
|
||||||
_wmo_render_load_requests.clear()
|
_wmo_render_load_requests.clear()
|
||||||
_wmo_render_missing_cache.clear()
|
_wmo_render_missing_cache.clear()
|
||||||
@@ -2137,7 +2137,7 @@ func _wait_for_tile_tasks() -> void:
|
|||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
continue
|
continue
|
||||||
var status := ResourceLoader.load_threaded_get_status(path)
|
var status := ResourceLoader.load_threaded_get_status(path)
|
||||||
if status == ResourceLoader.THREAD_LOAD_LOADED:
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||||
ResourceLoader.load_threaded_get(path)
|
ResourceLoader.load_threaded_get(path)
|
||||||
_terrain_upgrade_tasks.clear()
|
_terrain_upgrade_tasks.clear()
|
||||||
|
|
||||||
@@ -2148,7 +2148,7 @@ func _wait_for_tile_tasks() -> void:
|
|||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
continue
|
continue
|
||||||
var status := ResourceLoader.load_threaded_get_status(path)
|
var status := ResourceLoader.load_threaded_get_status(path)
|
||||||
if status == ResourceLoader.THREAD_LOAD_LOADED:
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||||
ResourceLoader.load_threaded_get(path)
|
ResourceLoader.load_threaded_get(path)
|
||||||
_terrain_control_splat_cache_tasks.clear()
|
_terrain_control_splat_cache_tasks.clear()
|
||||||
|
|
||||||
@@ -2162,7 +2162,7 @@ func _wait_for_tile_tasks() -> void:
|
|||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
continue
|
continue
|
||||||
var status := ResourceLoader.load_threaded_get_status(path)
|
var status := ResourceLoader.load_threaded_get_status(path)
|
||||||
if status == ResourceLoader.THREAD_LOAD_LOADED:
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||||
ResourceLoader.load_threaded_get(path)
|
ResourceLoader.load_threaded_get(path)
|
||||||
_terrain_splat_cache_tasks.clear()
|
_terrain_splat_cache_tasks.clear()
|
||||||
_terrain_splat_result_mutex.lock()
|
_terrain_splat_result_mutex.lock()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ extends SceneTree
|
|||||||
|
|
||||||
const DEFAULT_MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
|
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 M2_NATIVE_ANIMATED_BUILDER := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
|
||||||
|
const SHUTDOWN_DRAIN_FRAMES := 2
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
@@ -22,10 +23,13 @@ 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])
|
||||||
get_root().size = Vector2i(maxi(16, int(viewport[0])), maxi(16, int(viewport[1])))
|
var viewport_width := int(_arg(args, "--viewport-width", str(viewport[0])))
|
||||||
|
var viewport_height := int(_arg(args, "--viewport-height", str(viewport[1])))
|
||||||
|
get_root().size = Vector2i(maxi(16, viewport_width), maxi(16, viewport_height))
|
||||||
|
|
||||||
var abs_output_dir := ProjectSettings.globalize_path(output_dir)
|
var abs_output_dir := ProjectSettings.globalize_path(output_dir)
|
||||||
DirAccess.make_dir_recursive_absolute(abs_output_dir)
|
DirAccess.make_dir_recursive_absolute(abs_output_dir)
|
||||||
@@ -53,7 +57,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)
|
||||||
@@ -63,6 +67,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:
|
||||||
@@ -80,6 +85,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", {}),
|
||||||
@@ -107,7 +113,8 @@ func _capture_async() -> void:
|
|||||||
probe.scale = Vector3.ONE * float(checkpoint.get("probe_scale", 1.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.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)
|
_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)))
|
||||||
@@ -135,7 +142,6 @@ func _capture_async() -> void:
|
|||||||
await RenderingServer.frame_post_draw
|
await RenderingServer.frame_post_draw
|
||||||
|
|
||||||
var image := get_root().get_texture().get_image()
|
var image := get_root().get_texture().get_image()
|
||||||
image.flip_y()
|
|
||||||
var file_name := "%s__%s.png" % [checkpoint_name, pass_name]
|
var file_name := "%s__%s.png" % [checkpoint_name, pass_name]
|
||||||
var abs_path := abs_output_dir.path_join(file_name)
|
var abs_path := abs_output_dir.path_join(file_name)
|
||||||
var err := image.save_png(abs_path)
|
var err := image.save_png(abs_path)
|
||||||
@@ -162,6 +168,10 @@ func _capture_async() -> void:
|
|||||||
print("RENDER_BASELINE report=%s results=%d" % [report_path, captured])
|
print("RENDER_BASELINE report=%s results=%d" % [report_path, captured])
|
||||||
|
|
||||||
world.queue_free()
|
world.queue_free()
|
||||||
|
# SceneTree deletion is deferred. Allow the world and its queued children
|
||||||
|
# to run their shutdown paths before engine teardown.
|
||||||
|
for unused_frame in SHUTDOWN_DRAIN_FRAMES:
|
||||||
|
await process_frame
|
||||||
if captured <= 0:
|
if captured <= 0:
|
||||||
push_error("No checkpoints selected. --only filter was: %s" % only)
|
push_error("No checkpoints selected. --only filter was: %s" % only)
|
||||||
quit(1)
|
quit(1)
|
||||||
@@ -196,6 +206,15 @@ func _measure_frames(seconds: float, world: Node) -> Dictionary:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _orient_camera_without_roll(camera: Camera3D, target_position: Vector3) -> void:
|
||||||
|
var forward := (target_position - camera.global_position).normalized()
|
||||||
|
var right := forward.cross(Vector3.UP).normalized()
|
||||||
|
if right.is_zero_approx():
|
||||||
|
right = Vector3.RIGHT
|
||||||
|
var corrected_up := right.cross(forward).normalized()
|
||||||
|
camera.global_basis = Basis(right, corrected_up, -forward)
|
||||||
|
|
||||||
|
|
||||||
func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: float, metrics: Dictionary, sha256: String) -> Dictionary:
|
func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: float, metrics: Dictionary, sha256: String) -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"name": checkpoint.get("name", "checkpoint"),
|
"name": checkpoint.get("name", "checkpoint"),
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Compares original-client JPG/PNG references with OpenWC checkpoint PNGs.
|
||||||
|
## Usage: godot --headless --path . --script res://src/tools/compare_render_checkpoints.gd --
|
||||||
|
## --reference <directory> --candidate <directory> [--output <report.json>]
|
||||||
|
## [--pixel-threshold 0.04] [--mean-threshold 0.015] [--changed-ratio-threshold 0.01]
|
||||||
|
## Use --self-test for a synthetic regression check that writes only under user://.
|
||||||
|
|
||||||
|
const REPORT_SCHEMA_VERSION := 1
|
||||||
|
const DEFAULT_PIXEL_THRESHOLD := 0.04
|
||||||
|
const DEFAULT_MEAN_THRESHOLD := 0.015
|
||||||
|
const DEFAULT_CHANGED_RATIO_THRESHOLD := 0.01
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var arguments := _parse_arguments(OS.get_cmdline_user_args())
|
||||||
|
if arguments.has("error"):
|
||||||
|
push_error("RENDER_CHECKPOINT_DIFF: %s" % arguments.error)
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
if arguments.get("self_test", false):
|
||||||
|
quit(_run_self_test())
|
||||||
|
return
|
||||||
|
|
||||||
|
var reference_directory := String(arguments.get("reference", ""))
|
||||||
|
var candidate_directory := String(arguments.get("candidate", ""))
|
||||||
|
if reference_directory.is_empty() or candidate_directory.is_empty():
|
||||||
|
push_error("RENDER_CHECKPOINT_DIFF: --reference and --candidate are required")
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
var report := _compare_directories(reference_directory, candidate_directory, arguments)
|
||||||
|
var output_path := String(arguments.get("output", ""))
|
||||||
|
if not output_path.is_empty() and not _write_report(output_path, report):
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
print("RENDER_CHECKPOINT_DIFF %s compared=%d failed=%d missing=%d" % [
|
||||||
|
"PASS" if report.passed else "FAIL",
|
||||||
|
report.compared_count,
|
||||||
|
report.failed_count,
|
||||||
|
report.missing_count,
|
||||||
|
])
|
||||||
|
quit(0 if report.passed else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _parse_arguments(raw_arguments: PackedStringArray) -> Dictionary:
|
||||||
|
var parsed := {
|
||||||
|
"pixel_threshold": DEFAULT_PIXEL_THRESHOLD,
|
||||||
|
"mean_threshold": DEFAULT_MEAN_THRESHOLD,
|
||||||
|
"changed_ratio_threshold": DEFAULT_CHANGED_RATIO_THRESHOLD,
|
||||||
|
}
|
||||||
|
var index := 0
|
||||||
|
while index < raw_arguments.size():
|
||||||
|
var argument := raw_arguments[index]
|
||||||
|
if argument == "--self-test":
|
||||||
|
parsed.self_test = true
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
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}
|
||||||
|
var key := argument.trim_prefix("--").replace("-", "_")
|
||||||
|
var value := raw_arguments[index + 1]
|
||||||
|
if key.ends_with("threshold"):
|
||||||
|
if not value.is_valid_float():
|
||||||
|
return {"error": "%s requires a number" % argument}
|
||||||
|
parsed[key] = float(value)
|
||||||
|
else:
|
||||||
|
parsed[key] = value
|
||||||
|
index += 2
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
func _compare_directories(reference_directory: String, candidate_directory: String, options: Dictionary) -> Dictionary:
|
||||||
|
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)
|
||||||
|
if candidate_file_names.is_empty():
|
||||||
|
results.append({"checkpoint": checkpoint_name, "reference": file_name, "status": "missing_candidate"})
|
||||||
|
missing_count += 1
|
||||||
|
continue
|
||||||
|
for candidate_file_name in candidate_file_names:
|
||||||
|
var candidate_path := candidate_directory.path_join(candidate_file_name)
|
||||||
|
var comparison := _compare_images(reference_path, candidate_path, options)
|
||||||
|
comparison.checkpoint = checkpoint_name
|
||||||
|
comparison.reference = file_name
|
||||||
|
comparison.candidate = candidate_file_name
|
||||||
|
results.append(comparison)
|
||||||
|
if comparison.status != "passed":
|
||||||
|
failed_count += 1
|
||||||
|
var no_reference_images := reference_files.is_empty()
|
||||||
|
return {
|
||||||
|
"schema_version": REPORT_SCHEMA_VERSION,
|
||||||
|
"passed": not no_reference_images and failed_count == 0 and missing_count == 0,
|
||||||
|
"reference_directory": reference_directory,
|
||||||
|
"candidate_directory": candidate_directory,
|
||||||
|
"pixel_threshold": options.pixel_threshold,
|
||||||
|
"mean_threshold": options.mean_threshold,
|
||||||
|
"changed_ratio_threshold": options.changed_ratio_threshold,
|
||||||
|
"compared_count": results.size() - missing_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"missing_count": missing_count,
|
||||||
|
"no_reference_images": no_reference_images,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _compare_images(reference_path: String, candidate_path: String, options: Dictionary) -> Dictionary:
|
||||||
|
var reference_image := Image.load_from_file(reference_path)
|
||||||
|
var candidate_image := Image.load_from_file(candidate_path)
|
||||||
|
if reference_image == null or candidate_image == null or reference_image.is_empty() or candidate_image.is_empty():
|
||||||
|
return {"status": "load_error"}
|
||||||
|
if reference_image.get_size() != candidate_image.get_size():
|
||||||
|
return {
|
||||||
|
"status": "size_mismatch",
|
||||||
|
"reference_size": [reference_image.get_width(), reference_image.get_height()],
|
||||||
|
"candidate_size": [candidate_image.get_width(), candidate_image.get_height()],
|
||||||
|
}
|
||||||
|
|
||||||
|
var error_sum := 0.0
|
||||||
|
var changed_pixel_count := 0
|
||||||
|
var pixel_count := reference_image.get_width() * reference_image.get_height()
|
||||||
|
for y in reference_image.get_height():
|
||||||
|
for x in reference_image.get_width():
|
||||||
|
var perceptual_error := _perceptual_color_error(reference_image.get_pixel(x, y), candidate_image.get_pixel(x, y))
|
||||||
|
error_sum += perceptual_error
|
||||||
|
if perceptual_error > float(options.pixel_threshold):
|
||||||
|
changed_pixel_count += 1
|
||||||
|
var mean_perceptual_error := error_sum / float(pixel_count)
|
||||||
|
var changed_pixel_ratio := float(changed_pixel_count) / float(pixel_count)
|
||||||
|
var passed := mean_perceptual_error <= float(options.mean_threshold) and changed_pixel_ratio <= float(options.changed_ratio_threshold)
|
||||||
|
return {
|
||||||
|
"status": "passed" if passed else "threshold_exceeded",
|
||||||
|
"width": reference_image.get_width(),
|
||||||
|
"height": reference_image.get_height(),
|
||||||
|
"mean_perceptual_error": mean_perceptual_error,
|
||||||
|
"changed_pixel_ratio": changed_pixel_ratio,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _perceptual_color_error(reference_color: Color, candidate_color: Color) -> float:
|
||||||
|
var reference_linear := reference_color.srgb_to_linear()
|
||||||
|
var candidate_linear := candidate_color.srgb_to_linear()
|
||||||
|
var red_delta := absf(reference_linear.r - candidate_linear.r)
|
||||||
|
var green_delta := absf(reference_linear.g - candidate_linear.g)
|
||||||
|
var blue_delta := absf(reference_linear.b - candidate_linear.b)
|
||||||
|
var alpha_delta := absf(reference_linear.a - candidate_linear.a)
|
||||||
|
return 0.2126 * red_delta + 0.7152 * green_delta + 0.0722 * blue_delta + 0.25 * alpha_delta
|
||||||
|
|
||||||
|
|
||||||
|
func _reference_image_file_names(directory_path: String) -> Array[String]:
|
||||||
|
var directory := DirAccess.open(directory_path)
|
||||||
|
if directory == null:
|
||||||
|
return []
|
||||||
|
var file_names: Array[String] = []
|
||||||
|
for file_name in directory.get_files():
|
||||||
|
var lower_name := file_name.to_lower()
|
||||||
|
if not lower_name.begins_with("diagnostic_") and (lower_name.ends_with(".png") or lower_name.ends_with(".jpg") or lower_name.ends_with(".jpeg")):
|
||||||
|
file_names.append(file_name)
|
||||||
|
file_names.sort()
|
||||||
|
return file_names
|
||||||
|
|
||||||
|
|
||||||
|
func _candidate_file_names(candidate_directory: String, checkpoint_name: String) -> Array[String]:
|
||||||
|
var candidates: Array[String] = []
|
||||||
|
for pass_name in ["cold_process", "warm_revisit"]:
|
||||||
|
var file_name := "%s__%s.png" % [checkpoint_name, pass_name]
|
||||||
|
if FileAccess.file_exists(candidate_directory.path_join(file_name)):
|
||||||
|
candidates.append(file_name)
|
||||||
|
if candidates.is_empty():
|
||||||
|
var exact_file_name := "%s.png" % checkpoint_name
|
||||||
|
if FileAccess.file_exists(candidate_directory.path_join(exact_file_name)):
|
||||||
|
candidates.append(exact_file_name)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
func _write_report(output_path: String, report: Dictionary) -> bool:
|
||||||
|
var absolute_path := ProjectSettings.globalize_path(output_path)
|
||||||
|
var error := DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir())
|
||||||
|
if error != OK:
|
||||||
|
push_error("RENDER_CHECKPOINT_DIFF: cannot create report directory: %s" % error_string(error))
|
||||||
|
return false
|
||||||
|
var report_file := FileAccess.open(absolute_path, FileAccess.WRITE)
|
||||||
|
if report_file == null:
|
||||||
|
push_error("RENDER_CHECKPOINT_DIFF: cannot write report: %s" % output_path)
|
||||||
|
return false
|
||||||
|
report_file.store_string(JSON.stringify(report, " "))
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _run_self_test() -> int:
|
||||||
|
var root := "user://render_checkpoint_diff_self_test"
|
||||||
|
var reference_directory := root.path_join("reference")
|
||||||
|
var identical_directory := root.path_join("identical")
|
||||||
|
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()
|
||||||
|
changed_image.set_pixel(0, 0, Color.WHITE)
|
||||||
|
if reference_image.save_jpg(reference_directory.path_join("synthetic.jpg"), 1.0) != OK:
|
||||||
|
push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: cannot save JPG reference")
|
||||||
|
return 1
|
||||||
|
for pass_name in ["cold_process", "warm_revisit"]:
|
||||||
|
if reference_image.save_png(identical_directory.path_join("synthetic__%s.png" % pass_name)) != OK:
|
||||||
|
return 1
|
||||||
|
if changed_image.save_png(changed_directory.path_join("synthetic__%s.png" % pass_name)) != OK:
|
||||||
|
return 1
|
||||||
|
var options := {
|
||||||
|
"pixel_threshold": DEFAULT_PIXEL_THRESHOLD,
|
||||||
|
"mean_threshold": DEFAULT_MEAN_THRESHOLD,
|
||||||
|
"changed_ratio_threshold": DEFAULT_CHANGED_RATIO_THRESHOLD,
|
||||||
|
}
|
||||||
|
var identical_report := _compare_directories(reference_directory, identical_directory, options)
|
||||||
|
var changed_report := _compare_directories(reference_directory, changed_directory, options)
|
||||||
|
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)
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Reports published scene-tree geometry around calibrated renderer cameras.
|
||||||
|
## Usage: godot --headless --path . --script res://src/tools/probe_render_camera_occluders.gd --
|
||||||
|
## [--wait 3.0] [--output user://render_camera_occluders/report.json]
|
||||||
|
|
||||||
|
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
|
||||||
|
const MAX_REPORTED_INTERSECTIONS := 20
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run_async.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run_async() -> void:
|
||||||
|
var arguments := OS.get_cmdline_user_args()
|
||||||
|
var wait_seconds := float(_argument(arguments, "--wait", "3.0"))
|
||||||
|
var output_path := _argument(arguments, "--output", "user://render_camera_occluders/report.json")
|
||||||
|
var only_filter := _argument(arguments, "--only", "").to_lower()
|
||||||
|
var manifest := _load_json(MANIFEST_PATH)
|
||||||
|
var packed_scene: PackedScene = load(String(manifest.get("scene", "")))
|
||||||
|
if packed_scene == null:
|
||||||
|
push_error("CAMERA_OCCLUDER_PROBE: cannot load streaming scene")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var world := packed_scene.instantiate() as Node3D
|
||||||
|
var camera := Camera3D.new()
|
||||||
|
camera.name = "OccluderProbeCamera"
|
||||||
|
camera.current = true
|
||||||
|
world.add_child(camera)
|
||||||
|
world.set("camera_path", NodePath(camera.name))
|
||||||
|
world.set("debug_streaming", false)
|
||||||
|
get_root().add_child(world)
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var results: Array[Dictionary] = []
|
||||||
|
for checkpoint_variant in manifest.get("checkpoints", []):
|
||||||
|
if not (checkpoint_variant is Dictionary):
|
||||||
|
continue
|
||||||
|
var checkpoint: Dictionary = checkpoint_variant
|
||||||
|
if not checkpoint.has("reference_wow_camera"):
|
||||||
|
continue
|
||||||
|
var checkpoint_name := String(checkpoint.get("name", "checkpoint"))
|
||||||
|
if not only_filter.is_empty() and not checkpoint_name.to_lower().contains(only_filter):
|
||||||
|
continue
|
||||||
|
var camera_position := _vector3(checkpoint.get("camera", []))
|
||||||
|
var target_position := _vector3(checkpoint.get("target", []))
|
||||||
|
camera.global_position = camera_position
|
||||||
|
if world.has_method("_refresh_streaming_targets_at"):
|
||||||
|
world.call("_refresh_streaming_targets_at", camera_position, true)
|
||||||
|
await create_timer(maxf(0.1, wait_seconds)).timeout
|
||||||
|
var geometry_nodes: Array[Node3D] = []
|
||||||
|
_collect_geometry_nodes(world, geometry_nodes)
|
||||||
|
var containing: Array[Dictionary] = []
|
||||||
|
var intersecting: Array[Dictionary] = []
|
||||||
|
for geometry_node in geometry_nodes:
|
||||||
|
var world_aabb := _geometry_world_aabb(geometry_node)
|
||||||
|
if world_aabb.size.is_zero_approx():
|
||||||
|
continue
|
||||||
|
var record := _geometry_record(geometry_node, world_aabb, camera_position)
|
||||||
|
if world_aabb.has_point(camera_position):
|
||||||
|
containing.append(record)
|
||||||
|
var intersection = world_aabb.intersects_segment(camera_position, target_position)
|
||||||
|
if intersection != null:
|
||||||
|
record["intersection_distance"] = camera_position.distance_to(intersection as Vector3)
|
||||||
|
intersecting.append(record)
|
||||||
|
intersecting.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||||
|
return float(a.intersection_distance) < float(b.intersection_distance))
|
||||||
|
if intersecting.size() > MAX_REPORTED_INTERSECTIONS:
|
||||||
|
intersecting.resize(MAX_REPORTED_INTERSECTIONS)
|
||||||
|
var result := {
|
||||||
|
"name": checkpoint_name,
|
||||||
|
"geometry_node_count": geometry_nodes.size(),
|
||||||
|
"camera_containing_geometry": containing,
|
||||||
|
"segment_intersecting_geometry": intersecting,
|
||||||
|
}
|
||||||
|
results.append(result)
|
||||||
|
print("CAMERA_OCCLUDERS name=%s geometry=%d containing=%d segment=%d" % [
|
||||||
|
checkpoint_name, geometry_nodes.size(), containing.size(), intersecting.size()])
|
||||||
|
|
||||||
|
var report := {
|
||||||
|
"schema_version": 1,
|
||||||
|
"created_utc": Time.get_datetime_string_from_system(true, true),
|
||||||
|
"wait_seconds": wait_seconds,
|
||||||
|
"coverage": "scene_tree_only; RenderingServer RID-only instances are excluded",
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
if not _write_json(output_path, report):
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
world.queue_free()
|
||||||
|
quit(0 if not results.is_empty() else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _collect_geometry_nodes(node: Node, output: Array[Node3D]) -> void:
|
||||||
|
if node is MeshInstance3D:
|
||||||
|
var mesh_instance := node as MeshInstance3D
|
||||||
|
if mesh_instance.mesh != null and not mesh_instance.name.begins_with("TileLOD"):
|
||||||
|
output.append(mesh_instance)
|
||||||
|
elif node is MultiMeshInstance3D:
|
||||||
|
var multimesh_instance := node as MultiMeshInstance3D
|
||||||
|
if multimesh_instance.multimesh != null:
|
||||||
|
output.append(multimesh_instance)
|
||||||
|
for child in node.get_children():
|
||||||
|
_collect_geometry_nodes(child, output)
|
||||||
|
|
||||||
|
|
||||||
|
func _geometry_world_aabb(node: Node3D) -> AABB:
|
||||||
|
if node is MeshInstance3D:
|
||||||
|
return node.global_transform * (node as MeshInstance3D).mesh.get_aabb()
|
||||||
|
if node is MultiMeshInstance3D:
|
||||||
|
return node.global_transform * (node as MultiMeshInstance3D).multimesh.get_aabb()
|
||||||
|
return AABB()
|
||||||
|
|
||||||
|
|
||||||
|
func _geometry_record(node: Node3D, world_aabb: AABB, camera_position: Vector3) -> Dictionary:
|
||||||
|
var node_path := String(node.get_path())
|
||||||
|
var category := "geometry"
|
||||||
|
if node_path.contains("/M2s/") or node is MultiMeshInstance3D:
|
||||||
|
category = "m2"
|
||||||
|
elif node_path.contains("/WMOs/"):
|
||||||
|
category = "wmo"
|
||||||
|
return {
|
||||||
|
"category": category,
|
||||||
|
"node_path": node_path,
|
||||||
|
"distance_to_center": camera_position.distance_to(world_aabb.get_center()),
|
||||||
|
"aabb_position": _vector3_array(world_aabb.position),
|
||||||
|
"aabb_size": _vector3_array(world_aabb.size),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _vector3(value_variant) -> Vector3:
|
||||||
|
if not (value_variant is Array) or value_variant.size() != 3:
|
||||||
|
return Vector3.ZERO
|
||||||
|
return Vector3(float(value_variant[0]), float(value_variant[1]), float(value_variant[2]))
|
||||||
|
|
||||||
|
|
||||||
|
func _vector3_array(value: Vector3) -> Array[float]:
|
||||||
|
return [value.x, value.y, value.z]
|
||||||
|
|
||||||
|
|
||||||
|
func _argument(arguments: PackedStringArray, name: String, default_value: String) -> String:
|
||||||
|
var index := arguments.find(name)
|
||||||
|
if index >= 0 and index + 1 < arguments.size():
|
||||||
|
return arguments[index + 1]
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
|
||||||
|
func _load_json(path: String) -> Dictionary:
|
||||||
|
var file := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return {}
|
||||||
|
var parsed = JSON.parse_string(file.get_as_text())
|
||||||
|
return parsed if parsed is Dictionary else {}
|
||||||
|
|
||||||
|
|
||||||
|
func _write_json(path: String, value: Dictionary) -> bool:
|
||||||
|
var absolute_path := ProjectSettings.globalize_path(path)
|
||||||
|
if DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir()) != OK:
|
||||||
|
return false
|
||||||
|
var file := FileAccess.open(absolute_path, FileAccess.WRITE)
|
||||||
|
if file == null:
|
||||||
|
return false
|
||||||
|
file.store_string(JSON.stringify(value, " "))
|
||||||
|
return true
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Measures camera clearance against the active rendered terrain mesh.
|
||||||
|
## Usage: godot --path . --script res://src/tools/probe_render_terrain_height.gd --
|
||||||
|
## [--wait 3.0] [--output user://render_terrain_height/report.json]
|
||||||
|
|
||||||
|
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
|
||||||
|
const TILE_SIZE := 533.33333
|
||||||
|
const RAY_HEIGHT := 5000.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run_async.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run_async() -> void:
|
||||||
|
var arguments := OS.get_cmdline_user_args()
|
||||||
|
var wait_seconds := float(_argument(arguments, "--wait", "3.0"))
|
||||||
|
var output_path := _argument(arguments, "--output", "user://render_terrain_height/report.json")
|
||||||
|
var only_filter := _argument(arguments, "--only", "").to_lower()
|
||||||
|
var require_all := arguments.has("--require-all")
|
||||||
|
var manifest := _load_json(MANIFEST_PATH)
|
||||||
|
if manifest.is_empty():
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var packed_scene: PackedScene = load(String(manifest.get("scene", "")))
|
||||||
|
if packed_scene == null:
|
||||||
|
push_error("TERRAIN_HEIGHT_PROBE: cannot load streaming scene")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var world := packed_scene.instantiate() as Node3D
|
||||||
|
if world == null:
|
||||||
|
push_error("TERRAIN_HEIGHT_PROBE: streaming scene root is not Node3D")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var camera := Camera3D.new()
|
||||||
|
camera.current = true
|
||||||
|
world.add_child(camera)
|
||||||
|
world.set("camera_path", NodePath(camera.name))
|
||||||
|
world.set("debug_streaming", false)
|
||||||
|
get_root().add_child(world)
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var results: Array[Dictionary] = []
|
||||||
|
for checkpoint_variant in manifest.get("checkpoints", []):
|
||||||
|
if not (checkpoint_variant is Dictionary):
|
||||||
|
continue
|
||||||
|
var checkpoint: Dictionary = checkpoint_variant
|
||||||
|
if not checkpoint.has("reference_wow_camera"):
|
||||||
|
continue
|
||||||
|
if not only_filter.is_empty() and not String(checkpoint.get("name", "")).to_lower().contains(only_filter):
|
||||||
|
continue
|
||||||
|
var camera_position := _vector3(checkpoint.get("camera", []))
|
||||||
|
camera.global_position = camera_position
|
||||||
|
if world.has_method("_refresh_streaming_targets_at"):
|
||||||
|
world.call("_refresh_streaming_targets_at", camera_position, true)
|
||||||
|
await create_timer(maxf(0.1, wait_seconds)).timeout
|
||||||
|
var terrain_sample := _sample_terrain(world, camera_position)
|
||||||
|
terrain_sample["name"] = checkpoint.get("name", "checkpoint")
|
||||||
|
terrain_sample["camera_y"] = camera_position.y
|
||||||
|
if terrain_sample.has("terrain_height"):
|
||||||
|
terrain_sample["camera_clearance"] = camera_position.y - float(terrain_sample.terrain_height)
|
||||||
|
results.append(terrain_sample)
|
||||||
|
|
||||||
|
var report := {
|
||||||
|
"schema_version": 1,
|
||||||
|
"created_utc": Time.get_datetime_string_from_system(true, true),
|
||||||
|
"wait_seconds": wait_seconds,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
if not _write_json(output_path, report):
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var sampled_count := 0
|
||||||
|
for result in results:
|
||||||
|
if result.has("terrain_height"):
|
||||||
|
sampled_count += 1
|
||||||
|
print("TERRAIN_HEIGHT name=%s status=%s camera_y=%.3f terrain=%s clearance=%s" % [
|
||||||
|
result.get("name", "checkpoint"),
|
||||||
|
result.get("status", "unknown"),
|
||||||
|
float(result.get("camera_y", 0.0)),
|
||||||
|
str(result.get("terrain_height", "n/a")),
|
||||||
|
str(result.get("camera_clearance", "n/a")),
|
||||||
|
])
|
||||||
|
print("TERRAIN_HEIGHT_PROBE sampled=%d total=%d report=%s" % [sampled_count, results.size(), output_path])
|
||||||
|
world.queue_free()
|
||||||
|
var failed := sampled_count == 0 or (require_all and sampled_count != results.size())
|
||||||
|
quit(1 if failed else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary:
|
||||||
|
var tile_coordinate := Vector2i(
|
||||||
|
int(floor(world_position.x / TILE_SIZE)),
|
||||||
|
int(floor(world_position.z / TILE_SIZE)))
|
||||||
|
var tile_states: Dictionary = world.get("_tile_states")
|
||||||
|
var highest_terrain_height := -INF
|
||||||
|
var intersected_tile_key := ""
|
||||||
|
var ready_mesh_count := 0
|
||||||
|
for tile_y in range(tile_coordinate.y - 1, tile_coordinate.y + 2):
|
||||||
|
for tile_x in range(tile_coordinate.x - 1, tile_coordinate.x + 2):
|
||||||
|
var tile_key := "%d_%d" % [tile_x, tile_y]
|
||||||
|
if not tile_states.has(tile_key):
|
||||||
|
continue
|
||||||
|
var state: Dictionary = tile_states[tile_key]
|
||||||
|
var terrain_height_variant = _intersect_terrain_state(state, world_position)
|
||||||
|
if terrain_height_variant == null:
|
||||||
|
continue
|
||||||
|
ready_mesh_count += 1
|
||||||
|
var terrain_height := float(terrain_height_variant)
|
||||||
|
if terrain_height > highest_terrain_height:
|
||||||
|
highest_terrain_height = terrain_height
|
||||||
|
intersected_tile_key = tile_key
|
||||||
|
if not is_finite(highest_terrain_height):
|
||||||
|
var missing_result := {
|
||||||
|
"status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready",
|
||||||
|
"tile": "%d_%d" % [tile_coordinate.x, tile_coordinate.y],
|
||||||
|
}
|
||||||
|
missing_result.merge(_tile_runtime_diagnostic(world, String(missing_result.tile), world_position), true)
|
||||||
|
if bool(missing_result.get("quality_mesh_present", false)) or bool(missing_result.get("tile_lod_mesh_present", false)):
|
||||||
|
missing_result["status"] = "no_intersection"
|
||||||
|
missing_result.merge(_nearest_terrain_sample(world, world_position), true)
|
||||||
|
if missing_result.get("nearest_sample_distance", null) != null:
|
||||||
|
missing_result["status"] = "sampled_nearby"
|
||||||
|
missing_result["terrain_height"] = float(missing_result.nearest_sample_height)
|
||||||
|
return missing_result
|
||||||
|
return {
|
||||||
|
"status": "sampled",
|
||||||
|
"tile": intersected_tile_key,
|
||||||
|
"terrain_height": highest_terrain_height,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _intersect_terrain_state(state: Dictionary, world_position: Vector3):
|
||||||
|
var terrain_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
||||||
|
if terrain_mesh == null:
|
||||||
|
terrain_mesh = state.get("tile_lod_mesh", null)
|
||||||
|
if terrain_mesh == null:
|
||||||
|
return null
|
||||||
|
var triangle_mesh := terrain_mesh.generate_triangle_mesh()
|
||||||
|
if triangle_mesh == null:
|
||||||
|
return null
|
||||||
|
var tile_root := state.get("root", null) as Node3D
|
||||||
|
if tile_root == null:
|
||||||
|
return null
|
||||||
|
var inverse_transform := tile_root.global_transform.affine_inverse()
|
||||||
|
var local_ray_origin := inverse_transform * Vector3(world_position.x, RAY_HEIGHT, world_position.z)
|
||||||
|
var local_ray_direction := inverse_transform.basis * Vector3.DOWN
|
||||||
|
var intersection: Dictionary = triangle_mesh.intersect_ray(local_ray_origin, local_ray_direction)
|
||||||
|
if intersection.is_empty():
|
||||||
|
return null
|
||||||
|
var local_hit: Vector3 = intersection.get("position", Vector3.ZERO)
|
||||||
|
var world_hit := tile_root.global_transform * local_hit
|
||||||
|
return world_hit.y
|
||||||
|
|
||||||
|
|
||||||
|
func _tile_runtime_diagnostic(world: Node3D, tile_key: String, world_position: Vector3) -> Dictionary:
|
||||||
|
var available_tiles: Dictionary = world.get("_available_tiles")
|
||||||
|
var loading_tasks: Dictionary = world.get("_tile_loading_tasks")
|
||||||
|
var tile_states: Dictionary = world.get("_tile_states")
|
||||||
|
var load_queue: Array = world.get("_tile_load_queue")
|
||||||
|
var queued_index := -1
|
||||||
|
for index in load_queue.size():
|
||||||
|
var request: Dictionary = load_queue[index]
|
||||||
|
if String(request.get("key", "")) == tile_key:
|
||||||
|
queued_index = index
|
||||||
|
break
|
||||||
|
var diagnostic := {
|
||||||
|
"available": available_tiles.has(tile_key),
|
||||||
|
"queued_index": queued_index,
|
||||||
|
"loading": loading_tasks.has(tile_key),
|
||||||
|
"state_present": tile_states.has(tile_key),
|
||||||
|
"load_queue_size": load_queue.size(),
|
||||||
|
}
|
||||||
|
if tile_states.has(tile_key):
|
||||||
|
var state: Dictionary = tile_states[tile_key]
|
||||||
|
var quality_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
||||||
|
var tile_lod_mesh: Mesh = state.get("tile_lod_mesh", null)
|
||||||
|
diagnostic["quality_mesh_present"] = quality_mesh != null
|
||||||
|
diagnostic["tile_lod_mesh_present"] = tile_lod_mesh != null
|
||||||
|
diagnostic["quality_source"] = String(state.get("quality_terrain_source", ""))
|
||||||
|
diagnostic["tile_lod"] = int(state.get("tile_lod", -1))
|
||||||
|
var tile_root := state.get("root", null) as Node3D
|
||||||
|
if tile_root != null:
|
||||||
|
var local_position := tile_root.global_transform.affine_inverse() * world_position
|
||||||
|
diagnostic["tile_root_position"] = _vector3_array(tile_root.global_position)
|
||||||
|
diagnostic["probe_local_position"] = _vector3_array(local_position)
|
||||||
|
var diagnostic_mesh := quality_mesh if quality_mesh != null else tile_lod_mesh
|
||||||
|
if diagnostic_mesh != null:
|
||||||
|
var mesh_aabb := diagnostic_mesh.get_aabb()
|
||||||
|
diagnostic["mesh_aabb_position"] = _vector3_array(mesh_aabb.position)
|
||||||
|
diagnostic["mesh_aabb_size"] = _vector3_array(mesh_aabb.size)
|
||||||
|
return diagnostic
|
||||||
|
|
||||||
|
|
||||||
|
func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictionary:
|
||||||
|
var tile_states: Dictionary = world.get("_tile_states")
|
||||||
|
for radius in [2.0, 5.0, 10.0, 20.0, 40.0]:
|
||||||
|
for offset in [Vector2(radius, 0.0), Vector2(-radius, 0.0), Vector2(0.0, radius), Vector2(0.0, -radius)]:
|
||||||
|
var sample_position := world_position + Vector3(offset.x, 0.0, offset.y)
|
||||||
|
var sample_tile := Vector2i(
|
||||||
|
int(floor(sample_position.x / TILE_SIZE)),
|
||||||
|
int(floor(sample_position.z / TILE_SIZE)))
|
||||||
|
var sample_key := "%d_%d" % [sample_tile.x, sample_tile.y]
|
||||||
|
if not tile_states.has(sample_key):
|
||||||
|
continue
|
||||||
|
var height_variant = _intersect_terrain_state(tile_states[sample_key], sample_position)
|
||||||
|
if height_variant != null:
|
||||||
|
return {
|
||||||
|
"nearest_sample_distance": radius,
|
||||||
|
"nearest_sample_height": float(height_variant),
|
||||||
|
"nearest_sample_tile": sample_key,
|
||||||
|
}
|
||||||
|
return {"nearest_sample_distance": null}
|
||||||
|
|
||||||
|
|
||||||
|
func _vector3_array(value: Vector3) -> Array[float]:
|
||||||
|
return [value.x, value.y, value.z]
|
||||||
|
|
||||||
|
|
||||||
|
func _vector3(value_variant) -> Vector3:
|
||||||
|
if not (value_variant is Array) or value_variant.size() != 3:
|
||||||
|
return Vector3.ZERO
|
||||||
|
return Vector3(float(value_variant[0]), float(value_variant[1]), float(value_variant[2]))
|
||||||
|
|
||||||
|
|
||||||
|
func _argument(arguments: PackedStringArray, name: String, default_value: String) -> String:
|
||||||
|
var index := arguments.find(name)
|
||||||
|
if index >= 0 and index + 1 < arguments.size():
|
||||||
|
return arguments[index + 1]
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
|
||||||
|
func _load_json(path: String) -> Dictionary:
|
||||||
|
var file := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return {}
|
||||||
|
var parsed = JSON.parse_string(file.get_as_text())
|
||||||
|
return parsed if parsed is Dictionary else {}
|
||||||
|
|
||||||
|
|
||||||
|
func _write_json(path: String, value: Dictionary) -> bool:
|
||||||
|
var absolute_path := ProjectSettings.globalize_path(path)
|
||||||
|
if DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir()) != OK:
|
||||||
|
return false
|
||||||
|
var file := FileAccess.open(absolute_path, FileAccess.WRITE)
|
||||||
|
if file == null:
|
||||||
|
return false
|
||||||
|
file.store_string(JSON.stringify(value, " "))
|
||||||
|
return true
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"map": "Azeroth",
|
"map": "Azeroth",
|
||||||
"quality_preset": "High",
|
"quality_preset": "High",
|
||||||
"viewport": [1280, 900],
|
"viewport": [1280, 900],
|
||||||
|
"fidelity_comparison_viewport": [2560, 1440],
|
||||||
"camera_fov": 62.0,
|
"camera_fov": 62.0,
|
||||||
"default_wait_seconds": 8.0,
|
"default_wait_seconds": 8.0,
|
||||||
"default_measure_seconds": 3.0,
|
"default_measure_seconds": 3.0,
|
||||||
@@ -14,7 +15,10 @@
|
|||||||
"max_hitch_ms_max_regression_percent": 10.0,
|
"max_hitch_ms_max_regression_percent": 10.0,
|
||||||
"load_time_ms_max_regression_percent": 10.0,
|
"load_time_ms_max_regression_percent": 10.0,
|
||||||
"memory_bytes_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"
|
"visual_mean_perceptual_error_max": 0.015,
|
||||||
|
"visual_changed_pixel_ratio_max": 0.01,
|
||||||
|
"visual_pixel_error_threshold": 0.04,
|
||||||
|
"visual_diff": "automated thresholds require human approval; exact PNG hashes are evidence, not an animated-scene pass criterion"
|
||||||
},
|
},
|
||||||
"required_coverage": [
|
"required_coverage": [
|
||||||
"terrain",
|
"terrain",
|
||||||
@@ -39,6 +43,7 @@
|
|||||||
"name": "elwynn_terrain_overview",
|
"name": "elwynn_terrain_overview",
|
||||||
"coverage": ["terrain"],
|
"coverage": ["terrain"],
|
||||||
"camera": [16680.0, 180.0, 26220.0],
|
"camera": [16680.0, 180.0, 26220.0],
|
||||||
|
"reference_wow_camera": [-9153.334, 386.666, 180.0],
|
||||||
"target": [16800.0, 62.0, 26400.0],
|
"target": [16800.0, 62.0, 26400.0],
|
||||||
"player": [16800.0, 58.0, 26400.0],
|
"player": [16800.0, 58.0, 26400.0],
|
||||||
"time_hours": 13.0
|
"time_hours": 13.0
|
||||||
@@ -47,6 +52,7 @@
|
|||||||
"name": "elwynn_adt_boundary",
|
"name": "elwynn_adt_boundary",
|
||||||
"coverage": ["adt_boundary"],
|
"coverage": ["adt_boundary"],
|
||||||
"camera": [17020.0, 105.0, 26590.0],
|
"camera": [17020.0, 105.0, 26590.0],
|
||||||
|
"reference_wow_camera": [-9523.334, 46.666, 105.0],
|
||||||
"target": [17066.666, 62.0, 26666.666],
|
"target": [17066.666, 62.0, 26666.666],
|
||||||
"player": [17060.0, 58.0, 26660.0],
|
"player": [17060.0, 58.0, 26660.0],
|
||||||
"time_hours": 13.0
|
"time_hours": 13.0
|
||||||
@@ -54,7 +60,8 @@
|
|||||||
{
|
{
|
||||||
"name": "goldshire_dense_m2",
|
"name": "goldshire_dense_m2",
|
||||||
"coverage": ["dense_m2"],
|
"coverage": ["dense_m2"],
|
||||||
"camera": [16835.0, 135.0, 26435.0],
|
"camera": [16956.666, 150.0, 26466.666],
|
||||||
|
"reference_wow_camera": [-9400.0, 110.0, 150.0],
|
||||||
"target": [17015.0, 62.0, 26525.0],
|
"target": [17015.0, 62.0, 26525.0],
|
||||||
"player": [17015.0, 58.0, 26525.0],
|
"player": [17015.0, 58.0, 26525.0],
|
||||||
"time_hours": 13.0
|
"time_hours": 13.0
|
||||||
@@ -62,7 +69,8 @@
|
|||||||
{
|
{
|
||||||
"name": "goldshire_inn_large_wmo",
|
"name": "goldshire_inn_large_wmo",
|
||||||
"coverage": ["large_wmo"],
|
"coverage": ["large_wmo"],
|
||||||
"camera": [16942.0, 82.0, 26503.0],
|
"camera": [17013.666, 72.0, 26451.666],
|
||||||
|
"reference_wow_camera": [-9385.0, 53.0, 72.0],
|
||||||
"target": [17042.27, 66.0, 26530.91],
|
"target": [17042.27, 66.0, 26530.91],
|
||||||
"player": [17042.27, 58.0, 26530.91],
|
"player": [17042.27, 58.0, 26530.91],
|
||||||
"time_hours": 13.0
|
"time_hours": 13.0
|
||||||
@@ -70,7 +78,8 @@
|
|||||||
{
|
{
|
||||||
"name": "elwynn_waterfall_liquid",
|
"name": "elwynn_waterfall_liquid",
|
||||||
"coverage": ["liquid"],
|
"coverage": ["liquid"],
|
||||||
"camera": [16445.0, 125.0, 26295.0],
|
"camera": [16481.666, 190.0, 26366.666],
|
||||||
|
"reference_wow_camera": [-9300.0, 585.0, 190.0],
|
||||||
"target": [16518.84, 68.0, 26427.27],
|
"target": [16518.84, 68.0, 26427.27],
|
||||||
"player": [16518.84, 55.0, 26427.27],
|
"player": [16518.84, 55.0, 26427.27],
|
||||||
"time_hours": 13.0
|
"time_hours": 13.0
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ func _initialize() -> void:
|
|||||||
_expect(int(manifest.get("schema_version", 0)) == 1, "manifest schema must be version 1", failures)
|
_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(String(manifest.get("profile", "")) == "Blizzlike335", "manifest profile must be Blizzlike335", failures)
|
||||||
_expect(ResourceLoader.exists(String(manifest.get("scene", ""))), "streaming scene must exist", failures)
|
_expect(ResourceLoader.exists(String(manifest.get("scene", ""))), "streaming scene must exist", failures)
|
||||||
|
var fidelity_viewport: Array = manifest.get("fidelity_comparison_viewport", [])
|
||||||
|
_expect(fidelity_viewport.size() == 2 and int(fidelity_viewport[0]) > 0 and int(fidelity_viewport[1]) > 0, "fidelity comparison viewport must contain positive width and height", failures)
|
||||||
|
|
||||||
var covered := {}
|
var covered := {}
|
||||||
var names := {}
|
var names := {}
|
||||||
@@ -42,6 +44,11 @@ func _initialize() -> void:
|
|||||||
var required := String(required_variant)
|
var required := String(required_variant)
|
||||||
_expect(covered.has(required), "missing checkpoint coverage: %s" % required, failures)
|
_expect(covered.has(required), "missing checkpoint coverage: %s" % required, failures)
|
||||||
|
|
||||||
|
var comparison_budgets: Dictionary = manifest.get("comparison_budgets", {})
|
||||||
|
for threshold_name in ["visual_mean_perceptual_error_max", "visual_changed_pixel_ratio_max", "visual_pixel_error_threshold"]:
|
||||||
|
var threshold := float(comparison_budgets.get(threshold_name, -1.0))
|
||||||
|
_expect(threshold >= 0.0 and threshold <= 1.0, "%s must be between 0 and 1" % threshold_name, failures)
|
||||||
|
|
||||||
var contract: Dictionary = manifest.get("cache_contract", {})
|
var contract: Dictionary = manifest.get("cache_contract", {})
|
||||||
var actual_versions := {
|
var actual_versions := {
|
||||||
"baked_terrain": STREAMING_LOADER.REQUIRED_BAKED_TILE_FORMAT_VERSION,
|
"baked_terrain": STREAMING_LOADER.REQUIRED_BAKED_TILE_FORMAT_VERSION,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Verifies observed build 12340 camera coordinates against the current renderer
|
||||||
|
## WoW X/Y/Z to Godot X/Y/Z convention. This diagnostic does not define the
|
||||||
|
## future M01 production CoordinateMapper contract.
|
||||||
|
|
||||||
|
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
|
||||||
|
const WOW_WORLD_CENTER := 17066.666
|
||||||
|
const MAXIMUM_POSITION_ERROR := 0.002
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var manifest := _load_manifest()
|
||||||
|
if manifest.is_empty():
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var failures: Array[String] = []
|
||||||
|
var calibrated_count := 0
|
||||||
|
var maximum_round_trip_error := 0.0
|
||||||
|
for checkpoint_variant in manifest.get("checkpoints", []):
|
||||||
|
if not (checkpoint_variant is Dictionary):
|
||||||
|
continue
|
||||||
|
var checkpoint: Dictionary = checkpoint_variant
|
||||||
|
var reference_wow_camera_variant = checkpoint.get("reference_wow_camera", null)
|
||||||
|
if not (reference_wow_camera_variant is Array):
|
||||||
|
continue
|
||||||
|
var reference_wow_camera: Array = reference_wow_camera_variant
|
||||||
|
var checkpoint_name := String(checkpoint.get("name", "checkpoint"))
|
||||||
|
if reference_wow_camera.size() != 3:
|
||||||
|
failures.append("%s reference_wow_camera must contain three coordinates" % checkpoint_name)
|
||||||
|
continue
|
||||||
|
var expected_godot_camera := _array_to_vector3(checkpoint.get("camera", []))
|
||||||
|
var reference_wow_position := _array_to_vector3(reference_wow_camera)
|
||||||
|
var mapped_godot_position := _wow_to_godot(reference_wow_position)
|
||||||
|
var round_trip_wow_position := _godot_to_wow(mapped_godot_position)
|
||||||
|
var mapping_error := mapped_godot_position.distance_to(expected_godot_camera)
|
||||||
|
var round_trip_error := round_trip_wow_position.distance_to(reference_wow_position)
|
||||||
|
maximum_round_trip_error = maxf(maximum_round_trip_error, maxf(mapping_error, round_trip_error))
|
||||||
|
if mapping_error > MAXIMUM_POSITION_ERROR:
|
||||||
|
failures.append("%s maps %.6f units away from manifest camera" % [checkpoint_name, mapping_error])
|
||||||
|
if round_trip_error > MAXIMUM_POSITION_ERROR:
|
||||||
|
failures.append("%s round-trip error is %.6f" % [checkpoint_name, round_trip_error])
|
||||||
|
calibrated_count += 1
|
||||||
|
|
||||||
|
if calibrated_count < 5:
|
||||||
|
failures.append("expected at least five build 12340 camera calibrations, found %d" % calibrated_count)
|
||||||
|
if not failures.is_empty():
|
||||||
|
for failure in failures:
|
||||||
|
push_error("RENDER_COORDINATE_CALIBRATION: %s" % failure)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("RENDER_COORDINATE_CALIBRATION PASS points=%d maximum_error=%.6f" % [calibrated_count, maximum_round_trip_error])
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _wow_to_godot(wow_position: Vector3) -> Vector3:
|
||||||
|
return Vector3(
|
||||||
|
WOW_WORLD_CENTER - wow_position.y,
|
||||||
|
wow_position.z,
|
||||||
|
WOW_WORLD_CENTER - wow_position.x
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _godot_to_wow(godot_position: Vector3) -> Vector3:
|
||||||
|
return Vector3(
|
||||||
|
WOW_WORLD_CENTER - godot_position.z,
|
||||||
|
WOW_WORLD_CENTER - godot_position.x,
|
||||||
|
godot_position.y
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _array_to_vector3(value_variant) -> Vector3:
|
||||||
|
if not (value_variant is Array) or value_variant.size() != 3:
|
||||||
|
return Vector3.ZERO
|
||||||
|
return Vector3(float(value_variant[0]), float(value_variant[1]), float(value_variant[2]))
|
||||||
|
|
||||||
|
|
||||||
|
func _load_manifest() -> Dictionary:
|
||||||
|
var manifest_file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
|
||||||
|
if manifest_file == null:
|
||||||
|
push_error("Cannot open renderer baseline manifest: %s" % MANIFEST_PATH)
|
||||||
|
return {}
|
||||||
|
var parsed = JSON.parse_string(manifest_file.get_as_text())
|
||||||
|
if not (parsed is Dictionary):
|
||||||
|
push_error("Renderer baseline manifest is not a JSON object")
|
||||||
|
return {}
|
||||||
|
return parsed
|
||||||
@@ -5,6 +5,10 @@ param(
|
|||||||
[string]$CacheState = "existing",
|
[string]$CacheState = "existing",
|
||||||
[double]$WaitSeconds = 8.0,
|
[double]$WaitSeconds = 8.0,
|
||||||
[double]$MeasureSeconds = 3.0,
|
[double]$MeasureSeconds = 3.0,
|
||||||
|
[string]$ReferenceCheckpointDirectory,
|
||||||
|
[string]$VisualComparisonReport = "user://render_baseline/visual_comparison.json",
|
||||||
|
[int]$VisualComparisonWidth = 2560,
|
||||||
|
[int]$VisualComparisonHeight = 1440,
|
||||||
[switch]$DryRun
|
[switch]$DryRun
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ try {
|
|||||||
Invoke-GodotStep "render-materials" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_materials.gd")
|
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 "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")
|
Invoke-GodotStep "baseline-manifest" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_baseline_manifest.gd")
|
||||||
|
Invoke-GodotStep "coordinate-calibration" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_coordinate_calibration.gd")
|
||||||
|
|
||||||
$captureArgs = @(
|
$captureArgs = @(
|
||||||
"--path", ".",
|
"--path", ".",
|
||||||
@@ -57,6 +62,33 @@ try {
|
|||||||
$captureArgs = @("--headless") + $captureArgs + @("--dry-run")
|
$captureArgs = @("--headless") + $captureArgs + @("--dry-run")
|
||||||
}
|
}
|
||||||
Invoke-GodotStep "capture" $captureArgs
|
Invoke-GodotStep "capture" $captureArgs
|
||||||
|
if ($ReferenceCheckpointDirectory) {
|
||||||
|
if ($DryRun) {
|
||||||
|
throw "Reference checkpoint comparison requires PNG capture; remove -DryRun"
|
||||||
|
}
|
||||||
|
$visualCandidateOutput = "$Output/visual_comparison_candidates"
|
||||||
|
$visualCaptureArgs = @(
|
||||||
|
"--path", ".",
|
||||||
|
"--script", "res://src/tools/capture_render_checkpoints.gd",
|
||||||
|
"--",
|
||||||
|
"--output", $visualCandidateOutput,
|
||||||
|
"--cache-state", $CacheState,
|
||||||
|
"--revision", $revision,
|
||||||
|
"--wait", $WaitSeconds.ToString([Globalization.CultureInfo]::InvariantCulture),
|
||||||
|
"--measure", $MeasureSeconds.ToString([Globalization.CultureInfo]::InvariantCulture),
|
||||||
|
"--viewport-width", $VisualComparisonWidth.ToString(),
|
||||||
|
"--viewport-height", $VisualComparisonHeight.ToString()
|
||||||
|
)
|
||||||
|
Invoke-GodotStep "visual-capture" $visualCaptureArgs
|
||||||
|
Invoke-GodotStep "visual-comparison" @(
|
||||||
|
"--headless", "--path", ".",
|
||||||
|
"--script", "res://src/tools/compare_render_checkpoints.gd",
|
||||||
|
"--",
|
||||||
|
"--reference", $ReferenceCheckpointDirectory,
|
||||||
|
"--candidate", $visualCandidateOutput,
|
||||||
|
"--output", $VisualComparisonReport
|
||||||
|
)
|
||||||
|
}
|
||||||
Write-Host "Renderer baseline completed. Report: $Output/report.json"
|
Write-Host "Renderer baseline completed. Report: $Output/report.json"
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|||||||
Reference in New Issue
Block a user