rnd(M03): introduce renderer facade seam

Work-Package: M03-RND-FACADE-001
Agent: sindo-main-codex
Tests: facade/focus/coordinate/manifest contracts, renderer dry-run, coordination and documentation gates
Fidelity: focus targets, budgets, queues, caches and visible renderer behavior remain unchanged
This commit is contained in:
2026-07-15 23:48:53 +04:00
parent a629bedccf
commit 3d528e3bbf
12 changed files with 335 additions and 37 deletions
+17 -1
View File
@@ -25,7 +25,8 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g
## Основные файлы ## Основные файлы
- `src/scenes/streaming/streaming_world_loader.gd` - главный runtime streamer мира. - `src/render/world_render_facade.gd` - стабильная граница runtime/tools для focus и metrics.
- `src/scenes/streaming/streaming_world_loader.gd` - внутренний runtime streamer мира.
- `src/scenes/streaming/eastern_kingdoms_streaming.tscn` - текущая сцена для проверки Azeroth/Eastern Kingdoms. - `src/scenes/streaming/eastern_kingdoms_streaming.tscn` - текущая сцена для проверки Azeroth/Eastern Kingdoms.
- `addons/mpq_extractor/loaders/adt_builder.gd` - сборка ADT terrain, control splat материалов и MH2O liquids. - `addons/mpq_extractor/loaders/adt_builder.gd` - сборка ADT terrain, control splat материалов и MH2O liquids.
- `addons/mpq_extractor/loaders/m2_builder.gd` - сборка статического M2 mesh/material. - `addons/mpq_extractor/loaders/m2_builder.gd` - сборка статического M2 mesh/material.
@@ -873,6 +874,21 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- This closes a coordinate/renderer integration criterion only. It does not - This closes a coordinate/renderer integration criterion only. It does not
claim TrinityCore row equivalence or visual parity with the original client. claim TrinityCore row equivalence or visual parity with the original client.
## 2026-07-15 WorldRenderFacade First Seam
- Runtime streaming scenes, checkpoint capture and renderer probes now submit
typed focus through `WorldRenderFacade`; those callers no longer call the
monolithic streamer's focus methods or configure its focus source directly.
- Baseline capture reads queue/cache/activity counts through
`renderer_metrics_snapshot()`. The facade returns a deep copy, so diagnostics
cannot mutate renderer-owned state.
- `StreamingWorldLoader` remains the internal implementation and still owns all
queues, tasks, caches, nodes and RIDs. The facade adds no parallel state or
replacement scheduler.
- This package does not alter targets, budgets, cache versions, placement,
visibility, materials or animation. Environment, entity visuals and ground
query remain explicit follow-up facade contracts in M03.
## 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.
+37 -18
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`; декомпозиция M02–M03 | | Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-RND-FACADE-001` |
| Owners | Renderer workstream / milestone integrator | | Owners | Renderer workstream / milestone integrator |
| Last verified | Worktree `work/sindo-main-codex/m01-server-spawn-renderer`, 2026-07-13 | | Last verified | Worktree `work/sindo-main-codex/m03-renderer-facade`, 2026-07-15 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose ## Purpose
@@ -32,7 +32,8 @@ flowchart LR
SpawnFixture[Pinned server spawn fixture] --> SpawnManifest[Spawn render manifest] SpawnFixture[Pinned server spawn fixture] --> SpawnManifest[Spawn render manifest]
SpawnManifest --> Capture SpawnManifest --> Capture
Adapter --> Focus Adapter --> Focus
Focus --> Loader[StreamingWorldLoader] Focus --> Facade[WorldRenderFacade]
Facade --> Loader[StreamingWorldLoader internal]
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]
@@ -60,36 +61,43 @@ Forbidden dependencies:
## Public API ## Public API
Текущая система ещё не имеет стабильного facade. Фактический integration surface — `StreamingWorldLoader` Node3D и exported properties. M03 должен заменить этот surface на `WorldRenderFacade`. 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.
| Symbol | Kind | Purpose | Thread/lifetime | Errors | | Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---| |---|---|---|---|---|
| `map_name` | Exported property | WDT/map directory name | Set before world load | Missing WDT produces diagnostic/empty world | | `map_name` | Exported property | WDT/map directory name | Set before world load | Missing WDT produces diagnostic/empty world |
| `extracted_dir` | Exported property | Root of legally extracted client data | Session lifetime | Missing files reported per loader path | | `extracted_dir` | Exported property | Root of legally extracted client data | Session lifetime | Missing files reported per loader path |
| `StreamingFocus` | Immutable value | Typed Godot-basis position without Node/Camera dependency | Any thread; caller-owned reference | Null position is a caller contract violation | | `StreamingFocus` | Immutable value | Typed Godot-basis position without Node/Camera dependency | Any thread; caller-owned reference | Null position is a caller contract violation |
| `streaming_focus_source_path` | Exported adapter property | Samples any explicit `Node3D` source into `StreamingFocus` | Main thread, scene lifetime | Missing/wrong node warns once and retains prior focus | | `WorldRenderFacade.streaming_focus_source_path` | Exported adapter property | Samples any explicit `Node3D` source into `StreamingFocus` | Main thread, scene lifetime | Missing/wrong node warns once and retains prior focus |
| `set_streaming_focus` | Public method | Accepts focus from app/editor/tool composition | Main thread/session | Null focus is ignored by refresh until replaced | | `WorldRenderFacade.set_streaming_focus` | Public method | Accepts focus from app/editor/tool composition | Main thread/session | Missing internal renderer is diagnosed; no state is duplicated |
| `refresh_streaming_focus` | Public method | Samples configured source and applies refresh thresholds | Main thread/session | Returns `false` when no valid focus exists | | `WorldRenderFacade.refresh_streaming_focus` | Public method | Samples configured source and delegates existing refresh thresholds | Main thread/session | Returns `false` when dependency/focus is unavailable |
| `WorldRenderFacade.renderer_metrics_snapshot` | Public method | Returns a deep-detached queue/cache/activity snapshot | Main thread/caller-owned result | Missing/invalid implementation result returns empty snapshot with diagnostic |
| `WorldRenderFacade.streaming_world_loader_path` | Internal adapter property | Resolves the existing implementation without exposing it to consumers | Main thread, scene lifetime | Missing method/path produces one diagnostic until recovery |
| `camera_path` | Exported property | Camera used only by optional automatic overview positioning | Main thread, scene lifetime | Missing camera skips automatic positioning | | `camera_path` | Exported property | Camera used only by optional automatic overview positioning | Main thread, scene lifetime | Missing camera skips automatic positioning |
| `quality_preset` | Exported property | Applies renderer budgets/radii | Before/at runtime depending property | Invalid combination should be diagnosed | | `quality_preset` | Exported property | Applies renderer budgets/radii | Before/at runtime depending property | Invalid combination should be diagnosed |
| `runtime_stats_enabled` | Exported property | Enables periodic performance snapshot | Runtime mutable | Logging overhead only | | `runtime_stats_enabled` | Exported property | Enables periodic performance snapshot | Runtime mutable | Logging overhead only |
| `hitch_profiler_enabled` | Exported property | Enables named hitch sections | Runtime mutable | Logging overhead only | | `hitch_profiler_enabled` | Exported property | Enables named hitch sections | Runtime mutable | Logging overhead only |
Публичным contract не считаются внутренние dictionaries, queues, job records и generated node names. Публичным contract не считаются `StreamingWorldLoader` methods/properties,
внутренние dictionaries, queues, job records и generated node names. Scene-owned
loader configuration remains transitional composition data, not a caller API.
## Inputs and outputs ## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | | Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Input | `StreamingFocus` | Player/spectator/editor/capture adapter | `StreamingWorldLoader` | Immutable caller value | Main thread/session | | Input | `StreamingFocus` | Player/spectator/editor/capture adapter | `WorldRenderFacade` | Immutable caller value | Main thread/session |
| Input | Focus-source `NodePath` | Runtime/test scene composition | Loader source adapter | Scene-owned node, sampled position | Main thread/scene lifetime | | Input | Focus-source `NodePath` | Runtime/test scene composition | `WorldRenderFacade` source adapter | Scene-owned node, sampled position | Main thread/scene lifetime |
| Input | Map/configuration | Scene/app/editor | `StreamingWorldLoader` | Caller config, copied/read | Main thread/session | | Input | Map/configuration | Scene/app/editor | `StreamingWorldLoader` | Caller config, copied/read | Main thread/session |
| Input | WDT/ADT/M2/WMO/BLP bytes | Extracted asset repository | Native loaders/builders | Loader result owns parsed data | Worker or controlled load | | Input | WDT/ADT/M2/WMO/BLP bytes | Extracted asset repository | Native loaders/builders | Loader result owns parsed data | Worker or controlled load |
| 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 | Desired tile/detail operations | Streaming planner inside loader | Finalize queues | Loader-owned | Cross-frame | | Output | Desired tile/detail operations | Streaming planner inside loader | 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 | Metrics/diagnostics | Hitch/stat instrumentation | Logs/baseline tools | Immutable records | Periodic/frame | | Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand |
| Output | Logs/diagnostics | Hitch/stat instrumentation | Logs/baseline tools | Immutable records | Periodic/frame |
| Test output | Diagnostic spawn marker and cold/warm PNGs | Checkpoint capture tool | Human/integrator review | SceneTree marker; `user://` files | One capture process | | Test output | Diagnostic spawn marker and cold/warm PNGs | Checkpoint capture tool | Human/integrator review | SceneTree marker; `user://` files | One capture process |
Side effects: Side effects:
@@ -104,7 +112,7 @@ Side effects:
```mermaid ```mermaid
flowchart TD flowchart TD
P[Player/spectator Node3D] --> A[Explicit scene adapter] PlayerSource[Player/spectator Node3D] --> A[Explicit scene adapter]
C[Capture camera] --> A C[Capture camera] --> A
SF[Pinned server spawn] --> SM[Validated spawn manifest] SF[Pinned server spawn] --> SM[Validated spawn manifest]
SM --> C SM --> C
@@ -112,11 +120,12 @@ flowchart TD
E[Editor viewport] --> EA[Editor adapter] E[Editor viewport] --> EA[Editor adapter]
A --> F[StreamingFocus] A --> F[StreamingFocus]
EA --> F EA --> F
F --> T[Compute desired ADT tiles] F --> RF[WorldRenderFacade]
RF --> T[StreamingWorldLoader target refresh]
WDT[WDT tile catalog] --> T WDT[WDT tile catalog] --> T
T --> Q[Tile load queue] T --> Q[Tile load queue]
Q --> P[Worker parse/cache load] Q --> Parse[Worker parse/cache load]
P --> R[Result queues] Parse --> R[Result queues]
R --> B[Per-frame budget scheduler] R --> B[Per-frame budget scheduler]
B --> Terrain[Terrain attach/upgrade] B --> Terrain[Terrain attach/upgrade]
B --> M2[M2 group/MultiMesh attach] B --> M2[M2 group/MultiMesh attach]
@@ -155,12 +164,14 @@ stateDiagram-v2
sequenceDiagram sequenceDiagram
participant Source as Player/Editor/Capture source participant Source as Player/Editor/Capture source
participant Focus as StreamingFocus adapter participant Focus as StreamingFocus adapter
participant Stream as StreamingWorldLoader participant Facade as WorldRenderFacade
participant Stream as StreamingWorldLoader internal
participant Worker as Worker task participant Worker as Worker task
participant Budget as Main-thread budget participant Budget as Main-thread budget
participant Render as SceneTree/RenderingServer participant Render as SceneTree/RenderingServer
Source->>Focus: sample explicit Godot position Source->>Focus: sample explicit Godot position
Focus->>Stream: set/refresh typed focus Focus->>Facade: set/refresh typed focus
Facade->>Stream: delegate focus and refresh
Stream->>Stream: desired tiles and priorities Stream->>Stream: desired tiles and priorities
Stream->>Worker: parse/load tile and detail data Stream->>Worker: parse/load tile and detail data
Worker-->>Stream: immutable result Worker-->>Stream: immutable result
@@ -176,6 +187,8 @@ sequenceDiagram
## Ownership, threading and resources ## Ownership, threading and resources
- `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope. - `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope.
- `WorldRenderFacade` owns only scene-relative adapter paths. It does not own the
streamer, focus source, returned metrics, queues, caches, nodes or RIDs.
- 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.
@@ -192,6 +205,8 @@ sequenceDiagram
|---|---|---|---|---| |---|---|---|---|---|
| Missing WDT/ADT | File/catalog check | Tile/map unavailable | Loader warning/error | Fix extraction/profile, reload | | Missing WDT/ADT | File/catalog check | Tile/map unavailable | Loader warning/error | Fix extraction/profile, reload |
| Missing focus source | Explicit NodePath resolution | Retain current streamed content and prior valid focus | One warning until source recovers | Fix composition path or call `set_streaming_focus` | | Missing focus source | Explicit NodePath resolution | Retain current streamed content and prior valid focus | One warning until source recovers | Fix composition path or call `set_streaming_focus` |
| Missing renderer implementation | Facade path/method validation | Focus/metrics call fails without mutation | One facade diagnostic until recovery | Fix scene composition path/implementation |
| Invalid metrics result | Facade return-type validation | Return empty caller snapshot | Facade error | Restore `Dictionary` implementation contract |
| No focus supplied | Refresh contract | No target recomputation; queues continue draining | `refresh_streaming_focus()` returns `false` | Supply player/editor/capture focus | | No focus supplied | Refresh contract | No target recomputation; queues continue draining | `refresh_streaming_focus()` returns `false` | Supply player/editor/capture focus |
| Cache version mismatch | Required format constant | Reject stale cache/fallback | Version diagnostic | Rebuild matching cache | | Cache version mismatch | Required format constant | Reject stale cache/fallback | Version diagnostic | Rebuild matching cache |
| Worker failure | Task result/empty data | Skip/degrade affected asset | Queue/task warning | Retry/rebuild source | | Worker failure | Task result/empty data | Skip/degrade affected asset | Queue/task warning | Retry/rebuild source |
@@ -225,6 +240,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Diagnostics and observability ## Diagnostics and observability
- Logs: `HITCH`, `PERF`, `TERRAIN_QUALITY`, `SKY_LIGHT`, `SKYBOX_MODEL`. - Logs: `HITCH`, `PERF`, `TERRAIN_QUALITY`, `SKY_LIGHT`, `SKYBOX_MODEL`.
- API snapshot: `WorldRenderFacade.renderer_metrics_snapshot()` returns detached
queue/cache/activity counts without exposing mutable renderer state.
- Metrics: queue depths, active tiles/assets, named finalize times, frame percentiles and max hitch. - Metrics: queue depths, active tiles/assets, named finalize times, frame percentiles and max hitch.
- Debug views: render checkpoint scenes/captures; future streaming/LOD/portal overlays. - Debug views: render checkpoint scenes/captures; future streaming/LOD/portal overlays.
- Correlation IDs: currently mostly tile/path keys; target architecture adds session/job IDs. - Correlation IDs: currently mostly tile/path keys; target architecture adds session/job IDs.
@@ -257,7 +274,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Character presentation | Prototype | Character creator/player experiment | Full customization/equipment states | | Character presentation | Prototype | Character creator/player experiment | Full customization/equipment states |
| Camera-independent streaming focus | Implemented | Typed contract, two player-focused runtime scenes and three explicitly camera-focused diagnostic tools | Multi-focus/direction/velocity belong to planner extraction | | Camera-independent streaming focus | Implemented | Typed contract, two player-focused runtime scenes and three explicitly camera-focused diagnostic tools | Multi-focus/direction/velocity belong to planner extraction |
| 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 | Planned | M03 | Current caller uses monolithic Node API | | Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain |
## Known gaps and risks ## Known gaps and risks
@@ -283,9 +300,11 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Path | Responsibility | | Path | Responsibility |
|---|---| |---|---|
| `src/render/world_render_facade.gd` | Stable caller boundary for typed focus and detached renderer metrics |
| `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 |
| `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 |
+110
View File
@@ -0,0 +1,110 @@
@tool
class_name WorldRenderFacade
extends Node
## Stable main-thread boundary between runtime/tool callers and the monolithic
## streaming renderer. The facade owns no queues, caches, nodes or RIDs.
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
## Internal renderer implementation resolved relative to this node.
## The referenced node remains owned by its scene parent.
@export var streaming_world_loader_path: NodePath = NodePath("..")
## Optional explicit Node3D source sampled in Godot world coordinates.
## Runtime scenes use the player; capture and probe tools replace it with their camera.
@export var streaming_focus_source_path: NodePath
var _streaming_world_loader: Node
var _missing_loader_reported := false
var _missing_focus_source_reported := false
func _ready() -> void:
_resolve_streaming_world_loader()
set_process(streaming_focus_source_path != NodePath())
if streaming_focus_source_path != NodePath():
# Child nodes become ready before their parent StreamingWorldLoader. Only
# publish the initial focus here; the loader applies it after initialization.
_capture_streaming_focus_from_source()
func _process(_delta_seconds: float) -> void:
_capture_streaming_focus_from_source()
## Replaces the renderer focus. The immutable value remains caller-owned; the
## internal streamer retains the reference for its next throttled refresh.
func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
var loader := _resolve_streaming_world_loader()
if loader == null:
return
loader.call("set_streaming_focus", streaming_focus)
## Samples the configured source and requests target refresh from the internal
## streamer. Returns false when either dependency or a valid focus is unavailable.
func refresh_streaming_focus(force: bool = false) -> bool:
_capture_streaming_focus_from_source()
var loader := _resolve_streaming_world_loader()
if loader == null:
return false
return bool(loader.call("refresh_streaming_focus", force))
## Returns a detached read-only metrics snapshot for diagnostics and baselines.
## Mutating the returned Dictionary cannot mutate renderer-owned state.
func renderer_metrics_snapshot() -> Dictionary:
var loader := _resolve_streaming_world_loader()
if loader == null:
return {}
var metrics_variant = loader.call("render_baseline_snapshot")
if not (metrics_variant is Dictionary):
push_error("WorldRenderFacade received a non-Dictionary renderer metrics snapshot.")
return {}
return (metrics_variant as Dictionary).duplicate(true)
func _capture_streaming_focus_from_source() -> void:
if streaming_focus_source_path == NodePath():
return
var focus_source := get_node_or_null(streaming_focus_source_path) as Node3D
if focus_source == null:
if not _missing_focus_source_reported:
push_warning("WorldRenderFacade focus source is missing or is not Node3D: %s" % streaming_focus_source_path)
_missing_focus_source_reported = true
return
_missing_focus_source_reported = false
var godot_position: Vector3 = focus_source.global_position
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(
godot_position.x,
godot_position.y,
godot_position.z
)
set_streaming_focus(STREAMING_FOCUS_SCRIPT.new(typed_world_position))
func _resolve_streaming_world_loader() -> Node:
if is_instance_valid(_streaming_world_loader):
return _streaming_world_loader
var candidate := get_node_or_null(streaming_world_loader_path)
if candidate == null:
if not _missing_loader_reported:
push_error("WorldRenderFacade cannot resolve StreamingWorldLoader at %s" % streaming_world_loader_path)
_missing_loader_reported = true
return null
for required_method in [
&"set_streaming_focus",
&"refresh_streaming_focus",
&"render_baseline_snapshot",
]:
if not candidate.has_method(required_method):
if not _missing_loader_reported:
push_error("WorldRenderFacade renderer implementation lacks %s" % required_method)
_missing_loader_reported = true
return null
_streaming_world_loader = candidate
_missing_loader_reported = false
return _streaming_world_loader
+1
View File
@@ -0,0 +1 @@
uid://sxn3ai2ic18r
@@ -6,6 +6,7 @@
[ext_resource type="Script" uid="uid://cq60al4uw0mgo" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"] [ext_resource type="Script" uid="uid://cq60al4uw0mgo" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
[ext_resource type="Script" uid="uid://bhqbo1ftrt3a3" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"] [ext_resource type="Script" uid="uid://bhqbo1ftrt3a3" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" uid="uid://c2jnn0e3mmymc" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"] [ext_resource type="Script" uid="uid://c2jnn0e3mmymc" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[ext_resource type="Script" path="res://src/render/world_render_facade.gd" id="7_render_facade"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] [sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45 radius = 0.45
@@ -56,7 +57,6 @@ fog_depth_end = 5200.0
[node name="StreamingWorld" type="Node3D" unique_id=1063159974] [node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_stream") script = ExtResource("1_stream")
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D") camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High" quality_preset = "High"
update_interval = 0.1 update_interval = 0.1
@@ -94,6 +94,11 @@ wmo_visibility_range = 3600.0
debug_streaming = true debug_streaming = true
hitch_profiler_enabled = true hitch_profiler_enabled = true
[node name="WorldRenderFacade" type="Node" parent="."]
script = ExtResource("7_render_facade")
streaming_world_loader_path = NodePath("..")
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] [node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
script = ExtResource("2_player") script = ExtResource("2_player")
+6 -1
View File
@@ -6,6 +6,7 @@
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"] [ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"] [ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"] [ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[ext_resource type="Script" path="res://src/render/world_render_facade.gd" id="7_render_facade"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] [sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45 radius = 0.45
@@ -57,7 +58,6 @@ fog_depth_end = 5200.0
[node name="StreamingWorld" type="Node3D" unique_id=1063159974] [node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_sisqv") script = ExtResource("1_sisqv")
map_name = "Kalimdor" map_name = "Kalimdor"
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D") camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High" quality_preset = "High"
update_interval = 0.1 update_interval = 0.1
@@ -95,6 +95,11 @@ wmo_visibility_range = 3600.0
debug_streaming = true debug_streaming = true
hitch_profiler_enabled = true hitch_profiler_enabled = true
[node name="WorldRenderFacade" type="Node" parent="."]
script = ExtResource("7_render_facade")
streaming_world_loader_path = NodePath("..")
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] [node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
script = ExtResource("2_puy8r") script = ExtResource("2_puy8r")
+11 -7
View File
@@ -64,7 +64,12 @@ func _capture_async() -> void:
camera.far = 50000.0 camera.far = 50000.0
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0])) camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
(world as Node3D).add_child(camera) (world as Node3D).add_child(camera)
world.set("streaming_focus_source_path", NodePath("CheckpointCamera")) var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("Streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../CheckpointCamera"))
world.set("debug_streaming", true) world.set("debug_streaming", true)
world.set("runtime_stats_enabled", true) world.set("runtime_stats_enabled", true)
get_root().add_child(world) get_root().add_child(world)
@@ -126,8 +131,7 @@ func _capture_async() -> void:
if player != null: if player != null:
player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0]))) player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0])))
_set_sky_time(world, float(checkpoint.get("time_hours", 13.0))) _set_sky_time(world, float(checkpoint.get("time_hours", 13.0)))
if world.has_method("refresh_streaming_focus"): render_facade.call("refresh_streaming_focus", true)
world.call("refresh_streaming_focus", true)
if dry_run: if dry_run:
print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [ print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [
@@ -148,7 +152,7 @@ func _capture_async() -> void:
var load_started := Time.get_ticks_usec() var load_started := Time.get_ticks_usec()
await create_timer(maxf(wait_seconds, 0.0)).timeout await create_timer(maxf(wait_seconds, 0.0)).timeout
var load_time_ms := float(Time.get_ticks_usec() - load_started) / 1000.0 var load_time_ms := float(Time.get_ticks_usec() - load_started) / 1000.0
var metrics := await _measure_frames(maxf(measure_seconds, 0.1), world) var metrics := await _measure_frames(maxf(measure_seconds, 0.1), render_facade)
await RenderingServer.frame_post_draw await RenderingServer.frame_post_draw
var image := get_root().get_texture().get_image() var image := get_root().get_texture().get_image()
@@ -189,7 +193,7 @@ func _capture_async() -> void:
quit(0) quit(0)
func _measure_frames(seconds: float, world: Node) -> Dictionary: func _measure_frames(seconds: float, render_facade: Node) -> Dictionary:
var frame_times: Array[float] = [] var frame_times: Array[float] = []
var deadline := Time.get_ticks_usec() + int(seconds * 1000000.0) var deadline := Time.get_ticks_usec() + int(seconds * 1000000.0)
var previous := Time.get_ticks_usec() var previous := Time.get_ticks_usec()
@@ -200,8 +204,8 @@ func _measure_frames(seconds: float, world: Node) -> Dictionary:
previous = now previous = now
frame_times.sort() frame_times.sort()
var snapshot := {} var snapshot := {}
if world.has_method("render_baseline_snapshot"): if render_facade.has_method("renderer_metrics_snapshot"):
snapshot = world.call("render_baseline_snapshot") snapshot = render_facade.call("renderer_metrics_snapshot")
return { return {
"frames": frame_times.size(), "frames": frame_times.size(),
"frame_ms_p50": _percentile(frame_times, 0.50), "frame_ms_p50": _percentile(frame_times, 0.50),
+7 -3
View File
@@ -28,7 +28,12 @@ func _run_async() -> void:
camera.name = "OccluderProbeCamera" camera.name = "OccluderProbeCamera"
camera.current = true camera.current = true
world.add_child(camera) world.add_child(camera)
world.set("streaming_focus_source_path", NodePath(camera.name)) var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("CAMERA_OCCLUDER_PROBE: streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../%s" % camera.name))
world.set("debug_streaming", false) world.set("debug_streaming", false)
get_root().add_child(world) get_root().add_child(world)
await process_frame await process_frame
@@ -47,8 +52,7 @@ func _run_async() -> void:
var camera_position := _vector3(checkpoint.get("camera", [])) var camera_position := _vector3(checkpoint.get("camera", []))
var target_position := _vector3(checkpoint.get("target", [])) var target_position := _vector3(checkpoint.get("target", []))
camera.global_position = camera_position camera.global_position = camera_position
if world.has_method("refresh_streaming_focus"): render_facade.call("refresh_streaming_focus", true)
world.call("refresh_streaming_focus", true)
await create_timer(maxf(0.1, wait_seconds)).timeout await create_timer(maxf(0.1, wait_seconds)).timeout
var geometry_nodes: Array[Node3D] = [] var geometry_nodes: Array[Node3D] = []
_collect_geometry_nodes(world, geometry_nodes) _collect_geometry_nodes(world, geometry_nodes)
+7 -3
View File
@@ -37,7 +37,12 @@ func _run_async() -> void:
var camera := Camera3D.new() var camera := Camera3D.new()
camera.current = true camera.current = true
world.add_child(camera) world.add_child(camera)
world.set("streaming_focus_source_path", NodePath(camera.name)) var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("TERRAIN_HEIGHT_PROBE: streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../%s" % camera.name))
world.set("debug_streaming", false) world.set("debug_streaming", false)
get_root().add_child(world) get_root().add_child(world)
await process_frame await process_frame
@@ -54,8 +59,7 @@ func _run_async() -> void:
continue continue
var camera_position := _vector3(checkpoint.get("camera", [])) var camera_position := _vector3(checkpoint.get("camera", []))
camera.global_position = camera_position camera.global_position = camera_position
if world.has_method("refresh_streaming_focus"): render_facade.call("refresh_streaming_focus", true)
world.call("refresh_streaming_focus", true)
await create_timer(maxf(0.1, wait_seconds)).timeout await create_timer(maxf(0.1, wait_seconds)).timeout
var terrain_sample := _sample_terrain(world, camera_position) var terrain_sample := _sample_terrain(world, camera_position)
terrain_sample["name"] = checkpoint.get("name", "checkpoint") terrain_sample["name"] = checkpoint.get("name", "checkpoint")
+15 -3
View File
@@ -6,6 +6,7 @@ const StreamingFocusScript = preload("res://src/domain/streaming/streaming_focus
const GodotWorldPositionScript = preload("res://src/domain/coordinates/godot_world_position.gd") const GodotWorldPositionScript = preload("res://src/domain/coordinates/godot_world_position.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const FACADE_PATH := "res://src/render/world_render_facade.gd"
const RUNTIME_SCENE_PATHS: Array[String] = [ const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn", "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn", "res://src/scenes/streaming/kalimdor_streaming.tscn",
@@ -21,6 +22,7 @@ func _initialize() -> void:
var failures: Array[String] = [] var failures: Array[String] = []
_verify_scene_free_focus_value(failures) _verify_scene_free_focus_value(failures)
_verify_loader_boundary(failures) _verify_loader_boundary(failures)
_verify_facade_boundary(failures)
_verify_runtime_scene_wiring(failures) _verify_runtime_scene_wiring(failures)
_verify_capture_tool_wiring(failures) _verify_capture_tool_wiring(failures)
@@ -65,11 +67,21 @@ func _verify_loader_boundary(failures: Array[String]) -> void:
_expect_true(not loader_source.contains(forbidden_text), "loader omits %s" % forbidden_text, failures) _expect_true(not loader_source.contains(forbidden_text), "loader omits %s" % forbidden_text, failures)
func _verify_facade_boundary(failures: Array[String]) -> void:
var facade_source := _read_text(FACADE_PATH, failures)
for required_text in [
"class_name WorldRenderFacade",
"func set_streaming_focus(streaming_focus: StreamingFocus) -> void:",
"func refresh_streaming_focus(force: bool = false) -> bool:",
]:
_expect_true(facade_source.contains(required_text), "facade contains %s" % required_text, failures)
func _verify_runtime_scene_wiring(failures: Array[String]) -> void: func _verify_runtime_scene_wiring(failures: Array[String]) -> void:
for scene_path in RUNTIME_SCENE_PATHS: for scene_path in RUNTIME_SCENE_PATHS:
var scene_source := _read_text(scene_path, failures) var scene_source := _read_text(scene_path, failures)
_expect_true( _expect_true(
scene_source.contains('streaming_focus_source_path = NodePath("ThirdPersonPlayer")'), scene_source.contains('streaming_focus_source_path = NodePath("../ThirdPersonPlayer")'),
"%s uses player focus" % scene_path, "%s uses player focus" % scene_path,
failures failures
) )
@@ -78,8 +90,8 @@ func _verify_runtime_scene_wiring(failures: Array[String]) -> void:
func _verify_capture_tool_wiring(failures: Array[String]) -> void: func _verify_capture_tool_wiring(failures: Array[String]) -> void:
for tool_path in CAPTURE_TOOL_PATHS: for tool_path in CAPTURE_TOOL_PATHS:
var tool_source := _read_text(tool_path, failures) var tool_source := _read_text(tool_path, failures)
_expect_true(tool_source.contains('world.set("streaming_focus_source_path"'), "%s sets explicit focus source" % tool_path, failures) _expect_true(tool_source.contains('render_facade.set("streaming_focus_source_path"'), "%s sets explicit focus source" % tool_path, failures)
_expect_true(tool_source.contains('world.call("refresh_streaming_focus", true)'), "%s uses public refresh" % tool_path, failures) _expect_true(tool_source.contains('render_facade.call("refresh_streaming_focus", true)'), "%s uses facade refresh" % tool_path, failures)
_expect_true(not tool_source.contains('world.call("_refresh_streaming_targets_at"'), "%s avoids private refresh" % tool_path, failures) _expect_true(not tool_source.contains('world.call("_refresh_streaming_targets_at"'), "%s avoids private refresh" % tool_path, failures)
+117
View File
@@ -0,0 +1,117 @@
extends SceneTree
## Headless contract and wiring regression for the first M03 renderer facade seam.
const WORLD_RENDER_FACADE_SCRIPT := preload("res://src/render/world_render_facade.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
]
const TOOL_PATHS: Array[String] = [
"res://src/tools/capture_render_checkpoints.gd",
"res://src/tools/probe_render_camera_occluders.gd",
"res://src/tools/probe_render_terrain_height.gd",
]
class StreamingWorldLoaderDouble extends Node:
var current_focus: StreamingFocus
var refresh_count := 0
var metrics := {"tiles": 2, "queues": {"tile": 1}}
func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
current_focus = streaming_focus
func refresh_streaming_focus(_force: bool = false) -> bool:
refresh_count += 1
return current_focus != null
func render_baseline_snapshot() -> Dictionary:
return metrics
func _initialize() -> void:
var failures: Array[String] = []
await _verify_delegation_and_snapshot_isolation(failures)
_verify_repository_wiring(failures)
if not failures.is_empty():
for failure in failures:
push_error("WORLD_RENDER_FACADE: %s" % failure)
quit(1)
return
print("WORLD_RENDER_FACADE PASS delegation=3 runtime_scenes=2 tools=3")
quit(0)
func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void:
var test_root := Node.new()
var loader := StreamingWorldLoaderDouble.new()
loader.name = "StreamingWorldLoaderDouble"
var source := Node3D.new()
source.name = "FocusSource"
source.position = Vector3(10.0, 20.0, 30.0)
var facade = WORLD_RENDER_FACADE_SCRIPT.new()
facade.name = "WorldRenderFacade"
facade.streaming_world_loader_path = NodePath("../StreamingWorldLoaderDouble")
facade.streaming_focus_source_path = NodePath("../FocusSource")
test_root.add_child(loader)
test_root.add_child(source)
test_root.add_child(facade)
get_root().add_child(test_root)
await process_frame
_expect_true(loader.current_focus != null, "ready captures explicit source", failures)
if loader.current_focus != null:
_expect_near(loader.current_focus.world_position.x_units, 10.0, "source X", failures)
_expect_near(loader.current_focus.world_position.y_units, 20.0, "source Y", failures)
_expect_near(loader.current_focus.world_position.z_units, 30.0, "source Z", failures)
var typed_focus = STREAMING_FOCUS_SCRIPT.new(GODOT_WORLD_POSITION_SCRIPT.new(40.0, 50.0, 60.0))
facade.streaming_focus_source_path = NodePath()
facade.set_streaming_focus(typed_focus)
_expect_true(facade.refresh_streaming_focus(true), "typed focus refresh delegates", failures)
_expect_true(loader.current_focus == typed_focus, "typed focus reference delegates", failures)
var snapshot: Dictionary = facade.renderer_metrics_snapshot()
snapshot["tiles"] = 999
(snapshot["queues"] as Dictionary)["tile"] = 999
_expect_true(int(loader.metrics.tiles) == 2, "top-level metrics are detached", failures)
_expect_true(int(loader.metrics.queues.tile) == 1, "nested metrics are detached", failures)
test_root.queue_free()
func _verify_repository_wiring(failures: Array[String]) -> void:
for scene_path in RUNTIME_SCENE_PATHS:
var scene_source := _read_text(scene_path, failures)
_expect_true(scene_source.contains('path="res://src/render/world_render_facade.gd"'), "%s loads facade" % scene_path, failures)
_expect_true(scene_source.contains('[node name="WorldRenderFacade" type="Node" parent="."]'), "%s owns facade node" % scene_path, failures)
_expect_true(scene_source.contains('streaming_focus_source_path = NodePath("../ThirdPersonPlayer")'), "%s facade uses player focus" % scene_path, failures)
for tool_path in TOOL_PATHS:
var tool_source := _read_text(tool_path, failures)
_expect_true(tool_source.contains('get_node_or_null("WorldRenderFacade")'), "%s resolves facade" % tool_path, failures)
_expect_true(not tool_source.contains('world.call("refresh_streaming_focus"'), "%s avoids direct streamer refresh" % tool_path, failures)
_expect_true(not tool_source.contains('world.set("streaming_focus_source_path"'), "%s avoids direct streamer focus config" % tool_path, failures)
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_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if not is_equal_approx(actual_value, expected_value):
failures.append("%s expected %.3f, got %.3f" % [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)
@@ -0,0 +1 @@
uid://baoiewiesd02u