refactor(M03): extract M2 mesh resource traversal
Work-Package: M03-RND-M2-MESH-RESOURCE-EXTRACTOR-001 Agent: sindo-main-codex Tests: 36 headless renderer/coordinate contracts pass; checkpoint dry-run 7/7; documentation and coordination gates pass Fidelity: preserves first-Mesh preorder and temporary PackedScene cleanup; no visual parity claim
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
class_name M2MeshResourceExtractor
|
||||
extends RefCounted
|
||||
|
||||
## Selects the first Mesh from loaded M2 Resources or existing Node subtrees.
|
||||
## The caller owns ResourceLoader I/O, cache adoption and material preparation.
|
||||
|
||||
|
||||
## Returns the direct Mesh Resource or the first Mesh in a temporary PackedScene
|
||||
## instance. The temporary instance is freed before this method returns.
|
||||
func extract_first_mesh(resource: Resource) -> Mesh:
|
||||
if resource is Mesh:
|
||||
return resource as Mesh
|
||||
if not (resource is PackedScene):
|
||||
return null
|
||||
var temporary_root := (resource as PackedScene).instantiate()
|
||||
if temporary_root == null:
|
||||
return null
|
||||
var mesh := find_first_mesh_in_subtree(temporary_root)
|
||||
temporary_root.free()
|
||||
return mesh
|
||||
|
||||
|
||||
## Returns the first MeshInstance3D Mesh in depth-first preorder. The subtree
|
||||
## and Mesh ownership remain unchanged.
|
||||
func find_first_mesh_in_subtree(root: Node) -> Mesh:
|
||||
if root == null:
|
||||
return null
|
||||
if root is MeshInstance3D:
|
||||
return (root as MeshInstance3D).mesh
|
||||
for child in root.get_children():
|
||||
var mesh := find_first_mesh_in_subtree(child)
|
||||
if mesh != null:
|
||||
return mesh
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://dea4gekrxshjf
|
||||
@@ -69,6 +69,9 @@ const M2_MESH_LOAD_PIPELINE_STATE_SCRIPT := preload(
|
||||
const M2_MESH_RESOURCE_CACHE_STATE_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_mesh_resource_cache_state.gd"
|
||||
)
|
||||
const M2_MESH_RESOURCE_EXTRACTOR_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_mesh_resource_extractor.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")
|
||||
@@ -260,6 +263,7 @@ var _m2_runtime_mesh_rebuild_classifier := (
|
||||
M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()
|
||||
)
|
||||
var _m2_mesh_resource_cache_state := M2_MESH_RESOURCE_CACHE_STATE_SCRIPT.new()
|
||||
var _m2_mesh_resource_extractor := M2_MESH_RESOURCE_EXTRACTOR_SCRIPT.new()
|
||||
var _m2_mesh_load_pipeline_state := M2_MESH_LOAD_PIPELINE_STATE_SCRIPT.new()
|
||||
var _m2_animation_load_requests: Dictionary = {}
|
||||
var _m2_animation_finalize_queue: Array = []
|
||||
@@ -4277,7 +4281,7 @@ func _drain_m2_mesh_loads() -> void:
|
||||
|
||||
var path := String(pending.get("path", ""))
|
||||
var resource: Resource = ResourceLoader.load_threaded_get(path)
|
||||
var mesh := _extract_first_mesh_from_m2_resource(resource)
|
||||
var mesh := _m2_mesh_resource_extractor.extract_first_mesh(resource)
|
||||
if mesh != null:
|
||||
_m2_mesh_resource_cache_state.store_mesh(
|
||||
normalized_rel,
|
||||
@@ -4695,34 +4699,11 @@ func _rebuild_m2_mesh_from_data(data: Dictionary) -> Mesh:
|
||||
var prototype: Node3D = M2_BUILDER_SCRIPT.build(data, extracted_dir)
|
||||
if prototype == null:
|
||||
return null
|
||||
var rebuilt := _find_first_mesh_recursive(prototype)
|
||||
var rebuilt := _m2_mesh_resource_extractor.find_first_mesh_in_subtree(prototype)
|
||||
prototype.free()
|
||||
return rebuilt
|
||||
|
||||
|
||||
func _extract_first_mesh_from_m2_resource(resource: Resource) -> Mesh:
|
||||
if resource is Mesh:
|
||||
return resource as Mesh
|
||||
if resource is PackedScene:
|
||||
var node := (resource as PackedScene).instantiate()
|
||||
if node == null:
|
||||
return null
|
||||
var mesh := _find_first_mesh_recursive(node)
|
||||
node.free()
|
||||
return mesh
|
||||
return null
|
||||
|
||||
|
||||
func _find_first_mesh_recursive(node: Node) -> Mesh:
|
||||
if node is MeshInstance3D:
|
||||
return (node as MeshInstance3D).mesh
|
||||
for child in node.get_children():
|
||||
var mesh := _find_first_mesh_recursive(child)
|
||||
if mesh != null:
|
||||
return mesh
|
||||
return null
|
||||
|
||||
|
||||
func _get_or_load_m2_mesh(rel_path: String) -> Mesh:
|
||||
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
||||
if _m2_mesh_resource_cache_state.has_mesh(normalized_rel):
|
||||
@@ -4730,7 +4711,7 @@ func _get_or_load_m2_mesh(rel_path: String) -> Mesh:
|
||||
var prototype: Node3D = _get_or_load_m2_prototype(rel_path)
|
||||
if prototype == null:
|
||||
return null
|
||||
var mesh := _find_first_mesh_recursive(prototype)
|
||||
var mesh := _m2_mesh_resource_extractor.find_first_mesh_in_subtree(prototype)
|
||||
if mesh != null and not normalized_rel.is_empty():
|
||||
mesh = _prepare_m2_mesh_for_runtime(normalized_rel, mesh)
|
||||
_m2_mesh_resource_cache_state.store_mesh(normalized_rel, mesh)
|
||||
|
||||
@@ -94,7 +94,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
)
|
||||
for retained_loader_rule in [
|
||||
"ResourceLoader.load_threaded_get(path)",
|
||||
"_extract_first_mesh_from_m2_resource(resource)",
|
||||
"_m2_mesh_resource_extractor.extract_first_mesh(resource)",
|
||||
"_prepare_m2_mesh_for_runtime(normalized_rel, mesh)",
|
||||
"_m2_missing_cache[normalized_rel]",
|
||||
"_m2_scene_cache[normalized_rel]",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
extends SceneTree
|
||||
|
||||
## Synthetic direct-Resource/PackedScene/subtree/order/lifetime/boundary/timing
|
||||
## regression for first-Mesh extraction.
|
||||
|
||||
const EXTRACTOR_SCRIPT := preload("res://src/render/m2/m2_mesh_resource_extractor.gd")
|
||||
const EXTRACTOR_PATH := "res://src/render/m2/m2_mesh_resource_extractor.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_direct_and_invalid_resources(failures)
|
||||
_verify_depth_first_subtree_order(failures)
|
||||
_verify_packed_scene_mesh_and_temporary_lifetime(failures)
|
||||
_verify_ownership_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_MESH_RESOURCE_EXTRACTOR: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_MESH_RESOURCE_EXTRACTOR PASS cases=10 iterations=10000 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_direct_and_invalid_resources(failures: Array[String]) -> void:
|
||||
var extractor: RefCounted = EXTRACTOR_SCRIPT.new()
|
||||
var direct_mesh := ArrayMesh.new()
|
||||
_expect_same(
|
||||
extractor.call("extract_first_mesh", direct_mesh),
|
||||
direct_mesh,
|
||||
"direct Mesh reference retained",
|
||||
failures
|
||||
)
|
||||
_expect_same(extractor.call("extract_first_mesh", Resource.new()), null, "unsupported Resource returns null", failures)
|
||||
_expect_same(extractor.call("extract_first_mesh", null), null, "null Resource returns null", failures)
|
||||
_expect_same(extractor.call("find_first_mesh_in_subtree", null), null, "null subtree returns null", failures)
|
||||
|
||||
|
||||
func _verify_depth_first_subtree_order(failures: Array[String]) -> void:
|
||||
var extractor: RefCounted = EXTRACTOR_SCRIPT.new()
|
||||
var root := Node3D.new()
|
||||
var first_branch := Node3D.new()
|
||||
var second_branch := Node3D.new()
|
||||
var first_mesh_instance := MeshInstance3D.new()
|
||||
var second_mesh_instance := MeshInstance3D.new()
|
||||
var first_mesh := ArrayMesh.new()
|
||||
var second_mesh := ArrayMesh.new()
|
||||
first_mesh_instance.mesh = first_mesh
|
||||
second_mesh_instance.mesh = second_mesh
|
||||
root.add_child(first_branch)
|
||||
root.add_child(second_branch)
|
||||
first_branch.add_child(first_mesh_instance)
|
||||
second_branch.add_child(second_mesh_instance)
|
||||
_expect_same(
|
||||
extractor.call("find_first_mesh_in_subtree", root),
|
||||
first_mesh,
|
||||
"depth-first child order retained",
|
||||
failures
|
||||
)
|
||||
_expect_same(first_mesh_instance.get_parent(), first_branch, "subtree ownership unchanged", failures)
|
||||
first_mesh_instance.mesh = null
|
||||
_expect_same(
|
||||
extractor.call("find_first_mesh_in_subtree", root),
|
||||
second_mesh,
|
||||
"null MeshInstance Mesh skipped by parent traversal",
|
||||
failures
|
||||
)
|
||||
root.free()
|
||||
|
||||
|
||||
func _verify_packed_scene_mesh_and_temporary_lifetime(failures: Array[String]) -> void:
|
||||
var extractor: RefCounted = EXTRACTOR_SCRIPT.new()
|
||||
var source_root := Node3D.new()
|
||||
var mesh_instance := MeshInstance3D.new()
|
||||
var packed_mesh := ArrayMesh.new()
|
||||
mesh_instance.mesh = packed_mesh
|
||||
source_root.add_child(mesh_instance)
|
||||
mesh_instance.owner = source_root
|
||||
var packed_scene := PackedScene.new()
|
||||
_expect_equal(packed_scene.pack(source_root), OK, "synthetic PackedScene packs", failures)
|
||||
source_root.free()
|
||||
var node_count_before := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
var extracted_mesh: Mesh = extractor.call("extract_first_mesh", packed_scene)
|
||||
var node_count_after := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
_expect_same(extracted_mesh, packed_mesh, "PackedScene first Mesh retained", failures)
|
||||
_expect_equal(node_count_after, node_count_before, "temporary PackedScene root freed", failures)
|
||||
|
||||
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var extractor_source := FileAccess.get_file_as_string(EXTRACTOR_PATH)
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
_expect_true(
|
||||
loader_source.contains("M2_MESH_RESOURCE_EXTRACTOR_SCRIPT.new()"),
|
||||
"loader composes Mesh resource extractor",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
loader_source.contains("func _extract_first_mesh_from_m2_resource("),
|
||||
"legacy Resource extractor removed",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
loader_source.contains("func _find_first_mesh_recursive("),
|
||||
"legacy subtree extractor removed",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_mesh_resource_extractor.extract_first_mesh("),
|
||||
1,
|
||||
"threaded Resource finalization delegates",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_mesh_resource_extractor.find_first_mesh_in_subtree("),
|
||||
2,
|
||||
"rebuild and synchronous fallback delegate",
|
||||
failures
|
||||
)
|
||||
for retained_loader_rule in [
|
||||
"ResourceLoader.load_threaded_get(path)",
|
||||
"_prepare_m2_mesh_for_runtime(normalized_rel, mesh)",
|
||||
"_m2_mesh_resource_cache_state.store_mesh(",
|
||||
"M2_BUILDER_SCRIPT.build(data, extracted_dir)",
|
||||
"_m2_missing_cache[normalized_rel]",
|
||||
]:
|
||||
_expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures)
|
||||
for forbidden_dependency in [
|
||||
"ResourceLoader.",
|
||||
"FileAccess.",
|
||||
"WorkerThreadPool.",
|
||||
"M2Builder",
|
||||
"_m2_mesh_resource_cache_state",
|
||||
"_m2_missing_cache",
|
||||
"MultiMesh",
|
||||
]:
|
||||
_expect_false(
|
||||
extractor_source.contains(forbidden_dependency),
|
||||
"extractor omits %s ownership" % forbidden_dependency,
|
||||
failures
|
||||
)
|
||||
_expect_true(extractor_source.contains("temporary_root.free()"), "temporary root free remains explicit", failures)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var extractor: RefCounted = EXTRACTOR_SCRIPT.new()
|
||||
var root := Node3D.new()
|
||||
var branch := root
|
||||
for _depth_index in range(8):
|
||||
var child_branch := Node3D.new()
|
||||
branch.add_child(child_branch)
|
||||
branch = child_branch
|
||||
var mesh_instance := MeshInstance3D.new()
|
||||
mesh_instance.mesh = ArrayMesh.new()
|
||||
branch.add_child(mesh_instance)
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for _iteration in range(10000):
|
||||
extractor.call("find_first_mesh_in_subtree", root)
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
root.free()
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "10000 subtree lookups under 1 second", failures)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
func _expect_equal(actual: int, expected: int, label: String, failures: Array[String]) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s expected=%d actual=%d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_same(actual: Variant, expected: Variant, label: String, failures: Array[String]) -> void:
|
||||
if not is_same(actual, expected):
|
||||
failures.append(label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgvidp5u31sud
|
||||
Reference in New Issue
Block a user