extends SceneTree
## The procedural animation layer must advance at REAL time, not at frame rate.
##
## ⛔ WHY THIS EXISTS. procedural_animator.gd overrode the DEPRECATED
## SkeletonModifier3D._process_modification(), which is passed no delta, and
## invented one from get_physics_process_delta_time() -- a constant 1/60. But
## Skeleton3D.modifier_callback_mode_process is left at Godot's default (IDLE),
## so the callback fires once per RENDERED FRAME. Every spring, damper and lerp
## in that layer therefore ran at fps/60 of real time: gait measured 0.5519
## cycles/s at 25fps against 3.1941 at 144fps, for the SAME 8.00 m/s ground
## speed. Limbs 5.8x faster on a faster machine.
##
## Movement was never the problem (0.33% over 12 runs), which is why this gate
## asserts BOTH: gait rate AND ground speed, independently.
##
## "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_anim_frame_rate.gd
##
## ⛔ NOT IN ci.yaml, deliberately. It instantiates the FULL player scene, and in
## CI that scene does not come up intact:
## player_procedural.tscn: "a node in the scene this one inherits from has
## been removed or moved, so a recovery process needs to take place"
## Node 'Model/TumbleModel' ... has vanished
## Node not found: "Model/TumbleModel" (player_procedural.gd:407)
## CI runs from a `git archive` extraction and materialises binary assets from
## hyper-overlay pointers, and that model does not resolve there -- the same class
## of gap that makes teleporter.glb produce a null $Indicator in CI and not
## locally. Until that is fixed this gate fails for a reason that has nothing to
## do with what it tests, so it is a LOCAL gate.
const SLOW_FPS := 30
const FAST_FPS := 120
const WINDOW := 4.0
const GAIT_TOL := 0.15 ## the bug was 5.8x; 15% catches it with room for jitter
const DIST_TOL := 0.10
var ok := true
var _ran := false
var _player: Node3D = null
var _anim: Node = null
var _phase_prev := 0.0
var _phase_acc := 0.0
var _t0 := 0
var _p0 := Vector3.ZERO
var _measuring := false
var _results := {}
class StubGame extends Node3D:
var current_map: Node = null
func _p(c: bool, m: String) -> void:
print(("ok " if c else "FAIL: ") + m)
if not c:
ok = false
func _process(_d: float) -> bool:
if not _ran:
_ran = true
_build()
return false
func _build() -> void:
get_multiplayer().multiplayer_peer = OfflineMultiplayerPeer.new()
var game := StubGame.new()
game.name = "Game"
root.add_child(game)
var fb := StaticBody3D.new()
var fc := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(8000, 2, 8000)
fc.shape = box
fb.add_child(fc)
fb.position = Vector3(0, -1, 0)
game.add_child(fb)
var ps: PackedScene = load("res://scenes/player/player_procedural.tscn")
_p(ps != null, "player_procedural.tscn loads")
if ps == null:
_finish()
return
_player = ps.instantiate()
game.add_child(_player)
_player.global_position = Vector3(0, 1.5, 0)
_anim = _find_animator(_player)
_p(_anim != null, "the player carries a ProceduralAnimator")
if _anim == null:
_finish()
return
Input.action_press("move_forward")
physics_frame.connect(_tick)
create_timer(2.5).timeout.connect(_phase.bind(SLOW_FPS))
func _find_animator(n: Node) -> Node:
if n is ProceduralAnimator:
return n
for c in n.get_children():
var r: Node = _find_animator(c)
if r != null:
return r
return null
func _phase(fps: int) -> void:
Engine.max_fps = fps
# settle at the new rate before measuring
create_timer(1.0).timeout.connect(_begin.bind(fps))
func _begin(fps: int) -> void:
_phase_prev = float(_anim.get("_cycle_phase"))
_phase_acc = 0.0
_p0 = _player.global_position
_t0 = Time.get_ticks_usec()
_measuring = true
create_timer(WINDOW).timeout.connect(_end.bind(fps))
func _tick() -> void:
if not _measuring or _anim == null:
return
var ph: float = float(_anim.get("_cycle_phase"))
var d: float = ph - _phase_prev
if d < -0.5:
d += 1.0
elif d > 0.5:
d -= 1.0
_phase_acc += d
_phase_prev = ph
func _end(fps: int) -> void:
_measuring = false
var dt: float = float(Time.get_ticks_usec() - _t0) / 1000000.0
var flat: Vector3 = _player.global_position - _p0
flat.y = 0.0
_results[fps] = {
"gait": _phase_acc / dt,
"dist": flat.length() / dt,
"fps": Engine.get_frames_per_second(),
}
print(" %d fps cap -> gait %.4f cycles/s, ground %.4f m/s (measured fps %.1f)" % [
fps, _results[fps]["gait"], _results[fps]["dist"], _results[fps]["fps"]])
if fps == SLOW_FPS:
_phase(FAST_FPS)
else:
_compare()
func _compare() -> void:
var a: Dictionary = _results[SLOW_FPS]
var b: Dictionary = _results[FAST_FPS]
var gait_ratio: float = b["gait"] / maxf(a["gait"], 0.0001)
var dist_ratio: float = b["dist"] / maxf(a["dist"], 0.0001)
print(" gait ratio %.3f, ground ratio %.3f (1.000 = frame-rate independent)" % [gait_ratio, dist_ratio])
_p(absf(gait_ratio - 1.0) <= GAIT_TOL,
"the procedural layer advances at REAL time, not frame rate (gait ratio %.3f within %.2f) -- if this fails, something is inventing a delta instead of taking _process_modification_with_delta's" % [gait_ratio, GAIT_TOL])
_p(absf(dist_ratio - 1.0) <= DIST_TOL,
"and ground speed is frame-rate independent too (ratio %.3f within %.2f)" % [dist_ratio, DIST_TOL])
_finish()
func _finish() -> void:
Input.action_release("move_forward")
print("GATE anim_frame_rate: %s" % ("PASS" if ok else "FAIL"))
quit(0 if ok else 1)