render: extract M2 build dispatch planner
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user