OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / large_coord_physics_gate.gd

340 lines · gd ref:
extends SceneTree
## Does the ENGINE's character physics and the shipping SphereSweep still work at
## the coordinates a 917 km world puts you at?
##
## WHY THIS GATE EXISTS. Two symptoms were reported on the big maps (DC Erosion,
## the planetoid): the player gets stuck and cannot travel, and the foot IK is
## garbled. Those look like two bugs. They share one candidate cause -- float32
## coordinate magnitude -- and that candidate is testable WITHOUT any terrain,
## which is the whole point of this gate: it changes ONE variable, the world
## coordinate, against ground whose exact height is known analytically.
##
## float32 has a 24-bit significand, so the spacing between representable values
## at |p| is about |p| * 2^-23:
##
##     |p| =      0 m      exact
##     |p| =  1,000 m      0.00006 m
##     |p| = 10,000 m      0.0010  m   <- equals CharacterBody3D's default safe_margin
##     |p| = 39,000 m      0.0039  m   <- the DC Erosion spawn
##     |p| = 458,000 m     0.0625  m   <- the world edge; larger than the IK sweep sphere
##
## CharacterBody3D depenetrates using safe_margin (default 0.001 m) and FootIK
## sweeps a 0.05 m sphere over a 0.8 m span. Both are BELOW the representable
## spacing out there, so the prediction is: travel per second falls short, resting
## height jitters, and the sweep's hit point stops agreeing with the plane.
##
## THE INSTRUMENT IS A LAYER TOO. Offset 0 is the known-good input: if the numbers
## are not clean there, the ruler is broken and nothing below it means anything.
##
## TWO GROUNDS, because the shipping ground is not a box. DCCollisionTrimesh
## (dc_collision_trimesh.h:172-191) builds a ConcavePolygonShape3D whose faces are
## CHUNK-LOCAL and puts the world offset in the BODY TRANSFORM. A BoxShape3D at a
## global position is the other arrangement. If they differ, the difference is
## itself the finding -- it says where the precision is actually lost.
##
## Run:
##   "$HYPER_TPS_GODOT" --headless --script res://scenes/test/large_coord_physics_gate.gd

# Capsule + body settings copied from player_procedural.tscn (lines 13-15, 41-43,
# 47-49). Not approximated -- a body with different dimensions is a different
# experiment.
const CAP_RADIUS := 0.3
const CAP_HEIGHT := 1.58
const CAP_Y := 0.79
const FLOOR_MAX_ANGLE := 1.047
const FLOOR_SNAP := 0.2

# FootIK's sweep geometry (foot_ik.gd:20-23).
const SPHERE_RADIUS := 0.05
const SWEEP_UP := 0.2
const SWEEP_DOWN := 0.6
const GROUND_OFFSET := 0.02

const DRIVE_SPEED := 8.0        # run_speed, player_procedural.gd:95
const SETTLE_FRAMES := 60
const DRIVE_FRAMES := 120
const PROBE_SAMPLES := 16

# The cases. (31337, 9700, -21500) is the ACTUAL DC Erosion spawn column
# (map_dc_erosion.gd:180 + the 9700 m authored in map_dc_erosion.tscn:33), not a
# round number chosen to look like one.
var _cases: Array = [
	{"name": "origin        ", "pos": Vector3(0, 0, 0)},
	{"name": "1 km          ", "pos": Vector3(1000, 300, 1000)},
	{"name": "10 km         ", "pos": Vector3(10000, 3000, 10000)},
	{"name": "EROSION SPAWN ", "pos": Vector3(31337, 9700, -21500)},
	{"name": "100 km        ", "pos": Vector3(100000, 10000, 100000)},
	{"name": "world edge    ", "pos": Vector3(458000, 15000, 458000)},
]

var _ground_kinds: Array = ["box", "trimesh"]

var _kind_idx: int = 0
var _case_idx: int = 0
var _phase: int = 0          # 0 settle, 1 drive, 2 report
var _frame: int = 0
var _body: CharacterBody3D = null
var _ground: StaticBody3D = null
var _plane_y: float = 0.0
var _drive_start: Vector3 = Vector3.ZERO
var _settle_y_min: float = 0.0
var _settle_y_max: float = 0.0
var _floor_frames: int = 0
var _rows: Array = []
var _tick: float = 1.0 / 60.0


func _initialize() -> void:
	_tick = 1.0 / float(Engine.physics_ticks_per_second)
	print("[gate] physics tick = %.5f s, drive = %d frames, expected travel = %.3f m"
			% [_tick, DRIVE_FRAMES, DRIVE_SPEED * DRIVE_FRAMES * _tick])
	print("[gate] double precision build: %s" % str(_is_double()))
	_arith_control()
	_build_case()


