render: extract static M2 build resource observer
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
class_name M2StaticBuildResourceObserver
|
||||
extends RefCounted
|
||||
|
||||
## Resolves or requests one static M2 build Mesh and fills a resource snapshot.
|
||||
|
||||
const OUTCOME_REJECTED := &"rejected"
|
||||
const OUTCOME_CACHED := &"cached"
|
||||
const OUTCOME_PENDING := &"pending"
|
||||
const OUTCOME_REQUESTED := &"requested"
|
||||
const OUTCOME_MISSING := &"missing"
|
||||
|
||||
|
||||
## Observes cache/request state and adopts the exact static result into snapshot.
|
||||
func observe(
|
||||
resource_snapshot: RefCounted,
|
||||
normalized_relative_path: String,
|
||||
cache_directory: String,
|
||||
mesh_cache_state: RefCounted,
|
||||
prototype_cache_state: RefCounted,
|
||||
load_pipeline_state: RefCounted
|
||||
) -> StringName:
|
||||
if (
|
||||
resource_snapshot == null
|
||||
or normalized_relative_path.is_empty()
|
||||
or mesh_cache_state == null
|
||||
or prototype_cache_state == null
|
||||
or load_pipeline_state == null
|
||||
):
|
||||
return OUTCOME_REJECTED
|
||||
if bool(mesh_cache_state.call("has_mesh", normalized_relative_path)):
|
||||
resource_snapshot.call(
|
||||
"adopt_static_observation",
|
||||
mesh_cache_state.call("find_mesh", normalized_relative_path) as Mesh,
|
||||
false
|
||||
)
|
||||
return OUTCOME_CACHED
|
||||
if bool(prototype_cache_state.call("is_model_missing", normalized_relative_path)):
|
||||
resource_snapshot.call("adopt_static_observation", null, true)
|
||||
return OUTCOME_MISSING
|
||||
if bool(load_pipeline_state.call("has_request", normalized_relative_path)):
|
||||
resource_snapshot.call("adopt_static_observation", null, false)
|
||||
return OUTCOME_PENDING
|
||||
|
||||
for cache_resource_path in cache_resource_paths(
|
||||
cache_directory,
|
||||
normalized_relative_path,
|
||||
[".tscn", ".glb"]
|
||||
):
|
||||
if not ResourceLoader.exists(cache_resource_path):
|
||||
continue
|
||||
if (
|
||||
cache_resource_path.get_extension().to_lower() == "glb"
|
||||
and glb_animation_schema(cache_resource_path) == "pivot_prefix_v1"
|
||||
):
|
||||
continue
|
||||
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
|
||||
)
|
||||
resource_snapshot.call("adopt_static_observation", null, false)
|
||||
return OUTCOME_REQUESTED
|
||||
break
|
||||
|
||||
prototype_cache_state.call("mark_model_missing", normalized_relative_path)
|
||||
resource_snapshot.call("adopt_static_observation", null, true)
|
||||
return OUTCOME_MISSING
|
||||
|
||||
|
||||
## Returns historical nested/lowercase/basename cache candidates without I/O.
|
||||
func cache_resource_paths(
|
||||
cache_directory: String,
|
||||
relative_path: String,
|
||||
extensions: Array[String]
|
||||
) -> PackedStringArray:
|
||||
var normalized := relative_path.replace("\\", "/")
|
||||
var lower := normalized.to_lower()
|
||||
var stems := [
|
||||
normalized.get_basename(),
|
||||
lower.get_basename(),
|
||||
normalized.get_file().get_basename(),
|
||||
lower.get_file().get_basename(),
|
||||
]
|
||||
var result := PackedStringArray()
|
||||
for extension in extensions:
|
||||
for stem in stems:
|
||||
if stem.is_empty():
|
||||
continue
|
||||
var path := cache_directory.path_join(stem + extension)
|
||||
if not result.has(path):
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
## Reads only the OpenWC animation schema marker from a GLB JSON chunk.
|
||||
func glb_animation_schema(cache_resource_path: String) -> String:
|
||||
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()
|
||||
)
|
||||
if not (parsed is Dictionary):
|
||||
return ""
|
||||
var asset: Dictionary = (parsed as Dictionary).get("asset", {})
|
||||
var extras: Dictionary = asset.get("extras", {})
|
||||
return String(extras.get("openwc_m2_anim_schema", ""))
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0e3p80ld1dfb
|
||||
@@ -70,6 +70,9 @@ const M2_BUILD_QUEUE_SCRIPT := preload("res://src/render/m2/m2_build_queue.gd")
|
||||
const M2_STATIC_BATCH_MATERIALIZER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_static_batch_materializer.gd"
|
||||
)
|
||||
const M2_STATIC_BUILD_RESOURCE_OBSERVER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_static_build_resource_observer.gd"
|
||||
)
|
||||
const M2_RUNTIME_MESH_FINALIZER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_finalizer.gd"
|
||||
)
|
||||
@@ -277,6 +280,9 @@ var _m2_group_result_mutex := Mutex.new()
|
||||
var _m2_group_result_queue: Array = []
|
||||
var _m2_build_queue_state := M2_BUILD_QUEUE_SCRIPT.new()
|
||||
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_unique_placement_registry := (
|
||||
M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()
|
||||
)
|
||||
@@ -4373,14 +4379,13 @@ func _process_m2_build_jobs() -> void:
|
||||
batch_count > 0
|
||||
and not bool(resource_snapshot.call("has_animated_prototype"))
|
||||
):
|
||||
var static_mesh := _get_m2_mesh_or_request(rel_path)
|
||||
resource_snapshot.call(
|
||||
"adopt_static_observation",
|
||||
static_mesh,
|
||||
(
|
||||
static_mesh == null
|
||||
and _m2_prototype_cache_state.is_model_missing(normalized_rel)
|
||||
)
|
||||
_m2_static_build_resource_observer.observe(
|
||||
resource_snapshot,
|
||||
normalized_rel,
|
||||
m2_cache_dir,
|
||||
_m2_mesh_resource_cache_state,
|
||||
_m2_prototype_cache_state,
|
||||
_m2_mesh_load_pipeline_state
|
||||
)
|
||||
var dispatch_plan: Dictionary = _m2_build_dispatch_planner.plan_step(
|
||||
batch_count,
|
||||
@@ -4587,40 +4592,6 @@ func _normalize_m2_rel_path(rel_path: String) -> String:
|
||||
return normalized_rel
|
||||
|
||||
|
||||
func _get_m2_mesh_or_request(rel_path: String) -> Mesh:
|
||||
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
||||
if normalized_rel.is_empty():
|
||||
return null
|
||||
if _m2_mesh_resource_cache_state.has_mesh(normalized_rel):
|
||||
return _m2_mesh_resource_cache_state.find_mesh(normalized_rel)
|
||||
if _m2_prototype_cache_state.is_model_missing(normalized_rel):
|
||||
return null
|
||||
_request_m2_mesh_load(normalized_rel)
|
||||
return null
|
||||
|
||||
|
||||
func _request_m2_mesh_load(normalized_rel: String) -> void:
|
||||
if normalized_rel.is_empty() or _m2_mesh_load_pipeline_state.has_request(normalized_rel):
|
||||
return
|
||||
|
||||
for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn", ".glb"]):
|
||||
if not ResourceLoader.exists(cache_res_path):
|
||||
continue
|
||||
if cache_res_path.get_extension().to_lower() == "glb" and _glb_cache_animation_schema(cache_res_path) == "pivot_prefix_v1":
|
||||
continue
|
||||
var err := ResourceLoader.load_threaded_request(
|
||||
cache_res_path,
|
||||
"",
|
||||
false,
|
||||
ResourceLoader.CACHE_MODE_REUSE)
|
||||
if err == OK or err == ERR_BUSY:
|
||||
_m2_mesh_load_pipeline_state.remember_request(normalized_rel, cache_res_path)
|
||||
return
|
||||
break
|
||||
|
||||
_m2_prototype_cache_state.mark_model_missing(normalized_rel)
|
||||
|
||||
|
||||
func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh:
|
||||
if mesh == null:
|
||||
return null
|
||||
@@ -4820,7 +4791,12 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D:
|
||||
for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn", ".glb"]):
|
||||
if not ResourceLoader.exists(cache_res_path):
|
||||
continue
|
||||
if cache_res_path.get_extension().to_lower() == "glb" and _glb_cache_animation_schema(cache_res_path) == "pivot_prefix_v1":
|
||||
if (
|
||||
cache_res_path.get_extension().to_lower() == "glb"
|
||||
and _m2_static_build_resource_observer.glb_animation_schema(
|
||||
cache_res_path
|
||||
) == "pivot_prefix_v1"
|
||||
):
|
||||
continue
|
||||
var resource: Resource = load(cache_res_path)
|
||||
if resource is PackedScene:
|
||||
@@ -5041,28 +5017,6 @@ func _glb_primitive_count(gltf: Dictionary) -> int:
|
||||
return count
|
||||
|
||||
|
||||
func _glb_cache_animation_schema(cache_res_path: String) -> String:
|
||||
var abs_path := ProjectSettings.globalize_path(cache_res_path)
|
||||
if not FileAccess.file_exists(abs_path):
|
||||
return ""
|
||||
var file := FileAccess.open(abs_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_len := int(file.get_32())
|
||||
var chunk_type := file.get_32()
|
||||
if json_len <= 0 or chunk_type != 0x4e4f534a:
|
||||
return ""
|
||||
var parsed = JSON.parse_string(file.get_buffer(json_len).get_string_from_utf8())
|
||||
if parsed is Dictionary:
|
||||
return _glb_animation_schema(parsed as Dictionary)
|
||||
return ""
|
||||
|
||||
|
||||
func _glb_animation_schema(gltf: Dictionary) -> String:
|
||||
var asset: Dictionary = gltf.get("asset", {})
|
||||
var extras: Dictionary = asset.get("extras", {})
|
||||
|
||||
@@ -160,7 +160,7 @@ func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void:
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
loader_source.contains("_get_m2_mesh_or_request(rel_path)")
|
||||
loader_source.contains("_m2_static_build_resource_observer.observe(")
|
||||
and loader_source.contains("_materialize_m2_animated_batch(")
|
||||
and loader_source.contains("_materialize_m2_group_batch(")
|
||||
and loader_source.contains("try_consume_permit("),
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
@@ -134,10 +135,10 @@ func _verify_engine_lifetime(failures: Array[String]) -> void:
|
||||
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 snapshot_source := FileAccess.get_file_as_string(SNAPSHOT_PATH)
|
||||
for delegated_token in [
|
||||
"M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(",
|
||||
"\"adopt_static_observation\"",
|
||||
"resource_snapshot.call(\"animated_prototype\")",
|
||||
"resource_snapshot.call(\"static_mesh\")",
|
||||
]:
|
||||
@@ -146,6 +147,11 @@ func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void:
|
||||
"loader uses %s" % delegated_token,
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
static_observer_source.contains("\"adopt_static_observation\""),
|
||||
"static observer adopts snapshot",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
dispatch_source.contains("resource_snapshot.call(\"animation_request_pending\")")
|
||||
and dispatch_source.contains("resource_snapshot.call(\"has_static_mesh\")"),
|
||||
|
||||
@@ -6,6 +6,7 @@ extends SceneTree
|
||||
const CACHE_SCRIPT := preload("res://src/render/m2/m2_mesh_resource_cache_state.gd")
|
||||
const CACHE_PATH := "res://src/render/m2/m2_mesh_resource_cache_state.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const STATIC_OBSERVER_PATH := "res://src/render/m2/m2_static_build_resource_observer.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
@@ -68,6 +69,7 @@ func _verify_clear_and_diagnostics(failures: Array[String]) -> void:
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var cache_source := FileAccess.get_file_as_string(CACHE_PATH)
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
var observer_source := FileAccess.get_file_as_string(STATIC_OBSERVER_PATH)
|
||||
_expect_true(
|
||||
loader_source.contains("M2_MESH_RESOURCE_CACHE_STATE_SCRIPT.new()"),
|
||||
"loader composes Mesh cache state",
|
||||
@@ -81,7 +83,8 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_mesh_resource_cache_state.find_mesh("),
|
||||
loader_source.count("_m2_mesh_resource_cache_state.find_mesh(")
|
||||
+ observer_source.count("\"find_mesh\""),
|
||||
2,
|
||||
"two existing lookups delegate",
|
||||
failures
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
extends SceneTree
|
||||
|
||||
const OBSERVER_SCRIPT := preload("res://src/render/m2/m2_static_build_resource_observer.gd")
|
||||
const SNAPSHOT_SCRIPT := preload("res://src/render/m2/m2_build_resource_snapshot.gd")
|
||||
const MESH_CACHE_SCRIPT := preload("res://src/render/m2/m2_mesh_resource_cache_state.gd")
|
||||
const PROTOTYPE_CACHE_SCRIPT := preload("res://src/render/m2/m2_prototype_cache_state.gd")
|
||||
const PIPELINE_SCRIPT := preload("res://src/render/m2/m2_mesh_load_pipeline_state.gd")
|
||||
const OBSERVER_PATH := "res://src/render/m2/m2_static_build_resource_observer.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_invalid_cached_missing_pending(failures)
|
||||
_verify_request_and_path_order(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
var elapsed := _verify_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_STATIC_BUILD_RESOURCE_OBSERVER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print("M2_STATIC_BUILD_RESOURCE_OBSERVER PASS cases=11 iterations=20000 elapsed_ms=%.3f" % elapsed)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_invalid_cached_missing_pending(failures: Array[String]) -> void:
|
||||
var observer: RefCounted = OBSERVER_SCRIPT.new()
|
||||
var mesh_cache: RefCounted = MESH_CACHE_SCRIPT.new()
|
||||
var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new()
|
||||
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
|
||||
var snapshot: RefCounted = SNAPSHOT_SCRIPT.new("world/a.m2", null, false)
|
||||
_expect(
|
||||
observer.call(
|
||||
"observe", null, "world/a.m2", "res://none",
|
||||
mesh_cache, prototype_cache, pipeline
|
||||
)
|
||||
== OBSERVER_SCRIPT.OUTCOME_REJECTED,
|
||||
"null snapshot rejected", failures
|
||||
)
|
||||
var mesh := ArrayMesh.new()
|
||||
mesh_cache.call("store_mesh", "world/a.m2", mesh)
|
||||
_expect(
|
||||
observer.call(
|
||||
"observe", snapshot, "world/a.m2", "res://none",
|
||||
mesh_cache, prototype_cache, pipeline
|
||||
)
|
||||
== OBSERVER_SCRIPT.OUTCOME_CACHED, "cached outcome", failures
|
||||
)
|
||||
_expect(is_same(snapshot.call("static_mesh"), mesh), "cached Mesh identity", failures)
|
||||
mesh_cache.call("clear")
|
||||
prototype_cache.call("mark_model_missing", "world/a.m2")
|
||||
_expect(
|
||||
observer.call(
|
||||
"observe", snapshot, "world/a.m2", "res://none",
|
||||
mesh_cache, prototype_cache, pipeline
|
||||
)
|
||||
== OBSERVER_SCRIPT.OUTCOME_MISSING, "missing outcome", failures
|
||||
)
|
||||
_expect(bool(snapshot.call("static_model_missing")), "missing adopted", failures)
|
||||
prototype_cache = PROTOTYPE_CACHE_SCRIPT.new()
|
||||
pipeline.call("remember_request", "world/a.m2", "res://pending.tscn")
|
||||
_expect(
|
||||
observer.call(
|
||||
"observe", snapshot, "world/a.m2", "res://none",
|
||||
mesh_cache, prototype_cache, pipeline
|
||||
)
|
||||
== OBSERVER_SCRIPT.OUTCOME_PENDING, "pending outcome", failures
|
||||
)
|
||||
_expect(not bool(snapshot.call("static_model_missing")), "pending is not missing", failures)
|
||||
pipeline.call("clear")
|
||||
prototype_cache.call("clear_and_release")
|
||||
|
||||
|
||||
func _verify_request_and_path_order(failures: Array[String]) -> void:
|
||||
var observer: RefCounted = OBSERVER_SCRIPT.new()
|
||||
var extensions: Array[String] = [".tscn", ".glb"]
|
||||
var paths: PackedStringArray = observer.call(
|
||||
"cache_resource_paths", "res://cache", "World/Foo/Bar.M2", extensions
|
||||
)
|
||||
var expected := PackedStringArray([
|
||||
"res://cache/World/Foo/Bar.tscn", "res://cache/world/foo/bar.tscn",
|
||||
"res://cache/Bar.tscn", "res://cache/bar.tscn",
|
||||
"res://cache/World/Foo/Bar.glb", "res://cache/world/foo/bar.glb",
|
||||
"res://cache/Bar.glb", "res://cache/bar.glb",
|
||||
])
|
||||
_expect(paths == expected, "candidate order", failures)
|
||||
_expect(
|
||||
observer.call("glb_animation_schema", "res://missing.glb") == "",
|
||||
"missing GLB schema empty", failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var loader := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
var observer := FileAccess.get_file_as_string(OBSERVER_PATH)
|
||||
_expect(
|
||||
loader.contains("_m2_static_build_resource_observer.observe("),
|
||||
"loader delegates observer", failures
|
||||
)
|
||||
_expect(not loader.contains("func _get_m2_mesh_or_request"), "legacy lookup removed", failures)
|
||||
_expect(not loader.contains("func _request_m2_mesh_load"), "legacy request removed", failures)
|
||||
_expect(
|
||||
observer.contains("ResourceLoader.load_threaded_request("),
|
||||
"observer owns request", failures
|
||||
)
|
||||
for forbidden in ["queue_free", "RenderingServer", "WorkerThreadPool", "Mutex"]:
|
||||
_expect(not observer.contains(forbidden), "observer omits %s" % forbidden, failures)
|
||||
|
||||
|
||||
func _verify_timing(failures: Array[String]) -> float:
|
||||
var observer: RefCounted = OBSERVER_SCRIPT.new()
|
||||
var extensions: Array[String] = [".tscn", ".glb"]
|
||||
var started := Time.get_ticks_usec()
|
||||
for index in range(20000):
|
||||
observer.call(
|
||||
"cache_resource_paths", "res://cache",
|
||||
"world/%d/model.m2" % (index % 64), extensions
|
||||
)
|
||||
var elapsed := float(Time.get_ticks_usec() - started) / 1000.0
|
||||
_expect(elapsed < 1000.0, "path planning bounded", failures)
|
||||
return elapsed
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String, failures: Array[String]) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8bxs4swxsntd
|
||||
Reference in New Issue
Block a user