feat(M03): add environment snapshot facade
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
class_name WorldEnvironmentSnapshot
|
||||
extends RefCounted
|
||||
|
||||
## Immutable renderer input describing the authoritative time within one world day.
|
||||
## DBC light, area, fog and skybox selection remain owned by the sky controller.
|
||||
|
||||
const HOURS_PER_DAY := 24.0
|
||||
const SCRIPT_PATH := "res://src/render/environment/world_environment_snapshot.gd"
|
||||
|
||||
var is_valid: bool:
|
||||
get:
|
||||
return _is_valid
|
||||
|
||||
var time_of_day_hours: float:
|
||||
get:
|
||||
return _time_of_day_hours
|
||||
|
||||
var failure_code: StringName:
|
||||
get:
|
||||
return _failure_code
|
||||
|
||||
var _is_valid: bool
|
||||
var _time_of_day_hours: float
|
||||
var _failure_code: StringName
|
||||
|
||||
|
||||
## Creates a snapshot and normalizes finite input into the half-open [0, 24) day.
|
||||
## Non-finite input produces an explicit invalid value instead of renderer state.
|
||||
static func at_time_of_day(time_of_day_hours_value: float) -> RefCounted:
|
||||
return load(SCRIPT_PATH).new(time_of_day_hours_value)
|
||||
|
||||
|
||||
func _init(time_of_day_hours_value: float) -> void:
|
||||
_is_valid = is_finite(time_of_day_hours_value)
|
||||
_time_of_day_hours = (
|
||||
fposmod(time_of_day_hours_value, HOURS_PER_DAY)
|
||||
if _is_valid
|
||||
else 0.0
|
||||
)
|
||||
_failure_code = &"" if _is_valid else &"environment_time_not_finite"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dass4mi3ex2fu
|
||||
@@ -8,17 +8,24 @@ extends Node
|
||||
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 RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd")
|
||||
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.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("..")
|
||||
|
||||
## Scene-owned outdoor environment implementation resolved relative to this node.
|
||||
## The facade delegates snapshots but owns neither the controller nor Environment.
|
||||
@export var world_environment_controller_path: NodePath = NodePath("../WowSkyController")
|
||||
|
||||
## 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 _world_environment_controller: Node
|
||||
var _missing_loader_reported := false
|
||||
var _missing_environment_controller_reported := false
|
||||
var _missing_focus_source_reported := false
|
||||
|
||||
|
||||
@@ -94,6 +101,21 @@ func renderer_ground_query_snapshot(godot_world_position: GodotWorldPosition) ->
|
||||
return (snapshot_variant as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
## Applies an immutable environment snapshot through the renderer-owned sky path.
|
||||
## Returns false for an invalid value or unavailable/incompatible controller.
|
||||
func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool:
|
||||
if (
|
||||
world_environment_snapshot == null
|
||||
or world_environment_snapshot.get_script() != WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT
|
||||
or not bool(world_environment_snapshot.get("is_valid"))
|
||||
):
|
||||
return false
|
||||
var controller := _resolve_world_environment_controller()
|
||||
if controller == null:
|
||||
return false
|
||||
return bool(controller.call("apply_environment_snapshot", world_environment_snapshot))
|
||||
|
||||
|
||||
func _capture_streaming_focus_from_source() -> void:
|
||||
if streaming_focus_source_path == NodePath():
|
||||
return
|
||||
@@ -138,3 +160,20 @@ func _resolve_streaming_world_loader() -> Node:
|
||||
_streaming_world_loader = candidate
|
||||
_missing_loader_reported = false
|
||||
return _streaming_world_loader
|
||||
|
||||
|
||||
func _resolve_world_environment_controller() -> Node:
|
||||
if is_instance_valid(_world_environment_controller):
|
||||
return _world_environment_controller
|
||||
var candidate := get_node_or_null(world_environment_controller_path)
|
||||
if candidate == null or not candidate.has_method(&"apply_environment_snapshot"):
|
||||
if not _missing_environment_controller_reported:
|
||||
push_error(
|
||||
"WorldRenderFacade cannot resolve a compatible environment controller at %s"
|
||||
% world_environment_controller_path
|
||||
)
|
||||
_missing_environment_controller_reported = true
|
||||
return null
|
||||
_world_environment_controller = candidate
|
||||
_missing_environment_controller_reported = false
|
||||
return _world_environment_controller
|
||||
|
||||
@@ -4,6 +4,7 @@ extends Node
|
||||
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
|
||||
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
|
||||
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
|
||||
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")
|
||||
|
||||
const LIGHT_COORD_SCALE := 36.0
|
||||
const HALF_MINUTES_PER_DAY := 2880
|
||||
@@ -133,6 +134,22 @@ func _process(delta: float) -> void:
|
||||
_update_skybox_transform()
|
||||
|
||||
|
||||
## Accepts an authoritative time snapshot without moving DBC visual selection out
|
||||
## of this controller. Existing fixed-clock behavior is preserved and frozen until
|
||||
## another snapshot or explicit local clock configuration is supplied.
|
||||
func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool:
|
||||
if (
|
||||
world_environment_snapshot == null
|
||||
or world_environment_snapshot.get_script() != WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT
|
||||
or not bool(world_environment_snapshot.get("is_valid"))
|
||||
):
|
||||
return false
|
||||
use_system_time = false
|
||||
time_speed = 0.0
|
||||
fixed_time_hours = float(world_environment_snapshot.get("time_of_day_hours"))
|
||||
return true
|
||||
|
||||
|
||||
func _resolve_nodes() -> void:
|
||||
if not world_environment_path.is_empty():
|
||||
_world_environment = get_node_or_null(world_environment_path) as WorldEnvironment
|
||||
|
||||
@@ -98,6 +98,7 @@ hitch_profiler_enabled = true
|
||||
script = ExtResource("7_render_facade")
|
||||
streaming_world_loader_path = NodePath("..")
|
||||
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
|
||||
world_environment_controller_path = NodePath("../WowSkyController")
|
||||
|
||||
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
|
||||
|
||||
@@ -99,6 +99,7 @@ hitch_profiler_enabled = true
|
||||
script = ExtResource("7_render_facade")
|
||||
streaming_world_loader_path = NodePath("..")
|
||||
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
|
||||
world_environment_controller_path = NodePath("../WowSkyController")
|
||||
|
||||
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
|
||||
|
||||
@@ -2,6 +2,7 @@ extends SceneTree
|
||||
|
||||
const DEFAULT_MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
|
||||
const M2_NATIVE_ANIMATED_BUILDER := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
|
||||
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")
|
||||
const SHUTDOWN_DRAIN_FRAMES := 2
|
||||
|
||||
|
||||
@@ -130,7 +131,7 @@ func _capture_async() -> void:
|
||||
camera.make_current()
|
||||
if player != null:
|
||||
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)))
|
||||
_apply_environment_time(render_facade, float(checkpoint.get("time_hours", 13.0)))
|
||||
render_facade.call("refresh_streaming_focus", true)
|
||||
|
||||
if dry_run:
|
||||
@@ -308,12 +309,10 @@ func _create_diagnostic_spawn_marker(checkpoint: Dictionary) -> Node3D:
|
||||
return marker_root
|
||||
|
||||
|
||||
func _set_sky_time(world: Node, time_hours: float) -> void:
|
||||
var sky := world.get_node_or_null("WowSkyController")
|
||||
if sky != null:
|
||||
sky.set("use_system_time", false)
|
||||
sky.set("time_speed", 0.0)
|
||||
sky.set("fixed_time_hours", time_hours)
|
||||
func _apply_environment_time(render_facade: Node, time_hours: float) -> void:
|
||||
var environment_snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(time_hours)
|
||||
if not bool(render_facade.call("apply_environment_snapshot", environment_snapshot)):
|
||||
push_error("Render checkpoint could not apply environment time %.3f" % time_hours)
|
||||
|
||||
|
||||
func _environment_metadata() -> Dictionary:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
extends SceneTree
|
||||
|
||||
## Cold-start contract for immutable renderer environment input.
|
||||
|
||||
const SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
var afternoon: RefCounted = SNAPSHOT_SCRIPT.at_time_of_day(13.5)
|
||||
_expect_true(bool(afternoon.get("is_valid")), "finite snapshot", failures)
|
||||
_expect_near(float(afternoon.get("time_of_day_hours")), 13.5, "finite time", failures)
|
||||
|
||||
var wrapped: RefCounted = SNAPSHOT_SCRIPT.at_time_of_day(25.25)
|
||||
_expect_near(float(wrapped.get("time_of_day_hours")), 1.25, "positive day wrap", failures)
|
||||
var negative: RefCounted = SNAPSHOT_SCRIPT.at_time_of_day(-1.0)
|
||||
_expect_near(float(negative.get("time_of_day_hours")), 23.0, "negative day wrap", failures)
|
||||
|
||||
var invalid: RefCounted = SNAPSHOT_SCRIPT.at_time_of_day(INF)
|
||||
_expect_true(not bool(invalid.get("is_valid")), "non-finite invalid", failures)
|
||||
_expect_true(
|
||||
StringName(invalid.get("failure_code")) == &"environment_time_not_finite",
|
||||
"non-finite failure code",
|
||||
failures
|
||||
)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("WORLD_ENVIRONMENT_SNAPSHOT: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print("WORLD_ENVIRONMENT_SNAPSHOT PASS contract=5")
|
||||
quit(0)
|
||||
|
||||
|
||||
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://e85kt58s0ca4
|
||||
@@ -6,6 +6,8 @@ const WORLD_RENDER_FACADE_SCRIPT := preload("res://src/render/world_render_facad
|
||||
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 RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd")
|
||||
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")
|
||||
const WOW_SKY_CONTROLLER_SCRIPT := preload("res://src/scenes/sky/wow_sky_controller.gd")
|
||||
|
||||
const RUNTIME_SCENE_PATHS: Array[String] = [
|
||||
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
|
||||
@@ -47,9 +49,18 @@ class StreamingWorldLoaderDouble extends Node:
|
||||
return ground_snapshot
|
||||
|
||||
|
||||
class WorldEnvironmentControllerDouble extends Node:
|
||||
var applied_snapshot: RefCounted
|
||||
|
||||
func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool:
|
||||
applied_snapshot = world_environment_snapshot
|
||||
return true
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_rendered_ground_sample_contract(failures)
|
||||
_verify_sky_controller_application(failures)
|
||||
await _verify_delegation_and_snapshot_isolation(failures)
|
||||
_verify_repository_wiring(failures)
|
||||
|
||||
@@ -59,7 +70,7 @@ func _initialize() -> void:
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("WORLD_RENDER_FACADE PASS delegation=5 ground_contract=2 runtime_scenes=2 tools=3")
|
||||
print("WORLD_RENDER_FACADE PASS delegation=6 ground_contract=2 environment_contract=7 runtime_scenes=2 tools=3")
|
||||
quit(0)
|
||||
|
||||
|
||||
@@ -79,6 +90,23 @@ func _verify_rendered_ground_sample_contract(failures: Array[String]) -> void:
|
||||
)
|
||||
|
||||
|
||||
func _verify_sky_controller_application(failures: Array[String]) -> void:
|
||||
var controller: Node = WOW_SKY_CONTROLLER_SCRIPT.new()
|
||||
controller.set("use_system_time", true)
|
||||
controller.set("time_speed", 4.0)
|
||||
controller.set("fixed_time_hours", 3.0)
|
||||
var snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(18.75)
|
||||
_expect_true(
|
||||
bool(controller.call("apply_environment_snapshot", snapshot)),
|
||||
"sky controller accepts snapshot",
|
||||
failures
|
||||
)
|
||||
_expect_true(not bool(controller.get("use_system_time")), "snapshot disables system clock", failures)
|
||||
_expect_near(float(controller.get("time_speed")), 0.0, "snapshot freezes local clock", failures)
|
||||
_expect_near(float(controller.get("fixed_time_hours")), 18.75, "snapshot applies exact time", failures)
|
||||
controller.free()
|
||||
|
||||
|
||||
func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void:
|
||||
var test_root := Node.new()
|
||||
var loader := StreamingWorldLoaderDouble.new()
|
||||
@@ -86,12 +114,16 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void:
|
||||
var source := Node3D.new()
|
||||
source.name = "FocusSource"
|
||||
source.position = Vector3(10.0, 20.0, 30.0)
|
||||
var environment_controller := WorldEnvironmentControllerDouble.new()
|
||||
environment_controller.name = "WorldEnvironmentControllerDouble"
|
||||
var facade = WORLD_RENDER_FACADE_SCRIPT.new()
|
||||
facade.name = "WorldRenderFacade"
|
||||
facade.streaming_world_loader_path = NodePath("../StreamingWorldLoaderDouble")
|
||||
facade.streaming_focus_source_path = NodePath("../FocusSource")
|
||||
facade.world_environment_controller_path = NodePath("../WorldEnvironmentControllerDouble")
|
||||
test_root.add_child(loader)
|
||||
test_root.add_child(source)
|
||||
test_root.add_child(environment_controller)
|
||||
test_root.add_child(facade)
|
||||
get_root().add_child(test_root)
|
||||
await process_frame
|
||||
@@ -131,6 +163,24 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void:
|
||||
(ground_snapshot["diagnostic"] as Dictionary)["state_present"] = false
|
||||
_expect_near(float(loader.ground_snapshot.terrain_height), 42.25, "ground snapshot top-level detached", failures)
|
||||
_expect_true(bool(loader.ground_snapshot.diagnostic.state_present), "ground snapshot nested detached", failures)
|
||||
|
||||
var environment_snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(18.75)
|
||||
_expect_true(
|
||||
facade.apply_environment_snapshot(environment_snapshot),
|
||||
"environment snapshot delegates",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
environment_controller.applied_snapshot == environment_snapshot,
|
||||
"environment snapshot reference delegates",
|
||||
failures
|
||||
)
|
||||
var invalid_environment_snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(NAN)
|
||||
_expect_true(
|
||||
not facade.apply_environment_snapshot(invalid_environment_snapshot),
|
||||
"invalid environment snapshot rejected",
|
||||
failures
|
||||
)
|
||||
test_root.queue_free()
|
||||
|
||||
|
||||
@@ -140,6 +190,7 @@ func _verify_repository_wiring(failures: Array[String]) -> void:
|
||||
_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)
|
||||
_expect_true(scene_source.contains('world_environment_controller_path = NodePath("../WowSkyController")'), "%s facade uses sky controller" % scene_path, failures)
|
||||
|
||||
for tool_path in TOOL_PATHS:
|
||||
var tool_source := _read_text(tool_path, failures)
|
||||
@@ -171,6 +222,24 @@ func _verify_repository_wiring(failures: Array[String]) -> void:
|
||||
failures
|
||||
)
|
||||
|
||||
var capture_source := _read_text("res://src/tools/capture_render_checkpoints.gd", failures)
|
||||
_expect_true(
|
||||
capture_source.contains('call("apply_environment_snapshot", environment_snapshot)'),
|
||||
"capture applies environment through facade",
|
||||
failures
|
||||
)
|
||||
for forbidden_capture_text in [
|
||||
'get_node_or_null("WowSkyController")',
|
||||
'sky.set("use_system_time"',
|
||||
'sky.set("time_speed"',
|
||||
'sky.set("fixed_time_hours"',
|
||||
]:
|
||||
_expect_true(
|
||||
not capture_source.contains(forbidden_capture_text),
|
||||
"capture omits direct sky mutation %s" % forbidden_capture_text,
|
||||
failures
|
||||
)
|
||||
|
||||
var loader_source := _read_text(STREAMING_WORLD_LOADER_PATH, failures)
|
||||
for required_loader_text in [
|
||||
"func sample_rendered_ground_height(godot_world_position: GodotWorldPosition)",
|
||||
|
||||
Reference in New Issue
Block a user