Compare commits
10 Commits
650d584ff0
...
53d84ccc8a
| Author | SHA1 | Date | |
|---|---|---|---|
| 53d84ccc8a | |||
| 7e35de7f61 | |||
| ba9f89691d | |||
| b58bf2caa6 | |||
| 52ea639d64 | |||
| a023d76eea | |||
| 7ece2abea2 | |||
| 10c5d4cc12 | |||
| e52f703da5 | |||
| 91f0724ce2 |
@@ -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
|
||||
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
|
||||
|
||||
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.
|
||||
@@ -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]
|
||||
@@ -67,6 +68,8 @@ Forbidden dependencies:
|
||||
Runtime, capture and probe callers use `WorldRenderFacade` for focus and metrics.
|
||||
`StreamingWorldLoader` remains the internal scene implementation while later M03
|
||||
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 |
|
||||
|---|---|---|---|---|
|
||||
@@ -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 |
|
||||
| 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 +135,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 +177,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 +187,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 +206,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 +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.
|
||||
- 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.
|
||||
- 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
|
||||
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 +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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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.
|
||||
- M2/WMO/material/particle/ribbon/portal parity incomplete.
|
||||
- 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
|
||||
|
||||
@@ -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_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 |
|
||||
| `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/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
|
||||
@@ -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
|
||||
@@ -9,9 +9,9 @@
|
||||
## Steps
|
||||
|
||||
- [ ] Ввести `WorldRenderFacade` для focus, environment, entity visuals, ground query и metrics.
|
||||
- [ ] Запретить gameplay/editor доступ к внутренним очередям streamer.
|
||||
- [x] Запретить gameplay/editor доступ к внутренним очередям streamer.
|
||||
- [x] Извлечь pure `StreamingTargetPlanner`.
|
||||
- [ ] Извлечь `RenderBudgetScheduler` с bounded queues/cancellation.
|
||||
- [x] Извлечь `RenderBudgetScheduler` с bounded queues/cancellation.
|
||||
- [ ] Затем извлекать terrain, M2, WMO и liquid services по одному.
|
||||
- [ ] Сохранить cache versioning и main-thread GPU finalization rules.
|
||||
- [ ] Добавить dependency и performance regression checks.
|
||||
@@ -26,10 +26,25 @@ Runtime и Editor используют facade; planner/scheduler тестиру
|
||||
|
||||
## Evidence
|
||||
|
||||
- Date:
|
||||
- Revision/worktree:
|
||||
- Commands:
|
||||
- Results:
|
||||
- Fidelity comparison:
|
||||
- Changed files:
|
||||
- Remaining risks:
|
||||
- Date: 2026-07-16
|
||||
- Revision/worktree: master merges `7ece2ab`, `7e35de7`; packages
|
||||
`M03-RND-SCHEDULER-001`, `M03-RND-INTERNAL-ACCESS-GATE-001`
|
||||
- Commands: dedicated scheduler/planner/facade/focus/coordinate/manifest/shutdown
|
||||
headless verifiers; Godot cold editor parse; coordination, documentation and diff gates.
|
||||
- Results: scheduler `cases=6 iterations=20000 elapsed_ms=10.216`; planner
|
||||
`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.
|
||||
|
||||
Reference in New Issue
Block a user