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:
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -224,6 +224,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
||||
- Original-client paired fidelity evidence incomplete.
|
||||
- Первый 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.
|
||||
- D3D12 descriptor and shutdown RID/resource issues remain.
|
||||
- M2/WMO/material/particle/ribbon/portal parity incomplete.
|
||||
- Public API is mostly exported configuration rather than stable contracts.
|
||||
@@ -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/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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user