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
@@ -0,0 +1,861 @@
|
||||
// R3F JSX type augmentations (mesh, group, box3Helper, …) for the debug overlay.
|
||||
import '@react-three/fiber'
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { SurfaceRole } from '@pascal-app/core'
|
||||
import { Html, useAnimations } from '@react-three/drei'
|
||||
import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -9,11 +10,26 @@ import { color, float, uniform, uv } from 'three/tsl'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
|
||||
import { ZONE_LAYER } from '../../lib/layers'
|
||||
import { createSurfaceRoleMaterial } from '../../lib/materials'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
|
||||
const EXPLODED_GAP = 5
|
||||
|
||||
/** Baked `kind` → surface role, so monochrome can recolor by role like the
|
||||
* parametric viewer (textures-off collapses each face to its themed clay). */
|
||||
const ROLE_BY_KIND: Record<string, SurfaceRole> = {
|
||||
wall: 'wall',
|
||||
slab: 'floor',
|
||||
floor: 'floor',
|
||||
ceiling: 'ceiling',
|
||||
roof: 'roof',
|
||||
'roof-segment': 'roof',
|
||||
window: 'glazing',
|
||||
door: 'joinery',
|
||||
item: 'furnishing',
|
||||
}
|
||||
|
||||
/** A building floor discovered in the baked GLB, ordered bottom-to-top. */
|
||||
export type GlbLevel = { id: `level_${string}`; label: string }
|
||||
|
||||
@@ -23,6 +39,14 @@ export type GlbIdentity = Record<string, { kind: string; label: string }>
|
||||
/** What the cursor would act on at the current drill depth (for a hover label). */
|
||||
export type GlbHover = { kind: string; label: string } | null
|
||||
|
||||
/** Walkthrough HUD state, reported each frame: the floor/room the camera is in
|
||||
* and the openable door/window directly in view (for the reticle prompt). */
|
||||
export type GlbWalkthrough = {
|
||||
zoneLabel: string | null
|
||||
floorLabel: string | null
|
||||
door: { label: string; isOpen: boolean } | null
|
||||
} | null
|
||||
|
||||
type GlbLevelEntry = { id: GlbLevel['id']; node: THREE.Object3D; baseY: number }
|
||||
type GlbZoneEntry = {
|
||||
id: string
|
||||
@@ -43,6 +67,24 @@ type PascalExtras = {
|
||||
clips?: string[]
|
||||
polygon?: [number, number][]
|
||||
color?: string
|
||||
camera?: { position: [number, number, number]; target: [number, number, number] }
|
||||
}
|
||||
|
||||
/** The subset of the camera-controls instance the scene drives (drei makeDefault). */
|
||||
type LookAtControls = {
|
||||
setLookAt: (
|
||||
px: number,
|
||||
py: number,
|
||||
pz: number,
|
||||
tx: number,
|
||||
ty: number,
|
||||
tz: number,
|
||||
enableTransition?: boolean,
|
||||
) => unknown
|
||||
/** Wraps the wound-up azimuth so a transition rotates the short way, not 360°. */
|
||||
normalizeRotations?: () => unknown
|
||||
/** Pans camera + target together (keeps angle + distance) to re-center a point. */
|
||||
moveTo?: (x: number, y: number, z: number, enableTransition?: boolean) => unknown
|
||||
}
|
||||
|
||||
/** The resolved drill target for a raycast hit, given the current selection. */
|
||||
@@ -76,6 +118,15 @@ const _up = new THREE.Vector3(0, 1, 0)
|
||||
const _bounds = new THREE.Box3()
|
||||
const _boundsCenter = new THREE.Vector3()
|
||||
const _sample = new THREE.Vector3()
|
||||
const _camBox = new THREE.Box3()
|
||||
const _camCenter = new THREE.Vector3()
|
||||
const _camSize = new THREE.Vector3()
|
||||
const _camPoint = new THREE.Vector3()
|
||||
const _walkPos = new THREE.Vector3()
|
||||
const _reticleNdc = new THREE.Vector2(0, 0)
|
||||
const _reticleRaycaster = new THREE.Raycaster()
|
||||
/** How far ahead (metres) a door/window counts as "in view" for activation. */
|
||||
const WALK_REACH = 3
|
||||
const ZONE_FOOTPRINT_EPSILON = 0.05
|
||||
|
||||
const NO_RAYCAST: THREE.Mesh['raycast'] = () => {}
|
||||
@@ -231,11 +282,13 @@ export function GlbScene({
|
||||
onLevelsChange,
|
||||
onIdentityChange,
|
||||
onHoverChange,
|
||||
onWalkthroughChange,
|
||||
}: {
|
||||
url: string
|
||||
onLevelsChange?: (levels: GlbLevel[]) => void
|
||||
onIdentityChange?: (identity: GlbIdentity) => void
|
||||
onHoverChange?: (hover: GlbHover) => void
|
||||
onWalkthroughChange?: (state: GlbWalkthrough) => void
|
||||
}) {
|
||||
const gltf = useGLTFKTX2(url) as unknown as {
|
||||
scene: THREE.Group
|
||||
@@ -245,21 +298,57 @@ export function GlbScene({
|
||||
const { actions } = useAnimations(gltf.animations, rootRef)
|
||||
const camera = useThree((state) => state.camera)
|
||||
const raycaster = useThree((state) => state.raycaster)
|
||||
const controls = useThree((state) => state.controls) as LookAtControls | null
|
||||
const walkthroughMode = useViewer((s) => s.walkthroughMode)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
// Monochrome: strip the baked textures and recolor every building mesh with a
|
||||
// flat themed-clay material by surface role — mirrors the parametric viewer's
|
||||
// textures-off path. The original baked material is stashed on the mesh
|
||||
// (`userData.__bakedMaterial`) so it survives the cached GLTF across remounts.
|
||||
useEffect(() => {
|
||||
gltf.scene.traverse((object) => {
|
||||
const role = ROLE_BY_KIND[(object.userData as PascalExtras).kind ?? '']
|
||||
if (!role) return
|
||||
object.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh
|
||||
if (!mesh.isMesh || mesh.layers.isEnabled(ZONE_LAYER)) return
|
||||
const ud = mesh.userData as { __bakedMaterial?: THREE.Material | THREE.Material[] }
|
||||
if (!ud.__bakedMaterial) ud.__bakedMaterial = mesh.material
|
||||
mesh.material = textures
|
||||
? ud.__bakedMaterial
|
||||
: createSurfaceRoleMaterial(role, 'clay', THREE.DoubleSide, sceneTheme)
|
||||
})
|
||||
})
|
||||
}, [gltf.scene, textures, sceneTheme])
|
||||
|
||||
// One pass over the artifact: identity objects (id → Object3D), ordered floors,
|
||||
// and zone polygons. Levels stay out of `sceneRegistry` so the parametric
|
||||
// LevelSystem never re-stacks them.
|
||||
const { levels, identity, zoneEntries, occluders } = useMemo(() => {
|
||||
const { levels, identity, zoneEntries, occluders, rootNode, levelsWithZones } = useMemo(() => {
|
||||
const objects = new Map<string, THREE.Object3D>()
|
||||
const floors: GlbLevelEntry[] = []
|
||||
const zoneList: GlbZoneEntry[] = []
|
||||
// Ceilings + roof are hidden when a floor is focused (dollhouse view) so the
|
||||
// camera sees the rooms and the pointer ray reaches their contents.
|
||||
const occluderNodes: THREE.Object3D[] = []
|
||||
// The building (or site) node anchors the building-view camera bookmark/fit.
|
||||
let buildingNode: THREE.Object3D | null = null
|
||||
let siteNode: THREE.Object3D | null = null
|
||||
gltf.scene.traverse((object) => {
|
||||
const extras = object.userData as PascalExtras
|
||||
// The spawn marker is an authoring-only node (walkthrough start pose); it
|
||||
// should never render in the viewer. Its transform still feeds the
|
||||
// walkthrough controller — visibility doesn't affect that.
|
||||
if (extras.kind === 'spawn') {
|
||||
object.visible = false
|
||||
return
|
||||
}
|
||||
if (!extras.pascalId) return
|
||||
objects.set(extras.pascalId, object)
|
||||
if (extras.kind === 'building') buildingNode = object
|
||||
else if (extras.kind === 'site') siteNode = object
|
||||
if (extras.kind === 'ceiling' || extras.kind === 'roof') occluderNodes.push(object)
|
||||
if (extras.kind === 'level') {
|
||||
floors.push({
|
||||
@@ -286,10 +375,106 @@ export function GlbScene({
|
||||
}
|
||||
})
|
||||
floors.sort((a, b) => a.baseY - b.baseY)
|
||||
return { levels: floors, identity: objects, zoneEntries: zoneList, occluders: occluderNodes }
|
||||
return {
|
||||
levels: floors,
|
||||
identity: objects,
|
||||
zoneEntries: zoneList,
|
||||
occluders: occluderNodes,
|
||||
rootNode: (buildingNode ?? siteNode) as THREE.Object3D | null,
|
||||
// Levels that have rooms — only these trigger the dollhouse occluder strip.
|
||||
levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)),
|
||||
}
|
||||
}, [gltf.scene])
|
||||
const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries])
|
||||
|
||||
// Move the camera to match the drill depth: a saved bookmark (extras.camera)
|
||||
// wins; otherwise fit to the target's bounds (the object, the room's polygon
|
||||
// footprint for empty zone nodes, the level, or the whole building). Mirrors
|
||||
// the parametric viewer's selection framing so the GLB path feels identical.
|
||||
const focusLevelId = useViewer((s) => s.selection.levelId)
|
||||
const focusZoneId = useViewer((s) => s.selection.zoneId)
|
||||
const focusSelectedId = useViewer((s) => s.selection.selectedIds[0] ?? null)
|
||||
useEffect(() => {
|
||||
if (!controls) return
|
||||
const flyToBookmark = (bookmark: NonNullable<PascalExtras['camera']>) => {
|
||||
const { position: p, target: t } = bookmark
|
||||
controls.setLookAt(p[0], p[1], p[2], t[0], t[1], t[2], true)
|
||||
controls.normalizeRotations?.()
|
||||
}
|
||||
|
||||
// Item selection happens inside a room, where we're already at a good angle:
|
||||
// fly to the item's own bookmark if it has one, otherwise just pan to it
|
||||
// (keep the current orbit angle + distance) rather than reframing the camera.
|
||||
if (focusSelectedId) {
|
||||
const object = identity.get(focusSelectedId)
|
||||
if (!object) return
|
||||
const itemBookmark = (object.userData as PascalExtras).camera
|
||||
if (itemBookmark) {
|
||||
flyToBookmark(itemBookmark)
|
||||
return
|
||||
}
|
||||
_camBox.makeEmpty()
|
||||
_camBox.setFromObject(object)
|
||||
if (_camBox.isEmpty()) return
|
||||
_camBox.getCenter(_camCenter)
|
||||
controls.moveTo?.(_camCenter.x, _camCenter.y, _camCenter.z, true)
|
||||
return
|
||||
}
|
||||
|
||||
let bookmarkNode: THREE.Object3D | null = null
|
||||
_camBox.makeEmpty()
|
||||
if (focusZoneId) {
|
||||
const zone = zoneById.get(focusZoneId)
|
||||
if (!zone) return
|
||||
bookmarkNode = zone.node
|
||||
// Zone identity nodes carry no mesh — bound the room from its polygon.
|
||||
zone.node.updateWorldMatrix(true, false)
|
||||
for (const [x, z] of zone.polygon) {
|
||||
_camBox.expandByPoint(_camPoint.set(x, 0, z).applyMatrix4(zone.node.matrixWorld))
|
||||
_camBox.expandByPoint(
|
||||
_camPoint.set(x, ZONE_WALL_HEIGHT, z).applyMatrix4(zone.node.matrixWorld),
|
||||
)
|
||||
}
|
||||
} else if (focusLevelId) {
|
||||
const object = identity.get(focusLevelId)
|
||||
if (!object) return
|
||||
bookmarkNode = object
|
||||
_camBox.setFromObject(object)
|
||||
} else {
|
||||
bookmarkNode = rootNode
|
||||
_camBox.setFromObject(gltf.scene)
|
||||
}
|
||||
|
||||
const bookmark = (bookmarkNode?.userData as PascalExtras | undefined)?.camera
|
||||
if (bookmark) {
|
||||
flyToBookmark(bookmark)
|
||||
return
|
||||
}
|
||||
if (_camBox.isEmpty()) return
|
||||
_camBox.getCenter(_camCenter)
|
||||
_camBox.getSize(_camSize)
|
||||
const distance = Math.max(Math.max(_camSize.x, _camSize.y, _camSize.z) * 2, 15)
|
||||
controls.setLookAt(
|
||||
_camCenter.x + distance * 0.7,
|
||||
_camCenter.y + distance * 0.5,
|
||||
_camCenter.z + distance * 0.7,
|
||||
_camCenter.x,
|
||||
_camCenter.y,
|
||||
_camCenter.z,
|
||||
true,
|
||||
)
|
||||
controls.normalizeRotations?.()
|
||||
}, [
|
||||
controls,
|
||||
focusSelectedId,
|
||||
focusZoneId,
|
||||
focusLevelId,
|
||||
identity,
|
||||
zoneById,
|
||||
rootNode,
|
||||
gltf.scene,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const cameraMask = camera.layers.mask
|
||||
const raycasterMask = raycaster.layers.mask
|
||||
@@ -317,15 +502,22 @@ export function GlbScene({
|
||||
}
|
||||
}, [levels, identity, onLevelsChange, onIdentityChange])
|
||||
|
||||
// Apply the editor's level modes to the baked floors each frame.
|
||||
// Apply the editor's level modes to the baked floors each frame. Walkthrough
|
||||
// always shows the full stacked building (you're standing inside it) — and the
|
||||
// first-person collider is built from the visible meshes, so a hidden solo
|
||||
// floor would otherwise drop the player through the world.
|
||||
useFrame((_, delta) => {
|
||||
if (levels.length === 0) return
|
||||
const { levelMode, selection } = useViewer.getState()
|
||||
const { levelMode, selection, walkthroughMode } = useViewer.getState()
|
||||
const selectedLevel = selection.levelId
|
||||
levels.forEach(({ id, node, baseY }, index) => {
|
||||
const targetY = baseY + (levelMode === 'exploded' ? index * EXPLODED_GAP : 0)
|
||||
node.position.y = lerp(node.position.y, targetY, delta * 12)
|
||||
node.visible = levelMode !== 'solo' || !selectedLevel || id === selectedLevel
|
||||
const exploded = !walkthroughMode && levelMode === 'exploded'
|
||||
const targetY = baseY + (exploded ? index * EXPLODED_GAP : 0)
|
||||
// Snap (not lerp) in walkthrough so the first-person collider, built from
|
||||
// these world positions, matches the stacked building immediately.
|
||||
node.position.y = walkthroughMode ? targetY : lerp(node.position.y, targetY, delta * 12)
|
||||
node.visible =
|
||||
walkthroughMode || levelMode !== 'solo' || !selectedLevel || id === selectedLevel
|
||||
})
|
||||
}, 5)
|
||||
|
||||
@@ -520,17 +712,25 @@ export function GlbScene({
|
||||
// selection + local hover.
|
||||
const hoveredTarget = useRef<Target | null>(null)
|
||||
useFrame((_, delta) => {
|
||||
const { selection, outliner } = useViewer.getState()
|
||||
const state = useViewer.getState()
|
||||
const { selection, outliner } = state
|
||||
const t = Math.min(1, delta * 8)
|
||||
const hoveredZoneId = hoveredTarget.current?.kind === 'zone' ? hoveredTarget.current.id : null
|
||||
|
||||
// Dollhouse: once a floor is focused, hide ceilings + roof so the rooms (and
|
||||
// their zone tint) are visible from above and the ray reaches their contents.
|
||||
const focused = selection.levelId != null
|
||||
for (const occluder of occluders) occluder.visible = !focused
|
||||
// Walkthrough is a first-person tour: no zone tints, no dollhouse cutaway,
|
||||
// no selection outline — you're standing inside the real building.
|
||||
const walk = state.walkthroughMode
|
||||
|
||||
// Dollhouse: hide ceilings + roof so the rooms (and their zone tint) are
|
||||
// visible from above and the ray reaches their contents — but only when the
|
||||
// focused level actually has rooms. Focusing a zone-less floor keeps the
|
||||
// building intact (otherwise its roof would just vanish with nothing to show).
|
||||
const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId)
|
||||
for (const occluder of occluders) occluder.visible = !revealing
|
||||
|
||||
for (const { id, levelId, meshes, uniforms } of zoneFills.current) {
|
||||
const show = selection.levelId != null && levelId === selection.levelId && !selection.zoneId
|
||||
const show =
|
||||
!walk && selection.levelId != null && levelId === selection.levelId && !selection.zoneId
|
||||
const target = !show ? 0 : id === hoveredZoneId ? 1 : 0.65
|
||||
let visible = false
|
||||
for (const u of uniforms) {
|
||||
@@ -554,23 +754,96 @@ export function GlbScene({
|
||||
}
|
||||
}
|
||||
|
||||
outliner.selectedObjects.length = 0
|
||||
outliner.hoveredObjects.length = 0
|
||||
if (walk) return
|
||||
|
||||
const selectedObject = selection.selectedIds[0]
|
||||
? (identity.get(selection.selectedIds[0]) ?? null)
|
||||
: null
|
||||
outliner.selectedObjects.length = 0
|
||||
if (selectedObject) outliner.selectedObjects.push(selectedObject)
|
||||
// Rooms show hover via the fill brightness; everything else uses the outline.
|
||||
outliner.hoveredObjects.length = 0
|
||||
const hover = hoveredTarget.current
|
||||
if (hover && hover.kind !== 'zone' && hover.object !== selectedObject) {
|
||||
outliner.hoveredObjects.push(hover.object)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Walkthrough: first-person HUD + door/window interaction ────────────────
|
||||
const walkDoorRef = useRef<THREE.Object3D | null>(null)
|
||||
const lastWalkKey = useRef<string | null>(null)
|
||||
|
||||
// Each frame in walkthrough, report the floor + room the camera stands in and
|
||||
// the openable directly ahead (a forward ray from screen centre) so the host
|
||||
// can draw the reticle prompt. Fires the callback only when the state changes.
|
||||
useFrame(() => {
|
||||
if (!walkthroughMode) return
|
||||
camera.getWorldPosition(_walkPos)
|
||||
|
||||
let floor: GlbLevelEntry | null = levels[0] ?? null
|
||||
for (const level of levels) {
|
||||
if (_walkPos.y >= level.baseY - 0.5) floor = level
|
||||
else break
|
||||
}
|
||||
const floorLabel = floor ? ((floor.node.userData as PascalExtras).label ?? floor.id) : null
|
||||
const zone = floor ? zoneAtPoint(_walkPos, floor.id) : null
|
||||
|
||||
_reticleRaycaster.far = WALK_REACH
|
||||
_reticleRaycaster.setFromCamera(_reticleNdc, camera)
|
||||
const hit = _reticleRaycaster.intersectObject(gltf.scene, true)[0]
|
||||
let doorNode: THREE.Object3D | null = null
|
||||
let doorId = ''
|
||||
let door: { label: string; isOpen: boolean } | null = null
|
||||
if (hit) {
|
||||
const node = findIdentityAncestor(hit.object)
|
||||
const extras = node?.userData as PascalExtras | undefined
|
||||
if (node && extras?.openable && extras.clips?.length) {
|
||||
doorNode = node
|
||||
doorId = extras.pascalId as string
|
||||
door = { label: extras.label ?? 'Door', isOpen: openIds.current.has(doorId) }
|
||||
}
|
||||
}
|
||||
walkDoorRef.current = doorNode
|
||||
|
||||
const key = `${floor?.id ?? ''}|${zone?.id ?? ''}|${door ? `${doorId}:${door.isOpen}` : ''}`
|
||||
if (key !== lastWalkKey.current) {
|
||||
lastWalkKey.current = key
|
||||
onWalkthroughChange?.({ zoneLabel: zone?.label ?? null, floorLabel, door })
|
||||
}
|
||||
})
|
||||
|
||||
// E or click activates the openable in view. The click also re-locks the
|
||||
// pointer via WalkthroughControls — harmless overlap; no selection happens.
|
||||
const activateWalkDoor = useCallback(() => {
|
||||
if (walkDoorRef.current) toggleOpenable(walkDoorRef.current)
|
||||
}, [toggleOpenable])
|
||||
useEffect(() => {
|
||||
if (!walkthroughMode) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === 'e') activateWalkDoor()
|
||||
}
|
||||
const canvas = document.querySelector('canvas')
|
||||
window.addEventListener('keydown', onKey)
|
||||
canvas?.addEventListener('click', activateWalkDoor)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
canvas?.removeEventListener('click', activateWalkDoor)
|
||||
}
|
||||
}, [walkthroughMode, activateWalkDoor])
|
||||
|
||||
// Clear the HUD (and stale targeting) whenever walkthrough turns off.
|
||||
useEffect(() => {
|
||||
if (walkthroughMode) return
|
||||
walkDoorRef.current = null
|
||||
lastWalkKey.current = null
|
||||
onWalkthroughChange?.(null)
|
||||
}, [walkthroughMode, onWalkthroughChange])
|
||||
|
||||
const lastHover = useRef<string | null>(null)
|
||||
const handlePointerMove = useCallback(
|
||||
(event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
if (walkthroughMode) return
|
||||
const target = resolveTarget(
|
||||
event.intersections.map((hit) => ({ object: hit.object, point: hit.point })),
|
||||
event.ray,
|
||||
@@ -583,7 +856,7 @@ export function GlbScene({
|
||||
onHoverChange?.(target ? { kind: target.kind, label: target.label } : null)
|
||||
}
|
||||
},
|
||||
[resolveTarget, onHoverChange],
|
||||
[resolveTarget, onHoverChange, walkthroughMode],
|
||||
)
|
||||
|
||||
const handlePointerOut = useCallback(() => {
|
||||
@@ -601,6 +874,9 @@ export function GlbScene({
|
||||
const handleClick = useCallback(
|
||||
(event: ThreeEvent<MouseEvent>) => {
|
||||
event.stopPropagation()
|
||||
// Walkthrough handles its own door activation (E / canvas click) and never
|
||||
// selects — leave the drill hierarchy untouched.
|
||||
if (walkthroughMode) return
|
||||
const target = resolveTarget(
|
||||
event.intersections.map((hit) => ({ object: hit.object, point: hit.point })),
|
||||
event.ray,
|
||||
@@ -637,12 +913,13 @@ export function GlbScene({
|
||||
setSelection({ zoneId: null })
|
||||
}
|
||||
},
|
||||
[resolveTarget, toggleOpenable],
|
||||
[resolveTarget, toggleOpenable, walkthroughMode],
|
||||
)
|
||||
|
||||
// A click that hits nothing (empty space) steps one level back up the drill
|
||||
// hierarchy, like the legacy viewer.
|
||||
const handlePointerMissed = useCallback(() => {
|
||||
if (useViewer.getState().walkthroughMode) return
|
||||
const { selection, setSelection, setLevelMode } = useViewer.getState()
|
||||
if (selection.selectedIds.length > 0) {
|
||||
setSelection({ selectedIds: [] })
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
'use client'
|
||||
|
||||
import { KeyboardControls } from '@react-three/drei'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Box3,
|
||||
BoxGeometry,
|
||||
type BufferAttribute,
|
||||
BufferGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type InterleavedBufferAttribute,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
type Object3D,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||
import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
|
||||
import { SCENE_LAYER } from '../../lib/layers'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl'
|
||||
|
||||
// Eye/capsule geometry mirrors the editor's first-person controller so the
|
||||
// baked walkthrough feels identical. The capsule centre sits below the eye; the
|
||||
// camera rides the capsule with a small offset and the controller floats it to
|
||||
// the ground.
|
||||
const CAMERA_EYE_OFFSET = 0.45
|
||||
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
||||
const SPAWN_EYE_HEIGHT = 1.65
|
||||
const LOOK_SENSITIVITY = 0.002
|
||||
const VOID_FALL_RESPAWN_DEPTH = 12
|
||||
|
||||
// Kinds that must not block the player: room helpers, the spawn marker, the
|
||||
// ceiling/roof shell (you walk under them), and door/window leaves — excluding
|
||||
// the latter lets you pass any doorway whether the leaf is open or shut (the
|
||||
// wall already has the opening cut into its baked geometry).
|
||||
const COLLIDER_EXCLUDED_KINDS = new Set(['zone', 'spawn', 'ceiling', 'roof', 'door', 'window'])
|
||||
|
||||
const colliderMaterial = new MeshBasicMaterial({ visible: false })
|
||||
|
||||
const keyboardMap: Array<{ name: Exclude<keyof MovementInput, 'joystick'>; keys: string[] }> = [
|
||||
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
||||
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
|
||||
{ name: 'jump', keys: ['Space'] },
|
||||
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
||||
]
|
||||
|
||||
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
||||
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const spawnQuat = new Quaternion()
|
||||
const spawnEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const spawnPos = new Vector3()
|
||||
|
||||
type GlbColliderWorld = { mesh: Mesh; minY: number; dispose: () => void }
|
||||
|
||||
/** Effective visibility — an invisible ancestor hides the whole subtree. */
|
||||
function isEffectivelyVisible(object: Object3D) {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
if (!current.visible) return false
|
||||
current = current.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function kindOf(object: Object3D): string | undefined {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
const kind = (current.userData as { kind?: string }).kind
|
||||
if (kind) return kind
|
||||
current = current.parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Coerce any position attribute (quantized/interleaved) to a plain Float32 one
|
||||
// so mergeGeometries can combine geometries that don't share an array type.
|
||||
function toFloat32Position(source: BufferAttribute | InterleavedBufferAttribute) {
|
||||
const array = new Float32Array(source.count * 3)
|
||||
for (let i = 0; i < source.count; i++) {
|
||||
array[i * 3] = source.getX(i)
|
||||
array[i * 3 + 1] = source.getY(i)
|
||||
array[i * 3 + 2] = source.getZ(i)
|
||||
}
|
||||
return new Float32BufferAttribute(array, 3)
|
||||
}
|
||||
|
||||
const FALLBACK_THICKNESS = 0.08
|
||||
const GROUND_MIN = 2000
|
||||
|
||||
/** A thin position-only floor box whose top face sits at `topY`. */
|
||||
function boxFloorGeometry(
|
||||
cx: number,
|
||||
topY: number,
|
||||
cz: number,
|
||||
width: number,
|
||||
depth: number,
|
||||
): BufferGeometry {
|
||||
const box = new BoxGeometry(width, FALLBACK_THICKNESS, depth).toNonIndexed()
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', (box.getAttribute('position') as BufferAttribute).clone())
|
||||
box.dispose()
|
||||
geometry.applyMatrix4(new Matrix4().makeTranslation(cx, topY - FALLBACK_THICKNESS / 2, cz))
|
||||
return geometry
|
||||
}
|
||||
|
||||
// Fallback ground so the player never falls into the void: a single large
|
||||
// ground plane at the lowest level (level 0). Upper levels rely on their own
|
||||
// baked slabs — a slab-less upper floor lets you fall down to the ground, which
|
||||
// the ground plane catches. (Mirrors the editor walkthrough's site ground.)
|
||||
function addFallbackFloors(scene: Object3D, geometries: BufferGeometry[]) {
|
||||
const sceneBounds = new Box3()
|
||||
for (const geometry of geometries) {
|
||||
geometry.computeBoundingBox()
|
||||
if (geometry.boundingBox) sceneBounds.union(geometry.boundingBox)
|
||||
}
|
||||
|
||||
const center = new Vector3()
|
||||
const levelPos = new Vector3()
|
||||
let lowestLevelY = Number.POSITIVE_INFINITY
|
||||
|
||||
scene.traverse((object) => {
|
||||
if ((object.userData as { kind?: string }).kind !== 'level') return
|
||||
object.updateWorldMatrix(true, false)
|
||||
object.getWorldPosition(levelPos)
|
||||
lowestLevelY = Math.min(lowestLevelY, levelPos.y)
|
||||
})
|
||||
|
||||
if (sceneBounds.isEmpty()) return
|
||||
sceneBounds.getCenter(center)
|
||||
const groundY = Number.isFinite(lowestLevelY) ? lowestLevelY : sceneBounds.min.y
|
||||
geometries.push(boxFloorGeometry(center.x, groundY, center.z, GROUND_MIN, GROUND_MIN))
|
||||
}
|
||||
|
||||
/** Merge the baked GLB's walkable/blocking meshes into one BVH collider. */
|
||||
function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null {
|
||||
scene.updateWorldMatrix(true, true)
|
||||
const geometries: BufferGeometry[] = []
|
||||
|
||||
scene.traverse((object) => {
|
||||
const mesh = object as Mesh
|
||||
if (!mesh.isMesh) return
|
||||
// Zone fills live on a separate layer (and never collide).
|
||||
if (!mesh.layers.isEnabled(SCENE_LAYER)) return
|
||||
if (!isEffectivelyVisible(mesh)) return
|
||||
const kind = kindOf(mesh)
|
||||
if (kind && COLLIDER_EXCLUDED_KINDS.has(kind)) return
|
||||
const position = mesh.geometry?.getAttribute('position')
|
||||
if (!position || position.count < 3) return
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry
|
||||
geometry.setAttribute('position', toFloat32Position(source.getAttribute('position')))
|
||||
if (mesh.geometry.index) source.dispose()
|
||||
geometry.applyMatrix4(mesh.matrixWorld)
|
||||
geometries.push(geometry)
|
||||
})
|
||||
|
||||
if (geometries.length === 0) return null
|
||||
|
||||
addFallbackFloors(scene, geometries)
|
||||
|
||||
const merged = mergeGeometries(geometries, false)
|
||||
for (const geometry of geometries) geometry.dispose()
|
||||
if (!merged || merged.getAttribute('position') == null) {
|
||||
merged?.dispose()
|
||||
return null
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype
|
||||
;(merged as any).computeBoundsTree = computeBoundsTree
|
||||
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype
|
||||
;(merged as any).disposeBoundsTree = disposeBoundsTree
|
||||
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension
|
||||
;(merged as any).computeBoundsTree({ maxLeafSize: 12, strategy: 0 })
|
||||
merged.computeBoundingBox()
|
||||
|
||||
const mesh = new Mesh(merged, colliderMaterial)
|
||||
mesh.raycast = acceleratedRaycast
|
||||
mesh.visible = true
|
||||
mesh.userData = {
|
||||
type: 'STATIC',
|
||||
friction: 0.8,
|
||||
restitution: 0.05,
|
||||
excludeFloatHit: false,
|
||||
excludeCollisionCheck: false,
|
||||
}
|
||||
mesh.updateMatrixWorld(true)
|
||||
|
||||
return {
|
||||
mesh,
|
||||
minY: merged.boundingBox?.min.y ?? 0,
|
||||
dispose: () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension
|
||||
;(merged as any).disposeBoundsTree?.()
|
||||
merged.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The baked spawn marker's eye position + yaw, if the artifact carries one. */
|
||||
function resolveGlbSpawn(
|
||||
scene: Object3D,
|
||||
): { position: [number, number, number]; yaw: number } | null {
|
||||
let spawn: Object3D | null = null
|
||||
scene.traverse((object) => {
|
||||
if ((object.userData as { kind?: string }).kind === 'spawn') spawn = object
|
||||
})
|
||||
if (!spawn) return null
|
||||
const node = spawn as Object3D
|
||||
node.updateWorldMatrix(true, false)
|
||||
node.getWorldPosition(spawnPos)
|
||||
node.getWorldQuaternion(spawnQuat)
|
||||
spawnEuler.setFromQuaternion(spawnQuat, 'YXZ')
|
||||
return {
|
||||
position: [spawnPos.x, spawnPos.y + SPAWN_EYE_HEIGHT, spawnPos.z],
|
||||
yaw: spawnEuler.y,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First-person walkthrough controller for the baked GLB. Reuses the editor's
|
||||
* `BVHEcctrl` capsule character controller (gravity, jump, sprint, ground-float,
|
||||
* mesh collision) fed a collider built from the artifact's own geometry — so the
|
||||
* baked viewer walks the building with the same physics as the editor, without
|
||||
* the parametric scene. Pointer-lock drives look; WASD moves; Space jumps; Shift
|
||||
* sprints. Door/window interaction stays in `GlbScene` (its centre-ray HUD).
|
||||
*/
|
||||
export function GlbWalkthroughController({ url }: { url: string }) {
|
||||
const { camera, gl } = useThree()
|
||||
const gltf = useGLTFKTX2(url) as unknown as { scene: Object3D }
|
||||
|
||||
const worldRef = useRef<GlbColliderWorld | null>(null)
|
||||
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||
const yawRef = useRef(0)
|
||||
const pitchRef = useRef(0)
|
||||
const [start, setStart] = useState<{ position: [number, number, number] } | null>(null)
|
||||
const [world, setWorld] = useState<GlbColliderWorld | null>(null)
|
||||
|
||||
// Build the collider on the first frame (priority after GlbScene's level loop,
|
||||
// which snaps the floors to their stacked world positions in walkthrough) so it
|
||||
// matches the rendered building rather than a mid-lerp / exploded layout.
|
||||
const builtRef = useRef(false)
|
||||
useFrame(() => {
|
||||
if (builtRef.current) return
|
||||
builtRef.current = true
|
||||
setWorld(buildGlbColliderWorld(gltf.scene))
|
||||
}, 6)
|
||||
|
||||
// First-person needs a perspective camera — an orthographic projection has no
|
||||
// foreshortening and makes the walkthrough unusable. Force perspective while
|
||||
// walking and restore the prior projection on exit.
|
||||
useEffect(() => {
|
||||
const prevMode = useViewer.getState().cameraMode
|
||||
if (prevMode === 'orthographic') useViewer.getState().setCameraMode('perspective')
|
||||
return () => {
|
||||
if (prevMode === 'orthographic') useViewer.getState().setCameraMode('orthographic')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
worldRef.current = world
|
||||
if (world) {
|
||||
const triangles = world.mesh.geometry.getAttribute('position').count / 3
|
||||
console.warn('[glb-walkthrough] collider built', {
|
||||
triangles,
|
||||
minY: world.minY,
|
||||
hasBoundsTree: !!(world.mesh.geometry as { boundsTree?: unknown }).boundsTree,
|
||||
spawn: resolveGlbSpawn(gltf.scene),
|
||||
})
|
||||
} else {
|
||||
console.warn('[glb-walkthrough] NO collider world (no eligible meshes)')
|
||||
}
|
||||
return () => {
|
||||
world?.dispose()
|
||||
worldRef.current = null
|
||||
}
|
||||
}, [world, gltf.scene])
|
||||
|
||||
// Resolve the spawn once the collider exists; the capsule centre sits below
|
||||
// the eye, then the controller floats it onto the ground.
|
||||
useEffect(() => {
|
||||
if (!world || start) return
|
||||
const spawn = resolveGlbSpawn(gltf.scene)
|
||||
const eye = spawn?.position ?? [0, SPAWN_EYE_HEIGHT, 0]
|
||||
yawRef.current = spawn?.yaw ?? 0
|
||||
pitchRef.current = 0
|
||||
setStart({ position: [eye[0], eye[1] - CONTROLLER_CENTER_FROM_EYE, eye[2]] })
|
||||
}, [world, start, gltf.scene])
|
||||
|
||||
// Pointer-lock look + click-to-lock fallback + Esc/unlock to exit. Once the
|
||||
// pointer has been locked, releasing it (Esc — the browser swallows that
|
||||
// keydown, so we can't rely on it; or any other unlock) leaves the walkthrough
|
||||
// in a single press rather than just freeing the cursor.
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
let wasLocked = false
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (document.pointerLockElement !== canvas) return
|
||||
yawRef.current -= event.movementX * LOOK_SENSITIVITY
|
||||
pitchRef.current = Math.max(
|
||||
-(Math.PI / 2 - 0.05),
|
||||
Math.min(Math.PI / 2 - 0.05, pitchRef.current - event.movementY * LOOK_SENSITIVITY),
|
||||
)
|
||||
}
|
||||
const onClick = () => {
|
||||
if (document.pointerLockElement !== canvas) canvas.requestPointerLock?.()
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
// When locked, the browser intercepts Esc to release the pointer and the
|
||||
// pointerlockchange handler below exits; this only covers Esc while the
|
||||
// pointer is already free (e.g. lock never engaged).
|
||||
if (event.code === 'Escape' && document.pointerLockElement !== canvas) {
|
||||
useViewer.getState().setWalkthroughMode(false)
|
||||
}
|
||||
}
|
||||
const onPointerLockChange = () => {
|
||||
if (document.pointerLockElement === canvas) wasLocked = true
|
||||
else if (wasLocked) useViewer.getState().setWalkthroughMode(false)
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
canvas.addEventListener('click', onClick)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('pointerlockchange', onPointerLockChange)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
canvas.removeEventListener('click', onClick)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('pointerlockchange', onPointerLockChange)
|
||||
if (document.pointerLockElement === canvas) document.exitPointerLock()
|
||||
}
|
||||
}, [gl])
|
||||
|
||||
// Lock the pointer the moment the walkthrough is ready, so the user doesn't
|
||||
// have to click the canvas first. The walkthrough toggle is itself a user
|
||||
// gesture; if the browser still rejects the request (no transient activation
|
||||
// left), the click-to-lock fallback above covers it.
|
||||
useEffect(() => {
|
||||
if (!(world && start)) return
|
||||
const canvas = gl.domElement
|
||||
if (document.pointerLockElement === canvas) return
|
||||
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
|
||||
if (result && typeof result.catch === 'function') result.catch(() => {})
|
||||
}, [gl, world, start])
|
||||
|
||||
const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
|
||||
controllerRef.current = api
|
||||
}, [])
|
||||
|
||||
// Drive the camera from the capsule each frame + respawn if it falls into void.
|
||||
useFrame(() => {
|
||||
const group = controllerRef.current?.group
|
||||
if (!group) return
|
||||
|
||||
if (start && world && group.position.y < world.minY - VOID_FALL_RESPAWN_DEPTH) {
|
||||
group.position.set(start.position[0], start.position[1], start.position[2])
|
||||
controllerRef.current?.resetLinVel()
|
||||
}
|
||||
|
||||
group.rotation.y = 0
|
||||
camera.position.copy(group.position).add(cameraOffset)
|
||||
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
||||
camera.quaternion.setFromEuler(cameraEuler)
|
||||
camera.updateMatrixWorld(true)
|
||||
}, 2.5)
|
||||
|
||||
if (!(world && start)) return null
|
||||
|
||||
return (
|
||||
<KeyboardControls map={keyboardMap}>
|
||||
<BVHEcctrl
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||
colliderMeshes={[world.mesh]}
|
||||
collisionCheckIteration={3}
|
||||
collisionPushBackDamping={0.1}
|
||||
collisionPushBackThreshold={0.001}
|
||||
deceleration={30}
|
||||
delay={0}
|
||||
fallGravityFactor={4}
|
||||
floatCheckType="BOTH"
|
||||
floatDampingC={36}
|
||||
floatHeight={0.5}
|
||||
floatPullBackHeight={0.35}
|
||||
floatSensorRadius={0.15}
|
||||
floatSpringK={1200}
|
||||
gravity={9.81}
|
||||
jumpVel={6}
|
||||
maxRunSpeed={5.5}
|
||||
maxSlope={1.2}
|
||||
maxWalkSpeed={4}
|
||||
position={start.position}
|
||||
ref={setControllerApi}
|
||||
/>
|
||||
</KeyboardControls>
|
||||
)
|
||||
}
|
||||
@@ -13,12 +13,19 @@ export { ErrorBoundary } from './components/error-boundary'
|
||||
// — no per-kind re-exports needed.
|
||||
export { NodeRenderer } from './components/renderers/node-renderer'
|
||||
export { default as Viewer, type ViewerHandle } from './components/viewer'
|
||||
export {
|
||||
type BVHEcctrlApi,
|
||||
default as BVHEcctrl,
|
||||
type MovementInput,
|
||||
} from './components/viewer/bvh-ecctrl'
|
||||
export {
|
||||
type GlbHover,
|
||||
type GlbIdentity,
|
||||
type GlbLevel,
|
||||
GlbScene,
|
||||
type GlbWalkthrough,
|
||||
} from './components/viewer/glb-scene'
|
||||
export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller'
|
||||
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
||||
export {
|
||||
DEFAULT_HOVER_STYLES,
|
||||
|
||||
Reference in New Issue
Block a user