## Pure coordinate arithmetic -- no physics, no collision. If a 0.133 m step
## survives here it is representable at that magnitude, and any shortfall the
## physics rows show is the PHYSICS, not the number.
func _arith_control() -> void:
	print("")
	print("[arith] adding %.5f m per frame, %d frames, no physics" % [DRIVE_SPEED * _tick, DRIVE_FRAMES])
	for c in _cases:
		var base: Vector3 = c["pos"]
		var p: Vector3 = base
		var n: int = 0
		while n < DRIVE_FRAMES:
			p.x += DRIVE_SPEED * _tick
			n += 1
		var moved: float = p.x - base.x
		var expect: float = DRIVE_SPEED * float(DRIVE_FRAMES) * _tick
		print("[arith] %s moved %.5f m of %.5f m  (%.2f%%)" % [c["name"], moved, expect, 100.0 * moved / expect])
	print("")


func _is_double() -> bool:
	# 1.0 + 2^-30 survives a round trip only in float64. GDScript floats are always
	# 64-bit; what this asks is whether the ENGINE was built with real_t = double,
	# which is what the physics server actually uses.
	if Engine.has_method("is_editor_hint"):
		pass
	var v: Variant = ProjectSettings.get_setting("application/config/features")
	if v == null:
		return false
	return str(v).contains("Double Precision")


func _build_case() -> void:
	var kind: String = _ground_kinds[_kind_idx]
	var base: Vector3 = _cases[_case_idx]["pos"]
	_plane_y = base.y

	_ground = StaticBody3D.new()
	var cs := CollisionShape3D.new()
	if kind == "box":
		# A single global-space box. The shape carries the world position.
		var box := BoxShape3D.new()
		box.size = Vector3(400.0, 4.0, 400.0)
		cs.shape = box
		_ground.add_child(cs)
		root.add_child(_ground)
		_ground.global_position = Vector3(base.x, base.y - 2.0, base.z)
	else:
		# The SHIPPING arrangement: chunk-local faces, world offset in the body
		# transform. Mirrors dc_collision_trimesh.h:176-191.
		var faces := PackedVector3Array()
		var n := 30
		var step := 4.0
		var i := 0
		while i < n:
			var j := 0
			while j < n:
				var x0 := float(i) * step
				var z0 := float(j) * step
				var x1 := x0 + step
				var z1 := z0 + step
				faces.push_back(Vector3(x0, 0.0, z0))
				faces.push_back(Vector3(x0, 0.0, z1))
				faces.push_back(Vector3(x1, 0.0, z1))
				faces.push_back(Vector3(x0, 0.0, z0))
				faces.push_back(Vector3(x1, 0.0, z1))
				faces.push_back(Vector3(x1, 0.0, z0))
				j += 1
			i += 1
		var tri := ConcavePolygonShape3D.new()
		# BOTH SIDES. A one-sided concave shape makes winding a second variable,
		# and the first run of this gate fell through the patch at EVERY offset
		# including the origin -- which is the rig failing, not the coordinate.
		tri.backface_collision = true
		tri.set_faces(faces)
		cs.shape = tri
		_ground.add_child(cs)
		root.add_child(_ground)
		# Centre the patch on the case position, exactly as the chunk origin does.
		_ground.global_position = Vector3(base.x - float(n) * step * 0.5, base.y,
				base.z - float(n) * step * 0.5)

	_body = CharacterBody3D.new()
	_body.floor_max_angle = FLOOR_MAX_ANGLE
	_body.floor_snap_length = FLOOR_SNAP
	_body.floor_constant_speed = true
	var bcs := CollisionShape3D.new()
	var cap := CapsuleShape3D.new()
	cap.radius = CAP_RADIUS
	cap.height = CAP_HEIGHT
	bcs.shape = cap
	bcs.position = Vector3(0, CAP_Y, 0)
	_body.add_child(bcs)
	root.add_child(_body)
	_body.global_position = Vector3(base.x, base.y + 1.0, base.z)

	_phase = 0
	_frame = 0
	_floor_frames = 0
	_settle_y_min = 1e30
	_settle_y_max = -1e30


func _physics_process(_delta: float) -> bool:
	if _body == null:
		return true

	if _phase == 0:
		_body.velocity = Vector3(0.0, -4.0, 0.0)
		_body.move_and_slide()
		if _body.is_on_floor():
			var y: float = _body.global_position.y
			if y < _settle_y_min:
				_settle_y_min = y
			if y > _settle_y_max:
				_settle_y_max = y
		_frame += 1
		if _frame >= SETTLE_FRAMES:
			_phase = 1
			_frame = 0
			_drive_start = _body.global_position
		return false

	if _phase == 1:
		_body.velocity = Vector3(DRIVE_SPEED, -1.0, 0.0)
		_body.move_and_slide()
		if _body.is_on_floor():
			_floor_frames += 1
		_frame += 1
		if _frame >= DRIVE_FRAMES:
			_measure()
			_teardown()
			if not _advance():
				_report()
				return true
			_build_case()
		return false

	return true


