From 2d76ec16292fcc6217f0b8e8bf65177149c62e25 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 Apr 2026 19:46:56 +0800 Subject: [PATCH] fix: center world splat camera controls --- app/canvas/[id]/model3d/page.tsx | 4 +- components/canvas/world3d/World3DViewer.tsx | 200 ++++++++++++++++++-- 2 files changed, 182 insertions(+), 22 deletions(-) diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 0c3758f81..be3ed5a54 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -3861,11 +3861,11 @@ export default function CanvasModel3DPage() { {worldViewportMode === "splat" ? ( <>
- {worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景原点旋转"} + {worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景主体中心旋转"}
{worldFirstPerson - ? "点击画面后可锁定鼠标视角,WASD 或方向键移动,Q/R 上升,E/F 下降,Esc 退出鼠标锁定。" + ? "按住左键拖动画面转向,WASD 或方向键移动,Q/R 上升,E/F 下降。" : "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"}
diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 1492bb5e0..dffbdc609 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -281,6 +281,73 @@ function buildSplatParticleCloudData( }; } +function getPercentileValue(values: number[], percentile: number) { + if (!values.length) return 0; + const index = THREE.MathUtils.clamp( + Math.floor((values.length - 1) * percentile), + 0, + values.length - 1 + ); + return values[index] ?? 0; +} + +function getSplatWorldBounds(splatMesh: SparkSplatMesh) { + splatMesh.updateMatrixWorld(true); + return splatMesh + .getBoundingBox(true) + .clone() + .applyMatrix4(splatMesh.matrixWorld); +} + +function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) { + const packedSplats = splatMesh.packedSplats; + const totalSplats = Math.max(0, packedSplats?.numSplats || 0); + if (totalSplats < 32) { + return getSplatWorldBounds(splatMesh); + } + + splatMesh.updateMatrixWorld(true); + const targetCount = Math.min(totalSplats, 3200); + const sampleStep = Math.max(Math.floor(totalSplats / targetCount), 1); + const point = new THREE.Vector3(); + const xs: number[] = []; + const ys: number[] = []; + const zs: number[] = []; + + for (let index = 0; index < totalSplats; index += sampleStep) { + const splat = packedSplats.getSplat(index); + if (splat.opacity < 0.025) continue; + + point.copy(splat.center).applyMatrix4(splatMesh.matrixWorld); + xs.push(point.x); + ys.push(point.y); + zs.push(point.z); + } + + if (xs.length < 32) { + return getSplatWorldBounds(splatMesh); + } + + xs.sort((a, b) => a - b); + ys.sort((a, b) => a - b); + zs.sort((a, b) => a - b); + + const robustBounds = new THREE.Box3( + new THREE.Vector3( + getPercentileValue(xs, 0.02), + getPercentileValue(ys, 0.02), + getPercentileValue(zs, 0.02) + ), + new THREE.Vector3( + getPercentileValue(xs, 0.98), + getPercentileValue(ys, 0.98), + getPercentileValue(zs, 0.98) + ) + ); + + return robustBounds.isEmpty() ? getSplatWorldBounds(splatMesh) : robustBounds; +} + function positionCameraInsideWorld({ camera, controls, @@ -296,8 +363,7 @@ function positionCameraInsideWorld({ }): WorldCameraPose | null { if (!(camera instanceof THREE.PerspectiveCamera)) return null; - splatMesh.updateMatrixWorld(true); - const worldBounds = splatMesh.getBoundingBox(true).clone().applyMatrix4(splatMesh.matrixWorld); + const worldBounds = getRobustSplatWorldBounds(splatMesh); if (worldBounds.isEmpty()) return null; const center = worldBounds.getCenter(new THREE.Vector3()); @@ -388,9 +454,24 @@ function positionCameraInsideWorld({ Math.max(size.z * 0.08, 0.18), center.z ); - const orbitTargetPosition = anchor.clone(); + const orbitTargetPosition = center.clone(); + const orbitRadius = Math.max(size.length() * 0.5, majorSpan * 0.55, 1.2); + const orbitDistance = THREE.MathUtils.clamp(orbitRadius * 1.85, 2.2, 14); const targetPosition = firstPerson ? nearTargetPosition : orbitTargetPosition; + if (!firstPerson) { + cameraPosition + .copy(targetPosition) + .addScaledVector(horizontalAxis, orbitDistance) + .add( + new THREE.Vector3( + 0, + THREE.MathUtils.clamp(size.y * 0.08, 0.18, 1.2), + 0 + ) + ); + } + camera.position.copy(cameraPosition); camera.lookAt(targetPosition); camera.updateProjectionMatrix(); @@ -746,6 +827,97 @@ function CameraMovementControls({ return null; } +function FirstPersonLookControls({ + enabled, + resetToken, +}: { + enabled: boolean; + resetToken?: number; +}) { + const { camera, gl } = useThree(); + const yawRef = useRef(0); + const pitchRef = useRef(0); + const draggingPointerIdRef = useRef(null); + const directionRef = useRef(new THREE.Vector3()); + + const syncAnglesFromCamera = React.useCallback(() => { + camera.getWorldDirection(directionRef.current).normalize(); + yawRef.current = Math.atan2(directionRef.current.x, directionRef.current.z); + pitchRef.current = Math.asin( + THREE.MathUtils.clamp(directionRef.current.y, -0.98, 0.98) + ); + }, [camera]); + + const applyLookDirection = React.useCallback(() => { + const pitch = THREE.MathUtils.clamp( + pitchRef.current, + -Math.PI / 2 + 0.08, + Math.PI / 2 - 0.08 + ); + pitchRef.current = pitch; + + const cosPitch = Math.cos(pitch); + directionRef.current.set( + Math.sin(yawRef.current) * cosPitch, + Math.sin(pitch), + Math.cos(yawRef.current) * cosPitch + ); + camera.lookAt(camera.position.clone().add(directionRef.current)); + }, [camera]); + + useEffect(() => { + if (!enabled) return; + syncAnglesFromCamera(); + applyLookDirection(); + }, [applyLookDirection, enabled, resetToken, syncAnglesFromCamera]); + + useEffect(() => { + if (!enabled) return; + + const canvas = gl.domElement; + const sensitivity = 0.0032; + + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + if (isEditableKeyboardTarget(event.target)) return; + syncAnglesFromCamera(); + draggingPointerIdRef.current = event.pointerId; + canvas.setPointerCapture?.(event.pointerId); + event.preventDefault(); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (draggingPointerIdRef.current !== event.pointerId) return; + yawRef.current -= event.movementX * sensitivity; + pitchRef.current -= event.movementY * sensitivity; + applyLookDirection(); + event.preventDefault(); + }; + + const stopDragging = (event: PointerEvent) => { + if (draggingPointerIdRef.current !== event.pointerId) return; + draggingPointerIdRef.current = null; + canvas.releasePointerCapture?.(event.pointerId); + event.preventDefault(); + }; + + canvas.addEventListener("pointerdown", handlePointerDown); + canvas.addEventListener("pointermove", handlePointerMove); + canvas.addEventListener("pointerup", stopDragging); + canvas.addEventListener("pointercancel", stopDragging); + + return () => { + draggingPointerIdRef.current = null; + canvas.removeEventListener("pointerdown", handlePointerDown); + canvas.removeEventListener("pointermove", handlePointerMove); + canvas.removeEventListener("pointerup", stopDragging); + canvas.removeEventListener("pointercancel", stopDragging); + }; + }, [applyLookDirection, enabled, gl.domElement, syncAnglesFromCamera]); + + return null; +} + function HotspotBillboard({ hotspot, onSelect, @@ -1049,6 +1221,10 @@ function WorldSplatScene({ firstPerson={firstPerson} orbitControlsRef={orbitControlsRef} /> + {!firstPerson ? ( - ) : ( - - )} + ) : null}