OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_stand_wobble.gd

234 lines · gd ref:
extends SceneTree
## GATE: a STANDING player does not wobble. Was: does a STANDING player wobble, how much, at which layer, and does it
## depend on frame rate? (hyper-games roadmap #93: "when the player stands it
## wobbles often, much more on the lower end system" -- ashatron runs ~40 fps.)
##
##   DISPLAY=:0 "$G" --path . --rendering-driver vulkan --script res://scenes/test/gate_stand_wobble.gd
##
## The full player scene stands still, no input, on FLAT ground and on a 20 degree
## SLOPE, at each frame-rate cap in CAPS. Sampled once per RENDERED frame on
## RenderingServer.frame_pre_draw -- after every _process and skeleton modifier,
## i.e. what is drawn. Per case, over WINDOW s after SETTLE s:
##   body      -- the CharacterBody3D origin: what physics does (60 Hz ticks)
##   hip/head  -- bone world positions RELATIVE TO THE BODY: what the animator adds
##   jitter    -- RMS of the frame-to-frame second difference (mm): motion that
##                reverses direction from one frame to the next, i.e. shaking, not
##                the slow idle sway (which has a near-zero second difference)
##   p2p       -- peak-to-peak (mm) over the window
##   |tilt|    -- the animator's acceleration-tilt magnitude (degrees), max
##   floor     -- how many frames is_on_floor() changed value
const CAPS := [25, 40, 60, 144]
const SLOPES := [0.0, 20.0]
## Variants isolate a contributor: "all" = shipping; "no_look_ik" = chest+head IK
## off (their look rotation feeds Phase 6's COM balance offset onto the root).
const VARIANTS := ["all"]   # add "no_look_ik" to isolate the look IK
const SETTLE := 2.5
## PASS bounds, per case (shipping variant). RED before the fixes (same instrument,
## 2026-10-01): hip accel 2.84-16.45 m/s^2, p2p 66-196 mm, feet sliding with it --
## a 60x-stiff idle spring (k*frames, not k*dt), a 1 cm dead zone that popped the
## body at every zero crossing, and a sway read in metres instead of a fraction of
## the stance. GREEN after: 0.01-0.03 m/s^2, 8-28 mm, every cap 25-144.
const MAX_ACCEL := 0.5      # m/s^2 RMS -- smooth idle sway is ~0.03
const MAX_P2P_MM := 40.0    # Overgrowth's sway is ~+/-1.5 cm
## THE FEET STAY PLANTED while the body sways over them -- Overgrowth's DrawLeg
## solves each leg to its unchanged foot key. Before the leg solver the hip shift
## carried both feet with it (foot p2p == hip p2p, 9-28 mm).
const MAX_FOOT_MM := 3.0
var _fails := 0
const WINDOW := 4.0

var _player: CharacterBody3D = null
var _anim: Node = null
var _skel: Skeleton3D = null
var _floor: StaticBody3D = null
var _cases: Array = []
var _ci := -1
var _rec := false
var _t_end := 0
var _s: Dictionary = {}
var _ran := false

class StubGame extends Node3D:
	var current_map: Node = null


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 cam := Camera3D.new()
	game.add_child(cam)
	cam.position = Vector3(0, 3, 6)
	_floor = StaticBody3D.new()
	var fc := CollisionShape3D.new()
	var box := BoxShape3D.new()
	box.size = Vector3(400, 2, 400)
	fc.shape = box
	_floor.add_child(fc)
	game.add_child(_floor)
	var ps: PackedScene = load("res://scenes/player/player_procedural.tscn")
	_player = ps.instantiate()
	game.add_child(_player)
	_anim = _find(_player, "ProceduralAnimator")
	_skel = _find_skel(_player)
	if _anim == null or _skel == null:
		print("PROBE: no animator/skeleton"); quit(1); return
	var only := OS.get_environment("WOBBLE_VARIANTS")
	for v in VARIANTS:
		if not only.is_empty() and not (v in only.split(",")):
			continue
		for sl in SLOPES:
			for cap in CAPS:
				_cases.append([sl, cap, v])
	# skeleton_updated, NOT frame_pre_draw: the bone poses carry the modifiers'
	# output only from here until the next modifier pass. Read anywhere else they
	# are the un-modified pose -- the first run read a hip identical to 6 digits in
	# every case, idle sway and all.
	_skel.skeleton_updated.connect(_sample)
	_next()


