239 lines
6.8 KiB
GDScript
239 lines
6.8 KiB
GDScript
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", ""))
|