rnd(M03): extract render budget scheduler
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
## Issues bounded, per-frame permits for renderer main-thread operation lanes.
|
||||
##
|
||||
## The scheduler owns counters only. Queue contents, operation ordering and the
|
||||
## side effects performed after a permit remain owned by the caller. One instance
|
||||
## belongs to one renderer lifecycle and must only be used on the main thread.
|
||||
class_name RenderBudgetScheduler
|
||||
extends RefCounted
|
||||
|
||||
const TILE_FINALIZE: StringName = &"tile_finalize"
|
||||
const TERRAIN_UPGRADE_FINALIZE: StringName = &"terrain_upgrade_finalize"
|
||||
const TERRAIN_CONTROL_SPLAT_FINALIZE: StringName = &"terrain_control_splat_finalize"
|
||||
const TERRAIN_SPLAT_CACHE_FINALIZE: StringName = &"terrain_splat_cache_finalize"
|
||||
const TERRAIN_SPLAT_BUILD: StringName = &"terrain_splat_build"
|
||||
const WATER_FINALIZE: StringName = &"water_finalize"
|
||||
const CHUNK_GEOMETRY: StringName = &"chunk_geometry"
|
||||
const TILE_LOAD_START: StringName = &"tile_load_start"
|
||||
const TILE_LOD_CREATE: StringName = &"tile_lod_create"
|
||||
const TILE_LOD_REMOVE: StringName = &"tile_lod_remove"
|
||||
const M2_ANIMATION_FINALIZE: StringName = &"m2_animation_finalize"
|
||||
const M2_MESH_FINALIZE: StringName = &"m2_mesh_finalize"
|
||||
const M2_BUILD: StringName = &"m2_build"
|
||||
const WMO_BUILD: StringName = &"wmo_build"
|
||||
const WMO_RENDER_GROUP_BUILD: StringName = &"wmo_render_group_build"
|
||||
const DETAIL_ASSET_SYNC: StringName = &"detail_asset_sync"
|
||||
|
||||
var _remaining_permits_by_lane: Dictionary = {}
|
||||
var _consumed_permits_by_lane: Dictionary = {}
|
||||
var _is_cancelled := false
|
||||
|
||||
|
||||
## Replaces all lane limits for a new frame. Negative limits are treated as zero.
|
||||
## Calling this after [method cancel] leaves the scheduler cancelled.
|
||||
func begin_frame(operation_limits_by_lane: Dictionary) -> void:
|
||||
_remaining_permits_by_lane.clear()
|
||||
_consumed_permits_by_lane.clear()
|
||||
if _is_cancelled:
|
||||
return
|
||||
|
||||
for lane_variant in operation_limits_by_lane:
|
||||
var lane := StringName(lane_variant)
|
||||
_remaining_permits_by_lane[lane] = maxi(0, int(operation_limits_by_lane[lane_variant]))
|
||||
_consumed_permits_by_lane[lane] = 0
|
||||
|
||||
|
||||
## Returns whether a lane can issue another permit without consuming it.
|
||||
## Unknown lanes and cancelled schedulers return false.
|
||||
func has_remaining_permit(lane: StringName) -> bool:
|
||||
if _is_cancelled:
|
||||
return false
|
||||
return int(_remaining_permits_by_lane.get(lane, 0)) > 0
|
||||
|
||||
|
||||
## Consumes one permit from [param lane] and returns true when it was granted.
|
||||
## Unknown, exhausted or cancelled lanes return false without changing state.
|
||||
func try_consume_permit(lane: StringName) -> bool:
|
||||
if not has_remaining_permit(lane):
|
||||
return false
|
||||
_remaining_permits_by_lane[lane] = int(_remaining_permits_by_lane[lane]) - 1
|
||||
_consumed_permits_by_lane[lane] = int(_consumed_permits_by_lane.get(lane, 0)) + 1
|
||||
return true
|
||||
|
||||
|
||||
## Returns the unconsumed permit count for one lane.
|
||||
func remaining_permits(lane: StringName) -> int:
|
||||
if _is_cancelled:
|
||||
return 0
|
||||
return int(_remaining_permits_by_lane.get(lane, 0))
|
||||
|
||||
|
||||
## Returns a detached diagnostic snapshot of permits consumed in this frame.
|
||||
func consumed_permits_snapshot() -> Dictionary:
|
||||
return _consumed_permits_by_lane.duplicate()
|
||||
|
||||
|
||||
## Permanently rejects new permits for this scheduler instance and clears limits.
|
||||
## In-flight work remains the caller's responsibility and is not interrupted.
|
||||
func cancel() -> void:
|
||||
_is_cancelled = true
|
||||
_remaining_permits_by_lane.clear()
|
||||
|
||||
|
||||
## Returns whether [method cancel] ended this scheduler lifecycle.
|
||||
func is_cancelled() -> bool:
|
||||
return _is_cancelled
|
||||
@@ -0,0 +1 @@
|
||||
uid://byaii7lec82i6
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free M03 regression for bounded renderer operation permits and cancellation.
|
||||
|
||||
const SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
|
||||
const SCHEDULER_PATH := "res://src/render/streaming/render_budget_scheduler.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const PERFORMANCE_ITERATIONS := 20000
|
||||
const MAXIMUM_PERMIT_CHECK_MILLISECONDS := 150.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_bounded_lane_and_diagnostics(failures)
|
||||
_verify_shared_lane_priority(failures)
|
||||
_verify_independent_lanes_and_frame_reset(failures)
|
||||
_verify_invalid_limits_and_unknown_lane(failures)
|
||||
_verify_cancellation(failures)
|
||||
_verify_scene_free_extraction(failures)
|
||||
var elapsed_milliseconds := _measure_permit_checks(failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("RENDER_BUDGET_SCHEDULER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("RENDER_BUDGET_SCHEDULER PASS cases=6 iterations=%d elapsed_ms=%.3f" % [
|
||||
PERFORMANCE_ITERATIONS,
|
||||
elapsed_milliseconds,
|
||||
])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_bounded_lane_and_diagnostics(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.TILE_FINALIZE: 2})
|
||||
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "first permit", failures)
|
||||
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "second permit", failures)
|
||||
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "exhausted permit", failures)
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_FINALIZE), 0, "remaining permits", failures)
|
||||
var consumed_snapshot: Dictionary = scheduler.consumed_permits_snapshot()
|
||||
_expect_equal(int(consumed_snapshot[SCHEDULER_SCRIPT.TILE_FINALIZE]), 2, "consumed permits", failures)
|
||||
consumed_snapshot[SCHEDULER_SCRIPT.TILE_FINALIZE] = 99
|
||||
_expect_equal(
|
||||
int(scheduler.consumed_permits_snapshot()[SCHEDULER_SCRIPT.TILE_FINALIZE]),
|
||||
2,
|
||||
"diagnostic snapshot is detached",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_shared_lane_priority(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.CHUNK_GEOMETRY: 3})
|
||||
var removals := 0
|
||||
while removals < 2 and scheduler.try_consume_permit(SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
|
||||
removals += 1
|
||||
var creates := 0
|
||||
while scheduler.try_consume_permit(SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
|
||||
creates += 1
|
||||
_expect_equal(removals, 2, "shared lane removals consume first", failures)
|
||||
_expect_equal(creates, 1, "shared lane leaves one create", failures)
|
||||
|
||||
|
||||
func _verify_independent_lanes_and_frame_reset(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({
|
||||
SCHEDULER_SCRIPT.TILE_LOD_CREATE: 1,
|
||||
SCHEDULER_SCRIPT.TILE_LOD_REMOVE: 2,
|
||||
})
|
||||
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_LOD_CREATE), "create lane permit", failures)
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_REMOVE), 2, "remove lane independent", failures)
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.TILE_LOD_CREATE: 3})
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_CREATE), 3, "new frame resets lane", failures)
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_REMOVE), 0, "new frame removes absent lane", failures)
|
||||
_expect_equal(int(scheduler.consumed_permits_snapshot()[SCHEDULER_SCRIPT.TILE_LOD_CREATE]), 0, "new frame resets diagnostics", failures)
|
||||
|
||||
|
||||
func _verify_invalid_limits_and_unknown_lane(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({
|
||||
SCHEDULER_SCRIPT.WATER_FINALIZE: 0,
|
||||
SCHEDULER_SCRIPT.M2_BUILD: -4,
|
||||
})
|
||||
_expect_false(scheduler.has_remaining_permit(SCHEDULER_SCRIPT.WATER_FINALIZE), "zero limit", failures)
|
||||
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.M2_BUILD), "negative limit clamps", failures)
|
||||
_expect_false(scheduler.try_consume_permit(&"unknown_lane"), "unknown lane", failures)
|
||||
|
||||
|
||||
func _verify_cancellation(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.WMO_BUILD: 2})
|
||||
scheduler.cancel()
|
||||
_expect_true(scheduler.is_cancelled(), "cancelled state", failures)
|
||||
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.WMO_BUILD), "cancel rejects permit", failures)
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.WMO_BUILD: 5})
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.WMO_BUILD), 0, "begin frame cannot revive cancelled lifecycle", failures)
|
||||
|
||||
|
||||
func _verify_scene_free_extraction(failures: Array[String]) -> void:
|
||||
var scheduler_source := _read_text(SCHEDULER_PATH, failures)
|
||||
_expect_true(scheduler_source.contains("extends RefCounted"), "scheduler is RefCounted", failures)
|
||||
for forbidden_text in ["extends Node", "WorkerThreadPool", "ResourceLoader", "RenderingServer", "Mutex"]:
|
||||
_expect_false(scheduler_source.contains(forbidden_text), "scheduler omits %s" % forbidden_text, failures)
|
||||
|
||||
var loader_source := _read_text(LOADER_PATH, failures)
|
||||
_expect_true(loader_source.contains("begin_frame(_render_operation_limits_for_frame())"), "loader begins one budget frame", failures)
|
||||
_expect_true(loader_source.contains("_render_budget_scheduler.cancel()"), "loader cancels scheduler on teardown", failures)
|
||||
_expect_false(loader_source.contains("var ops_left"), "loader has no local operation counters", failures)
|
||||
_expect_false(loader_source.contains("var finalize_budget"), "loader has no local finalize counter", failures)
|
||||
for exported_limit_name in [
|
||||
"tile_finalize_ops_per_tick",
|
||||
"terrain_upgrade_finalize_ops_per_tick",
|
||||
"terrain_control_splat_cache_finalize_ops_per_tick",
|
||||
"terrain_splat_cache_finalize_ops_per_tick",
|
||||
"terrain_splat_builds_per_tick",
|
||||
"water_finalize_ops_per_tick",
|
||||
"chunk_ops_per_tick",
|
||||
"tiles_per_tick",
|
||||
"tile_lod_ops_per_tick",
|
||||
"tile_lod_remove_ops_per_tick",
|
||||
"m2_animation_finalize_ops_per_tick",
|
||||
"m2_mesh_finalize_ops_per_tick",
|
||||
"m2_build_groups_per_tick",
|
||||
"wmo_build_instances_per_tick",
|
||||
"wmo_render_group_ops_per_tick",
|
||||
"detail_asset_ops_per_tick",
|
||||
]:
|
||||
_expect_true(
|
||||
loader_source.contains(": maxi(") and loader_source.contains(exported_limit_name),
|
||||
"frame snapshot includes %s" % exported_limit_name,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _measure_permit_checks(failures: Array[String]) -> float:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC: PERFORMANCE_ITERATIONS})
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for _iteration in PERFORMANCE_ITERATIONS:
|
||||
scheduler.try_consume_permit(SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC)
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(
|
||||
elapsed_milliseconds <= MAXIMUM_PERMIT_CHECK_MILLISECONDS,
|
||||
"permit checks stay within %.1f ms (actual %.3f)" % [
|
||||
MAXIMUM_PERMIT_CHECK_MILLISECONDS,
|
||||
elapsed_milliseconds,
|
||||
],
|
||||
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)
|
||||
|
||||
|
||||
func _expect_false(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if actual_value:
|
||||
failures.append("%s expected false" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b27nncxfog7cv
|
||||
Reference in New Issue
Block a user