func _find(n: Node, nm: String) -> Node:
	if n.name == nm:
		return n
	for c in n.get_children():
		var r := _find(c, nm)
		if r != null:
			return r
	return null


func _find_skel(n: Node) -> Skeleton3D:
	if n is Skeleton3D:
		return n
	for c in n.get_children():
		var r := _find_skel(c)
		if r != null:
			return r
	return null


func _next() -> void:
	_ci += 1
	if _ci >= _cases.size():
		print("RESULT: %s" % ("PASS" if _fails == 0 else "FAIL"))
		quit(0 if _fails == 0 else 1)
		return
	var sl: float = _cases[_ci][0]
	var cap: int = _cases[_ci][1]
	Engine.max_fps = cap
	var variant: String = _cases[_ci][2]
	_anim.set("enable_chest_ik", variant != "no_look_ik")
	_anim.set("enable_head_ik", variant != "no_look_ik")
	# Slope: tilt the floor about X; the player stands on it near the origin.
	_floor.rotation = Vector3(deg_to_rad(sl), 0, 0)
	_floor.position = Vector3(0, -1.0 / cos(deg_to_rad(sl)), 0)
	_player.global_position = Vector3(0, 0.3, 0)
	_player.velocity = Vector3.ZERO
	_s = {"body": [], "hip": [], "head": [], "footl": [], "tilt": 0.0, "floor_flips": 0,
			"last_floor": -1, "vmax": 0.0, "frames": 0, "extra": [], "footr": []}
	_rec = false
	create_timer(SETTLE).timeout.connect(func() -> void:
		# THE INSTRUMENT FIRST: all-zero output is indistinguishable from a probe
		# reading a frozen copy, so say what it is looking at.
		print("[WOBBLE] case slope %.0f cap %d: pos %s on_floor %s authority %s phys_proc %s anim_state %s hip %s skel_modifiers_active %s" % [
				sl, cap, str(_player.global_position), str(_player.is_on_floor()),
				str(_player.is_multiplayer_authority()), str(_player.is_physics_processing()),
				str(_anim.get("_anim_state")), str(_bone("DEF-spine")), str((_anim as SkeletonModifier3D).active)])
		_rec = true
		_t_end = Time.get_ticks_msec() + int(WINDOW * 1000.0))


func _bone(nm: String) -> Vector3:
	var i := _skel.find_bone(nm)
	return (_skel.global_transform * _skel.get_bone_global_pose(i)).origin if i >= 0 else Vector3.ZERO


func _sample() -> void:
	if not _rec or _ci >= _cases.size():
		return
	var body: Vector3 = _player.global_position
	_s["body"].append(body)
	_s["hip"].append(_bone("DEF-spine") - body)
	_s["head"].append(_bone("DEF-spine.006") - body)
	_s["footl"].append(_bone("DEF-foot.L") - body)
	_s["footr"].append(_bone("DEF-foot.R") - body)
	_s["tilt"] = maxf(float(_s["tilt"]), (_anim.get("_tilt") as Vector3).length())
	var fl := 1 if _player.is_on_floor() else 0
	if int(_s["last_floor"]) >= 0 and fl != int(_s["last_floor"]):
		_s["floor_flips"] = int(_s["floor_flips"]) + 1
	_s["last_floor"] = fl
	_s["vmax"] = maxf(float(_s["vmax"]), _player.velocity.length())
	_s["frames"] = int(_s["frames"]) + 1
	var co: Vector3 = _anim.get("_com_offset")
	_s["extra"].append([co.x, co.z, float(_anim.get("_idle_stance_amount")), int(_anim.get("_anim_state"))])
	if Time.get_ticks_msec() >= _t_end:
		_rec = false
		_report()
		_next.call_deferred()


