refactor(M03): extract WMO render build planner
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user