From e52f703da5eb22763f20346fe03aef27be6ef721 Mon Sep 17 00:00:00 2001 From: sindoring Date: Thu, 16 Jul 2026 00:31:01 +0400 Subject: [PATCH] rnd(M03): extract render budget scheduler --- RENDER.md | 16 ++ docs/modules/README.md | 1 + docs/modules/render-budget-scheduler.md | 216 ++++++++++++++++++ docs/modules/world-renderer.md | 25 +- .../streaming/render_budget_scheduler.gd | 84 +++++++ .../streaming/render_budget_scheduler.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 149 ++++++------ src/tools/verify_render_budget_scheduler.gd | 175 ++++++++++++++ .../verify_render_budget_scheduler.gd.uid | 1 + 9 files changed, 594 insertions(+), 74 deletions(-) create mode 100644 docs/modules/render-budget-scheduler.md create mode 100644 src/render/streaming/render_budget_scheduler.gd create mode 100644 src/render/streaming/render_budget_scheduler.gd.uid create mode 100644 src/tools/verify_render_budget_scheduler.gd create mode 100644 src/tools/verify_render_budget_scheduler.gd.uid diff --git a/RENDER.md b/RENDER.md index 53fd503..65c9e26 100644 --- a/RENDER.md +++ b/RENDER.md @@ -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 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 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. diff --git a/docs/modules/README.md b/docs/modules/README.md index de4fe8c..00727c3 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -16,6 +16,7 @@ | Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.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) | +| 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) | | 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) | diff --git a/docs/modules/render-budget-scheduler.md b/docs/modules/render-budget-scheduler.md new file mode 100644 index 0000000..b386258 --- /dev/null +++ b/docs/modules/render-budget-scheduler.md @@ -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) diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 1c6946b..42c9ab1 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -5,9 +5,9 @@ | Field | Value | |---|---| | Status | Partial | -| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-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 | -| 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 | ## Purpose @@ -37,6 +37,7 @@ flowchart LR Loader --> Planner[StreamingTargetPlanner] Planner --> TargetPlan[StreamingTargetPlan] TargetPlan --> Loader + Loader --> Budget[RenderBudgetScheduler] Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders] Cache[Baked terrain/M2/WMO caches] --> Loader 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 | | 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 | +| 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 | 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 | @@ -131,7 +133,7 @@ flowchart TD T --> Q[Tile load queue] Q --> Parse[Worker parse/cache load] Parse --> R[Result queues] - R --> B[Per-frame budget scheduler] + R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] B --> WMO[WMO instance/group attach] @@ -173,7 +175,7 @@ sequenceDiagram participant Stream as StreamingWorldLoader internal participant Planner as StreamingTargetPlanner participant Worker as Worker task - participant Budget as Main-thread budget + participant Budget as RenderBudgetScheduler participant Render as SceneTree/RenderingServer Source->>Focus: sample explicit Godot position Focus->>Facade: set/refresh typed focus @@ -183,9 +185,12 @@ sequenceDiagram Stream->>Stream: queue priority and loaded-tile LOD/detail state Stream->>Worker: parse/load tile and detail data Worker-->>Stream: immutable result - Stream->>Budget: enqueue finalize operations - Budget->>Render: attach bounded terrain/M2/WMO/liquid work + Stream->>Budget: begin_frame(operation limits) + 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->>Budget: shutdown: cancel permit issuance Stream->>Worker: shutdown: wait for WorkerThreadPool tasks Stream->>Stream: shutdown: finish registered ResourceLoader requests Stream->>Render: clear queues and owned tree nodes/RIDs @@ -199,6 +204,8 @@ sequenceDiagram streamer, focus source, returned metrics, queues, caches, nodes or RIDs. - `StreamingTargetPlanner` is stateless and owns only call-local calculations; 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 the latest reference and converts it to `Vector3` only inside the render boundary. - 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. - Pure planner contract: center/corner/catalog/clamp/editor cases plus bounded 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 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. @@ -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 | | 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 | +| 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 @@ -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_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/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/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_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_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/m2_builder.gd` | Static M2 mesh/material construction | | `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment | diff --git a/src/render/streaming/render_budget_scheduler.gd b/src/render/streaming/render_budget_scheduler.gd new file mode 100644 index 0000000..ed23a5c --- /dev/null +++ b/src/render/streaming/render_budget_scheduler.gd @@ -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 diff --git a/src/render/streaming/render_budget_scheduler.gd.uid b/src/render/streaming/render_budget_scheduler.gd.uid new file mode 100644 index 0000000..90f6a2c --- /dev/null +++ b/src/render/streaming/render_budget_scheduler.gd.uid @@ -0,0 +1 @@ +uid://byaii7lec82i6 diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index d6b888c..5ff35db 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -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 STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd") const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd") +const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd") const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 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 _terrain_quality_log_accum := 0.0 var _shutting_down := false +var _render_budget_scheduler := RENDER_BUDGET_SCHEDULER_SCRIPT.new() var _last_refresh_focus_pos := Vector3.ZERO var _has_refresh_focus := false var _tile_mesh_cache: Dictionary = {} @@ -316,6 +318,7 @@ func _ready() -> void: func _process(delta: float) -> void: if _available_tiles.is_empty(): 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_start := Time.get_ticks_usec() if profile_enabled else 0 @@ -387,11 +390,33 @@ func _process(delta: float) -> void: func _exit_tree() -> void: _shutting_down = true + _render_budget_scheduler.cancel() _wait_for_tile_tasks() _clear_streamed_world() _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 ## loader after all asynchronous work has completed. Map refreshes retain these ## caches; only scene shutdown destroys them. @@ -909,33 +934,31 @@ func _rebuild_chunk_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. - 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()) - 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()) - tile_lod_remove_left -= 1 # Parse N ADT files this tick — the main throttle knob - var loads_left: int = tiles_per_tick - while loads_left > 0 and not _tile_load_queue.is_empty() and _tile_loading_tasks.size() < max_concurrent_tile_tasks: + while ( + 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()) - 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()) - tile_lod_create_left -= 1 # 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()) - ops_left -= 1 # Debug summary every 2 seconds _dbg_last_report += get_process_delta_time() @@ -1286,11 +1309,10 @@ func _load_tile_task(request: Dictionary) -> void: func _drain_tile_load_results() -> void: var results: Array = [] - var finalize_budget: int = maxi(1, tile_finalize_ops_per_tick) _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()) - finalize_budget -= 1 _tile_result_mutex.unlock() for result in results: @@ -1314,10 +1336,10 @@ func _drain_tile_load_results() -> void: _finalize_loaded_tile(request, result["data"]) var baked_ready: Array = [] - if finalize_budget <= 0: + if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE): return 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 var pending: Dictionary = _tile_loading_tasks[key] 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"]) 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: 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: var ready: Array[String] = [] - var budget := maxi(0, terrain_upgrade_finalize_ops_per_tick) - if budget <= 0: + if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE): return 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 var key := String(key_variant) var pending: Dictionary = _terrain_upgrade_tasks[key] var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) 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 for key in ready: @@ -1802,18 +1825,18 @@ func _drain_terrain_upgrade_results() -> void: func _drain_terrain_control_splat_cache_results() -> void: var ready: Array[String] = [] - var budget := maxi(0, terrain_control_splat_cache_finalize_ops_per_tick) - if budget <= 0: + if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE): return 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 var key := String(key_variant) var pending: Dictionary = _terrain_control_splat_cache_tasks[key] var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) 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 for key in ready: @@ -1868,18 +1891,18 @@ func _drain_terrain_control_splat_cache_results() -> void: func _drain_terrain_splat_cache_results() -> void: var ready: Array[String] = [] - var budget := maxi(0, terrain_splat_cache_finalize_ops_per_tick) - if budget <= 0: + if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE): return 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 var key := String(key_variant) var pending: Dictionary = _terrain_splat_cache_tasks[key] var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) 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 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: var results: Array = [] - var budget := maxi(0, terrain_splat_builds_per_tick) - if budget <= 0: + if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD): return _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()) - budget -= 1 _terrain_splat_result_mutex.unlock() var changed := false @@ -2067,14 +2089,13 @@ func _load_tile_water_task(key: String, adt_path: String) -> void: func _drain_water_load_results() -> void: var results: Array = [] - var budget := maxi(0, water_finalize_ops_per_tick) - if budget <= 0: + if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE): return _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()) - budget -= 1 _water_result_mutex.unlock() for result in results: @@ -3252,12 +3273,11 @@ func _enqueue_detail_asset_sync(key: String) -> void: func _process_detail_asset_queue() -> void: - var ops_left: int = maxi(0, detail_asset_ops_per_tick) - while ops_left > 0 and not _detail_asset_queue.is_empty(): + while not _detail_asset_queue.is_empty() and _render_budget_scheduler.try_consume_permit( + RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC): var key: String = String(_detail_asset_queue.pop_front()) _detail_asset_queued.erase(key) _process_detail_asset_key(key) - ops_left -= 1 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: _drain_wmo_render_loads() _drain_wmo_scene_loads() - var ops_left: int = maxi(0, wmo_build_instances_per_tick) - while ops_left > 0 and not _wmo_build_queue.is_empty(): + while _render_budget_scheduler.has_remaining_permit( + RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD) and not _wmo_build_queue.is_empty(): var tile_key: String = String(_wmo_build_queue.front()) if not _wmo_build_jobs.has(tile_key): _wmo_build_queue.pop_front() @@ -3439,7 +3459,7 @@ func _process_wmo_build_jobs() -> void: _tile_states[tile_key] = state _wmo_build_queue.pop_front() _wmo_build_queue.append(tile_key) - ops_left -= 1 + _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD) continue job["index"] = index + 1 @@ -3447,7 +3467,7 @@ func _process_wmo_build_jobs() -> void: state["wmo_refs"] = refs state["wmo_building"] = true _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(): 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: - var ops_left: int = maxi(0, wmo_render_group_ops_per_tick) - while ops_left > 0 and not _wmo_render_build_queue.is_empty(): + while _render_budget_scheduler.has_remaining_permit( + 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()) if not _wmo_render_build_jobs.has(unique_key): _wmo_render_build_queue.pop_front() @@ -3632,7 +3652,7 @@ func _process_wmo_render_build_jobs() -> void: _set_editor_owner_recursive(mesh_instance) job["mesh_index"] = mesh_index + 1 _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 var multimeshes: Array = render_resource.get("multimeshes") @@ -3664,7 +3684,7 @@ func _process_wmo_render_build_jobs() -> void: _set_editor_owner_recursive(multimesh_instance) job["multimesh_index"] = multimesh_index + 1 _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 _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_finalize_queue.append(pending) - var ops_left: int = maxi(0, m2_animation_finalize_ops_per_tick) - while ops_left > 0 and not _m2_animation_finalize_queue.is_empty(): + while not _m2_animation_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit( + RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE): var pending: Dictionary = _m2_animation_finalize_queue.pop_front() 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): - ops_left -= 1 continue if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: _m2_static_animation_cache[normalized_rel] = true - ops_left -= 1 continue 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 if debug_streaming: print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()]) - ops_left -= 1 continue node.free() _m2_static_animation_cache[normalized_rel] = true - ops_left -= 1 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_finalize_queue.append(pending) - var ops_left: int = maxi(0, m2_mesh_finalize_ops_per_tick) - while ops_left > 0 and not _m2_mesh_finalize_queue.is_empty(): + while not _m2_mesh_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit( + RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE): var pending: Dictionary = _m2_mesh_finalize_queue.pop_front() var normalized_rel := String(pending.get("normalized", "")) if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel): - ops_left -= 1 continue if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: _m2_missing_cache[normalized_rel] = true - ops_left -= 1 continue 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) else: _m2_missing_cache[normalized_rel] = true - ops_left -= 1 func _process_m2_build_jobs() -> void: - var ops_left: int = maxi(0, m2_build_groups_per_tick) - while ops_left > 0 and not _m2_build_queue.is_empty(): + while _render_budget_scheduler.has_remaining_permit( + RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) and not _m2_build_queue.is_empty(): var key: String = String(_m2_build_queue.front()) if not _m2_build_jobs.has(key): _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): _m2_build_queue.pop_front() _m2_build_queue.append(key) - ops_left -= 1 + _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) continue var batch_size: int = ( 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): _m2_build_queue.pop_front() _m2_build_queue.append(key) - ops_left -= 1 + _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) continue else: _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: job["offset"] = offset + consumed _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(): _finish_m2_build_job(key) diff --git a/src/tools/verify_render_budget_scheduler.gd b/src/tools/verify_render_budget_scheduler.gd new file mode 100644 index 0000000..3810b2a --- /dev/null +++ b/src/tools/verify_render_budget_scheduler.gd @@ -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) diff --git a/src/tools/verify_render_budget_scheduler.gd.uid b/src/tools/verify_render_budget_scheduler.gd.uid new file mode 100644 index 0000000..2030f07 --- /dev/null +++ b/src/tools/verify_render_budget_scheduler.gd.uid @@ -0,0 +1 @@ +uid://b27nncxfog7cv