func _measure() -> void:
	var kind: String = _ground_kinds[_kind_idx]
	var cname: String = _cases[_case_idx]["name"]
	var base: Vector3 = _cases[_case_idx]["pos"]
	var travelled: float = (_body.global_position - _drive_start).length()
	var expected: float = DRIVE_SPEED * float(DRIVE_FRAMES) * _tick
	var jitter: float = 0.0
	if _settle_y_max > -1e29:
		jitter = _settle_y_max - _settle_y_min

	# ---- FootIK-scale sweep, using the SHIPPING SphereSweep ----
	var space: PhysicsDirectSpaceState3D = _body.get_world_3d().direct_space_state
	var excludes: Array[RID] = [_body.get_rid()]
	var miss: int = 0
	var err_max: float = 0.0
	var err_sum: float = 0.0
	var k: int = 0
	while k < PROBE_SAMPLES:
		var ang: float = TAU * float(k) / float(PROBE_SAMPLES)
		var p := Vector3(base.x + cos(ang) * 0.4, base.y + 0.05, base.z + sin(ang) * 0.4)
		var res: Dictionary = SphereSweep.sweep(space, p + Vector3.UP * SWEEP_UP,
				p - Vector3.UP * SWEEP_DOWN, SPHERE_RADIUS, excludes)
		if res.is_empty():
			miss += 1
		else:
			var hit: Vector3 = res["point"]
			var e: float = hit.y - _plane_y
			err_sum += e
			if absf(e) > absf(err_max):
				err_max = e
		k += 1
	var hits: int = PROBE_SAMPLES - miss
	var err_mean: float = 0.0
	if hits > 0:
		err_mean = err_sum / float(hits)

	print("[case] %s %s  start=%s  end=%s" % [kind, cname, str(_drive_start), str(_body.global_position)])
	var ulp: float = maxf(maxf(absf(base.x), absf(base.y)), absf(base.z)) * pow(2.0, -23.0)
	_rows.push_back({
		"kind": kind, "name": cname, "ulp": ulp,
		"travel": travelled, "expected": expected,
		"pct": 100.0 * travelled / expected,
		"jitter": jitter, "floor_pct": 100.0 * float(_floor_frames) / float(DRIVE_FRAMES),
		"ik_miss": miss, "ik_err_mean": err_mean, "ik_err_max": err_max,
	})


func _teardown() -> void:
	if _body != null:
		_body.queue_free()
		_body = null
	if _ground != null:
		_ground.queue_free()
		_ground = null


func _advance() -> bool:
	_case_idx += 1
	if _case_idx < _cases.size():
		return true
	_case_idx = 0
	_kind_idx += 1
	return _kind_idx < _ground_kinds.size()


## The origin row of the same ground is the reference. Whatever constant offset
## get_rest_info reports for a sphere resting on a plane, it reports it at the
## origin too -- so only the DEPARTURE from that is evidence.
func _ik_baseline(kind: String) -> float:
	for r in _rows:
		if r["kind"] == kind and r["name"] == _cases[0]["name"]:
			return r["ik_err_max"]
	return 0.0


func _report() -> void:
	print("")
	print("ground   case            float32 ulp   travel/exp   travel     y-jitter   on_floor   IK miss   IK err mean   IK err max")
	print("---------------------------------------------------------------------------------------------------------------------")
	for r in _rows:
		print("%-8s %s  %10.5f m   %6.1f%%   %7.3f m   %8.4f m   %5.1f%%   %2d/%d      %9.5f m   %9.5f m" % [
			r["kind"], r["name"], r["ulp"], r["pct"], r["travel"],
			r["jitter"], r["floor_pct"], r["ik_miss"], PROBE_SAMPLES,
			r["ik_err_mean"], r["ik_err_max"]])
	print("")
	# Pass/fail is stated against the ORIGIN row of the same ground, not against an
	# absolute -- so a quirk of the rig cannot read as a coordinate effect.
	var fails: int = 0
	for r in _rows:
		var bad: bool = false
		if r["pct"] < 99.0:
			bad = true
		if r["ik_miss"] > 0:
			bad = true
		if absf(r["ik_err_max"] - _ik_baseline(r["kind"])) > SPHERE_RADIUS * 0.2:
			bad = true
		if bad:
			fails += 1
			print("FAIL  %s %s  travel %.1f%%  IK miss %d  IK err max %.5f m"
					% [r["kind"], r["name"], r["pct"], r["ik_miss"], r["ik_err_max"]])
	if fails == 0:
		print("PASS  every case travels and sweeps cleanly -- coordinate magnitude is NOT the cause")
	else:
		print("%d of %d cases failed" % [fails, _rows.size()])