render: extract static M2 batch materializer
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
class_name M2StaticBatchMaterializer
|
||||
extends RefCounted
|
||||
|
||||
## Builds and attaches one static M2 MultiMesh batch from an ordered transform
|
||||
## slice. All Resource and SceneTree mutation belongs on the main thread.
|
||||
|
||||
|
||||
## Returns the attached MultiMeshInstance3D, or null for invalid/empty input.
|
||||
## The supplied parent owns the returned node and its MultiMesh Resource.
|
||||
## Transform bounds are a build-planner precondition and are not clamped.
|
||||
func materialize_batch(
|
||||
m2_parent_root: Node3D,
|
||||
relative_path: String,
|
||||
mesh: Mesh,
|
||||
transforms: Array,
|
||||
start_index: int,
|
||||
instance_count: int,
|
||||
batch_serial: int,
|
||||
visibility_range_end: float,
|
||||
visibility_range_margin: float,
|
||||
cast_shadows: bool
|
||||
) -> MultiMeshInstance3D:
|
||||
if (
|
||||
m2_parent_root == null
|
||||
or mesh == null
|
||||
or transforms.is_empty()
|
||||
or instance_count <= 0
|
||||
):
|
||||
return null
|
||||
|
||||
var multimesh := MultiMesh.new()
|
||||
multimesh.transform_format = MultiMesh.TRANSFORM_3D
|
||||
multimesh.mesh = mesh
|
||||
multimesh.instance_count = instance_count
|
||||
for batch_offset in instance_count:
|
||||
multimesh.set_instance_transform(
|
||||
batch_offset,
|
||||
transforms[start_index + batch_offset]
|
||||
)
|
||||
|
||||
var multimesh_instance := MultiMeshInstance3D.new()
|
||||
multimesh_instance.name = "%s_%d" % [
|
||||
relative_path.get_file().get_basename(),
|
||||
batch_serial,
|
||||
]
|
||||
multimesh_instance.multimesh = multimesh
|
||||
multimesh_instance.cast_shadow = (
|
||||
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
||||
if cast_shadows
|
||||
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
)
|
||||
if visibility_range_end > 0.0:
|
||||
multimesh_instance.visibility_range_end = visibility_range_end
|
||||
multimesh_instance.visibility_range_end_margin = visibility_range_margin
|
||||
m2_parent_root.add_child(multimesh_instance)
|
||||
return multimesh_instance
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgiw27rkh8fbn
|
||||
@@ -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_STATIC_BATCH_MATERIALIZER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_static_batch_materializer.gd"
|
||||
)
|
||||
const M2_RUNTIME_MESH_FINALIZER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_finalizer.gd"
|
||||
)
|
||||
@@ -273,6 +276,7 @@ var _m2_unique_placement_registry := (
|
||||
var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
|
||||
var _m2_placement_grouper := M2_PLACEMENT_GROUPER_SCRIPT.new()
|
||||
var _m2_build_batch_planner := M2_BUILD_BATCH_PLANNER_SCRIPT.new()
|
||||
var _m2_static_batch_materializer := M2_STATIC_BATCH_MATERIALIZER_SCRIPT.new()
|
||||
var _m2_runtime_mesh_finalizer := M2_RUNTIME_MESH_FINALIZER_SCRIPT.new()
|
||||
var _m2_raw_model_repository := M2_RAW_MODEL_REPOSITORY_SCRIPT.new()
|
||||
var _m2_prototype_cache_state := M2_PROTOTYPE_CACHE_STATE_SCRIPT.new()
|
||||
@@ -4396,29 +4400,20 @@ func _materialize_m2_group_batch(
|
||||
start: int,
|
||||
count: int,
|
||||
serial: int) -> void:
|
||||
if transforms.is_empty() or count <= 0 or mesh == null:
|
||||
return
|
||||
|
||||
var mm := MultiMesh.new()
|
||||
mm.transform_format = MultiMesh.TRANSFORM_3D
|
||||
mm.mesh = mesh
|
||||
mm.instance_count = count
|
||||
for i in count:
|
||||
mm.set_instance_transform(i, transforms[start + i])
|
||||
|
||||
var mmi := MultiMeshInstance3D.new()
|
||||
mmi.name = "%s_%d" % [rel_path.get_file().get_basename(), serial]
|
||||
mmi.multimesh = mm
|
||||
mmi.cast_shadow = (
|
||||
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
||||
if m2_cast_shadows
|
||||
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
var multimesh_instance := _m2_static_batch_materializer.materialize_batch(
|
||||
m2_root,
|
||||
rel_path,
|
||||
mesh,
|
||||
transforms,
|
||||
start,
|
||||
count,
|
||||
serial,
|
||||
m2_visibility_range,
|
||||
CHUNK_SIZE,
|
||||
m2_cast_shadows
|
||||
)
|
||||
if m2_visibility_range > 0.0:
|
||||
mmi.visibility_range_end = m2_visibility_range
|
||||
mmi.visibility_range_end_margin = CHUNK_SIZE
|
||||
m2_root.add_child(mmi)
|
||||
_set_editor_owner_recursive(mmi)
|
||||
if multimesh_instance != null:
|
||||
_set_editor_owner_recursive(multimesh_instance)
|
||||
|
||||
|
||||
func _materialize_m2_animated_batch(
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
extends SceneTree
|
||||
|
||||
## Synthetic MultiMesh identity, transform order, render settings, ownership
|
||||
## boundary and bounded-timing regression for static M2 batch materialization.
|
||||
|
||||
const MATERIALIZER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_static_batch_materializer.gd"
|
||||
)
|
||||
const MATERIALIZER_PATH := "res://src/render/m2/m2_static_batch_materializer.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_batch_contract(failures)
|
||||
_verify_zero_visibility_contract(failures)
|
||||
_verify_invalid_inputs(failures)
|
||||
_verify_ownership_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_STATIC_BATCH_MATERIALIZER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_STATIC_BATCH_MATERIALIZER PASS cases=11 instances=10000 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_batch_contract(failures: Array[String]) -> void:
|
||||
var materializer = MATERIALIZER_SCRIPT.new()
|
||||
var parent_root := Node3D.new()
|
||||
get_root().add_child(parent_root)
|
||||
var mesh := ArrayMesh.new()
|
||||
var transforms: Array = [
|
||||
Transform3D(Basis.IDENTITY, Vector3.ZERO),
|
||||
Transform3D(Basis.IDENTITY, Vector3(1.0, 2.0, 3.0)),
|
||||
Transform3D(Basis.IDENTITY, Vector3(4.0, 5.0, 6.0)),
|
||||
]
|
||||
var multimesh_instance: MultiMeshInstance3D = materializer.materialize_batch(
|
||||
parent_root,
|
||||
"World\\Doodads\\StaticTree.m2",
|
||||
mesh,
|
||||
transforms,
|
||||
1,
|
||||
2,
|
||||
7,
|
||||
345.0,
|
||||
16.0,
|
||||
false
|
||||
)
|
||||
_expect_true(multimesh_instance != null, "valid batch returned", failures)
|
||||
_expect_same(multimesh_instance.get_parent(), parent_root, "batch attached once", failures)
|
||||
_expect_equal(parent_root.get_child_count(), 1, "one parent child", failures)
|
||||
_expect_string_equal(multimesh_instance.name, "StaticTree_7", "batch name", failures)
|
||||
_expect_equal(multimesh_instance.multimesh.transform_format, MultiMesh.TRANSFORM_3D, "3D transform format", failures)
|
||||
_expect_same(multimesh_instance.multimesh.mesh, mesh, "exact Mesh identity", failures)
|
||||
_expect_equal(multimesh_instance.multimesh.instance_count, 2, "instance count", failures)
|
||||
_expect_equal(
|
||||
multimesh_instance.cast_shadow,
|
||||
GeometryInstance3D.SHADOW_CASTING_SETTING_OFF,
|
||||
"shadow mode",
|
||||
failures
|
||||
)
|
||||
_expect_float_equal(multimesh_instance.visibility_range_end, 345.0, "visibility end", failures)
|
||||
_expect_float_equal(multimesh_instance.visibility_range_end_margin, 16.0, "visibility margin", failures)
|
||||
parent_root.free()
|
||||
|
||||
|
||||
func _verify_zero_visibility_contract(failures: Array[String]) -> void:
|
||||
var materializer = MATERIALIZER_SCRIPT.new()
|
||||
var parent_root := Node3D.new()
|
||||
var multimesh_instance: MultiMeshInstance3D = materializer.materialize_batch(
|
||||
parent_root,
|
||||
"world/static.m2",
|
||||
ArrayMesh.new(),
|
||||
[Transform3D.IDENTITY],
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0.0,
|
||||
99.0,
|
||||
true
|
||||
)
|
||||
_expect_float_equal(multimesh_instance.visibility_range_end, 0.0, "disabled visibility unchanged", failures)
|
||||
_expect_equal(
|
||||
multimesh_instance.cast_shadow,
|
||||
GeometryInstance3D.SHADOW_CASTING_SETTING_ON,
|
||||
"enabled shadow mode",
|
||||
failures
|
||||
)
|
||||
parent_root.free()
|
||||
|
||||
|
||||
func _verify_invalid_inputs(failures: Array[String]) -> void:
|
||||
var materializer = MATERIALIZER_SCRIPT.new()
|
||||
var parent_root := Node3D.new()
|
||||
var mesh := ArrayMesh.new()
|
||||
var transforms: Array = [Transform3D.IDENTITY]
|
||||
_expect_true(
|
||||
materializer.materialize_batch(
|
||||
null, "a.m2", mesh, transforms, 0, 1, 0, 0.0, 0.0, true
|
||||
) == null,
|
||||
"null parent rejected",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
materializer.materialize_batch(
|
||||
parent_root, "a.m2", null, transforms, 0, 1, 0, 0.0, 0.0, true
|
||||
) == null,
|
||||
"null Mesh rejected",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
materializer.materialize_batch(
|
||||
parent_root, "a.m2", mesh, [], 0, 1, 0, 0.0, 0.0, true
|
||||
) == null,
|
||||
"empty transforms rejected",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
materializer.materialize_batch(
|
||||
parent_root, "a.m2", mesh, transforms, 0, 0, 0, 0.0, 0.0, true
|
||||
) == null,
|
||||
"zero count rejected",
|
||||
failures
|
||||
)
|
||||
_expect_equal(parent_root.get_child_count(), 0, "invalid input has no attachment", failures)
|
||||
parent_root.free()
|
||||
|
||||
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var materializer_source := FileAccess.get_file_as_string(MATERIALIZER_PATH)
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
var loader_adapter_source := _source_between(
|
||||
loader_source,
|
||||
"func _materialize_m2_group_batch(",
|
||||
"func _materialize_m2_animated_batch(",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
loader_source.contains("M2_STATIC_BATCH_MATERIALIZER_SCRIPT.new()"),
|
||||
"loader composes materializer",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_adapter_source.count("_m2_static_batch_materializer.materialize_batch("),
|
||||
1,
|
||||
"loader delegates once",
|
||||
failures
|
||||
)
|
||||
_expect_false(loader_adapter_source.contains("MultiMesh.new()"), "loader adapter omits MultiMesh construction", failures)
|
||||
_expect_false(loader_adapter_source.contains("set_instance_transform("), "loader adapter omits transform upload", failures)
|
||||
_expect_true(loader_adapter_source.contains("_set_editor_owner_recursive(multimesh_instance)"), "loader retains Editor ownership", failures)
|
||||
for required_materializer_rule in [
|
||||
"MultiMesh.TRANSFORM_3D",
|
||||
"multimesh.mesh = mesh",
|
||||
"multimesh.instance_count = instance_count",
|
||||
"multimesh.set_instance_transform(",
|
||||
"transforms[start_index + batch_offset]",
|
||||
"m2_parent_root.add_child(multimesh_instance)",
|
||||
]:
|
||||
_expect_true(
|
||||
materializer_source.contains(required_materializer_rule),
|
||||
"materializer owns %s" % required_materializer_rule,
|
||||
failures
|
||||
)
|
||||
for forbidden_dependency in [
|
||||
"ResourceLoader.",
|
||||
"FileAccess.",
|
||||
"WorkerThreadPool.",
|
||||
"RenderingServer.",
|
||||
".owner =",
|
||||
"_m2_build_jobs",
|
||||
"_render_budget_scheduler",
|
||||
]:
|
||||
_expect_false(
|
||||
materializer_source.contains(forbidden_dependency),
|
||||
"materializer omits %s ownership" % forbidden_dependency,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var materializer = MATERIALIZER_SCRIPT.new()
|
||||
var parent_root := Node3D.new()
|
||||
get_root().add_child(parent_root)
|
||||
var transforms: Array = []
|
||||
for instance_index in 10000:
|
||||
transforms.append(
|
||||
Transform3D(Basis.IDENTITY, Vector3(float(instance_index), 0.0, 0.0))
|
||||
)
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
var multimesh_instance: MultiMeshInstance3D = materializer.materialize_batch(
|
||||
parent_root,
|
||||
"world/timing.m2",
|
||||
ArrayMesh.new(),
|
||||
transforms,
|
||||
0,
|
||||
10000,
|
||||
1,
|
||||
0.0,
|
||||
0.0,
|
||||
true
|
||||
)
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_equal(multimesh_instance.multimesh.instance_count, 10000, "timing batch complete", failures)
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "10000 transforms under one second", failures)
|
||||
parent_root.free()
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _source_between(
|
||||
source: String,
|
||||
start_marker: String,
|
||||
end_marker: String,
|
||||
failures: Array[String]
|
||||
) -> String:
|
||||
var start_offset := source.find(start_marker)
|
||||
var end_offset := source.find(end_marker, start_offset + start_marker.length())
|
||||
if start_offset < 0 or end_offset <= start_offset:
|
||||
failures.append("cannot isolate source between %s and %s" % [start_marker, end_marker])
|
||||
return ""
|
||||
return source.substr(start_offset, end_offset - start_offset)
|
||||
|
||||
|
||||
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_float_equal(actual: float, expected: float, label: String, failures: Array[String]) -> void:
|
||||
if not is_equal_approx(actual, expected):
|
||||
failures.append("%s expected=%.6f actual=%.6f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string_equal(actual: String, expected: String, label: String, failures: Array[String]) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s expected=%s actual=%s" % [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://hdpp6alfpaa3
|
||||
Reference in New Issue
Block a user