第九章 摄像机与视角控制:LookAt矩阵,第一人称摄像机,触摸滑动控制视角旋转
好,我们终于聊到摄像机了。
说实话,在OpenGL ES里,根本没有摄像机。你没看错,硬件层面不存在什么“摄像机对象”。那我们在屏幕上看到的视角移动、旋转、拉近拉远,是怎么做到的?
答案很简单:整个世界在动,摄像机没动。
你想想看,你站在一个房间里,头向左转,看到右边的墙。在OpenGL里,我们不做“转头”这个动作,而是把整个房间向右旋转。效果一样,但实现方式完全不同。这就是摄像机视角的本质——对世界坐标系施加逆变换。
9.1 LookAt矩阵:摄像机视角的数学本质
LookAt矩阵,说白了就是一套数学公式,帮你算出一个变换矩阵。这个矩阵能把世界坐标系的点,变换到以摄像机为原点的坐标系里。
我记得刚学OpenGL那会儿,看到LookAt矩阵的推导公式,头都大了。后来我发现,其实只需要理解三个向量:
- 摄像机位置(eye):你在哪儿
- 观察目标点(center):你看哪儿
- 上方向(up):你的头顶朝哪儿
有了这三个向量,就能唯一确定一个摄像机姿态。LookAt矩阵的构造过程,其实就是用这三个向量构建一个正交基,然后做一次坐标系变换。
核心公式(理解即可,不用背):
LookAt矩阵 = 旋转矩阵 × 平移矩阵
其中旋转矩阵由三个正交基向量组成:前向(forward)、右向(right)、上向(up)
在Android的OpenGL ES中,我们直接用 Matrix.setLookAtM() 这个API。我项目中几乎所有的3D应用,都用这个函数来初始化摄像机矩阵。
// 标准LookAt用法
float[] viewMatrix = new float[16];
Matrix.setLookAtM(
viewMatrix, 0,
eyeX, eyeY, eyeZ, // 摄像机位置
centerX, centerY, centerZ, // 目标点
upX, upY, upZ // 上方向
);
这里有个坑,我踩过好几次:上方向向量不要和视线方向平行。如果你抬头看天,上方向就不能是(0,1,0),否则矩阵会奇异,画面直接崩掉。
9.2 第一人称摄像机:从数学到代码
第一人称摄像机,说白了就是让摄像机的位置和朝向,跟随用户的输入实时变化。我们需要维护几个状态变量:
- 位置(position):三维向量
- 水平角(yaw):左右旋转角度
- 俯仰角(pitch):上下旋转角度
有了yaw和pitch,就能算出前向向量:
// 从欧拉角计算前向向量
float forwardX = cos(pitch) * sin(yaw);
float forwardY = sin(pitch);
float forwardZ = cos(pitch) * cos(yaw);
然后,用这个前向向量和固定的上方向(0,1,0),就能构造出LookAt矩阵。注意,目标点 = 位置 + 前向向量。
个人习惯:我一般把pitch限制在-89°到89°之间,防止出现万向锁。虽然OpenGL ES里万向锁不致命,但用户体验会非常诡异——你转一下头,整个世界突然翻了个个儿。
完整的摄像机类,我通常会这样组织:
public class Camera {
private float[] position = new float[3];
private float yaw = -90f; // 初始看向-Z方向
private float pitch = 0f;
public float[] getViewMatrix() {
float[] viewMatrix = new float[16];
float[] forward = calculateForward();
Matrix.setLookAtM(
viewMatrix, 0,
position[0], position[1], position[2],
position[0] + forward[0],
position[1] + forward[1],
position[2] + forward[2],
0, 1, 0 // 上方向
);
return viewMatrix;
}
private float[] calculateForward() {
float x = (float)(Math.cos(pitch) * Math.sin(yaw));
float y = (float)(Math.sin(pitch));
float z = (float)(Math.cos(pitch) * Math.cos(yaw));
return new float[]{x, y, z};
}
public void rotate(float deltaYaw, float deltaPitch) {
yaw += deltaYaw;
pitch += deltaPitch;
// 限制俯仰角范围
if (pitch > 89f) pitch = 89f;
if (pitch < -89f) pitch = -89f;
}
}
9.3 触摸滑动控制视角旋转
好了,数学有了,代码有了,现在要让用户用手指滑动来控制视角。Android上处理触摸事件,核心就是 onTouchEvent() 方法。
我的做法很简单:记录上一次触摸点位置,计算偏移量,然后映射到yaw和pitch的变化上。
// 在GLSurfaceView中处理触摸
private float previousX, previousY;
private Camera camera;
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
previousX = x;
previousY = y;
break;
case MotionEvent.ACTION_MOVE:
float deltaX = x - previousX;
float deltaY = y - previousY;
// 灵敏度系数,我一般用0.1f到0.3f
float sensitivity = 0.15f;
camera.rotate(deltaX * sensitivity, -deltaY * sensitivity);
previousX = x;
previousY = y;
break;
}
return true;
}
这里有个细节:deltaY为什么要取负号? 因为屏幕坐标系Y轴向下,而pitch向上为正。手指向下滑,我们希望视角向上转,所以取反。
我曾经踩过的坑:在ACTION_MOVE里直接调用requestRender(),导致每帧都触发渲染,耗电严重。后来我改成只在手指停止滑动后,或者用固定帧率渲染。如果你的应用是连续渲染模式(RENDERMODE_CONTINUOUSLY),那没问题。但如果是按需渲染(RENDERMODE_WHEN_DIRTY),记得在触摸事件里主动请求重绘。
9.4 知识体系总览
下面这张图,是我自己总结的摄像机系统核心逻辑。你看一遍,基本就能把整个流程串起来:
9.5 实战中的几个关键点
代码写完了,图也画了,我再补充几个实际项目中一定会遇到的问题:
- 多指触摸冲突:如果同时支持缩放和旋转,记得用PointerIndex区分手指。我一般用第一根手指旋转,第二根手指缩放。
- 性能优化:不要在触摸事件里做矩阵运算。把触摸数据存下来,在渲染线程的onDrawFrame里统一处理。
- 平滑插值:直接赋值会让视角很生硬。我习惯用线性插值或者球面插值,让视角平滑过渡。
一句话总结:摄像机视角控制,就是触摸偏移量 → 欧拉角 → 前向向量 → LookAt矩阵 这条链路。把这个链路理清楚,任何视角控制都不在话下。
好了,这一章的内容就到这里。代码不多,但背后的数学逻辑值得你多花点时间理解。下一章我们会聊投影矩阵——说白了就是怎么把3D世界压扁到2D屏幕上。到时候见。
公众号:蓝海资料掘金营,微信deep3321