straywild/tests/character_rig_validation.gd

332 lines
8.7 KiB
GDScript

extends SceneTree
const CHARACTER_SCENE: PackedScene = preload(
"res://art/exported/characters/base/straywild_base_character.glb"
)
const EXPECTED_ANIMATIONS: Array[StringName] = [
&"casting",
&"casting_sit",
&"draw",
&"fighting",
&"fighting_sit",
&"fishing",
&"fishing_sit",
&"idle",
&"idle_show",
&"idle_sit",
&"idle_sit_show",
&"idle_sneak",
&"pocket_idle_idle",
&"pocket_idle_show",
&"pocket_show_idle",
&"pocket_show_show",
&"pocket_sit_idle_idle",
&"pocket_sit_idle_show",
&"pocket_sit_show_idle",
&"pocket_sit_show_show",
&"pocket_walking_idle_idle",
&"pocket_walking_idle_show",
&"pocket_walking_show_idle",
&"pocket_walking_show_show",
&"release",
&"release_sit",
&"retract",
&"retract_sit",
&"running",
&"running_show",
&"sneaking",
&"strike",
&"walking",
&"walking_show",
]
const EXPECTED_HEAD_MESHES: PackedStringArray = [
"head_round",
"head_pointy",
"head_anteater",
"head_axolotl",
"head_bat",
"head_bird",
"head_boar",
"head_butterfly",
"head_donkey",
"head_elephant",
"head_goat",
"head_hamster",
"head_horse",
"head_moth",
"head_opossum",
"head_owl",
"head_panda",
"head_pig",
"head_shark",
"head_sheep",
"head_snail",
]
const EXPECTED_ARM_MESHES: PackedStringArray = [
"arms_mammal", "arms_fish", "arms_avian",
]
const EXPECTED_EAR_MESHES: PackedStringArray = [
"ears_cat",
"ears_long",
"ears_fox",
"ears_bunny",
"ears_antlers_round",
"ears_bear",
"ears_anteater",
"ears_bat",
"ears_boar",
"ears_butterfly",
"ears_donkey",
"ears_axolotl",
"ears_elephant",
"ears_goat",
"ears_hamster",
"ears_horse",
"ears_opossum",
"ears_moth",
"ears_pig",
"ears_owl",
"ears_panda",
]
const EXPECTED_TAIL_MESHES: PackedStringArray = [
"tails_gator",
"tails_fox",
"tails_bunny",
"tails_cat",
"tails_bear",
"tails_pointy",
"tails_anteater",
"tails_boar",
"tails_butterfly",
"tail_donkey",
"tails_axolotl",
"tails_horse",
"tails_opossum",
"tails_moth",
"tails_pig",
"tails_shark",
"tails_bird",
]
const EXPECTED_SPECIAL_MESHES: PackedStringArray = [
"tusks_boar",
"tusks_elephant",
"wings_butterfly",
"wings_bat",
"wings_moth",
"horns_goat",
"horns_sheep",
"back_shell",
"head_fin",
"beak_owl",
"beak_bird",
]
const FACE_FEATURES: PackedStringArray = ["eyes", "nose", "mouth"]
const EXCLUDED_AUTHORING_MESHES: PackedStringArray = [
"frog_eyes_1",
"frog_eyes_2",
"frog_head_1",
"frog_head_2",
"rod_socket_preview-noimp",
]
const EXPECTED_BONE_PARENTS: Dictionary = {
"root": "",
"hips": "root",
"spine": "hips",
"clavicle.L": "spine",
"upper_arm.L": "clavicle.L",
"forearm.L": "upper_arm.L",
"hand.L": "forearm.L",
"clavicle.R": "spine",
"upper_arm.R": "clavicle.R",
"forearm.R": "upper_arm.R",
"hand.R": "forearm.R",
"rod_socket": "hand.R",
"rod_socket.001": "rod_socket",
"neck": "spine",
"head": "neck",
"thigh.L": "hips",
"shin.L": "thigh.L",
"thigh.R": "hips",
"shin.R": "thigh.R",
}
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var character := CHARACTER_SCENE.instantiate()
root.add_child(character)
var skeleton := _find_first(character, "Skeleton3D") as Skeleton3D
var animation_player := _find_first(
character,
"AnimationPlayer",
) as AnimationPlayer
assert(skeleton != null)
assert(animation_player != null)
assert(character.get_path_to(skeleton) == ^"CharacterRig/Skeleton3D")
assert(skeleton.get_bone_count() == EXPECTED_BONE_PARENTS.size())
for bone_name: String in EXPECTED_BONE_PARENTS:
var bone_index := skeleton.find_bone(bone_name)
assert(bone_index >= 0, "Missing bone: %s" % bone_name)
var parent_index := skeleton.get_bone_parent(bone_index)
var actual_parent := (
str(skeleton.get_bone_name(parent_index))
if parent_index >= 0
else ""
)
assert(
actual_parent == str(EXPECTED_BONE_PARENTS[bone_name]),
"Unexpected parent for bone %s: %s" % [bone_name, actual_parent],
)
var socket_index := skeleton.find_bone("rod_socket")
assert(socket_index >= 0)
var socket_direction_index := skeleton.find_bone("rod_socket.001")
assert(socket_direction_index >= 0)
assert(skeleton.get_bone_parent(socket_direction_index) == socket_index)
var animation_names := animation_player.get_animation_list()
assert(animation_names.size() == EXPECTED_ANIMATIONS.size())
for expected in EXPECTED_ANIMATIONS:
assert(expected in animation_names, "Missing animation: %s" % expected)
var animation := animation_player.get_animation(expected)
if expected in [&"casting", &"draw", &"idle_sneak", &"sneaking", &"strike"]:
var has_socket_track: bool = false
for track_index in animation.get_track_count():
has_socket_track = has_socket_track or String(
animation.track_get_path(track_index)
).contains("rod_socket")
assert(
has_socket_track,
"Animation %s lost its authored rod socket pose." % expected,
)
var expected_mesh_names := _expected_runtime_mesh_names()
assert(expected_mesh_names.size() == 137)
var face_decal_count := 0
for head_name: String in EXPECTED_HEAD_MESHES:
for feature_name: String in FACE_FEATURES:
assert(expected_mesh_names.has(
"%s_%s" % [head_name, feature_name]
))
face_decal_count += 1
assert(face_decal_count == 63)
var runtime_meshes: Array[MeshInstance3D] = []
_collect_mesh_instances(character, runtime_meshes)
assert(runtime_meshes.size() == expected_mesh_names.size())
var actual_mesh_names: Dictionary = {}
var total_blended_vertices := 0
for mesh_instance: MeshInstance3D in runtime_meshes:
assert(not actual_mesh_names.has(mesh_instance.name))
actual_mesh_names[mesh_instance.name] = true
assert(
expected_mesh_names.has(mesh_instance.name),
"Unexpected runtime character mesh: %s" % mesh_instance.name,
)
total_blended_vertices += _validate_mesh_weights(mesh_instance)
assert(actual_mesh_names.size() == expected_mesh_names.size())
for mesh_name: String in expected_mesh_names:
assert(actual_mesh_names.has(mesh_name), "Missing mesh: %s" % mesh_name)
for mesh_name: String in EXCLUDED_AUTHORING_MESHES:
assert(not actual_mesh_names.has(mesh_name))
var blended_arm_vertices := 0
for mesh_name: String in EXPECTED_ARM_MESHES:
var arms := character.find_child(
mesh_name,
true,
false,
) as MeshInstance3D
assert(arms != null)
var blended_vertices := _validate_mesh_weights(arms)
assert(
blended_vertices > 0,
"%s has no blended joint weights." % mesh_name,
)
blended_arm_vertices += blended_vertices
assert(total_blended_vertices >= blended_arm_vertices)
print(
(
"Character rig validation: PASS "
+ "(meshes=%d, face decals=%d, bones=%d, animations=%d, "
+ "blended arm vertices=%d)"
)
% [
runtime_meshes.size(),
face_decal_count,
skeleton.get_bone_count(),
EXPECTED_ANIMATIONS.size(),
blended_arm_vertices,
]
)
character.queue_free()
quit()
func _validate_mesh_weights(mesh_instance: MeshInstance3D) -> int:
var blended_vertices := 0
for surface_index in mesh_instance.mesh.get_surface_count():
var arrays := mesh_instance.mesh.surface_get_arrays(surface_index)
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
assert(not weights.is_empty())
assert(not vertices.is_empty())
var components_per_vertex: int = int(
float(weights.size()) / float(vertices.size())
)
assert(components_per_vertex == 4 or components_per_vertex == 8)
for offset in range(0, weights.size(), components_per_vertex):
var total := 0.0
var influence_count := 0
for component in range(components_per_vertex):
var weight := weights[offset + component]
total += weight
if weight > 0.000001:
influence_count += 1
assert(absf(total - 1.0) <= 0.0001)
assert(influence_count <= 4)
if influence_count > 1:
blended_vertices += 1
return blended_vertices
func _expected_runtime_mesh_names() -> Dictionary:
var result: Dictionary = {"body_main": true}
for mesh_name: String in EXPECTED_ARM_MESHES:
result[mesh_name] = true
for head_name: String in EXPECTED_HEAD_MESHES:
result[head_name] = true
for feature_name: String in FACE_FEATURES:
result["%s_%s" % [head_name, feature_name]] = true
for mesh_name: String in EXPECTED_EAR_MESHES:
result[mesh_name] = true
for mesh_name: String in EXPECTED_TAIL_MESHES:
result[mesh_name] = true
for mesh_name: String in EXPECTED_SPECIAL_MESHES:
result[mesh_name] = true
return result
func _collect_mesh_instances(
parent: Node,
result: Array[MeshInstance3D],
) -> void:
if parent is MeshInstance3D:
result.append(parent as MeshInstance3D)
for child: Node in parent.get_children():
_collect_mesh_instances(child, result)
func _find_first(parent: Node, class_name_to_find: String) -> Node:
if parent.is_class(class_name_to_find):
return parent
for child in parent.get_children():
var found := _find_first(child, class_name_to_find)
if found != null:
return found
return null