refactor(M03): extract terrain chunk LOD planner
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
class_name TerrainChunkLodPlanner
|
||||
extends RefCounted
|
||||
|
||||
## Pure linear desired-state planner for parsed ADT chunk records.
|
||||
|
||||
const POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd")
|
||||
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
|
||||
|
||||
|
||||
## Maps source chunk indices to LOD 0/1/2 using horizontal distance from the
|
||||
## typed focus to each chunk center. Empty/out-of-range chunks remain absent.
|
||||
func plan(
|
||||
chunks: Array,
|
||||
focus_position: RefCounted,
|
||||
policy: RefCounted
|
||||
) -> Dictionary:
|
||||
if (
|
||||
focus_position == null
|
||||
or focus_position.get_script() != GODOT_WORLD_POSITION_SCRIPT
|
||||
or policy == null
|
||||
or policy.get_script() != POLICY_SCRIPT
|
||||
or not bool(policy.get("is_enabled"))
|
||||
):
|
||||
return {}
|
||||
|
||||
var chunk_size_units := float(policy.get("chunk_size_units"))
|
||||
var lod0_radius_squared := (float(policy.get("lod0_radius_chunks")) * chunk_size_units) ** 2
|
||||
var lod1_radius_squared := (float(policy.get("lod1_radius_chunks")) * chunk_size_units) ** 2
|
||||
var lod2_radius_squared := (float(policy.get("lod2_radius_chunks")) * chunk_size_units) ** 2
|
||||
var focus_x_units := float(focus_position.get("x_units"))
|
||||
var focus_z_units := float(focus_position.get("z_units"))
|
||||
var desired_lods: Dictionary = {}
|
||||
|
||||
for chunk_id in range(chunks.size()):
|
||||
var chunk: Dictionary = chunks[chunk_id]
|
||||
if chunk.is_empty():
|
||||
continue
|
||||
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
|
||||
var center_x_units := origin.x + chunk_size_units * 0.5
|
||||
var center_z_units := origin.z + chunk_size_units * 0.5
|
||||
var delta_x_units := center_x_units - focus_x_units
|
||||
var delta_z_units := center_z_units - focus_z_units
|
||||
var distance_squared_units := (
|
||||
delta_x_units * delta_x_units + delta_z_units * delta_z_units
|
||||
)
|
||||
if distance_squared_units <= lod0_radius_squared:
|
||||
desired_lods[chunk_id] = 0
|
||||
elif distance_squared_units <= lod1_radius_squared:
|
||||
desired_lods[chunk_id] = 1
|
||||
elif distance_squared_units <= lod2_radius_squared:
|
||||
desired_lods[chunk_id] = 2
|
||||
|
||||
return desired_lods
|
||||
@@ -0,0 +1 @@
|
||||
uid://x8simqkfctqw
|
||||
@@ -0,0 +1,45 @@
|
||||
class_name TerrainChunkLodPolicy
|
||||
extends RefCounted
|
||||
|
||||
## Immutable snapshot of the streamer's chunk-terrain LOD configuration.
|
||||
|
||||
var is_enabled: bool:
|
||||
get:
|
||||
return _is_enabled
|
||||
|
||||
var lod0_radius_chunks: int:
|
||||
get:
|
||||
return _lod0_radius_chunks
|
||||
|
||||
var lod1_radius_chunks: int:
|
||||
get:
|
||||
return _lod1_radius_chunks
|
||||
|
||||
var lod2_radius_chunks: int:
|
||||
get:
|
||||
return _lod2_radius_chunks
|
||||
|
||||
var chunk_size_units: float:
|
||||
get:
|
||||
return _chunk_size_units
|
||||
|
||||
var _is_enabled: bool
|
||||
var _lod0_radius_chunks: int
|
||||
var _lod1_radius_chunks: int
|
||||
var _lod2_radius_chunks: int
|
||||
var _chunk_size_units: float
|
||||
|
||||
|
||||
## Captures one renderer configuration snapshot for a deterministic plan.
|
||||
func _init(
|
||||
is_enabled_value: bool,
|
||||
lod0_radius_chunks_value: int,
|
||||
lod1_radius_chunks_value: int,
|
||||
lod2_radius_chunks_value: int,
|
||||
chunk_size_units_value: float
|
||||
) -> void:
|
||||
_is_enabled = is_enabled_value
|
||||
_lod0_radius_chunks = lod0_radius_chunks_value
|
||||
_lod1_radius_chunks = lod1_radius_chunks_value
|
||||
_lod2_radius_chunks = lod2_radius_chunks_value
|
||||
_chunk_size_units = chunk_size_units_value
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmwi83dp52pa8
|
||||
@@ -19,6 +19,8 @@ const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_ti
|
||||
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
|
||||
const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd")
|
||||
const TERRAIN_QUALITY_MESH_CACHE_SCRIPT := preload("res://src/render/terrain/terrain_quality_mesh_cache.gd")
|
||||
const TERRAIN_CHUNK_LOD_POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd")
|
||||
const TERRAIN_CHUNK_LOD_PLANNER_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_planner.gd")
|
||||
const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd")
|
||||
const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd")
|
||||
const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
|
||||
@@ -181,6 +183,7 @@ const QUALITY_HIGH := "High"
|
||||
|
||||
var _builder
|
||||
var _streaming_target_planner := STREAMING_TARGET_PLANNER_SCRIPT.new()
|
||||
var _terrain_chunk_lod_planner := TERRAIN_CHUNK_LOD_PLANNER_SCRIPT.new()
|
||||
var _map_dir := ""
|
||||
var _abs_map_dir := ""
|
||||
var _wdt_info: Dictionary = {}
|
||||
@@ -797,6 +800,8 @@ func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
|
||||
|
||||
func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionary, focus_pos: Vector3) -> void:
|
||||
_last_focus_pos = focus_pos
|
||||
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
|
||||
var terrain_chunk_lod_policy = _create_terrain_chunk_lod_policy()
|
||||
|
||||
var filtered_load_queue: Array = []
|
||||
for request in _tile_load_queue:
|
||||
@@ -831,7 +836,7 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar
|
||||
state["wanted"] = wanted
|
||||
state["retained"] = retained
|
||||
if wanted:
|
||||
state["desired_lods"] = _compute_desired_lods(state, focus_pos)
|
||||
state["desired_lods"] = _compute_desired_lods(state, typed_focus_position, terrain_chunk_lod_policy)
|
||||
state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, focus_pos)
|
||||
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
||||
elif retained:
|
||||
@@ -2492,7 +2497,16 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void:
|
||||
}
|
||||
|
||||
# Use _last_focus_pos — already set correctly for both editor and game.
|
||||
state["desired_lods"] = _compute_desired_lods(state, _last_focus_pos)
|
||||
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(
|
||||
_last_focus_pos.x,
|
||||
_last_focus_pos.y,
|
||||
_last_focus_pos.z
|
||||
)
|
||||
state["desired_lods"] = _compute_desired_lods(
|
||||
state,
|
||||
typed_focus_position,
|
||||
_create_terrain_chunk_lod_policy()
|
||||
)
|
||||
state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, _last_focus_pos)
|
||||
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
|
||||
_tile_states[key] = state
|
||||
@@ -3045,33 +3059,23 @@ func _reset_terrain_root_children() -> void:
|
||||
child.free()
|
||||
|
||||
|
||||
func _compute_desired_lods(state: Dictionary, cam_pos: Vector3) -> Dictionary:
|
||||
if not use_chunk_terrain_lods:
|
||||
return {}
|
||||
|
||||
var desired := {}
|
||||
func _compute_desired_lods(
|
||||
state: Dictionary,
|
||||
typed_focus_position: RefCounted,
|
||||
terrain_chunk_lod_policy: RefCounted
|
||||
) -> Dictionary:
|
||||
var chunks: Array = state["data"].get("chunks", [])
|
||||
return _terrain_chunk_lod_planner.plan(chunks, typed_focus_position, terrain_chunk_lod_policy)
|
||||
|
||||
var lod0_sq: float = (lod0_radius_chunks * CHUNK_SIZE) ** 2
|
||||
var lod1_sq: float = (lod1_radius_chunks * CHUNK_SIZE) ** 2
|
||||
var lod2_sq: float = (lod2_radius_chunks * CHUNK_SIZE) ** 2
|
||||
|
||||
for chunk_id in range(chunks.size()):
|
||||
var chunk: Dictionary = chunks[chunk_id]
|
||||
if chunk.is_empty():
|
||||
continue
|
||||
|
||||
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
|
||||
var center := origin + Vector3(CHUNK_SIZE * 0.5, 0.0, CHUNK_SIZE * 0.5)
|
||||
var dx := center.x - cam_pos.x
|
||||
var dz := center.z - cam_pos.z
|
||||
var dist_sq := dx * dx + dz * dz
|
||||
|
||||
if dist_sq <= lod0_sq: desired[chunk_id] = 0
|
||||
elif dist_sq <= lod1_sq: desired[chunk_id] = 1
|
||||
elif dist_sq <= lod2_sq: desired[chunk_id] = 2
|
||||
|
||||
return desired
|
||||
func _create_terrain_chunk_lod_policy() -> RefCounted:
|
||||
return TERRAIN_CHUNK_LOD_POLICY_SCRIPT.new(
|
||||
use_chunk_terrain_lods,
|
||||
lod0_radius_chunks,
|
||||
lod1_radius_chunks,
|
||||
lod2_radius_chunks,
|
||||
CHUNK_SIZE
|
||||
)
|
||||
|
||||
|
||||
func _runtime_load_tile_radius() -> int:
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free formula, dependency and bounded timing regression for chunk LOD.
|
||||
|
||||
const PLANNER_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_planner.gd")
|
||||
const POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd")
|
||||
const POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_formula(failures)
|
||||
_verify_disabled_and_custom_policy(failures)
|
||||
_verify_loader_boundary(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("TERRAIN_CHUNK_LOD_PLANNER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print("TERRAIN_CHUNK_LOD_PLANNER PASS cases=7 iterations=250 elapsed_ms=%.3f" % elapsed_milliseconds)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_formula(failures: Array[String]) -> void:
|
||||
var planner: RefCounted = PLANNER_SCRIPT.new()
|
||||
var policy: RefCounted = POLICY_SCRIPT.new(true, 1, 2, 3, 10.0)
|
||||
var focus: RefCounted = POSITION_SCRIPT.new(0.0, 9999.0, 0.0)
|
||||
var chunks: Array = [
|
||||
{"origin": Vector3(-5.0, 2000.0, -5.0)},
|
||||
{"origin": Vector3(5.0, -3000.0, -5.0)},
|
||||
{"origin": Vector3(5.001, 0.0, -5.0)},
|
||||
{"origin": Vector3(15.0, 0.0, -5.0)},
|
||||
{"origin": Vector3(20.0, 0.0, -5.0)},
|
||||
{"origin": Vector3(25.0, 0.0, -5.0)},
|
||||
{"origin": Vector3(25.001, 0.0, -5.0)},
|
||||
{},
|
||||
]
|
||||
var desired: Dictionary = planner.call("plan", chunks, focus, policy)
|
||||
_expect_equal(int(desired.get(0, -1)), 0, "center ignores vertical distance", failures)
|
||||
_expect_equal(int(desired.get(1, -1)), 0, "lod0 inclusive boundary", failures)
|
||||
_expect_equal(int(desired.get(2, -1)), 1, "just outside lod0", failures)
|
||||
_expect_equal(int(desired.get(3, -1)), 1, "lod1 inclusive boundary", failures)
|
||||
_expect_equal(int(desired.get(4, -1)), 2, "between lod1 and lod2", failures)
|
||||
_expect_equal(int(desired.get(5, -1)), 2, "lod2 inclusive boundary", failures)
|
||||
_expect_true(not desired.has(6) and not desired.has(7), "outside and sparse chunks absent", failures)
|
||||
|
||||
|
||||
func _verify_disabled_and_custom_policy(failures: Array[String]) -> void:
|
||||
var planner: RefCounted = PLANNER_SCRIPT.new()
|
||||
var focus: RefCounted = POSITION_SCRIPT.new(0.0, 0.0, 0.0)
|
||||
var chunks: Array = [{"origin": Vector3(-5.0, 0.0, -5.0)}]
|
||||
_expect_true((planner.call("plan", chunks, focus, POLICY_SCRIPT.new(false, 1, 2, 3, 10.0)) as Dictionary).is_empty(), "disabled policy empty", failures)
|
||||
var negative_policy: RefCounted = POLICY_SCRIPT.new(true, -1, -2, -3, 10.0)
|
||||
_expect_equal(int((planner.call("plan", chunks, focus, negative_policy) as Dictionary).get(0, -1)), 0, "negative radius keeps squared semantics", failures)
|
||||
|
||||
|
||||
func _verify_loader_boundary(failures: Array[String]) -> void:
|
||||
var source := _read_text(LOADER_PATH, failures)
|
||||
_expect_true(source.contains("TERRAIN_CHUNK_LOD_PLANNER_SCRIPT.new()"), "loader composes planner", failures)
|
||||
_expect_true(source.contains("_terrain_chunk_lod_planner.plan(chunks, typed_focus_position, terrain_chunk_lod_policy)"), "loader delegates plan", failures)
|
||||
_expect_true(not source.contains("dist_sq <= lod0_sq"), "loader omits chunk LOD formula", failures)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var planner: RefCounted = PLANNER_SCRIPT.new()
|
||||
var policy: RefCounted = POLICY_SCRIPT.new(true, 10, 20, 40, 33.333333)
|
||||
var focus: RefCounted = POSITION_SCRIPT.new(1000.0, 0.0, 1000.0)
|
||||
var chunks: Array = []
|
||||
for chunk_index in range(256):
|
||||
chunks.append({"origin": Vector3(float(chunk_index % 16) * 33.333333, 0.0, float(chunk_index / 16) * 33.333333)})
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for _iteration in range(250):
|
||||
planner.call("plan", chunks, focus, policy)
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "250 plans remain bounded", failures)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot open %s" % path)
|
||||
return ""
|
||||
return file.get_as_text()
|
||||
|
||||
|
||||
func _expect_equal(actual_value: int, expected_value: int, label: String, failures: Array[String]) -> void:
|
||||
if actual_value != expected_value:
|
||||
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if not actual_value:
|
||||
failures.append("%s expected true" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://2oljbpoxndx3
|
||||
Reference in New Issue
Block a user