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}