rnd(M03): extract render budget scheduler
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user