From aea7787b9bba5ac8fd66cf242a3326d514dde8a2 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 11 Jul 2026 22:23:33 +0400 Subject: [PATCH] test(M00): diagnose checkpoint camera occluders Work-Package: M00-QAR-CAMERA-OCCLUDERS-001 Agent: sindo-main-codex Tests: five-point camera occluder probe, coordination and documentation gates Fidelity: no camera containment found; manual target and FOV mismatch recorded --- .../claims/M00-QAR-CAMERA-OCCLUDERS-001.md | 78 ++++++++ docs/RENDER_BASELINE.md | 21 +++ docs/modules/world-renderer.md | 2 + src/tools/probe_render_camera_occluders.gd | 166 ++++++++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 coordination/claims/M00-QAR-CAMERA-OCCLUDERS-001.md create mode 100644 src/tools/probe_render_camera_occluders.gd diff --git a/coordination/claims/M00-QAR-CAMERA-OCCLUDERS-001.md b/coordination/claims/M00-QAR-CAMERA-OCCLUDERS-001.md new file mode 100644 index 0000000..b5a66f3 --- /dev/null +++ b/coordination/claims/M00-QAR-CAMERA-OCCLUDERS-001.md @@ -0,0 +1,78 @@ +# M00-QAR-CAMERA-OCCLUDERS-001 — Camera occluder diagnostic + + + +## 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` diff --git a/docs/RENDER_BASELINE.md b/docs/RENDER_BASELINE.md index 394618b..04ead79 100644 --- a/docs/RENDER_BASELINE.md +++ b/docs/RENDER_BASELINE.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. diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 0e0b1d4..4c3224d 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -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 diff --git a/src/tools/probe_render_camera_occluders.gd b/src/tools/probe_render_camera_occluders.gd new file mode 100644 index 0000000..8261ac5 --- /dev/null +++ b/src/tools/probe_render_camera_occluders.gd @@ -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