LCM Nav3D - Realistic Flight/Swim Locomotion Reference
Ultra-realistic 6-DOF locomotion for flying/swimming pawns (drones, birds, fish), layered on top of LCMNav3D navigation. The nav system provides guidance (where to go); this layer provides physically-plausible dynamics (how the body moves).
Concept - Guidance → Control → Dynamics
- Nav (unchanged): SVO path / flow field → pure-pursuit + CBS/ORCA avoidance → a desired velocity.
ULcmFlightMovementComponent(new): consumes that desired velocity and runs a 6-DOF rigid-body flight/hydro model (thrust, lift/drag, gravity/buoyancy, attitude PID) per archetype, producing the pawn transform and an animation-drive struct.
Quick start
- Add a Lcm Flight Movement Component to a pawn whose root is a collision primitive.
- Set:
bEnabled = true,LocomotionModel = FlightDynamics,Archetype(Multirotor / Bird / Fish / Generic6DOF), and tuneFlightConfig. - Drive it one of two ways:
- Nav-driven (recommended): on the pawn's
BT/StateTree FlyTo task, tick
Use Flight Movement Component. The task feeds guidance automatically. - Manual: call
RequestVelocity(WorldDesiredVelocity)each tick from your own code/Blueprint.
- Nav-driven (recommended): on the pawn's
BT/StateTree FlyTo task, tick
Off by default: with bEnabled=false (or the task flag
off) nothing changes - motion is the legacy kinematic FlyTo.
Already have a movement component on the pawn? Leave
it there - nothing to remove. While the flight component is enabled it
takes sole movement authority: any other movement
component driving the same root (FloatingPawnMovement,
CharacterMovement, ULcmNavMovementComponent)
is automatically paused (velocity zeroed, deactivated, tick off) and
restored exactly as found the moment flight stops driving
(bEnabled=false, the component deactivated, or the pawn
torn down). Without this, UE runs both integrators against one body and
the last ticker overwrites the pawn's velocity readout - and a
CharacterMovement keeps applying gravity against the flight
dynamics. Opt out per-instance with Pause Conflicting Movement
Components, or globally with
r.LcmNav.Flight.MovementAuthority 0. Two notes: stock
MoveTo path-following still binds the pawn's original
movement component, so drive an enabled flight component with
FlyTo (or RequestVelocity) - and the
pawn's root primitive must not have Simulate Physics
enabled (the component warns in the log if it does).
Stopping at the goal (Hold Station When Idle)
A movement component is told what velocity to have, so "stop" has always meant "target zero velocity" - and a body with momentum answers that by coasting until drag absorbs it, with nothing to bring it back. Measured settling error after arriving inside a 150 cm sphere and being told to stop: multirotor 267 cm past the goal, jet 936 cm, fish 2369 cm, and the bird 14,790 cm away and still descending.
Hold Station When Idle (on by default,
r.LcmNav.Flight.HoldStation) makes an unguided component
steer back to the point guidance handed it over at, on a gentle
brake-shaped approach with a deadband so a parked body is genuinely at
rest. Any RequestVelocity call takes over immediately, so
it only ever governs an otherwise-idle body. With it on, all four
archetypes now settle within ~2 m of the goal and stay there.
Debugging the flight path - planned vs predicted vs flown
Turn on the navigation manager's
bDrawPathfindingDebug (the master switch
for all path debug) and a flight-component pawn draws three distinct
lines:
| line | what it shows | switch |
|---|---|---|
| Orange polyline + red spheres | the PLANNED path the planner delivered | manager bDrawPathfindingDebug |
| Magenta arc ahead of the pawn | the PREDICTED dynamic flight path - the component's real dynamics rolled forward along the remaining route (look-ahead, banking, braking included) | component bDrawDebugPrediction (default on) /
r.LcmNav.Flight.DebugPredict, horizon
r.LcmNav.Flight.DebugPredictSec |
| Green→red fading trail behind | the FLOWN trajectory (speed-coloured), plus yellow = velocity and cyan = guidance arrows | component bDrawDebugTrail (default off) /
r.LcmNav.Flight.DebugTrail, lifetime
r.LcmNav.Flight.DebugTrailSec |
How to read them: the gap between orange and magenta is the body's physics (corner-cutting, brake distance - expected and correct); the gap between magenta and the trail is live avoidance (neighbours, walls - the prediction deliberately excludes what depends on other agents' futures). A lone agent whose trail leaves the magenta line is a tracking defect worth reporting. All three draw nothing in Shipping builds.
Archetypes (key
FlightConfig params)
| Archetype | Model | Notable params |
|---|---|---|
Jet / Fixed-Wing (Generic6DOF) |
Body-forward thrust, airspeed² lift carrying the weight, coordinated
bank-to-turn, thrust-vectored VTOL blend below
WingBorneSpeed |
MaxThrust, ThrottleResponse,
WingBorneSpeed, LiftCoeff,
MaxLiftFactor, BankTurnGain,
MaxBankAngleDeg, VelocityP,
AttitudeP/D |
| Multirotor | Tilt-to-translate, altitude hold, spool lag, wind/gust, hover jitter | RotorSpinUpRate, WindVelocity,
GustStrength/Frequency, HoverJitterStrength,
bYawTracksVelocity, RotorMaxRPM |
| Bird | Effort-throttled pulsed wingbeat, airspeed² lift, flap↔︎glide, banked turns, flare | FlapFrequency, FlapThrust,
LiftCoeff, MaxLiftFactor,
BankTurnGain, MaxBankAngleDeg,
FlareSpeed, FlareDragScale |
| Fish | Neutral buoyancy, tail-beat undulatory thrust + sway, anisotropic hydro drag, current | bNeutralBuoyancy, TailBeatFrequency,
TailBeatFreqGain, TailWiggleStrength,
LateralDragMult, WaterCurrent |
Shared: MaxSpeed, MaxAcceleration,
MaxTiltAngleDeg (Multirotor), MaxTurnRateDeg,
SubSteps (integration sub-steps; higher = stiffer +
smoother), BankingAmount/MaxBankingAngle
(kinematic-mode roll).
Winged bodies (Jet, Bird) - how they fly, and how to tune them
Both share one wing model, and it behaves like an aircraft rather than a hovering body:
- Turns are made by banking. The heading error
commands a bank; the banked lift supplies the centripetal force; the
nose then follows the velocity. So a winged body has a genuine
minimum turn radius,
v² / (g·tan(MaxBankAngleDeg))- 212 cm at 600 cm/s and 60°. RaiseMaxBankAngleDeg(andMaxLiftFactorwith it) for tighter turns; lower it for a heavy bomber. - Lift carries the weight, thrust only fights drag.
At cruise the throttle sits near
drag/MaxThrust(~0.12 at defaults).LiftCoeffsets how much airspeed the wing needs: weight is balanced whenLiftCoeff·v² = Mass·980. - They cannot pinpoint-stop on aerodynamics alone.
Commanded to a hard stop, a wing has nothing to push against. The
Jet answers this with a thrust-vectored blend below
WingBorneSpeed; the Bird flares to shed speed and hover-flaps to hold. Both rely on the nav layer's arrival ramp - with a constant full-speed command any fast forward-flyer orbits its goal, which is expected. - Arriving is not the same as staying. When a FlyTo task arrives it stops feeding guidance, and an unguided flight component holds the position it was left at rather than coasting to wherever momentum runs out (see Hold Station When Idle below). Without that a winged body sails past its goal and never comes back.
- The Jet is
Generic6DOFremodelled (the enum name is unchanged so existing content keeps working).r.LcmNav.Flight.JetGeneric 0restores the previous hover-thruster behaviour exactly.
Notes:
A fast forward-flyer (bird/fish) can't pinpoint-stop; the nav layer's arrival slowdown makes it flare to a perch. Without arrival-aware guidance a fast body orbits its goal (expected).
Realistic dynamics lag guidance by design; raise
MaxAccelerationfor snappier, lower for heavier/driftier.The two control loops are a cascade - tune them as a pair, never one alone.
VelocityPis the outer loop (time constant1/VelocityP); it commands a tilt that the innerAttitudeP/AttitudeDloop must then achieve. Keep the inner loop ~4× faster than the outer one, or the body wanders side to side and hunts back and forth around its goal:formula shipped default inner natural frequency wn = sqrt(AttitudeP)7.75 rad/s inner damping ratio z = (AttitudeD + AngularDrag) / (2*wn)0.84 inner time constant 1/(z*wn)0.15 s outer time constant 1/VelocityP0.67 s ⚠
AngularDragadds toAttitudeD- the integrator applies-AttitudeD*wand then-AngularDrag*w, so the loop's real damping is the sum. RaisingAttitudePfor a snappier body without raisingAttitudeDwith it dropszbelow ~0.7 and the body visibly rings after every heading change. Damping must scale as2*z*sqrt(AttitudeP).
Altitude
while manoeuvring - MaxTiltAngleDeg and the thrust
budget
A tilt-to-translate body (Generic6DOF / Multirotor) keeps only
cos(tilt) of its thrust opposing gravity, so holding height
while tilted needs a thrust-to-weight ratio of
1/cos(tilt):
| tilt | lateral accel available | thrust-to-weight needed just to hold height |
|---|---|---|
| 30° | 566 cm/s² | 1.15× |
| 45° | 980 cm/s² | 1.41× |
| 60° (default) | 1697 cm/s² | 2.0× |
| 75° | 3657 cm/s² | 3.9× |
MaxTiltAngleDeg caps this so the body cannot ask for a
manoeuvre that costs it altitude, and when the thrust budget saturates
the vertical axis is served first and the lateral axis
takes the remainder. ⚠ Raising the tilt limit without raising
MaxThrust to match makes the body descend under hard
lateral commands - the harder you ask it to move, the faster it sinks.
Keep MaxThrust ≳ Mass * 980 / cos(MaxTiltAngleDeg); at the
shipped 1 kg / 3000 / 60° there is 2× margin.
Reaching the commanded speed - drag is fed forward
The velocity loop cancels the drag it is about to apply, exactly as
it already cancels gravity. That matters more than it sounds: drag is a
constant disturbance to a proportional-only loop, and a P-only
loop answers one with a permanent offset of
|drag| / (Mass * VelocityP). Left uncancelled at the
shipped defaults that is 336 cm/s against a 600 cm/s command - the body
would cruise at 44 % of what it was told, and no gain fixes it (raising
VelocityP shrinks the error but costs the visible lean).
Kill switch r.LcmNav.Flight.DragFeedForward 0.
Making a body lean (tilt-to-translate look)
A thrust-vector body leans because thrust must point along
Mass*accel + weight. There is no cosmetic lean parameter in
FlightDynamics mode - the attitude is physical, so you get
lean by changing what the body has to do. Three separate
mechanisms, commonly confused:
| Want | Knob | Why |
|---|---|---|
| Forward lean while cruising | LinearDragCoeff.X/Y ⬆ |
At steady speed accel≈0, so lean is exactly
atan(drag/weight). No control gain can change
this. 0.5→1.4 takes it from 10°→28° at 600 cm/s. |
| Lean while accelerating/braking | VelocityP ⬇ |
Peak lean is atan(VelocityP*MaxSpeed/g) but it decays
over 1/VelocityP, and the area is fixed. High gain = a
spike too brief to see; low gain = a shallower lean actually held. |
| Bank into turns | YawP ⬇ (keep ≪ AttitudeP) |
The body banks only while its velocity error is lateral. Fast yaw whips the nose onto the new heading first, making the error forward instead - pivot-then-go, no bank. |
⚠ In Kinematic mode none of the above applies: attitude
there is cosmetic, driven by BankingAmount and
MaxBankingAngle.
BankingAmount
is now a multiple of the physically correct bank
BankingAmount (Kinematic mode, and the matching field on
BT/StateTree FlyTo) is a multiplier on the coordinated-turn
angle tan(bank) = V * yawRate / g. 1
is the true bank a real body holds in that turn; above 1 exaggerates it
for readability; 0 flies wings-level.
⚠ Its default changed from 4 to 1 and the old number does not
carry over. The previous law dotted a normalised
per-frame velocity delta with the right vector - normalising throws away
how hard the turn actually is, so the roll command sat near ±1 on
ordinary velocity noise and the body flicked between ±4° every frame in
straight flight. Multiplying the correct law by 4 instead saturates
MaxBankingAngle in any ordinary turn. If you had authored a
value here, re-tune it around 1. Legacy law:
r.LcmNav.Flight.CoordinatedBank 0.
Flying straight up or down
Travel-referenced attitude is built by parallel transport of the previous frame, not from a world-up reference. This is not a refinement - the two obvious UE builders are discontinuous at vertical travel, and both were in use:
FVector::Rotationyaws byatan2(Y, X), which atX = Y = 0isatan2(0,0) = 0: a body climbing straight up snaps its heading to world +X, then swings with any lateral noise.FRotationMatrix::MakeFromXZsubstitutes(1,0,0)for its up reference once|Fwd.Z|passes1 - KINDA_SMALL_NUMBER- the frame teleports rather than degrading.
Either one makes a climbing or diving agent roll hard for reasons
invisible in its flight path. World-up levelling is still applied,
weighted by how horizontal the travel is, so it fades out exactly where
the reference stops being meaningful and the body simply holds its roll
through vertical. Applies to Kinematic, Bird, Fish and both FlyTo tasks.
Kill switch r.LcmNav.Flight.ContinuousFrame 0.
The vertical
axis has its own gain - VelocityPZ
VelocityP is held low on purpose: horizontally it
commands a tilt the attitude loop then has to achieve,
so it must stay ~4× slower than that loop. The vertical axis has no such
constraint - thrust acts on it directly, with no
attitude change in between - so making it share the slow gain costs
bandwidth for nothing. Every production multirotor controller splits
these (PX4 MPC_Z_VEL_P vs MPC_XY_VEL_P;
ArduPilot PSC_VELZ_P vs PSC_VELXY_P).
Measured on a closed-loop rig flying a dead-level path, so any altitude motion is controller-generated:
VelocityPZ |
settled altitude wander | settled vertical speed |
|---|---|---|
legacy (shares VelocityP = 1.5) |
40.3 cm | 33.4 cm/s |
| 2 | 26.1 | 30.3 |
| 4 (default) | 6.0 cm | 12.1 cm/s |
| 8 | 5.4 | 9.9 |
Cross-track error and arrival time are unchanged (173.8→173.6 cm,
10.85→10.83 s), so it costs nothing horizontally. 4 is derived,
not chosen: position-loop damping
ζ = 0.5·√(Kz·L/v) reaches 1.0 at Kz=4 when the look-ahead
equals the speed, and the velocity loop's damping against the rotor
spool, ζ = 1/(2√(Kz·τ_spool)), is 0.707 at exactly Kz=4
with RotorSpinUpRate 8. Raise RotorSpinUpRate
too if you push it much past 4.
A path that ends short of the goal no longer orbits
If the delivered path's last waypoint is further than 2 ×
Acceptance Radius from the goal, the task used to pin the index
back to that waypoint every tick - and since the agent is
already inside the acceptance radius of it, the next tick retired it and
pinned again. Measured: 1750 pin events in 40 s, with
the task neither succeeding nor failing. It now pins once, holds
position, replans twice, and then reports Failed with a
log line naming the likely cause. Common causes of a short path: the
goal quantises to a leaf centre, or
r.LcmNav.SeamSafe.TruncateUnprovable (default ON) truncated
the route at its first unprovable leg.
Troubleshooting - symptom to cause
Every row below is a structural cause with its own kill switch, so each can be A/B'd on its own. Work down the list; do not start by re-tuning gains, because none of these is a gain problem.
| Symptom | Likely cause | Switch to A/B |
|---|---|---|
| Altitude swings up and down continuously, even in level flight | The vertical axis shared the (deliberately slow) horizontal
VelocityP, leaving the pure-pursuit position loop at ζ≈0.61
- underdamped, with nothing to damp it |
VelocityPZ (default 4; set 0 for the legacy
shared gain) |
| Never reaches a waypoint, circles it forever, task never Succeeds or Fails | End-of-path fallsafe re-pinned the index every tick when the path ended >2×AcceptanceRadius short of the goal | fixed unconditionally; look for the [FlyTo] giving up:
warning |
| Altitude rings up then sags whenever it starts a horizontal move | Thrust magnitude applied along the body's current axis instead of projected onto it | r.LcmNav.Flight.ThrustProjection |
| Sinks during hard turns or fast starts | Tilt unbounded / thrust saturation cutting the vertical component | r.LcmNav.Flight.ThrustPriority, then raise
MaxThrust or lower MaxTiltAngleDeg |
| Never reaches its commanded speed; feels unresponsive | Drag not fed forward → permanent P-loop offset | r.LcmNav.Flight.DragFeedForward |
| Rolls violently when climbing or diving steeply | Discontinuous world-up attitude reference | r.LcmNav.Flight.ContinuousFrame |
| Twitches side to side in straight, level flight | Roll commanded from a normalised velocity delta | r.LcmNav.Flight.CoordinatedBank |
| Lurches when it passes a waypoint or an avoidance term flips | Discontinuous guidance command stepping the cascade | r.LcmNav.Flight.GuidanceSlew |
| Judders / grinds along a surface it is touching | Velocity not corrected after a blocking sweep | r.LcmNav.Flight.SlideVelocity |
| Weaves near geometry it is not even approaching | Wall whiskers cast off the nose (which lags travel), binary, no forward or vertical coverage | r.LcmNav.WallAvoidV2 1 (⚠ default 0 - see below) |
| Bird's wingbeat stutters on and off at cruise | Flap/glide threshold had no hysteresis | fixed unconditionally (Schmitt trigger on
GaitEffort) |
| Fish rolls in time with its own tail beat | Wiggle force fed back into the attitude error | fixed unconditionally |
| Passes waypoints / overshoots the goal | Guidance solving stopping distance against
MaxAcceleration, a ceiling the loop rarely reaches |
r.LcmNav.FlightGuidanceAccelFromVelocityP (already
default 1) |
| Weaves along a dense path | Pure-pursuit look-ahead shorter than the body's response distance | r.LcmNav.PursuitLagFloorK (already default 1.5) |
⚠ r.LcmNav.WallAvoidV2 defaults to 0 on
purpose. Reducing this term's authority has been measured
harmful once already - a bounded version cost a single agent its
arrival (33 loop events, orbiting), because a planned route sometimes
points into a wall and only a full-authority push clears it. V2 keeps
full authority at contact and only ramps the far field, but that
measurement has not been retaken against it. Turn it on if flyers judder
near geometry, and re-check arrival rates.
Animation contract -
FLcmFlightState
Every tick the component produces FLcmFlightState
(archetype-agnostic where possible):
| Field | Meaning |
|---|---|
Speed01 |
Normalized airspeed (|velocity| / MaxSpeed) - blendspace axis |
VerticalSpeed |
cm/s, climb + / dive − - climb/dive pose blend |
BankAngleDeg / PitchDeg |
Body roll / pitch - additive lean/pitch |
YawRateDeg |
Turn rate - lean/tilt into turns |
GaitPhase |
0.1 cyclic driver: rotor spin / wingbeat / tail-beat |
GaitAmplitude01 |
Throttle (drone) / flap-glide blend (bird) / undulation vigour (fish) |
RotorRPM |
Multirotor rotor RPM |
Throttle01 / Airspeed |
Commanded effort / raw speed |
Consume it two ways:
- Pull:
GetFlightState(BlueprintPure) on the component, e.g. from an AnimBP's Update. - Push: implement
ILcmFlightAnimInterface::ReceiveFlightStateon your AnimBP (or pawn). The component calls it every tick (bBroadcastAnimState, default on). Cache the struct and drive:GaitPhase→ rotor/wing/tail cyclic pose (Sine/curve on the phase)Speed01+VerticalSpeed→ locomotion blendspaceBankAngleDeg/PitchDeg→ additive lean/pitchGaitAmplitude01→ flap-vs-glide (bird) or effort (fish/drone) blend weightRotorRPM→ prop-spin material/WPO param (Multirotor)
Mass crowds & scale (P7-P8)
The LCM Nav3D Agent trait (NavMode = PathFollowing)
carries the same flight options (LocomotionModel,
FlightArchetype, FlightConfig) - set them and
a whole crowd flies with the identical 6-DOF physics as a pawn, still
pathing + avoiding + arriving. Default Kinematic →
byte-identical to before.
Dynamics LOD (the scale knob). Full 6-DOF per agent
is expensive; set FlightDynamicsLODDistance (cm) so only
agents within that distance of the viewer run full dynamics - everyone
else uses the cheap kinematic integrator (face-velocity, no roll). So a
10K crowd keeps only the on-screen agents on the expensive path.
0 = always full. Global kill switch:
r.LcmNav.Mass.FlightLOD (1 default / 0 = full everywhere).
LOD switching is seamless - a far agent's integrator state re-syncs from
its live transform when it returns to range.
Deferred (documented) scale extensions:
- GPU 6-DOF compute for very large crowds is a designed extension, intentionally not shipped here: CPU dynamics-LOD already bounds cost to the visible set, which is the cheaper win. Revisit with engine uplift.
- Shared flight config -
FlightConfigis currently per-entity; moving it to a const-shared fragment per archetype would cut crowd memory. Deferred (shared-fragment archetype plumbing).
Determinism
The dynamics are deterministic: fixed integration sub-steps, no RNG or wall-clock; gust/jitter are seeded per-agent sinusoidal noise. Same input sequence → identical trajectory.
Bird and Fish do not share the drone control cascade
⚠ StepBird and StepFish are
separate integrators - they never call
RunStep6DOF. That is deliberate (a bird does not
translate sideways by tilting a rotor), but it meant they also never had
its velocity-tracking loop: thrust acts along body-forward only, and
until 2026-08-06 nothing corrected a velocity error
perpendicular to the nose. They pointed at the guidance
direction and waited for drag, so cross-track and altitude error could
only decay passively - never be driven to zero. That is what "drifts
away from the path points" was.
They now generate the perpendicular force a banked wing or a flexing
body actually produces, bounded by what the body can physically supply -
lift for the bird, tail/fin thrust scaled by
swim effort for the fish. BankTurnGain is the rate
(1/s) that converts a cm/s error into that demand, which is what its own
description always claimed. Measured on one corner path, same
guidance:
| archetype | cross-track before → after | altitude error before → after |
|---|---|---|
| Generic6DOF (reference) | 174.3 → 174.3 | 9.4 → 9.4 |
| Multirotor (reference) | 173.6 → 173.6 | 22.8 → 22.8 |
| Bird | 234.4 → 183.9 | 69.7 → 61.5 |
| Fish | 203.7 → 198.4 | 64.0 → 17.4 |
Both drone archetypes are byte-identical (their gate checksums are
unchanged), because the change is scoped to the two integrators that
lacked the loop. Kill switch
r.LcmNav.Flight.BodyManeuver 0.
⛔ Do not "simplify" the bird by removing its
LiftControl trim as a duplicate vertical
controller. Measured and reverted: it is the bird's only means
of balancing lift against gravity - lift is
LiftCoeff × airspeed², which at cruise exceeds body weight,
so without the trim the bird climbs away (altitude error 61.5 → 186.4
cm). The trim sets lift magnitude for equilibrium; the
manoeuvre force supplies perpendicular authority. They are not
redundant.
Hard turns: progress does not depend on hitting a sphere
⚠ If a tighter Acceptance Radius makes your agents stick,
this is why. A waypoint used to be retired only by
HasReachedOrPassedWaypoint: within Acceptance
Radius, or past the plane through it normal to
the incoming leg. At a corner sharper than the body's minimum turn
radius - v / VelocityP, which is 400 cm at
the shipped defaults - the agent cannot pass within a 100 cm radius, and
the plane has rotated with the path so it is not past that either.
The gate becomes unsatisfiable, the index stalls on a
point now behind the agent, and because pure pursuit measures
its look-ahead from the current index, the aim point sits
behind too and drags the agent backwards.
Measured on 375 cm waypoint spacing (what ChunkSize 3000
produces), 135° corner:
| acceptance radius | gate only | + projection advance |
|---|---|---|
| 50 cm | stuck - 39.9 s stalled, 1214 backtrack ticks | arrives, 0 backtrack |
| 100 cm | stuck - 40.0 s stalled, 1214 backtrack ticks | arrives, 0 backtrack |
| 200 cm | arrives | identical |
| 400 cm | arrives | identical |
The index now also advances by projecting the agent
onto its path, so progress is monotone and independent of the
acceptance gate. It is inert wherever the gate already
works (45° and 90° corners are byte-identical; 200 cm and 400 cm radii
are byte-identical). On the demo map at ChunkSize 3000 with
7 agents it took arrivals from 1/7 to 3-4/7 and mean cross-track from
392 cm to ~225 cm.
⚠ r.LcmNav.PathProjectWindow (default 3) is
load-bearing, not an optimisation. On a hairpin the return leg
runs spatially alongside the outbound one, so an unbounded
nearest-segment search would snap the agent onto the return leg before
it had rounded the corner and cut the whole turn. Kill switch for the
whole behaviour: r.LcmNav.PathProjectAdvance 0.
⚠ Acceptance Radius still does not control path adherence - it never
did. It decides when a point is retired, not how closely the
agent flies. Corner-cutting is bounded by physics: minimum turn radius
is v²/a_eff, so waypoints spaced closer than that will be
passed wide however you tune this.
Scope: every follower rework in this section is 6-DOF ONLY
⛔ The behaviours documented below - progress-based stall detection,
the visibility-clamped look-ahead, the backward speed profile, and the
projection index advance - apply only to a pawn driven
by an enabled ULcmFlightMovementComponent in
FlightDynamics (6-DOF) mode.
The Kinematic (legacy point-mass) model and pawns with no component at all (which the task integrates itself) keep their original behaviour exactly: the acceptance-gate-only waypoint advance, the unclamped look-ahead target, the one-corner speed cap, and the displacement-based stall timer. Those are separate shipped locomotion models whose feel users have already built content against.
⚠ Both defaults point away from 6-DOF
(LocomotionModel = Kinematic,
bUseFlightMovementComponent = false), so a
default-configured pawn gets none of this. It is
opt-in, by design. LcmFlightLocomotion::IsSixDofBody is the
single predicate; do not re-derive it.
The same rule covers the attitude rework
(r.LcmNav.Flight.LegacyAttitudeOutside6DOF, default 1) and
the component's guidance-reference limiter and slide-velocity
correction, which are likewise 6-DOF only. The limiter in particular
must not reach Kinematic:
ELcmFlightStyle::LinearDirect exists to answer a command
directly and PhysicsWeighted to answer it with inertia, and
handing LinearDirect a reference already ramped at
MaxAcceleration makes the two indistinguishable.
Stall recovery measures PROGRESS, not motion
An agent that is flying in a circle is, to every speed-based health
check, perfectly healthy. That is not a hypothetical: on
Vertical_Skyscraper at ChunkSize 3000 the
flyer went 828 m to gain 35 m (tortuosity 23.8, 53 loop
events) and every recovery counter read zero -
stallFrac 0.00, escapeTicks 0,
replans 0, paths 1. It orbited the first
storey until the run timed out.
The cause was one line. The stall timer reset whenever the pawn had
moved more than 50 cm from StallEscapeAnchor - and then
moved the anchor to the pawn:
if (FVector::DistSquared(NowPos, StallEscapeAnchor) > 2500.0f) // ">50 cm = progress"
{ StallEscapeAnchor = NowPos; StallEscapeSeconds = 0.0f; }
An orbiting agent clears 50 cm every few frames, so the timer could never accumulate. The one recovery path that could have broken the loop was held open by the loop itself.
Progress is now remaining arc length along the path
(ULcmSteeringMath::RemainingPathLengthCm), which falls
monotonically along any correct traversal and does not fall at all while
orbiting.
⛔ Straight-line distance to the goal is the wrong scalar and must not be substituted. A correct route routinely moves away from the goal - the horizontal leg of a vertical switchback, or any detour round an obstacle - so a goal-distance watchdog fires on healthy navigation.
⚠ StallBestRemainCm must be reset to
TNumericLimits<float>::Max every time
CurrentPath is replaced. A fresh path legitimately has a
larger remaining length; without the reset it reads as permanent
no-progress and arms recovery forever.
Vertical_Skyscraper, CS 3000 |
legacy | progress-based |
|---|---|---|
| outcome | stuck at Z=−6999 (first storey) | climbs the tower |
| closest approach | 15855 cm | 600 cm (143 cm with a shorter look-ahead) |
| tortuosity | 23.8-31.6 | 5.6-6.8 |
| loop events | 53-68 | 0-13 |
| replans | 1 | 19-39 |
The legacy failure is bit-reproducible (15855 cm / 31.56 / 68 loops / 1 path on every run), which makes this map a far better regression instrument than the 7-agent demo scenario.
Kill switch: r.LcmNav.ProgressStallDetect 0. Threshold:
r.LcmNav.ProgressEpsilonCm (default 50).
The look-ahead target must be REACHABLE, not merely on the path
Pure pursuit measures its look-ahead along the path, then flies at the result in a straight line. Those are the same thing only where the path is locally straight. In a vertical shaft with offset floor openings, a target 400-1000 cm along the path sits on the far side of a slab, and the straight line to it goes through solid. The agent charges a point it cannot reach, is blocked, retries, and orbits - the user-visible "it reaches for a further point before finishing the nearest one, then gets stuck".
ULcmSteeringMath::GetPurePursuitTargetVisible walks the
look-ahead back until the agent→target segment is clear in the SVO, and
falls back to Path[CurrentIndex] - the nearest unconsumed
waypoint, which is always a safe aim because the planner already proved
the path traversable. At most 4 line-of-sight tests per tick.
⚠ It fails OPEN. With no chunk data - unstreamed, or the segment leaves the single chunk this test can see - it returns the unmodified target, i.e. exactly legacy behaviour. Never infer a blockage from missing data; that would brake the follower at every chunk boundary.
Vertical_Skyscraper, CS 3000, progress detector on |
look-ahead unclamped | visibility-clamped |
|---|---|---|
| arrives | no, no | yes, yes |
| time to goal | never | 80.12 s, 80.25 s |
| closest approach | 6615, 6615 cm | 144, 145 cm |
| loop events | 46, 46 | 0, 0 |
| replans | 40, 40 | 15, 15 |
Both fixes are needed. The progress detector alone breaks the orbit-lock but still ends 66 m short; the visibility clamp alone leaves the stall detector blind to any orbit it does not prevent.
Kill switch: r.LcmNav.PursuitVisibleTarget 0.
Regression on Mixed_Indoor_Outdoor
(same pinned single-agent instrument, 2 runs per arm): loop events fell
48/46 → 3/1 there too, with trackErrMax
unchanged (2326/2406 → 2683/2133, overlapping). ⚠ Replans rose 23/10 →
76/64: that is the progress detector doing its job - the agent now
notices it is getting nowhere instead of orbiting silently -
but it is real planner load. Raise
r.LcmNav.ProgressEpsilonCm if that rate is too high for
your budget.
⚠ ChunkSize 8000 cannot navigate this map at
all, for an unrelated reason. It yields leaves up to 1000 cm,
too coarse to resolve the floor openings, so the slabs seal the shaft
into disconnected components and no path exists to plan
(portalsInAgentComp=0, open set exhausted).
The agent never moves because there is nothing to follow - do not read
that as a follower defect.
Speed planning: the agent slows down for corners it can already see
Fixing the index advance stops the agent sticking, but it does not stop it going into a corner too fast. Those are separate defects with the same symptom, and the second one is speed-dependent - which is exactly the "fine when it's slow, sticks when I speed it up" report.
The speed governor's corner cap used to look exactly one
waypoint ahead: it capped speed at
sqrt(v_corner² + 2·a·d) where d is the
distance to the single next waypoint. That is the right
equation solved over the wrong distance. Braking distance grows as
v², waypoint spacing is fixed by the voxel grid, and
the body's response lag is a fixed v / VelocityP - so past
some speed the brake command is issued too late to be achievable, and
the agent simply carries its speed through the corner. Measured at a
135° corner on 375 cm spacing, MaxSpeed 1200: the old cap
held mean speed to 823 cm/s - precisely
sqrt(v_corner² + 2·900·375), i.e. it did exactly what it
was written to do, and that was not enough. Peak deviation from the path
was still 371 cm. In a corridor, 371 cm is a wall.
The governor now builds a proper backward velocity
profile over a window
(r.LcmNav.SpeedProfilePoints, default 8):
- Give every upcoming waypoint the speed its own curvature
allows,
v_i = sqrt(a · R_i). - Sweep backward:
v_i = min(v_i, sqrt(v_{i+1}² + 2·a·d_i)), so each corner's limit propagates upstream as far as the braking distance actually needs. - Brake from the agent's position to every limit in
the window and obey the tightest, charging the body's response distance
v / VelocityPagainst each available braking distance.
Peak cross-track at a 135° corner, 375 cm spacing, by
MaxSpeed:
MaxSpeed |
ungoverned | one-corner cap (old) | backward profile | mean speed, old → new |
|---|---|---|---|---|
| 300 | 94.5 cm | 94.5 cm | 95.0 cm | 284 → 281 |
| 600 | 201.5 cm | 184.8 cm | 93.4 cm | 517 → 506 |
| 900 | 287.2 cm | 268.1 cm | 89.8 cm | 693 → 688 |
| 1200 | 397.8 cm | 371.4 cm | 103.5 cm | 823 → 827 |
Tracking error is now flat in speed instead of growing linearly with it, and it is not bought with travel time - mean speed is unchanged (at 1200 it is marginally higher, because the agent stops holding a compromise speed down the straight and instead runs fast then brakes late-but-sufficiently).
⛔ r.LcmNav.SpeedProfilePoints 1 narrows the window to a
single corner but is NOT the legacy cap, and must not
be used as a way to restore it. The old cap took the incoming leg from
the agent's own position (Path[i] - CurrentLoc); the
profile takes it from the path (Path[i] - Path[i-1]),
because path geometry is the only definition available for the waypoints
further out. The braking distances differ, and on a
LinearDirect body that difference shows up as overshooting
the waypoint. Non-6-DOF bodies therefore run the original block verbatim
rather than a 1-wide profile.
On the demo map (Mixed_Indoor_Outdoor,
BT_FlightBehavior_Loop, single agent, pinned start/goal, 3
runs per arm) exactly one follower metric moves, and it is the one that
matters:
| metric | one-corner cap | backward profile | |
|---|---|---|---|
| worst-case cross-track | 3570 / 3244 / 3282 cm | 2536 / 2560 / 2544 cm | −24%, no overlap |
| mean cross-track | 1226 / 1197 / 1320 | 839 / 1232 / 1047 | overlaps - no effect |
| commanded deflection | 13.7 / 7.7 / 2.8° | 1.8 / 5.0 / 16.1° | overlaps - no effect |
| avoidance-dominated ticks | 10 / 7 / 4 % | 2 / 4 / 10 % | overlaps - no effect |
The worst-case figure is not merely better, it is tight: ±12 cm across runs whose replan counts ranged 16-47, against ±170 cm for the one-corner cap. That is the signature of a bound that now holds structurally rather than by luck. Mean cross-track not moving is consistent with the mechanism - the profile acts at corners, and the mean is dominated by the straights, where both arms are identical.
⚠ Do not measure this with the 7-agent
configuration. ORCA/flocking interference, a looping BT that
disperses agents differently each run, and an unpinned start together
produce a 57× spread within a single arm
(wall-avoidance term 0.08 to 4.60), which is far larger than the effect.
Arrival count alone came back 0, 1 and 3 of 7 across runs of the
same build. Pin the start and goal and use one agent. Note also
that reach= is meaningless under a looping BT - it cycles
targets by design.
⛔ Do not "improve" step 3 by walking the response distance forward along the path and evaluating the profile at that single lagged point. It reads as the more principled model of a lagging body, and it is worse at every speed (166 / 238 / 288 cm against 95 / 163 / 247): once the lagged point passes the corner, the limit there is the post-corner straight-leg limit, so the governor releases the brake while the body is still entering the turn. The lag credit is sound for accelerating and unsound for braking - only the pessimistic direction may use it.