diff --git a/RENDER.md b/RENDER.md index 023eead..d6f7522 100644 --- a/RENDER.md +++ b/RENDER.md @@ -950,6 +950,20 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - The ray height, 3x3 tile search and `2/5/10/20/40`-unit nearby fallback are preserved. No terrain geometry, cache, streaming or visual behavior changed. +## 2026-07-16 Terrain Chunk LOD Planner Extraction + +- `TerrainChunkLodPlanner` now owns the pure mapping from populated parsed ADT + chunk indices to desired terrain LOD 0/1/2. +- `TerrainChunkLodPolicy` snapshots the existing enabled flag, three raw chunk + radii and chunk size once per streaming-target application. +- Horizontal XZ distance to the chunk center, inclusive squared thresholds, + sparse-index omission and historical negative-radius squaring are unchanged. +- `StreamingWorldLoader` still owns tile/chunk state, queues, operation budgets, + creation/removal, Mesh/Node/RID finalization and tile-LOD fallback. +- Cache formats, quality presets and visible behavior are unchanged. The + scene-free contract is formula evidence only; asset-backed p95/p99 and visual + acceptance remain required. + ## 2026-07-16 World Environment Snapshot Facade - `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value, diff --git a/docs/modules/README.md b/docs/modules/README.md index a7d4d30..7f8b659 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -13,6 +13,7 @@ | Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) | | Terrain query | Implemented | [`terrain-query.md`](terrain-query.md) | | Terrain quality Mesh cache | Implemented extraction | [`terrain-quality-mesh-cache.md`](terrain-quality-mesh-cache.md) | +| Terrain chunk LOD planner | Implemented extraction | [`terrain-chunk-lod-planner.md`](terrain-chunk-lod-planner.md) | | Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) | | Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | diff --git a/docs/modules/terrain-chunk-lod-planner.md b/docs/modules/terrain-chunk-lod-planner.md new file mode 100644 index 0000000..b048c00 --- /dev/null +++ b/docs/modules/terrain-chunk-lod-planner.md @@ -0,0 +1,179 @@ +# Terrain Chunk LOD Planner + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-TERRAIN-LOD-PLANNER-001` | +| Owners | Pure desired chunk-LOD calculation only | +| Last verified | Worktree `work/sindo-main-codex/m03-terrain-chunk-lod-planner`, 2026-07-16 | +| Profiles/capabilities | Existing Performance/Balanced/High/Custom radii | + +## Purpose + +Calculate the desired terrain LOD for each populated parsed ADT chunk from one +typed Godot-space focus and one immutable renderer-policy snapshot. + +## Non-goals + +- Own tile/chunk state, queues, budgets, tasks, Mesh, Node, material or RID. +- Select tile LOD, create/remove geometry or apply a completed plan. +- Normalize custom radii, change profile defaults or claim visual parity. +- Parse ADT data or convert between WoW and Godot coordinate spaces. + +## Context and boundaries + +```mermaid +flowchart LR + Focus[GodotWorldPosition] --> Planner[TerrainChunkLodPlanner] + Chunks[Parsed ADT chunk records] --> Planner + Config[Streamer LOD configuration] --> Policy[TerrainChunkLodPolicy] + Policy --> Planner + Planner --> Desired[chunk index to LOD 0/1/2] + Desired --> Loader[StreamingWorldLoader state adapter] + Loader --> Queue[Existing create/remove queues and budgets] +``` + +Allowed dependencies are the typed Godot position, immutable policy and +call-local Array/Dictionary/Vector3 values. SceneTree, RenderingServer, gameplay, +network, files, tasks and persistent caches are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `TerrainChunkLodPolicy.new(...)` | Immutable value | Snapshot enabled flag, three raw radii and chunk size | Caller-owned; one planning refresh | Values are preserved without normalization | +| `TerrainChunkLodPlanner.plan(chunks, focus_position, policy)` | Pure query | Map populated source indices to LOD 0/1/2 | Synchronous; result caller-owned | Invalid/null typed inputs or disabled policy return empty | + +The planner validates scripts by preloaded identity so it works from a cold +Godot global-class cache. + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Parsed chunk array with `origin: Vector3` | ADT load result | Planner | Loader-owned; read only | One call | +| Input | `GodotWorldPosition` | Streamer focus adapter | Planner | Immutable caller reference | One call | +| Input | `TerrainChunkLodPolicy` | Streamer exported configuration | Planner | Immutable caller reference | One call | +| Output | `{source_chunk_index: 0/1/2}` | Planner | Loader desired state | Fresh caller dictionary | One call | + +The Y coordinate is intentionally ignored. Empty chunk records and chunks beyond +the third radius are absent from the result. + +## Data flow + +```mermaid +flowchart TD + Start[plan] --> Valid{Typed inputs and enabled?} + Valid -->|no| Empty[Return empty dictionary] + Valid -->|yes| Radii[Square raw radius times chunk size] + Radii --> Iterate[Visit source chunk indices in order] + Iterate --> Populated{Record populated?} + Populated -->|no| Iterate + Populated -->|yes| Center[origin XZ plus half chunk] + Center --> Distance[Squared horizontal distance] + Distance --> Threshold{First inclusive threshold} + Threshold -->|LOD0/1/2| Record[Record source index] + Threshold -->|outside| Iterate + Record --> Iterate + Iterate --> Done[Return desired mapping] +``` + +## Lifecycle/state + +The planner is stateless. A policy is constructed for one refresh, read during +synchronous planning and released. The returned dictionary is then owned by the +loader state. There is no cancellation, recovery state or retained resource. + +## Main sequence + +```mermaid +sequenceDiagram + participant Stream as StreamingWorldLoader + participant Policy as TerrainChunkLodPolicy + participant Planner as TerrainChunkLodPlanner + Stream->>Policy: snapshot enabled/radii/chunk size + Stream->>Planner: plan(chunks, typed focus, policy) + Planner-->>Stream: fresh desired LOD dictionary + Stream->>Stream: existing tile state, queue and budget application +``` + +## Ownership, threading and resources + +- Planner and policy own no engine resources, nodes, RIDs, tasks or caches. +- The loader retains parsed chunks, desired state and every application side effect. +- Current calls run synchronously on the renderer main thread; the pure inputs + also make isolated contract testing possible without a world scene. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Null/wrong focus or policy script | Identity guard | Empty plan | Contract verifier | Supply typed values next refresh | +| Disabled chunk LOD | Policy flag | Empty plan; existing tile-LOD fallback applies | Existing renderer state | Enable profile option | +| Empty chunk record | Record guard | Preserve sparse source index; omit record | None | Not an error | +| Custom negative radius | No normalization | Existing squared-radius behavior is retained | Regression fixture | Change caller configuration if undesired | + +There is no asynchronous work or cancellation token. + +## Configuration and capabilities + +The policy snapshots `use_chunk_terrain_lods`, `lod0_radius_chunks`, +`lod1_radius_chunks`, `lod2_radius_chunks` and the existing `CHUNK_SIZE`. Inclusive +squared thresholds and raw custom values deliberately preserve prior behavior. + +## Persistence, cache and migration + +No serialization or cache format changes. Existing baked terrain, control-splat +and splat cache versions remain unchanged and require no rebuild. + +## Diagnostics and observability + +The service emits no logs. The contract verifier reports semantic cases and a +bounded synthetic timing. Asset-backed p95/p99 remains milestone acceptance work. + +## Verification + +- `verify_terrain_chunk_lod_planner.gd`: center and vertical invariance, + inclusive thresholds, sparse/outside chunks, disabled/custom-negative policy, + loader delegation and 250-by-256 bounded planning. +- Renderer cache, facade, internal-access, baseline, scheduler, target-planner, + focus, ground-query and coordinate-boundary regressions remain required. +- Fidelity evidence is formula preservation only; no new build-12340 visual + equivalence claim is made. + +## Extension points + +- Add measured direction/velocity only through an explicit policy/contract change. +- Keep queue prioritization and LOD application as separately bounded services. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Pure chunk desired-LOD calculation | Implemented extraction | Asset-free semantic/source-boundary contract | Asset-backed p95/p99 pending | +| Queue/state/finalization ownership | Remains in loader | Source-boundary regression | Further terrain decomposition required | + +## Known gaps and risks + +- Raw negative and non-monotonic custom radii retain historical squared/ordered + semantics, even though such settings may be surprising. +- The planner is linear in parsed chunk count and allocates one result dictionary. +- Terrain build, quality upgrades and geometry lifecycle remain monolithic. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/terrain/terrain_chunk_lod_policy.gd` | Immutable configuration snapshot | +| `src/render/terrain/terrain_chunk_lod_planner.gd` | Pure horizontal-distance plan | +| `src/scenes/streaming/streaming_world_loader.gd` | Composition and plan application adapter | +| `src/tools/verify_terrain_chunk_lod_planner.gd` | Contract, dependency and timing regression | + +## Related decisions and references + +- [`world-renderer.md`](world-renderer.md) +- [`terrain-quality-mesh-cache.md`](terrain-quality-mesh-cache.md) +- [`../../RENDER.md`](../../RENDER.md) +- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md) diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index f63fc0e..5355ac5 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -5,9 +5,9 @@ | Field | Value | |---|---| | Status | Partial | -| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; `M03-RND-TERRAIN-CACHE-SERVICE-001` | +| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; `M03-RND-TERRAIN-CACHE-SERVICE-001`; `M03-RND-TERRAIN-LOD-PLANNER-001` | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-terrain-quality-cache`, 2026-07-16 | +| Last verified | Worktree `work/sindo-main-codex/m03-terrain-chunk-lod-planner`, 2026-07-16 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -42,6 +42,8 @@ flowchart LR Cache[Baked terrain/M2/WMO caches] --> Loader Loader --> TerrainCache[TerrainQualityMeshCache] TerrainCache --> Loader + Loader --> ChunkLod[TerrainChunkLodPlanner] + ChunkLod --> Loader Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -106,6 +108,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `runtime_stats_enabled` | Exported property | Enables periodic performance snapshot | Runtime mutable | Logging overhead only | | `hitch_profiler_enabled` | Exported property | Enables named hitch sections | Runtime mutable | Logging overhead only | | `TerrainQualityMeshCache.store/restore/clear` | Internal terrain service | Owns admitted full-quality revisit Mesh references and LRU | Renderer thread/map session | Rejected/missed entry leaves loader fallback unchanged | +| `TerrainChunkLodPlanner.plan` | Internal pure terrain query | Maps populated parsed chunk indices to desired LOD 0/1/2 | Synchronous per target refresh | Invalid/disabled input returns empty for existing tile-LOD fallback | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -128,6 +131,7 @@ loader configuration remains transitional composition data, not a caller API. | Output | `StreamingTargetPlan` | `StreamingTargetPlanner` | Streamer queue/state apply | Immutable ephemeral value | One refresh | | Internal control | Per-frame lane limits and boolean permits | Streamer configuration / `RenderBudgetScheduler` | Ordered streamer drains | Scheduler-owned counters | Main thread/frame | | Internal cache | Full-quality terrain Mesh/source | Loader terrain upgrade / `TerrainQualityMeshCache` | Revisited tile state | Cache-retained Mesh reference | Map session until eviction/reset | +| Internal plan | Parsed chunks, typed focus and immutable chunk-LOD policy | Loader / `TerrainChunkLodPlanner` | Loader desired state | Fresh dictionary; no retained resources | One target refresh | | Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame | | Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach | | Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand | @@ -167,6 +171,8 @@ flowchart TD Parse --> R[Result queues] R --> TerrainCache[TerrainQualityMeshCache store] TerrainCache --> Revisit[Revisited tile state restore] + R --> ChunkLod[TerrainChunkLodPlanner] + ChunkLod --> DesiredChunkLod[Desired chunk LOD state] R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] @@ -272,6 +278,9 @@ sequenceDiagram - `TerrainQualityMeshCache` owns only full-quality revisit Mesh references and LRU keys. The loader retains terrain tasks/results, tile state, source choice, cache versions and every material/Node/RID finalization side effect. +- `TerrainChunkLodPlanner` is stateless and owns only call-local horizontal + distance calculations. The loader retains parsed chunks, desired state, + queues, budgets and all Mesh/Node/RID application side effects. - Rendered-ground query results and diagnostic snapshots are caller-owned values; the facade never returns Mesh, Node, tile-state or queue references. - Loaded-mesh ground sampling is renderer diagnostics, not authoritative gameplay @@ -348,6 +357,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - Unit/contract tests: streaming-focus contract/wiring, material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan. - Pure planner contract: center/corner/catalog/clamp/editor cases plus bounded High-like iteration timing without loading a world scene. +- Terrain chunk LOD contract: horizontal chunk-center distance, inclusive + thresholds, sparse indices, disabled/raw-custom policy, loader delegation and + bounded 250-by-256 planning without loading a world scene. - Budget scheduler contract: exact lane exhaustion, shared chunk removal/create priority, independent lanes, frame reset, invalid limits, terminal cancellation, dependency boundaries and bounded permit timing without loading a world scene. @@ -387,6 +399,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | Environment snapshot input | Implemented boundary / Partial fidelity | Immutable time contract, facade/controller delegation and checkpoint migration | Weather/indoor state and paired zone-transition fidelity remain | | Entity presentation commands | Implemented boundary / Prototype visuals | Versioned typed snapshot plus present/remove lifecycle and runtime service composition | Display/equipment/animation/effects and async scale path remain | | Terrain quality Mesh cache service | Implemented extraction | Scene-free admission/LRU/clear contract and loader delegation | Remaining terrain build/state/finalization is monolithic | +| Terrain chunk LOD planner | Implemented extraction | Scene-free formula/dependency/timing contract and loader delegation | Queue/state/finalization and asset-backed p95/p99 remain pending | | Pure streaming target planner | Implemented | `M03-RND-STREAMING-PLANNER-001` asset-free contract/performance regression | Queue scheduling and loaded-tile LOD application remain in streamer | | Bounded render budget scheduler | Implemented | `M03-RND-SCHEDULER-001` asset-free permit/cancellation/performance regression | Queue storage, worker concurrency and operation execution remain in streamer/services | | Gameplay/editor/tool internal-access boundary | Implemented | Access gate scans gameplay, EditorPlugin packages and terrain probe against actual private streamer declarations | Add future renderer diagnostic tools explicitly to the protected list | diff --git a/src/render/terrain/terrain_chunk_lod_planner.gd b/src/render/terrain/terrain_chunk_lod_planner.gd new file mode 100644 index 0000000..c632d35 --- /dev/null +++ b/src/render/terrain/terrain_chunk_lod_planner.gd @@ -0,0 +1,53 @@ +class_name TerrainChunkLodPlanner +extends RefCounted + +## Pure linear desired-state planner for parsed ADT chunk records. + +const POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd") +const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") + + +## Maps source chunk indices to LOD 0/1/2 using horizontal distance from the +## typed focus to each chunk center. Empty/out-of-range chunks remain absent. +func plan( + chunks: Array, + focus_position: RefCounted, + policy: RefCounted +) -> Dictionary: + if ( + focus_position == null + or focus_position.get_script() != GODOT_WORLD_POSITION_SCRIPT + or policy == null + or policy.get_script() != POLICY_SCRIPT + or not bool(policy.get("is_enabled")) + ): + return {} + + var chunk_size_units := float(policy.get("chunk_size_units")) + var lod0_radius_squared := (float(policy.get("lod0_radius_chunks")) * chunk_size_units) ** 2 + var lod1_radius_squared := (float(policy.get("lod1_radius_chunks")) * chunk_size_units) ** 2 + var lod2_radius_squared := (float(policy.get("lod2_radius_chunks")) * chunk_size_units) ** 2 + var focus_x_units := float(focus_position.get("x_units")) + var focus_z_units := float(focus_position.get("z_units")) + var desired_lods: Dictionary = {} + + for chunk_id in range(chunks.size()): + var chunk: Dictionary = chunks[chunk_id] + if chunk.is_empty(): + continue + var origin: Vector3 = chunk.get("origin", Vector3.ZERO) + var center_x_units := origin.x + chunk_size_units * 0.5 + var center_z_units := origin.z + chunk_size_units * 0.5 + var delta_x_units := center_x_units - focus_x_units + var delta_z_units := center_z_units - focus_z_units + var distance_squared_units := ( + delta_x_units * delta_x_units + delta_z_units * delta_z_units + ) + if distance_squared_units <= lod0_radius_squared: + desired_lods[chunk_id] = 0 + elif distance_squared_units <= lod1_radius_squared: + desired_lods[chunk_id] = 1 + elif distance_squared_units <= lod2_radius_squared: + desired_lods[chunk_id] = 2 + + return desired_lods diff --git a/src/render/terrain/terrain_chunk_lod_planner.gd.uid b/src/render/terrain/terrain_chunk_lod_planner.gd.uid new file mode 100644 index 0000000..3af6802 --- /dev/null +++ b/src/render/terrain/terrain_chunk_lod_planner.gd.uid @@ -0,0 +1 @@ +uid://x8simqkfctqw diff --git a/src/render/terrain/terrain_chunk_lod_policy.gd b/src/render/terrain/terrain_chunk_lod_policy.gd new file mode 100644 index 0000000..66e566c --- /dev/null +++ b/src/render/terrain/terrain_chunk_lod_policy.gd @@ -0,0 +1,45 @@ +class_name TerrainChunkLodPolicy +extends RefCounted + +## Immutable snapshot of the streamer's chunk-terrain LOD configuration. + +var is_enabled: bool: + get: + return _is_enabled + +var lod0_radius_chunks: int: + get: + return _lod0_radius_chunks + +var lod1_radius_chunks: int: + get: + return _lod1_radius_chunks + +var lod2_radius_chunks: int: + get: + return _lod2_radius_chunks + +var chunk_size_units: float: + get: + return _chunk_size_units + +var _is_enabled: bool +var _lod0_radius_chunks: int +var _lod1_radius_chunks: int +var _lod2_radius_chunks: int +var _chunk_size_units: float + + +## Captures one renderer configuration snapshot for a deterministic plan. +func _init( + is_enabled_value: bool, + lod0_radius_chunks_value: int, + lod1_radius_chunks_value: int, + lod2_radius_chunks_value: int, + chunk_size_units_value: float +) -> void: + _is_enabled = is_enabled_value + _lod0_radius_chunks = lod0_radius_chunks_value + _lod1_radius_chunks = lod1_radius_chunks_value + _lod2_radius_chunks = lod2_radius_chunks_value + _chunk_size_units = chunk_size_units_value diff --git a/src/render/terrain/terrain_chunk_lod_policy.gd.uid b/src/render/terrain/terrain_chunk_lod_policy.gd.uid new file mode 100644 index 0000000..0b707f4 --- /dev/null +++ b/src/render/terrain/terrain_chunk_lod_policy.gd.uid @@ -0,0 +1 @@ +uid://dmwi83dp52pa8 diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index afd234b..b433ab9 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -19,6 +19,8 @@ const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_ti const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd") const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd") const TERRAIN_QUALITY_MESH_CACHE_SCRIPT := preload("res://src/render/terrain/terrain_quality_mesh_cache.gd") +const TERRAIN_CHUNK_LOD_POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd") +const TERRAIN_CHUNK_LOD_PLANNER_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_planner.gd") const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd") const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd") const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd") @@ -181,6 +183,7 @@ const QUALITY_HIGH := "High" var _builder var _streaming_target_planner := STREAMING_TARGET_PLANNER_SCRIPT.new() +var _terrain_chunk_lod_planner := TERRAIN_CHUNK_LOD_PLANNER_SCRIPT.new() var _map_dir := "" var _abs_map_dir := "" var _wdt_info: Dictionary = {} @@ -797,6 +800,8 @@ func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void: func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionary, focus_pos: Vector3) -> void: _last_focus_pos = focus_pos + var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z) + var terrain_chunk_lod_policy = _create_terrain_chunk_lod_policy() var filtered_load_queue: Array = [] for request in _tile_load_queue: @@ -831,7 +836,7 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar state["wanted"] = wanted state["retained"] = retained if wanted: - state["desired_lods"] = _compute_desired_lods(state, focus_pos) + state["desired_lods"] = _compute_desired_lods(state, typed_focus_position, terrain_chunk_lod_policy) state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, focus_pos) state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) elif retained: @@ -2492,7 +2497,16 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void: } # Use _last_focus_pos — already set correctly for both editor and game. - state["desired_lods"] = _compute_desired_lods(state, _last_focus_pos) + var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new( + _last_focus_pos.x, + _last_focus_pos.y, + _last_focus_pos.z + ) + state["desired_lods"] = _compute_desired_lods( + state, + typed_focus_position, + _create_terrain_chunk_lod_policy() + ) state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, _last_focus_pos) state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos) _tile_states[key] = state @@ -3045,33 +3059,23 @@ func _reset_terrain_root_children() -> void: child.free() -func _compute_desired_lods(state: Dictionary, cam_pos: Vector3) -> Dictionary: - if not use_chunk_terrain_lods: - return {} - - var desired := {} +func _compute_desired_lods( + state: Dictionary, + typed_focus_position: RefCounted, + terrain_chunk_lod_policy: RefCounted +) -> Dictionary: var chunks: Array = state["data"].get("chunks", []) + return _terrain_chunk_lod_planner.plan(chunks, typed_focus_position, terrain_chunk_lod_policy) - var lod0_sq: float = (lod0_radius_chunks * CHUNK_SIZE) ** 2 - var lod1_sq: float = (lod1_radius_chunks * CHUNK_SIZE) ** 2 - var lod2_sq: float = (lod2_radius_chunks * CHUNK_SIZE) ** 2 - for chunk_id in range(chunks.size()): - var chunk: Dictionary = chunks[chunk_id] - if chunk.is_empty(): - continue - - var origin: Vector3 = chunk.get("origin", Vector3.ZERO) - var center := origin + Vector3(CHUNK_SIZE * 0.5, 0.0, CHUNK_SIZE * 0.5) - var dx := center.x - cam_pos.x - var dz := center.z - cam_pos.z - var dist_sq := dx * dx + dz * dz - - if dist_sq <= lod0_sq: desired[chunk_id] = 0 - elif dist_sq <= lod1_sq: desired[chunk_id] = 1 - elif dist_sq <= lod2_sq: desired[chunk_id] = 2 - - return desired +func _create_terrain_chunk_lod_policy() -> RefCounted: + return TERRAIN_CHUNK_LOD_POLICY_SCRIPT.new( + use_chunk_terrain_lods, + lod0_radius_chunks, + lod1_radius_chunks, + lod2_radius_chunks, + CHUNK_SIZE + ) func _runtime_load_tile_radius() -> int: diff --git a/src/tools/verify_terrain_chunk_lod_planner.gd b/src/tools/verify_terrain_chunk_lod_planner.gd new file mode 100644 index 0000000..327430d --- /dev/null +++ b/src/tools/verify_terrain_chunk_lod_planner.gd @@ -0,0 +1,96 @@ +extends SceneTree + +## Asset-free formula, dependency and bounded timing regression for chunk LOD. + +const PLANNER_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_planner.gd") +const POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd") +const POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_formula(failures) + _verify_disabled_and_custom_policy(failures) + _verify_loader_boundary(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("TERRAIN_CHUNK_LOD_PLANNER: %s" % failure) + quit(1) + return + print("TERRAIN_CHUNK_LOD_PLANNER PASS cases=7 iterations=250 elapsed_ms=%.3f" % elapsed_milliseconds) + quit(0) + + +func _verify_formula(failures: Array[String]) -> void: + var planner: RefCounted = PLANNER_SCRIPT.new() + var policy: RefCounted = POLICY_SCRIPT.new(true, 1, 2, 3, 10.0) + var focus: RefCounted = POSITION_SCRIPT.new(0.0, 9999.0, 0.0) + var chunks: Array = [ + {"origin": Vector3(-5.0, 2000.0, -5.0)}, + {"origin": Vector3(5.0, -3000.0, -5.0)}, + {"origin": Vector3(5.001, 0.0, -5.0)}, + {"origin": Vector3(15.0, 0.0, -5.0)}, + {"origin": Vector3(20.0, 0.0, -5.0)}, + {"origin": Vector3(25.0, 0.0, -5.0)}, + {"origin": Vector3(25.001, 0.0, -5.0)}, + {}, + ] + var desired: Dictionary = planner.call("plan", chunks, focus, policy) + _expect_equal(int(desired.get(0, -1)), 0, "center ignores vertical distance", failures) + _expect_equal(int(desired.get(1, -1)), 0, "lod0 inclusive boundary", failures) + _expect_equal(int(desired.get(2, -1)), 1, "just outside lod0", failures) + _expect_equal(int(desired.get(3, -1)), 1, "lod1 inclusive boundary", failures) + _expect_equal(int(desired.get(4, -1)), 2, "between lod1 and lod2", failures) + _expect_equal(int(desired.get(5, -1)), 2, "lod2 inclusive boundary", failures) + _expect_true(not desired.has(6) and not desired.has(7), "outside and sparse chunks absent", failures) + + +func _verify_disabled_and_custom_policy(failures: Array[String]) -> void: + var planner: RefCounted = PLANNER_SCRIPT.new() + var focus: RefCounted = POSITION_SCRIPT.new(0.0, 0.0, 0.0) + var chunks: Array = [{"origin": Vector3(-5.0, 0.0, -5.0)}] + _expect_true((planner.call("plan", chunks, focus, POLICY_SCRIPT.new(false, 1, 2, 3, 10.0)) as Dictionary).is_empty(), "disabled policy empty", failures) + var negative_policy: RefCounted = POLICY_SCRIPT.new(true, -1, -2, -3, 10.0) + _expect_equal(int((planner.call("plan", chunks, focus, negative_policy) as Dictionary).get(0, -1)), 0, "negative radius keeps squared semantics", failures) + + +func _verify_loader_boundary(failures: Array[String]) -> void: + var source := _read_text(LOADER_PATH, failures) + _expect_true(source.contains("TERRAIN_CHUNK_LOD_PLANNER_SCRIPT.new()"), "loader composes planner", failures) + _expect_true(source.contains("_terrain_chunk_lod_planner.plan(chunks, typed_focus_position, terrain_chunk_lod_policy)"), "loader delegates plan", failures) + _expect_true(not source.contains("dist_sq <= lod0_sq"), "loader omits chunk LOD formula", failures) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var planner: RefCounted = PLANNER_SCRIPT.new() + var policy: RefCounted = POLICY_SCRIPT.new(true, 10, 20, 40, 33.333333) + var focus: RefCounted = POSITION_SCRIPT.new(1000.0, 0.0, 1000.0) + var chunks: Array = [] + for chunk_index in range(256): + chunks.append({"origin": Vector3(float(chunk_index % 16) * 33.333333, 0.0, float(chunk_index / 16) * 33.333333)}) + var started_microseconds := Time.get_ticks_usec() + for _iteration in range(250): + planner.call("plan", chunks, focus, policy) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "250 plans remain bounded", failures) + return elapsed_milliseconds + + +func _read_text(path: String, failures: Array[String]) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("cannot open %s" % path) + return "" + return file.get_as_text() + + +func _expect_equal(actual_value: int, expected_value: int, label: String, failures: Array[String]) -> void: + if actual_value != expected_value: + failures.append("%s expected %d, got %d" % [label, expected_value, actual_value]) + + +func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void: + if not actual_value: + failures.append("%s expected true" % label) diff --git a/src/tools/verify_terrain_chunk_lod_planner.gd.uid b/src/tools/verify_terrain_chunk_lod_planner.gd.uid new file mode 100644 index 0000000..2cff25b --- /dev/null +++ b/src/tools/verify_terrain_chunk_lod_planner.gd.uid @@ -0,0 +1 @@ +uid://2oljbpoxndx3