fix: never drop walkthrough player below the site ground plane

The site ground collider was sized to the scene footprint with a 30 m
minimum — exactly the default site polygon, so one step past the site
boundary left nothing under the character controller and the player
fell into the void. The ground collider now extends 2 km (still a
single BVH box) so the ground plane acts unbounded, and a respawn net
in the first-person frame loop recovers the controller if it ever ends
up below every collider (e.g. scenes with no site node), preferring the
live spawn node over the mount-time start position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-10 00:14:38 -04:00
co-authored by Claude Fable 5
parent 478f0910f7
commit 1487328ec7
3 changed files with 37 additions and 7 deletions
@@ -78,6 +78,7 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
const VOID_FALL_RESPAWN_DEPTH = 12
const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
@@ -1217,6 +1218,29 @@ export const FirstPersonControls = () => {
if (!controllerRef.current?.group) return
const group = controllerRef.current.group
// The site ground collider is effectively unbounded, but scenes without a
// site node only have finite fallback floors — if the controller still ends
// up below every collider it can never land, so put it back at the spawn.
// Prefer the live spawn node over the mount-time start position so a spawn
// moved mid-walkthrough doesn't respawn the player at stale coordinates.
const worldBounds = worldRef.current?.bounds
if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) {
const respawnPosition = placedSpawn
? [
placedSpawn.position[0],
placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE,
placedSpawn.position[2],
]
: controllerStart?.position
if (respawnPosition) {
group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!)
controllerRef.current.resetLinVel()
ridingElevatorRef.current = null
setElevatorRideLocked(false)
}
}
group.rotation.y = 0
camera.position.copy(group.position).add(cameraOffset)
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
@@ -141,9 +141,12 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
// Ground slab sits just below the site ground plane (y = 0).
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
expect(world?.bounds?.max.y).toBeCloseTo(0)
// Default site footprint falls back to the 30 m minimum size.
expect(world?.bounds?.min.x).toBeCloseTo(-15)
expect(world?.bounds?.max.x).toBeCloseTo(15)
// The ground collider extends far past the site polygon so stepping out of
// the site boundary never drops the player below the ground plane.
expect(world?.bounds?.min.x).toBeCloseTo(-1000)
expect(world?.bounds?.max.x).toBeCloseTo(1000)
expect(world?.bounds?.min.z).toBeCloseTo(-1000)
expect(world?.bounds?.max.z).toBeCloseTo(1000)
world?.dispose()
})
})
@@ -27,6 +27,7 @@ const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08
const LEVEL_FALLBACK_FLOOR_PADDING = 2
const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
const SITE_GROUND_COLLIDER_MIN_SIZE = 2000
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
@@ -129,8 +130,10 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
// a dedicated collider, a spawn on the bare ground (no slab, or not parented to
// a level that triggers the per-level fallback) has no floor to stand on and the
// walkthrough player falls through. Derive a thin ground slab from node data (not
// the rendered mesh) so it exists regardless of geometry-mount timing, sized to
// cover the whole scene footprint at the site's ground plane.
// the rendered mesh) so it exists regardless of geometry-mount timing. The slab
// is effectively unbounded (not sized to the site polygon): the ground plane must
// keep holding the player up even after they step past the site boundary,
// otherwise they fall below the ground plane into the void.
function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
if (site.visible === false) return null
@@ -142,11 +145,11 @@ function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
const width = Math.max(
boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
SITE_GROUND_COLLIDER_MIN_SIZE,
)
const depth = Math.max(
boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
SITE_GROUND_COLLIDER_MIN_SIZE,
)
const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)