render: extract M2 build dispatch planner

This commit is contained in:
2026-07-18 02:34:18 +04:00
parent 1fb566f9bc
commit 17f5cc0faa
12 changed files with 632 additions and 36 deletions
@@ -0,0 +1,48 @@
class_name M2BuildDispatchPlanner
extends RefCounted
## Selects the next M2 build action from already observed resource state.
## The planner retains no state or engine-object references.
const ACTION_WAIT_FOR_ANIMATION := &"wait_for_animation"
const ACTION_MATERIALIZE_ANIMATED := &"materialize_animated"
const ACTION_WAIT_FOR_STATIC_MESH := &"wait_for_static_mesh"
const ACTION_MATERIALIZE_STATIC := &"materialize_static"
const ACTION_ADVANCE_WITHOUT_MATERIALIZATION := &"advance_without_materialization"
## Returns a detached action/transition plan for one M2 build operation.
func plan_step(
batch_count: int,
animation_request_pending: bool,
has_animated_prototype: bool,
has_static_mesh: bool,
static_model_missing: bool
) -> Dictionary:
if animation_request_pending:
return _wait_plan(ACTION_WAIT_FOR_ANIMATION)
if batch_count <= 0:
return _advance_plan(ACTION_ADVANCE_WITHOUT_MATERIALIZATION, false)
if has_animated_prototype:
return _advance_plan(ACTION_MATERIALIZE_ANIMATED, true)
if has_static_mesh:
return _advance_plan(ACTION_MATERIALIZE_STATIC, true)
if static_model_missing:
return _advance_plan(ACTION_ADVANCE_WITHOUT_MATERIALIZATION, true)
return _wait_plan(ACTION_WAIT_FOR_STATIC_MESH)
func _wait_plan(action: StringName) -> Dictionary:
return {
"action": action,
"rotate_queue": true,
"increment_batch_serial": false,
}
func _advance_plan(action: StringName, increment_batch_serial: bool) -> Dictionary:
return {
"action": action,
"rotate_queue": false,
"increment_batch_serial": increment_batch_serial,
}
@@ -0,0 +1 @@
uid://df0bv1x2x4j2x
+67 -23
View File
@@ -60,6 +60,9 @@ const M2_PLACEMENT_GROUPER_SCRIPT := preload(
const M2_BUILD_BATCH_PLANNER_SCRIPT := preload(
"res://src/render/m2/m2_build_batch_planner.gd"
)
const M2_BUILD_DISPATCH_PLANNER_SCRIPT := preload(
"res://src/render/m2/m2_build_dispatch_planner.gd"
)
const M2_BUILD_QUEUE_SCRIPT := preload("res://src/render/m2/m2_build_queue.gd")
const M2_STATIC_BATCH_MATERIALIZER_SCRIPT := preload(
"res://src/render/m2/m2_static_batch_materializer.gd"
@@ -270,6 +273,7 @@ var _m2_group_tasks: Dictionary = {}
var _m2_group_result_mutex := Mutex.new()
var _m2_group_result_queue: Array = []
var _m2_build_queue_state := M2_BUILD_QUEUE_SCRIPT.new()
var _m2_build_dispatch_planner := M2_BUILD_DISPATCH_PLANNER_SCRIPT.new()
var _m2_unique_placement_registry := (
M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()
)
@@ -4342,32 +4346,72 @@ func _process_m2_build_jobs() -> void:
animated_prototype = _get_or_load_m2_native_animated_prototype(rel_path)
if animated_prototype == null:
animated_prototype = _get_or_load_m2_animated_prototype(rel_path)
if enable_m2_animated_instances and _m2_animation_load_pipeline_state.has_request(normalized_rel):
_m2_build_queue_state.rotate_front()
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
continue
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 animation_request_pending := (
enable_m2_animated_instances
and _m2_animation_load_pipeline_state.has_request(normalized_rel)
)
var batch_count: int = int(batch_plan["batch_count"])
var batch_plan: Dictionary = {}
var batch_count := 0
var static_mesh: Mesh = null
var static_model_missing := false
if not animation_request_pending:
batch_plan = _m2_build_batch_planner.plan_batch(
transforms.size(),
offset,
animated_prototype != null,
m2_animated_instances_per_tick,
m2_multimesh_batch_size
)
batch_count = int(batch_plan["batch_count"])
if batch_count > 0 and animated_prototype == null:
static_mesh = _get_m2_mesh_or_request(rel_path)
static_model_missing = (
static_mesh == null
and _m2_prototype_cache_state.is_model_missing(normalized_rel)
)
var dispatch_plan: Dictionary = _m2_build_dispatch_planner.plan_step(
batch_count,
animation_request_pending,
animated_prototype != null,
static_mesh != null,
static_model_missing
)
if bool(dispatch_plan["rotate_queue"]):
_m2_build_queue_state.rotate_front()
_render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD
)
continue
var serial := _m2_build_queue_state.batch_serial_for(key)
var next_serial := serial
if batch_count > 0:
if animated_prototype != null:
_materialize_m2_animated_batch(root as Node3D, rel_path, animated_prototype, transforms, offset, batch_count, serial)
else:
var mesh := _get_m2_mesh_or_request(rel_path)
if mesh == null:
if not _m2_prototype_cache_state.is_model_missing(normalized_rel):
_m2_build_queue_state.rotate_front()
_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)
var dispatch_action: StringName = dispatch_plan["action"]
if (
dispatch_action
== M2_BUILD_DISPATCH_PLANNER_SCRIPT.ACTION_MATERIALIZE_ANIMATED
):
_materialize_m2_animated_batch(
root as Node3D,
rel_path,
animated_prototype,
transforms,
offset,
batch_count,
serial
)
elif (
dispatch_action
== M2_BUILD_DISPATCH_PLANNER_SCRIPT.ACTION_MATERIALIZE_STATIC
):
_materialize_m2_group_batch(
root as Node3D,
rel_path,
static_mesh,
transforms,
offset,
batch_count,
serial
)
if bool(dispatch_plan["increment_batch_serial"]):
next_serial = serial + 1
var next_group_index := index
var next_transform_offset := offset
@@ -0,0 +1,225 @@
extends SceneTree
## Asset-free M2 build dispatch priority, transition, source-boundary and
## bounded-timing regression.
const PLANNER_SCRIPT := preload(
"res://src/render/m2/m2_build_dispatch_planner.gd"
)
const PLANNER_PATH := "res://src/render/m2/m2_build_dispatch_planner.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_wait_and_empty_actions(failures)
_verify_materialization_priority(failures)
_verify_static_retry_and_missing_actions(failures)
_verify_fresh_results(failures)
_verify_loader_and_dependency_boundaries(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("M2_BUILD_DISPATCH_PLANNER: %s" % failure)
quit(1)
return
print(
"M2_BUILD_DISPATCH_PLANNER PASS cases=10 iterations=20000 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_wait_and_empty_actions(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var animation_wait: Dictionary = planner.call(
"plan_step", 4, true, true, true, true
)
_expect_plan(
animation_wait,
PLANNER_SCRIPT.ACTION_WAIT_FOR_ANIMATION,
true,
false,
"animation wait has absolute priority",
failures
)
var empty_batch: Dictionary = planner.call(
"plan_step", 0, false, true, true, true
)
_expect_plan(
empty_batch,
PLANNER_SCRIPT.ACTION_ADVANCE_WITHOUT_MATERIALIZATION,
false,
false,
"zero batch advances without serial",
failures
)
var negative_batch: Dictionary = planner.call(
"plan_step", -3, false, false, false, false
)
_expect_plan(
negative_batch,
PLANNER_SCRIPT.ACTION_ADVANCE_WITHOUT_MATERIALIZATION,
false,
false,
"negative batch advances without serial",
failures
)
func _verify_materialization_priority(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var animated: Dictionary = planner.call(
"plan_step", 3, false, true, true, true
)
_expect_plan(
animated,
PLANNER_SCRIPT.ACTION_MATERIALIZE_ANIMATED,
false,
true,
"animated prototype wins over static state",
failures
)
var static_mesh: Dictionary = planner.call(
"plan_step", 3, false, false, true, true
)
_expect_plan(
static_mesh,
PLANNER_SCRIPT.ACTION_MATERIALIZE_STATIC,
false,
true,
"prepared static mesh wins over missing flag",
failures
)
func _verify_static_retry_and_missing_actions(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var static_wait: Dictionary = planner.call(
"plan_step", 2, false, false, false, false
)
_expect_plan(
static_wait,
PLANNER_SCRIPT.ACTION_WAIT_FOR_STATIC_MESH,
true,
false,
"unresolved static mesh rotates without serial",
failures
)
var missing: Dictionary = planner.call(
"plan_step", 2, false, false, false, true
)
_expect_plan(
missing,
PLANNER_SCRIPT.ACTION_ADVANCE_WITHOUT_MATERIALIZATION,
false,
true,
"terminal missing model advances serial",
failures
)
func _verify_fresh_results(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var first: Dictionary = planner.call("plan_step", 1, false, true, false, false)
first["action"] = &"mutated"
first["rotate_queue"] = true
var second: Dictionary = planner.call("plan_step", 1, false, true, false, false)
_expect_plan(
second,
PLANNER_SCRIPT.ACTION_MATERIALIZE_ANIMATED,
false,
true,
"plan results are detached",
failures
)
func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void:
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
var planner_source := FileAccess.get_file_as_string(PLANNER_PATH)
_expect_true(
loader_source.contains("M2_BUILD_DISPATCH_PLANNER_SCRIPT.new()"),
"loader composes dispatch planner",
failures
)
_expect_true(
loader_source.contains("_m2_build_dispatch_planner.plan_step("),
"loader delegates dispatch decision",
failures
)
_expect_true(
loader_source.contains("_get_m2_mesh_or_request(rel_path)")
and loader_source.contains("_materialize_m2_animated_batch(")
and loader_source.contains("_materialize_m2_group_batch(")
and loader_source.contains("try_consume_permit("),
"loader retains readiness, materialization and permits",
failures
)
for forbidden_token in [
"preload(",
"ResourceLoader",
"RenderingServer",
"FileAccess",
"queue_free",
"Node3D",
"WorkerThreadPool",
"Thread",
"Mutex",
]:
_expect_false(
planner_source.contains(forbidden_token),
"planner omits %s ownership" % forbidden_token,
failures
)
func _verify_bounded_timing(failures: Array[String]) -> float:
var planner: RefCounted = PLANNER_SCRIPT.new()
var started_microseconds := Time.get_ticks_usec()
for iteration in range(20000):
planner.call(
"plan_step",
iteration % 5,
iteration % 11 == 0,
iteration % 3 == 0,
iteration % 3 == 1,
iteration % 7 == 0
)
var elapsed_milliseconds := (
float(Time.get_ticks_usec() - started_microseconds) / 1000.0
)
_expect_true(
elapsed_milliseconds < 1000.0,
"20,000 dispatch plans remain bounded",
failures
)
return elapsed_milliseconds
func _expect_plan(
plan: Dictionary,
expected_action: StringName,
expected_rotation: bool,
expected_serial_increment: bool,
label: String,
failures: Array[String]
) -> void:
if plan.get("action", &"") != expected_action:
failures.append("%s action" % label)
if bool(plan.get("rotate_queue", false)) != expected_rotation:
failures.append("%s rotation" % label)
if (
bool(plan.get("increment_batch_serial", false))
!= expected_serial_increment
):
failures.append("%s serial" % label)
func _expect_true(condition: bool, label: String, failures: Array[String]) -> void:
if not condition:
failures.append(label)
func _expect_false(condition: bool, label: String, failures: Array[String]) -> void:
_expect_true(not condition, label, failures)
@@ -0,0 +1 @@
uid://ck4mypi4djlmy