rnd(M03): extract render budget scheduler

This commit is contained in:
2026-07-16 00:31:01 +04:00
parent 91f0724ce2
commit e52f703da5
9 changed files with 594 additions and 74 deletions
+81 -68
View File
@@ -19,6 +19,7 @@ const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_ti
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd")
const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd")
const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
@@ -233,6 +234,7 @@ var _dbg_last_report := 0.0
var _runtime_stats_accum := 0.0
var _terrain_quality_log_accum := 0.0
var _shutting_down := false
var _render_budget_scheduler := RENDER_BUDGET_SCHEDULER_SCRIPT.new()
var _last_refresh_focus_pos := Vector3.ZERO
var _has_refresh_focus := false
var _tile_mesh_cache: Dictionary = {}
@@ -316,6 +318,7 @@ func _ready() -> void:
func _process(delta: float) -> void:
if _available_tiles.is_empty():
return
_render_budget_scheduler.begin_frame(_render_operation_limits_for_frame())
var profile_enabled := hitch_profiler_enabled and not Engine.is_editor_hint()
var profile_start := Time.get_ticks_usec() if profile_enabled else 0
@@ -387,11 +390,33 @@ func _process(delta: float) -> void:
func _exit_tree() -> void:
_shutting_down = true
_render_budget_scheduler.cancel()
_wait_for_tile_tasks()
_clear_streamed_world()
_release_runtime_caches_for_shutdown()
func _render_operation_limits_for_frame() -> Dictionary:
return {
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE: maxi(1, tile_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE: maxi(0, terrain_upgrade_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE: maxi(0, terrain_control_splat_cache_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE: maxi(0, terrain_splat_cache_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD: maxi(0, terrain_splat_builds_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE: maxi(0, water_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY: maxi(0, chunk_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOAD_START: maxi(0, tiles_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_CREATE: maxi(0, tile_lod_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_REMOVE: maxi(0, tile_lod_remove_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE: maxi(0, m2_animation_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE: maxi(0, m2_mesh_finalize_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD: maxi(0, m2_build_groups_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD: maxi(0, wmo_build_instances_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD: maxi(0, wmo_render_group_ops_per_tick),
RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC: maxi(0, detail_asset_ops_per_tick),
}
## Frees detached prototype nodes and releases render resources owned by this
## loader after all asynchronous work has completed. Map refreshes retain these
## caches; only scene shutdown destroys them.
@@ -909,33 +934,31 @@ func _rebuild_chunk_queues() -> void:
func _process_queues() -> void:
var ops_left: int = chunk_ops_per_tick
var tile_lod_create_left: int = tile_lod_ops_per_tick
var tile_lod_remove_left: int = tile_lod_remove_ops_per_tick
# Free stale terrain first so GPU descriptors/memory are reclaimed before new creates.
while ops_left > 0 and not _chunk_remove_queue.is_empty():
while not _chunk_remove_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
_remove_chunk(_chunk_remove_queue.pop_back())
ops_left -= 1
while tile_lod_remove_left > 0 and not _tile_lod_remove_queue.is_empty():
while not _tile_lod_remove_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_REMOVE):
_remove_tile_lod(_tile_lod_remove_queue.pop_back())
tile_lod_remove_left -= 1
# Parse N ADT files this tick — the main throttle knob
var loads_left: int = tiles_per_tick
while loads_left > 0 and not _tile_load_queue.is_empty() and _tile_loading_tasks.size() < max_concurrent_tile_tasks:
while (
not _tile_load_queue.is_empty()
and _tile_loading_tasks.size() < max_concurrent_tile_tasks
and _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOAD_START)
):
_start_tile_load_async(_tile_load_queue.pop_front())
loads_left -= 1
while tile_lod_create_left > 0 and not _tile_lod_create_queue.is_empty():
while not _tile_lod_create_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_CREATE):
_create_tile_lod(_tile_lod_create_queue.pop_front())
tile_lod_create_left -= 1
# Create chunk meshes — sorted nearest-first so visible detail appears first
while ops_left > 0 and not _chunk_create_queue.is_empty():
while not _chunk_create_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
_create_chunk(_chunk_create_queue.pop_front())
ops_left -= 1
# Debug summary every 2 seconds
_dbg_last_report += get_process_delta_time()
@@ -1286,11 +1309,10 @@ func _load_tile_task(request: Dictionary) -> void:
func _drain_tile_load_results() -> void:
var results: Array = []
var finalize_budget: int = maxi(1, tile_finalize_ops_per_tick)
_tile_result_mutex.lock()
while finalize_budget > 0 and not _tile_result_queue.is_empty():
while not _tile_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
results.append(_tile_result_queue.pop_front())
finalize_budget -= 1
_tile_result_mutex.unlock()
for result in results:
@@ -1314,10 +1336,10 @@ func _drain_tile_load_results() -> void:
_finalize_loaded_tile(request, result["data"])
var baked_ready: Array = []
if finalize_budget <= 0:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
return
for key in _tile_loading_tasks.keys():
if baked_ready.size() >= finalize_budget:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
break
var pending: Dictionary = _tile_loading_tasks[key]
if pending.get("mode", "raw") != "baked" and pending.get("mode", "raw") != "stream":
@@ -1325,7 +1347,8 @@ func _drain_tile_load_results() -> void:
var status := ResourceLoader.load_threaded_get_status(pending["path"])
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
baked_ready.append(key)
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
baked_ready.append(key)
for key in baked_ready:
if not _tile_loading_tasks.has(key):
@@ -1743,18 +1766,18 @@ func _request_terrain_splat_cache(state: Dictionary) -> bool:
func _drain_terrain_upgrade_results() -> void:
var ready: Array[String] = []
var budget := maxi(0, terrain_upgrade_finalize_ops_per_tick)
if budget <= 0:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
return
for key_variant in _terrain_upgrade_tasks.keys():
if ready.size() >= budget:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
break
var key := String(key_variant)
var pending: Dictionary = _terrain_upgrade_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
ready.append(key)
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
ready.append(key)
var changed := false
for key in ready:
@@ -1802,18 +1825,18 @@ func _drain_terrain_upgrade_results() -> void:
func _drain_terrain_control_splat_cache_results() -> void:
var ready: Array[String] = []
var budget := maxi(0, terrain_control_splat_cache_finalize_ops_per_tick)
if budget <= 0:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
return
for key_variant in _terrain_control_splat_cache_tasks.keys():
if ready.size() >= budget:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
break
var key := String(key_variant)
var pending: Dictionary = _terrain_control_splat_cache_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
ready.append(key)
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
ready.append(key)
var changed := false
for key in ready:
@@ -1868,18 +1891,18 @@ func _drain_terrain_control_splat_cache_results() -> void:
func _drain_terrain_splat_cache_results() -> void:
var ready: Array[String] = []
var budget := maxi(0, terrain_splat_cache_finalize_ops_per_tick)
if budget <= 0:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
return
for key_variant in _terrain_splat_cache_tasks.keys():
if ready.size() >= budget:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
break
var key := String(key_variant)
var pending: Dictionary = _terrain_splat_cache_tasks[key]
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
ready.append(key)
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
ready.append(key)
var changed := false
for key in ready:
@@ -1956,14 +1979,13 @@ func _load_terrain_splat_task(key: String, adt_path: String) -> void:
func _drain_terrain_splat_results() -> void:
var results: Array = []
var budget := maxi(0, terrain_splat_builds_per_tick)
if budget <= 0:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD):
return
_terrain_splat_result_mutex.lock()
while budget > 0 and not _terrain_splat_result_queue.is_empty():
while not _terrain_splat_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD):
results.append(_terrain_splat_result_queue.pop_front())
budget -= 1
_terrain_splat_result_mutex.unlock()
var changed := false
@@ -2067,14 +2089,13 @@ func _load_tile_water_task(key: String, adt_path: String) -> void:
func _drain_water_load_results() -> void:
var results: Array = []
var budget := maxi(0, water_finalize_ops_per_tick)
if budget <= 0:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
return
_water_result_mutex.lock()
while budget > 0 and not _water_result_queue.is_empty():
while not _water_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
results.append(_water_result_queue.pop_front())
budget -= 1
_water_result_mutex.unlock()
for result in results:
@@ -3252,12 +3273,11 @@ func _enqueue_detail_asset_sync(key: String) -> void:
func _process_detail_asset_queue() -> void:
var ops_left: int = maxi(0, detail_asset_ops_per_tick)
while ops_left > 0 and not _detail_asset_queue.is_empty():
while not _detail_asset_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC):
var key: String = String(_detail_asset_queue.pop_front())
_detail_asset_queued.erase(key)
_process_detail_asset_key(key)
ops_left -= 1
func _process_detail_asset_key(key: String) -> void:
@@ -3341,8 +3361,8 @@ func _register_tile_wmos(state: Dictionary, wmo_names: PackedStringArray, wmo_pl
func _process_wmo_build_jobs() -> void:
_drain_wmo_render_loads()
_drain_wmo_scene_loads()
var ops_left: int = maxi(0, wmo_build_instances_per_tick)
while ops_left > 0 and not _wmo_build_queue.is_empty():
while _render_budget_scheduler.has_remaining_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD) and not _wmo_build_queue.is_empty():
var tile_key: String = String(_wmo_build_queue.front())
if not _wmo_build_jobs.has(tile_key):
_wmo_build_queue.pop_front()
@@ -3439,7 +3459,7 @@ func _process_wmo_build_jobs() -> void:
_tile_states[tile_key] = state
_wmo_build_queue.pop_front()
_wmo_build_queue.append(tile_key)
ops_left -= 1
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD)
continue
job["index"] = index + 1
@@ -3447,7 +3467,7 @@ func _process_wmo_build_jobs() -> void:
state["wmo_refs"] = refs
state["wmo_building"] = true
_tile_states[tile_key] = state
ops_left -= 1
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD)
if int(job["index"]) >= wmo_placements.size():
state["wmo_refs"] = refs
@@ -3589,8 +3609,8 @@ func _start_wmo_render_build(unique_key: String, root: Node3D, render_resource:
func _process_wmo_render_build_jobs() -> void:
var ops_left: int = maxi(0, wmo_render_group_ops_per_tick)
while ops_left > 0 and not _wmo_render_build_queue.is_empty():
while _render_budget_scheduler.has_remaining_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD) and not _wmo_render_build_queue.is_empty():
var unique_key := String(_wmo_render_build_queue.front())
if not _wmo_render_build_jobs.has(unique_key):
_wmo_render_build_queue.pop_front()
@@ -3632,7 +3652,7 @@ func _process_wmo_render_build_jobs() -> void:
_set_editor_owner_recursive(mesh_instance)
job["mesh_index"] = mesh_index + 1
_wmo_render_build_jobs[unique_key] = job
ops_left -= 1
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
continue
var multimeshes: Array = render_resource.get("multimeshes")
@@ -3664,7 +3684,7 @@ func _process_wmo_render_build_jobs() -> void:
_set_editor_owner_recursive(multimesh_instance)
job["multimesh_index"] = multimesh_index + 1
_wmo_render_build_jobs[unique_key] = job
ops_left -= 1
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
continue
_cancel_wmo_render_build_job(unique_key)
@@ -4176,16 +4196,14 @@ func _drain_m2_animation_loads() -> void:
_m2_animation_load_requests.erase(normalized_rel)
_m2_animation_finalize_queue.append(pending)
var ops_left: int = maxi(0, m2_animation_finalize_ops_per_tick)
while ops_left > 0 and not _m2_animation_finalize_queue.is_empty():
while not _m2_animation_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE):
var pending: Dictionary = _m2_animation_finalize_queue.pop_front()
var normalized_rel := String(pending.get("normalized", ""))
if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel):
ops_left -= 1
continue
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
_m2_static_animation_cache[normalized_rel] = true
ops_left -= 1
continue
var path := String(pending.get("path", ""))
@@ -4199,11 +4217,9 @@ func _drain_m2_animation_loads() -> void:
_m2_animated_scene_cache[normalized_rel] = node as Node3D
if debug_streaming:
print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()])
ops_left -= 1
continue
node.free()
_m2_static_animation_cache[normalized_rel] = true
ops_left -= 1
func _drain_m2_mesh_loads() -> void:
@@ -4222,16 +4238,14 @@ func _drain_m2_mesh_loads() -> void:
_m2_mesh_load_requests.erase(normalized_rel)
_m2_mesh_finalize_queue.append(pending)
var ops_left: int = maxi(0, m2_mesh_finalize_ops_per_tick)
while ops_left > 0 and not _m2_mesh_finalize_queue.is_empty():
while not _m2_mesh_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE):
var pending: Dictionary = _m2_mesh_finalize_queue.pop_front()
var normalized_rel := String(pending.get("normalized", ""))
if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel):
ops_left -= 1
continue
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
_m2_missing_cache[normalized_rel] = true
ops_left -= 1
continue
var path := String(pending.get("path", ""))
@@ -4241,12 +4255,11 @@ func _drain_m2_mesh_loads() -> void:
_m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh)
else:
_m2_missing_cache[normalized_rel] = true
ops_left -= 1
func _process_m2_build_jobs() -> void:
var ops_left: int = maxi(0, m2_build_groups_per_tick)
while ops_left > 0 and not _m2_build_queue.is_empty():
while _render_budget_scheduler.has_remaining_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) and not _m2_build_queue.is_empty():
var key: String = String(_m2_build_queue.front())
if not _m2_build_jobs.has(key):
_m2_build_queue.pop_front()
@@ -4292,7 +4305,7 @@ func _process_m2_build_jobs() -> void:
if enable_m2_animated_instances and _m2_animation_load_requests.has(normalized_rel):
_m2_build_queue.pop_front()
_m2_build_queue.append(key)
ops_left -= 1
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
continue
var batch_size: int = (
maxi(1, m2_animated_instances_per_tick)
@@ -4311,7 +4324,7 @@ func _process_m2_build_jobs() -> void:
if not _m2_missing_cache.has(normalized_rel):
_m2_build_queue.pop_front()
_m2_build_queue.append(key)
ops_left -= 1
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
continue
else:
_materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial)
@@ -4322,7 +4335,7 @@ func _process_m2_build_jobs() -> void:
else:
job["offset"] = offset + consumed
_m2_build_jobs[key] = job
ops_left -= 1
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
if int(job["index"]) >= group_keys.size():
_finish_m2_build_job(key)