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:
co-authored by
Claude Fable 5
parent
478f0910f7
commit
1487328ec7
@@ -78,6 +78,7 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
|
|||||||
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
|
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
|
||||||
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
|
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
|
||||||
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
||||||
|
const VOID_FALL_RESPAWN_DEPTH = 12
|
||||||
const keyboardMap = [
|
const keyboardMap = [
|
||||||
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||||
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||||
@@ -1217,6 +1218,29 @@ export const FirstPersonControls = () => {
|
|||||||
if (!controllerRef.current?.group) return
|
if (!controllerRef.current?.group) return
|
||||||
|
|
||||||
const group = controllerRef.current.group
|
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
|
group.rotation.y = 0
|
||||||
camera.position.copy(group.position).add(cameraOffset)
|
camera.position.copy(group.position).add(cameraOffset)
|
||||||
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
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).
|
// Ground slab sits just below the site ground plane (y = 0).
|
||||||
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
|
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
|
||||||
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
||||||
// Default site footprint falls back to the 30 m minimum size.
|
// The ground collider extends far past the site polygon so stepping out of
|
||||||
expect(world?.bounds?.min.x).toBeCloseTo(-15)
|
// the site boundary never drops the player below the ground plane.
|
||||||
expect(world?.bounds?.max.x).toBeCloseTo(15)
|
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()
|
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_THICKNESS = 0.08
|
||||||
const LEVEL_FALLBACK_FLOOR_PADDING = 2
|
const LEVEL_FALLBACK_FLOOR_PADDING = 2
|
||||||
const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
|
const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
|
||||||
|
const SITE_GROUND_COLLIDER_MIN_SIZE = 2000
|
||||||
|
|
||||||
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
|
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 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
|
// 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
|
// 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
|
// the rendered mesh) so it exists regardless of geometry-mount timing. The slab
|
||||||
// cover the whole scene footprint at the site's ground plane.
|
// 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) {
|
function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
|
||||||
if (site.visible === false) return null
|
if (site.visible === false) return null
|
||||||
|
|
||||||
@@ -142,11 +145,11 @@ function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
|
|||||||
const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
|
const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
|
||||||
const width = Math.max(
|
const width = Math.max(
|
||||||
boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
||||||
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
|
SITE_GROUND_COLLIDER_MIN_SIZE,
|
||||||
)
|
)
|
||||||
const depth = Math.max(
|
const depth = Math.max(
|
||||||
boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
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)
|
const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
|
||||||
|
|||||||
Reference in New Issue
Block a user