refactor(M03): extract M2 placement grouper
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
class_name M2PlacementGrouper
|
||||
extends RefCounted
|
||||
|
||||
## Stateless ADT M2 placement validation, normalization and transform grouping.
|
||||
|
||||
const M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_placement_transform_resolver.gd"
|
||||
)
|
||||
|
||||
|
||||
## Groups valid placements by normalized relative M2 path while preserving
|
||||
## placement order inside each group. Returned containers are caller-owned.
|
||||
func group_placements(
|
||||
tile_origin: Vector3,
|
||||
m2_names: PackedStringArray,
|
||||
m2_placements: Array
|
||||
) -> Dictionary:
|
||||
var groups: Dictionary = {}
|
||||
var placement_transform_resolver := (
|
||||
M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
|
||||
)
|
||||
for placement_variant in m2_placements:
|
||||
if not (placement_variant is Dictionary):
|
||||
continue
|
||||
var placement: Dictionary = placement_variant
|
||||
var name_id: int = int(placement.get("name_id", -1))
|
||||
if name_id < 0 or name_id >= m2_names.size():
|
||||
continue
|
||||
|
||||
var normalized_relative_path := _normalize_relative_path(m2_names[name_id])
|
||||
if normalized_relative_path.is_empty():
|
||||
continue
|
||||
|
||||
var world_position: Vector3 = placement.get("pos", Vector3.ZERO)
|
||||
var local_position := world_position - tile_origin
|
||||
var rotation_radians: Vector3 = placement.get("rot", Vector3.ZERO)
|
||||
var scale_value := float(placement.get("scale", 1.0))
|
||||
var placement_basis: Basis = placement_transform_resolver.resolve_basis(
|
||||
rotation_radians,
|
||||
normalized_relative_path
|
||||
)
|
||||
var origin_offset: Vector3 = placement_transform_resolver.resolve_origin_offset(
|
||||
rotation_radians,
|
||||
normalized_relative_path,
|
||||
scale_value
|
||||
)
|
||||
var local_transform := Transform3D(
|
||||
placement_basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)),
|
||||
local_position + origin_offset
|
||||
)
|
||||
|
||||
if not groups.has(normalized_relative_path):
|
||||
groups[normalized_relative_path] = []
|
||||
(groups[normalized_relative_path] as Array).append(local_transform)
|
||||
return groups
|
||||
|
||||
|
||||
func _normalize_relative_path(relative_path: String) -> String:
|
||||
var normalized_relative_path := relative_path.replace("\\", "/")
|
||||
if (
|
||||
normalized_relative_path.ends_with(".mdx")
|
||||
or normalized_relative_path.ends_with(".mdl")
|
||||
):
|
||||
normalized_relative_path = normalized_relative_path.get_basename() + ".m2"
|
||||
return normalized_relative_path
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkktxa5essfn
|
||||
@@ -30,6 +30,9 @@ const M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT := preload(
|
||||
const M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_placement_transform_resolver.gd"
|
||||
)
|
||||
const M2_PLACEMENT_GROUPER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_placement_grouper.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")
|
||||
@@ -215,6 +218,7 @@ var _m2_unique_placement_registry := (
|
||||
M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()
|
||||
)
|
||||
var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
|
||||
var _m2_placement_grouper := M2_PLACEMENT_GROUPER_SCRIPT.new()
|
||||
var _m2_mesh_cache: Dictionary = {}
|
||||
var _m2_mesh_load_requests: Dictionary = {}
|
||||
var _m2_mesh_finalize_queue: Array = []
|
||||
@@ -4119,36 +4123,11 @@ func _group_tile_m2_task(
|
||||
tile_origin: Vector3,
|
||||
m2_names: PackedStringArray,
|
||||
m2_placements: Array) -> void:
|
||||
var groups: Dictionary = {}
|
||||
for placement_variant in m2_placements:
|
||||
if not (placement_variant is Dictionary):
|
||||
continue
|
||||
var placement: Dictionary = placement_variant
|
||||
var name_id: int = int(placement.get("name_id", -1))
|
||||
if name_id < 0 or name_id >= m2_names.size():
|
||||
continue
|
||||
|
||||
var normalized: String = str(m2_names[name_id]).replace("\\", "/")
|
||||
if normalized.ends_with(".mdx") or normalized.ends_with(".mdl"):
|
||||
normalized = normalized.get_basename() + ".m2"
|
||||
if normalized.is_empty():
|
||||
continue
|
||||
|
||||
var world_pos: Vector3 = placement.get("pos", Vector3.ZERO)
|
||||
var pos: Vector3 = world_pos - tile_origin
|
||||
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
|
||||
var scale_value: float = float(placement.get("scale", 1.0))
|
||||
var basis: Basis = _m2_placement_transform_resolver.resolve_basis(rot, normalized)
|
||||
var offset: Vector3 = _m2_placement_transform_resolver.resolve_origin_offset(
|
||||
rot,
|
||||
normalized,
|
||||
scale_value
|
||||
)
|
||||
var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos + offset)
|
||||
|
||||
if not groups.has(normalized):
|
||||
groups[normalized] = []
|
||||
(groups[normalized] as Array).append(xform)
|
||||
var groups: Dictionary = _m2_placement_grouper.group_placements(
|
||||
tile_origin,
|
||||
m2_names,
|
||||
m2_placements
|
||||
)
|
||||
|
||||
_m2_group_result_mutex.lock()
|
||||
_m2_group_result_queue.append({
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free contract, dependency and timing regression for ADT M2 grouping.
|
||||
|
||||
const GROUPER_SCRIPT := preload("res://src/render/m2/m2_placement_grouper.gd")
|
||||
const TRANSFORM_RESOLVER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_placement_transform_resolver.gd"
|
||||
)
|
||||
const GROUPER_PATH := "res://src/render/m2/m2_placement_grouper.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_empty_and_invalid_inputs(failures)
|
||||
_verify_historical_path_normalization(failures)
|
||||
_verify_group_order(failures)
|
||||
_verify_tile_local_defaults(failures)
|
||||
_verify_regular_transform(failures)
|
||||
_verify_waterfall_transform(failures)
|
||||
_verify_scale_clamp(failures)
|
||||
_verify_fresh_output(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_PLACEMENT_GROUPER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_PLACEMENT_GROUPER PASS cases=9 iterations=100 placements=256 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_empty_and_invalid_inputs(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
_expect_equal(
|
||||
grouper.group_placements(Vector3.ZERO, PackedStringArray(), []).size(),
|
||||
0,
|
||||
"empty",
|
||||
failures
|
||||
)
|
||||
var placements: Array = [
|
||||
"invalid",
|
||||
{"name_id": -1},
|
||||
{"name_id": 2},
|
||||
{"name_id": 0},
|
||||
]
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3.ZERO,
|
||||
PackedStringArray([""]),
|
||||
placements
|
||||
)
|
||||
_expect_equal(groups.size(), 0, "invalid placements", failures)
|
||||
|
||||
|
||||
func _verify_historical_path_normalization(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var names := PackedStringArray([
|
||||
"World\\Generic\\Tree.mdx",
|
||||
"World/Generic/Rock.mdl",
|
||||
"World/Generic/Upper.MDX",
|
||||
])
|
||||
var placements: Array = [{"name_id": 0}, {"name_id": 1}, {"name_id": 2}]
|
||||
var groups: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
|
||||
_expect_true(groups.has("World/Generic/Tree.m2"), "backslash and mdx normalization", failures)
|
||||
_expect_true(groups.has("World/Generic/Rock.m2"), "mdl normalization", failures)
|
||||
_expect_true(groups.has("World/Generic/Upper.MDX"), "uppercase suffix preservation", failures)
|
||||
|
||||
|
||||
func _verify_group_order(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var placements: Array = [
|
||||
{"name_id": 0, "pos": Vector3(3.0, 0.0, 0.0)},
|
||||
{"name_id": 1, "pos": Vector3(8.0, 0.0, 0.0)},
|
||||
{"name_id": 0, "pos": Vector3(5.0, 0.0, 0.0)},
|
||||
]
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3.ZERO,
|
||||
PackedStringArray(["Tree.m2", "Rock.m2"]),
|
||||
placements
|
||||
)
|
||||
_expect_equal(groups.size(), 2, "group count", failures)
|
||||
var tree_transforms: Array = groups.get("Tree.m2", [])
|
||||
_expect_equal(tree_transforms.size(), 2, "tree transform count", failures)
|
||||
if tree_transforms.size() == 2:
|
||||
_expect_vector_near(tree_transforms[0].origin, Vector3(3.0, 0.0, 0.0), "first tree", failures)
|
||||
_expect_vector_near(tree_transforms[1].origin, Vector3(5.0, 0.0, 0.0), "second tree", failures)
|
||||
|
||||
|
||||
func _verify_tile_local_defaults(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3(10.0, 20.0, 30.0),
|
||||
PackedStringArray(["Tree.m2"]),
|
||||
[{"name_id": 0}]
|
||||
)
|
||||
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
|
||||
_expect_vector_near(transform.origin, Vector3(-10.0, -20.0, -30.0), "default local position", failures)
|
||||
_expect_basis_near(transform.basis, Basis.IDENTITY, "default basis and scale", failures)
|
||||
|
||||
|
||||
func _verify_regular_transform(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var rotation := Vector3(0.2, -0.4, 0.6)
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3(1.0, 2.0, 3.0),
|
||||
PackedStringArray(["Tree.m2"]),
|
||||
[{
|
||||
"name_id": 0,
|
||||
"pos": Vector3(5.0, 8.0, 13.0),
|
||||
"rot": rotation,
|
||||
"scale": 2.0,
|
||||
}]
|
||||
)
|
||||
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
|
||||
_expect_basis_near(transform.basis, Basis.from_euler(rotation).scaled(Vector3.ONE * 2.0), "regular basis", failures)
|
||||
_expect_vector_near(transform.origin, Vector3(4.0, 6.0, 10.0), "regular origin", failures)
|
||||
|
||||
|
||||
func _verify_waterfall_transform(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var resolver := TRANSFORM_RESOLVER_SCRIPT.new()
|
||||
var path := "World/X/ElwynnTallWaterfall01.m2"
|
||||
var rotation := Vector3(0.05, -0.2, 0.15)
|
||||
var scale_value := 1.5
|
||||
var position := Vector3(20.0, 30.0, 40.0)
|
||||
var tile_origin := Vector3(2.0, 3.0, 4.0)
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
tile_origin,
|
||||
PackedStringArray([path]),
|
||||
[{
|
||||
"name_id": 0,
|
||||
"pos": position,
|
||||
"rot": rotation,
|
||||
"scale": scale_value,
|
||||
}]
|
||||
)
|
||||
var transform: Transform3D = (groups[path] as Array)[0]
|
||||
var expected_basis: Basis = resolver.resolve_basis(rotation, path).scaled(
|
||||
Vector3.ONE * scale_value
|
||||
)
|
||||
var expected_origin: Vector3 = (
|
||||
position
|
||||
- tile_origin
|
||||
+ resolver.resolve_origin_offset(rotation, path, scale_value)
|
||||
)
|
||||
_expect_basis_near(transform.basis, expected_basis, "waterfall basis", failures)
|
||||
_expect_vector_near(transform.origin, expected_origin, "waterfall origin", failures)
|
||||
|
||||
|
||||
func _verify_scale_clamp(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3.ZERO,
|
||||
PackedStringArray(["Tree.m2"]),
|
||||
[{"name_id": 0, "scale": -5.0}]
|
||||
)
|
||||
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
|
||||
_expect_basis_near(
|
||||
transform.basis,
|
||||
Basis.IDENTITY.scaled(Vector3.ONE * 0.0001),
|
||||
"negative scale clamp",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_fresh_output(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var names := PackedStringArray(["Tree.m2"])
|
||||
var placements: Array = [{"name_id": 0}]
|
||||
var first: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
|
||||
(first["Tree.m2"] as Array).clear()
|
||||
var second: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
|
||||
_expect_equal((second["Tree.m2"] as Array).size(), 1, "fresh output", failures)
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var loader_source := _read_text(LOADER_PATH, failures)
|
||||
var grouper_source := _read_text(GROUPER_PATH, failures)
|
||||
var grouping_task_source := _source_between(
|
||||
loader_source,
|
||||
"func _group_tile_m2_task(",
|
||||
"func _drain_m2_group_results()",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_placement_grouper.group_placements("),
|
||||
1,
|
||||
"one loader grouping adapter",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
not grouping_task_source.contains("for placement_variant in m2_placements"),
|
||||
"loader omits grouping loop",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
grouper_source.count("placement_transform_resolver.resolve_basis("),
|
||||
1,
|
||||
"one grouper basis adapter",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
grouper_source.count("placement_transform_resolver.resolve_origin_offset("),
|
||||
1,
|
||||
"one grouper offset adapter",
|
||||
failures
|
||||
)
|
||||
for forbidden_dependency in ["WorkerThreadPool", "Mutex", "RenderingServer", "ResourceLoader"]:
|
||||
_expect_true(
|
||||
not grouper_source.contains(forbidden_dependency),
|
||||
"grouper omits %s" % forbidden_dependency,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var names := PackedStringArray(["Tree.m2", "Rock.mdx"])
|
||||
var placements: Array = []
|
||||
for placement_index in range(256):
|
||||
placements.append({
|
||||
"name_id": placement_index % 2,
|
||||
"pos": Vector3(float(placement_index), 0.0, float(-placement_index)),
|
||||
"rot": Vector3(0.1, 0.2, 0.3),
|
||||
"scale": 1.0,
|
||||
})
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for iteration in range(100):
|
||||
var groups: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
|
||||
if groups.size() != 2:
|
||||
failures.append("timing iteration %d produced wrong group count" % iteration)
|
||||
break
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "100 x 256 grouping bounded", failures)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
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 _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_basis_near(
|
||||
actual_basis: Basis,
|
||||
expected_basis: Basis,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
if not actual_basis.is_equal_approx(expected_basis):
|
||||
failures.append("%s basis mismatch" % label)
|
||||
|
||||
|
||||
func _expect_vector_near(
|
||||
actual_value: Vector3,
|
||||
expected_value: Vector3,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
if not actual_value.is_equal_approx(expected_value):
|
||||
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbx57qy0lp11w
|
||||
@@ -4,6 +4,7 @@ extends SceneTree
|
||||
|
||||
const RESOLVER_SCRIPT := preload("res://src/render/m2/m2_placement_transform_resolver.gd")
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const GROUPER_PATH := "res://src/render/m2/m2_placement_grouper.gd"
|
||||
const WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
|
||||
const CLIFFROCK_WORLD_YAW_OFFSET := PI
|
||||
|
||||
@@ -130,6 +131,7 @@ func _verify_path_normalization_and_scale_clamp(failures: Array[String]) -> void
|
||||
|
||||
func _verify_loader_boundary(failures: Array[String]) -> void:
|
||||
var source := _read_text(LOADER_PATH, failures)
|
||||
var grouper_source := _read_text(GROUPER_PATH, failures)
|
||||
_expect_true(
|
||||
source.contains("M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()"),
|
||||
"loader composes resolver",
|
||||
@@ -137,14 +139,26 @@ func _verify_loader_boundary(failures: Array[String]) -> void:
|
||||
)
|
||||
_expect_equal(
|
||||
source.count("_m2_placement_transform_resolver.resolve_basis("),
|
||||
3,
|
||||
"three basis adapters",
|
||||
2,
|
||||
"two direct loader basis adapters",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
source.count("_m2_placement_transform_resolver.resolve_origin_offset("),
|
||||
3,
|
||||
"three offset adapters",
|
||||
2,
|
||||
"two direct loader offset adapters",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
grouper_source.count("placement_transform_resolver.resolve_basis("),
|
||||
1,
|
||||
"one grouped basis adapter",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
grouper_source.count("placement_transform_resolver.resolve_origin_offset("),
|
||||
1,
|
||||
"one grouped offset adapter",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
|
||||
Reference in New Issue
Block a user