diff --git a/scripts/fp/animals/cat_bodies.gd b/scripts/fp/animals/cat_bodies.gd index 50c10e7..fb226df 100644 --- a/scripts/fp/animals/cat_bodies.gd +++ b/scripts/fp/animals/cat_bodies.gd @@ -92,6 +92,8 @@ func _bind(b: DB.Body, scene: Node3D) -> void: b.head = b.skel.find_bone("Head") var rig := CAT_RIG.new() rig.name = "CatRig" + # The rig runs on the idle frame, not once per physics frame (people_pool.gd says why). + b.skel.modifier_callback_mode_process = Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE b.skel.add_child(rig) rig.bind(b.skel, scene, CAT_RIG.CAT_TAIL, "Neck") b.rig = rig diff --git a/scripts/fp/animals/dog_bodies.gd b/scripts/fp/animals/dog_bodies.gd index 5687f92..a73c137 100644 --- a/scripts/fp/animals/dog_bodies.gd +++ b/scripts/fp/animals/dog_bodies.gd @@ -176,6 +176,8 @@ func _bind(b: Body, scene: Node3D) -> void: b.head = b.skel.find_bone("Head") b.rig = RIG.new() b.rig.name = "DogRig" + # The rig runs on the idle frame, not once per physics frame (people_pool.gd says why). + b.skel.modifier_callback_mode_process = Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE b.skel.add_child(b.rig) b.rig.bind(b.skel, scene) diff --git a/scripts/fp/player/hero_look.gd b/scripts/fp/player/hero_look.gd index 61894da..8b136cd 100644 --- a/scripts/fp/player/hero_look.gd +++ b/scripts/fp/player/hero_look.gd @@ -120,6 +120,8 @@ func _modifier(n: String, bone: String, yaw_lim: float, pitch_lim: float) -> Loo m.transition_type = Tween.TRANS_SINE m.ease_type = Tween.EASE_IN_OUT m.origin_safe_margin = LOOK_SAFE_MARGIN + # Runs on the idle frame, not once per physics frame (people_pool.gd says why). + _skeleton.modifier_callback_mode_process = Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE _skeleton.add_child(m) return m diff --git a/scripts/fp/player/hero_strike_pose.gd b/scripts/fp/player/hero_strike_pose.gd index 60defff..0210c8e 100644 --- a/scripts/fp/player/hero_strike_pose.gd +++ b/scripts/fp/player/hero_strike_pose.gd @@ -160,6 +160,9 @@ func _init( _curl.append_array(fist_joints(p_sk, s)) _mod = Mod.new(self) _mod.name = "StrikePose" + # Runs on the idle frame, after the hand-advanced tree, not once per physics frame + # (the web build's catch-up steps would multiply that; people_pool.gd says more). + p_sk.modifier_callback_mode_process = Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE p_sk.add_child(_mod) _mod.active = false diff --git a/scripts/fp/traffic/people_pool.gd b/scripts/fp/traffic/people_pool.gd index e6345e8..205baef 100644 --- a/scripts/fp/traffic/people_pool.gd +++ b/scripts/fp/traffic/people_pool.gd @@ -212,6 +212,10 @@ func make(ai: int) -> Body: b.root.queue_free() return null b.head = b.skel.find_bone("Head") + # Skeleton modifiers (the T0 head look) run on the idle frame with the hand-advanced + # mixers; the engine default PHYSICS mode would pay them once per physics frame, + # multiplied by the web build's catch-up steps on slow frames. + b.skel.modifier_callback_mode_process = Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE for n: Node in b.scene.find_children("*", "GeometryInstance3D", true, false): b.meshes.append(n as GeometryInstance3D) var lib: AnimationLibrary = ppl._libs[a.gender] diff --git a/tools/tests/fp_animals_test.gd b/tools/tests/fp_animals_test.gd index 1adbff9..ca7a399 100644 --- a/tools/tests/fp_animals_test.gd +++ b/tools/tests/fp_animals_test.gd @@ -263,6 +263,18 @@ func _check_lead() -> WK.Walker: rigged and b.ap.has_animation(&"Gallop") and b.ap.has_animation(&"Attack"), "lead: the dog is the rigged %s with its clips and a DogRig" % d.breed ) + K.check( + fails, + ( + b.skel != null + and ( + b.skel.modifier_callback_mode_process + == Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE + ) + ), + "lead: the DogRig runs on the idle frame, not once per physics frame" + ) + K.check_modifier_modes(fails, get_root()) var worst := 0.0 var left := 0.0 var n := 0 diff --git a/tools/tests/fp_cats_test.gd b/tools/tests/fp_cats_test.gd index 231f88e..0ef5b19 100644 --- a/tools/tests/fp_cats_test.gd +++ b/tools/tests/fp_cats_test.gd @@ -334,6 +334,18 @@ func _check_spawn() -> LIFE.Cat: dressed and clips and c.body.rig is CB.CAT_RIG and not c.name.is_empty(), "spawn: %s, a %s %s cat in the cat's body and its coat" % [c.name, c.temper, c.coat] ) + K.check( + fails, + ( + c.body.skel != null + and ( + c.body.skel.modifier_callback_mode_process + == Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE + ) + ), + "spawn: the CatRig runs on the idle frame, not once per physics frame" + ) + K.check_modifier_modes(fails, get_root()) cats.scan(ctx.player.position, false, true, {}) var gone := _cat() == null _step(AF.SCAN_S + 0.1) diff --git a/tools/tests/fp_kit.gd b/tools/tests/fp_kit.gd index dac8ca5..4b5c3b9 100644 --- a/tools/tests/fp_kit.gd +++ b/tools/tests/fp_kit.gd @@ -426,6 +426,27 @@ static func check(fails: Array, ok: bool, what: String) -> void: fails.append(what) +## Every SkeletonModifier3D under root rides a skeleton in the IDLE modifier callback mode: +## the engine default PHYSICS mode pays each modifier once per physics frame, multiplied by +## the web build's catch-up steps on slow frames (the day-10 tick-frame attribution). +static func check_modifier_modes(fails: Array, root: Node) -> void: + var mods := root.find_children("*", "SkeletonModifier3D", true, false) + var bad := 0 + for m: Node in mods: + var sk := m.get_parent() as Skeleton3D + var idle := ( + sk != null + and sk.modifier_callback_mode_process == Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE + ) + if not idle: + bad += 1 + check( + fails, + bad == 0, + "every SkeletonModifier3D runs on the idle frame (%d of %d off)" % [bad, mods.size()] + ) + + ## Input events for a feature's _unhandled_input: action a pressed. static func action_event(a: String) -> InputEventAction: var e := InputEventAction.new() diff --git a/tools/tests/fp_people_prewarm_test.gd b/tools/tests/fp_people_prewarm_test.gd index 58d61a1..15aad14 100644 --- a/tools/tests/fp_people_prewarm_test.gd +++ b/tools/tests/fp_people_prewarm_test.gd @@ -60,4 +60,17 @@ func _init() -> void: pe.get("scene_loads") == settled, "a seat after warming initiates no load (still %d)" % settled ) + + # The body a seat lends runs its skeleton modifiers (the T0 head look) on the idle frame + # with the hand-advanced mixers, not once per physics frame (web catch-up multiplies that). + var seated: Array = (pe.get("_pool") as RefCounted).get("_seated") + var sk := (seated[0].get("skel") if not seated.is_empty() else null) as Skeleton3D + K.check( + fails, + ( + sk != null + and sk.modifier_callback_mode_process == Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE + ), + "a seated body's modifiers run on the idle frame, not once per physics frame" + ) K.finish(self, "FP_PEOPLE_PREWARM_TEST", fails) diff --git a/tools/tests/fp_player_test.gd b/tools/tests/fp_player_test.gd index 74da3b9..0a932ac 100644 --- a/tools/tests/fp_player_test.gd +++ b/tools/tests/fp_player_test.gd @@ -63,6 +63,7 @@ func _init() -> void: _check_thin_wall(ctx) _check_stuck(ctx) _check_cam_trunk(ctx) + _check_hero_modifier_mode(ctx) K.finish(self, "FP_PLAYER_TEST", fails) @@ -1486,3 +1487,20 @@ func _check_cam_trunk(ctx: FpContext) -> void: "a trunk where the camera sat: inside it or looking through it on %d of 240 frames" % bad ) K.check(fails, vaultable.is_empty(), "parkour's boxes_near still leaves the trunk out") + + +## The hero's strike-pose and head-look modifiers run on the idle frame, after the +## hand-advanced AnimationTree, not once per physics frame (the web build's catch-up steps +## would multiply that). +func _check_hero_modifier_mode(ctx: FpContext) -> void: + var rig: Variant = (ctx.player as PL).avatar.get("rig") + var sk := (rig.get("skeleton") if rig != null else null) as Skeleton3D + K.check( + fails, + ( + sk != null + and sk.modifier_callback_mode_process == Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE + ), + "hero: the skeleton's modifiers run on the idle frame, not once per physics frame" + ) + K.check_modifier_modes(fails, get_root())