-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtest_facial.gd
More file actions
266 lines (228 loc) · 11.3 KB
/
Copy pathtest_facial.gd
File metadata and controls
266 lines (228 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
## Facial animation subsystem: SidekickFacialController + SKFacePoses. Run:
## godot --headless -s tests/test_facial.gd
extends SceneTree
var _failures := 0
var _library: SKPartLibrary
func _init() -> void:
ProjectSettings.set_setting("kickdot/build/combine_meshes", false)
ProjectSettings.set_setting(SKResourceCache.SETTING_CACHE_DIR, "user://test_kickdot_cache")
call_deferred("_run")
func _run() -> void:
_library = SKPartLibrary.new()
_library.scan()
_test_bake()
await _test_controller(false)
await _test_controller(true)
await _test_poses_and_eyes()
await _test_animation_player()
await _test_rebuild_resync()
_test_preset_roundtrip()
print("FACIAL: %s" % ("ALL PASSED" if _failures == 0 else "%d FAILURES" % _failures))
quit(0 if _failures == 0 else 1)
func _test_bake() -> void:
print("[bake]")
_check(SKFacePoses.bake(), "bake succeeds")
var data := SKFacePoses.load_data()
# Synty's earlier tool releases ship 24 curated poses; newer ones add ReferencePose
# (25) - accept both so the test doesn't depend on which release is downloaded.
_check(data.poses.size() >= 24, "curated poses parsed (got %d)" % data.poses.size())
_check(not data.poses.has("eyeL"), "Synty's stray eyeL.anim excluded")
_check(data.cycles.size() == 3, "3 cycles parsed (got %d)" % data.cycles.size())
var angry: Dictionary = data.poses.get("Angry", {})
_check(absf(float(angry.get("noseSneerLeft", -1.0)) - 0.0665) < 0.001,
"Angry noseSneerLeft matches the .anim value (got %s)" % angry.get("noseSneerLeft"))
_check(data.poses.has("Blink") and data.poses.has("EyeLeft"),
"anim-only poses present (Blink, EyeLeft)")
var anger: Dictionary = data.cycles.get("Anger", {})
_check(float(anger.get("length", 0.0)) > 1.0, "Anger cycle has a duration (got %s)" % anger.get("length"))
_check(FileAccess.file_exists(SKResourceCache.cache_dir().path_join(SKFacePoses.LIBRARY_FILE)),
"animation library resource written")
func _build_character() -> Node3D:
var recipe := SKRecipe.new()
for part in ["SK_HUMN_BASE_01_01HEAD_HU01", "SK_HUMN_BASE_01_13TORS_HU01"]:
if _library.has_part(part):
recipe.parts.append({ "name": part })
var builder := SKCharacterBuilder.new(_library, null)
return builder.build(recipe)
func _shape_value(character: Node3D, suffix: String) -> float:
for mesh: MeshInstance3D in character.find_children("*", "MeshInstance3D", true, false):
if mesh.mesh == null:
continue
for i in mesh.mesh.get_blend_shape_count():
if SKMeshCombiner.blend_shape_suffix(String(mesh.mesh.get_blend_shape_name(i))) == suffix:
return mesh.get_blend_shape_value(i)
return -1.0
func _test_controller(combined: bool) -> void:
print("[controller %s]" % ("combined" if combined else "separate"))
ProjectSettings.set_setting("kickdot/build/combine_meshes", combined)
var character := _build_character()
ProjectSettings.set_setting("kickdot/build/combine_meshes", false)
if character == null:
_check(false, "character build")
return
root.add_child(character)
var controller := SidekickFacialController.new()
controller.name = "FacialController"
character.add_child(controller)
await process_frame
controller.set("jaw_open_close", 1.0)
controller.apply()
_check(absf(_shape_value(character, "jawOpen") - 1.0) < 0.001, "jaw_open_close +1 opens jaw")
# The head has no jawClose shape, so test the bipolar split on back/forward.
controller.set("jaw_back_forward", -0.5)
controller.apply()
_check(absf(_shape_value(character, "jawBackward") - 0.5) < 0.001, "jaw_back_forward -0.5 drives jawBackward")
_check(absf(_shape_value(character, "jawForward")) < 0.001, "jaw_back_forward -0.5 keeps jawForward 0")
controller.set("eyel_blink", 1.0)
controller.apply()
_check(absf(_shape_value(character, "eyeBlinkUpperLeft") - 1.0) < 0.001
and absf(_shape_value(character, "eyeBlinkLowerLeft") - 1.0) < 0.001,
"eyel_blink drives both blink shapes")
controller.reset_all()
controller.apply()
_check(absf(_shape_value(character, "jawBackward")) < 0.001
and absf(_shape_value(character, "eyeBlinkUpperLeft")) < 0.001, "reset_all clears shapes")
root.remove_child(character)
character.free()
func _test_poses_and_eyes() -> void:
print("[poses + eye bones]")
var character := _build_character()
root.add_child(character)
var controller := SidekickFacialController.new()
controller.name = "FacialController"
character.add_child(controller)
await process_frame
controller.apply_pose(SKFacePoses.get_pose("Angry"))
controller.apply()
_check(absf(_shape_value(character, "noseSneerLeft") - 0.0665) < 0.005,
"Angry pose applies noseSneerLeft (got %.4f)" % _shape_value(character, "noseSneerLeft"))
# The inspector's Expression dropdown is a plain property.
controller.set("expression", "Happy")
controller.apply()
_check(_shape_value(character, "mouthSmileLeft") > 0.1, "expression property applies a pose")
_check(String(controller.get("expression")) == "Happy", "expression reads back")
controller.set("mth_funnel", 0.4)
_check(String(controller.get("expression")) == "Custom", "slider edit flips expression to Custom")
controller.set("expression", "Happy")
controller.apply()
var full := _shape_value(character, "mouthSmileLeft")
controller.set("expression_strength", 0.5)
controller.apply()
_check(absf(_shape_value(character, "mouthSmileLeft") - full * 0.5) < 0.001,
"expression_strength 0.5 halves the pose (%.3f -> %.3f)" % [full, _shape_value(character, "mouthSmileLeft")])
_check(String(controller.get("expression")) == "Happy", "strength change keeps the expression name")
controller.set("expression_strength", 1.0)
var skeleton: Skeleton3D = character.find_children("*", "Skeleton3D", true, false).front()
var eye := skeleton.find_bone("eye_l")
_check(eye >= 0, "eye_l bone exists")
if eye >= 0:
var rest := skeleton.get_bone_rest(eye).basis.get_rotation_quaternion()
controller.apply_pose(SKFacePoses.get_pose("EyeLeft"))
controller.apply()
var look_left := skeleton.get_bone_pose_rotation(eye)
var angle := rad_to_deg(rest.angle_to(look_left))
_check(absf(angle - 22.0) < 1.0, "EyeLeft rotates eye_l ~22 deg (got %.2f)" % angle)
_check(float(controller.get("eyel_left_right")) < -0.9,
"EyeLeft pose maps to negative left_right slider (got %s)" % controller.get("eyel_left_right"))
controller.reset_all()
controller.apply()
_check(skeleton.get_bone_pose_rotation(eye).angle_to(rest) < 0.001, "reset returns eye to rest")
# Gaze direction in world space: the character faces +Z, so look up/down must
# deflect Y and left/right must deflect X. Unity's asymmetric limits (22° out,
# 15° in) double as proof the left/right semantics aren't mirrored.
var global_rest := (skeleton.transform.basis * skeleton.get_bone_global_rest(eye).basis).orthonormalized()
var forward_local: Vector3 = global_rest.inverse() * Vector3(0, 0, 1)
var gaze := func() -> Vector3:
var delta := Basis(rest.inverse() * skeleton.get_bone_pose_rotation(eye))
return (global_rest * (delta * forward_local)).normalized()
var directions := {}
for pose_name in ["EyeDown", "EyeUp", "EyeLeft", "EyeRight"]:
controller.apply_pose(SKFacePoses.get_pose(pose_name))
controller.apply()
directions[pose_name] = gaze.call()
_check(directions["EyeDown"].y < -0.18, "EyeDown gaze deflects down (y=%.3f)" % directions["EyeDown"].y)
_check(directions["EyeUp"].y > 0.12, "EyeUp gaze deflects up (y=%.3f)" % directions["EyeUp"].y)
var left_x: float = directions["EyeLeft"].x
var right_x: float = directions["EyeRight"].x
_check(signf(left_x) != signf(right_x) and absf(left_x) > 0.3 and absf(right_x) > 0.2,
"EyeLeft/EyeRight deflect X oppositely (%.3f / %.3f)" % [left_x, right_x])
_check(absf(left_x) > absf(right_x), "look-out is wider than look-in (22 vs 15 deg)")
controller.reset_all()
controller.apply()
root.remove_child(character)
character.free()
func _test_animation_player() -> void:
print("[animation player]")
var library := SKFacePoses.build_animation_library()
var expected: int = 1 + SKFacePoses.load_data().poses.size() + SKFacePoses.load_data().cycles.size()
_check(library.get_animation_list().size() == expected,
"library has RESET + poses + cycles (%d)" % library.get_animation_list().size())
var happy := library.get_animation("Happy")
_check(happy != null and happy.get_track_count() == SidekickFacialController.slider_names().size(),
"pose animation tracks every slider")
var anger := library.get_animation("AngerCycle")
_check(anger != null and anger.loop_mode == Animation.LOOP_LINEAR and anger.length > 1.0,
"cycle animation loops with real duration")
var character := _build_character()
root.add_child(character)
var controller := SidekickFacialController.new()
controller.name = "FacialController"
character.add_child(controller)
var player := AnimationPlayer.new()
player.root_node = NodePath("..")
character.add_child(player)
player.add_animation_library("face", library)
await process_frame
player.play("face/Happy")
player.advance(0.0)
controller.apply()
var smile := _shape_value(character, "mouthSmileLeft")
_check(smile > 0.1, "playing Happy raises mouthSmileLeft (got %.3f)" % smile)
# The inspector button: creates or reuses a sibling player and loads the library.
var reused: AnimationPlayer = controller.setup_animation_player()
_check(reused == player, "setup_animation_player reuses an existing sibling player")
_check(reused.has_animation("face/Happy") and String(reused.root_node) == "..",
"reused player has the face library and character root")
player.queue_free()
await process_frame
var created: AnimationPlayer = controller.setup_animation_player()
_check(created != null and created.get_parent() == character
and created.has_animation("face/AngerCycle"), "setup_animation_player creates a player when missing")
root.remove_child(character)
character.free()
func _test_rebuild_resync() -> void:
print("[rebuild resync]")
var character := SidekickCharacter.new()
character.build_on_load = false
root.add_child(character)
character.recipe_text = FileAccess.get_file_as_string("res://synty_assets/characters/Starter_01.sk")
var controller := SidekickFacialController.new()
controller.name = "FacialController"
character.add_child(controller)
controller.set("eyel_blink", 1.0)
character.rebuild(_library)
_check(absf(_shape_value(character, "eyeBlinkUpperLeft") - 1.0) < 0.001,
"slider survives a character rebuild (got %.3f)" % _shape_value(character, "eyeBlinkUpperLeft"))
root.remove_child(character)
character.free()
func _test_preset_roundtrip() -> void:
print("[.fcp preset round trip]")
var controller := SidekickFacialController.new()
controller.set("mthl_frown_smile", 0.75)
controller.set("eyer_squint", 0.3)
controller.set("tong_curl_side_down_up", -0.5)
var path := "user://test_face.fcp"
_check(controller.save_preset(path) == OK, "preset saves")
var text := FileAccess.get_file_as_string(path)
_check(text.contains("mthlFrownSmileBlendValue:0.75"), "Unity field names written")
var other := SidekickFacialController.new()
_check(other.load_preset(path) == OK, "preset loads")
_check(absf(float(other.get("mthl_frown_smile")) - 0.75) < 0.001
and absf(float(other.get("eyer_squint")) - 0.3) < 0.001
and absf(float(other.get("tong_curl_side_down_up")) + 0.5) < 0.001, "values survive round trip")
controller.free()
other.free()
func _check(condition: bool, label: String) -> void:
print(" %s %s" % ["PASS" if condition else "FAIL", label])
if not condition:
_failures += 1