diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index f917bf595..60def880a 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -6,6 +6,10 @@ import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber" import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark"; import { AlertCircle, + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, Box, Crosshair, Globe, @@ -29,6 +33,16 @@ type World3DPickResult = { point: [number, number, number]; }; +type MovementCommand = + | "forward" + | "backward" + | "left" + | "right" + | "up" + | "down"; + +type MovementState = Record; + type World3DViewerProps = { mode: World3DViewMode; worldUrl?: string | null; @@ -58,6 +72,32 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = { wireframe: false, }; +const EMPTY_MOVEMENT_STATE: MovementState = { + forward: false, + backward: false, + left: false, + right: false, + up: false, + down: false, +}; + +const KEY_TO_MOVEMENT: Partial> = { + KeyW: "forward", + ArrowUp: "forward", + KeyS: "backward", + ArrowDown: "backward", + KeyA: "left", + ArrowLeft: "left", + KeyD: "right", + ArrowRight: "right", + KeyQ: "up", + KeyR: "up", + PageUp: "up", + KeyE: "down", + KeyF: "down", + PageDown: "down", +}; + function getSemanticFacts(metadata?: Record | null) { if (!metadata) return []; @@ -118,24 +158,115 @@ function WorldEmptyState({ ); } -function FirstPersonMovement({ enabled }: { enabled: boolean }) { +function createMovementState(): MovementState { + return { ...EMPTY_MOVEMENT_STATE }; +} + +function DirectionPad({ + onCommandChange, +}: { + onCommandChange: (command: MovementCommand, active: boolean) => void; +}) { + const pressHandlers = (command: MovementCommand) => ({ + onMouseDown: () => onCommandChange(command, true), + onMouseUp: () => onCommandChange(command, false), + onMouseLeave: () => onCommandChange(command, false), + onTouchStart: () => onCommandChange(command, true), + onTouchEnd: () => onCommandChange(command, false), + onTouchCancel: () => onCommandChange(command, false), + }); + + return ( +
+
方向移动
+
+
+ + + + +
+ 前后左右 +
+ + +
+ + +
+
+ ); +} + +function CameraMovementControls({ + enabled, + firstPerson, + virtualMovement, + orbitControlsRef, +}: { + enabled: boolean; + firstPerson: boolean; + virtualMovement: MovementState; + orbitControlsRef: React.MutableRefObject; +}) { const { camera } = useThree(); - const pressedKeysRef = useRef>({}); + const pressedKeysRef = useRef(createMovementState()); const directionRef = useRef(new THREE.Vector3()); const rightRef = useRef(new THREE.Vector3()); const upRef = useRef(new THREE.Vector3()); + const moveRef = useRef(new THREE.Vector3()); useEffect(() => { if (!enabled) { - pressedKeysRef.current = {}; + pressedKeysRef.current = createMovementState(); return; } const handleKeyDown = (event: KeyboardEvent) => { - pressedKeysRef.current[event.code] = true; + const movement = KEY_TO_MOVEMENT[event.code]; + if (!movement) return; + pressedKeysRef.current[movement] = true; }; const handleKeyUp = (event: KeyboardEvent) => { - pressedKeysRef.current[event.code] = false; + const movement = KEY_TO_MOVEMENT[event.code]; + if (!movement) return; + pressedKeysRef.current[movement] = false; }; window.addEventListener("keydown", handleKeyDown); @@ -151,23 +282,51 @@ function FirstPersonMovement({ enabled }: { enabled: boolean }) { if (!enabled) return; const pressed = pressedKeysRef.current; - const moveSpeed = pressed.ShiftLeft || pressed.ShiftRight ? 6.5 : 3.4; + const hasMovement = + Object.values(pressed).some(Boolean) || Object.values(virtualMovement).some(Boolean); + if (!hasMovement) return; + + const moveSpeed = 3.8; const distance = moveSpeed * delta; camera.getWorldDirection(directionRef.current); - directionRef.current.normalize(); + directionRef.current.setY(0).normalize(); rightRef.current.crossVectors(directionRef.current, camera.up).normalize(); upRef.current.copy(camera.up).normalize(); - if (pressed.KeyW) camera.position.addScaledVector(directionRef.current, distance); - if (pressed.KeyS) camera.position.addScaledVector(directionRef.current, -distance); - if (pressed.KeyA) camera.position.addScaledVector(rightRef.current, -distance); - if (pressed.KeyD) camera.position.addScaledVector(rightRef.current, distance); - if (pressed.KeyQ) camera.position.addScaledVector(upRef.current, distance); - if (pressed.KeyE) camera.position.addScaledVector(upRef.current, -distance); + moveRef.current.set(0, 0, 0); + + if (pressed.forward || virtualMovement.forward) { + moveRef.current.add(directionRef.current); + } + if (pressed.backward || virtualMovement.backward) { + moveRef.current.addScaledVector(directionRef.current, -1); + } + if (pressed.left || virtualMovement.left) { + moveRef.current.addScaledVector(rightRef.current, -1); + } + if (pressed.right || virtualMovement.right) { + moveRef.current.add(rightRef.current); + } + if (pressed.up || virtualMovement.up) { + moveRef.current.add(upRef.current); + } + if (pressed.down || virtualMovement.down) { + moveRef.current.addScaledVector(upRef.current, -1); + } + + if (moveRef.current.lengthSq() === 0) return; + + moveRef.current.normalize().multiplyScalar(distance); + camera.position.add(moveRef.current); + + if (!firstPerson && orbitControlsRef.current?.target) { + orbitControlsRef.current.target.add(moveRef.current); + orbitControlsRef.current.update(); + } }); - if (!enabled) return null; + if (!enabled || !firstPerson) return null; return ; } @@ -218,6 +377,9 @@ function WorldSplatScene({ onLoadStateChange?: (state: "ready" | "error") => void; }) { const renderer = useThree((state) => state.gl); + const orbitControlsRef = useRef(null); + const [virtualMovement, setVirtualMovement] = + useState(createMovementState); const semanticFacts = useMemo( () => getSemanticFacts(semanticsMetadata), [semanticsMetadata] @@ -243,6 +405,7 @@ function WorldSplatScene({ useEffect(() => { splatMesh.frustumCulled = false; + splatMesh.rotation.x = Math.PI; let cancelled = false; splatMesh.initialized .then(() => { @@ -263,9 +426,19 @@ function WorldSplatScene({ const groundPlaneOffset = typeof semanticsMetadata?.ground_plane_offset === "number" - ? semanticsMetadata.ground_plane_offset + ? -semanticsMetadata.ground_plane_offset : null; + const handleDirectionChange = (command: MovementCommand, active: boolean) => { + setVirtualMovement((prev) => { + if (prev[command] === active) return prev; + return { + ...prev, + [command]: active, + }; + }); + }; + const handlePointerDown = (event: ThreeEvent) => { if (interactionMode !== "hotspot") return; event.stopPropagation(); @@ -298,6 +471,12 @@ function WorldSplatScene({ onSelect={onHotspotSelect} /> ))} + {!firstPerson ? ( ) : ( - + )}
@@ -336,13 +521,20 @@ function WorldSplatScene({
- {firstPerson ? "WASD 移动 · 鼠标观察 · Shift 加速 · Esc 退出" : "拖拽旋转 · 滚轮缩放 · 原生 SPZ 渲染"} + {firstPerson + ? "方向键 / WASD 前后左右 · Q/E 或 PgUp/PgDn 上下 · 鼠标观察 · Esc 退出" + : "拖拽旋转 · 滚轮缩放 · 方向键 / 按钮可做六向移动"} +
+
+ {interactionMode === "navigate" ? ( + + ) : null} + {(firstPerson || interactionMode === "hotspot") && ( +
+ +
+ )}
- {(firstPerson || interactionMode === "hotspot") && ( -
- -
- )}