refactor(M03): extract M2 runtime rebuild classifier
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
extends SceneTree
|
||||
|
||||
## Synthetic predicate, memoization, boundary and timing regression for stale
|
||||
## cached M2 runtime mesh rebuild decisions.
|
||||
|
||||
const CLASSIFIER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
)
|
||||
const CLASSIFIER_PATH := "res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_billboard_decisions(failures)
|
||||
_verify_uv_rotation_decisions(failures)
|
||||
_verify_stage_and_index_boundaries(failures)
|
||||
_verify_memoization_clear_and_diagnostics(failures)
|
||||
_verify_ownership_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_RUNTIME_MESH_REBUILD_CLASSIFIER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_RUNTIME_MESH_REBUILD_CLASSIFIER PASS cases=12 iterations=100 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_billboard_decisions(failures: Array[String]) -> void:
|
||||
_expect_false(_classify("empty", {}), "empty data does not rebuild", failures)
|
||||
_expect_false(
|
||||
_classify("invalid", {"batches": ["invalid", 7, null]}),
|
||||
"non-Dictionary batches skipped",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify("flag", {"batches": [{"has_billboard": true}]}),
|
||||
"billboard flag rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify("vertices", {"batches": [{"billboard_vertex_count": 1}]}),
|
||||
"positive billboard vertex count rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
_classify("zero_vertices", {"batches": [{"billboard_vertex_count": 0}]}),
|
||||
"zero billboard vertex count does not rebuild",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_uv_rotation_decisions(failures: Array[String]) -> void:
|
||||
_expect_false(
|
||||
_classify("identity", _uv_data([_rotation_transform()])),
|
||||
"identity UV rotation does not rebuild",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify(
|
||||
"rotation",
|
||||
_uv_data([_rotation_transform(Vector4(0.0, 1.0, -1.0, 0.0))])
|
||||
),
|
||||
"non-identity UV rotation rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify("positive_speed", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), 0.000002)])),
|
||||
"positive UV rotation speed rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify("negative_speed", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), -0.000002)])),
|
||||
"negative UV rotation speed rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
_classify("epsilon", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), 0.000001)])),
|
||||
"exact UV rotation epsilon does not rebuild",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_stage_and_index_boundaries(failures: Array[String]) -> void:
|
||||
var fifth_stage_transforms: Array = [
|
||||
_rotation_transform(),
|
||||
_rotation_transform(),
|
||||
_rotation_transform(),
|
||||
_rotation_transform(),
|
||||
_rotation_transform(Vector4(0.0, 1.0, -1.0, 0.0)),
|
||||
]
|
||||
_expect_false(
|
||||
_classify(
|
||||
"fifth_stage",
|
||||
_uv_data(fifth_stage_transforms, PackedInt32Array([0, 1, 2, 3, 4]), 5)
|
||||
),
|
||||
"fifth texture stage remains ignored",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
_classify(
|
||||
"invalid_indices",
|
||||
_uv_data([_rotation_transform(Vector4(0, 1, -1, 0))], PackedInt32Array([-1, 9]), 2)
|
||||
),
|
||||
"invalid transform indices skipped",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
_classify(
|
||||
"invalid_transform",
|
||||
_uv_data(["not-a-transform"], PackedInt32Array([0]), 1)
|
||||
),
|
||||
"non-Dictionary transform uses identity defaults",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_memoization_clear_and_diagnostics(failures: Array[String]) -> void:
|
||||
var classifier: RefCounted = CLASSIFIER_SCRIPT.new()
|
||||
var billboard_data := {"batches": [{"has_billboard": true}]}
|
||||
_expect_true(
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", billboard_data),
|
||||
"first true decision",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}),
|
||||
"first path decision memoized",
|
||||
failures
|
||||
)
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/rock.m2", {})
|
||||
_expect_equal(int(classifier.call("cached_path_count")), 2, "cached path count", failures)
|
||||
var snapshot: Dictionary = classifier.call("diagnostic_snapshot")
|
||||
snapshot["world/tree.m2"] = false
|
||||
_expect_true(
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}),
|
||||
"diagnostics detached",
|
||||
failures
|
||||
)
|
||||
classifier.call("clear")
|
||||
_expect_equal(int(classifier.call("cached_path_count")), 0, "clear count", failures)
|
||||
_expect_false(
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}),
|
||||
"clear allows recomputation",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var classifier_source := FileAccess.get_file_as_string(CLASSIFIER_PATH)
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
_expect_true(
|
||||
loader_source.contains("M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()"),
|
||||
"loader composes classifier",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_runtime_mesh_rebuild_classifier.clear()"),
|
||||
2,
|
||||
"loader retains two classifier clear sites",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
loader_source.contains("_m2_runtime_rebuild_required_cache"),
|
||||
"loader removes legacy rebuild cache",
|
||||
failures
|
||||
)
|
||||
for removed_function in [
|
||||
"func _m2_raw_data_needs_runtime_mesh_rebuild",
|
||||
"func _m2_raw_data_has_billboards",
|
||||
"func _m2_raw_data_has_uv_rotation",
|
||||
]:
|
||||
_expect_false(loader_source.contains(removed_function), "loader removes %s" % removed_function, failures)
|
||||
for forbidden_dependency in [
|
||||
"ResourceLoader.",
|
||||
"WorkerThreadPool.",
|
||||
"M2_BUILDER_SCRIPT",
|
||||
"ClassDB.instantiate",
|
||||
"FileAccess.",
|
||||
]:
|
||||
_expect_false(
|
||||
classifier_source.contains(forbidden_dependency),
|
||||
"classifier omits %s dependency" % forbidden_dependency,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var classifier: RefCounted = CLASSIFIER_SCRIPT.new()
|
||||
var ordinary_data := {"batches": [{"has_billboard": false}]}
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for iteration in range(100):
|
||||
for path_index in range(256):
|
||||
classifier.call(
|
||||
"needs_runtime_mesh_rebuild",
|
||||
"world/model_%d.m2" % path_index,
|
||||
ordinary_data
|
||||
)
|
||||
classifier.call("clear")
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "100 by 256 classifications under 1 second", failures)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _classify(normalized_path: String, raw_data: Dictionary) -> bool:
|
||||
var classifier: RefCounted = CLASSIFIER_SCRIPT.new()
|
||||
return bool(classifier.call("needs_runtime_mesh_rebuild", normalized_path, raw_data))
|
||||
|
||||
|
||||
func _uv_data(
|
||||
texture_transforms: Array,
|
||||
texture_transform_combos: PackedInt32Array = PackedInt32Array([0]),
|
||||
texture_count: int = 1) -> Dictionary:
|
||||
return {
|
||||
"texture_transforms": texture_transforms,
|
||||
"texture_transform_combos": texture_transform_combos,
|
||||
"batches": [{
|
||||
"texture_count": texture_count,
|
||||
"texture_transform_combo_index": 0,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
func _rotation_transform(
|
||||
rotation: Vector4 = Vector4(1.0, 0.0, 0.0, 1.0),
|
||||
rotation_speed: float = 0.0) -> Dictionary:
|
||||
return {
|
||||
"rotation": rotation,
|
||||
"rotation_speed": rotation_speed,
|
||||
}
|
||||
|
||||
|
||||
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])
|
||||
Reference in New Issue
Block a user