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 =

View File

@@ -626,6 +626,103 @@ describe("RapierCollisionWorld", () => {
}
});
it("autosteps up authored stairs when the top surface is within the max step height", async () => {
const floorBrush = createBoxBrush({
id: "brush-floor-stair-climb",
center: {
x: 0,
y: -0.5,
z: 0
},
size: {
x: 8,
y: 1,
z: 10
}
});
const stepOne = createBoxBrush({
id: "brush-stair-step-one",
center: {
x: 0,
y: 0.1,
z: 0.75
},
size: {
x: 2,
y: 0.2,
z: 0.5
}
});
const stepTwo = createBoxBrush({
id: "brush-stair-step-two",
center: {
x: 0,
y: 0.3,
z: 1.25
},
size: {
x: 2,
y: 0.6,
z: 0.5
}
});
const stepThree = createBoxBrush({
id: "brush-stair-step-three",
center: {
x: 0,
y: 0.5,
z: 1.75
},
size: {
x: 2,
y: 1,
z: 0.5
}
});
const runtimeScene = buildRuntimeSceneFromDocument({
...createEmptySceneDocument({ name: "Stair Climb Scene" }),
brushes: {
[floorBrush.id]: floorBrush,
[stepOne.id]: stepOne,
[stepTwo.id]: stepTwo,
[stepThree.id]: stepThree
}
});
const collisionWorld = await RapierCollisionWorld.create(
runtimeScene.colliders,
runtimeScene.playerCollider,
{
maxStepHeight: 0.25
}
);
try {
let feetPosition = {
x: 0,
y: 0,
z: -0.4
};
for (let index = 0; index < 10; index += 1) {
feetPosition = collisionWorld.resolveFirstPersonMotion(
feetPosition,
{
x: 0,
y: 0,
z: 0.25
},
runtimeScene.playerCollider
).feetPosition;
}
expect(feetPosition.z).toBeGreaterThan(1.6);
expect(feetPosition.y).toBeGreaterThan(0.45);
expect(feetPosition.y).toBeLessThan(0.62);
} finally {
collisionWorld.dispose();
}
});
it("supports authored Player Start collision mode none without world clipping", async () => {
const wallBrush = createBoxBrush({
id: "brush-wall-no-collision",

View File

@@ -11,6 +11,7 @@ import { createIdleRuntimeLocomotionState } from "../../src/runtime-three/player
import { stepPlayerLocomotion } from "../../src/runtime-three/player-locomotion";
import type { PlayerStartActionInputState } from "../../src/runtime-three/player-input-bindings";
import type { RuntimePlayerMovement } from "../../src/runtime-three/runtime-scene-build";
import { smoothGroundedStairHeight } from "../../src/runtime-three/stair-height-smoothing";
const movementTemplate = createPlayerStartMovementTemplate();
@@ -638,6 +639,43 @@ describe("player-locomotion", () => {
expect(step?.locomotionState.planarSpeed).toBeCloseTo(4.5);
});
it("smooths grounded stair height changes instead of snapping", () => {
const smoothedHeight = smoothGroundedStairHeight({
currentSmoothedFeetY: 0,
targetFeetY: 0.2,
grounded: true,
dt: 1 / 60,
maxStepHeight: 0.5
});
expect(smoothedHeight).toBeGreaterThan(0);
expect(smoothedHeight).toBeLessThan(0.2);
});
it("snaps grounded height smoothing when the player leaves the ground", () => {
const smoothedHeight = smoothGroundedStairHeight({
currentSmoothedFeetY: 0,
targetFeetY: 0.2,
grounded: false,
dt: 1 / 60,
maxStepHeight: 0.5
});
expect(smoothedHeight).toBeCloseTo(0.2);
});
it("snaps grounded height smoothing for ledge-sized vertical jumps", () => {
const smoothedHeight = smoothGroundedStairHeight({
currentSmoothedFeetY: 0,
targetFeetY: 1,
grounded: true,
dt: 1 / 60,
maxStepHeight: 0.35
});
expect(smoothedHeight).toBeCloseTo(1);
});
it("sinks toward the water surface while keeping the head above water", () => {
const step = stepPlayerLocomotion({
dt: 0.1,