Merge pull request 'test(M00): diagnose checkpoint camera occluders' (#5) from work/sindo-main-codex/m00-camera-occluders into master

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-07-11 23:04:13 +03:00
4 changed files with 267 additions and 0 deletions
@@ -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`
+21
View File
@@ -158,3 +158,24 @@ flowchart LR
Измеренный 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. Измеренный 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` остаётся строгим режимом для действительно неснятых точек. 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.
+2
View File
@@ -224,6 +224,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
- Original-client paired fidelity evidence incomplete. - Original-client paired fidelity evidence incomplete.
- Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC. - Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC.
- Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units. - Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units.
- Camera-occluder probe не нашёл camera containment в пяти точках; paired mismatch локализован прежде всего в manual look direction/target/FOV calibration, с явным ограничением по RID-only geometry.
- D3D12 descriptor and shutdown RID/resource issues remain. - D3D12 descriptor and shutdown RID/resource issues remain.
- M2/WMO/material/particle/ribbon/portal parity incomplete. - M2/WMO/material/particle/ribbon/portal parity incomplete.
- Public API is mostly exported configuration rather than stable contracts. - Public API is mostly exported configuration rather than stable contracts.
@@ -245,6 +246,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| `src/tools/capture_render_checkpoints.gd` | Deterministic no-roll checkpoint camera, performance and visual capture | | `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/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_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
+166
View File
@@ -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