From f800e11cd2f74e3012d6e397f7681e9bfe919f76 Mon Sep 17 00:00:00 2001 From: sindoring Date: Thu, 16 Jul 2026 09:52:01 +0400 Subject: [PATCH 1/2] refactor(M03): extract terrain chunk queue planner --- RENDER.md | 13 ++ docs/modules/README.md | 1 + .../terrain-chunk-geometry-queue-planner.md | 195 ++++++++++++++++++ docs/modules/world-renderer.md | 17 +- .../terrain_chunk_geometry_queue_planner.gd | 102 +++++++++ ...errain_chunk_geometry_queue_planner.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 65 ++---- ...fy_terrain_chunk_geometry_queue_planner.gd | 169 +++++++++++++++ ...errain_chunk_geometry_queue_planner.gd.uid | 1 + 9 files changed, 515 insertions(+), 49 deletions(-) create mode 100644 docs/modules/terrain-chunk-geometry-queue-planner.md create mode 100644 src/render/terrain/terrain_chunk_geometry_queue_planner.gd create mode 100644 src/render/terrain/terrain_chunk_geometry_queue_planner.gd.uid create mode 100644 src/tools/verify_terrain_chunk_geometry_queue_planner.gd create mode 100644 src/tools/verify_terrain_chunk_geometry_queue_planner.gd.uid diff --git a/RENDER.md b/RENDER.md index d6f7522..722ac41 100644 --- a/RENDER.md +++ b/RENDER.md @@ -964,6 +964,19 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' scene-free contract is formula evidence only; asset-backed p95/p99 and visual acceptance remain required. +## 2026-07-16 Terrain Chunk Geometry Queue Planner Extraction + +- `TerrainChunkGeometryQueuePlanner` now compares current and desired chunk state + and returns fresh chunk create/remove request arrays. +- LOD mismatches and absent chunks retain the existing request shape; creates + remain sorted by horizontal distance from chunk origin to streaming focus. +- Desired tile LOD still removes every current chunk, while sparse parsed chunk + records retain the existing tile-origin distance fallback. +- `StreamingWorldLoader` still owns queue storage/drains, removal-first execution, + the shared `chunk_geometry` budget lane and every Mesh/Node/RID side effect. +- Cache formats, quality profiles and visual rules are unchanged. 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 7f8b659..8ca2f83 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -14,6 +14,7 @@ | 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) | +| Terrain chunk geometry queue planner | Implemented extraction | [`terrain-chunk-geometry-queue-planner.md`](terrain-chunk-geometry-queue-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-geometry-queue-planner.md b/docs/modules/terrain-chunk-geometry-queue-planner.md new file mode 100644 index 0000000..2afe02c --- /dev/null +++ b/docs/modules/terrain-chunk-geometry-queue-planner.md @@ -0,0 +1,195 @@ +# Terrain Chunk Geometry Queue Planner + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-TERRAIN-CHUNK-QUEUE-001` | +| Owners | Pure chunk geometry create/remove request calculation | +| Last verified | Worktree `work/sindo-main-codex/m03-terrain-chunk-queue`, 2026-07-16 | +| Profiles/capabilities | Existing chunk/tile LOD desired state; all profiles | + +## Purpose + +Compare loader-owned current chunk geometry with desired chunk/tile LOD state +and produce fresh removal and nearest-first creation request arrays. + +## Non-goals + +- Retain, drain or bound queues, or own per-frame operation permits. +- Create/remove Mesh, Node, material or RID resources. +- Calculate desired chunk/tile LOD, parse ADT data or schedule worker tasks. +- Change cache versions, renderer profiles or visible fidelity rules. +- Provide a generic queue/operation framework. + +## Context and boundaries + +```mermaid +flowchart LR + Current[Loader-owned tile/chunk state] --> Planner[TerrainChunkGeometryQueuePlanner] + Desired[Desired chunk and tile LOD state] --> Planner + Focus[GodotWorldPosition] --> Planner + Planner --> Remove[Stable-order remove requests] + Planner --> Create[Nearest-first create requests] + Remove --> Loader[StreamingWorldLoader queues] + Create --> Loader + Loader --> Budget[RenderBudgetScheduler permit] + Budget --> GPU[Existing Mesh/Node/RID side effects] +``` + +Allowed dependencies are typed Godot position and call-local scalar/container +values. SceneTree, RenderingServer, ResourceLoader, worker APIs, gameplay, +network, editor UI, files and persistent caches are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `TerrainChunkGeometryQueuePlanner.plan(tile_states, focus_position)` | Pure query | Return fresh `create_requests` and `remove_requests` arrays | Synchronous; caller owns result | Null/wrong focus returns two empty arrays | + +Script-identity validation uses a preload and therefore works from a cold Godot +global-class cache. + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Tile-state read model with current chunks and desired LODs | Streamer | Planner | Loader-owned; read only | One rebuild | +| Input | Desired tile LOD | Tile LOD calculation | Planner | Scalar in loader state | One rebuild | +| Input | Parsed chunk origins and tile fallback origin | ADT result/loader | Planner | Loader-owned; read only | Map session | +| Input | `GodotWorldPosition` | Streamer focus adapter | Planner | Immutable caller reference | One rebuild | +| Output | Remove `{tile, chunk}` records | Planner | Loader removal queue | Fresh caller array/dictionaries | Until drained/rebuilt | +| Output | Create `{tile, chunk, lod, dist_sq}` records | Planner | Loader creation queue | Fresh nearest-first array/dictionaries | Until drained/rebuilt | + +Distance is horizontal XZ squared distance from the chunk origin, not its center. +Y is intentionally ignored. A missing/sparse parsed origin falls back to the +tile state's existing origin exactly as before extraction. + +## Data flow + +```mermaid +flowchart TD + Start[plan] --> FocusValid{Typed focus?} + FocusValid -->|no| Empty[Return two empty arrays] + FocusValid -->|yes| Tile[Iterate tile state insertion order] + Tile --> TileLod{Desired tile LOD active?} + TileLod -->|yes| RemoveAll[Append every current chunk removal] + TileLod -->|no| Compare[Compare current and desired chunks] + Compare --> RemoveMissing[Append current chunks absent from desired] + Compare --> CreateMismatch[Append LOD-mismatched creates] + Compare --> CreateMissing[Append absent desired creates] + CreateMismatch --> Origin[Parsed chunk origin or tile fallback] + CreateMissing --> Origin + Origin --> Distance[Attach squared XZ distance] + RemoveAll --> Next[Next tile] + RemoveMissing --> Next + Distance --> Next + Next --> Sort[Sort creates nearest-first] + Sort --> Result[Return fresh arrays] +``` + +## Lifecycle/state + +The planner is stateless. Each loader queue rebuild supplies current read models +and receives new arrays. The loader adopts and drains those arrays until another +rebuild replaces them. Planner destruction requires no cleanup or cancellation. + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant Planner as TerrainChunkGeometryQueuePlanner + participant Budget as RenderBudgetScheduler + participant Render as Existing terrain finalization + Loader->>Planner: plan(tile states, typed focus) + Planner-->>Loader: fresh create/remove arrays + Loader->>Budget: request shared chunk_geometry permit + Budget-->>Loader: permit + Loader->>Render: pop_back removal first + Loader->>Budget: request shared chunk_geometry permit + Budget-->>Loader: permit + Loader->>Render: pop_front nearest creation +``` + +## Ownership, threading and resources + +- The planner owns only call-local arrays and dictionaries until return. +- The loader owns adopted queue storage, drain ordering, tile/chunk state and all + Mesh/Node/material/RID side effects. +- Calls and drains currently run on the renderer main thread. The planner has no + lock, task, retained resource or concurrent mutation. +- Removal and creation continue to share the existing `chunk_geometry` budget lane. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Null/wrong focus script | Identity guard | Return empty request arrays | Contract verifier | Supply typed focus and rebuild | +| Sparse/out-of-range chunk data | Origin resolver | Use tile fallback origin | Synthetic fixture | Normal ADT completion restores precise origin | +| Tile LOD becomes desired | Desired tile LOD check | Remove every current chunk; no chunk creates | Queue metrics | Existing tile-LOD create path takes over | +| Queue rebuild before drain | Loader lifecycle | Replace old desired-operation arrays | Existing queue metrics | Latest state wins | +| Shutdown/map reset | Loader lifecycle | Loader clears queues; planner has nothing to cancel | Shutdown verifier | New session replans | + +## Configuration and capabilities + +The planner introduces no settings. It consumes already-calculated desired LOD +state. Operation limits, profile defaults and removal-first drain order remain +loader/scheduler configuration. + +## Persistence, cache and migration + +No serialization, cache entry or format changes. Baked terrain, control-splat +and splat caches require no rebuild or migration. + +## Diagnostics and observability + +The planner emits no logs. Existing queue metrics observe the adopted arrays. +The contract verifier reports semantic cases and bounded multi-tile timing. +Asset-backed p95/p99 remains milestone acceptance work. + +## Verification + +- `verify_terrain_chunk_geometry_queue_planner.gd`: missing/mismatched/unchanged + chunks, tile-LOD removal, sparse origin fallback, XZ-only distance, stable + removals, nearest creates, invalid focus, loader boundary and bounded timing. +- Existing LOD planner, terrain cache, facade, internal-access, manifest, + shutdown, scheduler, streaming and coordinate regressions remain required. +- Fidelity evidence is exact branch/request/order preservation only. No new + build-12340 visual parity claim is made. + +## Extension points + +- Extract tile-LOD operation planning as its own bounded contract if justified. +- Add explicit typed tile-state adapters only when more than one consumer exists. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Pure chunk create/remove planning | Implemented extraction | Asset-free semantic/source/timing verifier | Asset-backed p95/p99 pending | +| Queue execution/resource ownership | Remains in loader | Drain source boundary and scheduler regression | Further terrain decomposition required | + +## Known gaps and risks + +- Raw tile/chunk dictionaries remain a transitional ADT adapter boundary. +- The planner performs a second linear tile-state pass beside tile-LOD planning; + bounded synthetic timing is green, but asset-backed frame metrics are pending. +- Equal-distance create ordering retains comparator behavior without a tie-breaker. +- Terrain geometry finalization and state mutation remain monolithic. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/terrain/terrain_chunk_geometry_queue_planner.gd` | Pure request calculation and nearest-first creation order | +| `src/scenes/streaming/streaming_world_loader.gd` | Queue adoption, tile-LOD planning, budgets and render side effects | +| `src/tools/verify_terrain_chunk_geometry_queue_planner.gd` | Semantic, boundary and timing regression | + +## Related decisions and references + +- [`terrain-chunk-lod-planner.md`](terrain-chunk-lod-planner.md) +- [`world-renderer.md`](world-renderer.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 5355ac5..975c27d 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`; `M03-RND-TERRAIN-LOD-PLANNER-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 terrain cache/LOD/geometry-queue packages | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-terrain-chunk-lod-planner`, 2026-07-16 | +| Last verified | Worktree `work/sindo-main-codex/m03-terrain-chunk-queue`, 2026-07-16 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -44,6 +44,8 @@ flowchart LR TerrainCache --> Loader Loader --> ChunkLod[TerrainChunkLodPlanner] ChunkLod --> Loader + Loader --> ChunkQueue[TerrainChunkGeometryQueuePlanner] + ChunkQueue --> Loader Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -109,6 +111,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `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 | +| `TerrainChunkGeometryQueuePlanner.plan` | Internal pure terrain query | Produces chunk create/remove requests from current and desired state | Synchronous per queue rebuild | Invalid focus returns empty arrays | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -132,6 +135,7 @@ loader configuration remains transitional composition data, not a caller API. | 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 | +| Internal plan | Current/desired chunk state and typed focus | Loader / `TerrainChunkGeometryQueuePlanner` | Loader chunk queues | Fresh request arrays; no retained resources | One queue rebuild | | 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 | @@ -173,6 +177,8 @@ flowchart TD TerrainCache --> Revisit[Revisited tile state restore] R --> ChunkLod[TerrainChunkLodPlanner] ChunkLod --> DesiredChunkLod[Desired chunk LOD state] + DesiredChunkLod --> ChunkQueue[TerrainChunkGeometryQueuePlanner] + ChunkQueue --> B R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] @@ -281,6 +287,9 @@ sequenceDiagram - `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. +- `TerrainChunkGeometryQueuePlanner` is stateless and owns only call-local + create/remove request calculation and sorting. The loader adopts the fresh + arrays and retains removal-first drains, budgets and render 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 @@ -360,6 +369,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - 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. +- Terrain chunk geometry queue contract: current/desired comparison, tile-LOD + takeover, sparse origin fallback, stable removals, nearest creates, loader + drain boundary and bounded multi-tile timing without 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. @@ -400,6 +412,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | 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 | +| Terrain chunk geometry queue planner | Implemented extraction | Scene-free request/order/dependency/timing contract and loader delegation | Queue drains/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_geometry_queue_planner.gd b/src/render/terrain/terrain_chunk_geometry_queue_planner.gd new file mode 100644 index 0000000..3dbc139 --- /dev/null +++ b/src/render/terrain/terrain_chunk_geometry_queue_planner.gd @@ -0,0 +1,102 @@ +class_name TerrainChunkGeometryQueuePlanner +extends RefCounted + +## Pure desired-operation planner for terrain chunk geometry queues. + +const GODOT_WORLD_POSITION_SCRIPT := preload( + "res://src/domain/coordinates/godot_world_position.gd" +) + + +## Returns fresh nearest-first create and stable-order remove request arrays. +## The caller retains queue storage, execution order and every render side effect. +func plan(tile_states: Dictionary, focus_position: RefCounted) -> Dictionary: + var chunk_create_requests: Array = [] + var chunk_remove_requests: Array = [] + if ( + focus_position == null + or focus_position.get_script() != GODOT_WORLD_POSITION_SCRIPT + ): + return _create_plan(chunk_create_requests, chunk_remove_requests) + + var focus_x_units := float(focus_position.get("x_units")) + var focus_z_units := float(focus_position.get("z_units")) + for tile_key_variant in tile_states.keys(): + var tile_key := String(tile_key_variant) + var tile_state: Dictionary = tile_states[tile_key_variant] + var current_chunks: Dictionary = tile_state["chunks"] + var desired_lods: Dictionary = tile_state["desired_lods"] + var desired_tile_lod := int(tile_state.get("desired_tile_lod", -1)) + + if desired_tile_lod >= 0: + for chunk_id in current_chunks.keys(): + chunk_remove_requests.append({"tile": tile_key, "chunk": chunk_id}) + continue + + for chunk_id in current_chunks.keys(): + if not desired_lods.has(chunk_id): + chunk_remove_requests.append({"tile": tile_key, "chunk": chunk_id}) + continue + if int(current_chunks[chunk_id]["lod"]) != int(desired_lods[chunk_id]): + chunk_create_requests.append(_create_chunk_request( + tile_key, + chunk_id, + int(desired_lods[chunk_id]), + _resolve_chunk_origin(tile_state, int(chunk_id)), + focus_x_units, + focus_z_units + )) + + for chunk_id in desired_lods.keys(): + if current_chunks.has(chunk_id): + continue + chunk_create_requests.append(_create_chunk_request( + tile_key, + chunk_id, + int(desired_lods[chunk_id]), + _resolve_chunk_origin(tile_state, int(chunk_id)), + focus_x_units, + focus_z_units + )) + + chunk_create_requests.sort_custom(func( + left_request: Dictionary, + right_request: Dictionary + ) -> bool: + return float(left_request["dist_sq"]) < float(right_request["dist_sq"]) + ) + return _create_plan(chunk_create_requests, chunk_remove_requests) + + +func _resolve_chunk_origin(tile_state: Dictionary, chunk_id: int) -> Vector3: + var chunks: Array = tile_state["data"].get("chunks", []) + if chunk_id >= 0 and chunk_id < chunks.size(): + var chunk: Dictionary = chunks[chunk_id] + if not chunk.is_empty(): + return chunk.get("origin", Vector3.ZERO) + return tile_state.get("origin", Vector3.ZERO) + + +func _create_chunk_request( + tile_key: String, + chunk_id: Variant, + desired_lod: int, + chunk_origin: Vector3, + focus_x_units: float, + focus_z_units: float +) -> Dictionary: + var delta_x_units := chunk_origin.x - focus_x_units + var delta_z_units := chunk_origin.z - focus_z_units + return { + "tile": tile_key, + "chunk": chunk_id, + "lod": desired_lod, + "dist_sq": delta_x_units * delta_x_units + delta_z_units * delta_z_units, + } + + +func _create_plan(chunk_create_requests: Array, chunk_remove_requests: Array) -> Dictionary: + return { + "create_requests": chunk_create_requests, + "remove_requests": chunk_remove_requests, + } diff --git a/src/render/terrain/terrain_chunk_geometry_queue_planner.gd.uid b/src/render/terrain/terrain_chunk_geometry_queue_planner.gd.uid new file mode 100644 index 0000000..ebcbe5a --- /dev/null +++ b/src/render/terrain/terrain_chunk_geometry_queue_planner.gd.uid @@ -0,0 +1 @@ +uid://ct1ar5inci34u diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index b433ab9..856d2aa 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -21,6 +21,9 @@ const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendere 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 TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT := preload( + "res://src/render/terrain/terrain_chunk_geometry_queue_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") @@ -184,6 +187,9 @@ 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 _terrain_chunk_geometry_operation_planner := ( + TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT.new() +) var _map_dir := "" var _abs_map_dir := "" var _wdt_info: Dictionary = {} @@ -857,34 +863,29 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar _rebuild_chunk_queues() -func _chunk_origin_for(state: Dictionary, chunk_id: int) -> Vector3: - var chunks: Array = state["data"].get("chunks", []) - if chunk_id >= 0 and chunk_id < chunks.size(): - var c: Dictionary = chunks[chunk_id] - if not c.is_empty(): - return c.get("origin", Vector3.ZERO) - return state.get("origin", Vector3.ZERO) - - func _rebuild_chunk_queues() -> void: - _chunk_remove_queue.clear() - _chunk_create_queue.clear() + var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new( + _last_focus_pos.x, + _last_focus_pos.y, + _last_focus_pos.z + ) + var chunk_queue_plan: Dictionary = _terrain_chunk_geometry_operation_planner.plan( + _tile_states, + typed_focus_position + ) + _chunk_remove_queue = chunk_queue_plan.get("remove_requests", []) + _chunk_create_queue = chunk_queue_plan.get("create_requests", []) _tile_lod_remove_queue.clear() _tile_lod_create_queue.clear() for key in _tile_states.keys(): var state: Dictionary = _tile_states[key] - var current_chunks: Dictionary = state["chunks"] - var desired_lods: Dictionary = state["desired_lods"] var current_tile_lod: int = int(state.get("tile_lod", -1)) var desired_tile_lod: int = int(state.get("desired_tile_lod", -1)) var current_tile_lod_visible: bool = bool(state.get("tile_lod_visible", false)) var desired_tile_lod_visible: bool = bool(state.get("desired_tile_lod_visible", false)) if desired_tile_lod >= 0: - for chunk_id in current_chunks.keys(): - _chunk_remove_queue.append({"tile": key, "chunk": chunk_id}) - if current_tile_lod != desired_tile_lod or current_tile_lod_visible != desired_tile_lod_visible: _tile_lod_create_queue.append({ "tile": key, @@ -905,37 +906,7 @@ func _rebuild_chunk_queues() -> void: if current_tile_lod >= 0 and _is_tile_chunk_set_ready(state): _tile_lod_remove_queue.append({"tile": key}) - for chunk_id in current_chunks.keys(): - if not desired_lods.has(chunk_id): - _chunk_remove_queue.append({"tile": key, "chunk": chunk_id}) - continue - if current_chunks[chunk_id]["lod"] != desired_lods[chunk_id]: - var lod_change_origin: Vector3 = _chunk_origin_for(state, chunk_id) - var dx2: float = lod_change_origin.x - _last_focus_pos.x - var dz2: float = lod_change_origin.z - _last_focus_pos.z - _chunk_create_queue.append({ - "tile": key, - "chunk": chunk_id, - "lod": desired_lods[chunk_id], - "dist_sq": dx2 * dx2 + dz2 * dz2, - }) - - for chunk_id in desired_lods.keys(): - if current_chunks.has(chunk_id): - continue - var chunk_origin: Vector3 = _chunk_origin_for(state, chunk_id) - var dx: float = chunk_origin.x - _last_focus_pos.x - var dz: float = chunk_origin.z - _last_focus_pos.z - _chunk_create_queue.append({ - "tile": key, - "chunk": chunk_id, - "lod": desired_lods[chunk_id], - "dist_sq": dx * dx + dz * dz, - }) - - # Sort after fully rebuilding so nearest chunks are processed first - _chunk_create_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return a["dist_sq"] < b["dist_sq"]) + # Sort tile LOD creates after fully rebuilding; chunk creates arrive nearest-first. _tile_lod_create_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["dist_sq"] < b["dist_sq"]) diff --git a/src/tools/verify_terrain_chunk_geometry_queue_planner.gd b/src/tools/verify_terrain_chunk_geometry_queue_planner.gd new file mode 100644 index 0000000..ebd55fc --- /dev/null +++ b/src/tools/verify_terrain_chunk_geometry_queue_planner.gd @@ -0,0 +1,169 @@ +extends SceneTree + +## Asset-free semantic, dependency and timing regression for chunk queue planning. + +const PLANNER_SCRIPT := preload( + "res://src/render/terrain/terrain_chunk_geometry_queue_planner.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_create_remove_and_order(failures) + _verify_tile_lod_removes_all(failures) + _verify_fallback_origin_and_vertical_invariance(failures) + _verify_invalid_focus(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_GEOMETRY_QUEUE_PLANNER: %s" % failure) + quit(1) + return + print( + "TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER PASS cases=8 iterations=100 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_create_remove_and_order(failures: Array[String]) -> void: + var planner: RefCounted = PLANNER_SCRIPT.new() + var tile_states := { + "A": _create_tile_state( + {0: {"lod": 0}, 1: {"lod": 1}, 3: {"lod": 0}}, + {0: 0, 1: 2, 2: 1}, + [ + {"origin": Vector3(100.0, 0.0, 0.0)}, + {"origin": Vector3(30.0, 8000.0, 0.0)}, + {"origin": Vector3(10.0, -8000.0, 0.0)}, + ] + ), + "B": _create_tile_state({5: {"lod": 0}}, {}, []), + } + var plan: Dictionary = planner.call("plan", tile_states, POSITION_SCRIPT.new(0.0, 50.0, 0.0)) + var creates: Array = plan["create_requests"] + var removes: Array = plan["remove_requests"] + _expect_equal(creates.size(), 2, "missing and mismatched chunks create", failures) + _expect_equal(int(creates[0]["chunk"]), 2, "nearest missing chunk first", failures) + _expect_equal(int(creates[1]["chunk"]), 1, "farther LOD mismatch second", failures) + _expect_equal(int(creates[1]["lod"]), 2, "mismatch uses desired LOD", failures) + _expect_equal(removes.size(), 2, "undesired current chunks remove", failures) + _expect_string_equal(String(removes[0]["tile"]), "A", "stable tile removal order", failures) + _expect_equal(int(removes[0]["chunk"]), 3, "first undesired chunk", failures) + _expect_equal(int(removes[1]["chunk"]), 5, "second tile undesired chunk", failures) + + +func _verify_tile_lod_removes_all(failures: Array[String]) -> void: + var tile_state := _create_tile_state({7: {"lod": 0}, 8: {"lod": 1}}, {7: 0}, []) + tile_state["desired_tile_lod"] = 3 + var plan: Dictionary = PLANNER_SCRIPT.new().call( + "plan", + {"tile": tile_state}, + POSITION_SCRIPT.new(0.0, 0.0, 0.0) + ) + _expect_true((plan["create_requests"] as Array).is_empty(), "tile LOD suppresses chunk creates", failures) + _expect_equal((plan["remove_requests"] as Array).size(), 2, "tile LOD removes every chunk", failures) + + +func _verify_fallback_origin_and_vertical_invariance(failures: Array[String]) -> void: + var tile_state := _create_tile_state({}, {9: 1}, []) + tile_state["origin"] = Vector3(4.0, 99999.0, 3.0) + var plan: Dictionary = PLANNER_SCRIPT.new().call( + "plan", + {"fallback": tile_state}, + POSITION_SCRIPT.new(0.0, -99999.0, 0.0) + ) + var request: Dictionary = (plan["create_requests"] as Array)[0] + _expect_near(float(request["dist_sq"]), 25.0, "fallback origin uses horizontal distance", failures) + + +func _verify_invalid_focus(failures: Array[String]) -> void: + var plan: Dictionary = PLANNER_SCRIPT.new().call("plan", {}, null) + _expect_true((plan["create_requests"] as Array).is_empty(), "invalid focus create empty", failures) + _expect_true((plan["remove_requests"] as Array).is_empty(), "invalid focus remove empty", failures) + + +func _verify_loader_boundary(failures: Array[String]) -> void: + var source := _read_text(LOADER_PATH, failures) + _expect_true(source.contains("TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT.new()"), "loader composes planner", failures) + _expect_true(source.contains("_terrain_chunk_geometry_operation_planner.plan("), "loader delegates plan", failures) + _expect_true(not source.contains("func _chunk_origin_for("), "loader omits chunk-origin planning", failures) + _expect_true(source.contains("_remove_chunk(_chunk_remove_queue.pop_back())"), "loader retains removal-first drain", failures) + _expect_true(source.contains("_create_chunk(_chunk_create_queue.pop_front())"), "loader retains nearest-first drain", failures) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var tile_states: Dictionary = {} + for tile_index in range(64): + var current_chunks: Dictionary = {} + var desired_lods: Dictionary = {} + var parsed_chunks: Array = [] + for chunk_index in range(16): + current_chunks[chunk_index] = {"lod": chunk_index % 3} + desired_lods[chunk_index] = (chunk_index + 1) % 3 + parsed_chunks.append({ + "origin": Vector3(float(tile_index * 16 + chunk_index), 0.0, float(tile_index)) + }) + tile_states["tile-%d" % tile_index] = _create_tile_state( + current_chunks, + desired_lods, + parsed_chunks + ) + var planner: RefCounted = PLANNER_SCRIPT.new() + var focus: RefCounted = POSITION_SCRIPT.new(0.0, 0.0, 0.0) + var started_microseconds := Time.get_ticks_usec() + for _iteration in range(100): + planner.call("plan", tile_states, focus) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "100 multi-tile plans remain bounded", failures) + return elapsed_milliseconds + + +func _create_tile_state( + current_chunks: Dictionary, + desired_lods: Dictionary, + parsed_chunks: Array +) -> Dictionary: + return { + "chunks": current_chunks, + "desired_lods": desired_lods, + "desired_tile_lod": -1, + "data": {"chunks": parsed_chunks}, + "origin": Vector3.ZERO, + } + + +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_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void: + if not is_equal_approx(actual_value, expected_value): + failures.append("%s expected %.3f, got %.3f" % [label, expected_value, actual_value]) + + +func _expect_string_equal( + actual_value: String, + expected_value: String, + label: String, + failures: Array[String] +) -> void: + if actual_value != expected_value: + failures.append("%s expected %s, got %s" % [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_geometry_queue_planner.gd.uid b/src/tools/verify_terrain_chunk_geometry_queue_planner.gd.uid new file mode 100644 index 0000000..7d4b07c --- /dev/null +++ b/src/tools/verify_terrain_chunk_geometry_queue_planner.gd.uid @@ -0,0 +1 @@ +uid://dcimxvm2atvst From 4b90dbc1c13562975bb0243c83a1f345fb5ca513 Mon Sep 17 00:00:00 2001 From: sindoring Date: Thu, 16 Jul 2026 09:52:18 +0400 Subject: [PATCH 2/2] docs(coordination): hand off terrain chunk queue planner --- .../claims/M03-RND-TERRAIN-CHUNK-QUEUE-001.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/coordination/claims/M03-RND-TERRAIN-CHUNK-QUEUE-001.md b/coordination/claims/M03-RND-TERRAIN-CHUNK-QUEUE-001.md index 5839c2d..dffdcb7 100644 --- a/coordination/claims/M03-RND-TERRAIN-CHUNK-QUEUE-001.md +++ b/coordination/claims/M03-RND-TERRAIN-CHUNK-QUEUE-001.md @@ -77,14 +77,23 @@ streamer into a scene-free `TerrainChunkGeometryQueuePlanner`. ## Status -- State: claimed -- Done: scope, ordering and ownership published -- Next: implement planner, migrate chunk queue calculation, tests and docs +- State: handoff +- Done: pure planner, loader queue adoption, semantic/timing regressions and docs +- Next: milestone integrator merge and asset-backed performance acceptance later in M03 - Blocked by: ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `f800e11` (`refactor(M03): extract terrain chunk queue planner`) +- Results: dedicated contract PASS (`cases=8`, `iterations=100`, `235.096 ms`); + LOD/cache/facade/internal-access/manifest/shutdown/scheduler/streaming/focus/ + ground/terrain-query/coordinate regressions PASS; internal inventory remains + `42`; editor parse has no script errors; documentation/coordination/diff gates + PASS; checkpoint dry-run planned `7/7` entries with expected missing-private- + corpus diagnostics +- Remaining risks: planner adds a second linear tile-state pass; asset-backed + p95/p99 and visual acceptance remain pending; queue drains, terrain state and + Mesh/Node/RID finalization remain loader-owned +- Documentation updated: `terrain-chunk-geometry-queue-planner.md`, module + registry, `world-renderer.md`, `RENDER.md`, inline API documentation and + data-flow/lifecycle/sequence diagrams