feat(M01): add explicit streaming focus

Work-Package: M01-RND-STREAMING-FOCUS-001

Agent: sindo-main-codex
This commit is contained in:
2026-07-13 15:41:16 +04:00
parent fbef131bcb
commit 7815385b3b
12 changed files with 253 additions and 61 deletions
+13
View File
@@ -830,6 +830,19 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- A focused 8-second GUI checkpoint and a seven-checkpoint single-pass GUI run finish without Node, renderer resource or RID leak diagnostics. A timing-dependent anonymous zero-reference `RefCounted` ObjectDB warning can remain at engine teardown; it is tracked separately and is not suppressed. - A focused 8-second GUI checkpoint and a seven-checkpoint single-pass GUI run finish without Node, renderer resource or RID leak diagnostics. A timing-dependent anonymous zero-reference `RefCounted` ObjectDB warning can remain at engine teardown; it is tracked separately and is not suppressed.
- This is shutdown-only ownership behavior and does not alter checkpoint rendering, cache formats or the `Blizzlike335` compatibility profile. - This is shutdown-only ownership behavior and does not alter checkpoint rendering, cache formats or the `Blizzlike335` compatibility profile.
## 2026-07-13 Explicit Streaming Focus
- `StreamingWorldLoader` consumes an immutable `StreamingFocus` containing a
typed `GodotWorldPosition`; runtime streaming no longer discovers the active
`Camera3D` from the viewport or child nodes.
- Eastern Kingdoms and Kalimdor runtime scenes explicitly use the player as the
streaming source. Checkpoint capture and terrain/occluder probes explicitly
use their dedicated camera through the same public refresh path.
- The editor viewport camera remains behind a dedicated editor-only adapter.
`camera_path` is retained solely for optional automatic overview positioning.
- Streaming radii, LOD decisions, cache formats and renderer quality profiles
are unchanged by this migration.
## 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.
+43 -14
View File
@@ -5,9 +5,9 @@
| Field | Value | | Field | Value |
|---|---| |---|---|
| Status | Partial | | Status | Partial |
| Target/work package | M00 active; декомпозиция M01M03 | | Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; декомпозиция M02M03 |
| Owners | Renderer workstream / milestone integrator | | Owners | Renderer workstream / milestone integrator |
| Last verified | M00 visual-diff worktree, 2026-07-11 | | Last verified | Worktree `work/sindo-main-codex/m01-streaming-focus`, 2026-07-13 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose ## Purpose
@@ -26,7 +26,11 @@
```mermaid ```mermaid
flowchart LR flowchart LR
Focus[Camera/player/editor focus] --> Loader[StreamingWorldLoader] Player[Player or spectator Node3D] --> Adapter[Explicit source adapter]
Editor[Editor viewport adapter] --> Focus[StreamingFocus]
Capture[Capture tool] --> Adapter
Adapter --> Focus
Focus --> Loader[StreamingWorldLoader]
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,7 +64,11 @@ Forbidden dependencies:
|---|---|---|---|---| |---|---|---|---|---|
| `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 |
| `camera_path` | Exported property | Current streaming focus compatibility path | Main thread, scene lifetime | Missing camera prevents normal focus update | | `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 |
| `set_streaming_focus` | Public method | Accepts focus from app/editor/tool composition | Main thread/session | Null focus is ignored by refresh until replaced |
| `refresh_streaming_focus` | Public method | Samples configured source and applies refresh thresholds | Main thread/session | Returns `false` when no valid focus exists |
| `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 |
@@ -71,7 +79,9 @@ Forbidden dependencies:
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | | Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Input | Map/focus/configuration | Scene/app/editor | `StreamingWorldLoader` | Caller config, copied/read | Main thread/session | | Input | `StreamingFocus` | Player/spectator/editor/capture adapter | `StreamingWorldLoader` | 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 | 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 |
| 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 |
@@ -90,7 +100,12 @@ Side effects:
```mermaid ```mermaid
flowchart TD flowchart TD
F[Streaming focus] --> T[Compute desired ADT tiles] P[Player/spectator Node3D] --> A[Explicit scene adapter]
C[Capture camera] --> A
E[Editor viewport] --> EA[Editor adapter]
A --> F[StreamingFocus]
EA --> F
F --> T[Compute desired ADT tiles]
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 --> P[Worker parse/cache load]
@@ -113,13 +128,14 @@ flowchart TD
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> Initializing [*] --> Initializing
Initializing --> Ready: WDT/catalog/config ready Initializing --> WaitingForFocus: WDT/catalog/config ready
Initializing --> Degraded: missing loaders/data/cache Initializing --> Degraded: missing loaders/data/cache
Ready --> Streaming: focus update WaitingForFocus --> Streaming: valid StreamingFocus
Streaming --> Streaming: load/finalize/evict ticks Streaming --> Streaming: load/finalize/evict ticks
Streaming --> WaitingForFocus: world reset without replacement focus
Streaming --> SwitchingMap: map/focus reset Streaming --> SwitchingMap: map/focus reset
SwitchingMap --> Initializing SwitchingMap --> Initializing
Ready --> ShuttingDown WaitingForFocus --> ShuttingDown
Degraded --> ShuttingDown Degraded --> ShuttingDown
Streaming --> ShuttingDown Streaming --> ShuttingDown
ShuttingDown --> [*] ShuttingDown --> [*]
@@ -129,12 +145,14 @@ stateDiagram-v2
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Focus as Camera/Player/Editor participant Source as Player/Editor/Capture source
participant Focus as StreamingFocus adapter
participant Stream as StreamingWorldLoader participant Stream as StreamingWorldLoader
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
Focus->>Stream: focus position changed Source->>Focus: sample explicit Godot position
Focus->>Stream: set/refresh typed focus
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
@@ -150,6 +168,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.
- 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. - Worker tasks не должны менять SceneTree и shared Resource concurrently.
- Parsed/grouped results передаются обратно через guarded result queues. - Parsed/grouped results передаются обратно через guarded result queues.
- Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets. - Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets.
@@ -163,6 +183,8 @@ sequenceDiagram
| Failure | Detection | Behavior | Diagnostic | Recovery | | Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---| |---|---|---|---|---|
| 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` |
| 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 |
| Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path | | Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path |
@@ -184,6 +206,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Persistence, cache and migration ## Persistence, cache and migration
- `StreamingFocus` is runtime-only and is not serialized. Scene migration adds
`streaming_focus_source_path`; existing `camera_path` remains valid only for
optional automatic overview positioning.
- Renderer reads caches under `data/cache`; caches are not committed. - Renderer reads caches under `data/cache`; caches are not committed.
- Format changes require `FORMAT_VERSION`/required-version bump and rebuild instructions. - Format changes require `FORMAT_VERSION`/required-version bump and rebuild instructions.
- Atomic cache publication and unified manifest are target-state work from FND/M03. - Atomic cache publication and unified manifest are target-state work from FND/M03.
@@ -198,7 +223,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Verification ## Verification
- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan. - Unit/contract tests: streaming-focus contract/wiring, material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan.
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints. - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints.
- Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe. - Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe.
- Performance budgets: M00 report records cold/warm p95 and max hitch; no final acceptance threshold yet. - Performance budgets: M00 report records cold/warm p95 and max hitch; no final acceptance threshold yet.
@@ -207,7 +232,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Extension points ## Extension points
- Target `WorldRenderFacade` for runtime/editor/capture callers. - Target `WorldRenderFacade` for runtime/editor/capture callers.
- Target `StreamingFocus` independent of Camera3D. - Extend `StreamingFocus` with measured direction/velocity needs when the pure planner is extracted.
- Extracted planner, budget scheduler, terrain, M2, WMO, liquid, sky and character services. - Extracted planner, budget scheduler, terrain, M2, WMO, liquid, sky and character services.
- Separate Blizzlike/Enhanced material and quality profiles. - Separate Blizzlike/Enhanced material and quality profiles.
@@ -221,12 +246,14 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity | | Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity |
| Sky/light | Partial | DBC controller/logs | Zone transition paired validation | | Sky/light | Partial | DBC controller/logs | Zone transition paired validation |
| 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 |
| Stable renderer facade | Planned | M03 | Current caller uses monolithic Node API | | Stable renderer facade | Planned | M03 | Current caller uses monolithic Node API |
## Known gaps and risks ## Known gaps and risks
- Monolithic streamer mixes planning, jobs, caches and presentation. - Monolithic streamer mixes planning, jobs, caches and presentation.
- Direct camera path remains until M01/M03. - `camera_path` remains only for optional automatic overview positioning; it no
longer drives runtime streaming or discovers the active viewport camera.
- Original-client paired fidelity evidence incomplete. - Original-client paired fidelity evidence incomplete.
- Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC. - Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC.
- Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units. - Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units.
@@ -247,6 +274,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Path | Responsibility | | Path | Responsibility |
|---|---| |---|---|
| `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/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool 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 |
+17
View File
@@ -0,0 +1,17 @@
class_name StreamingFocus
extends RefCounted
## Immutable world-streaming point independent of cameras and the scene tree.
## Renderer adapters may obtain this position from a player, spectator, editor
## viewport or capture tool, but streaming consumers receive only this value.
var world_position: GodotWorldPosition:
get:
return _world_position
var _world_position: GodotWorldPosition
## Creates a focus at an explicit position in the Godot renderer basis.
func _init(world_position_value: GodotWorldPosition) -> void:
_world_position = world_position_value
@@ -0,0 +1 @@
uid://ehv78jvge0q2
@@ -53,6 +53,7 @@ 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
@@ -54,6 +54,7 @@ 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
+65 -37
View File
@@ -1,5 +1,5 @@
@tool @tool
## Streams Azeroth terrain around the active camera with chunk-based LODs. ## Streams Azeroth terrain around an explicit [StreamingFocus] with chunk-based LODs.
extends Node3D extends Node3D
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd") const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
@@ -12,6 +12,8 @@ const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_buil
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd") const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd")
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5 const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
@@ -29,6 +31,9 @@ const QUALITY_HIGH := "High"
@export var extracted_dir: String = "res://data/extracted" @export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth" @export var map_name: String = "Azeroth"
## Optional scene adapter source. Any Node3D may provide the runtime focus.
@export var streaming_focus_source_path: NodePath
## Camera used only by the optional automatic overview positioning feature.
@export var camera_path: NodePath @export var camera_path: NodePath
@export_enum("Custom", "Performance", "Balanced", "High") var quality_preset: String = QUALITY_CUSTOM @export_enum("Custom", "Performance", "Balanced", "High") var quality_preset: String = QUALITY_CUSTOM
@@ -213,6 +218,8 @@ var _tile_min := Vector2i(63, 63)
var _tile_max := Vector2i(0, 0) var _tile_max := Vector2i(0, 0)
var _camera_initialized := false var _camera_initialized := false
var _editor_signature := "" var _editor_signature := ""
var _streaming_focus: StreamingFocus
var _missing_focus_source_reported := false
var _last_focus_pos := Vector3.ZERO var _last_focus_pos := Vector3.ZERO
var _dbg_chunks_created := 0 var _dbg_chunks_created := 0
var _dbg_chunks_null := 0 var _dbg_chunks_null := 0
@@ -293,6 +300,8 @@ func _ready() -> void:
_tick_editor_streaming() _tick_editor_streaming()
elif auto_position_camera: elif auto_position_camera:
_position_camera_over_world() _position_camera_over_world()
if not Engine.is_editor_hint():
_capture_streaming_focus_from_source()
set_process(true) set_process(true)
call_deferred("_refresh_streaming_targets_after_ready") call_deferred("_refresh_streaming_targets_after_ready")
@@ -364,7 +373,7 @@ func _process(delta: float) -> void:
if Engine.is_editor_hint(): if Engine.is_editor_hint():
_tick_editor_streaming() _tick_editor_streaming()
else: else:
_refresh_streaming_targets(false) refresh_streaming_focus(false)
did_refresh = true did_refresh = true
_profile_section(timings, "refresh", section_start, profile_enabled) _profile_section(timings, "refresh", section_start, profile_enabled)
_log_hitch_profile(profile_start, timings, did_refresh, profile_enabled) _log_hitch_profile(profile_start, timings, did_refresh, profile_enabled)
@@ -590,10 +599,11 @@ func _tick_editor_streaming() -> void:
var focus_world := _tile_center_to_world(editor_preview_center_x, editor_preview_center_y) var focus_world := _tile_center_to_world(editor_preview_center_x, editor_preview_center_y)
if editor_follow_view_camera: if editor_follow_view_camera:
var editor_camera := _get_stream_camera() var editor_camera := _get_editor_view_camera()
if editor_camera: if editor_camera:
focus_world += editor_camera.global_position focus_world += editor_camera.global_position
_refresh_editor_streaming_targets_at(focus_world, false) _set_streaming_focus_from_vector3(focus_world)
_refresh_editor_streaming_targets_at(_streaming_focus_to_vector3(), false)
func _scan_available_tiles() -> void: func _scan_available_tiles() -> void:
@@ -681,24 +691,28 @@ func _load_tiles_from_directory() -> void:
_tile_max.y = max(_tile_max.y, ty) _tile_max.y = max(_tile_max.y, ty)
func _refresh_streaming_targets(force: bool) -> void: ## Replaces the current streaming point without requiring a Node or Camera3D.
var camera := _get_stream_camera() func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
if camera == null: _streaming_focus = streaming_focus
return
## Samples the configured scene source, then refreshes streaming from the typed focus.
## Returns false when no valid focus is available; existing streamed content is retained.
func refresh_streaming_focus(force: bool = false) -> bool:
_capture_streaming_focus_from_source()
if _streaming_focus == null or _streaming_focus.world_position == null:
return false
_terrain_root.position = Vector3.ZERO _terrain_root.position = Vector3.ZERO
_refresh_streaming_targets_at(camera.global_position, force) _refresh_streaming_targets_at(_streaming_focus_to_vector3(), force)
return true
func _refresh_streaming_targets_after_ready() -> void: func _refresh_streaming_targets_after_ready() -> void:
var camera := _get_stream_camera()
if camera == null:
return
_terrain_root.position = Vector3.ZERO
_has_refresh_focus = false _has_refresh_focus = false
_refresh_streaming_targets_at(camera.global_position, false) refresh_streaming_focus(false)
## Core streaming update — works for both game (camera pos) and editor (preview center pos). ## Core streaming update for runtime and editor focus positions.
func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> void: func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
if not force and not _should_refresh_focus(focus_pos): if not force and not _should_refresh_focus(focus_pos):
return return
@@ -3048,32 +3062,46 @@ func _is_tile_queued(key: String) -> bool:
return false return false
func _get_stream_camera() -> Camera3D: func _capture_streaming_focus_from_source() -> void:
if Engine.is_editor_hint(): if streaming_focus_source_path == NodePath():
# The 3D editor viewport camera lives in EditorInterface, not get_viewport(). return
# get_viewport().get_camera_3d() from a @tool script returns whichever Camera3D var focus_source := get_node_or_null(streaming_focus_source_path) as Node3D
# belongs to the edited scene (often null), so editor-view movement is invisible if focus_source == null:
# unless we reach into EditorInterface. if not _missing_focus_source_reported:
var vp := EditorInterface.get_editor_viewport_3d(0) push_warning("Streaming focus source is missing or is not Node3D: %s" % streaming_focus_source_path)
if vp != null: _missing_focus_source_reported = true
var cam := vp.get_camera_3d() return
if cam: _missing_focus_source_reported = false
return cam _set_streaming_focus_from_vector3(focus_source.global_position)
if camera_path != NodePath():
var from_path := get_node_or_null(camera_path)
if from_path is Camera3D:
return from_path
var viewport_camera := get_viewport().get_camera_3d() func _set_streaming_focus_from_vector3(world_position: Vector3) -> void:
if viewport_camera: var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(
return viewport_camera world_position.x,
world_position.y,
world_position.z
)
set_streaming_focus(STREAMING_FOCUS_SCRIPT.new(typed_world_position))
for child in get_children():
if child is Camera3D and child.current:
return child
func _streaming_focus_to_vector3() -> Vector3:
var world_position: GodotWorldPosition = _streaming_focus.world_position
return Vector3(world_position.x_units, world_position.y_units, world_position.z_units)
func _get_editor_view_camera() -> Camera3D:
# EditorInterface owns this camera. This method is the explicit editor adapter;
# runtime streaming never discovers a viewport camera.
var editor_viewport := EditorInterface.get_editor_viewport_3d(0)
if editor_viewport == null:
return null return null
return editor_viewport.get_camera_3d()
func _get_auto_position_camera() -> Camera3D:
if camera_path == NodePath():
return null
return get_node_or_null(camera_path) as Camera3D
func _can_use_baked_tile_cache() -> bool: func _can_use_baked_tile_cache() -> bool:
@@ -5365,7 +5393,7 @@ func _position_camera_over_world() -> void:
if _camera_initialized: if _camera_initialized:
return return
var camera := _get_stream_camera() var camera := _get_auto_position_camera()
if camera == null: if camera == null:
return return
+3 -3
View File
@@ -64,7 +64,7 @@ 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("camera_path", NodePath("CheckpointCamera")) world.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 +126,8 @@ 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_targets_at"): if world.has_method("refresh_streaming_focus"):
world.call("_refresh_streaming_targets_at", camera.global_position, 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" % [
+3 -3
View File
@@ -28,7 +28,7 @@ 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("camera_path", NodePath(camera.name)) world.set("streaming_focus_source_path", NodePath(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 +47,8 @@ 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_targets_at"): if world.has_method("refresh_streaming_focus"):
world.call("_refresh_streaming_targets_at", camera_position, 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)
+3 -3
View File
@@ -36,7 +36,7 @@ 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("camera_path", NodePath(camera.name)) world.set("streaming_focus_source_path", NodePath(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
@@ -53,8 +53,8 @@ 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_targets_at"): if world.has_method("refresh_streaming_focus"):
world.call("_refresh_streaming_targets_at", camera_position, 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")
+101
View File
@@ -0,0 +1,101 @@
extends SceneTree
## Headless M01 contract and wiring regression for camera-independent streaming.
const StreamingFocusScript = preload("res://src/domain/streaming/streaming_focus.gd")
const GodotWorldPositionScript = preload("res://src/domain/coordinates/godot_world_position.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
]
const CAPTURE_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",
]
func _initialize() -> void:
var failures: Array[String] = []
_verify_scene_free_focus_value(failures)
_verify_loader_boundary(failures)
_verify_runtime_scene_wiring(failures)
_verify_capture_tool_wiring(failures)
if not failures.is_empty():
for failure in failures:
push_error("STREAMING_FOCUS: %s" % failure)
quit(1)
return
print("STREAMING_FOCUS PASS contract=1 runtime_scenes=2 capture_tools=3")
quit(0)
func _verify_scene_free_focus_value(failures: Array[String]) -> void:
var position = GodotWorldPositionScript.new(17199.159666667, 83.5312, 26016.616666667)
var focus = StreamingFocusScript.new(position)
_expect_true(focus.world_position == position, "focus retains typed position", failures)
_expect_near(focus.world_position.x_units, 17199.159666667, "focus X", failures)
_expect_near(focus.world_position.y_units, 83.5312, "focus Y", failures)
_expect_near(focus.world_position.z_units, 26016.616666667, "focus Z", failures)
var focus_source := _read_text("res://src/domain/streaming/streaming_focus.gd", failures)
_expect_true(not focus_source.contains("extends Node"), "focus does not inherit Node", failures)
_expect_true(not focus_source.contains("extends Resource"), "focus does not inherit Resource", failures)
_expect_true(not focus_source.contains("Vector3"), "focus does not expose Vector3", failures)
func _verify_loader_boundary(failures: Array[String]) -> void:
var loader_source := _read_text(LOADER_PATH, failures)
for required_text in [
"@export var streaming_focus_source_path: NodePath",
"func set_streaming_focus(streaming_focus: StreamingFocus) -> void:",
"func refresh_streaming_focus(force: bool = false) -> bool:",
]:
_expect_true(loader_source.contains(required_text), "loader contains %s" % required_text, failures)
for forbidden_text in [
"get_viewport().get_camera_3d()",
"child is Camera3D and child.current",
"func _get_stream_camera()",
]:
_expect_true(not loader_source.contains(forbidden_text), "loader omits %s" % forbidden_text, failures)
func _verify_runtime_scene_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('streaming_focus_source_path = NodePath("ThirdPersonPlayer")'),
"%s uses player focus" % scene_path,
failures
)
func _verify_capture_tool_wiring(failures: Array[String]) -> void:
for tool_path in CAPTURE_TOOL_PATHS:
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('world.call("refresh_streaming_focus", true)'), "%s uses public refresh" % tool_path, failures)
_expect_true(not tool_source.contains('world.call("_refresh_streaming_targets_at"'), "%s avoids private refresh" % 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 absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.9f, got %.9f" % [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)
+1
View File
@@ -0,0 +1 @@
uid://b7ayw5a0bn1lq