feat(viewer): GLB walkthrough in viewer, monochrome, spawn/export polish
Move the first-person walkthrough into @pascal-app/viewer (BVHEcctrl + GlbWalkthroughController) and round out the GLB-consuming viewer: - Walkthrough: fallback ground only on level 0 (upper floors rely on baked slabs), hidden spawn marker, auto pointer-lock on enter, single-Esc exit via pointerlockchange, force perspective on enter / restore on exit. - GlbScene: monochrome strips baked textures and recolours meshes by surface role using the active theme's clay tints; spawn node hidden from render. - glb-export: camera/label/spawn identity extras for the viewer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6cfc6ae01b
commit
5aedc366a6
@@ -45,6 +45,7 @@ import {
|
||||
} from 'three'
|
||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||
import '../../three-types'
|
||||
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
|
||||
import {
|
||||
closeDoorOpenState,
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
@@ -64,8 +65,6 @@ import {
|
||||
type FirstPersonColliderWorld,
|
||||
type FirstPersonSpawn,
|
||||
} from './first-person/build-collider-world'
|
||||
import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl'
|
||||
import BVHEcctrl from './first-person/bvh-ecctrl'
|
||||
|
||||
const CAMERA_EYE_OFFSET = 0.45
|
||||
const LOOK_SENSITIVITY = 0.002
|
||||
|
||||
@@ -1,860 +0,0 @@
|
||||
import '../../../three-types'
|
||||
import { TransformControls, useKeyboardControls } from '@react-three/drei'
|
||||
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import type { ReactNode } from 'react'
|
||||
import { forwardRef, Suspense, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { clamp } from 'three/src/math/MathUtils.js'
|
||||
|
||||
export type MovementInput = {
|
||||
forward?: boolean
|
||||
backward?: boolean
|
||||
leftward?: boolean
|
||||
rightward?: boolean
|
||||
joystick?: { x: number; y: number }
|
||||
run?: boolean
|
||||
jump?: boolean
|
||||
}
|
||||
|
||||
export type CharacterAnimationStatus =
|
||||
| 'IDLE'
|
||||
| 'WALK'
|
||||
| 'RUN'
|
||||
| 'JUMP_START'
|
||||
| 'JUMP_IDLE'
|
||||
| 'JUMP_FALL'
|
||||
| 'JUMP_LAND'
|
||||
|
||||
export type FloatCheckType = 'RAYCAST' | 'SHAPECAST' | 'BOTH'
|
||||
|
||||
export interface BVHEcctrlApi {
|
||||
group: THREE.Group | null
|
||||
model: THREE.Group | null
|
||||
resetLinVel: () => void
|
||||
addLinVel: (v: THREE.Vector3) => void
|
||||
setLinVel: (v: THREE.Vector3) => void
|
||||
setMovement: (input: MovementInput) => void
|
||||
}
|
||||
|
||||
export interface EcctrlProps extends Omit<ThreeElements['group'], 'ref'> {
|
||||
children?: ReactNode
|
||||
debug?: boolean
|
||||
colliderMeshes?: THREE.Mesh[]
|
||||
colliderCapsuleArgs?: [
|
||||
radius: number,
|
||||
length: number,
|
||||
capSegments: number,
|
||||
radialSegments: number,
|
||||
]
|
||||
paused?: boolean
|
||||
delay?: number
|
||||
gravity?: number
|
||||
fallGravityFactor?: number
|
||||
maxFallSpeed?: number
|
||||
mass?: number
|
||||
sleepTimeout?: number
|
||||
slowMotionFactor?: number
|
||||
turnSpeed?: number
|
||||
maxWalkSpeed?: number
|
||||
maxRunSpeed?: number
|
||||
acceleration?: number
|
||||
deceleration?: number
|
||||
counterAccFactor?: number
|
||||
airDragFactor?: number
|
||||
jumpVel?: number
|
||||
floatCheckType?: FloatCheckType
|
||||
maxSlope?: number
|
||||
floatHeight?: number
|
||||
floatPullBackHeight?: number
|
||||
floatSensorRadius?: number
|
||||
floatSpringK?: number
|
||||
floatDampingC?: number
|
||||
collisionCheckIteration?: number
|
||||
collisionPushBackDamping?: number
|
||||
collisionPushBackThreshold?: number
|
||||
}
|
||||
|
||||
type CharacterStatus = {
|
||||
position: THREE.Vector3
|
||||
linvel: THREE.Vector3
|
||||
quaternion: THREE.Quaternion
|
||||
inputDir: THREE.Vector3
|
||||
movingDir: THREE.Vector3
|
||||
isOnGround: boolean
|
||||
isOnMovingPlatform: boolean
|
||||
animationStatus: CharacterAnimationStatus
|
||||
}
|
||||
|
||||
export const characterStatus: CharacterStatus = {
|
||||
position: new THREE.Vector3(),
|
||||
linvel: new THREE.Vector3(),
|
||||
quaternion: new THREE.Quaternion(),
|
||||
inputDir: new THREE.Vector3(),
|
||||
movingDir: new THREE.Vector3(),
|
||||
isOnGround: false,
|
||||
isOnMovingPlatform: false,
|
||||
animationStatus: 'IDLE',
|
||||
}
|
||||
|
||||
const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
debug = false,
|
||||
colliderMeshes = [],
|
||||
colliderCapsuleArgs = [0.3, 0.6, 4, 8],
|
||||
paused = false,
|
||||
delay = 1.5,
|
||||
gravity = 9.81,
|
||||
fallGravityFactor = 4,
|
||||
maxFallSpeed = 50,
|
||||
mass = 1,
|
||||
sleepTimeout = 10,
|
||||
slowMotionFactor = 1,
|
||||
turnSpeed = 15,
|
||||
maxWalkSpeed = 3,
|
||||
maxRunSpeed = 5,
|
||||
acceleration = 30,
|
||||
deceleration = 20,
|
||||
counterAccFactor = 0.5,
|
||||
airDragFactor = 0.3,
|
||||
jumpVel = 5,
|
||||
floatCheckType = 'BOTH',
|
||||
maxSlope = 1,
|
||||
floatHeight = 0.2,
|
||||
floatPullBackHeight = 0.25,
|
||||
floatSensorRadius = 0.12,
|
||||
floatSpringK = 600,
|
||||
floatDampingC = 28,
|
||||
collisionCheckIteration = 3,
|
||||
collisionPushBackDamping = 0.1,
|
||||
collisionPushBackThreshold = 0.05,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { camera } = useThree()
|
||||
const capsuleRadius = useMemo(() => colliderCapsuleArgs[0], [colliderCapsuleArgs])
|
||||
const capsuleLength = useMemo(() => colliderCapsuleArgs[1], [colliderCapsuleArgs])
|
||||
const characterGroupRef = useRef<THREE.Group | null>(null)
|
||||
const characterColliderRef = useRef<THREE.Mesh | null>(null)
|
||||
const characterModelRef = useRef<THREE.Group | null>(null)
|
||||
const debugLineStart = useRef<THREE.Mesh | null>(null)
|
||||
const debugLineEnd = useRef<THREE.Mesh | null>(null)
|
||||
const debugRaySensorStart = useRef<THREE.Mesh | null>(null)
|
||||
const debugRaySensorEnd = useRef<THREE.Mesh | null>(null)
|
||||
const standPointRef = useRef<THREE.Mesh | null>(null)
|
||||
const lookDirRef = useRef<THREE.Mesh | null>(null)
|
||||
const inputDirRef = useRef<THREE.ArrowHelper | null>(null)
|
||||
const moveDirRef = useRef<THREE.ArrowHelper | null>(null)
|
||||
const elapsedRef = useRef(0)
|
||||
|
||||
const [, getKeys] = useKeyboardControls()
|
||||
const presetKeys = {
|
||||
forward: false,
|
||||
backward: false,
|
||||
leftward: false,
|
||||
rightward: false,
|
||||
jump: false,
|
||||
run: false,
|
||||
}
|
||||
|
||||
const upAxis = useRef(new THREE.Vector3(0, 1, 0))
|
||||
const localUpAxis = useRef(new THREE.Vector3())
|
||||
const gravityDir = useRef(new THREE.Vector3(0, -1, 0))
|
||||
const currentLinVel = useRef(new THREE.Vector3())
|
||||
const currentLinVelOnPlane = useRef(new THREE.Vector3())
|
||||
const isFalling = useRef(false)
|
||||
const idleTime = useRef(0)
|
||||
const isSleeping = useRef(false)
|
||||
const camProjDir = useRef(new THREE.Vector3())
|
||||
const camRightDir = useRef(new THREE.Vector3())
|
||||
const inputDir = useRef(new THREE.Vector3())
|
||||
const inputDirOnPlane = useRef(new THREE.Vector3())
|
||||
const movingDir = useRef(new THREE.Vector3())
|
||||
const deltaLinVel = useRef(new THREE.Vector3())
|
||||
const wantToMoveVel = useRef(new THREE.Vector3())
|
||||
const forwardState = useRef(false)
|
||||
const backwardState = useRef(false)
|
||||
const leftwardState = useRef(false)
|
||||
const rightwardState = useRef(false)
|
||||
const joystickState = useRef(new THREE.Vector2())
|
||||
const runState = useRef(false)
|
||||
const jumpState = useRef(false)
|
||||
const isOnGround = useRef(false)
|
||||
const prevIsOnGround = useRef(false)
|
||||
const prevAnimation = useRef<CharacterAnimationStatus>('IDLE')
|
||||
const characterModelTargetQuat = useRef(new THREE.Quaternion())
|
||||
const characterModelLookMatrix = useRef(new THREE.Matrix4())
|
||||
const characterOrigin = useMemo(() => new THREE.Vector3(0, 0, 0), [])
|
||||
const contactDepth = useRef(0)
|
||||
const contactNormal = useRef(new THREE.Vector3())
|
||||
const triContactPoint = useRef(new THREE.Vector3())
|
||||
const capsuleContactPoint = useRef(new THREE.Vector3())
|
||||
const totalDepth = useRef(0)
|
||||
const triangleCount = useRef(0)
|
||||
const accumulatedContactNormal = useRef(new THREE.Vector3())
|
||||
const accumulatedContactPoint = useRef(new THREE.Vector3())
|
||||
const absorbVel = useRef(new THREE.Vector3())
|
||||
const pushBackVel = useRef(new THREE.Vector3())
|
||||
const characterBbox = useRef(new THREE.Box3())
|
||||
const characterSegment = useRef(new THREE.Line3())
|
||||
const localCharacterBbox = useRef(new THREE.Box3())
|
||||
const localCharacterSegment = useRef(new THREE.Line3())
|
||||
const collideInvertMatrix = useRef(new THREE.Matrix4())
|
||||
const relativeCollideVel = useRef(new THREE.Vector3())
|
||||
const scaledContactRadiusVec = useRef(new THREE.Vector3())
|
||||
const deltaDist = useRef(new THREE.Vector3())
|
||||
const currSlopeAngle = useRef(0)
|
||||
const localMinDistance = useRef(Number.POSITIVE_INFINITY)
|
||||
const localClosestPoint = useRef(new THREE.Vector3())
|
||||
const localHitNormal = useRef(new THREE.Vector3())
|
||||
const triNormal = useRef(new THREE.Vector3())
|
||||
const globalMinDistance = useRef(Number.POSITIVE_INFINITY)
|
||||
const globalClosestPoint = useRef(new THREE.Vector3())
|
||||
const triHitPoint = useRef(new THREE.Vector3())
|
||||
const segHitPoint = useRef(new THREE.Vector3())
|
||||
const floatHitNormal = useRef(new THREE.Vector3())
|
||||
const groundFriction = useRef(0.8)
|
||||
const floatSensorBbox = useRef(new THREE.Box3())
|
||||
const floatSensorBboxExpendPoint = useRef(new THREE.Vector3())
|
||||
const floatSensorSegment = useRef(new THREE.Line3())
|
||||
const localFloatSensorBbox = useRef(new THREE.Box3())
|
||||
const localFloatSensorBboxExpendPoint = useRef(new THREE.Vector3())
|
||||
const localFloatSensorSegment = useRef(new THREE.Line3())
|
||||
const floatInvertMatrix = useRef(new THREE.Matrix4())
|
||||
const floatNormalInverseMatrix = useRef(new THREE.Matrix3())
|
||||
const floatNormalMatrix = useRef(new THREE.Matrix3())
|
||||
const floatRaycaster = useRef(new THREE.Raycaster())
|
||||
const relativeHitPoint = useRef(new THREE.Vector3())
|
||||
const totalPlatformDeltaPos = useRef(new THREE.Vector3())
|
||||
const isOnMovingPlatform = useRef(false)
|
||||
const floatTempPos = useRef(new THREE.Vector3())
|
||||
const floatTempQuat = useRef(new THREE.Quaternion())
|
||||
const floatTempScale = useRef(new THREE.Vector3())
|
||||
const scaledFloatRadiusVec = useRef(new THREE.Vector3())
|
||||
const deltaHit = useRef(new THREE.Vector3())
|
||||
const rotationDeltaPos = useRef(new THREE.Vector3())
|
||||
const yawQuaternion = useRef(new THREE.Quaternion())
|
||||
const contactTempPos = useRef(new THREE.Vector3())
|
||||
const contactTempQuat = useRef(new THREE.Quaternion())
|
||||
const contactTempScale = useRef(new THREE.Vector3())
|
||||
|
||||
floatRaycaster.current.far = capsuleRadius + floatHeight + floatPullBackHeight
|
||||
|
||||
const floatRaycastCandidates = useMemo(
|
||||
() =>
|
||||
colliderMeshes.filter(
|
||||
(mesh) => mesh.geometry.boundsTree && !(mesh instanceof THREE.InstancedMesh),
|
||||
),
|
||||
[colliderMeshes],
|
||||
)
|
||||
|
||||
const applyGravity = useCallback(
|
||||
(delta: number) => {
|
||||
gravityDir.current.copy(upAxis.current).negate()
|
||||
const fallingSpeed = currentLinVel.current.dot(gravityDir.current)
|
||||
isFalling.current = fallingSpeed > 0
|
||||
if (fallingSpeed < maxFallSpeed) {
|
||||
currentLinVel.current.addScaledVector(
|
||||
gravityDir.current,
|
||||
gravity * (isFalling.current ? fallGravityFactor : 1) * delta,
|
||||
)
|
||||
}
|
||||
},
|
||||
[fallGravityFactor, gravity, maxFallSpeed],
|
||||
)
|
||||
|
||||
const checkCharacterSleep = useCallback(
|
||||
(jump: boolean, delta: number) => {
|
||||
const moving = currentLinVel.current.lengthSq() > 1e-6
|
||||
const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6
|
||||
|
||||
if (
|
||||
!moving &&
|
||||
isOnGround.current &&
|
||||
!jump &&
|
||||
!isOnMovingPlatform.current &&
|
||||
!platformIsMoving
|
||||
) {
|
||||
idleTime.current += delta
|
||||
if (idleTime.current > sleepTimeout) isSleeping.current = true
|
||||
} else {
|
||||
idleTime.current = 0
|
||||
isSleeping.current = false
|
||||
}
|
||||
},
|
||||
[sleepTimeout],
|
||||
)
|
||||
|
||||
const setInputDirection = useCallback(
|
||||
(dir: {
|
||||
forward?: boolean
|
||||
backward?: boolean
|
||||
leftward?: boolean
|
||||
rightward?: boolean
|
||||
joystick?: THREE.Vector2
|
||||
}) => {
|
||||
inputDir.current.set(0, 0, 0)
|
||||
|
||||
camera.getWorldDirection(camProjDir.current)
|
||||
camProjDir.current.projectOnPlane(upAxis.current).normalize()
|
||||
camRightDir.current.crossVectors(camProjDir.current, upAxis.current).normalize()
|
||||
|
||||
if (dir.joystick && dir.joystick.lengthSq() > 0) {
|
||||
inputDir.current
|
||||
.addScaledVector(camProjDir.current, dir.joystick.y)
|
||||
.addScaledVector(camRightDir.current, dir.joystick.x)
|
||||
} else {
|
||||
if (dir.forward) inputDir.current.add(camProjDir.current)
|
||||
if (dir.backward) inputDir.current.sub(camProjDir.current)
|
||||
if (dir.leftward) inputDir.current.sub(camRightDir.current)
|
||||
if (dir.rightward) inputDir.current.add(camRightDir.current)
|
||||
}
|
||||
|
||||
inputDir.current.normalize()
|
||||
},
|
||||
[camera],
|
||||
)
|
||||
|
||||
const handleCharacterMovement = useCallback(
|
||||
(run: boolean, delta: number) => {
|
||||
const friction = clamp(groundFriction.current, 0, 1)
|
||||
|
||||
if (inputDir.current.lengthSq() > 0) {
|
||||
if (characterModelRef.current) {
|
||||
inputDirOnPlane.current.copy(inputDir.current).projectOnPlane(upAxis.current)
|
||||
characterModelLookMatrix.current.lookAt(
|
||||
inputDirOnPlane.current,
|
||||
characterOrigin,
|
||||
upAxis.current,
|
||||
)
|
||||
characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current)
|
||||
characterModelRef.current.quaternion.slerp(
|
||||
characterModelTargetQuat.current,
|
||||
delta * turnSpeed,
|
||||
)
|
||||
}
|
||||
|
||||
const maxSpeed = run ? maxRunSpeed : maxWalkSpeed
|
||||
wantToMoveVel.current.copy(inputDir.current).multiplyScalar(maxSpeed)
|
||||
const dot = movingDir.current.dot(inputDir.current)
|
||||
|
||||
deltaLinVel.current.subVectors(wantToMoveVel.current, currentLinVelOnPlane.current)
|
||||
deltaLinVel.current.clampLength(
|
||||
0,
|
||||
(dot <= 0 ? 1 + counterAccFactor : 1) *
|
||||
acceleration *
|
||||
friction *
|
||||
delta *
|
||||
(isOnGround.current ? 1 : airDragFactor),
|
||||
)
|
||||
currentLinVel.current.add(deltaLinVel.current)
|
||||
} else if (isOnGround.current) {
|
||||
deltaLinVel.current
|
||||
.copy(currentLinVelOnPlane.current)
|
||||
.clampLength(0, deceleration * friction * delta)
|
||||
currentLinVel.current.sub(deltaLinVel.current)
|
||||
}
|
||||
},
|
||||
[
|
||||
acceleration,
|
||||
airDragFactor,
|
||||
counterAccFactor,
|
||||
deceleration,
|
||||
maxRunSpeed,
|
||||
maxWalkSpeed,
|
||||
turnSpeed,
|
||||
characterOrigin,
|
||||
],
|
||||
)
|
||||
|
||||
const updateSegmentBBox = useCallback(() => {
|
||||
if (!characterGroupRef.current) return
|
||||
|
||||
characterSegment.current.start
|
||||
.set(0, capsuleLength / 2, 0)
|
||||
.add(characterGroupRef.current.position)
|
||||
characterSegment.current.end
|
||||
.set(0, -capsuleLength / 2, 0)
|
||||
.add(characterGroupRef.current.position)
|
||||
|
||||
characterBbox.current
|
||||
.makeEmpty()
|
||||
.expandByPoint(characterSegment.current.start)
|
||||
.expandByPoint(characterSegment.current.end)
|
||||
.expandByScalar(capsuleRadius)
|
||||
|
||||
floatSensorSegment.current.start.copy(characterSegment.current.end)
|
||||
floatSensorSegment.current.end
|
||||
.copy(floatSensorSegment.current.start)
|
||||
.addScaledVector(gravityDir.current, floatHeight + capsuleRadius)
|
||||
floatSensorBboxExpendPoint.current
|
||||
.copy(floatSensorSegment.current.end)
|
||||
.addScaledVector(gravityDir.current, floatPullBackHeight)
|
||||
|
||||
floatSensorBbox.current
|
||||
.makeEmpty()
|
||||
.expandByPoint(floatSensorSegment.current.start)
|
||||
.expandByPoint(floatSensorBboxExpendPoint.current)
|
||||
.expandByScalar(floatSensorRadius)
|
||||
}, [capsuleLength, capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius])
|
||||
|
||||
const collisionCheck = useCallback(
|
||||
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => {
|
||||
if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeCollisionCheck)
|
||||
return
|
||||
|
||||
originMatrix.decompose(
|
||||
contactTempPos.current,
|
||||
contactTempQuat.current,
|
||||
contactTempScale.current,
|
||||
)
|
||||
collideInvertMatrix.current.copy(originMatrix).invert()
|
||||
localCharacterSegment.current
|
||||
.copy(characterSegment.current)
|
||||
.applyMatrix4(collideInvertMatrix.current)
|
||||
|
||||
scaledContactRadiusVec.current.set(
|
||||
capsuleRadius / contactTempScale.current.x,
|
||||
capsuleRadius / contactTempScale.current.y,
|
||||
capsuleRadius / contactTempScale.current.z,
|
||||
)
|
||||
|
||||
localCharacterBbox.current
|
||||
.makeEmpty()
|
||||
.expandByPoint(localCharacterSegment.current.start)
|
||||
.expandByPoint(localCharacterSegment.current.end)
|
||||
localCharacterBbox.current.min.addScaledVector(scaledContactRadiusVec.current, -1)
|
||||
localCharacterBbox.current.max.add(scaledContactRadiusVec.current)
|
||||
|
||||
contactDepth.current = 0
|
||||
contactNormal.current.set(0, 0, 0)
|
||||
absorbVel.current.set(0, 0, 0)
|
||||
pushBackVel.current.set(0, 0, 0)
|
||||
totalDepth.current = 0
|
||||
triangleCount.current = 0
|
||||
accumulatedContactNormal.current.set(0, 0, 0)
|
||||
accumulatedContactPoint.current.set(0, 0, 0)
|
||||
|
||||
mesh.geometry.boundsTree.shapecast({
|
||||
intersectsBounds: (box) => box.intersectsBox(localCharacterBbox.current),
|
||||
intersectsTriangle: (tri) => {
|
||||
tri.closestPointToSegment(
|
||||
localCharacterSegment.current,
|
||||
triContactPoint.current,
|
||||
capsuleContactPoint.current,
|
||||
)
|
||||
|
||||
deltaDist.current.copy(triContactPoint.current).sub(capsuleContactPoint.current)
|
||||
deltaDist.current.divide(scaledContactRadiusVec.current)
|
||||
|
||||
if (deltaDist.current.lengthSq() < 1) {
|
||||
triContactPoint.current.applyMatrix4(originMatrix)
|
||||
capsuleContactPoint.current.applyMatrix4(originMatrix)
|
||||
|
||||
contactNormal.current
|
||||
.copy(capsuleContactPoint.current)
|
||||
.sub(triContactPoint.current)
|
||||
.normalize()
|
||||
contactDepth.current =
|
||||
capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current)
|
||||
|
||||
accumulatedContactNormal.current.addScaledVector(
|
||||
contactNormal.current,
|
||||
contactDepth.current,
|
||||
)
|
||||
accumulatedContactPoint.current.add(triContactPoint.current)
|
||||
totalDepth.current += contactDepth.current
|
||||
triangleCount.current += 1
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (triangleCount.current > 0) {
|
||||
accumulatedContactNormal.current.normalize()
|
||||
accumulatedContactPoint.current.divideScalar(triangleCount.current)
|
||||
const avgDepth = totalDepth.current / triangleCount.current
|
||||
relativeCollideVel.current.copy(currentLinVel.current)
|
||||
const intoSurfaceVel = relativeCollideVel.current.dot(accumulatedContactNormal.current)
|
||||
|
||||
if (intoSurfaceVel < 0) {
|
||||
absorbVel.current
|
||||
.copy(accumulatedContactNormal.current)
|
||||
.multiplyScalar(-intoSurfaceVel * (1 + (mesh.userData.restitution ?? 0.05)))
|
||||
currentLinVel.current.add(absorbVel.current)
|
||||
}
|
||||
|
||||
if (avgDepth > collisionPushBackThreshold) {
|
||||
const correction = (collisionPushBackDamping / delta) * avgDepth
|
||||
pushBackVel.current.copy(accumulatedContactNormal.current).multiplyScalar(correction)
|
||||
currentLinVel.current.add(pushBackVel.current)
|
||||
}
|
||||
}
|
||||
},
|
||||
[capsuleRadius, collisionPushBackDamping, collisionPushBackThreshold],
|
||||
)
|
||||
|
||||
const handleCollisionResponse = useCallback(
|
||||
(meshes: THREE.Mesh[], delta: number) => {
|
||||
if (meshes.length === 0) return
|
||||
|
||||
for (let iteration = 0; iteration < collisionCheckIteration; iteration += 1) {
|
||||
for (const mesh of meshes) {
|
||||
collisionCheck(mesh, mesh.matrixWorld, delta)
|
||||
}
|
||||
}
|
||||
},
|
||||
[collisionCheck, collisionCheckIteration],
|
||||
)
|
||||
|
||||
const floatingCheck = useCallback(
|
||||
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => {
|
||||
if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeFloatHit) return
|
||||
|
||||
originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current)
|
||||
floatInvertMatrix.current.copy(originMatrix).invert()
|
||||
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
|
||||
floatNormalMatrix.current.getNormalMatrix(originMatrix)
|
||||
|
||||
localFloatSensorSegment.current
|
||||
.copy(floatSensorSegment.current)
|
||||
.applyMatrix4(floatInvertMatrix.current)
|
||||
localFloatSensorBboxExpendPoint.current
|
||||
.copy(floatSensorBboxExpendPoint.current)
|
||||
.applyMatrix4(floatInvertMatrix.current)
|
||||
|
||||
scaledFloatRadiusVec.current.set(
|
||||
floatSensorRadius / floatTempScale.current.x,
|
||||
floatSensorRadius / floatTempScale.current.y,
|
||||
floatSensorRadius / floatTempScale.current.z,
|
||||
)
|
||||
|
||||
localFloatSensorBbox.current
|
||||
.makeEmpty()
|
||||
.expandByPoint(localFloatSensorSegment.current.start)
|
||||
.expandByPoint(localFloatSensorBboxExpendPoint.current)
|
||||
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
|
||||
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
|
||||
|
||||
localMinDistance.current = Number.POSITIVE_INFINITY
|
||||
localClosestPoint.current.set(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
|
||||
mesh.geometry.boundsTree.shapecast({
|
||||
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
|
||||
intersectsTriangle: (tri) => {
|
||||
tri.closestPointToSegment(
|
||||
localFloatSensorSegment.current,
|
||||
triHitPoint.current,
|
||||
segHitPoint.current,
|
||||
)
|
||||
localUpAxis.current
|
||||
.copy(upAxis.current)
|
||||
.applyMatrix3(floatNormalInverseMatrix.current)
|
||||
.normalize()
|
||||
deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start)
|
||||
deltaHit.current.divide(scaledFloatRadiusVec.current)
|
||||
|
||||
const totalLengthSq = deltaHit.current.lengthSq()
|
||||
const dot = deltaHit.current.dot(localUpAxis.current)
|
||||
const verticalLength =
|
||||
Math.abs(dot) /
|
||||
((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
|
||||
const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot))
|
||||
|
||||
if (horizontalLength < 1 && verticalLength < 1) {
|
||||
tri.getNormal(triNormal.current)
|
||||
triNormal.current.applyMatrix3(floatNormalMatrix.current).normalize()
|
||||
triHitPoint.current.applyMatrix4(originMatrix)
|
||||
|
||||
const slopeAngle = triNormal.current.angleTo(upAxis.current)
|
||||
if (verticalLength < localMinDistance.current && slopeAngle < maxSlope) {
|
||||
localMinDistance.current = verticalLength
|
||||
localClosestPoint.current.copy(triHitPoint.current)
|
||||
localHitNormal.current.copy(triNormal.current)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (localMinDistance.current < globalMinDistance.current) {
|
||||
globalMinDistance.current = localMinDistance.current
|
||||
globalClosestPoint.current.copy(localClosestPoint.current)
|
||||
floatHitNormal.current.copy(localHitNormal.current)
|
||||
}
|
||||
},
|
||||
[capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius, maxSlope],
|
||||
)
|
||||
|
||||
const handleFloatingResponse = useCallback(
|
||||
(meshes: THREE.Mesh[], jump: boolean, delta: number) => {
|
||||
if (meshes.length === 0) return
|
||||
let shouldJump = jump
|
||||
|
||||
globalMinDistance.current = Number.POSITIVE_INFINITY
|
||||
globalClosestPoint.current.set(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
floatHitNormal.current.set(0, 1, 0)
|
||||
isOnGround.current = false
|
||||
totalPlatformDeltaPos.current.set(0, 0, 0)
|
||||
isOnMovingPlatform.current = false
|
||||
|
||||
if (floatCheckType !== 'RAYCAST') {
|
||||
for (const mesh of meshes) {
|
||||
floatingCheck(mesh, mesh.matrixWorld)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
floatCheckType !== 'SHAPECAST' &&
|
||||
floatRaycastCandidates.length > 0 &&
|
||||
globalMinDistance.current === Number.POSITIVE_INFINITY
|
||||
) {
|
||||
floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start)
|
||||
floatRaycaster.current.ray.direction.copy(gravityDir.current)
|
||||
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
|
||||
const hit = hits[0]
|
||||
if (hit?.point) {
|
||||
globalClosestPoint.current.copy(hit.point)
|
||||
if (hit.face) {
|
||||
floatHitNormal.current
|
||||
.copy(hit.face.normal)
|
||||
.transformDirection(hit.object.matrixWorld)
|
||||
.normalize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (globalClosestPoint.current.x === Number.POSITIVE_INFINITY) return
|
||||
|
||||
relativeHitPoint.current
|
||||
.copy(globalClosestPoint.current)
|
||||
.sub(floatSensorSegment.current.start)
|
||||
const currentDistance = relativeHitPoint.current.length()
|
||||
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
|
||||
|
||||
if (currentDistance < floatHeight + capsuleRadius) {
|
||||
isOnGround.current = true
|
||||
shouldJump = false
|
||||
}
|
||||
|
||||
if (!shouldJump) {
|
||||
const displacement = floatHeight + capsuleRadius - currentDistance
|
||||
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
|
||||
const springForce = displacement * floatSpringK
|
||||
const dampingForce = -velocityOnHitNormal * floatDampingC
|
||||
const totalForce = springForce + dampingForce - mass * gravity
|
||||
|
||||
currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta)
|
||||
}
|
||||
},
|
||||
[
|
||||
capsuleRadius,
|
||||
floatCheckType,
|
||||
floatDampingC,
|
||||
floatHeight,
|
||||
floatRaycastCandidates,
|
||||
floatSpringK,
|
||||
floatingCheck,
|
||||
gravity,
|
||||
mass,
|
||||
],
|
||||
)
|
||||
|
||||
const updateCharacterWithPlatform = useCallback(() => {
|
||||
if (!characterGroupRef.current) return
|
||||
rotationDeltaPos.current.copy(totalPlatformDeltaPos.current)
|
||||
characterGroupRef.current.position.add(rotationDeltaPos.current)
|
||||
yawQuaternion.current.setFromUnitVectors(upAxis.current, floatHitNormal.current)
|
||||
}, [])
|
||||
|
||||
const updateCharacterAnimation = useCallback(
|
||||
(run: boolean, jump: boolean): CharacterAnimationStatus => {
|
||||
if (prevIsOnGround.current && jump) return 'JUMP_START'
|
||||
if (!isOnGround.current && currentLinVel.current.y > 0) return 'JUMP_IDLE'
|
||||
if (!isOnGround.current && currentLinVel.current.y <= 0) return 'JUMP_FALL'
|
||||
if (!prevIsOnGround.current && isOnGround.current) return 'JUMP_LAND'
|
||||
if (inputDir.current.lengthSq() > 0) return run ? 'RUN' : 'WALK'
|
||||
return 'IDLE'
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const updateCharacterStatus = useCallback(
|
||||
(run: boolean, jump: boolean) => {
|
||||
characterModelRef.current?.getWorldPosition(characterStatus.position)
|
||||
characterModelRef.current?.getWorldQuaternion(characterStatus.quaternion)
|
||||
characterStatus.linvel.copy(currentLinVel.current)
|
||||
characterStatus.inputDir.copy(inputDir.current)
|
||||
characterStatus.movingDir.copy(movingDir.current)
|
||||
characterStatus.isOnGround = isOnGround.current
|
||||
characterStatus.isOnMovingPlatform = isOnMovingPlatform.current
|
||||
characterStatus.animationStatus = updateCharacterAnimation(run, jump)
|
||||
prevAnimation.current = characterStatus.animationStatus
|
||||
},
|
||||
[updateCharacterAnimation],
|
||||
)
|
||||
|
||||
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
|
||||
const addLinVel = useCallback(
|
||||
(velocity: THREE.Vector3) => currentLinVel.current.add(velocity),
|
||||
[],
|
||||
)
|
||||
const setLinVel = useCallback(
|
||||
(velocity: THREE.Vector3) => currentLinVel.current.copy(velocity),
|
||||
[],
|
||||
)
|
||||
const setMovement = useCallback((movement: MovementInput) => {
|
||||
if (movement.forward !== undefined) forwardState.current = movement.forward
|
||||
if (movement.backward !== undefined) backwardState.current = movement.backward
|
||||
if (movement.leftward !== undefined) leftwardState.current = movement.leftward
|
||||
if (movement.rightward !== undefined) rightwardState.current = movement.rightward
|
||||
if (movement.joystick) joystickState.current.set(movement.joystick.x, movement.joystick.y)
|
||||
if (movement.run !== undefined) runState.current = movement.run
|
||||
if (movement.jump !== undefined) jumpState.current = movement.jump
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
get group() {
|
||||
return characterGroupRef.current
|
||||
},
|
||||
get model() {
|
||||
return characterModelRef.current
|
||||
},
|
||||
resetLinVel,
|
||||
addLinVel,
|
||||
setLinVel,
|
||||
setMovement,
|
||||
}),
|
||||
[addLinVel, resetLinVel, setLinVel, setMovement],
|
||||
)
|
||||
|
||||
const updateDebugger = useCallback(() => {
|
||||
debugLineStart.current?.position.copy(characterSegment.current.start)
|
||||
debugLineEnd.current?.position.copy(characterSegment.current.end)
|
||||
debugRaySensorStart.current?.position.copy(floatSensorSegment.current.start)
|
||||
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
|
||||
standPointRef.current?.position.copy(globalClosestPoint.current)
|
||||
if (characterGroupRef.current) {
|
||||
lookDirRef.current?.position
|
||||
.copy(characterGroupRef.current.position)
|
||||
.addScaledVector(upAxis.current, 0.7)
|
||||
}
|
||||
lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current))
|
||||
inputDirRef.current?.position.copy(characterSegment.current.end)
|
||||
inputDirRef.current?.setDirection(inputDir.current)
|
||||
inputDirRef.current?.setLength(inputDir.current.lengthSq())
|
||||
moveDirRef.current?.position.copy(characterSegment.current.end)
|
||||
moveDirRef.current?.setDirection(currentLinVel.current)
|
||||
moveDirRef.current?.setLength(currentLinVel.current.length() / maxWalkSpeed)
|
||||
}, [maxWalkSpeed])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
elapsedRef.current += delta
|
||||
if (paused || elapsedRef.current < delay) return
|
||||
|
||||
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
|
||||
const keys = getKeys() ?? presetKeys
|
||||
const forward = forwardState.current || (keys.forward ?? false)
|
||||
const backward = backwardState.current || (keys.backward ?? false)
|
||||
const leftward = leftwardState.current || (keys.leftward ?? false)
|
||||
const rightward = rightwardState.current || (keys.rightward ?? false)
|
||||
const run = runState.current || (keys.run ?? false)
|
||||
const jump = jumpState.current || (keys.jump ?? false)
|
||||
|
||||
setInputDirection({
|
||||
forward,
|
||||
backward,
|
||||
leftward,
|
||||
rightward,
|
||||
joystick: joystickState.current,
|
||||
})
|
||||
handleCharacterMovement(run, deltaTime)
|
||||
if (jump && isOnGround.current) currentLinVel.current.y = jumpVel
|
||||
movingDir.current.copy(currentLinVel.current).normalize()
|
||||
currentLinVelOnPlane.current.copy(currentLinVel.current).projectOnPlane(upAxis.current)
|
||||
|
||||
checkCharacterSleep(jump, deltaTime)
|
||||
if (!isSleeping.current) {
|
||||
if (!isOnGround.current) applyGravity(deltaTime)
|
||||
|
||||
updateSegmentBBox()
|
||||
handleCollisionResponse(colliderMeshes, deltaTime)
|
||||
handleFloatingResponse(colliderMeshes, jump, deltaTime)
|
||||
updateCharacterWithPlatform()
|
||||
|
||||
if (characterGroupRef.current) {
|
||||
characterGroupRef.current.position.addScaledVector(currentLinVel.current, deltaTime)
|
||||
}
|
||||
|
||||
updateCharacterStatus(run, jump)
|
||||
prevIsOnGround.current = isOnGround.current
|
||||
}
|
||||
|
||||
if (debug) updateDebugger()
|
||||
})
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<group {...props} dispose={null} ref={characterGroupRef}>
|
||||
{debug && (
|
||||
<mesh ref={characterColliderRef}>
|
||||
<capsuleGeometry args={colliderCapsuleArgs} />
|
||||
<meshNormalMaterial wireframe />
|
||||
</mesh>
|
||||
)}
|
||||
<group name="BVHEcctrl-Model" ref={characterModelRef}>
|
||||
{children}
|
||||
</group>
|
||||
</group>
|
||||
|
||||
{debug && (
|
||||
<group>
|
||||
<TransformControls object={characterGroupRef.current!} />
|
||||
<box3Helper args={[characterBbox.current]} />
|
||||
<mesh ref={debugLineStart}>
|
||||
<octahedronGeometry args={[0.05, 0]} />
|
||||
<meshNormalMaterial />
|
||||
</mesh>
|
||||
<mesh ref={debugLineEnd}>
|
||||
<octahedronGeometry args={[0.05, 0]} />
|
||||
<meshNormalMaterial />
|
||||
</mesh>
|
||||
<box3Helper args={[floatSensorBbox.current]} />
|
||||
<mesh ref={debugRaySensorStart}>
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<meshBasicMaterial color="yellow" wireframe />
|
||||
</mesh>
|
||||
<mesh ref={debugRaySensorEnd}>
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<meshBasicMaterial color="yellow" wireframe />
|
||||
</mesh>
|
||||
<mesh ref={lookDirRef} scale={[1, 0.5, 4]}>
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<meshNormalMaterial />
|
||||
</mesh>
|
||||
<arrowHelper args={[undefined, undefined, undefined, '#00f']} ref={inputDirRef} />
|
||||
<arrowHelper args={[undefined, undefined, undefined, '#f00']} ref={moveDirRef} />
|
||||
<mesh ref={standPointRef}>
|
||||
<octahedronGeometry args={[0.12, 0]} />
|
||||
<meshBasicMaterial color="red" opacity={0.2} transparent />
|
||||
</mesh>
|
||||
</group>
|
||||
)}
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BVHEcctrl.displayName = 'BVHEcctrl'
|
||||
|
||||
export default BVHEcctrl
|
||||
@@ -470,6 +470,39 @@ function bakeWindowClip(
|
||||
* (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the
|
||||
* file describes itself with exactly the fields a consumer needs.
|
||||
*/
|
||||
/**
|
||||
* Human-readable label for a baked node, mirroring the viewer's `getNodeName`:
|
||||
* an explicit name wins, items fall back to their catalog asset name, other
|
||||
* kinds to a capitalized type. Levels override this with their display name.
|
||||
*/
|
||||
function nodeDisplayLabel(node: AnyNode): string {
|
||||
if (node.name) return node.name
|
||||
switch (node.type) {
|
||||
case 'item':
|
||||
return (node as { asset?: { name?: string } }).asset?.name || 'Item'
|
||||
case 'wall':
|
||||
return 'Wall'
|
||||
case 'door':
|
||||
return 'Door'
|
||||
case 'window':
|
||||
return 'Window'
|
||||
case 'slab':
|
||||
return 'Slab'
|
||||
case 'ceiling':
|
||||
return 'Ceiling'
|
||||
case 'roof':
|
||||
return 'Roof'
|
||||
case 'fence':
|
||||
return 'Fence'
|
||||
case 'column':
|
||||
return 'Column'
|
||||
case 'stair':
|
||||
return 'Stairs'
|
||||
default:
|
||||
return node.type
|
||||
}
|
||||
}
|
||||
|
||||
function stampIdentity(
|
||||
scene: THREE.Object3D,
|
||||
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
||||
@@ -487,7 +520,12 @@ function stampIdentity(
|
||||
|
||||
target.name = id
|
||||
const extras: Record<string, unknown> = { pascalId: id, kind: node.type }
|
||||
if (node.name) extras.label = node.name
|
||||
// Stamp a human label for every node (catalog name for items, a type label
|
||||
// otherwise) so the viewer breadcrumb/hover read names, not raw pascalIds.
|
||||
extras.label = nodeDisplayLabel(node)
|
||||
// Camera bookmarks ride on the identity node (any kind can carry one) so the
|
||||
// baked viewer flies to a saved pose on selection without a side file.
|
||||
if (node.camera) extras.camera = node.camera
|
||||
// Levels carry no stored name; stamp the editor's display name ("Level 1")
|
||||
// so the baked viewer's level/breadcrumb UI reads the same labels. Force the
|
||||
// node visible: the bake must capture every floor regardless of the editor's
|
||||
@@ -516,6 +554,14 @@ function stampIdentity(
|
||||
extras.color = zone.color
|
||||
target.visible = true
|
||||
}
|
||||
if (node.type === 'spawn') {
|
||||
// The spawn marker's visible mesh lives on a non-scene overlay layer (and
|
||||
// is pruned), so this identity node is an empty transform. Keep it + force
|
||||
// visible so the baked walkthrough can read its world position/yaw and
|
||||
// start the player there (`extras.rotation` mirrors the node's yaw).
|
||||
extras.rotation = (node as { rotation?: number }).rotation ?? 0
|
||||
target.visible = true
|
||||
}
|
||||
target.userData = extras
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user