LCM Nav3D Free demo Get it on Fab

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

Quick start

  1. Add a Lcm Flight Movement Component to a pawn whose root is a collision primitive.
  2. Set: bEnabled = true, LocomotionModel = FlightDynamics, Archetype (Multirotor / Bird / Fish / Generic6DOF), and tune FlightConfig.
  3. 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.

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:

Notes:

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:

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:

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:

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 , 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):

  1. Give every upcoming waypoint the speed its own curvature allows, v_i = sqrt(a · R_i).
  2. 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.
  3. Brake from the agent's position to every limit in the window and obey the tightest, charging the body's response distance v / VelocityP against 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.