OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_anim_frame_rate.gd

161 lines · gd ref:
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)