第九章 摄像机与视角控制: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 知识体系总览

下面这张图,是我自己总结的摄像机系统核心逻辑。你看一遍,基本就能把整个流程串起来:

第一人称摄像机系统核心流程 触摸输入 onTouchEvent 角度计算 yaw / pitch 更新 前向向量 forward = f(yaw, pitch) LookAt矩阵 setLookAtM(eye, center, up) 视图矩阵 传入顶点着色器 屏幕渲染 每帧更新 → 视角变化 核心:触摸偏移 → 角度变化 → 前向向量 → LookAt矩阵 → 视图变换

9.5 实战中的几个关键点

代码写完了,图也画了,我再补充几个实际项目中一定会遇到的问题:

  1. 多指触摸冲突:如果同时支持缩放和旋转,记得用PointerIndex区分手指。我一般用第一根手指旋转,第二根手指缩放。
  2. 性能优化:不要在触摸事件里做矩阵运算。把触摸数据存下来,在渲染线程的onDrawFrame里统一处理。
  3. 平滑插值:直接赋值会让视角很生硬。我习惯用线性插值或者球面插值,让视角平滑过渡。

一句话总结:摄像机视角控制,就是触摸偏移量 → 欧拉角 → 前向向量 → LookAt矩阵 这条链路。把这个链路理清楚,任何视角控制都不在话下。

好了,这一章的内容就到这里。代码不多,但背后的数学逻辑值得你多花点时间理解。下一章我们会聊投影矩阵——说白了就是怎么把3D世界压扁到2D屏幕上。到时候见。


公众号:蓝海资料掘金营,微信deep3321