From 1487328ec79c342a009c93c81d832f5af2151c16 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 00:14:38 -0400 Subject: [PATCH] fix: never drop walkthrough player below the site ground plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../editor/first-person-controls.tsx | 24 +++++++++++++++++++ .../first-person/build-collider-world.test.ts | 9 ++++--- .../first-person/build-collider-world.ts | 11 +++++---- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index e1793c5d..0bcd06b3 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -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') diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index c76d50f1..2a5d2ae1 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -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() }) }) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 666e3de9..0e439eaa 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -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)