rnd(M03): extract render budget scheduler

This commit is contained in:
2026-07-16 00:31:01 +04:00
parent 91f0724ce2
commit e52f703da5
9 changed files with 594 additions and 74 deletions
+16
View File
@@ -903,6 +903,22 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- Cache versions, quality profiles, queue priority and visible rendering rules - Cache versions, quality profiles, queue priority and visible rendering rules
are unchanged by the extraction. are unchanged by the extraction.
## 2026-07-16 RenderBudgetScheduler Extraction
- The scene-free `RenderBudgetScheduler` now owns the per-frame counters for 16
named renderer operation lanes. It owns no queue, task, cache, Node or RID.
- `StreamingWorldLoader` snapshots its existing exported limits once per process
frame and asks for a boolean permit immediately before each historically
budgeted operation.
- Chunk removals and creations intentionally share one `chunk_geometry` lane;
the existing removal-first drain order and combined limit are preserved.
- Loader teardown cancels permit issuance before waiting for asynchronous work.
Existing worker/result staleness checks and resource cleanup remain unchanged.
- Asset-free contracts cover exact bounds, independent/shared lanes, frame reset,
invalid inputs, terminal cancellation, detached diagnostics and hot-path timing.
- Defaults, quality presets, queue order, cache versions and visible rendering
rules are unchanged. Asset-backed p95/p99 comparison remains required.
## Practical Rule For Future Work ## Practical Rule For Future Work
If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it.
+1
View File
@@ -16,6 +16,7 @@
| 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) |
| Streaming target planner | Implemented | [`streaming-target-planner.md`](streaming-target-planner.md) | | Streaming target planner | Implemented | [`streaming-target-planner.md`](streaming-target-planner.md) |
| Render budget scheduler | Implemented | [`render-budget-scheduler.md`](render-budget-scheduler.md) |
| Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) | | Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) |
| Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) | | Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
| UI/Lua/audio | Prototype/Planned | [`../../targets/roadmap/05-ui-lua-audio.md`](../../targets/roadmap/05-ui-lua-audio.md) | | UI/Lua/audio | Prototype/Planned | [`../../targets/roadmap/05-ui-lua-audio.md`](../../targets/roadmap/05-ui-lua-audio.md) |
+216
View File
@@ -0,0 +1,216 @@
# Render Budget Scheduler
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | `M03-RND-SCHEDULER-001` |
| Owners | Renderer workstream / M03 integrator |
| Last verified | Worktree `work/sindo-main-codex/m03-render-budget-scheduler`, 2026-07-16 |
| Profiles/capabilities | Runtime and Editor main-thread operation quotas; profile values supplied by streamer composition |
## Purpose
Issue a bounded number of permits for each renderer operation lane during one
process frame. The scheduler centralizes quota accounting and teardown
cancellation without taking ownership of queues or render work.
## Non-goals
- Own, sort, drain or cap the stored length of a renderer queue.
- Submit, cancel or await worker and `ResourceLoader` tasks.
- Execute callbacks or mutate the SceneTree, resources, caches or RIDs.
- Adapt limits from elapsed time or create a general job/dependency framework.
- Select renderer quality or compatibility profiles.
## Context and boundaries
```mermaid
flowchart LR
Config[Streamer exported per-frame limits] --> Frame[operation limit snapshot]
Frame --> Scheduler[RenderBudgetScheduler]
Queues[StreamingWorldLoader owned queues] --> Drains[Ordered queue drains]
Scheduler -->|boolean permits| Drains
Drains --> Render[Main-thread finalize/attach/evict]
Shutdown[StreamingWorldLoader exit] -->|cancel| Scheduler
Scheduler -. does not own .-> Queues
```
Allowed dependencies are Godot value containers and `RefCounted`. The module
must not depend on `Node`, `WorkerThreadPool`, `ResourceLoader`, `RenderingServer`,
mutexes, renderer assets or streamer queue fields.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Failure behavior |
|---|---|---|---|---|
| Lane constants | `StringName` constants | Stable operation categories used in the frame limit map | Process lifetime | Unknown lanes have zero permits |
| `begin_frame` | Method | Replaces all limits and clears consumption diagnostics for one frame | Main thread; once before drains | Negative limits clamp to zero; cannot revive a cancelled instance |
| `has_remaining_permit` | Query | Tests a lane without consuming | Main thread; current frame | Unknown/exhausted/cancelled returns `false` |
| `try_consume_permit` | Mutation | Atomically checks and consumes one caller operation permit | Main thread; current frame | Unknown/exhausted/cancelled returns `false` |
| `remaining_permits` | Query | Returns a lane's current unconsumed count | Main thread; current frame | Unknown/cancelled returns zero |
| `consumed_permits_snapshot` | Query | Returns detached per-lane diagnostics | Caller owns returned dictionary | Mutation cannot affect scheduler state |
| `cancel` | Lifecycle method | Permanently stops new permits and clears remaining limits | Main thread; renderer teardown | Idempotent; does not interrupt in-flight caller work |
| `is_cancelled` | Query | Reports terminal lifecycle state | Scheduler lifetime | No failure |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `Dictionary[StringName, int]` operation limits | `StreamingWorldLoader` configuration snapshot | Scheduler | Copied by scheduler | One process frame |
| Input | Lane ID | Ordered streamer drain | Scheduler | Constant value | One permit check |
| Input | Cancellation | Streamer `_exit_tree` | Scheduler | Terminal flag | Renderer instance lifetime |
| Output | Permit boolean | Scheduler | Streamer drain loop | Scalar | One attempted operation |
| Output | Remaining count | Scheduler | Streamer/tests | Scalar | Current frame |
| Output | Consumed snapshot | Scheduler | Diagnostics/tests | Detached caller-owned copy | Current frame snapshot |
The scheduler has no I/O, logging, SceneTree, task, cache or GPU side effects.
## Data flow
```mermaid
flowchart TD
Exports[Exported operation limits] --> Limits[_render_operation_limits_for_frame]
Limits --> Begin[begin_frame]
Begin --> Remaining[remaining permits by lane]
Queue[Non-empty caller-owned queue] --> Check[try_consume_permit]
Remaining --> Check
Check -->|true| Execute[Caller performs one operation]
Check -->|false| Defer[Operation remains queued]
Execute --> Consumed[consumed permits diagnostics]
```
## Lifecycle and state
```mermaid
stateDiagram-v2
[*] --> Ready
Ready --> FrameActive: begin_frame
FrameActive --> FrameActive: consume/check
FrameActive --> FrameActive: begin_frame resets limits
Ready --> Cancelled: cancel
FrameActive --> Cancelled: cancel
Cancelled --> Cancelled: begin_frame/check/cancel
Cancelled --> [*]
```
Cancellation is intentionally terminal. A new renderer scene creates a new
scheduler instance; an old instance cannot accidentally resume after teardown.
## Main sequence
```mermaid
sequenceDiagram
participant Stream as StreamingWorldLoader
participant Budget as RenderBudgetScheduler
participant Queue as Loader-owned queues
participant Render as SceneTree/RenderingServer
Stream->>Budget: begin_frame(operation limits)
loop existing fixed drain order
Stream->>Queue: inspect next operation
Stream->>Budget: try_consume_permit(lane)
Budget-->>Stream: true or false
Stream->>Render: execute one operation when true
end
Stream->>Budget: cancel() during _exit_tree
Budget-->>Stream: later permits rejected
Stream->>Stream: await tasks and release owned resources
```
## Ownership, threading and resources
- `StreamingWorldLoader` owns every queue, task registry, cache, Node, Resource and RID.
- The scheduler owns two small per-frame counter dictionaries and one terminal flag.
- The streamer preserves operation order. In particular, chunk removals and
creations share `CHUNK_GEOMETRY`, with removals drained first.
- All current scheduler calls occur on the Godot main thread. The service has no
mutex and is not safe for concurrent mutation.
- A scheduler instance lives exactly as long as its loader instance.
## Errors, cancellation and recovery
| Condition | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Negative/zero limit | `begin_frame` clamp | Lane issues no permits | Contract verifier | Correct configuration or accept disabled lane |
| Unknown lane | Permit lookup miss | Returns `false`/zero | Contract verifier | Add the lane to the frame snapshot |
| Exhausted lane | Remaining count is zero | Work remains in caller queue for a later frame | Queue-depth/hitch metrics | Next `begin_frame` replenishes permits |
| Renderer teardown | Streamer calls `cancel` before task waits | New permits stop immediately | Shutdown ownership regression | New scene creates a new scheduler |
| In-flight operation at cancel | Caller already consumed permit | Scheduler does not interrupt it | Ownership docs | Caller completes/cleans it through existing shutdown path |
This cancellation scope is permit issuance, not worker cancellation. Existing
task/result staleness and shutdown waits remain streamer responsibilities.
## Configuration and capabilities
The streamer's existing exported values populate 16 lanes: tile finalization,
terrain upgrade/control-splat/splat-cache/splat-build, water finalization, shared
chunk geometry, tile-load starts, tile LOD create/remove, M2 animation/mesh
finalization and build, WMO instance/group build, and detail synchronization.
Defaults and quality-preset assignments are unchanged. `tile_finalize` retains
its historical minimum of one; other negative values behave as zero.
## Persistence, cache and migration
The module is runtime-only and serializes nothing. It changes no cache, asset,
scene, protocol or database format and requires no version bump or migration.
## Diagnostics and observability
- `consumed_permits_snapshot` exposes detached per-frame counts for tests and
future facade metrics without exposing mutable scheduler state.
- Existing queue depths, named hitch sections and `HITCH`/`PERF` logs remain in
`StreamingWorldLoader`.
- No scheduler logging occurs on the hot permit path.
## Verification
- `src/tools/verify_render_budget_scheduler.gd` covers exact exhaustion,
independent lanes, shared removal/create priority, frame reset, invalid/unknown
lanes, detached diagnostics, terminal cancellation and source boundaries.
- The verifier performs 20,000 permit checks with a generous 150 ms regression
ceiling on the headless runner; this is a guard, not a renderer p95 claim.
- Integration regressions must retain the seven renderer checkpoint plans and
existing focus/planner/facade contracts.
- Fidelity evidence is behavior-preserving: exported values, process order,
cache versions and visual operations are unchanged. No build-12340 parity
claim follows from this extraction.
## Extension points
- Add facade consumption metrics using detached snapshots.
- Move a service's queue drain behind its own boundary while retaining these lane IDs.
- Add measured time-aware policy only after asset-backed p95/p99 evidence proves
fixed operation counts insufficient; do not embed callbacks in this scheduler.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Per-lane frame bounds | Implemented | Exact exhaustion and independent-lane contracts | Asset-backed p95/p99 baseline remains |
| Shared removal/create priority | Implemented | Synthetic shared `CHUNK_GEOMETRY` contract | Other service priorities remain streamer-owned |
| Teardown permit cancellation | Implemented | Terminal cancellation contract and loader source gate | Worker stale-result cancellation remains streamer-owned |
| Detached consumption diagnostics | Implemented | Snapshot isolation contract | Not yet exposed through facade metrics |
## Known gaps and risks
- Queue storage length is not capped; only per-frame draining is bounded.
- Worker concurrency and stale-result cancellation remain existing streamer logic.
- Exact p95/p99 impact requires local extracted assets and paired baseline runs.
- Lane IDs are dynamic dictionary keys; the verifier guards the current contract.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/streaming/render_budget_scheduler.gd` | Scene-free lane counters, permits, diagnostics and cancellation |
| `src/scenes/streaming/streaming_world_loader.gd` | Limit composition, fixed drain order, queue/resource ownership and operation execution |
| `src/tools/verify_render_budget_scheduler.gd` | Asset-free behavior, dependency and bounded timing regression |
## Related decisions and references
- [`world-renderer.md`](world-renderer.md)
- [`streaming-target-planner.md`](streaming-target-planner.md)
- [`../../targets/03-renderer-facade.md`](../../targets/03-renderer-facade.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
- [`../CODING_STANDARD.md`](../CODING_STANDARD.md)
+19 -6
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-RND-FACADE-001` | | Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-RND-FACADE-001`; `M03-RND-STREAMING-PLANNER-001`; `M03-RND-SCHEDULER-001` |
| Owners | Renderer workstream / milestone integrator | | Owners | Renderer workstream / milestone integrator |
| Last verified | Worktree `work/sindo-main-codex/m03-renderer-facade`, 2026-07-15 | | Last verified | Worktree `work/sindo-main-codex/m03-render-budget-scheduler`, 2026-07-16 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose ## Purpose
@@ -37,6 +37,7 @@ flowchart LR
Loader --> Planner[StreamingTargetPlanner] Loader --> Planner[StreamingTargetPlanner]
Planner --> TargetPlan[StreamingTargetPlan] Planner --> TargetPlan[StreamingTargetPlan]
TargetPlan --> Loader TargetPlan --> Loader
Loader --> Budget[RenderBudgetScheduler]
Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders] Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders]
Cache[Baked terrain/M2/WMO caches] --> Loader Cache[Baked terrain/M2/WMO caches] --> Loader
Native --> Parsed[Parsed tile/model data] Native --> Parsed[Parsed tile/model data]
@@ -98,6 +99,7 @@ loader configuration remains transitional composition data, not a caller API.
| Input | Baked resources | Cache build tools | Streamer/builders | Shared immutable resource/cache entry | Session/cache lifetime | | Input | Baked resources | Cache build tools | Streamer/builders | Shared immutable resource/cache entry | Session/cache lifetime |
| Test input | Server-spawn render manifest | Coordinate fixture/verifier | Checkpoint capture tool | Repository-owned diagnostic data | Test-process lifetime | | Test input | Server-spawn render manifest | Coordinate fixture/verifier | Checkpoint capture tool | Repository-owned diagnostic data | Test-process lifetime |
| Output | `StreamingTargetPlan` | `StreamingTargetPlanner` | Streamer queue/state apply | Immutable ephemeral value | One refresh | | 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 |
| 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 |
@@ -131,7 +133,7 @@ flowchart TD
T --> Q[Tile load queue] T --> Q[Tile load queue]
Q --> Parse[Worker parse/cache load] Q --> Parse[Worker parse/cache load]
Parse --> R[Result queues] Parse --> R[Result queues]
R --> B[Per-frame budget scheduler] 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]
B --> WMO[WMO instance/group attach] B --> WMO[WMO instance/group attach]
@@ -173,7 +175,7 @@ sequenceDiagram
participant Stream as StreamingWorldLoader internal participant Stream as StreamingWorldLoader internal
participant Planner as StreamingTargetPlanner participant Planner as StreamingTargetPlanner
participant Worker as Worker task participant Worker as Worker task
participant Budget as Main-thread budget participant Budget as RenderBudgetScheduler
participant Render as SceneTree/RenderingServer participant Render as SceneTree/RenderingServer
Source->>Focus: sample explicit Godot position Source->>Focus: sample explicit Godot position
Focus->>Facade: set/refresh typed focus Focus->>Facade: set/refresh typed focus
@@ -183,9 +185,12 @@ sequenceDiagram
Stream->>Stream: queue priority and loaded-tile LOD/detail state Stream->>Stream: queue priority and loaded-tile LOD/detail state
Stream->>Worker: parse/load tile and detail data Stream->>Worker: parse/load tile and detail data
Worker-->>Stream: immutable result Worker-->>Stream: immutable result
Stream->>Budget: enqueue finalize operations Stream->>Budget: begin_frame(operation limits)
Budget->>Render: attach bounded terrain/M2/WMO/liquid work Stream->>Budget: try_consume_permit(lane)
Budget-->>Stream: true while lane remains bounded
Stream->>Render: attach permitted terrain/M2/WMO/liquid work
Stream->>Render: evict outside retention range Stream->>Render: evict outside retention range
Stream->>Budget: shutdown: cancel permit issuance
Stream->>Worker: shutdown: wait for WorkerThreadPool tasks Stream->>Worker: shutdown: wait for WorkerThreadPool tasks
Stream->>Stream: shutdown: finish registered ResourceLoader requests Stream->>Stream: shutdown: finish registered ResourceLoader requests
Stream->>Render: clear queues and owned tree nodes/RIDs Stream->>Render: clear queues and owned tree nodes/RIDs
@@ -199,6 +204,8 @@ sequenceDiagram
streamer, focus source, returned metrics, queues, caches, nodes or RIDs. streamer, focus source, returned metrics, queues, caches, nodes or RIDs.
- `StreamingTargetPlanner` is stateless and owns only call-local calculations; - `StreamingTargetPlanner` is stateless and owns only call-local calculations;
its immutable plan is consumed synchronously by the streamer. its immutable plan is consumed synchronously by the streamer.
- `RenderBudgetScheduler` owns only per-frame lane counters and a terminal
cancellation flag. The streamer retains queue ordering and all operation side effects.
- Focus producers own the immutable `StreamingFocus` reference; the loader retains - Focus producers own the immutable `StreamingFocus` reference; the loader retains
the latest reference and converts it to `Vector3` only inside the render boundary. the latest reference and converts it to `Vector3` only inside the render boundary.
- Worker tasks не должны менять SceneTree и shared Resource concurrently. - Worker tasks не должны менять SceneTree и shared Resource concurrently.
@@ -261,6 +268,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. - 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 - Pure planner contract: center/corner/catalog/clamp/editor cases plus bounded
High-like iteration timing without loading a world scene. High-like iteration timing 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.
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00 - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00
cold/warm checkpoints and one dedicated mapped server-spawn checkpoint. cold/warm checkpoints and one dedicated mapped server-spawn checkpoint.
- Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe. - Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe.
@@ -288,6 +298,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Golden server-spawn visualization | Implemented | Pinned AzerothCore fixture, validated renderer manifest and diagnostic origin-marker capture at ADT `(32,48)`/MCNK `(3,12)` | No TrinityCore row or original-client visual-parity claim | | Golden server-spawn visualization | Implemented | Pinned AzerothCore fixture, validated renderer manifest and diagnostic origin-marker capture at ADT `(32,48)`/MCNK `(3,12)` | No TrinityCore row or original-client visual-parity claim |
| Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain | | Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain |
| 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 |
## Known gaps and risks ## Known gaps and risks
@@ -317,11 +328,13 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation | | `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation |
| `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy | | `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy |
| `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets | | `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets |
| `src/render/streaming/render_budget_scheduler.gd` | Scene-free per-frame operation permits and terminal cancellation |
| `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment | | `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment |
| `src/domain/streaming/streaming_focus.gd` | Immutable camera-independent streaming position contract | | `src/domain/streaming/streaming_focus.gd` | Immutable camera-independent streaming position contract |
| `src/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool wiring regression | | `src/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool wiring regression |
| `src/tools/verify_world_render_facade.gd` | Facade delegation, snapshot isolation and consumer wiring regression | | `src/tools/verify_world_render_facade.gd` | Facade delegation, snapshot isolation and consumer wiring regression |
| `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression | | `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression |
| `src/tools/verify_render_budget_scheduler.gd` | Scheduler bounds, shared-lane priority, cancellation and timing regression |
| `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction | | `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction |
| `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction | | `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction |
| `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment | | `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment |
@@ -0,0 +1,84 @@
## Issues bounded, per-frame permits for renderer main-thread operation lanes.
##
## The scheduler owns counters only. Queue contents, operation ordering and the
## side effects performed after a permit remain owned by the caller. One instance
## belongs to one renderer lifecycle and must only be used on the main thread.
class_name RenderBudgetScheduler
extends RefCounted
const TILE_FINALIZE: StringName = &"tile_finalize"
const TERRAIN_UPGRADE_FINALIZE: StringName = &"terrain_upgrade_finalize"
const TERRAIN_CONTROL_SPLAT_FINALIZE: StringName = &"terrain_control_splat_finalize"
const TERRAIN_SPLAT_CACHE_FINALIZE: StringName = &"terrain_splat_cache_finalize"
const TERRAIN_SPLAT_BUILD: StringName = &"terrain_splat_build"
const WATER_FINALIZE: StringName = &"water_finalize"
const CHUNK_GEOMETRY: StringName = &"chunk_geometry"
const TILE_LOAD_START: StringName = &"tile_load_start"
const TILE_LOD_CREATE: StringName = &"tile_lod_create"
const TILE_LOD_REMOVE: StringName = &"tile_lod_remove"
const M2_ANIMATION_FINALIZE: StringName = &"m2_animation_finalize"
const M2_MESH_FINALIZE: StringName = &"m2_mesh_finalize"
const M2_BUILD: StringName = &"m2_build"
const WMO_BUILD: StringName = &"wmo_build"
const WMO_RENDER_GROUP_BUILD: StringName = &"wmo_render_group_build"
const DETAIL_ASSET_SYNC: StringName = &"detail_asset_sync"
var _remaining_permits_by_lane: Dictionary = {}
var _consumed_permits_by_lane: Dictionary = {}
var _is_cancelled := false
## Replaces all lane limits for a new frame. Negative limits are treated as zero.
## Calling this after [method cancel] leaves the scheduler cancelled.
func begin_frame(operation_limits_by_lane: Dictionary) -> void:
_remaining_permits_by_lane.clear()
_consumed_permits_by_lane.clear()
if _is_cancelled:
return
for lane_variant in operation_limits_by_lane:
var lane := StringName(lane_variant)
_remaining_permits_by_lane[lane] = maxi(0, int(operation_limits_by_lane[lane_variant]))
_consumed_permits_by_lane[lane] = 0
## Returns whether a lane can issue another permit without consuming it.
## Unknown lanes and cancelled schedulers return false.
func has_remaining_permit(lane: StringName) -> bool:
if _is_cancelled:
return false
return int(_remaining_permits_by_lane.get(lane, 0)) > 0
## Consumes one permit from [param lane] and returns true when it was granted.
## Unknown, exhausted or cancelled lanes return false without changing state.
func try_consume_permit(lane: StringName) -> bool:
if not has_remaining_permit(lane):
return false
_remaining_permits_by_lane[lane] = int(_remaining_permits_by_lane[lane]) - 1
_consumed_permits_by_lane[lane] = int(_consumed_permits_by_lane.get(lane, 0)) + 1
return true
## Returns the unconsumed permit count for one lane.
func remaining_permits(lane: StringName) -> int:
if _is_cancelled:
return 0
return int(_remaining_permits_by_lane.get(lane, 0))
## Returns a detached diagnostic snapshot of permits consumed in this frame.
func consumed_permits_snapshot() -> Dictionary:
return _consumed_permits_by_lane.duplicate()
## Permanently rejects new permits for this scheduler instance and clears limits.
## In-flight work remains the caller's responsibility and is not interrupted.
func cancel() -> void:
_is_cancelled = true
_remaining_permits_by_lane.clear()
## Returns whether [method cancel] ended this scheduler lifecycle.
func is_cancelled() -> bool:
return _is_cancelled
@@ -0,0 +1 @@
uid://byaii7lec82i6
+81 -68
View File
@@ -19,6 +19,7 @@ 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 ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.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 REQUIRED_BAKED_TILE_FORMAT_VERSION := 5 const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
@@ -233,6 +234,7 @@ var _dbg_last_report := 0.0
var _runtime_stats_accum := 0.0 var _runtime_stats_accum := 0.0
var _terrain_quality_log_accum := 0.0 var _terrain_quality_log_accum := 0.0
var _shutting_down := false var _shutting_down := false
var _render_budget_scheduler := RENDER_BUDGET_SCHEDULER_SCRIPT.new()
var _last_refresh_focus_pos := Vector3.ZERO var _last_refresh_focus_pos := Vector3.ZERO
var _has_refresh_focus := false var _has_refresh_focus := false
var _tile_mesh_cache: Dictionary = {} var _tile_mesh_cache: Dictionary = {}
@@ -316,6 +318,7 @@ func _ready() -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if _available_tiles.is_empty(): if _available_tiles.is_empty():
return return
_render_budget_scheduler.begin_frame(_render_operation_limits_for_frame())
var profile_enabled := hitch_profiler_enabled and not Engine.is_editor_hint() var profile_enabled := hitch_profiler_enabled and not Engine.is_editor_hint()
var profile_start := Time.get_ticks_usec() if profile_enabled else 0 var profile_start := Time.get_ticks_usec() if profile_enabled else 0
@@ -387,11 +390,33 @@ func _process(delta: float) -> void:
func _exit_tree() -> void: func _exit_tree() -> void:
_shutting_down = true _shutting_down = true
_render_budget_scheduler.cancel()
_wait_for_tile_tasks() _wait_for_tile_tasks()
_clear_streamed_world() _clear_streamed_world()
_release_runtime_caches_for_shutdown() _release_runtime_caches_for_shutdown()
func _render_operation_limits_for_frame() -> Dictionary:
return {
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE: maxi(1, tile_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE: maxi(0, terrain_upgrade_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE: maxi(0, terrain_control_splat_cache_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE: maxi(0, terrain_splat_cache_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD: maxi(0, terrain_splat_builds_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE: maxi(0, water_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY: maxi(0, chunk_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOAD_START: maxi(0, tiles_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_CREATE: maxi(0, tile_lod_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_REMOVE: maxi(0, tile_lod_remove_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE: maxi(0, m2_animation_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE: maxi(0, m2_mesh_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD: maxi(0, m2_build_groups_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD: maxi(0, wmo_build_instances_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD: maxi(0, wmo_render_group_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC: maxi(0, detail_asset_ops_per_tick),
}
## Frees detached prototype nodes and releases render resources owned by this ## Frees detached prototype nodes and releases render resources owned by this
## loader after all asynchronous work has completed. Map refreshes retain these ## loader after all asynchronous work has completed. Map refreshes retain these
## caches; only scene shutdown destroys them. ## caches; only scene shutdown destroys them.
@@ -909,33 +934,31 @@ func _rebuild_chunk_queues() -> void:
func _process_queues() -> void: func _process_queues() -> void:
var ops_left: int = chunk_ops_per_tick
var tile_lod_create_left: int = tile_lod_ops_per_tick
var tile_lod_remove_left: int = tile_lod_remove_ops_per_tick
# Free stale terrain first so GPU descriptors/memory are reclaimed before new creates. # Free stale terrain first so GPU descriptors/memory are reclaimed before new creates.
while ops_left > 0 and not _chunk_remove_queue.is_empty(): while not _chunk_remove_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
_remove_chunk(_chunk_remove_queue.pop_back()) _remove_chunk(_chunk_remove_queue.pop_back())
ops_left -= 1
while tile_lod_remove_left > 0 and not _tile_lod_remove_queue.is_empty(): while not _tile_lod_remove_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_REMOVE):
_remove_tile_lod(_tile_lod_remove_queue.pop_back()) _remove_tile_lod(_tile_lod_remove_queue.pop_back())
tile_lod_remove_left -= 1
# Parse N ADT files this tick — the main throttle knob # Parse N ADT files this tick — the main throttle knob
var loads_left: int = tiles_per_tick while (
while loads_left > 0 and not _tile_load_queue.is_empty() and _tile_loading_tasks.size() < max_concurrent_tile_tasks: not _tile_load_queue.is_empty()
and _tile_loading_tasks.size() < max_concurrent_tile_tasks
and _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOAD_START)
):
_start_tile_load_async(_tile_load_queue.pop_front()) _start_tile_load_async(_tile_load_queue.pop_front())
loads_left -= 1
while tile_lod_create_left > 0 and not _tile_lod_create_queue.is_empty(): while not _tile_lod_create_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_CREATE):
_create_tile_lod(_tile_lod_create_queue.pop_front()) _create_tile_lod(_tile_lod_create_queue.pop_front())
tile_lod_create_left -= 1
# Create chunk meshes — sorted nearest-first so visible detail appears first # Create chunk meshes — sorted nearest-first so visible detail appears first
while ops_left > 0 and not _chunk_create_queue.is_empty(): while not _chunk_create_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
_create_chunk(_chunk_create_queue.pop_front()) _create_chunk(_chunk_create_queue.pop_front())
ops_left -= 1
# Debug summary every 2 seconds # Debug summary every 2 seconds
_dbg_last_report += get_process_delta_time() _dbg_last_report += get_process_delta_time()
@@ -1286,11 +1309,10 @@ func _load_tile_task(request: Dictionary) -> void:
func _drain_tile_load_results() -> void: func _drain_tile_load_results() -> void:
var results: Array = [] var results: Array = []
var finalize_budget: int = maxi(1, tile_finalize_ops_per_tick)
_tile_result_mutex.lock() _tile_result_mutex.lock()
while finalize_budget > 0 and not _tile_result_queue.is_empty(): while not _tile_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
results.append(_tile_result_queue.pop_front()) results.append(_tile_result_queue.pop_front())
finalize_budget -= 1
_tile_result_mutex.unlock() _tile_result_mutex.unlock()
for result in results: for result in results:
@@ -1314,10 +1336,10 @@ func _drain_tile_load_results() -> void:
_finalize_loaded_tile(request, result["data"]) _finalize_loaded_tile(request, result["data"])
var baked_ready: Array = [] var baked_ready: Array = []
if finalize_budget <= 0: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
return return
for key in _tile_loading_tasks.keys(): for key in _tile_loading_tasks.keys():
if baked_ready.size() >= finalize_budget: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
break break
var pending: Dictionary = _tile_loading_tasks[key] var pending: Dictionary = _tile_loading_tasks[key]
if pending.get("mode", "raw") != "baked" and pending.get("mode", "raw") != "stream": if pending.get("mode", "raw") != "baked" and pending.get("mode", "raw") != "stream":
@@ -1325,7 +1347,8 @@ func _drain_tile_load_results() -> void:
var status := ResourceLoader.load_threaded_get_status(pending["path"]) var status := ResourceLoader.load_threaded_get_status(pending["path"])
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
baked_ready.append(key) if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
baked_ready.append(key)
for key in baked_ready: for key in baked_ready:
if not _tile_loading_tasks.has(key): if not _tile_loading_tasks.has(key):
@@ -1743,18 +1766,18 @@ func _request_terrain_splat_cache(state: Dictionary) -> bool:
func _drain_terrain_upgrade_results() -> void: func _drain_terrain_upgrade_results() -> void:
var ready: Array[String] = [] var ready: Array[String] = []
var budget := maxi(0, terrain_upgrade_finalize_ops_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
if budget <= 0:
return return
for key_variant in _terrain_upgrade_tasks.keys(): for key_variant in _terrain_upgrade_tasks.keys():
if ready.size() >= budget: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
break break
var key := String(key_variant) var key := String(key_variant)
var pending: Dictionary = _terrain_upgrade_tasks[key] var pending: Dictionary = _terrain_upgrade_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
ready.append(key) if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
ready.append(key)
var changed := false var changed := false
for key in ready: for key in ready:
@@ -1802,18 +1825,18 @@ func _drain_terrain_upgrade_results() -> void:
func _drain_terrain_control_splat_cache_results() -> void: func _drain_terrain_control_splat_cache_results() -> void:
var ready: Array[String] = [] var ready: Array[String] = []
var budget := maxi(0, terrain_control_splat_cache_finalize_ops_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
if budget <= 0:
return return
for key_variant in _terrain_control_splat_cache_tasks.keys(): for key_variant in _terrain_control_splat_cache_tasks.keys():
if ready.size() >= budget: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
break break
var key := String(key_variant) var key := String(key_variant)
var pending: Dictionary = _terrain_control_splat_cache_tasks[key] var pending: Dictionary = _terrain_control_splat_cache_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
ready.append(key) if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
ready.append(key)
var changed := false var changed := false
for key in ready: for key in ready:
@@ -1868,18 +1891,18 @@ func _drain_terrain_control_splat_cache_results() -> void:
func _drain_terrain_splat_cache_results() -> void: func _drain_terrain_splat_cache_results() -> void:
var ready: Array[String] = [] var ready: Array[String] = []
var budget := maxi(0, terrain_splat_cache_finalize_ops_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
if budget <= 0:
return return
for key_variant in _terrain_splat_cache_tasks.keys(): for key_variant in _terrain_splat_cache_tasks.keys():
if ready.size() >= budget: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
break break
var key := String(key_variant) var key := String(key_variant)
var pending: Dictionary = _terrain_splat_cache_tasks[key] var pending: Dictionary = _terrain_splat_cache_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
ready.append(key) if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
ready.append(key)
var changed := false var changed := false
for key in ready: for key in ready:
@@ -1956,14 +1979,13 @@ func _load_terrain_splat_task(key: String, adt_path: String) -> void:
func _drain_terrain_splat_results() -> void: func _drain_terrain_splat_results() -> void:
var results: Array = [] var results: Array = []
var budget := maxi(0, terrain_splat_builds_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD):
if budget <= 0:
return return
_terrain_splat_result_mutex.lock() _terrain_splat_result_mutex.lock()
while budget > 0 and not _terrain_splat_result_queue.is_empty(): while not _terrain_splat_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD):
results.append(_terrain_splat_result_queue.pop_front()) results.append(_terrain_splat_result_queue.pop_front())
budget -= 1
_terrain_splat_result_mutex.unlock() _terrain_splat_result_mutex.unlock()
var changed := false var changed := false
@@ -2067,14 +2089,13 @@ func _load_tile_water_task(key: String, adt_path: String) -> void:
func _drain_water_load_results() -> void: func _drain_water_load_results() -> void:
var results: Array = [] var results: Array = []
var budget := maxi(0, water_finalize_ops_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
if budget <= 0:
return return
_water_result_mutex.lock() _water_result_mutex.lock()
while budget > 0 and not _water_result_queue.is_empty(): while not _water_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
results.append(_water_result_queue.pop_front()) results.append(_water_result_queue.pop_front())
budget -= 1
_water_result_mutex.unlock() _water_result_mutex.unlock()
for result in results: for result in results:
@@ -3252,12 +3273,11 @@ func _enqueue_detail_asset_sync(key: String) -> void:
func _process_detail_asset_queue() -> void: func _process_detail_asset_queue() -> void:
var ops_left: int = maxi(0, detail_asset_ops_per_tick) while not _detail_asset_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
while ops_left > 0 and not _detail_asset_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC):
var key: String = String(_detail_asset_queue.pop_front()) var key: String = String(_detail_asset_queue.pop_front())
_detail_asset_queued.erase(key) _detail_asset_queued.erase(key)
_process_detail_asset_key(key) _process_detail_asset_key(key)
ops_left -= 1
func _process_detail_asset_key(key: String) -> void: func _process_detail_asset_key(key: String) -> void:
@@ -3341,8 +3361,8 @@ func _register_tile_wmos(state: Dictionary, wmo_names: PackedStringArray, wmo_pl
func _process_wmo_build_jobs() -> void: func _process_wmo_build_jobs() -> void:
_drain_wmo_render_loads() _drain_wmo_render_loads()
_drain_wmo_scene_loads() _drain_wmo_scene_loads()
var ops_left: int = maxi(0, wmo_build_instances_per_tick) while _render_budget_scheduler.has_remaining_permit(
while ops_left > 0 and not _wmo_build_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD) and not _wmo_build_queue.is_empty():
var tile_key: String = String(_wmo_build_queue.front()) var tile_key: String = String(_wmo_build_queue.front())
if not _wmo_build_jobs.has(tile_key): if not _wmo_build_jobs.has(tile_key):
_wmo_build_queue.pop_front() _wmo_build_queue.pop_front()
@@ -3439,7 +3459,7 @@ func _process_wmo_build_jobs() -> void:
_tile_states[tile_key] = state _tile_states[tile_key] = state
_wmo_build_queue.pop_front() _wmo_build_queue.pop_front()
_wmo_build_queue.append(tile_key) _wmo_build_queue.append(tile_key)
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD)
continue continue
job["index"] = index + 1 job["index"] = index + 1
@@ -3447,7 +3467,7 @@ func _process_wmo_build_jobs() -> void:
state["wmo_refs"] = refs state["wmo_refs"] = refs
state["wmo_building"] = true state["wmo_building"] = true
_tile_states[tile_key] = state _tile_states[tile_key] = state
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD)
if int(job["index"]) >= wmo_placements.size(): if int(job["index"]) >= wmo_placements.size():
state["wmo_refs"] = refs state["wmo_refs"] = refs
@@ -3589,8 +3609,8 @@ func _start_wmo_render_build(unique_key: String, root: Node3D, render_resource:
func _process_wmo_render_build_jobs() -> void: func _process_wmo_render_build_jobs() -> void:
var ops_left: int = maxi(0, wmo_render_group_ops_per_tick) while _render_budget_scheduler.has_remaining_permit(
while ops_left > 0 and not _wmo_render_build_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD) and not _wmo_render_build_queue.is_empty():
var unique_key := String(_wmo_render_build_queue.front()) var unique_key := String(_wmo_render_build_queue.front())
if not _wmo_render_build_jobs.has(unique_key): if not _wmo_render_build_jobs.has(unique_key):
_wmo_render_build_queue.pop_front() _wmo_render_build_queue.pop_front()
@@ -3632,7 +3652,7 @@ func _process_wmo_render_build_jobs() -> void:
_set_editor_owner_recursive(mesh_instance) _set_editor_owner_recursive(mesh_instance)
job["mesh_index"] = mesh_index + 1 job["mesh_index"] = mesh_index + 1
_wmo_render_build_jobs[unique_key] = job _wmo_render_build_jobs[unique_key] = job
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
continue continue
var multimeshes: Array = render_resource.get("multimeshes") var multimeshes: Array = render_resource.get("multimeshes")
@@ -3664,7 +3684,7 @@ func _process_wmo_render_build_jobs() -> void:
_set_editor_owner_recursive(multimesh_instance) _set_editor_owner_recursive(multimesh_instance)
job["multimesh_index"] = multimesh_index + 1 job["multimesh_index"] = multimesh_index + 1
_wmo_render_build_jobs[unique_key] = job _wmo_render_build_jobs[unique_key] = job
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
continue continue
_cancel_wmo_render_build_job(unique_key) _cancel_wmo_render_build_job(unique_key)
@@ -4176,16 +4196,14 @@ func _drain_m2_animation_loads() -> void:
_m2_animation_load_requests.erase(normalized_rel) _m2_animation_load_requests.erase(normalized_rel)
_m2_animation_finalize_queue.append(pending) _m2_animation_finalize_queue.append(pending)
var ops_left: int = maxi(0, m2_animation_finalize_ops_per_tick) while not _m2_animation_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
while ops_left > 0 and not _m2_animation_finalize_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE):
var pending: Dictionary = _m2_animation_finalize_queue.pop_front() var pending: Dictionary = _m2_animation_finalize_queue.pop_front()
var normalized_rel := String(pending.get("normalized", "")) var normalized_rel := String(pending.get("normalized", ""))
if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel): if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel):
ops_left -= 1
continue continue
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
_m2_static_animation_cache[normalized_rel] = true _m2_static_animation_cache[normalized_rel] = true
ops_left -= 1
continue continue
var path := String(pending.get("path", "")) var path := String(pending.get("path", ""))
@@ -4199,11 +4217,9 @@ func _drain_m2_animation_loads() -> void:
_m2_animated_scene_cache[normalized_rel] = node as Node3D _m2_animated_scene_cache[normalized_rel] = node as Node3D
if debug_streaming: if debug_streaming:
print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()]) print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()])
ops_left -= 1
continue continue
node.free() node.free()
_m2_static_animation_cache[normalized_rel] = true _m2_static_animation_cache[normalized_rel] = true
ops_left -= 1
func _drain_m2_mesh_loads() -> void: func _drain_m2_mesh_loads() -> void:
@@ -4222,16 +4238,14 @@ func _drain_m2_mesh_loads() -> void:
_m2_mesh_load_requests.erase(normalized_rel) _m2_mesh_load_requests.erase(normalized_rel)
_m2_mesh_finalize_queue.append(pending) _m2_mesh_finalize_queue.append(pending)
var ops_left: int = maxi(0, m2_mesh_finalize_ops_per_tick) while not _m2_mesh_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
while ops_left > 0 and not _m2_mesh_finalize_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE):
var pending: Dictionary = _m2_mesh_finalize_queue.pop_front() var pending: Dictionary = _m2_mesh_finalize_queue.pop_front()
var normalized_rel := String(pending.get("normalized", "")) var normalized_rel := String(pending.get("normalized", ""))
if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel): if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel):
ops_left -= 1
continue continue
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
_m2_missing_cache[normalized_rel] = true _m2_missing_cache[normalized_rel] = true
ops_left -= 1
continue continue
var path := String(pending.get("path", "")) var path := String(pending.get("path", ""))
@@ -4241,12 +4255,11 @@ func _drain_m2_mesh_loads() -> void:
_m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh) _m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh)
else: else:
_m2_missing_cache[normalized_rel] = true _m2_missing_cache[normalized_rel] = true
ops_left -= 1
func _process_m2_build_jobs() -> void: func _process_m2_build_jobs() -> void:
var ops_left: int = maxi(0, m2_build_groups_per_tick) while _render_budget_scheduler.has_remaining_permit(
while ops_left > 0 and not _m2_build_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) and not _m2_build_queue.is_empty():
var key: String = String(_m2_build_queue.front()) var key: String = String(_m2_build_queue.front())
if not _m2_build_jobs.has(key): if not _m2_build_jobs.has(key):
_m2_build_queue.pop_front() _m2_build_queue.pop_front()
@@ -4292,7 +4305,7 @@ func _process_m2_build_jobs() -> void:
if enable_m2_animated_instances and _m2_animation_load_requests.has(normalized_rel): if enable_m2_animated_instances and _m2_animation_load_requests.has(normalized_rel):
_m2_build_queue.pop_front() _m2_build_queue.pop_front()
_m2_build_queue.append(key) _m2_build_queue.append(key)
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
continue continue
var batch_size: int = ( var batch_size: int = (
maxi(1, m2_animated_instances_per_tick) maxi(1, m2_animated_instances_per_tick)
@@ -4311,7 +4324,7 @@ func _process_m2_build_jobs() -> void:
if not _m2_missing_cache.has(normalized_rel): if not _m2_missing_cache.has(normalized_rel):
_m2_build_queue.pop_front() _m2_build_queue.pop_front()
_m2_build_queue.append(key) _m2_build_queue.append(key)
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
continue continue
else: else:
_materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial) _materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial)
@@ -4322,7 +4335,7 @@ func _process_m2_build_jobs() -> void:
else: else:
job["offset"] = offset + consumed job["offset"] = offset + consumed
_m2_build_jobs[key] = job _m2_build_jobs[key] = job
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
if int(job["index"]) >= group_keys.size(): if int(job["index"]) >= group_keys.size():
_finish_m2_build_job(key) _finish_m2_build_job(key)
+175
View File
@@ -0,0 +1,175 @@
extends SceneTree
## Asset-free M03 regression for bounded renderer operation permits and cancellation.
const SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
const SCHEDULER_PATH := "res://src/render/streaming/render_budget_scheduler.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const PERFORMANCE_ITERATIONS := 20000
const MAXIMUM_PERMIT_CHECK_MILLISECONDS := 150.0
func _initialize() -> void:
var failures: Array[String] = []
_verify_bounded_lane_and_diagnostics(failures)
_verify_shared_lane_priority(failures)
_verify_independent_lanes_and_frame_reset(failures)
_verify_invalid_limits_and_unknown_lane(failures)
_verify_cancellation(failures)
_verify_scene_free_extraction(failures)
var elapsed_milliseconds := _measure_permit_checks(failures)
if not failures.is_empty():
for failure in failures:
push_error("RENDER_BUDGET_SCHEDULER: %s" % failure)
quit(1)
return
print("RENDER_BUDGET_SCHEDULER PASS cases=6 iterations=%d elapsed_ms=%.3f" % [
PERFORMANCE_ITERATIONS,
elapsed_milliseconds,
])
quit(0)
func _verify_bounded_lane_and_diagnostics(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({SCHEDULER_SCRIPT.TILE_FINALIZE: 2})
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "first permit", failures)
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "second permit", failures)
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "exhausted permit", failures)
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_FINALIZE), 0, "remaining permits", failures)
var consumed_snapshot: Dictionary = scheduler.consumed_permits_snapshot()
_expect_equal(int(consumed_snapshot[SCHEDULER_SCRIPT.TILE_FINALIZE]), 2, "consumed permits", failures)
consumed_snapshot[SCHEDULER_SCRIPT.TILE_FINALIZE] = 99
_expect_equal(
int(scheduler.consumed_permits_snapshot()[SCHEDULER_SCRIPT.TILE_FINALIZE]),
2,
"diagnostic snapshot is detached",
failures
)
func _verify_shared_lane_priority(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({SCHEDULER_SCRIPT.CHUNK_GEOMETRY: 3})
var removals := 0
while removals < 2 and scheduler.try_consume_permit(SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
removals += 1
var creates := 0
while scheduler.try_consume_permit(SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
creates += 1
_expect_equal(removals, 2, "shared lane removals consume first", failures)
_expect_equal(creates, 1, "shared lane leaves one create", failures)
func _verify_independent_lanes_and_frame_reset(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({
SCHEDULER_SCRIPT.TILE_LOD_CREATE: 1,
SCHEDULER_SCRIPT.TILE_LOD_REMOVE: 2,
})
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_LOD_CREATE), "create lane permit", failures)
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_REMOVE), 2, "remove lane independent", failures)
scheduler.begin_frame({SCHEDULER_SCRIPT.TILE_LOD_CREATE: 3})
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_CREATE), 3, "new frame resets lane", failures)
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_REMOVE), 0, "new frame removes absent lane", failures)
_expect_equal(int(scheduler.consumed_permits_snapshot()[SCHEDULER_SCRIPT.TILE_LOD_CREATE]), 0, "new frame resets diagnostics", failures)
func _verify_invalid_limits_and_unknown_lane(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({
SCHEDULER_SCRIPT.WATER_FINALIZE: 0,
SCHEDULER_SCRIPT.M2_BUILD: -4,
})
_expect_false(scheduler.has_remaining_permit(SCHEDULER_SCRIPT.WATER_FINALIZE), "zero limit", failures)
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.M2_BUILD), "negative limit clamps", failures)
_expect_false(scheduler.try_consume_permit(&"unknown_lane"), "unknown lane", failures)
func _verify_cancellation(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({SCHEDULER_SCRIPT.WMO_BUILD: 2})
scheduler.cancel()
_expect_true(scheduler.is_cancelled(), "cancelled state", failures)
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.WMO_BUILD), "cancel rejects permit", failures)
scheduler.begin_frame({SCHEDULER_SCRIPT.WMO_BUILD: 5})
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.WMO_BUILD), 0, "begin frame cannot revive cancelled lifecycle", failures)
func _verify_scene_free_extraction(failures: Array[String]) -> void:
var scheduler_source := _read_text(SCHEDULER_PATH, failures)
_expect_true(scheduler_source.contains("extends RefCounted"), "scheduler is RefCounted", failures)
for forbidden_text in ["extends Node", "WorkerThreadPool", "ResourceLoader", "RenderingServer", "Mutex"]:
_expect_false(scheduler_source.contains(forbidden_text), "scheduler omits %s" % forbidden_text, failures)
var loader_source := _read_text(LOADER_PATH, failures)
_expect_true(loader_source.contains("begin_frame(_render_operation_limits_for_frame())"), "loader begins one budget frame", failures)
_expect_true(loader_source.contains("_render_budget_scheduler.cancel()"), "loader cancels scheduler on teardown", failures)
_expect_false(loader_source.contains("var ops_left"), "loader has no local operation counters", failures)
_expect_false(loader_source.contains("var finalize_budget"), "loader has no local finalize counter", failures)
for exported_limit_name in [
"tile_finalize_ops_per_tick",
"terrain_upgrade_finalize_ops_per_tick",
"terrain_control_splat_cache_finalize_ops_per_tick",
"terrain_splat_cache_finalize_ops_per_tick",
"terrain_splat_builds_per_tick",
"water_finalize_ops_per_tick",
"chunk_ops_per_tick",
"tiles_per_tick",
"tile_lod_ops_per_tick",
"tile_lod_remove_ops_per_tick",
"m2_animation_finalize_ops_per_tick",
"m2_mesh_finalize_ops_per_tick",
"m2_build_groups_per_tick",
"wmo_build_instances_per_tick",
"wmo_render_group_ops_per_tick",
"detail_asset_ops_per_tick",
]:
_expect_true(
loader_source.contains(": maxi(") and loader_source.contains(exported_limit_name),
"frame snapshot includes %s" % exported_limit_name,
failures
)
func _measure_permit_checks(failures: Array[String]) -> float:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC: PERFORMANCE_ITERATIONS})
var started_microseconds := Time.get_ticks_usec()
for _iteration in PERFORMANCE_ITERATIONS:
scheduler.try_consume_permit(SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(
elapsed_milliseconds <= MAXIMUM_PERMIT_CHECK_MILLISECONDS,
"permit checks stay within %.1f ms (actual %.3f)" % [
MAXIMUM_PERMIT_CHECK_MILLISECONDS,
elapsed_milliseconds,
],
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)
func _expect_false(actual_value: bool, label: String, failures: Array[String]) -> void:
if actual_value:
failures.append("%s expected false" % label)
@@ -0,0 +1 @@
uid://b27nncxfog7cv