手指拖动200像素,相机为什么不是在世界里移动200个单位?

前言
前面四篇,3D 房间已经能看了:有家具、有材质、有灯光。但只能从一个固定视角看,用户没法自己转一转、看一看。
这一篇开始做交互:单指拖动转视角,双指缩放,双指平移。看起来是很常见的手势,但做起来有个很容易绕晕的问题:
ArkUI 手势给的是二维屏幕坐标,像素单位。Camera 在三维世界里工作,单位是世界坐标。手指拖动 200 像素,不是相机在世界里移动 200 个单位------中间需要一层映射。
很多人刚开始做的时候,直接把手势 delta 加到相机位置上,结果就是:手指动一点,相机飞出去十万八千里,或者手指拖半天相机纹丝不动。
这一篇就解决这个问题:怎么把二维手势输入,映射成三维空间里的相机旋转、缩放和平移。
一、先搞清楚:手势给的是什么,相机需要的是什么
先把两边的输入输出列清楚:
| 来源 | 给的是什么 | 单位 |
|---|---|---|
| ArkUI 手势 | 手指在屏幕上移动了多少像素 | 屏幕像素(x, y) |
| Camera 需要的 | 绕 Y 轴转了多少度(Yaw)、绕 X 轴转了多少度(Pitch)、离目标点多远(Distance) | 角度、世界单位 |
这两个东西量纲都不一样,不能直接加。需要一个转换系数:屏幕上拖多少像素,对应转多少度。
举个例子:如果用户手指在屏幕上从左到右拖满整个屏幕宽度(比如 1080 像素),我们希望相机正好绕 Y 轴转 360 度。那转换系数就是 360 / 1080 = 0.333 度每像素。
也就是说,手指拖 100 像素,相机就转 33.3 度。
这个系数不是固定的,可以根据手感调整。有人喜欢灵敏一点,有人喜欢稳一点。
二、写一个 CameraController,封装视角逻辑
先写 CameraController,把相机的状态和操作都封装进去:
typescript
import { scene } from '@kit.ArkGraphics3DKit';
export class CameraController {
// 相机状态
private yaw: number = 0; // 绕Y轴旋转角度
private pitch: number = -30; // 绕X轴俯仰角度
private distance: number = 8; // 离目标点的距离
private target: {x: number, y: number, z: number} = {x: 0, y: 0.5, z: 0}; // 观察目标点
// 手势灵敏度
private rotateSensitivity: number = 0.3; // 每像素对应多少度
private zoomSensitivity: number = 0.01; // 每像素对应缩放多少
private cameraNode: scene.Node | null = null;
constructor(private scene: scene.Scene) {}
/**
* 初始化相机
*/
setupCamera(): void {
this.cameraNode = this.scene.createNode('MainCamera');
const camera = this.cameraNode.createComponent(scene.NodeComponentType.CAMERA) as scene.Camera;
// 更新相机位置
this.updateCameraTransform();
this.scene.getRoot().addChild(this.cameraNode);
console.info('CameraController: camera ready');
}
/**
* 根据当前状态计算相机位置
*/
private updateCameraTransform(): void {
if (!this.cameraNode) return;
// 球坐标转直角坐标:根据yaw、pitch、distance算相机位置
const radYaw = this.yaw * Math.PI / 180;
const radPitch = this.pitch * Math.PI / 180;
const x = this.target.x + this.distance * Math.sin(radYaw) * Math.cos(radPitch);
const y = this.target.y + this.distance * Math.sin(radPitch);
const z = this.target.z + this.distance * Math.cos(radYaw) * Math.cos(radPitch);
this.cameraNode.position = {x, y, z};
// 相机始终看向目标点
const camera = this.cameraNode.getComponent(scene.NodeComponentType.CAMERA) as scene.Camera;
camera.lookAt = this.target;
}
/**
* 旋转视角(单指拖动)
*/
rotate(deltaX: number, deltaY: number): void {
// deltaX 控制左右旋转(yaw)
this.yaw += deltaX * this.rotateSensitivity;
// deltaY 控制上下俯仰(pitch)
this.pitch += deltaY * this.rotateSensitivity;
// 限制俯仰角度,不要转到天上或地下
this.pitch = Math.max(-80, Math.min(80, this.pitch));
this.updateCameraTransform();
}
/**
* 缩放(双指捏合)
*/
zoom(delta: number): void {
this.distance -= delta * this.zoomSensitivity;
// 限制距离,不要太近也不要太远
this.distance = Math.max(2, Math.min(20, this.distance));
this.updateCameraTransform();
}
}
核心逻辑就在 updateCameraTransform() 里:用 yaw(左右角度)、pitch(俯仰角度)、distance(距离)这三个参数,算出相机在空间里的位置,然后让相机看向目标点。
这就是轨道相机(Orbit Camera)的基本模型:相机绕着一个目标点转,不是相机自己乱飞。

