refactor(M03): extract WMO render build planner

This commit is contained in:
2026-07-17 00:47:54 +04:00
parent e420a8724f
commit 3a6b1d306d
9 changed files with 490 additions and 22 deletions
@@ -0,0 +1,40 @@
class_name WmoRenderBuildStepPlanner
extends RefCounted
## Selects one value-only WMO render-group build operation without engine objects.
const OPERATION_MESH: StringName = &"mesh"
const OPERATION_MULTIMESH: StringName = &"multimesh"
const OPERATION_COMPLETE: StringName = &"complete"
## Selects the next mesh-first build operation and returns its selected index
## plus the cursor values to adopt after that one operation. Counts and cursors
## are intentionally not clamped so the loader's historical comparisons remain
## exact for every integer input.
func plan_step(
mesh_count: int,
mesh_index: int,
multimesh_count: int,
multimesh_index: int
) -> Dictionary:
if mesh_index < mesh_count:
return {
"operation": OPERATION_MESH,
"selected_index": mesh_index,
"next_mesh_index": mesh_index + 1,
"next_multimesh_index": multimesh_index,
}
if multimesh_index < multimesh_count:
return {
"operation": OPERATION_MULTIMESH,
"selected_index": multimesh_index,
"next_mesh_index": mesh_index,
"next_multimesh_index": multimesh_index + 1,
}
return {
"operation": OPERATION_COMPLETE,
"selected_index": -1,
"next_mesh_index": mesh_index,
"next_multimesh_index": multimesh_index,
}
@@ -0,0 +1 @@
uid://dqogt0el0x3kx
+33 -18
View File
@@ -15,6 +15,9 @@ const WMO_PLACEMENT_RESOLVER_SCRIPT := preload(
const WMO_PLACEMENT_REGISTRY_SCRIPT := preload(
"res://src/render/wmo/wmo_placement_registry.gd"
)
const WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT := preload(
"res://src/render/wmo/wmo_render_build_step_planner.gd"
)
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd")
@@ -290,6 +293,7 @@ var _m2_static_animation_cache: Dictionary = {}
var _m2_missing_cache: Dictionary = {}
var _world_wmo_root: Node3D
var _wmo_placement_registry := WMO_PLACEMENT_REGISTRY_SCRIPT.new()
var _wmo_render_build_step_planner := WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.new()
var _wmo_instance_nodes_by_unique_key: Dictionary = {}
@@ -3791,15 +3795,30 @@ func _process_wmo_render_build_jobs() -> void:
var mesh_transforms: Array = render_resource.get("mesh_transforms")
var mesh_names: PackedStringArray = render_resource.get("mesh_names")
var mesh_index: int = int(job.get("mesh_index", 0))
if mesh_index < meshes.size():
var mesh := meshes[mesh_index] as Mesh
var multimeshes: Array = render_resource.get("multimeshes")
var multimesh_transforms: Array = render_resource.get("multimesh_transforms")
var multimesh_names: PackedStringArray = render_resource.get("multimesh_names")
var multimesh_index: int = int(job.get("multimesh_index", 0))
var build_step: Dictionary = _wmo_render_build_step_planner.plan_step(
meshes.size(),
mesh_index,
multimeshes.size(),
multimesh_index
)
var selected_index := int(build_step["selected_index"])
if build_step["operation"] == WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.OPERATION_MESH:
var mesh := meshes[selected_index] as Mesh
if mesh != null:
_refresh_cached_wmo_mesh_materials(mesh)
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = mesh_names[mesh_index] if mesh_index < mesh_names.size() else "Group_%d" % mesh_index
mesh_instance.name = (
mesh_names[selected_index]
if selected_index < mesh_names.size()
else "Group_%d" % selected_index
)
mesh_instance.mesh = mesh
if mesh_index < mesh_transforms.size():
mesh_instance.transform = mesh_transforms[mesh_index]
if selected_index < mesh_transforms.size():
mesh_instance.transform = mesh_transforms[selected_index]
mesh_instance.cast_shadow = (
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
if wmo_cast_shadows
@@ -3810,28 +3829,24 @@ func _process_wmo_render_build_jobs() -> void:
mesh_instance.visibility_range_end_margin = CHUNK_SIZE
(root as Node3D).add_child(mesh_instance)
_set_editor_owner_recursive(mesh_instance)
job["mesh_index"] = mesh_index + 1
job["mesh_index"] = int(build_step["next_mesh_index"])
_wmo_render_build_jobs[unique_key] = job
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
continue
var multimeshes: Array = render_resource.get("multimeshes")
var multimesh_transforms: Array = render_resource.get("multimesh_transforms")
var multimesh_names: PackedStringArray = render_resource.get("multimesh_names")
var multimesh_index: int = int(job.get("multimesh_index", 0))
if multimesh_index < multimeshes.size():
var multimesh := multimeshes[multimesh_index] as MultiMesh
if build_step["operation"] == WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.OPERATION_MULTIMESH:
var multimesh := multimeshes[selected_index] as MultiMesh
if multimesh != null:
_refresh_cached_wmo_mesh_materials(multimesh.mesh)
var multimesh_instance := MultiMeshInstance3D.new()
multimesh_instance.name = (
multimesh_names[multimesh_index]
if multimesh_index < multimesh_names.size()
else "DoodadGroup_%d" % multimesh_index
multimesh_names[selected_index]
if selected_index < multimesh_names.size()
else "DoodadGroup_%d" % selected_index
)
multimesh_instance.multimesh = multimesh
if multimesh_index < multimesh_transforms.size():
multimesh_instance.transform = multimesh_transforms[multimesh_index]
if selected_index < multimesh_transforms.size():
multimesh_instance.transform = multimesh_transforms[selected_index]
multimesh_instance.cast_shadow = (
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
if wmo_cast_shadows
@@ -3842,7 +3857,7 @@ func _process_wmo_render_build_jobs() -> void:
multimesh_instance.visibility_range_end_margin = CHUNK_SIZE
(root as Node3D).add_child(multimesh_instance)
_set_editor_owner_recursive(multimesh_instance)
job["multimesh_index"] = multimesh_index + 1
job["multimesh_index"] = int(build_step["next_multimesh_index"])
_wmo_render_build_jobs[unique_key] = job
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
continue
@@ -0,0 +1,213 @@
extends SceneTree
## Asset-free ordering, cursor, dependency and timing regression for WMO builds.
const PLANNER_SCRIPT := preload("res://src/render/wmo/wmo_render_build_step_planner.gd")
const PLANNER_PATH := "res://src/render/wmo/wmo_render_build_step_planner.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_mesh_steps_precede_multimeshes(failures)
_verify_multimesh_cursor_and_completion(failures)
_verify_empty_and_raw_integer_behavior(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("WMO_RENDER_BUILD_STEP_PLANNER: %s" % failure)
quit(1)
return
print(
"WMO_RENDER_BUILD_STEP_PLANNER PASS cases=9 iterations=20000 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_mesh_steps_precede_multimeshes(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var first: Dictionary = planner.call("plan_step", 2, 0, 3, 0)
_expect_operation(first, PLANNER_SCRIPT.OPERATION_MESH, 0, 1, 0, "first mesh", failures)
var second: Dictionary = planner.call("plan_step", 2, 1, 3, 0)
_expect_operation(second, PLANNER_SCRIPT.OPERATION_MESH, 1, 2, 0, "second mesh", failures)
var after_meshes: Dictionary = planner.call("plan_step", 2, 2, 3, 0)
_expect_operation(
after_meshes,
PLANNER_SCRIPT.OPERATION_MULTIMESH,
0,
2,
1,
"multimesh follows meshes",
failures
)
func _verify_multimesh_cursor_and_completion(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var last_multimesh: Dictionary = planner.call("plan_step", 1, 1, 2, 1)
_expect_operation(
last_multimesh,
PLANNER_SCRIPT.OPERATION_MULTIMESH,
1,
1,
2,
"last multimesh",
failures
)
var complete: Dictionary = planner.call("plan_step", 1, 1, 2, 2)
_expect_operation(
complete,
PLANNER_SCRIPT.OPERATION_COMPLETE,
-1,
1,
2,
"both ranges complete",
failures
)
func _verify_empty_and_raw_integer_behavior(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var empty: Dictionary = planner.call("plan_step", 0, 0, 0, 0)
_expect_operation(empty, PLANNER_SCRIPT.OPERATION_COMPLETE, -1, 0, 0, "empty", failures)
var negative_mesh_cursor: Dictionary = planner.call("plan_step", 1, -1, 0, 0)
_expect_operation(
negative_mesh_cursor,
PLANNER_SCRIPT.OPERATION_MESH,
-1,
0,
0,
"negative mesh cursor is not clamped",
failures
)
var negative_counts: Dictionary = planner.call("plan_step", -1, 0, -1, 0)
_expect_operation(
negative_counts,
PLANNER_SCRIPT.OPERATION_COMPLETE,
-1,
0,
0,
"negative counts preserve comparisons",
failures
)
func _verify_fresh_results(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var first: Dictionary = planner.call("plan_step", 1, 0, 0, 0)
first["next_mesh_index"] = 99
var second: Dictionary = planner.call("plan_step", 1, 0, 0, 0)
_expect_equal(int(second["next_mesh_index"]), 1, "result is fresh", failures)
func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void:
var loader_source := _read_text(LOADER_PATH, failures)
var planner_source := _read_text(PLANNER_PATH, failures)
_expect_true(
loader_source.contains("WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.new()"),
"loader composes planner",
failures
)
_expect_true(
loader_source.contains("_wmo_render_build_step_planner.plan_step("),
"loader delegates step decision",
failures
)
_expect_true(
loader_source.contains("MeshInstance3D.new()")
and loader_source.contains("MultiMeshInstance3D.new()"),
"loader retains node materialization",
failures
)
_expect_false(
loader_source.contains("if mesh_index < meshes.size():"),
"loader omits mesh cursor decision",
failures
)
for forbidden_token in [
"extends Node", "Node3D", "Resource", "Mesh", "RID", "Thread", "Mutex", "FileAccess"
]:
_expect_false(
planner_source.contains(forbidden_token),
"planner omits %s dependency" % forbidden_token,
failures
)
func _verify_bounded_timing(failures: Array[String]) -> float:
var planner: RefCounted = PLANNER_SCRIPT.new()
var started_microseconds := Time.get_ticks_usec()
var checksum := 0
for iteration in range(20000):
var step: Dictionary = planner.call(
"plan_step",
48,
iteration % 49,
24,
iteration % 25
)
checksum += int(step["selected_index"])
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(checksum > 0, "timing checksum", failures)
_expect_true(elapsed_milliseconds < 1000.0, "20,000 plans remain bounded", failures)
return elapsed_milliseconds
func _expect_operation(
actual_step: Dictionary,
expected_operation: StringName,
expected_selected_index: int,
expected_next_mesh_index: int,
expected_next_multimesh_index: int,
label: String,
failures: Array[String]
) -> void:
if actual_step["operation"] != expected_operation:
failures.append(
"%s operation expected %s, got %s"
% [label, expected_operation, actual_step["operation"]]
)
_expect_equal(
int(actual_step["selected_index"]),
expected_selected_index,
"%s selected" % label,
failures
)
_expect_equal(
int(actual_step["next_mesh_index"]),
expected_next_mesh_index,
"%s next mesh" % label,
failures
)
_expect_equal(
int(actual_step["next_multimesh_index"]),
expected_next_multimesh_index,
"%s next multimesh" % 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 _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://w68w77v5sps4