Compare commits

...

10 Commits

14 changed files with 1030 additions and 83 deletions
+31
View File
@@ -903,6 +903,37 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- Cache versions, quality profiles, queue priority and visible rendering rules - Cache versions, quality profiles, queue priority and visible rendering rules
are unchanged by the extraction. are unchanged by the extraction.
## 2026-07-16 RenderBudgetScheduler Extraction
- The scene-free `RenderBudgetScheduler` now owns the per-frame counters for 16
named renderer operation lanes. It owns no queue, task, cache, Node or RID.
- `StreamingWorldLoader` snapshots its existing exported limits once per process
frame and asks for a boolean permit immediately before each historically
budgeted operation.
- Chunk removals and creations intentionally share one `chunk_geometry` lane;
the existing removal-first drain order and combined limit are preserved.
- Loader teardown cancels permit issuance before waiting for asynchronous work.
Existing worker/result staleness checks and resource cleanup remain unchanged.
- Asset-free contracts cover exact bounds, independent/shared lanes, frame reset,
invalid inputs, terminal cancellation, detached diagnostics and hot-path timing.
- Defaults, quality presets, queue order, cache versions and visible rendering
rules are unchanged. Asset-backed p95/p99 comparison remains required.
## 2026-07-16 Streamer Internal Access Gate
- `verify_renderer_internal_access.gd` derives private queue, task, cache and
tile-state field names from `StreamingWorldLoader` declarations and rejects
external member/reflection access in gameplay and Godot `EditorPlugin` package sources.
- The gate follows the implementation inventory instead of duplicating a manual
list, so a newly named private renderer field is covered automatically when it
matches those ownership categories.
- Scene composition may still reference the loader script and the facade's
implementation path; those are composition details, not mutable queue access.
- Renderer diagnostic probes are outside this gameplay/editor checklist.
`probe_render_terrain_height.gd` remains a documented temporary exception until
readiness and ground-query facade contracts replace its internal inspection.
- This is a source-only boundary check and changes no renderer behavior or fidelity.
## Practical Rule For Future Work ## Practical Rule For Future Work
If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it.
@@ -0,0 +1,106 @@
# M03-RND-INTERNAL-ACCESS-GATE-001 — Streamer internal access gate
<!-- OPENWC_CLAIM:M03-RND-INTERNAL-ACCESS-GATE-001:sindo-main-codex:2026-07-18 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-streamer-internal-access-gate`
- Lease expires UTC: 2026-07-18
- Integrator: M03 milestone integrator
## Outcome
Add an automated repository boundary gate proving gameplay code and Godot
`EditorPlugin` implementations do not access `StreamingWorldLoader` private
queue, task, cache or tile-state fields.
## Non-goals
- Change renderer behavior, queues, jobs, cache formats or facade contracts.
- Ban scene composition references to the loader script or facade adapter path.
- Migrate renderer-only diagnostic probes that inspect internal readiness/terrain;
those require the later metrics/ground-query facade contract.
- Mark M03 complete.
## Paths
- Exclusive: `src/tools/verify_renderer_internal_access.gd`, this claim
- Shared/hotspots: `docs/modules/world-renderer.md`, `RENDER.md`,
`targets/03-renderer-facade.md`
- Read-only scan scope: `src/gameplay/**/*.gd`, `src/editor/**/*.gd`, Godot
`EditorPlugin` package scripts under `src/**/*.gd` and `addons/**/*.gd`, and
the streamer field declarations
## Contracts and data
- Input: repository GDScript source and actual private streamer declarations
- Output: deterministic pass/fail diagnostics with consumer path and symbol
- Runtime, scene, cache, coordinate and asset contracts: unchanged
## Dependencies
- Requires: integrated facade and scheduler master `a023d76`
- Blocks: safe service extraction and M03 dependency regression acceptance
- External state: none
## Verification
- Commands: dedicated internal-access gate, facade regression, coordination,
documentation and diff gates
- Fixtures: gameplay source discovery, EditorPlugin discovery and synthetic
forbidden symbol detection
- Fidelity evidence: source-only enforcement; no runtime or visual code changes
- Performance budget: one bounded repository source scan in a headless test process
## Documentation deliverables
- World-renderer boundary/verification/known-gap/source-map updates
- Renderer implementation note documenting diagnostic-tool exception
- No new module spec because this package adds a verifier to the existing renderer module
## Simplicity and naming
- Important name: `verify_renderer_internal_access.gd`
- Simplest approach: derive forbidden fields from the current streamer instead
of maintaining a second hand-written queue list
- Rejected complexity: language plugin, custom linter framework or runtime proxy
- Unavoidable complexity: recursive source discovery expands each actual
`EditorPlugin` root to its package scripts while excluding reference mirrors
## Status
- State: ready-for-review
- Done: derived 43 private renderer symbols, scanned seven gameplay and nine
EditorPlugin-package sources, added synthetic positive/negative fixtures and docs
- Next: M03 integrator reviews and merges `b58bf2c`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-INTERNAL-ACCESS-GATE-001:b58bf2c -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-INTERNAL-ACCESS-GATE-001:7e35de7 -->
## Handoff
- Commit: `b58bf2c`
- Results: repository gate derives private queue/task/cache/state declarations
from the streamer and rejects external member/reflection access in gameplay and
EditorPlugin package sources. Current scope passes with `43/7/9` inventory counts.
- Verification: internal access, facade and budget scheduler headless contracts;
Godot editor scan with local ignored dependencies; coordination, documentation
and diff gates passed.
- Fidelity: source-only enforcement changes no runtime configuration, queue order,
cache version, placement, material, animation or visibility behavior. No
build-12340 parity claim is added.
- External/local inputs: ignored native DLL and two generated ADT resource scripts
were copied from master only for facade/editor smoke and then removed. Proprietary
extracted world data was unavailable, producing expected degraded-data diagnostics.
- Remaining risks: renderer diagnostic terrain probe remains an explicit exception;
whitespace-obfuscated dynamic reflection is outside the lightweight source gate;
future non-EditorPlugin authoring code is already prohibited by repository policy.
- Documentation: world-renderer boundary/verification/capability/known-gap/source
map and `RENDER.md` implementation note updated; no new module was introduced.
- Integration: accepted in master merge `7e35de7`; post-merge access gate
`private_symbols=43 gameplay=7 editor_sources=9`, facade/scheduler/planner and
repository gates passed.
@@ -0,0 +1,116 @@
# M03-RND-SCHEDULER-001 — Bounded render budget scheduler
<!-- OPENWC_CLAIM:M03-RND-SCHEDULER-001:sindo-main-codex:2026-07-18 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-render-budget-scheduler`
- Lease expires UTC: 2026-07-18
- Integrator: M03 milestone integrator
## Outcome
Extract the streamer's per-frame main-thread operation quotas into a scene-free
`RenderBudgetScheduler`, preserving existing queue priority and quota values and
stopping permit issuance after renderer teardown cancellation.
## Non-goals
- Move queue, worker-task, cache, Node, Resource or RID ownership out of the streamer.
- Change queue ordering, concurrency limits, exported defaults or visual behavior.
- Add a generic job framework, priority graph, time-based adaptive budgeting or dependency.
- Change cache formats, renderer profiles or target milestone status.
## Paths
- Exclusive: `src/render/streaming/render_budget_scheduler.gd`,
`src/tools/verify_render_budget_scheduler.gd`,
`docs/modules/render-budget-scheduler.md`, this claim
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`,
`targets/03-renderer-facade.md`
- Generated/ignored: `.godot`, native DLL, generated cache resources and
proprietary renderer corpus
## Contracts and data
- Input: immutable per-frame map from documented operation lane IDs to
non-negative operation limits
- Output: boolean permits and remaining/consumed diagnostic snapshots
- Lifecycle: `begin_frame` resets permits; `cancel` permanently rejects permits
for the current scheduler instance
- Queue contents, ordering, task concurrency and schema/cache versions: unchanged
- Consumer: `StreamingWorldLoader` main-thread queue/finalize drains
## Dependencies
- Requires: integrated M03 facade and streaming planner on master `650d584`
- Blocks: renderer performance regression gate and per-service extraction
- External state: none; tests use synthetic lane limits and no world assets
## Verification
- Commands: dedicated scheduler verifier, facade/planner/focus/coordinate
regressions, renderer manifest/dry-run and repository gates
- Fixtures: shared lane priority, independent lanes, zero/negative limits,
frame reset, unknown lane and cancellation
- Fidelity evidence: operation defaults and loop order are captured before
migration; all seven renderer checkpoint plans remain valid
- Performance budget: permit checks are constant-time dictionary operations and
create no Node, Resource, worker task, cache entry or RID
## Documentation deliverables
- Inline API docs for scheduler lifecycle, permits and diagnostics
- New module specification with input/output table, data-flow, sequence and
lifecycle diagrams
- Updated world-renderer flow/ownership/source map, module registry and renderer notes
## Simplicity and naming
- Important name: `RenderBudgetScheduler`
- Simplest approach: one small main-thread service with named quota lanes
- Rejected complexity: generic job graph, callbacks, queue ownership, adaptive
timing controller, service container or third-party dependency
- Unavoidable complexity: chunk removal and creation intentionally share one
lane so removal retains priority while consuming the same historical budget
- Measured optimization evidence: no optimization claim; exact bounded extraction
## Status
- State: ready-for-review
- Done: extracted all 16 historical operation quotas, migrated ordered drains,
added terminal teardown cancellation, asset-free verification and module docs
- Next: M03 integrator reviews and merges `e52f703`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-SCHEDULER-001:e52f703 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-SCHEDULER-001:7ece2ab -->
## Handoff
- Commit: `e52f703`
- Results: `RenderBudgetScheduler` owns only per-frame lane counters and terminal
cancellation; `StreamingWorldLoader` retains queue order, task/cache/node/RID
ownership and operation execution. Chunk removal/create share one historical budget.
- Verification: scheduler `cases=6 iterations=20000 elapsed_ms=7.495`; planner
`cases=5 average_ms=1.879`; facade/focus contracts passed; coordinate boundary
`files=111 consumers=6`; renderer manifest `checkpoints=7 coverage=7 caches=7`;
shutdown ownership, documentation, coordination and diff gates passed.
- Fidelity: exported defaults, quality presets, queue order, operation sites,
cache versions and visible rendering rules are unchanged. Seven checkpoint
plans remain valid. No build-12340 parity claim is added.
- External/local inputs: ignored native DLL and two generated ADT resource scripts
were copied from master only for the worktree parse/smoke and then removed.
Proprietary extracted ADT data was unavailable, producing expected degraded-data diagnostics.
- Remaining risks: queue storage length and worker concurrency remain streamer-owned;
cancellation stops permit issuance but does not interrupt in-flight work; exact
p95/p99 impact still requires an asset-backed baseline.
- Documentation: new scheduler module spec with API, input/output, data-flow,
sequence, lifecycle, ownership, cancellation, performance and source-map sections;
world renderer, module registry and renderer implementation notes updated.
- Integration: accepted in master merge `7ece2ab`; post-merge scheduler timing
`cases=6 iterations=20000 elapsed_ms=10.216`, all listed regressions and gates passed.
+1
View File
@@ -16,6 +16,7 @@
| Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) | | Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) |
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) |
| Streaming target planner | Implemented | [`streaming-target-planner.md`](streaming-target-planner.md) | | Streaming target planner | Implemented | [`streaming-target-planner.md`](streaming-target-planner.md) |
| Render budget scheduler | Implemented | [`render-budget-scheduler.md`](render-budget-scheduler.md) |
| Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) | | Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) |
| Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) | | Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
| UI/Lua/audio | Prototype/Planned | [`../../targets/roadmap/05-ui-lua-audio.md`](../../targets/roadmap/05-ui-lua-audio.md) | | UI/Lua/audio | Prototype/Planned | [`../../targets/roadmap/05-ui-lua-audio.md`](../../targets/roadmap/05-ui-lua-audio.md) |
+216
View File
@@ -0,0 +1,216 @@
# Render Budget Scheduler
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | `M03-RND-SCHEDULER-001` |
| Owners | Renderer workstream / M03 integrator |
| Last verified | Worktree `work/sindo-main-codex/m03-render-budget-scheduler`, 2026-07-16 |
| Profiles/capabilities | Runtime and Editor main-thread operation quotas; profile values supplied by streamer composition |
## Purpose
Issue a bounded number of permits for each renderer operation lane during one
process frame. The scheduler centralizes quota accounting and teardown
cancellation without taking ownership of queues or render work.
## Non-goals
- Own, sort, drain or cap the stored length of a renderer queue.
- Submit, cancel or await worker and `ResourceLoader` tasks.
- Execute callbacks or mutate the SceneTree, resources, caches or RIDs.
- Adapt limits from elapsed time or create a general job/dependency framework.
- Select renderer quality or compatibility profiles.
## Context and boundaries
```mermaid
flowchart LR
Config[Streamer exported per-frame limits] --> Frame[operation limit snapshot]
Frame --> Scheduler[RenderBudgetScheduler]
Queues[StreamingWorldLoader owned queues] --> Drains[Ordered queue drains]
Scheduler -->|boolean permits| Drains
Drains --> Render[Main-thread finalize/attach/evict]
Shutdown[StreamingWorldLoader exit] -->|cancel| Scheduler
Scheduler -. does not own .-> Queues
```
Allowed dependencies are Godot value containers and `RefCounted`. The module
must not depend on `Node`, `WorkerThreadPool`, `ResourceLoader`, `RenderingServer`,
mutexes, renderer assets or streamer queue fields.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Failure behavior |
|---|---|---|---|---|
| Lane constants | `StringName` constants | Stable operation categories used in the frame limit map | Process lifetime | Unknown lanes have zero permits |
| `begin_frame` | Method | Replaces all limits and clears consumption diagnostics for one frame | Main thread; once before drains | Negative limits clamp to zero; cannot revive a cancelled instance |
| `has_remaining_permit` | Query | Tests a lane without consuming | Main thread; current frame | Unknown/exhausted/cancelled returns `false` |
| `try_consume_permit` | Mutation | Atomically checks and consumes one caller operation permit | Main thread; current frame | Unknown/exhausted/cancelled returns `false` |
| `remaining_permits` | Query | Returns a lane's current unconsumed count | Main thread; current frame | Unknown/cancelled returns zero |
| `consumed_permits_snapshot` | Query | Returns detached per-lane diagnostics | Caller owns returned dictionary | Mutation cannot affect scheduler state |
| `cancel` | Lifecycle method | Permanently stops new permits and clears remaining limits | Main thread; renderer teardown | Idempotent; does not interrupt in-flight caller work |
| `is_cancelled` | Query | Reports terminal lifecycle state | Scheduler lifetime | No failure |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `Dictionary[StringName, int]` operation limits | `StreamingWorldLoader` configuration snapshot | Scheduler | Copied by scheduler | One process frame |
| Input | Lane ID | Ordered streamer drain | Scheduler | Constant value | One permit check |
| Input | Cancellation | Streamer `_exit_tree` | Scheduler | Terminal flag | Renderer instance lifetime |
| Output | Permit boolean | Scheduler | Streamer drain loop | Scalar | One attempted operation |
| Output | Remaining count | Scheduler | Streamer/tests | Scalar | Current frame |
| Output | Consumed snapshot | Scheduler | Diagnostics/tests | Detached caller-owned copy | Current frame snapshot |
The scheduler has no I/O, logging, SceneTree, task, cache or GPU side effects.
## Data flow
```mermaid
flowchart TD
Exports[Exported operation limits] --> Limits[_render_operation_limits_for_frame]
Limits --> Begin[begin_frame]
Begin --> Remaining[remaining permits by lane]
Queue[Non-empty caller-owned queue] --> Check[try_consume_permit]
Remaining --> Check
Check -->|true| Execute[Caller performs one operation]
Check -->|false| Defer[Operation remains queued]
Execute --> Consumed[consumed permits diagnostics]
```
## Lifecycle and state
```mermaid
stateDiagram-v2
[*] --> Ready
Ready --> FrameActive: begin_frame
FrameActive --> FrameActive: consume/check
FrameActive --> FrameActive: begin_frame resets limits
Ready --> Cancelled: cancel
FrameActive --> Cancelled: cancel
Cancelled --> Cancelled: begin_frame/check/cancel
Cancelled --> [*]
```
Cancellation is intentionally terminal. A new renderer scene creates a new
scheduler instance; an old instance cannot accidentally resume after teardown.
## Main sequence
```mermaid
sequenceDiagram
participant Stream as StreamingWorldLoader
participant Budget as RenderBudgetScheduler
participant Queue as Loader-owned queues
participant Render as SceneTree/RenderingServer
Stream->>Budget: begin_frame(operation limits)
loop existing fixed drain order
Stream->>Queue: inspect next operation
Stream->>Budget: try_consume_permit(lane)
Budget-->>Stream: true or false
Stream->>Render: execute one operation when true
end
Stream->>Budget: cancel() during _exit_tree
Budget-->>Stream: later permits rejected
Stream->>Stream: await tasks and release owned resources
```
## Ownership, threading and resources
- `StreamingWorldLoader` owns every queue, task registry, cache, Node, Resource and RID.
- The scheduler owns two small per-frame counter dictionaries and one terminal flag.
- The streamer preserves operation order. In particular, chunk removals and
creations share `CHUNK_GEOMETRY`, with removals drained first.
- All current scheduler calls occur on the Godot main thread. The service has no
mutex and is not safe for concurrent mutation.
- A scheduler instance lives exactly as long as its loader instance.
## Errors, cancellation and recovery
| Condition | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Negative/zero limit | `begin_frame` clamp | Lane issues no permits | Contract verifier | Correct configuration or accept disabled lane |
| Unknown lane | Permit lookup miss | Returns `false`/zero | Contract verifier | Add the lane to the frame snapshot |
| Exhausted lane | Remaining count is zero | Work remains in caller queue for a later frame | Queue-depth/hitch metrics | Next `begin_frame` replenishes permits |
| Renderer teardown | Streamer calls `cancel` before task waits | New permits stop immediately | Shutdown ownership regression | New scene creates a new scheduler |
| In-flight operation at cancel | Caller already consumed permit | Scheduler does not interrupt it | Ownership docs | Caller completes/cleans it through existing shutdown path |
This cancellation scope is permit issuance, not worker cancellation. Existing
task/result staleness and shutdown waits remain streamer responsibilities.
## Configuration and capabilities
The streamer's existing exported values populate 16 lanes: tile finalization,
terrain upgrade/control-splat/splat-cache/splat-build, water finalization, shared
chunk geometry, tile-load starts, tile LOD create/remove, M2 animation/mesh
finalization and build, WMO instance/group build, and detail synchronization.
Defaults and quality-preset assignments are unchanged. `tile_finalize` retains
its historical minimum of one; other negative values behave as zero.
## Persistence, cache and migration
The module is runtime-only and serializes nothing. It changes no cache, asset,
scene, protocol or database format and requires no version bump or migration.
## Diagnostics and observability
- `consumed_permits_snapshot` exposes detached per-frame counts for tests and
future facade metrics without exposing mutable scheduler state.
- Existing queue depths, named hitch sections and `HITCH`/`PERF` logs remain in
`StreamingWorldLoader`.
- No scheduler logging occurs on the hot permit path.
## Verification
- `src/tools/verify_render_budget_scheduler.gd` covers exact exhaustion,
independent lanes, shared removal/create priority, frame reset, invalid/unknown
lanes, detached diagnostics, terminal cancellation and source boundaries.
- The verifier performs 20,000 permit checks with a generous 150 ms regression
ceiling on the headless runner; this is a guard, not a renderer p95 claim.
- Integration regressions must retain the seven renderer checkpoint plans and
existing focus/planner/facade contracts.
- Fidelity evidence is behavior-preserving: exported values, process order,
cache versions and visual operations are unchanged. No build-12340 parity
claim follows from this extraction.
## Extension points
- Add facade consumption metrics using detached snapshots.
- Move a service's queue drain behind its own boundary while retaining these lane IDs.
- Add measured time-aware policy only after asset-backed p95/p99 evidence proves
fixed operation counts insufficient; do not embed callbacks in this scheduler.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Per-lane frame bounds | Implemented | Exact exhaustion and independent-lane contracts | Asset-backed p95/p99 baseline remains |
| Shared removal/create priority | Implemented | Synthetic shared `CHUNK_GEOMETRY` contract | Other service priorities remain streamer-owned |
| Teardown permit cancellation | Implemented | Terminal cancellation contract and loader source gate | Worker stale-result cancellation remains streamer-owned |
| Detached consumption diagnostics | Implemented | Snapshot isolation contract | Not yet exposed through facade metrics |
## Known gaps and risks
- Queue storage length is not capped; only per-frame draining is bounded.
- Worker concurrency and stale-result cancellation remain existing streamer logic.
- Exact p95/p99 impact requires local extracted assets and paired baseline runs.
- Lane IDs are dynamic dictionary keys; the verifier guards the current contract.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/streaming/render_budget_scheduler.gd` | Scene-free lane counters, permits, diagnostics and cancellation |
| `src/scenes/streaming/streaming_world_loader.gd` | Limit composition, fixed drain order, queue/resource ownership and operation execution |
| `src/tools/verify_render_budget_scheduler.gd` | Asset-free behavior, dependency and bounded timing regression |
## Related decisions and references
- [`world-renderer.md`](world-renderer.md)
- [`streaming-target-planner.md`](streaming-target-planner.md)
- [`../../targets/03-renderer-facade.md`](../../targets/03-renderer-facade.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
- [`../CODING_STANDARD.md`](../CODING_STANDARD.md)
+29 -6
View File
@@ -5,9 +5,9 @@
| Field | Value | | Field | Value |
|---|---| |---|---|
| Status | Partial | | Status | Partial |
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-RND-FACADE-001` | | Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-RND-FACADE-001`; `M03-RND-STREAMING-PLANNER-001`; `M03-RND-SCHEDULER-001` |
| Owners | Renderer workstream / milestone integrator | | Owners | Renderer workstream / milestone integrator |
| Last verified | Worktree `work/sindo-main-codex/m03-renderer-facade`, 2026-07-15 | | Last verified | Worktree `work/sindo-main-codex/m03-render-budget-scheduler`, 2026-07-16 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose ## Purpose
@@ -37,6 +37,7 @@ flowchart LR
Loader --> Planner[StreamingTargetPlanner] Loader --> Planner[StreamingTargetPlanner]
Planner --> TargetPlan[StreamingTargetPlan] Planner --> TargetPlan[StreamingTargetPlan]
TargetPlan --> Loader TargetPlan --> Loader
Loader --> Budget[RenderBudgetScheduler]
Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders] Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders]
Cache[Baked terrain/M2/WMO caches] --> Loader Cache[Baked terrain/M2/WMO caches] --> Loader
Native --> Parsed[Parsed tile/model data] Native --> Parsed[Parsed tile/model data]
@@ -67,6 +68,8 @@ Forbidden dependencies:
Runtime, capture and probe callers use `WorldRenderFacade` for focus and metrics. Runtime, capture and probe callers use `WorldRenderFacade` for focus and metrics.
`StreamingWorldLoader` remains the internal scene implementation while later M03 `StreamingWorldLoader` remains the internal scene implementation while later M03
packages add environment, entity-visual and ground-query contracts. packages add environment, entity-visual and ground-query contracts.
Gameplay modules and Godot `EditorPlugin` package sources are repository-gated
from externally reading/writing loader-private queue, task, cache and tile-state fields.
| Symbol | Kind | Purpose | Thread/lifetime | Errors | | Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---| |---|---|---|---|---|
@@ -98,6 +101,7 @@ loader configuration remains transitional composition data, not a caller API.
| Input | Baked resources | Cache build tools | Streamer/builders | Shared immutable resource/cache entry | Session/cache lifetime | | Input | Baked resources | Cache build tools | Streamer/builders | Shared immutable resource/cache entry | Session/cache lifetime |
| Test input | Server-spawn render manifest | Coordinate fixture/verifier | Checkpoint capture tool | Repository-owned diagnostic data | Test-process lifetime | | Test input | Server-spawn render manifest | Coordinate fixture/verifier | Checkpoint capture tool | Repository-owned diagnostic data | Test-process lifetime |
| Output | `StreamingTargetPlan` | `StreamingTargetPlanner` | Streamer queue/state apply | Immutable ephemeral value | One refresh | | Output | `StreamingTargetPlan` | `StreamingTargetPlanner` | Streamer queue/state apply | Immutable ephemeral value | One refresh |
| Internal control | Per-frame lane limits and boolean permits | Streamer configuration / `RenderBudgetScheduler` | Ordered streamer drains | Scheduler-owned counters | Main thread/frame |
| Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame | | Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame |
| Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach | | Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach |
| Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand | | Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand |
@@ -131,7 +135,7 @@ flowchart TD
T --> Q[Tile load queue] T --> Q[Tile load queue]
Q --> Parse[Worker parse/cache load] Q --> Parse[Worker parse/cache load]
Parse --> R[Result queues] Parse --> R[Result queues]
R --> B[Per-frame budget scheduler] R --> B[RenderBudgetScheduler permits]
B --> Terrain[Terrain attach/upgrade] B --> Terrain[Terrain attach/upgrade]
B --> M2[M2 group/MultiMesh attach] B --> M2[M2 group/MultiMesh attach]
B --> WMO[WMO instance/group attach] B --> WMO[WMO instance/group attach]
@@ -173,7 +177,7 @@ sequenceDiagram
participant Stream as StreamingWorldLoader internal participant Stream as StreamingWorldLoader internal
participant Planner as StreamingTargetPlanner participant Planner as StreamingTargetPlanner
participant Worker as Worker task participant Worker as Worker task
participant Budget as Main-thread budget participant Budget as RenderBudgetScheduler
participant Render as SceneTree/RenderingServer participant Render as SceneTree/RenderingServer
Source->>Focus: sample explicit Godot position Source->>Focus: sample explicit Godot position
Focus->>Facade: set/refresh typed focus Focus->>Facade: set/refresh typed focus
@@ -183,9 +187,12 @@ sequenceDiagram
Stream->>Stream: queue priority and loaded-tile LOD/detail state Stream->>Stream: queue priority and loaded-tile LOD/detail state
Stream->>Worker: parse/load tile and detail data Stream->>Worker: parse/load tile and detail data
Worker-->>Stream: immutable result Worker-->>Stream: immutable result
Stream->>Budget: enqueue finalize operations Stream->>Budget: begin_frame(operation limits)
Budget->>Render: attach bounded terrain/M2/WMO/liquid work Stream->>Budget: try_consume_permit(lane)
Budget-->>Stream: true while lane remains bounded
Stream->>Render: attach permitted terrain/M2/WMO/liquid work
Stream->>Render: evict outside retention range Stream->>Render: evict outside retention range
Stream->>Budget: shutdown: cancel permit issuance
Stream->>Worker: shutdown: wait for WorkerThreadPool tasks Stream->>Worker: shutdown: wait for WorkerThreadPool tasks
Stream->>Stream: shutdown: finish registered ResourceLoader requests Stream->>Stream: shutdown: finish registered ResourceLoader requests
Stream->>Render: clear queues and owned tree nodes/RIDs Stream->>Render: clear queues and owned tree nodes/RIDs
@@ -199,6 +206,8 @@ sequenceDiagram
streamer, focus source, returned metrics, queues, caches, nodes or RIDs. streamer, focus source, returned metrics, queues, caches, nodes or RIDs.
- `StreamingTargetPlanner` is stateless and owns only call-local calculations; - `StreamingTargetPlanner` is stateless and owns only call-local calculations;
its immutable plan is consumed synchronously by the streamer. its immutable plan is consumed synchronously by the streamer.
- `RenderBudgetScheduler` owns only per-frame lane counters and a terminal
cancellation flag. The streamer retains queue ordering and all operation side effects.
- Focus producers own the immutable `StreamingFocus` reference; the loader retains - Focus producers own the immutable `StreamingFocus` reference; the loader retains
the latest reference and converts it to `Vector3` only inside the render boundary. the latest reference and converts it to `Vector3` only inside the render boundary.
- Worker tasks не должны менять SceneTree и shared Resource concurrently. - Worker tasks не должны менять SceneTree и shared Resource concurrently.
@@ -261,6 +270,12 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
- Unit/contract tests: streaming-focus contract/wiring, material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan. - Unit/contract tests: streaming-focus contract/wiring, material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan.
- Pure planner contract: center/corner/catalog/clamp/editor cases plus bounded - Pure planner contract: center/corner/catalog/clamp/editor cases plus bounded
High-like iteration timing without loading a world scene. High-like iteration timing without loading a world scene.
- Budget scheduler contract: exact lane exhaustion, shared chunk removal/create
priority, independent lanes, frame reset, invalid limits, terminal cancellation,
dependency boundaries and bounded permit timing without loading a world scene.
- Internal-access contract: derives the current streamer's private queue/task/
cache/state field inventory and rejects external member/reflection access from
gameplay or EditorPlugin package code.
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00 - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00
cold/warm checkpoints and one dedicated mapped server-spawn checkpoint. cold/warm checkpoints and one dedicated mapped server-spawn checkpoint.
- Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe. - Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe.
@@ -288,6 +303,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Golden server-spawn visualization | Implemented | Pinned AzerothCore fixture, validated renderer manifest and diagnostic origin-marker capture at ADT `(32,48)`/MCNK `(3,12)` | No TrinityCore row or original-client visual-parity claim | | Golden server-spawn visualization | Implemented | Pinned AzerothCore fixture, validated renderer manifest and diagnostic origin-marker capture at ADT `(32,48)`/MCNK `(3,12)` | No TrinityCore row or original-client visual-parity claim |
| Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain | | Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain |
| Pure streaming target planner | Implemented | `M03-RND-STREAMING-PLANNER-001` asset-free contract/performance regression | Queue scheduling and loaded-tile LOD application remain in streamer | | Pure streaming target planner | Implemented | `M03-RND-STREAMING-PLANNER-001` asset-free contract/performance regression | Queue scheduling and loaded-tile LOD application remain in streamer |
| Bounded render budget scheduler | Implemented | `M03-RND-SCHEDULER-001` asset-free permit/cancellation/performance regression | Queue storage, worker concurrency and operation execution remain in streamer/services |
| Gameplay/editor internal-access boundary | Implemented | `M03-RND-INTERNAL-ACCESS-GATE-001` scans actual private streamer declarations against gameplay and EditorPlugin sources | Renderer diagnostic probes remain a scoped exception pending ground-query/readiness facade contracts |
## Known gaps and risks ## Known gaps and risks
@@ -308,6 +325,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
- D3D12 descriptor issues remain. Explicit shutdown ownership removes the GUI capture's Node/resource/RID leaks in focused and seven-checkpoint runs; a timing-dependent ObjectDB warning for anonymous zero-reference `RefCounted` objects remains as a separate engine-teardown diagnostic. - D3D12 descriptor issues remain. Explicit shutdown ownership removes the GUI capture's Node/resource/RID leaks in focused and seven-checkpoint runs; a timing-dependent ObjectDB warning for anonymous zero-reference `RefCounted` objects remains as a separate engine-teardown diagnostic.
- M2/WMO/material/particle/ribbon/portal parity incomplete. - M2/WMO/material/particle/ribbon/portal parity incomplete.
- Public API is mostly exported configuration rather than stable contracts. - Public API is mostly exported configuration rather than stable contracts.
- `probe_render_terrain_height.gd` remains a renderer-only diagnostic exception
that reads tile readiness/terrain internals; it is excluded from the gameplay/
EditorPlugin gate until equivalent ground-query and readiness facade contracts exist.
## Source map ## Source map
@@ -317,11 +337,14 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation | | `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation |
| `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy | | `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy |
| `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets | | `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets |
| `src/render/streaming/render_budget_scheduler.gd` | Scene-free per-frame operation permits and terminal cancellation |
| `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment | | `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment |
| `src/domain/streaming/streaming_focus.gd` | Immutable camera-independent streaming position contract | | `src/domain/streaming/streaming_focus.gd` | Immutable camera-independent streaming position contract |
| `src/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool wiring regression | | `src/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool wiring regression |
| `src/tools/verify_world_render_facade.gd` | Facade delegation, snapshot isolation and consumer wiring regression | | `src/tools/verify_world_render_facade.gd` | Facade delegation, snapshot isolation and consumer wiring regression |
| `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression | | `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression |
| `src/tools/verify_render_budget_scheduler.gd` | Scheduler bounds, shared-lane priority, cancellation and timing regression |
| `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin boundary gate derived from private streamer fields |
| `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction | | `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction |
| `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction | | `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction |
| `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment | | `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment |
@@ -0,0 +1,84 @@
## Issues bounded, per-frame permits for renderer main-thread operation lanes.
##
## The scheduler owns counters only. Queue contents, operation ordering and the
## side effects performed after a permit remain owned by the caller. One instance
## belongs to one renderer lifecycle and must only be used on the main thread.
class_name RenderBudgetScheduler
extends RefCounted
const TILE_FINALIZE: StringName = &"tile_finalize"
const TERRAIN_UPGRADE_FINALIZE: StringName = &"terrain_upgrade_finalize"
const TERRAIN_CONTROL_SPLAT_FINALIZE: StringName = &"terrain_control_splat_finalize"
const TERRAIN_SPLAT_CACHE_FINALIZE: StringName = &"terrain_splat_cache_finalize"
const TERRAIN_SPLAT_BUILD: StringName = &"terrain_splat_build"
const WATER_FINALIZE: StringName = &"water_finalize"
const CHUNK_GEOMETRY: StringName = &"chunk_geometry"
const TILE_LOAD_START: StringName = &"tile_load_start"
const TILE_LOD_CREATE: StringName = &"tile_lod_create"
const TILE_LOD_REMOVE: StringName = &"tile_lod_remove"
const M2_ANIMATION_FINALIZE: StringName = &"m2_animation_finalize"
const M2_MESH_FINALIZE: StringName = &"m2_mesh_finalize"
const M2_BUILD: StringName = &"m2_build"
const WMO_BUILD: StringName = &"wmo_build"
const WMO_RENDER_GROUP_BUILD: StringName = &"wmo_render_group_build"
const DETAIL_ASSET_SYNC: StringName = &"detail_asset_sync"
var _remaining_permits_by_lane: Dictionary = {}
var _consumed_permits_by_lane: Dictionary = {}
var _is_cancelled := false
## Replaces all lane limits for a new frame. Negative limits are treated as zero.
## Calling this after [method cancel] leaves the scheduler cancelled.
func begin_frame(operation_limits_by_lane: Dictionary) -> void:
_remaining_permits_by_lane.clear()
_consumed_permits_by_lane.clear()
if _is_cancelled:
return
for lane_variant in operation_limits_by_lane:
var lane := StringName(lane_variant)
_remaining_permits_by_lane[lane] = maxi(0, int(operation_limits_by_lane[lane_variant]))
_consumed_permits_by_lane[lane] = 0
## Returns whether a lane can issue another permit without consuming it.
## Unknown lanes and cancelled schedulers return false.
func has_remaining_permit(lane: StringName) -> bool:
if _is_cancelled:
return false
return int(_remaining_permits_by_lane.get(lane, 0)) > 0
## Consumes one permit from [param lane] and returns true when it was granted.
## Unknown, exhausted or cancelled lanes return false without changing state.
func try_consume_permit(lane: StringName) -> bool:
if not has_remaining_permit(lane):
return false
_remaining_permits_by_lane[lane] = int(_remaining_permits_by_lane[lane]) - 1
_consumed_permits_by_lane[lane] = int(_consumed_permits_by_lane.get(lane, 0)) + 1
return true
## Returns the unconsumed permit count for one lane.
func remaining_permits(lane: StringName) -> int:
if _is_cancelled:
return 0
return int(_remaining_permits_by_lane.get(lane, 0))
## Returns a detached diagnostic snapshot of permits consumed in this frame.
func consumed_permits_snapshot() -> Dictionary:
return _consumed_permits_by_lane.duplicate()
## Permanently rejects new permits for this scheduler instance and clears limits.
## In-flight work remains the caller's responsibility and is not interrupted.
func cancel() -> void:
_is_cancelled = true
_remaining_permits_by_lane.clear()
## Returns whether [method cancel] ended this scheduler lifecycle.
func is_cancelled() -> bool:
return _is_cancelled
@@ -0,0 +1 @@
uid://byaii7lec82i6
+77 -64
View File
@@ -19,6 +19,7 @@ const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_ti
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd") const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd") const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd")
const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd") const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd")
const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5 const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
@@ -233,6 +234,7 @@ var _dbg_last_report := 0.0
var _runtime_stats_accum := 0.0 var _runtime_stats_accum := 0.0
var _terrain_quality_log_accum := 0.0 var _terrain_quality_log_accum := 0.0
var _shutting_down := false var _shutting_down := false
var _render_budget_scheduler := RENDER_BUDGET_SCHEDULER_SCRIPT.new()
var _last_refresh_focus_pos := Vector3.ZERO var _last_refresh_focus_pos := Vector3.ZERO
var _has_refresh_focus := false var _has_refresh_focus := false
var _tile_mesh_cache: Dictionary = {} var _tile_mesh_cache: Dictionary = {}
@@ -316,6 +318,7 @@ func _ready() -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if _available_tiles.is_empty(): if _available_tiles.is_empty():
return return
_render_budget_scheduler.begin_frame(_render_operation_limits_for_frame())
var profile_enabled := hitch_profiler_enabled and not Engine.is_editor_hint() var profile_enabled := hitch_profiler_enabled and not Engine.is_editor_hint()
var profile_start := Time.get_ticks_usec() if profile_enabled else 0 var profile_start := Time.get_ticks_usec() if profile_enabled else 0
@@ -387,11 +390,33 @@ func _process(delta: float) -> void:
func _exit_tree() -> void: func _exit_tree() -> void:
_shutting_down = true _shutting_down = true
_render_budget_scheduler.cancel()
_wait_for_tile_tasks() _wait_for_tile_tasks()
_clear_streamed_world() _clear_streamed_world()
_release_runtime_caches_for_shutdown() _release_runtime_caches_for_shutdown()
func _render_operation_limits_for_frame() -> Dictionary:
return {
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE: maxi(1, tile_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE: maxi(0, terrain_upgrade_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE: maxi(0, terrain_control_splat_cache_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE: maxi(0, terrain_splat_cache_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD: maxi(0, terrain_splat_builds_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE: maxi(0, water_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY: maxi(0, chunk_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOAD_START: maxi(0, tiles_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_CREATE: maxi(0, tile_lod_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_REMOVE: maxi(0, tile_lod_remove_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE: maxi(0, m2_animation_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE: maxi(0, m2_mesh_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD: maxi(0, m2_build_groups_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD: maxi(0, wmo_build_instances_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD: maxi(0, wmo_render_group_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC: maxi(0, detail_asset_ops_per_tick),
}
## Frees detached prototype nodes and releases render resources owned by this ## Frees detached prototype nodes and releases render resources owned by this
## loader after all asynchronous work has completed. Map refreshes retain these ## loader after all asynchronous work has completed. Map refreshes retain these
## caches; only scene shutdown destroys them. ## caches; only scene shutdown destroys them.
@@ -909,33 +934,31 @@ func _rebuild_chunk_queues() -> void:
func _process_queues() -> void: func _process_queues() -> void:
var ops_left: int = chunk_ops_per_tick
var tile_lod_create_left: int = tile_lod_ops_per_tick
var tile_lod_remove_left: int = tile_lod_remove_ops_per_tick
# Free stale terrain first so GPU descriptors/memory are reclaimed before new creates. # Free stale terrain first so GPU descriptors/memory are reclaimed before new creates.
while ops_left > 0 and not _chunk_remove_queue.is_empty(): while not _chunk_remove_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
_remove_chunk(_chunk_remove_queue.pop_back()) _remove_chunk(_chunk_remove_queue.pop_back())
ops_left -= 1
while tile_lod_remove_left > 0 and not _tile_lod_remove_queue.is_empty(): while not _tile_lod_remove_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_REMOVE):
_remove_tile_lod(_tile_lod_remove_queue.pop_back()) _remove_tile_lod(_tile_lod_remove_queue.pop_back())
tile_lod_remove_left -= 1
# Parse N ADT files this tick — the main throttle knob # Parse N ADT files this tick — the main throttle knob
var loads_left: int = tiles_per_tick while (
while loads_left > 0 and not _tile_load_queue.is_empty() and _tile_loading_tasks.size() < max_concurrent_tile_tasks: not _tile_load_queue.is_empty()
and _tile_loading_tasks.size() < max_concurrent_tile_tasks
and _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOAD_START)
):
_start_tile_load_async(_tile_load_queue.pop_front()) _start_tile_load_async(_tile_load_queue.pop_front())
loads_left -= 1
while tile_lod_create_left > 0 and not _tile_lod_create_queue.is_empty(): while not _tile_lod_create_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_CREATE):
_create_tile_lod(_tile_lod_create_queue.pop_front()) _create_tile_lod(_tile_lod_create_queue.pop_front())
tile_lod_create_left -= 1
# Create chunk meshes — sorted nearest-first so visible detail appears first # Create chunk meshes — sorted nearest-first so visible detail appears first
while ops_left > 0 and not _chunk_create_queue.is_empty(): while not _chunk_create_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
_create_chunk(_chunk_create_queue.pop_front()) _create_chunk(_chunk_create_queue.pop_front())
ops_left -= 1
# Debug summary every 2 seconds # Debug summary every 2 seconds
_dbg_last_report += get_process_delta_time() _dbg_last_report += get_process_delta_time()
@@ -1286,11 +1309,10 @@ func _load_tile_task(request: Dictionary) -> void:
func _drain_tile_load_results() -> void: func _drain_tile_load_results() -> void:
var results: Array = [] var results: Array = []
var finalize_budget: int = maxi(1, tile_finalize_ops_per_tick)
_tile_result_mutex.lock() _tile_result_mutex.lock()
while finalize_budget > 0 and not _tile_result_queue.is_empty(): while not _tile_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
results.append(_tile_result_queue.pop_front()) results.append(_tile_result_queue.pop_front())
finalize_budget -= 1
_tile_result_mutex.unlock() _tile_result_mutex.unlock()
for result in results: for result in results:
@@ -1314,10 +1336,10 @@ func _drain_tile_load_results() -> void:
_finalize_loaded_tile(request, result["data"]) _finalize_loaded_tile(request, result["data"])
var baked_ready: Array = [] var baked_ready: Array = []
if finalize_budget <= 0: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
return return
for key in _tile_loading_tasks.keys(): for key in _tile_loading_tasks.keys():
if baked_ready.size() >= finalize_budget: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
break break
var pending: Dictionary = _tile_loading_tasks[key] var pending: Dictionary = _tile_loading_tasks[key]
if pending.get("mode", "raw") != "baked" and pending.get("mode", "raw") != "stream": if pending.get("mode", "raw") != "baked" and pending.get("mode", "raw") != "stream":
@@ -1325,6 +1347,7 @@ func _drain_tile_load_results() -> void:
var status := ResourceLoader.load_threaded_get_status(pending["path"]) var status := ResourceLoader.load_threaded_get_status(pending["path"])
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
baked_ready.append(key) baked_ready.append(key)
for key in baked_ready: for key in baked_ready:
@@ -1743,17 +1766,17 @@ func _request_terrain_splat_cache(state: Dictionary) -> bool:
func _drain_terrain_upgrade_results() -> void: func _drain_terrain_upgrade_results() -> void:
var ready: Array[String] = [] var ready: Array[String] = []
var budget := maxi(0, terrain_upgrade_finalize_ops_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
if budget <= 0:
return return
for key_variant in _terrain_upgrade_tasks.keys(): for key_variant in _terrain_upgrade_tasks.keys():
if ready.size() >= budget: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
break break
var key := String(key_variant) var key := String(key_variant)
var pending: Dictionary = _terrain_upgrade_tasks[key] var pending: Dictionary = _terrain_upgrade_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
ready.append(key) ready.append(key)
var changed := false var changed := false
@@ -1802,17 +1825,17 @@ func _drain_terrain_upgrade_results() -> void:
func _drain_terrain_control_splat_cache_results() -> void: func _drain_terrain_control_splat_cache_results() -> void:
var ready: Array[String] = [] var ready: Array[String] = []
var budget := maxi(0, terrain_control_splat_cache_finalize_ops_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
if budget <= 0:
return return
for key_variant in _terrain_control_splat_cache_tasks.keys(): for key_variant in _terrain_control_splat_cache_tasks.keys():
if ready.size() >= budget: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
break break
var key := String(key_variant) var key := String(key_variant)
var pending: Dictionary = _terrain_control_splat_cache_tasks[key] var pending: Dictionary = _terrain_control_splat_cache_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
ready.append(key) ready.append(key)
var changed := false var changed := false
@@ -1868,17 +1891,17 @@ func _drain_terrain_control_splat_cache_results() -> void:
func _drain_terrain_splat_cache_results() -> void: func _drain_terrain_splat_cache_results() -> void:
var ready: Array[String] = [] var ready: Array[String] = []
var budget := maxi(0, terrain_splat_cache_finalize_ops_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
if budget <= 0:
return return
for key_variant in _terrain_splat_cache_tasks.keys(): for key_variant in _terrain_splat_cache_tasks.keys():
if ready.size() >= budget: if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
break break
var key := String(key_variant) var key := String(key_variant)
var pending: Dictionary = _terrain_splat_cache_tasks[key] var pending: Dictionary = _terrain_splat_cache_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
ready.append(key) ready.append(key)
var changed := false var changed := false
@@ -1956,14 +1979,13 @@ func _load_terrain_splat_task(key: String, adt_path: String) -> void:
func _drain_terrain_splat_results() -> void: func _drain_terrain_splat_results() -> void:
var results: Array = [] var results: Array = []
var budget := maxi(0, terrain_splat_builds_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD):
if budget <= 0:
return return
_terrain_splat_result_mutex.lock() _terrain_splat_result_mutex.lock()
while budget > 0 and not _terrain_splat_result_queue.is_empty(): while not _terrain_splat_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD):
results.append(_terrain_splat_result_queue.pop_front()) results.append(_terrain_splat_result_queue.pop_front())
budget -= 1
_terrain_splat_result_mutex.unlock() _terrain_splat_result_mutex.unlock()
var changed := false var changed := false
@@ -2067,14 +2089,13 @@ func _load_tile_water_task(key: String, adt_path: String) -> void:
func _drain_water_load_results() -> void: func _drain_water_load_results() -> void:
var results: Array = [] var results: Array = []
var budget := maxi(0, water_finalize_ops_per_tick) if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
if budget <= 0:
return return
_water_result_mutex.lock() _water_result_mutex.lock()
while budget > 0 and not _water_result_queue.is_empty(): while not _water_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
results.append(_water_result_queue.pop_front()) results.append(_water_result_queue.pop_front())
budget -= 1
_water_result_mutex.unlock() _water_result_mutex.unlock()
for result in results: for result in results:
@@ -3252,12 +3273,11 @@ func _enqueue_detail_asset_sync(key: String) -> void:
func _process_detail_asset_queue() -> void: func _process_detail_asset_queue() -> void:
var ops_left: int = maxi(0, detail_asset_ops_per_tick) while not _detail_asset_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
while ops_left > 0 and not _detail_asset_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC):
var key: String = String(_detail_asset_queue.pop_front()) var key: String = String(_detail_asset_queue.pop_front())
_detail_asset_queued.erase(key) _detail_asset_queued.erase(key)
_process_detail_asset_key(key) _process_detail_asset_key(key)
ops_left -= 1
func _process_detail_asset_key(key: String) -> void: func _process_detail_asset_key(key: String) -> void:
@@ -3341,8 +3361,8 @@ func _register_tile_wmos(state: Dictionary, wmo_names: PackedStringArray, wmo_pl
func _process_wmo_build_jobs() -> void: func _process_wmo_build_jobs() -> void:
_drain_wmo_render_loads() _drain_wmo_render_loads()
_drain_wmo_scene_loads() _drain_wmo_scene_loads()
var ops_left: int = maxi(0, wmo_build_instances_per_tick) while _render_budget_scheduler.has_remaining_permit(
while ops_left > 0 and not _wmo_build_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD) and not _wmo_build_queue.is_empty():
var tile_key: String = String(_wmo_build_queue.front()) var tile_key: String = String(_wmo_build_queue.front())
if not _wmo_build_jobs.has(tile_key): if not _wmo_build_jobs.has(tile_key):
_wmo_build_queue.pop_front() _wmo_build_queue.pop_front()
@@ -3439,7 +3459,7 @@ func _process_wmo_build_jobs() -> void:
_tile_states[tile_key] = state _tile_states[tile_key] = state
_wmo_build_queue.pop_front() _wmo_build_queue.pop_front()
_wmo_build_queue.append(tile_key) _wmo_build_queue.append(tile_key)
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD)
continue continue
job["index"] = index + 1 job["index"] = index + 1
@@ -3447,7 +3467,7 @@ func _process_wmo_build_jobs() -> void:
state["wmo_refs"] = refs state["wmo_refs"] = refs
state["wmo_building"] = true state["wmo_building"] = true
_tile_states[tile_key] = state _tile_states[tile_key] = state
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD)
if int(job["index"]) >= wmo_placements.size(): if int(job["index"]) >= wmo_placements.size():
state["wmo_refs"] = refs state["wmo_refs"] = refs
@@ -3589,8 +3609,8 @@ func _start_wmo_render_build(unique_key: String, root: Node3D, render_resource:
func _process_wmo_render_build_jobs() -> void: func _process_wmo_render_build_jobs() -> void:
var ops_left: int = maxi(0, wmo_render_group_ops_per_tick) while _render_budget_scheduler.has_remaining_permit(
while ops_left > 0 and not _wmo_render_build_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD) and not _wmo_render_build_queue.is_empty():
var unique_key := String(_wmo_render_build_queue.front()) var unique_key := String(_wmo_render_build_queue.front())
if not _wmo_render_build_jobs.has(unique_key): if not _wmo_render_build_jobs.has(unique_key):
_wmo_render_build_queue.pop_front() _wmo_render_build_queue.pop_front()
@@ -3632,7 +3652,7 @@ func _process_wmo_render_build_jobs() -> void:
_set_editor_owner_recursive(mesh_instance) _set_editor_owner_recursive(mesh_instance)
job["mesh_index"] = mesh_index + 1 job["mesh_index"] = mesh_index + 1
_wmo_render_build_jobs[unique_key] = job _wmo_render_build_jobs[unique_key] = job
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
continue continue
var multimeshes: Array = render_resource.get("multimeshes") var multimeshes: Array = render_resource.get("multimeshes")
@@ -3664,7 +3684,7 @@ func _process_wmo_render_build_jobs() -> void:
_set_editor_owner_recursive(multimesh_instance) _set_editor_owner_recursive(multimesh_instance)
job["multimesh_index"] = multimesh_index + 1 job["multimesh_index"] = multimesh_index + 1
_wmo_render_build_jobs[unique_key] = job _wmo_render_build_jobs[unique_key] = job
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
continue continue
_cancel_wmo_render_build_job(unique_key) _cancel_wmo_render_build_job(unique_key)
@@ -4176,16 +4196,14 @@ func _drain_m2_animation_loads() -> void:
_m2_animation_load_requests.erase(normalized_rel) _m2_animation_load_requests.erase(normalized_rel)
_m2_animation_finalize_queue.append(pending) _m2_animation_finalize_queue.append(pending)
var ops_left: int = maxi(0, m2_animation_finalize_ops_per_tick) while not _m2_animation_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
while ops_left > 0 and not _m2_animation_finalize_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE):
var pending: Dictionary = _m2_animation_finalize_queue.pop_front() var pending: Dictionary = _m2_animation_finalize_queue.pop_front()
var normalized_rel := String(pending.get("normalized", "")) var normalized_rel := String(pending.get("normalized", ""))
if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel): if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel):
ops_left -= 1
continue continue
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
_m2_static_animation_cache[normalized_rel] = true _m2_static_animation_cache[normalized_rel] = true
ops_left -= 1
continue continue
var path := String(pending.get("path", "")) var path := String(pending.get("path", ""))
@@ -4199,11 +4217,9 @@ func _drain_m2_animation_loads() -> void:
_m2_animated_scene_cache[normalized_rel] = node as Node3D _m2_animated_scene_cache[normalized_rel] = node as Node3D
if debug_streaming: if debug_streaming:
print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()]) print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()])
ops_left -= 1
continue continue
node.free() node.free()
_m2_static_animation_cache[normalized_rel] = true _m2_static_animation_cache[normalized_rel] = true
ops_left -= 1
func _drain_m2_mesh_loads() -> void: func _drain_m2_mesh_loads() -> void:
@@ -4222,16 +4238,14 @@ func _drain_m2_mesh_loads() -> void:
_m2_mesh_load_requests.erase(normalized_rel) _m2_mesh_load_requests.erase(normalized_rel)
_m2_mesh_finalize_queue.append(pending) _m2_mesh_finalize_queue.append(pending)
var ops_left: int = maxi(0, m2_mesh_finalize_ops_per_tick) while not _m2_mesh_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
while ops_left > 0 and not _m2_mesh_finalize_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE):
var pending: Dictionary = _m2_mesh_finalize_queue.pop_front() var pending: Dictionary = _m2_mesh_finalize_queue.pop_front()
var normalized_rel := String(pending.get("normalized", "")) var normalized_rel := String(pending.get("normalized", ""))
if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel): if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel):
ops_left -= 1
continue continue
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
_m2_missing_cache[normalized_rel] = true _m2_missing_cache[normalized_rel] = true
ops_left -= 1
continue continue
var path := String(pending.get("path", "")) var path := String(pending.get("path", ""))
@@ -4241,12 +4255,11 @@ func _drain_m2_mesh_loads() -> void:
_m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh) _m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh)
else: else:
_m2_missing_cache[normalized_rel] = true _m2_missing_cache[normalized_rel] = true
ops_left -= 1
func _process_m2_build_jobs() -> void: func _process_m2_build_jobs() -> void:
var ops_left: int = maxi(0, m2_build_groups_per_tick) while _render_budget_scheduler.has_remaining_permit(
while ops_left > 0 and not _m2_build_queue.is_empty(): RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) and not _m2_build_queue.is_empty():
var key: String = String(_m2_build_queue.front()) var key: String = String(_m2_build_queue.front())
if not _m2_build_jobs.has(key): if not _m2_build_jobs.has(key):
_m2_build_queue.pop_front() _m2_build_queue.pop_front()
@@ -4292,7 +4305,7 @@ func _process_m2_build_jobs() -> void:
if enable_m2_animated_instances and _m2_animation_load_requests.has(normalized_rel): if enable_m2_animated_instances and _m2_animation_load_requests.has(normalized_rel):
_m2_build_queue.pop_front() _m2_build_queue.pop_front()
_m2_build_queue.append(key) _m2_build_queue.append(key)
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
continue continue
var batch_size: int = ( var batch_size: int = (
maxi(1, m2_animated_instances_per_tick) maxi(1, m2_animated_instances_per_tick)
@@ -4311,7 +4324,7 @@ func _process_m2_build_jobs() -> void:
if not _m2_missing_cache.has(normalized_rel): if not _m2_missing_cache.has(normalized_rel):
_m2_build_queue.pop_front() _m2_build_queue.pop_front()
_m2_build_queue.append(key) _m2_build_queue.append(key)
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
continue continue
else: else:
_materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial) _materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial)
@@ -4322,7 +4335,7 @@ func _process_m2_build_jobs() -> void:
else: else:
job["offset"] = offset + consumed job["offset"] = offset + consumed
_m2_build_jobs[key] = job _m2_build_jobs[key] = job
ops_left -= 1 _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
if int(job["index"]) >= group_keys.size(): if int(job["index"]) >= group_keys.size():
_finish_m2_build_job(key) _finish_m2_build_job(key)
+175
View File
@@ -0,0 +1,175 @@
extends SceneTree
## Asset-free M03 regression for bounded renderer operation permits and cancellation.
const SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
const SCHEDULER_PATH := "res://src/render/streaming/render_budget_scheduler.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const PERFORMANCE_ITERATIONS := 20000
const MAXIMUM_PERMIT_CHECK_MILLISECONDS := 150.0
func _initialize() -> void:
var failures: Array[String] = []
_verify_bounded_lane_and_diagnostics(failures)
_verify_shared_lane_priority(failures)
_verify_independent_lanes_and_frame_reset(failures)
_verify_invalid_limits_and_unknown_lane(failures)
_verify_cancellation(failures)
_verify_scene_free_extraction(failures)
var elapsed_milliseconds := _measure_permit_checks(failures)
if not failures.is_empty():
for failure in failures:
push_error("RENDER_BUDGET_SCHEDULER: %s" % failure)
quit(1)
return
print("RENDER_BUDGET_SCHEDULER PASS cases=6 iterations=%d elapsed_ms=%.3f" % [
PERFORMANCE_ITERATIONS,
elapsed_milliseconds,
])
quit(0)
func _verify_bounded_lane_and_diagnostics(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({SCHEDULER_SCRIPT.TILE_FINALIZE: 2})
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "first permit", failures)
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "second permit", failures)
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "exhausted permit", failures)
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_FINALIZE), 0, "remaining permits", failures)
var consumed_snapshot: Dictionary = scheduler.consumed_permits_snapshot()
_expect_equal(int(consumed_snapshot[SCHEDULER_SCRIPT.TILE_FINALIZE]), 2, "consumed permits", failures)
consumed_snapshot[SCHEDULER_SCRIPT.TILE_FINALIZE] = 99
_expect_equal(
int(scheduler.consumed_permits_snapshot()[SCHEDULER_SCRIPT.TILE_FINALIZE]),
2,
"diagnostic snapshot is detached",
failures
)
func _verify_shared_lane_priority(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({SCHEDULER_SCRIPT.CHUNK_GEOMETRY: 3})
var removals := 0
while removals < 2 and scheduler.try_consume_permit(SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
removals += 1
var creates := 0
while scheduler.try_consume_permit(SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
creates += 1
_expect_equal(removals, 2, "shared lane removals consume first", failures)
_expect_equal(creates, 1, "shared lane leaves one create", failures)
func _verify_independent_lanes_and_frame_reset(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({
SCHEDULER_SCRIPT.TILE_LOD_CREATE: 1,
SCHEDULER_SCRIPT.TILE_LOD_REMOVE: 2,
})
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_LOD_CREATE), "create lane permit", failures)
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_REMOVE), 2, "remove lane independent", failures)
scheduler.begin_frame({SCHEDULER_SCRIPT.TILE_LOD_CREATE: 3})
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_CREATE), 3, "new frame resets lane", failures)
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_REMOVE), 0, "new frame removes absent lane", failures)
_expect_equal(int(scheduler.consumed_permits_snapshot()[SCHEDULER_SCRIPT.TILE_LOD_CREATE]), 0, "new frame resets diagnostics", failures)
func _verify_invalid_limits_and_unknown_lane(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({
SCHEDULER_SCRIPT.WATER_FINALIZE: 0,
SCHEDULER_SCRIPT.M2_BUILD: -4,
})
_expect_false(scheduler.has_remaining_permit(SCHEDULER_SCRIPT.WATER_FINALIZE), "zero limit", failures)
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.M2_BUILD), "negative limit clamps", failures)
_expect_false(scheduler.try_consume_permit(&"unknown_lane"), "unknown lane", failures)
func _verify_cancellation(failures: Array[String]) -> void:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({SCHEDULER_SCRIPT.WMO_BUILD: 2})
scheduler.cancel()
_expect_true(scheduler.is_cancelled(), "cancelled state", failures)
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.WMO_BUILD), "cancel rejects permit", failures)
scheduler.begin_frame({SCHEDULER_SCRIPT.WMO_BUILD: 5})
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.WMO_BUILD), 0, "begin frame cannot revive cancelled lifecycle", failures)
func _verify_scene_free_extraction(failures: Array[String]) -> void:
var scheduler_source := _read_text(SCHEDULER_PATH, failures)
_expect_true(scheduler_source.contains("extends RefCounted"), "scheduler is RefCounted", failures)
for forbidden_text in ["extends Node", "WorkerThreadPool", "ResourceLoader", "RenderingServer", "Mutex"]:
_expect_false(scheduler_source.contains(forbidden_text), "scheduler omits %s" % forbidden_text, failures)
var loader_source := _read_text(LOADER_PATH, failures)
_expect_true(loader_source.contains("begin_frame(_render_operation_limits_for_frame())"), "loader begins one budget frame", failures)
_expect_true(loader_source.contains("_render_budget_scheduler.cancel()"), "loader cancels scheduler on teardown", failures)
_expect_false(loader_source.contains("var ops_left"), "loader has no local operation counters", failures)
_expect_false(loader_source.contains("var finalize_budget"), "loader has no local finalize counter", failures)
for exported_limit_name in [
"tile_finalize_ops_per_tick",
"terrain_upgrade_finalize_ops_per_tick",
"terrain_control_splat_cache_finalize_ops_per_tick",
"terrain_splat_cache_finalize_ops_per_tick",
"terrain_splat_builds_per_tick",
"water_finalize_ops_per_tick",
"chunk_ops_per_tick",
"tiles_per_tick",
"tile_lod_ops_per_tick",
"tile_lod_remove_ops_per_tick",
"m2_animation_finalize_ops_per_tick",
"m2_mesh_finalize_ops_per_tick",
"m2_build_groups_per_tick",
"wmo_build_instances_per_tick",
"wmo_render_group_ops_per_tick",
"detail_asset_ops_per_tick",
]:
_expect_true(
loader_source.contains(": maxi(") and loader_source.contains(exported_limit_name),
"frame snapshot includes %s" % exported_limit_name,
failures
)
func _measure_permit_checks(failures: Array[String]) -> float:
var scheduler = SCHEDULER_SCRIPT.new()
scheduler.begin_frame({SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC: PERFORMANCE_ITERATIONS})
var started_microseconds := Time.get_ticks_usec()
for _iteration in PERFORMANCE_ITERATIONS:
scheduler.try_consume_permit(SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(
elapsed_milliseconds <= MAXIMUM_PERMIT_CHECK_MILLISECONDS,
"permit checks stay within %.1f ms (actual %.3f)" % [
MAXIMUM_PERMIT_CHECK_MILLISECONDS,
elapsed_milliseconds,
],
failures
)
return elapsed_milliseconds
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_equal(actual_value: int, expected_value: int, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
func _expect_false(actual_value: bool, label: String, failures: Array[String]) -> void:
if actual_value:
failures.append("%s expected false" % label)
@@ -0,0 +1 @@
uid://b27nncxfog7cv
@@ -0,0 +1,164 @@
extends SceneTree
## M03 repository gate preventing gameplay/EditorPlugin coupling to streamer internals.
const STREAMING_WORLD_LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const GAMEPLAY_ROOT := "res://src/gameplay"
const SOURCE_ROOTS: Array[String] = ["res://src", "res://addons"]
func _initialize() -> void:
var failures: Array[String] = []
var loader_source := _read_text(STREAMING_WORLD_LOADER_PATH, failures)
var private_renderer_symbols := _extract_private_renderer_symbols(loader_source, failures)
_verify_synthetic_detection(private_renderer_symbols, failures)
var gameplay_paths := _collect_gdscript_paths(GAMEPLAY_ROOT)
var editor_source_paths := _discover_editor_source_paths(failures)
var consumer_paths_by_path: Dictionary = {}
for gameplay_path in gameplay_paths:
consumer_paths_by_path[gameplay_path] = true
for editor_source_path in editor_source_paths:
consumer_paths_by_path[editor_source_path] = true
for consumer_path_variant in consumer_paths_by_path:
var consumer_path := String(consumer_path_variant)
var consumer_source := _read_text(consumer_path, failures)
for forbidden_symbol in _find_forbidden_symbols(consumer_source, private_renderer_symbols):
failures.append("%s accesses private renderer symbol %s" % [consumer_path, forbidden_symbol])
if not failures.is_empty():
for failure in failures:
push_error("RENDERER_INTERNAL_ACCESS: %s" % failure)
quit(1)
return
print("RENDERER_INTERNAL_ACCESS PASS private_symbols=%d gameplay=%d editor_sources=%d" % [
private_renderer_symbols.size(),
gameplay_paths.size(),
editor_source_paths.size(),
])
quit(0)
func _extract_private_renderer_symbols(loader_source: String, failures: Array[String]) -> Array[String]:
var declaration_pattern := RegEx.new()
var compile_error := declaration_pattern.compile(
"(?m)^var\\s+(_[A-Za-z0-9_]*(?:queue|tasks|cache|states)[A-Za-z0-9_]*)"
)
if compile_error != OK:
failures.append("cannot compile private renderer symbol pattern")
return []
var symbols_by_name: Dictionary = {}
for regex_match in declaration_pattern.search_all(loader_source):
symbols_by_name[regex_match.get_string(1)] = true
var symbols: Array[String] = []
for symbol_variant in symbols_by_name:
symbols.append(String(symbol_variant))
symbols.sort()
if symbols.is_empty():
failures.append("streamer private symbol inventory is empty")
return symbols
func _discover_editor_source_paths(failures: Array[String]) -> Array[String]:
var all_source_paths: Array[String] = []
for source_root in SOURCE_ROOTS:
all_source_paths.append_array(_collect_gdscript_paths(source_root))
var editor_roots_by_path: Dictionary = {}
var editor_source_paths_by_path: Dictionary = {}
for source_path in all_source_paths:
var source := _read_text(source_path, failures)
if source_path.contains("/editor/"):
editor_source_paths_by_path[source_path] = true
if source.contains("extends " + "EditorPlugin") or source_path.ends_with("/plugin.gd"):
editor_roots_by_path[source_path.get_base_dir()] = true
for source_path in all_source_paths:
for editor_root_variant in editor_roots_by_path:
var editor_root := String(editor_root_variant)
if source_path.begins_with(editor_root + "/"):
editor_source_paths_by_path[source_path] = true
var editor_source_paths: Array[String] = []
for editor_source_path_variant in editor_source_paths_by_path:
editor_source_paths.append(String(editor_source_path_variant))
editor_source_paths.sort()
return editor_source_paths
func _collect_gdscript_paths(root_path: String) -> Array[String]:
var paths: Array[String] = []
var directory := DirAccess.open(root_path)
if directory == null:
return paths
directory.list_dir_begin()
var entry_name := directory.get_next()
while not entry_name.is_empty():
if entry_name.begins_with(".") or entry_name == "reference":
entry_name = directory.get_next()
continue
var entry_path := root_path.path_join(entry_name)
if directory.current_is_dir():
paths.append_array(_collect_gdscript_paths(entry_path))
elif entry_name.ends_with(".gd"):
paths.append(entry_path)
entry_name = directory.get_next()
directory.list_dir_end()
paths.sort()
return paths
func _find_forbidden_symbols(source: String, private_renderer_symbols: Array[String]) -> Array[String]:
var found_symbols: Array[String] = []
for private_renderer_symbol in private_renderer_symbols:
if _contains_external_symbol_access(source, private_renderer_symbol):
found_symbols.append(private_renderer_symbol)
return found_symbols
func _contains_external_symbol_access(source: String, private_renderer_symbol: String) -> bool:
for access_text in [
"." + private_renderer_symbol,
'.get("%s"' % private_renderer_symbol,
".get('%s'" % private_renderer_symbol,
'.set("%s"' % private_renderer_symbol,
".set('%s'" % private_renderer_symbol,
'.call("%s"' % private_renderer_symbol,
".call('%s'" % private_renderer_symbol,
'["%s"]' % private_renderer_symbol,
"['%s']" % private_renderer_symbol,
]:
if source.contains(access_text):
return true
return false
func _verify_synthetic_detection(private_renderer_symbols: Array[String], failures: Array[String]) -> void:
if not private_renderer_symbols.has("_tile_states"):
failures.append("streamer inventory does not contain _tile_states fixture")
return
var forbidden_fixture := 'var tile_states = world.get("_tile_states")'
var detected_symbols := _find_forbidden_symbols(forbidden_fixture, private_renderer_symbols)
if detected_symbols != ["_tile_states"]:
failures.append("synthetic private access was not detected exactly")
var direct_member_fixture := "var tile_states = world._tile_states"
if _find_forbidden_symbols(direct_member_fixture, private_renderer_symbols) != ["_tile_states"]:
failures.append("synthetic direct member access was not detected exactly")
var unrelated_owner_fixture := "var _tile_states: Dictionary = {}"
if not _find_forbidden_symbols(unrelated_owner_fixture, private_renderer_symbols).is_empty():
failures.append("unrelated private declaration was rejected")
var safe_fixture := "var metrics = render_facade.renderer_metrics_snapshot()"
if not _find_forbidden_symbols(safe_fixture, private_renderer_symbols).is_empty():
failures.append("facade metrics fixture was rejected")
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()
@@ -0,0 +1 @@
uid://y11r5k0ve0md
+24 -9
View File
@@ -9,9 +9,9 @@
## Steps ## Steps
- [ ] Ввести `WorldRenderFacade` для focus, environment, entity visuals, ground query и metrics. - [ ] Ввести `WorldRenderFacade` для focus, environment, entity visuals, ground query и metrics.
- [ ] Запретить gameplay/editor доступ к внутренним очередям streamer. - [x] Запретить gameplay/editor доступ к внутренним очередям streamer.
- [x] Извлечь pure `StreamingTargetPlanner`. - [x] Извлечь pure `StreamingTargetPlanner`.
- [ ] Извлечь `RenderBudgetScheduler` с bounded queues/cancellation. - [x] Извлечь `RenderBudgetScheduler` с bounded queues/cancellation.
- [ ] Затем извлекать terrain, M2, WMO и liquid services по одному. - [ ] Затем извлекать terrain, M2, WMO и liquid services по одному.
- [ ] Сохранить cache versioning и main-thread GPU finalization rules. - [ ] Сохранить cache versioning и main-thread GPU finalization rules.
- [ ] Добавить dependency и performance regression checks. - [ ] Добавить dependency и performance regression checks.
@@ -26,10 +26,25 @@ Runtime и Editor используют facade; planner/scheduler тестиру
## Evidence ## Evidence
- Date: - Date: 2026-07-16
- Revision/worktree: - Revision/worktree: master merges `7ece2ab`, `7e35de7`; packages
- Commands: `M03-RND-SCHEDULER-001`, `M03-RND-INTERNAL-ACCESS-GATE-001`
- Results: - Commands: dedicated scheduler/planner/facade/focus/coordinate/manifest/shutdown
- Fidelity comparison: headless verifiers; Godot cold editor parse; coordination, documentation and diff gates.
- Changed files: - Results: scheduler `cases=6 iterations=20000 elapsed_ms=10.216`; planner
- Remaining risks: `cases=5 average_ms=1.804`; facade/focus passed; coordinate boundary
`files=111 consumers=6`; renderer manifest retained `7/7` checkpoints;
shutdown ownership and repository gates passed. Internal-access gate additionally
derived `43` private symbols and found no forbidden access across `7` gameplay
and `9` EditorPlugin-package sources (13 pre-existing expired M00 claim warnings).
- Fidelity comparison: all 16 historical operation limits and drain sites were
preserved; chunk removal/create retain their shared removal-first budget.
Defaults, quality presets, cache versions and visual rules are unchanged.
No new build-12340 parity claim is made; asset-backed visual/p95/p99 comparison remains pending.
- Changed files: scene-free scheduler and verifier; streamer budget composition/
permit delegation; scheduler module spec; world renderer/module registry/RENDER notes;
gameplay/editor internal-access verifier; work-package claims and this evidence.
- Remaining risks: queue storage length and worker concurrency/stale-result logic
remain streamer-owned; cancellation stops new permits but does not interrupt
in-flight work; M03 still needs remaining facade contracts, service extraction
and dependency/performance acceptance.