render: extract cached M2 animation resource observer
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
class_name M2CachedAnimationResourceObserver
|
||||
extends RefCounted
|
||||
|
||||
## Observes or requests the cached-GLB animated phase of one M2 build step.
|
||||
|
||||
const M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_build_resource_snapshot.gd"
|
||||
)
|
||||
|
||||
|
||||
## Returns a snapshot containing the exact cached prototype or pending state.
|
||||
## Invalid composition and terminal failures produce a non-pending snapshot.
|
||||
func observe(
|
||||
normalized_relative_path: String,
|
||||
cache_directory: String,
|
||||
maximum_primitive_count: int,
|
||||
allowlist_patterns: PackedStringArray,
|
||||
denylist_patterns: PackedStringArray,
|
||||
prototype_cache_state: RefCounted,
|
||||
load_pipeline_state: RefCounted,
|
||||
debug_logging_enabled: bool = false
|
||||
) -> RefCounted:
|
||||
var empty_snapshot: RefCounted = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
|
||||
normalized_relative_path,
|
||||
null,
|
||||
false
|
||||
)
|
||||
if (
|
||||
normalized_relative_path.is_empty()
|
||||
or prototype_cache_state == null
|
||||
or load_pipeline_state == null
|
||||
):
|
||||
return empty_snapshot
|
||||
|
||||
var cached_prototype := prototype_cache_state.call(
|
||||
"find_animated_prototype",
|
||||
normalized_relative_path
|
||||
) as Node3D
|
||||
if cached_prototype != null:
|
||||
return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
|
||||
normalized_relative_path,
|
||||
cached_prototype,
|
||||
false
|
||||
)
|
||||
if bool(prototype_cache_state.call(
|
||||
"is_animation_static",
|
||||
normalized_relative_path
|
||||
)):
|
||||
return empty_snapshot
|
||||
if bool(load_pipeline_state.call("has_request", normalized_relative_path)):
|
||||
return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
|
||||
normalized_relative_path,
|
||||
null,
|
||||
true
|
||||
)
|
||||
|
||||
var cache_resource_path := find_eligible_glb_cache_path(
|
||||
normalized_relative_path,
|
||||
cache_directory,
|
||||
maximum_primitive_count,
|
||||
allowlist_patterns,
|
||||
denylist_patterns,
|
||||
debug_logging_enabled
|
||||
)
|
||||
if cache_resource_path.is_empty():
|
||||
prototype_cache_state.call(
|
||||
"mark_animation_static",
|
||||
normalized_relative_path
|
||||
)
|
||||
return empty_snapshot
|
||||
|
||||
var request_error := ResourceLoader.load_threaded_request(
|
||||
cache_resource_path,
|
||||
"",
|
||||
false,
|
||||
ResourceLoader.CACHE_MODE_REUSE
|
||||
)
|
||||
if request_error == OK or request_error == ERR_BUSY:
|
||||
load_pipeline_state.call(
|
||||
"remember_request",
|
||||
normalized_relative_path,
|
||||
cache_resource_path
|
||||
)
|
||||
return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
|
||||
normalized_relative_path,
|
||||
null,
|
||||
true
|
||||
)
|
||||
|
||||
prototype_cache_state.call("mark_animation_static", normalized_relative_path)
|
||||
return empty_snapshot
|
||||
|
||||
|
||||
## Returns the first eligible historical `.glb` cache candidate.
|
||||
func find_eligible_glb_cache_path(
|
||||
normalized_relative_path: String,
|
||||
cache_directory: String,
|
||||
maximum_primitive_count: int,
|
||||
allowlist_patterns: PackedStringArray,
|
||||
denylist_patterns: PackedStringArray,
|
||||
debug_logging_enabled: bool = false
|
||||
) -> String:
|
||||
if not is_animation_path_allowed(
|
||||
normalized_relative_path,
|
||||
allowlist_patterns,
|
||||
denylist_patterns
|
||||
):
|
||||
return ""
|
||||
for cache_resource_path in cache_resource_paths(
|
||||
cache_directory,
|
||||
normalized_relative_path
|
||||
):
|
||||
if not ResourceLoader.exists(cache_resource_path):
|
||||
continue
|
||||
if glb_cache_is_safe_for_runtime_animation(
|
||||
cache_resource_path,
|
||||
maximum_primitive_count,
|
||||
debug_logging_enabled
|
||||
):
|
||||
return cache_resource_path
|
||||
return ""
|
||||
|
||||
|
||||
## Applies the historical stripped, case-insensitive allow/deny substring policy.
|
||||
func is_animation_path_allowed(
|
||||
normalized_relative_path: String,
|
||||
allowlist_patterns: PackedStringArray,
|
||||
denylist_patterns: PackedStringArray
|
||||
) -> bool:
|
||||
if normalized_relative_path.is_empty() or allowlist_patterns.is_empty():
|
||||
return false
|
||||
var lowercase_path := normalized_relative_path.to_lower()
|
||||
var allowed := false
|
||||
for pattern in allowlist_patterns:
|
||||
var needle := String(pattern).strip_edges().to_lower()
|
||||
if not needle.is_empty() and lowercase_path.contains(needle):
|
||||
allowed = true
|
||||
break
|
||||
if not allowed:
|
||||
return false
|
||||
for pattern in denylist_patterns:
|
||||
var needle := String(pattern).strip_edges().to_lower()
|
||||
if not needle.is_empty() and lowercase_path.contains(needle):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Returns historical nested/lowercase/basename `.glb` candidates without I/O.
|
||||
func cache_resource_paths(
|
||||
cache_directory: String,
|
||||
relative_path: String
|
||||
) -> PackedStringArray:
|
||||
var normalized := relative_path.replace("\\", "/")
|
||||
var lowercase := normalized.to_lower()
|
||||
var stems := [
|
||||
normalized.get_basename(),
|
||||
lowercase.get_basename(),
|
||||
normalized.get_file().get_basename(),
|
||||
lowercase.get_file().get_basename(),
|
||||
]
|
||||
var result := PackedStringArray()
|
||||
for stem in stems:
|
||||
if stem.is_empty():
|
||||
continue
|
||||
var cache_resource_path := cache_directory.path_join(stem + ".glb")
|
||||
if not result.has(cache_resource_path):
|
||||
result.append(cache_resource_path)
|
||||
return result
|
||||
|
||||
|
||||
## Checks animation presence, primitive limit and accepted OpenWC schema.
|
||||
func glb_cache_is_safe_for_runtime_animation(
|
||||
cache_resource_path: String,
|
||||
maximum_primitive_count: int,
|
||||
debug_logging_enabled: bool = false
|
||||
) -> bool:
|
||||
var gltf := read_glb_json(cache_resource_path)
|
||||
if gltf.is_empty():
|
||||
return false
|
||||
var animations: Array = gltf.get("animations", [])
|
||||
if animations.is_empty():
|
||||
return false
|
||||
var primitive_count := glb_primitive_count(gltf)
|
||||
if maximum_primitive_count > 0 and primitive_count > maximum_primitive_count:
|
||||
return false
|
||||
var schema := glb_animation_schema(gltf)
|
||||
if schema == "pivot_prefix_v1" or schema.is_empty():
|
||||
return true
|
||||
if debug_logging_enabled:
|
||||
print(
|
||||
"M2_ANIM_REJECT schema=%s primitives=%d cache=%s"
|
||||
% [schema, primitive_count, cache_resource_path]
|
||||
)
|
||||
return false
|
||||
|
||||
|
||||
## Reads the JSON chunk of a version-2 GLB or returns an empty Dictionary.
|
||||
func read_glb_json(cache_resource_path: String) -> Dictionary:
|
||||
var absolute_path := ProjectSettings.globalize_path(cache_resource_path)
|
||||
if not FileAccess.file_exists(absolute_path):
|
||||
return {}
|
||||
var file := FileAccess.open(absolute_path, FileAccess.READ)
|
||||
if file == null or file.get_length() < 20:
|
||||
return {}
|
||||
var magic := file.get_32()
|
||||
var version := file.get_32()
|
||||
file.get_32()
|
||||
if magic != 0x46546c67 or version != 2:
|
||||
return {}
|
||||
var json_length := int(file.get_32())
|
||||
var chunk_type := file.get_32()
|
||||
if json_length <= 0 or chunk_type != 0x4e4f534a:
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(
|
||||
file.get_buffer(json_length).get_string_from_utf8()
|
||||
)
|
||||
return parsed as Dictionary if parsed is Dictionary else {}
|
||||
|
||||
|
||||
## Counts all glTF Mesh primitives using the historical safety rule.
|
||||
func glb_primitive_count(gltf: Dictionary) -> int:
|
||||
var count := 0
|
||||
var meshes: Array = gltf.get("meshes", [])
|
||||
for mesh_variant in meshes:
|
||||
if mesh_variant is Dictionary:
|
||||
var primitives: Array = (mesh_variant as Dictionary).get(
|
||||
"primitives",
|
||||
[]
|
||||
)
|
||||
count += primitives.size()
|
||||
return count
|
||||
|
||||
|
||||
## Returns the OpenWC animation schema marker from parsed glTF metadata.
|
||||
func glb_animation_schema(gltf: Dictionary) -> String:
|
||||
var asset: Dictionary = gltf.get("asset", {})
|
||||
var extras: Dictionary = asset.get("extras", {})
|
||||
return String(extras.get("openwc_m2_anim_schema", ""))
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0ptej41k2smt
|
||||
@@ -73,6 +73,9 @@ const M2_STATIC_BATCH_MATERIALIZER_SCRIPT := preload(
|
||||
const M2_STATIC_BUILD_RESOURCE_OBSERVER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_static_build_resource_observer.gd"
|
||||
)
|
||||
const M2_CACHED_ANIMATION_RESOURCE_OBSERVER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
|
||||
)
|
||||
const M2_RUNTIME_MESH_FINALIZER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_finalizer.gd"
|
||||
)
|
||||
@@ -283,6 +286,9 @@ var _m2_build_dispatch_planner := M2_BUILD_DISPATCH_PLANNER_SCRIPT.new()
|
||||
var _m2_static_build_resource_observer := (
|
||||
M2_STATIC_BUILD_RESOURCE_OBSERVER_SCRIPT.new()
|
||||
)
|
||||
var _m2_animation_resource_observer := (
|
||||
M2_CACHED_ANIMATION_RESOURCE_OBSERVER_SCRIPT.new()
|
||||
)
|
||||
var _m2_unique_placement_registry := (
|
||||
M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()
|
||||
)
|
||||
@@ -4350,20 +4356,34 @@ func _process_m2_build_jobs() -> void:
|
||||
var groups := _m2_build_queue_state.groups_for(key)
|
||||
var transforms: Array = groups.get(rel_path, [])
|
||||
var offset := _m2_build_queue_state.transform_offset_for(key)
|
||||
var animated_prototype: Node3D = null
|
||||
var resource_snapshot: RefCounted
|
||||
if enable_m2_animated_instances:
|
||||
animated_prototype = _get_or_load_m2_native_animated_prototype(rel_path)
|
||||
if animated_prototype == null:
|
||||
animated_prototype = _get_or_load_m2_animated_prototype(rel_path)
|
||||
var animation_request_pending := (
|
||||
enable_m2_animated_instances
|
||||
and _m2_animation_load_pipeline_state.has_request(normalized_rel)
|
||||
)
|
||||
var resource_snapshot: RefCounted = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
|
||||
normalized_rel,
|
||||
animated_prototype,
|
||||
animation_request_pending
|
||||
)
|
||||
var native_animated_prototype := (
|
||||
_get_or_load_m2_native_animated_prototype(rel_path)
|
||||
)
|
||||
if native_animated_prototype != null:
|
||||
resource_snapshot = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
|
||||
normalized_rel,
|
||||
native_animated_prototype,
|
||||
false
|
||||
)
|
||||
else:
|
||||
resource_snapshot = _m2_animation_resource_observer.observe(
|
||||
normalized_rel,
|
||||
m2_cache_dir,
|
||||
m2_animated_max_primitives,
|
||||
m2_animated_allowlist_patterns,
|
||||
m2_animated_denylist_patterns,
|
||||
_m2_prototype_cache_state,
|
||||
_m2_animation_load_pipeline_state,
|
||||
debug_streaming
|
||||
)
|
||||
else:
|
||||
resource_snapshot = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
|
||||
normalized_rel,
|
||||
null,
|
||||
false
|
||||
)
|
||||
var batch_plan: Dictionary = {}
|
||||
var batch_count := 0
|
||||
if not bool(resource_snapshot.call("animation_request_pending")):
|
||||
@@ -4875,75 +4895,6 @@ func _is_m2_native_animation_candidate(normalized_rel: String) -> bool:
|
||||
return normalized_rel.to_lower().contains("gryphonroost")
|
||||
|
||||
|
||||
func _get_or_load_m2_animated_prototype(rel_path: String) -> Node3D:
|
||||
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
||||
if normalized_rel.is_empty():
|
||||
return null
|
||||
var cached_animated := _m2_prototype_cache_state.find_animated_prototype(
|
||||
normalized_rel
|
||||
)
|
||||
if cached_animated != null:
|
||||
return cached_animated
|
||||
if _m2_prototype_cache_state.is_animation_static(normalized_rel):
|
||||
return null
|
||||
if _m2_animation_load_pipeline_state.has_request(normalized_rel):
|
||||
return null
|
||||
|
||||
_request_m2_animation_load(normalized_rel)
|
||||
return null
|
||||
|
||||
|
||||
func _request_m2_animation_load(normalized_rel: String) -> void:
|
||||
if normalized_rel.is_empty() or _m2_animation_load_pipeline_state.has_request(normalized_rel):
|
||||
return
|
||||
var cache_res_path := _find_m2_animated_glb_cache_path(normalized_rel)
|
||||
if cache_res_path.is_empty():
|
||||
_m2_prototype_cache_state.mark_animation_static(normalized_rel)
|
||||
return
|
||||
var err := ResourceLoader.load_threaded_request(
|
||||
cache_res_path,
|
||||
"",
|
||||
false,
|
||||
ResourceLoader.CACHE_MODE_REUSE)
|
||||
if err == OK or err == ERR_BUSY:
|
||||
_m2_animation_load_pipeline_state.remember_request(normalized_rel, cache_res_path)
|
||||
else:
|
||||
_m2_prototype_cache_state.mark_animation_static(normalized_rel)
|
||||
|
||||
|
||||
func _find_m2_animated_glb_cache_path(normalized_rel: String) -> String:
|
||||
if not _is_m2_animation_allowed(normalized_rel):
|
||||
return ""
|
||||
if _is_m2_animation_denied(normalized_rel):
|
||||
return ""
|
||||
for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".glb"]):
|
||||
if not ResourceLoader.exists(cache_res_path):
|
||||
continue
|
||||
if _glb_cache_is_safe_for_runtime_animation(cache_res_path):
|
||||
return cache_res_path
|
||||
return ""
|
||||
|
||||
|
||||
func _is_m2_animation_allowed(normalized_rel: String) -> bool:
|
||||
if m2_animated_allowlist_patterns.is_empty():
|
||||
return false
|
||||
var lower := normalized_rel.to_lower()
|
||||
for pattern in m2_animated_allowlist_patterns:
|
||||
var needle := String(pattern).strip_edges().to_lower()
|
||||
if not needle.is_empty() and lower.contains(needle):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _is_m2_animation_denied(normalized_rel: String) -> bool:
|
||||
var lower := normalized_rel.to_lower()
|
||||
for pattern in m2_animated_denylist_patterns:
|
||||
var needle := String(pattern).strip_edges().to_lower()
|
||||
if not needle.is_empty() and lower.contains(needle):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D:
|
||||
if normalized_rel.is_empty():
|
||||
return null
|
||||
@@ -4967,62 +4918,6 @@ func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D:
|
||||
return null
|
||||
|
||||
|
||||
func _glb_cache_is_safe_for_runtime_animation(cache_res_path: String) -> bool:
|
||||
var abs_path := ProjectSettings.globalize_path(cache_res_path)
|
||||
if not FileAccess.file_exists(abs_path):
|
||||
return false
|
||||
var file := FileAccess.open(abs_path, FileAccess.READ)
|
||||
if file == null or file.get_length() < 20:
|
||||
return false
|
||||
var magic := file.get_32()
|
||||
var version := file.get_32()
|
||||
file.get_32()
|
||||
if magic != 0x46546c67 or version != 2:
|
||||
return false
|
||||
var json_len := int(file.get_32())
|
||||
var chunk_type := file.get_32()
|
||||
if json_len <= 0 or chunk_type != 0x4e4f534a:
|
||||
return false
|
||||
var json_text := file.get_buffer(json_len).get_string_from_utf8()
|
||||
var parsed = JSON.parse_string(json_text)
|
||||
if not (parsed is Dictionary):
|
||||
return false
|
||||
var gltf := parsed as Dictionary
|
||||
var animations: Array = gltf.get("animations", [])
|
||||
if animations.is_empty():
|
||||
return false
|
||||
var primitive_count := _glb_primitive_count(gltf)
|
||||
if m2_animated_max_primitives > 0 and primitive_count > m2_animated_max_primitives:
|
||||
return false
|
||||
var schema := _glb_animation_schema(gltf)
|
||||
if schema == "pivot_prefix_v1":
|
||||
return true
|
||||
# Older simple critter GLBs were generated before the schema marker existed.
|
||||
# Accept them only after allowlist/denylist filtering and primitive-count checks.
|
||||
if schema.is_empty():
|
||||
return true
|
||||
if debug_streaming:
|
||||
print("M2_ANIM_REJECT schema=%s primitives=%d cache=%s" % [schema, primitive_count, cache_res_path])
|
||||
return false
|
||||
|
||||
|
||||
func _glb_primitive_count(gltf: Dictionary) -> int:
|
||||
var count := 0
|
||||
var meshes: Array = gltf.get("meshes", [])
|
||||
for mesh_variant in meshes:
|
||||
if not (mesh_variant is Dictionary):
|
||||
continue
|
||||
var primitives: Array = (mesh_variant as Dictionary).get("primitives", [])
|
||||
count += primitives.size()
|
||||
return count
|
||||
|
||||
|
||||
func _glb_animation_schema(gltf: Dictionary) -> String:
|
||||
var asset: Dictionary = gltf.get("asset", {})
|
||||
var extras: Dictionary = asset.get("extras", {})
|
||||
return String(extras.get("openwc_m2_anim_schema", ""))
|
||||
|
||||
|
||||
func _get_or_load_wmo_prototype(rel_path: String) -> Node3D:
|
||||
var normalized_rel := rel_path.replace("\\", "/")
|
||||
if normalized_rel.is_empty():
|
||||
|
||||
@@ -5,6 +5,9 @@ extends SceneTree
|
||||
|
||||
const PIPELINE_SCRIPT := preload("res://src/render/m2/m2_animation_load_pipeline_state.gd")
|
||||
const PIPELINE_PATH := "res://src/render/m2/m2_animation_load_pipeline_state.gd"
|
||||
const OBSERVER_PATH := (
|
||||
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
|
||||
)
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
@@ -80,14 +83,19 @@ func _verify_discard_metrics_clear_and_diagnostics(failures: Array[String]) -> v
|
||||
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var pipeline_source := FileAccess.get_file_as_string(PIPELINE_PATH)
|
||||
var observer_source := FileAccess.get_file_as_string(OBSERVER_PATH)
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
_expect_true(loader_source.contains("M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT.new()"), "loader composes pipeline state", failures)
|
||||
_expect_false(loader_source.contains("var _m2_animation_load_requests:"), "legacy request field removed", failures)
|
||||
_expect_false(loader_source.contains("var _m2_animation_finalize_queue:"), "legacy finalize field removed", failures)
|
||||
_expect_equal(loader_source.count("_m2_animation_load_pipeline_state.total_work_count()"), 3, "three existing metrics delegate", failures)
|
||||
_expect_equal(loader_source.count("_m2_animation_load_pipeline_state.clear()"), 2, "two existing clear sites delegate", failures)
|
||||
_expect_true(
|
||||
observer_source.contains("ResourceLoader.load_threaded_request("),
|
||||
"cached observer owns request admission",
|
||||
failures
|
||||
)
|
||||
for retained_loader_rule in [
|
||||
"ResourceLoader.load_threaded_request(",
|
||||
"ResourceLoader.load_threaded_get_status(path)",
|
||||
"ResourceLoader.load_threaded_get(path)",
|
||||
"RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE",
|
||||
|
||||
@@ -9,6 +9,9 @@ const SNAPSHOT_SCRIPT := preload(
|
||||
const SNAPSHOT_PATH := "res://src/render/m2/m2_build_resource_snapshot.gd"
|
||||
const DISPATCH_PATH := "res://src/render/m2/m2_build_dispatch_planner.gd"
|
||||
const STATIC_OBSERVER_PATH := "res://src/render/m2/m2_static_build_resource_observer.gd"
|
||||
const CACHED_ANIMATION_OBSERVER_PATH := (
|
||||
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
|
||||
)
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
@@ -136,6 +139,9 @@ func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void:
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
var dispatch_source := FileAccess.get_file_as_string(DISPATCH_PATH)
|
||||
var static_observer_source := FileAccess.get_file_as_string(STATIC_OBSERVER_PATH)
|
||||
var cached_observer_source := FileAccess.get_file_as_string(
|
||||
CACHED_ANIMATION_OBSERVER_PATH
|
||||
)
|
||||
var snapshot_source := FileAccess.get_file_as_string(SNAPSHOT_PATH)
|
||||
for delegated_token in [
|
||||
"M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(",
|
||||
@@ -152,6 +158,11 @@ func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void:
|
||||
"static observer adopts snapshot",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
cached_observer_source.contains("M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new("),
|
||||
"cached animation observer produces snapshot",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
dispatch_source.contains("resource_snapshot.call(\"animation_request_pending\")")
|
||||
and dispatch_source.contains("resource_snapshot.call(\"has_static_mesh\")"),
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
extends SceneTree
|
||||
|
||||
const OBSERVER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
|
||||
)
|
||||
const PROTOTYPE_CACHE_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_prototype_cache_state.gd"
|
||||
)
|
||||
const PIPELINE_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_animation_load_pipeline_state.gd"
|
||||
)
|
||||
const OBSERVER_PATH := (
|
||||
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
|
||||
)
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const FIXTURE_DIRECTORY := "user://verify_m2_cached_animation_observer"
|
||||
|
||||
var _fixture_paths: PackedStringArray = PackedStringArray()
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_observation_lifecycle(failures)
|
||||
_verify_path_policy_and_order(failures)
|
||||
_verify_glb_safety(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
_remove_fixtures()
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_CACHED_ANIMATION_RESOURCE_OBSERVER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_CACHED_ANIMATION_RESOURCE_OBSERVER PASS "
|
||||
+ "cases=33 iterations=20000 elapsed_ms=%.3f" % elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_observation_lifecycle(failures: Array[String]) -> void:
|
||||
var observer: RefCounted = OBSERVER_SCRIPT.new()
|
||||
var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new()
|
||||
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
|
||||
var invalid_snapshot: RefCounted = observer.call(
|
||||
"observe", "", "res://none", 3, PackedStringArray(),
|
||||
PackedStringArray(), prototype_cache, pipeline
|
||||
)
|
||||
_expect_false(
|
||||
bool(invalid_snapshot.call("animation_request_pending")),
|
||||
"invalid path is not pending",
|
||||
failures
|
||||
)
|
||||
var prototype := Node3D.new()
|
||||
prototype_cache.call(
|
||||
"adopt_animated_prototype",
|
||||
"creature/fish/a.m2",
|
||||
prototype
|
||||
)
|
||||
var cached_snapshot: RefCounted = observer.call(
|
||||
"observe", "creature/fish/a.m2", "res://none", 3,
|
||||
PackedStringArray(["creature/fish/"]), PackedStringArray(),
|
||||
prototype_cache, pipeline
|
||||
)
|
||||
_expect_true(
|
||||
is_same(cached_snapshot.call("animated_prototype"), prototype),
|
||||
"cached prototype identity",
|
||||
failures
|
||||
)
|
||||
prototype_cache.call("mark_animation_static", "creature/fish/static.m2")
|
||||
var static_snapshot: RefCounted = observer.call(
|
||||
"observe", "creature/fish/static.m2", "res://none", 3,
|
||||
PackedStringArray(["creature/fish/"]), PackedStringArray(),
|
||||
prototype_cache, pipeline
|
||||
)
|
||||
_expect_false(
|
||||
bool(static_snapshot.call("animation_request_pending")),
|
||||
"static outcome is not pending",
|
||||
failures
|
||||
)
|
||||
pipeline.call(
|
||||
"remember_request",
|
||||
"creature/fish/pending.m2",
|
||||
"res://pending.glb"
|
||||
)
|
||||
var pending_snapshot: RefCounted = observer.call(
|
||||
"observe", "creature/fish/pending.m2", "res://none", 3,
|
||||
PackedStringArray(["creature/fish/"]), PackedStringArray(),
|
||||
prototype_cache, pipeline
|
||||
)
|
||||
_expect_true(
|
||||
bool(pending_snapshot.call("animation_request_pending")),
|
||||
"existing request remains pending",
|
||||
failures
|
||||
)
|
||||
var unavailable_snapshot: RefCounted = observer.call(
|
||||
"observe", "creature/fish/unavailable.m2", "res://none", 3,
|
||||
PackedStringArray(["creature/fish/"]), PackedStringArray(),
|
||||
prototype_cache, pipeline
|
||||
)
|
||||
_expect_false(
|
||||
bool(unavailable_snapshot.call("animation_request_pending")),
|
||||
"missing candidate is not pending",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
bool(prototype_cache.call(
|
||||
"is_animation_static",
|
||||
"creature/fish/unavailable.m2"
|
||||
)),
|
||||
"missing candidate marks static",
|
||||
failures
|
||||
)
|
||||
pipeline.call("clear")
|
||||
prototype_cache.call("clear_and_release")
|
||||
|
||||
|
||||
func _verify_path_policy_and_order(failures: Array[String]) -> void:
|
||||
var observer: RefCounted = OBSERVER_SCRIPT.new()
|
||||
_expect_false(
|
||||
bool(observer.call(
|
||||
"is_animation_path_allowed", "creature/fish/a.m2",
|
||||
PackedStringArray(), PackedStringArray()
|
||||
)),
|
||||
"empty allowlist rejects",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
bool(observer.call(
|
||||
"is_animation_path_allowed", "Creature/Fish/A.M2",
|
||||
PackedStringArray([" creature/fish/ "]), PackedStringArray()
|
||||
)),
|
||||
"allowlist strips and ignores case",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
bool(observer.call(
|
||||
"is_animation_path_allowed", "creature/fish/boss/a.m2",
|
||||
PackedStringArray(["creature/fish/"]),
|
||||
PackedStringArray([" FISH/BOSS "])
|
||||
)),
|
||||
"denylist wins",
|
||||
failures
|
||||
)
|
||||
var paths: PackedStringArray = observer.call(
|
||||
"cache_resource_paths",
|
||||
"res://cache",
|
||||
"World/Critter/Foo.M2"
|
||||
)
|
||||
_expect_true(
|
||||
paths == PackedStringArray([
|
||||
"res://cache/World/Critter/Foo.glb",
|
||||
"res://cache/world/critter/foo.glb",
|
||||
"res://cache/Foo.glb",
|
||||
"res://cache/foo.glb",
|
||||
]),
|
||||
"historical GLB candidate order",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_glb_safety(failures: Array[String]) -> void:
|
||||
var observer: RefCounted = OBSERVER_SCRIPT.new()
|
||||
_expect_false(
|
||||
bool(observer.call(
|
||||
"glb_cache_is_safe_for_runtime_animation",
|
||||
"user://missing.glb",
|
||||
3
|
||||
)),
|
||||
"missing GLB rejects",
|
||||
failures
|
||||
)
|
||||
var pivot_path := _write_glb_fixture(
|
||||
"pivot.glb",
|
||||
_make_gltf(2, "pivot_prefix_v1", true)
|
||||
)
|
||||
_expect_true(
|
||||
bool(observer.call(
|
||||
"glb_cache_is_safe_for_runtime_animation", pivot_path, 3
|
||||
)),
|
||||
"pivot-prefix schema accepts",
|
||||
failures
|
||||
)
|
||||
var legacy_path := _write_glb_fixture(
|
||||
"legacy.glb",
|
||||
_make_gltf(1, "", true)
|
||||
)
|
||||
_expect_true(
|
||||
bool(observer.call(
|
||||
"glb_cache_is_safe_for_runtime_animation", legacy_path, 3
|
||||
)),
|
||||
"legacy empty schema accepts",
|
||||
failures
|
||||
)
|
||||
var unknown_path := _write_glb_fixture(
|
||||
"unknown.glb",
|
||||
_make_gltf(1, "future_schema", true)
|
||||
)
|
||||
_expect_false(
|
||||
bool(observer.call(
|
||||
"glb_cache_is_safe_for_runtime_animation", unknown_path, 3
|
||||
)),
|
||||
"unknown schema rejects",
|
||||
failures
|
||||
)
|
||||
var complex_path := _write_glb_fixture(
|
||||
"complex.glb",
|
||||
_make_gltf(4, "pivot_prefix_v1", true)
|
||||
)
|
||||
_expect_false(
|
||||
bool(observer.call(
|
||||
"glb_cache_is_safe_for_runtime_animation", complex_path, 3
|
||||
)),
|
||||
"primitive limit rejects",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
bool(observer.call(
|
||||
"glb_cache_is_safe_for_runtime_animation", complex_path, 0
|
||||
)),
|
||||
"non-positive primitive limit disables cap",
|
||||
failures
|
||||
)
|
||||
var static_path := _write_glb_fixture(
|
||||
"static.glb",
|
||||
_make_gltf(1, "pivot_prefix_v1", false)
|
||||
)
|
||||
_expect_false(
|
||||
bool(observer.call(
|
||||
"glb_cache_is_safe_for_runtime_animation", static_path, 3
|
||||
)),
|
||||
"GLB without animations rejects",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
int(observer.call("glb_primitive_count", _make_gltf(5, "", true))),
|
||||
5,
|
||||
"primitive count retained",
|
||||
failures
|
||||
)
|
||||
_expect_string_equal(
|
||||
String(observer.call(
|
||||
"glb_animation_schema",
|
||||
_make_gltf(1, "pivot_prefix_v1", true)
|
||||
)),
|
||||
"pivot_prefix_v1",
|
||||
"schema retained",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
var observer_source := FileAccess.get_file_as_string(OBSERVER_PATH)
|
||||
_expect_true(
|
||||
loader_source.contains("_m2_animation_resource_observer.observe("),
|
||||
"loader delegates cached observation",
|
||||
failures
|
||||
)
|
||||
for removed_function in [
|
||||
"func _get_or_load_m2_animated_prototype",
|
||||
"func _request_m2_animation_load",
|
||||
"func _find_m2_animated_glb_cache_path",
|
||||
"func _glb_cache_is_safe_for_runtime_animation",
|
||||
]:
|
||||
_expect_false(
|
||||
loader_source.contains(removed_function),
|
||||
"loader omits %s" % removed_function,
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
loader_source.contains("func _get_or_load_m2_native_animated_prototype"),
|
||||
"loader retains native observation",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
observer_source.contains("ResourceLoader.load_threaded_request("),
|
||||
"observer owns request admission",
|
||||
failures
|
||||
)
|
||||
for forbidden_token in [
|
||||
"queue_free",
|
||||
"RenderingServer",
|
||||
"WorkerThreadPool",
|
||||
"M2_NATIVE_ANIMATED_BUILDER",
|
||||
"M2RawModelRepository",
|
||||
"RenderBudgetScheduler",
|
||||
]:
|
||||
_expect_false(
|
||||
observer_source.contains(forbidden_token),
|
||||
"observer omits %s" % forbidden_token,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var observer: RefCounted = OBSERVER_SCRIPT.new()
|
||||
var allowlist := PackedStringArray(["creature/fish/", "world/critter/"])
|
||||
var denylist := PackedStringArray(["boss"])
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for iteration in range(20000):
|
||||
var path := "Creature/Fish/model_%d.m2" % (iteration % 64)
|
||||
observer.call(
|
||||
"is_animation_path_allowed",
|
||||
path,
|
||||
allowlist,
|
||||
denylist
|
||||
)
|
||||
observer.call("cache_resource_paths", "res://cache", path)
|
||||
var elapsed_milliseconds := (
|
||||
float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
)
|
||||
_expect_true(
|
||||
elapsed_milliseconds < 1000.0,
|
||||
"20,000 policy/path observations remain bounded",
|
||||
failures
|
||||
)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _make_gltf(
|
||||
primitive_count: int,
|
||||
schema: String,
|
||||
has_animation: bool
|
||||
) -> Dictionary:
|
||||
var primitives: Array[Dictionary] = []
|
||||
for _index in range(primitive_count):
|
||||
primitives.append({})
|
||||
var gltf := {
|
||||
"asset": {"version": "2.0", "extras": {}},
|
||||
"meshes": [{"primitives": primitives}],
|
||||
"animations": [{}] if has_animation else [],
|
||||
}
|
||||
if not schema.is_empty():
|
||||
(gltf["asset"]["extras"] as Dictionary)[
|
||||
"openwc_m2_anim_schema"
|
||||
] = schema
|
||||
return gltf
|
||||
|
||||
|
||||
func _write_glb_fixture(file_name: String, gltf: Dictionary) -> String:
|
||||
var absolute_directory := ProjectSettings.globalize_path(FIXTURE_DIRECTORY)
|
||||
DirAccess.make_dir_recursive_absolute(absolute_directory)
|
||||
var resource_path := FIXTURE_DIRECTORY.path_join(file_name)
|
||||
var absolute_path := ProjectSettings.globalize_path(resource_path)
|
||||
var json_bytes := JSON.stringify(gltf).to_utf8_buffer()
|
||||
while json_bytes.size() % 4 != 0:
|
||||
json_bytes.append(0x20)
|
||||
var file := FileAccess.open(absolute_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return resource_path
|
||||
file.store_32(0x46546c67)
|
||||
file.store_32(2)
|
||||
file.store_32(20 + json_bytes.size())
|
||||
file.store_32(json_bytes.size())
|
||||
file.store_32(0x4e4f534a)
|
||||
file.store_buffer(json_bytes)
|
||||
file.close()
|
||||
_fixture_paths.append(absolute_path)
|
||||
return resource_path
|
||||
|
||||
|
||||
func _remove_fixtures() -> void:
|
||||
for absolute_path in _fixture_paths:
|
||||
if FileAccess.file_exists(absolute_path):
|
||||
DirAccess.remove_absolute(absolute_path)
|
||||
var absolute_directory := ProjectSettings.globalize_path(FIXTURE_DIRECTORY)
|
||||
if DirAccess.dir_exists_absolute(absolute_directory):
|
||||
DirAccess.remove_absolute(absolute_directory)
|
||||
|
||||
|
||||
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_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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbviicmix3kb0
|
||||
Reference in New Issue
Block a user