refactor(M03): extract M2 build batch planner

This commit is contained in:
2026-07-17 00:12:42 +04:00
parent a562d126b9
commit be6db7f7e0
10 changed files with 478 additions and 13 deletions
+30
View File
@@ -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
+13 -8
View File
@@ -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)
+217
View File
@@ -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