Add stair height smoothing and update navigation controllers

This commit is contained in:
2026-04-12 02:29:42 +02:00
parent 42cee3d19a
commit b1d7720cdd
6 changed files with 228 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ import {
stepPlayerLocomotion
} from "./player-locomotion";
import { createPlayerControllerTelemetry } from "./player-controller-telemetry";
import { smoothGroundedStairHeight } from "./stair-height-smoothing";
import type { PlayerControllerTelemetry } from "./navigation-controller";
import type {
NavigationControllerDeactivateOptions,
@@ -130,6 +131,7 @@ export class FirstPersonNavigationController implements NavigationController {
private previousTelemetry: PlayerControllerTelemetry | null = null;
private latestJumpStarted = false;
private latestHeadBump = false;
private smoothedFeetY = 0;
private previousPlanarDisplacement = {
x: 0,
y: 0,
@@ -159,6 +161,7 @@ export class FirstPersonNavigationController implements NavigationController {
this.verticalVelocity = 0;
this.grounded = false;
this.jumpPressed = false;
this.smoothedFeetY = this.feetPosition.y;
this.locomotionState = createIdleRuntimeLocomotionState(
runtimeScene.playerCollider.mode === "none" ? "flying" : "airborne"
);
@@ -259,6 +262,7 @@ export class FirstPersonNavigationController implements NavigationController {
this.verticalVelocity = 0;
this.grounded = false;
this.jumpPressed = false;
this.smoothedFeetY = 0;
this.standingPlayerShape = cloneFirstPersonPlayerShape(
FIRST_PERSON_PLAYER_SHAPE
);
@@ -367,6 +371,13 @@ export class FirstPersonNavigationController implements NavigationController {
this.grounded = locomotionStep.locomotionState.grounded;
this.inWaterVolume = locomotionStep.inWaterVolume;
this.inFogVolume = locomotionStep.inFogVolume;
this.smoothedFeetY = smoothGroundedStairHeight({
currentSmoothedFeetY: this.smoothedFeetY,
targetFeetY: this.feetPosition.y,
grounded: this.grounded,
dt,
maxStepHeight: playerMovement.maxStepHeight
});
this.updateCameraTransform();
this.publishTelemetry();
@@ -381,6 +392,7 @@ export class FirstPersonNavigationController implements NavigationController {
this.verticalVelocity = 0;
this.grounded = false;
this.jumpPressed = false;
this.smoothedFeetY = this.feetPosition.y;
this.activePlayerShape = cloneFirstPersonPlayerShape(
this.context?.getRuntimeScene().playerCollider ?? FIRST_PERSON_PLAYER_SHAPE
);
@@ -412,8 +424,13 @@ export class FirstPersonNavigationController implements NavigationController {
return;
}
const renderedFeetPosition = {
x: this.feetPosition.x,
y: this.smoothedFeetY,
z: this.feetPosition.z
};
const eyePosition = toEyePosition(
this.feetPosition,
renderedFeetPosition,
getFirstPersonPlayerEyeHeight(this.activePlayerShape)
);
this.cameraRotation.x = this.pitchRadians;
@@ -436,8 +453,13 @@ export class FirstPersonNavigationController implements NavigationController {
return;
}
const renderedFeetPosition = {
x: this.feetPosition.x,
y: this.smoothedFeetY,
z: this.feetPosition.z
};
const eyePosition = toEyePosition(
this.feetPosition,
renderedFeetPosition,
getFirstPersonPlayerEyeHeight(this.activePlayerShape)
);
const cameraVolumeState =

View File

@@ -21,6 +21,8 @@ import type { RuntimeBrushTriMeshCollider, RuntimeSceneCollider } from "./runtim
const CHARACTER_CONTROLLER_OFFSET = 0.01;
const CHARACTER_CONTROLLER_SNAP_TO_GROUND_DISTANCE = 0.2;
const AUTOSTEP_MIN_WIDTH_FACTOR = 0.4285714286;
const AUTOSTEP_SURFACE_PROBE_DISTANCE = 0.08;
const AUTOSTEP_PLANAR_PROGRESS_EPSILON = 0.02;
const COLLISION_EPSILON = 1e-5;
const CAMERA_COLLISION_EPSILON = 1e-3;
const MAX_WALKABLE_SLOPE_RADIANS = Math.PI * 0.25;
@@ -321,6 +323,10 @@ function toVec3(vector: RAPIER.Vector): Vec3 {
};
}
function computePlanarDistance(vector: Vec3): number {
return Math.hypot(vector.x, vector.z);
}
export async function initializeRapierCollisionWorld(): Promise<typeof RAPIER> {
rapierInitPromise ??= RAPIER.init().then(() => RAPIER);
return rapierInitPromise;
@@ -555,7 +561,20 @@ export class RapierCollisionWorld {
resolved.correctedMovement.y > COLLISION_EPSILON &&
(resolved.collidedAxes.x || resolved.collidedAxes.z)
) {
resolved = computeResolvedMovement(motion, false);
const withoutAutostep = computeResolvedMovement(motion, false);
const landedOnStepSurface = this.probePlayerGround(
resolved.nextFeetPosition,
shape,
AUTOSTEP_SURFACE_PROBE_DISTANCE
).grounded;
const improvedPlanarDistance =
computePlanarDistance(resolved.correctedMovement) >
computePlanarDistance(withoutAutostep.correctedMovement) +
AUTOSTEP_PLANAR_PROGRESS_EPSILON;
if (!landedOnStepSurface || !improvedPlanarDistance) {
resolved = withoutAutostep;
}
}
if (

View File

@@ -0,0 +1,36 @@
const HEIGHT_EPSILON = 1e-4;
const STAIR_SMOOTHING_RISE_RATE = 10;
const STAIR_SMOOTHING_FALL_RATE = 14;
const STAIR_SMOOTHING_DELTA_MULTIPLIER = 1.5;
export function smoothGroundedStairHeight(options: {
currentSmoothedFeetY: number;
targetFeetY: number;
grounded: boolean;
dt: number;
maxStepHeight: number;
}): number {
if (options.dt <= 0 || !options.grounded || options.maxStepHeight <= 0) {
return options.targetFeetY;
}
const delta = options.targetFeetY - options.currentSmoothedFeetY;
if (Math.abs(delta) <= HEIGHT_EPSILON) {
return options.targetFeetY;
}
const maxSmoothableDelta = Math.max(
0.04,
options.maxStepHeight * STAIR_SMOOTHING_DELTA_MULTIPLIER
);
if (Math.abs(delta) > maxSmoothableDelta) {
return options.targetFeetY;
}
const rate = delta >= 0 ? STAIR_SMOOTHING_RISE_RATE : STAIR_SMOOTHING_FALL_RATE;
const alpha = 1 - Math.exp(-rate * options.dt);
return options.currentSmoothedFeetY + delta * alpha;
}

View File

@@ -16,6 +16,7 @@ import {
stepPlayerLocomotion
} from "./player-locomotion";
import { createPlayerControllerTelemetry } from "./player-controller-telemetry";
import { smoothGroundedStairHeight } from "./stair-height-smoothing";
import type { PlayerControllerTelemetry } from "./navigation-controller";
import type {
NavigationController,
@@ -127,6 +128,7 @@ export class ThirdPersonNavigationController implements NavigationController {
private previousTelemetry: PlayerControllerTelemetry | null = null;
private latestJumpStarted = false;
private latestHeadBump = false;
private smoothedFeetY = 0;
private previousPlanarDisplacement = {
x: 0,
y: 0,
@@ -158,6 +160,7 @@ export class ThirdPersonNavigationController implements NavigationController {
this.verticalVelocity = 0;
this.grounded = false;
this.jumpPressed = false;
this.smoothedFeetY = this.feetPosition.y;
this.locomotionState = createIdleRuntimeLocomotionState(
runtimeScene.playerCollider.mode === "none" ? "flying" : "airborne"
);
@@ -230,6 +233,7 @@ export class ThirdPersonNavigationController implements NavigationController {
this.verticalVelocity = 0;
this.grounded = false;
this.jumpPressed = false;
this.smoothedFeetY = 0;
this.standingPlayerShape = cloneFirstPersonPlayerShape(
FIRST_PERSON_PLAYER_SHAPE
);
@@ -339,6 +343,13 @@ export class ThirdPersonNavigationController implements NavigationController {
this.grounded = locomotionStep.locomotionState.grounded;
this.inWaterVolume = locomotionStep.inWaterVolume;
this.inFogVolume = locomotionStep.inFogVolume;
this.smoothedFeetY = smoothGroundedStairHeight({
currentSmoothedFeetY: this.smoothedFeetY,
targetFeetY: this.feetPosition.y,
grounded: this.grounded,
dt,
maxStepHeight: playerMovement.maxStepHeight
});
if (
Math.hypot(
@@ -366,6 +377,7 @@ export class ThirdPersonNavigationController implements NavigationController {
this.verticalVelocity = 0;
this.grounded = false;
this.jumpPressed = false;
this.smoothedFeetY = this.feetPosition.y;
this.activePlayerShape = cloneFirstPersonPlayerShape(
this.context?.getRuntimeScene().playerCollider ?? FIRST_PERSON_PLAYER_SHAPE
);
@@ -400,7 +412,7 @@ export class ThirdPersonNavigationController implements NavigationController {
const eyeHeight = getFirstPersonPlayerEyeHeight(this.activePlayerShape);
const pivot = {
x: this.feetPosition.x,
y: this.feetPosition.y + eyeHeight * CAMERA_PIVOT_EYE_HEIGHT_FACTOR,
y: this.smoothedFeetY + eyeHeight * CAMERA_PIVOT_EYE_HEIGHT_FACTOR,
z: this.feetPosition.z
};
const horizontalDistance =