fix: center world splat camera controls
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-30 19:46:56 +08:00
parent 57b7292269
commit 2d76ec1629
2 changed files with 182 additions and 22 deletions

View File

@ -3861,11 +3861,11 @@ export default function CanvasModel3DPage() {
{worldViewportMode === "splat" ? (
<>
<div className="font-medium text-zinc-200">
{worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景原点旋转"}
{worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景主体中心旋转"}
</div>
<div className="mt-1">
{worldFirstPerson
? "点击画面后可锁定鼠标视角,WASD 或方向键移动,Q/R 上升,E/F 下降,Esc 退出鼠标锁定。"
? "按住左键拖动画面转向,WASD 或方向键移动,Q/R 上升,E/F 下降。"
: "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"}
</div>
</>

View File

@ -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<number | null>(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}
/>
<FirstPersonLookControls
enabled={interactionMode === "navigate" && firstPerson}
resetToken={resetViewToken}
/>
{!firstPerson ? (
<OrbitControls
autoRotate={false}
@ -1068,23 +1244,7 @@ function WorldSplatScene({
minDistance={0.5}
ref={handleOrbitControlsRef}
/>
) : (
<OrbitControls
dampingFactor={0.08}
enableDamping
enablePan={false}
enableRotate
enableZoom={false}
key="world-splat-first-person"
makeDefault
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.ROTATE,
RIGHT: THREE.MOUSE.ROTATE,
}}
ref={handleOrbitControlsRef}
/>
)}
) : null}
<Html className="pointer-events-none" fullscreen>
<div className="flex h-full w-full flex-col justify-between p-4">
<div className="flex items-start justify-between">