refactor(M03): extract M2 build batch planner
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
class_name M2BuildBatchPlanner
|
||||
extends RefCounted
|
||||
|
||||
## Stateless M2 build batch sizing and group-cursor progression.
|
||||
|
||||
|
||||
## Plans one static or animated build batch. The returned Dictionary is detached
|
||||
## and contains effective_batch_size, batch_count, next_offset and group_complete.
|
||||
func plan_batch(
|
||||
transform_count: int,
|
||||
current_offset: int,
|
||||
has_animated_prototype: bool,
|
||||
animated_batch_limit: int,
|
||||
static_batch_limit: int
|
||||
) -> Dictionary:
|
||||
var selected_batch_limit := (
|
||||
animated_batch_limit if has_animated_prototype else static_batch_limit
|
||||
)
|
||||
var effective_batch_size := maxi(1, selected_batch_limit)
|
||||
var remaining_transform_count := maxi(0, transform_count - current_offset)
|
||||
var batch_count := mini(effective_batch_size, remaining_transform_count)
|
||||
var is_group_complete := (
|
||||
batch_count <= 0 or current_offset + batch_count >= transform_count
|
||||
)
|
||||
return {
|
||||
"effective_batch_size": effective_batch_size,
|
||||
"batch_count": batch_count,
|
||||
"next_offset": 0 if is_group_complete else current_offset + batch_count,
|
||||
"group_complete": is_group_complete,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0my7yntfsvuy
|
||||
@@ -33,6 +33,9 @@ const M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT := preload(
|
||||
const M2_PLACEMENT_GROUPER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_placement_grouper.gd"
|
||||
)
|
||||
const M2_BUILD_BATCH_PLANNER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_build_batch_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")
|
||||
@@ -219,6 +222,7 @@ var _m2_unique_placement_registry := (
|
||||
)
|
||||
var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
|
||||
var _m2_placement_grouper := M2_PLACEMENT_GROUPER_SCRIPT.new()
|
||||
var _m2_build_batch_planner := M2_BUILD_BATCH_PLANNER_SCRIPT.new()
|
||||
var _m2_mesh_cache: Dictionary = {}
|
||||
var _m2_mesh_load_requests: Dictionary = {}
|
||||
var _m2_mesh_finalize_queue: Array = []
|
||||
@@ -4315,14 +4319,15 @@ func _process_m2_build_jobs() -> void:
|
||||
_m2_build_queue.append(key)
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
|
||||
continue
|
||||
var batch_size: int = (
|
||||
maxi(1, m2_animated_instances_per_tick)
|
||||
if animated_prototype != null
|
||||
else maxi(1, m2_multimesh_batch_size)
|
||||
var batch_plan: Dictionary = _m2_build_batch_planner.plan_batch(
|
||||
transforms.size(),
|
||||
offset,
|
||||
animated_prototype != null,
|
||||
m2_animated_instances_per_tick,
|
||||
m2_multimesh_batch_size
|
||||
)
|
||||
var batch_count: int = mini(batch_size, maxi(0, transforms.size() - offset))
|
||||
var batch_count: int = int(batch_plan["batch_count"])
|
||||
var serial: int = int(job.get("serial", 0))
|
||||
var consumed := batch_count
|
||||
if batch_count > 0:
|
||||
if animated_prototype != null:
|
||||
_materialize_m2_animated_batch(root as Node3D, rel_path, animated_prototype, transforms, offset, batch_count, serial)
|
||||
@@ -4337,11 +4342,11 @@ func _process_m2_build_jobs() -> void:
|
||||
else:
|
||||
_materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial)
|
||||
job["serial"] = serial + 1
|
||||
if consumed <= 0 or offset + consumed >= transforms.size():
|
||||
if bool(batch_plan["group_complete"]):
|
||||
job["index"] = index + 1
|
||||
job["offset"] = 0
|
||||
else:
|
||||
job["offset"] = offset + consumed
|
||||
job["offset"] = int(batch_plan["next_offset"])
|
||||
_m2_build_jobs[key] = job
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free contract, dependency and timing regression for M2 batch planning.
|
||||
|
||||
const PLANNER_SCRIPT := preload("res://src/render/m2/m2_build_batch_planner.gd")
|
||||
const PLANNER_PATH := "res://src/render/m2/m2_build_batch_planner.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_static_batch_limit(failures)
|
||||
_verify_animated_batch_limit(failures)
|
||||
_verify_non_positive_limit_clamp(failures)
|
||||
_verify_remaining_count_cap(failures)
|
||||
_verify_exact_group_completion(failures)
|
||||
_verify_empty_and_past_end(failures)
|
||||
_verify_historical_negative_offset(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_BUILD_BATCH_PLANNER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_BUILD_BATCH_PLANNER PASS cases=8 iterations=20000 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_static_batch_limit(failures: Array[String]) -> void:
|
||||
var plan := _plan(300, 0, false, 8, 128)
|
||||
_expect_plan(plan, 128, 128, 128, false, "static batch", failures)
|
||||
|
||||
|
||||
func _verify_animated_batch_limit(failures: Array[String]) -> void:
|
||||
var plan := _plan(300, 0, true, 8, 128)
|
||||
_expect_plan(plan, 8, 8, 8, false, "animated batch", failures)
|
||||
|
||||
|
||||
func _verify_non_positive_limit_clamp(failures: Array[String]) -> void:
|
||||
var animated_plan := _plan(4, 0, true, 0, 128)
|
||||
_expect_plan(animated_plan, 1, 1, 1, false, "zero animated limit", failures)
|
||||
var static_plan := _plan(4, 0, false, 8, -10)
|
||||
_expect_plan(static_plan, 1, 1, 1, false, "negative static limit", failures)
|
||||
|
||||
|
||||
func _verify_remaining_count_cap(failures: Array[String]) -> void:
|
||||
var plan := _plan(140, 128, false, 8, 128)
|
||||
_expect_plan(plan, 128, 12, 0, true, "remaining cap", failures)
|
||||
|
||||
|
||||
func _verify_exact_group_completion(failures: Array[String]) -> void:
|
||||
var plan := _plan(16, 8, true, 8, 128)
|
||||
_expect_plan(plan, 8, 8, 0, true, "exact completion", failures)
|
||||
|
||||
|
||||
func _verify_empty_and_past_end(failures: Array[String]) -> void:
|
||||
var empty_plan := _plan(0, 0, false, 8, 128)
|
||||
_expect_plan(empty_plan, 128, 0, 0, true, "empty group", failures)
|
||||
var past_end_plan := _plan(10, 11, false, 8, 128)
|
||||
_expect_plan(past_end_plan, 128, 0, 0, true, "past end", failures)
|
||||
|
||||
|
||||
func _verify_historical_negative_offset(failures: Array[String]) -> void:
|
||||
var plan := _plan(10, -2, false, 8, 4)
|
||||
_expect_plan(plan, 4, 4, 2, false, "negative offset", failures)
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var loader_source := _read_text(LOADER_PATH, failures)
|
||||
var planner_source := _read_text(PLANNER_PATH, failures)
|
||||
var build_loop_source := _source_between(
|
||||
loader_source,
|
||||
"func _process_m2_build_jobs()",
|
||||
"func _materialize_m2_group_batch(",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
build_loop_source.count("_m2_build_batch_planner.plan_batch("),
|
||||
1,
|
||||
"one loader planner adapter",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
not build_loop_source.contains("var batch_size:"),
|
||||
"loader omits batch-size formula",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
not build_loop_source.contains("mini(batch_size"),
|
||||
"loader omits batch-count formula",
|
||||
failures
|
||||
)
|
||||
for forbidden_dependency in [
|
||||
"WorkerThreadPool",
|
||||
"RenderingServer",
|
||||
"ResourceLoader",
|
||||
"MultiMesh",
|
||||
"Node",
|
||||
"Mutex",
|
||||
]:
|
||||
_expect_true(
|
||||
not planner_source.contains(forbidden_dependency),
|
||||
"planner omits %s" % forbidden_dependency,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var planner := PLANNER_SCRIPT.new()
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for iteration in range(20000):
|
||||
var plan: Dictionary = planner.plan_batch(
|
||||
512,
|
||||
iteration % 512,
|
||||
iteration % 3 == 0,
|
||||
8,
|
||||
128
|
||||
)
|
||||
if int(plan.get("batch_count", -1)) < 0:
|
||||
failures.append("timing iteration returned negative batch count")
|
||||
break
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "20000 batch plans bounded", failures)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _plan(
|
||||
transform_count: int,
|
||||
current_offset: int,
|
||||
has_animated_prototype: bool,
|
||||
animated_batch_limit: int,
|
||||
static_batch_limit: int
|
||||
) -> Dictionary:
|
||||
var planner := PLANNER_SCRIPT.new()
|
||||
return planner.plan_batch(
|
||||
transform_count,
|
||||
current_offset,
|
||||
has_animated_prototype,
|
||||
animated_batch_limit,
|
||||
static_batch_limit
|
||||
)
|
||||
|
||||
|
||||
func _expect_plan(
|
||||
plan: Dictionary,
|
||||
expected_batch_size: int,
|
||||
expected_batch_count: int,
|
||||
expected_next_offset: int,
|
||||
expected_group_complete: bool,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
_expect_equal(
|
||||
int(plan.get("effective_batch_size", -1)),
|
||||
expected_batch_size,
|
||||
"%s effective batch size" % label,
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
int(plan.get("batch_count", -1)),
|
||||
expected_batch_count,
|
||||
"%s batch count" % label,
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
int(plan.get("next_offset", -1)),
|
||||
expected_next_offset,
|
||||
"%s next offset" % label,
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
bool(plan.get("group_complete", not expected_group_complete))
|
||||
== expected_group_complete,
|
||||
"%s group complete" % label,
|
||||
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 _source_between(
|
||||
source: String,
|
||||
start_marker: String,
|
||||
end_marker: String,
|
||||
failures: Array[String]
|
||||
) -> String:
|
||||
var start_offset := source.find(start_marker)
|
||||
var end_offset := source.find(end_marker, start_offset + start_marker.length())
|
||||
if start_offset < 0 or end_offset <= start_offset:
|
||||
failures.append("cannot isolate source between %s and %s" % [start_marker, end_marker])
|
||||
return ""
|
||||
return source.substr(start_offset, end_offset - start_offset)
|
||||
|
||||
|
||||
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://bw088yviu4kk3
|
||||
Reference in New Issue
Block a user