refactor(M03): extract terrain chunk queue planner

This commit is contained in:
2026-07-16 09:52:01 +04:00
parent 0d2c820ea7
commit f800e11cd2
9 changed files with 515 additions and 49 deletions
+13
View File
@@ -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 scene-free contract is formula evidence only; asset-backed p95/p99 and visual
acceptance remain required. 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 ## 2026-07-16 World Environment Snapshot Facade
- `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value, - `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value,
+1
View File
@@ -14,6 +14,7 @@
| Terrain query | Implemented | [`terrain-query.md`](terrain-query.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 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 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) | | Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) |
| Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.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) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.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)
+15 -2
View File
@@ -5,9 +5,9 @@
| Field | Value | | Field | Value |
|---|---| |---|---|
| Status | Partial | | 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 | | 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 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose ## Purpose
@@ -44,6 +44,8 @@ flowchart LR
TerrainCache --> Loader TerrainCache --> Loader
Loader --> ChunkLod[TerrainChunkLodPlanner] Loader --> ChunkLod[TerrainChunkLodPlanner]
ChunkLod --> Loader ChunkLod --> Loader
Loader --> ChunkQueue[TerrainChunkGeometryQueuePlanner]
ChunkQueue --> Loader
Native --> Parsed[Parsed tile/model data] Native --> Parsed[Parsed tile/model data]
Parsed --> Loader Parsed --> Loader
Loader --> Scene[SceneTree nodes] 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 | | `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 | | `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 | | `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, Публичным contract не считаются `StreamingWorldLoader` methods/properties,
внутренние dictionaries, queues, job records и generated node names. Scene-owned внутренние 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 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 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 | 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 | 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 | 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 | | 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] TerrainCache --> Revisit[Revisited tile state restore]
R --> ChunkLod[TerrainChunkLodPlanner] R --> ChunkLod[TerrainChunkLodPlanner]
ChunkLod --> DesiredChunkLod[Desired chunk LOD state] ChunkLod --> DesiredChunkLod[Desired chunk LOD state]
DesiredChunkLod --> ChunkQueue[TerrainChunkGeometryQueuePlanner]
ChunkQueue --> B
R --> B[RenderBudgetScheduler permits] R --> B[RenderBudgetScheduler permits]
B --> Terrain[Terrain attach/upgrade] B --> Terrain[Terrain attach/upgrade]
B --> M2[M2 group/MultiMesh attach] B --> M2[M2 group/MultiMesh attach]
@@ -281,6 +287,9 @@ sequenceDiagram
- `TerrainChunkLodPlanner` is stateless and owns only call-local horizontal - `TerrainChunkLodPlanner` is stateless and owns only call-local horizontal
distance calculations. The loader retains parsed chunks, desired state, distance calculations. The loader retains parsed chunks, desired state,
queues, budgets and all Mesh/Node/RID application side effects. 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; - Rendered-ground query results and diagnostic snapshots are caller-owned values;
the facade never returns Mesh, Node, tile-state or queue references. the facade never returns Mesh, Node, tile-state or queue references.
- Loaded-mesh ground sampling is renderer diagnostics, not authoritative gameplay - 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 - Terrain chunk LOD contract: horizontal chunk-center distance, inclusive
thresholds, sparse indices, disabled/raw-custom policy, loader delegation and thresholds, sparse indices, disabled/raw-custom policy, loader delegation and
bounded 250-by-256 planning without loading a world scene. 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 - Budget scheduler contract: exact lane exhaustion, shared chunk removal/create
priority, independent lanes, frame reset, invalid limits, terminal cancellation, priority, independent lanes, frame reset, invalid limits, terminal cancellation,
dependency boundaries and bounded permit timing without loading a world scene. 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 | | 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 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 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 | | 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 | | 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 | | 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 |
@@ -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,
}
@@ -0,0 +1 @@
uid://ct1ar5inci34u
+18 -47
View File
@@ -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_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_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_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_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 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") 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 _builder
var _streaming_target_planner := STREAMING_TARGET_PLANNER_SCRIPT.new() var _streaming_target_planner := STREAMING_TARGET_PLANNER_SCRIPT.new()
var _terrain_chunk_lod_planner := TERRAIN_CHUNK_LOD_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 _map_dir := ""
var _abs_map_dir := "" var _abs_map_dir := ""
var _wdt_info: Dictionary = {} var _wdt_info: Dictionary = {}
@@ -857,34 +863,29 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar
_rebuild_chunk_queues() _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: func _rebuild_chunk_queues() -> void:
_chunk_remove_queue.clear() var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(
_chunk_create_queue.clear() _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_remove_queue.clear()
_tile_lod_create_queue.clear() _tile_lod_create_queue.clear()
for key in _tile_states.keys(): for key in _tile_states.keys():
var state: Dictionary = _tile_states[key] 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 current_tile_lod: int = int(state.get("tile_lod", -1))
var desired_tile_lod: int = int(state.get("desired_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 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)) var desired_tile_lod_visible: bool = bool(state.get("desired_tile_lod_visible", false))
if desired_tile_lod >= 0: 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: if current_tile_lod != desired_tile_lod or current_tile_lod_visible != desired_tile_lod_visible:
_tile_lod_create_queue.append({ _tile_lod_create_queue.append({
"tile": key, "tile": key,
@@ -905,37 +906,7 @@ func _rebuild_chunk_queues() -> void:
if current_tile_lod >= 0 and _is_tile_chunk_set_ready(state): if current_tile_lod >= 0 and _is_tile_chunk_set_ready(state):
_tile_lod_remove_queue.append({"tile": key}) _tile_lod_remove_queue.append({"tile": key})
for chunk_id in current_chunks.keys(): # Sort tile LOD creates after fully rebuilding; chunk creates arrive nearest-first.
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"])
_tile_lod_create_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: _tile_lod_create_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["dist_sq"] < b["dist_sq"]) return a["dist_sq"] < b["dist_sq"])
@@ -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)
@@ -0,0 +1 @@
uid://dcimxvm2atvst