Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42fdf40282 | |||
| f8538ba2cf | |||
| d233a41ce8 |
@@ -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`
|
||||
@@ -138,3 +138,23 @@ flowchart LR
|
||||
```
|
||||
|
||||
На пяти точках 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` остаётся строгим режимом для действительно неснятых точек.
|
||||
|
||||
@@ -223,6 +223,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
||||
- Direct camera path remains until M01/M03.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -243,6 +244,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
||||
| `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 |
|
||||
|
||||
## Related decisions and references
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user