OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / procedural / sphere_sweep.gd

121 lines · gd ref:
class_name SphereSweep extends RefCounted
## Shared sphere sweep utility. Wraps cast_motion + get_rest_info with
## Overgrowth-style sliding sphere refinement for accurate surface normals.
## Caches SphereShape3D by radius to avoid per-call allocations.
##
## WHY THERE IS A NUDGE, AND WHY THE FALLBACK CHANGED (2026-08-31).
##
## cast_motion returns [safe, unsafe]; the unsafe fraction puts the sphere at
## TANGENCY with the surface -- touching, with zero overlap. get_rest_info at
## exact tangency is a knife edge, and a pose is only representable to within one
## float32 ulp of its coordinate. High ALTITUDE is what breaks it: at y = 9700 m
## an ulp is 0.00097 m, the tangent pose lands on the separated side, and
## get_rest_info returns EMPTY. Horizontal distance does not do this -- measured
## clean at 100 km in X.
##
## The old fallback then returned a point 2r ABOVE the surface (a sign error:
## `contact_pos - motion.normalized()*radius` moves BACK along the sweep, away
## from the thing that was hit) and a normal of `-motion.normalized()`, i.e.
## straight up for a downward sweep. FootIK therefore lifted every foot 0.1 m --
## five times its own GROUND_OFFSET -- and never tilted a foot to the ground.
## WallClimb and player_procedural._detect_walls read the same invented normal.
##
## Gate: scenes/test/large_coord_ik_probe.gd (sloped ground, because on FLAT
## ground the invented straight-up normal is accidentally correct and a flat
## gate would certify it).

static var _shape_cache: Dictionary = {}  ## radius → SphereShape3D
static var _params: PhysicsShapeQueryParameters3D = null
## Every sweep() ever made in this process. Callers that want THEIR share diff it around
## their own work (SimStats.add, hyper-games #114 §7) — feet, crawlers and hoppers
## all come through here.
static var calls: int = 0

## Sweep a sphere from `from` to `to`. Returns { "point", "normal", "fraction" } or empty.
static func sweep(space: PhysicsDirectSpaceState3D, from: Vector3, to: Vector3,
		radius: float, exclude: Array[RID] = [], collision_mask: int = 0xFFFFFFFF) -> Dictionary:
	calls += 1
	var motion: Vector3 = to - from

	# Cache shapes by radius (snapped to 3 decimals to avoid unbounded growth)
	var key: float = snappedf(radius, 0.001)
	var shape: SphereShape3D
	if _shape_cache.has(key):
		shape = _shape_cache[key]
	else:
		shape = SphereShape3D.new()
		shape.radius = key
		_shape_cache[key] = shape

	# Reuse params object
	if _params == null:
		_params = PhysicsShapeQueryParameters3D.new()
	_params.shape = shape
	_params.transform = Transform3D(Basis.IDENTITY, from)
	_params.motion = motion
	_params.collision_mask = collision_mask
	if not exclude.is_empty():
		_params.exclude = exclude
	else:
		_params.exclude = []

	var fractions: PackedFloat32Array = space.cast_motion(_params)
	if fractions.is_empty() or fractions[1] >= 1.0:
		return {}

	var contact_t: float = fractions[1]
	var contact_pos: Vector3 = from + motion * contact_t
	var dir: Vector3 = motion.normalized()
	var nudge: float = _nudge(from, radius)

	# Try the tangent pose first, so anywhere this already worked is untouched.
	# Only when it comes back empty do we push the sphere into the surface far
	# enough that the overlap survives the coordinate's own quantisation.
	_params.transform = Transform3D(Basis.IDENTITY, contact_pos)
	var rest: Dictionary = space.get_rest_info(_params)
	if rest.is_empty():
		_params.transform = Transform3D(Basis.IDENTITY, contact_pos + dir * nudge)
		rest = space.get_rest_info(_params)

	if rest.is_empty():
		# Last resort. The surface is one radius FORWARD along the sweep from the
		# tangent centre, not backward. The normal here is a guess -- the reverse
		# of the sweep direction -- and callers that care about surface tilt
		# (FootIK, WallClimb) are being told something invented, so this branch
		# should stay rare rather than become the normal path.
		return {
			"point": contact_pos + dir * radius,
			"normal": -dir,
			"fraction": contact_t
		}

	var hit_point: Vector3 = rest["point"]
	var hit_normal: Vector3 = rest["normal"]

	# Sliding sphere refinement for accurate surface normal. Seat the sphere just
	# INSIDE the surface for the same reason as above: `hit_point + normal*radius`
	# is the tangent pose again, and at altitude it returns empty and the
	# refinement silently does nothing.
	var slide_pos: Vector3 = hit_point + hit_normal * (radius - nudge)
	_params.transform = Transform3D(Basis.IDENTITY, slide_pos)
	var slide_rest: Dictionary = space.get_rest_info(_params)
	if not slide_rest.is_empty():
		hit_normal = slide_rest["normal"]

	return {
		"point": hit_point,
		"normal": hit_normal,
		"fraction": contact_t
	}


## How far to push the sphere past tangency. Scaled to the coordinate, because
## that is what sets the representable step: float32 has a 24-bit significand, so
## the spacing at |p| is |p| * 2^-23. Four of those clears the knife edge.
## Floored so it is never zero near the origin, and capped at half the radius so
## the sphere cannot be pushed clean through a thin feature.
static func _nudge(at: Vector3, radius: float) -> float:
	var scale: float = maxf(maxf(absf(at.x), absf(at.y)), absf(at.z))
	var ulp: float = scale * 1.1920929e-7
	return clampf(ulp * 4.0, radius * 0.02, radius * 0.5)