三、把 ArkUI 手势接到 CameraController 上
CameraController 写好了,接下来接 ArkUI 的手势:
typescript
@Entry
@Component
struct SpaceRoomPage {
private cameraCtrl: CameraController | null = null;
build() {
Stack() {
scene.SceneView({ scene: undefined })
.width('100%')
.height('100%')
.gesture(
// 单指拖动旋转
PanGesture()
.onActionUpdate((event: GestureEvent) => {
this.cameraCtrl?.rotate(event.offsetX, event.offsetY);
})
)
.gesture(
// 双指捏合缩放
PinchGesture()
.onActionUpdate((event: GestureEvent) => {
// 双指距离变化来控制缩放
this.cameraCtrl?.zoom(event.scale * 10);
})
)
}
.width('100%')
.height('100%')
}
}
这里有个坑:PanGesture 的 event.offsetX 是本次手势开始到现在的总偏移,不是每次回调的增量。
也就是说,手指从左边拖到右边,拖了 500 像素,第一次回调 offsetX 是 10,第二次是 30,第三次是 60......不是每次给你这次动了多少,而是从手势开始到现在累计动了多少。
所以正确的做法是记录上一次的位置,每次算增量:
typescript
private lastX: number = 0;
private lastY: number = 0;
// onActionStart 的时候记录初始位置
.onActionStart(() => {
this.lastX = 0;
this.lastY = 0;
})
.onActionUpdate((event: GestureEvent) => {
// 本次增量 = 本次总偏移 - 上次总偏移
const deltaX = event.offsetX - this.lastX;
const deltaY = event.offsetY - this.lastY;
this.cameraCtrl?.rotate(deltaX, deltaY);
// 更新上次位置
this.lastX = event.offsetX;
this.lastY = event.offsetY;
})
这个坑特别常见。我第一次做的时候,直接把 event.offsetX 加进去,结果就是:手指刚拖一下,相机瞬间就转了 180 度,因为 offsetX 是从手势开始累计的,不是增量。

四、加边界限制:不要让相机穿墙
开放的 3D 场景,相机是可以随便跑的。但我们这个是房间内部,相机不能穿墙,不能转到房间外面去。
两个限制:
- 俯仰角不要太极端,不要转到天花板上面或者地板下面
- 距离不要太近,不要贴到家具里面
刚才代码里已经加了:
typescript
// 限制俯仰角度在 -80 到 80 度之间
this.pitch = Math.max(-80, Math.min(80, this.pitch));
// 限制距离在 2 到 20 之间
this.distance = Math.max(2, Math.min(20, this.distance));
这样用户再怎么拖,相机也不会飞到离谱的位置。

五、几个最容易踩的交互坑
把这一篇遇到的坑总结一下:
| 坑 | 现象 | 解决办法 |
|---|---|---|
| 手势坐标直接当世界坐标 | 手指动一点相机飞出去 | 加灵敏度系数,像素转角度 |
| PanGesture 用了累计偏移不是增量 | 刚开始拖一下相机转半圈 | 记录上次位置,每次算增量 |
| 没有俯仰角限制 | 相机转到天上或者地下 | pitch 限制在 -80 到 80 度 |
| 没有距离限制 | 缩放太近贴到模型里,太远看不到 | distance 限制在 2 到 20 |
| 拖动方向反了 | 手指向右拖,场景向左转 | deltaX 正负号调整一下 |
| 手势太灵敏或者太迟钝 | 手感不对 | 调整 rotateSensitivity 参数 |
这些问题都不是技术上做不到,是手感调优。3D 交互的难点从来不是"能不能转",而是"转起来是不是自然"。
总结
第五篇的核心就一句话:二维手势和三维相机之间,必须有一层映射。
- ArkUI 手势给的是屏幕像素,相机需要的是角度和距离,不能直接加
- 用轨道相机模型:yaw(左右转)、pitch(上下俯仰)、distance(距离)三个参数控制相机
- PanGesture 给的是累计偏移,要自己算每次的增量
- 加边界限制:俯仰角、距离都要有上下限,不然相机乱飞
- 灵敏度参数根据手感调,没有标准答案
SpaceRoom 现在用户可以自己转着看了。下一篇做更有意思的交互:用户点一下沙发,能选中它、移动它、删除它------也就是 3D 拾取。