fix(physics): make upright a real state, and actually start ships on the floor

Six rounds of reward shaping (~700M steps) failed to produce upright ground
driving. A critical review of the simulation rather than the reward found
why:

1. The hull was a 1x1x4 box with inertia (1,1,1) and no restoring torque
   anywhere, so belly-down and rolled-90 were geometrically identical
   resting states. "Upright" was not a physically distinguished state at
   all - the reward was paying for a property the simulation did not have.
2. ~65% of episodes spawned ships via _random_position, which samples Y
   uniformly over the full 18m volume (mean ~8.7m). The measured
   airborne_fraction ~0.44 was largely that spawn distribution, and every
   ground-handling term fades out above 3m, so the shaping being tuned
   barely ever applied.
3. air_drill_chance 0.20 spawned deliberately unreachable-without-climbing
   states in the stage meant to teach ground driving, and its own
   air_touch_fraction (0.0002) shows the drills were never solved.

Fixes land in the physics and the task distribution, not the reward:
- ship.tscn: hull 1x1x4 -> 1.6x0.6x4 so it has one stable resting face;
  inertia (1,1,1) -> (7,1,7), physically correct for the hull, making
  tumbling reluctant while keeping yaw snappy.
- ship.gd: new altitude-faded righting torque (spring-damper toward
  belly-down, faded out by 3m so aerials keep full attitude freedom).
  This is the grav-plating analogue of Rocket League's auto-righting and
  helps human pilots land cleanly too.
- training_mode.gd: new ground_start_chance branch spawning ships level and
  resting on the floor with a floor-level ball - the state the handling
  stage's rewards are actually written for.
- generation5.py: ground-start-chance 0.50, air-drill-chance 0.20 -> 0.0.

Reward terms are left exactly as they were; they should finally pull in a
direction the ship can go.
This commit is contained in:
Josh Creek
2026-08-16 08:17:16 +01:00
parent 033a774c92
commit ea756bd5ba
15 changed files with 146 additions and 270 deletions
+41
View File
@@ -26,6 +26,21 @@ extends RigidBody3D
@export var ceiling_pull_strength = 11.5 # Ceiling grav-plating strength; nets above gravity so a ship can hold a ceiling
@export var ceiling_pull_range = 3.0 # Metres from the ceiling where pull begins
# Grav-plating righting torque: a spring-damper that rolls/pitches the hull
# back toward belly-down, strongest at floor level and faded to nothing by
# righting_range so genuine aerials keep full attitude freedom. Without it
# "upright" is not a physically distinguished state at all — the hull is a
# box with no restoring torque, so belly-down and rolled-90 are equally
# stable and a policy has no dynamics-level reason to prefer either. Six
# rounds of RL reward shaping (see TRAINING.md) failed to buy upright
# ground handling for exactly this reason; the fix belongs in the physics,
# not the reward. Same idea as the wall/ceiling pull above — the plating
# orients you, not just attracts you — and it helps human pilots land
# cleanly too.
@export var righting_strength: float = 20.0 # Righting spring gain (0 disables)
@export var righting_damping: float = 6.0 # Opposes tumble while righting
@export var righting_range: float = 3.0 # Metres above the floor where righting fades out
# Non-tinted hull meshes, runtime-merged into one ArrayMesh by
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
# _apply_team_color() retints them per-team and must keep addressing them by
@@ -337,6 +352,7 @@ func _integrate_forces(state):
# === ROTATION (Turning) ===
apply_rotation_forces(state, _current_action.rotation)
apply_righting_torque(state)
# === DRAG AND LIMITS ===
apply_drag_and_limits(state, _current_action.rotation)
@@ -385,6 +401,31 @@ func apply_surface_pull(state: PhysicsDirectBodyState3D) -> void:
state.apply_central_force(pull * mass)
# Spring-damper torque toward belly-down (see righting_strength). The spring
# term basis.y x UP is a world-space axis whose magnitude is sin(tilt) and
# whose direction is the shortest rotation back to upright, so it is zero
# when already level, peaks on its side, and — being a cross product —
# vanishes again when perfectly inverted. The damping term is applied only
# about that same righting axis, so it bleeds off tumble without taxing
# deliberate yaw. Fades linearly to nothing by righting_range metres up,
# matching every other ground-handling term's altitude ramp (see
# ShipAIController.GROUND_HANDLING_HEIGHT).
func apply_righting_torque(state: PhysicsDirectBodyState3D) -> void:
if righting_strength <= 0.0:
return
var height_factor := 1.0 - clampf(global_position.y / righting_range, 0.0, 1.0)
if height_factor <= 0.0:
return
var righting_axis := global_transform.basis.y.cross(Vector3.UP)
var torque := righting_axis * righting_strength
# Damp only the component of spin around the righting axis.
if righting_axis.length_squared() > 0.0001:
var axis := righting_axis.normalized()
torque -= axis * state.angular_velocity.dot(axis) * righting_damping
state.apply_torque(torque * height_factor)
func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
if rotation_input.length() < 0.01:
return