## RMS of the second difference, mm: shaking, not drift.
func _jitter(a: Array) -> float:
	if a.size() < 3:
		return 0.0
	var acc := 0.0
	for i in range(1, a.size() - 1):
		acc += ((a[i + 1] as Vector3) - 2.0 * (a[i] as Vector3) + (a[i - 1] as Vector3)).length_squared()
	return sqrt(acc / float(a.size() - 2)) * 1000.0


func _p2p(a: Array) -> float:
	if a.is_empty():
		return 0.0
	var lo: Vector3 = a[0]
	var hi: Vector3 = a[0]
	for v: Vector3 in a:
		lo = lo.min(v)
		hi = hi.max(v)
	return (hi - lo).length() * 1000.0


func _report() -> void:
	var dump := OS.get_environment("WOBBLE_DUMP")
	if not dump.is_empty():
		var f := FileAccess.open("%s/wobble_%s_s%d_c%d.csv" % [dump, _cases[_ci][2], int(_cases[_ci][0]), int(_cases[_ci][1])], FileAccess.WRITE)
		f.store_line("i,hx,hy,hz,fx,fy,fz,com_x,com_z,idle,anim_state")
		for i in (_s["hip"] as Array).size():
			var h: Vector3 = _s["hip"][i]
			var ft: Vector3 = _s["footl"][i]
			var e: Array = _s["extra"][i]
			f.store_line("%d,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f,%.3f,%d" % [i, h.x, h.y, h.z, ft.x, ft.y, ft.z, e[0], e[1], e[2], e[3]])
	var sl: float = _cases[_ci][0]
	var cap: int = _cases[_ci][1]
	# jitter is per FRAME, so it scales with dt^2 for the same motion; divided by
	# dt^2 it is an acceleration (m/s^2) and comparable across frame-rate caps.
	var dt: float = WINDOW / maxf(float(_s["frames"]), 1.0)
	var acc := func(a: Array) -> float: return _jitter(a) / 1000.0 / (dt * dt)
	if _cases[_ci][2] == "all":
		var ha: float = acc.call(_s["hip"])
		var hp: float = _p2p(_s["hip"])
		if ha > MAX_ACCEL or hp > MAX_P2P_MM:
			_fails += 1
			print("[WOBBLE] FAIL: hip accel %.2f (max %.2f), p2p %.1f mm (max %.0f)" % [ha, MAX_ACCEL, hp, MAX_P2P_MM])
		var fl: float = _p2p(_s["footl"])
		var fr: float = _p2p(_s["footr"])
		if fl > MAX_FOOT_MM or fr > MAX_FOOT_MM:
			_fails += 1
			print("[WOBBLE] FAIL: feet slide %.1f / %.1f mm (max %.0f)" % [fl, fr, MAX_FOOT_MM])
	print("[WOBBLE] leg solves out of reach so far: %d (want 0)" % int(load("res://scenes/player/procedural/chain_solver.gd").get("clamped")))
	print("[WOBBLE] %-10s slope %2.0f cap %3d (%3d frames, %5.1f fps) | body p2p %5.1f mm | hip p2p %6.1f mm accel %5.2f m/s2 | head p2p %6.1f accel %5.2f | foot.L p2p %6.1f mm accel %5.2f | |tilt| max %5.2f | floor flips %d | |v| max %.3f" % [
			_cases[_ci][2], sl, cap, _s["frames"], _s["frames"] / WINDOW,
			_p2p(_s["body"]),
			_p2p(_s["hip"]), acc.call(_s["hip"]),
			_p2p(_s["head"]), acc.call(_s["head"]),
			_p2p(_s["footl"]), acc.call(_s["footl"]), _s["tilt"], _s["floor_flips"], _s["vmax"]])