一、视图矩阵是什么
渲染时一个顶点要经过三次变换:
模型空间 ──Model──▶ 世界空间 ──View──▶ 观察空间 ──Projection──▶ 裁剪空间视图矩阵(View Matrix)把世界坐标转换到"以摄像机为原点"的坐标系。它就是摄像机自身变换矩阵的逆矩阵:
摄像机的世界变换: CameraWorld = T(位置) × R(朝向) 视图矩阵: View = CameraWorld⁻¹ = Rᵀ × T(−位置)可以理解为:游戏里并没有真的"移动摄像机",而是把整个世界反向移动、反向旋转,让摄像机始终待在原点、看向固定方向(OpenGL 约定看向 −Z,DirectX 约定看向 +Z)。
二、构建方法:LookAt
最常用的构建方式是给出"摄像机位置、看向的目标、上方向":
// 右手坐标系(OpenGL / glm::lookAt 的做法)mat4LookAt(vec3 eye,vec3 target,vec3 worldUp){vec3 f=normalize(target-eye);// 前vec3 r=normalize(cross(f,worldUp));// 右vec3 u=cross(r,f);// 真正的上(与 f、r 正交)mat4V(1.0f);V[0][0]=r.x;V[1][0]=r.y;V[2][0]=r.z;V[0][1]=u.x;V[1][1]=u.y;V[2][1]=u.z;V[0][2]=-f.x;V[1][2]=-f.y;V[2][2]=-f.z;V[3][0]=-dot(r,eye);V[3][1]=-dot(u,eye);V[3][2]=dot(f,eye);returnV;}矩阵结构(行表示):
| R.x R.y R.z −R·eye | 前三行是摄像机的三个轴(右、上、后) | U.x U.y U.z −U·eye | 旋转部分是正交矩阵,所以求逆只需转置 |−F.x −F.y −F.z F·eye | 平移部分是"摄像机位置在自身轴上的投影"取负 | 0 0 0 1 |⚠️ 当前方向
f与worldUp平行(正上方或正下方)时,叉积为零,矩阵失效。所以 FPS 摄像机的俯仰角要限制在 ±89° 以内。
三、各类摄像机
1. 第一人称(FPS)
用偏航角(yaw)和俯仰角(pitch)表示朝向,鼠标改变角度:
voidFpsCamera::OnMouse(floatdx,floatdy){yaw+=dx*sensitivity;pitch-=dy*sensitivity;pitch=clamp(pitch,-89.0f,89.0f);// 防止翻转和 LookAt 退化}mat4FpsCamera::GetView(){vec3 forward;forward.x=cos(radians(pitch))*cos(radians(yaw));forward.y=sin(radians(pitch));forward.z=cos(radians(pitch))*sin(radians(yaw));returnlookAt(position,position+forward,vec3(0,1,0));}移动时用摄像机的前、右方向(通常把前方向压平到水平面,这样抬头走路不会飞起来)。
2. 第三人称(环绕跟随)
摄像机绕角色转动,保持一定距离:
mat4ThirdPersonCamera::GetView(vec3 playerPos){vec3 target=playerPos+vec3(0,1.6f,0);// 看向角色头部附近vec3 offset=SphericalToCartesian(yaw,pitch,distance);vec3 desired=target+offset;// 防穿墙:从目标向摄像机发射线,碰到墙就把摄像机拉近floathit;if(Physics::Raycast(target,normalize(offset),distance,&hit))desired=target+normalize(offset)*(hit-0.2f);// 平滑跟随:位置做指数插值,避免抖动position=lerp(position,desired,1.0f-exp(-followSpeed*dt));returnlookAt(position,target,vec3(0,1,0));}3. 俯视角 / 等距视角(RTS、ARPG)
摄像机固定角度,只做平移和缩放:
vec3 eye=focusPoint+vec3(0,height,-height*0.8f);// 固定倾斜角mat4 view=lookAt(eye,focusPoint,vec3(0,1,0));等距(Isometric)游戏通常再配合正交投影。
4. 过场动画
摄像机沿样条曲线运动,朝向用四元数插值:
vec3 pos=CatmullRom(p0,p1,p2,p3,t);quat rot=slerp(q1,q2,t);mat4 view=inverse(translate(pos)*mat4_cast(rot));// 直接对摄像机变换求逆💡 插值时要分别插值位置和四元数,再构建矩阵。不要直接对两个视图矩阵做线性插值,否则旋转部分会变形、缩放。
四、视图矩阵的其他用途
1. 天空盒:去掉平移
天空盒应该"无限远",摄像机怎么移动都不应靠近它,所以只保留旋转:
mat4 skyView=mat4(mat3(view));// 取左上 3×3,平移清零2. 公告板(Billboard):取摄像机轴
粒子、草、血条要始终面向摄像机。视图矩阵的前两行就是摄像机在世界空间中的右方向和上方向,直接取出即可:
vec3 camRight=vec3(view[0][0],view[1][0],view[2][0]);vec3 camUp=vec3(view[0][1],view[1][1],view[2][1]);// 四边形的四个角vec3 corner=center+camRight*(dx*size)+camUp*(dy*size);血条、树木这类只想绕 Y 轴转的,把camUp换成(0,1,0)即可。
3. 鼠标拾取:逆视图矩阵
点击屏幕选中物体,要把屏幕坐标反推成世界空间的射线:
RayScreenToWorldRay(vec2 mouse,vec2 screen,mat4 proj,mat4 view){vec2 ndc={2.0f*mouse.x/screen.x-1.0f,1.0f-2.0f*mouse.y/screen.y};// 屏幕 Y 向下,需翻转vec4 clip={ndc.x,ndc.y,-1.0f,1.0f};vec4 eye=inverse(proj)*clip;eye=vec4(eye.x,eye.y,-1.0f,0.0f);// 方向向量,w = 0vec3 dir=normalize(vec3(inverse(view)*eye));vec3 origin=vec3(inverse(view)[3]);// 摄像机世界位置return{origin,dir};}逆视图矩阵的第 4 列就是摄像机的世界坐标,这在着色器里也常用(计算高光、雾效时需要摄像机位置)。
4. 阴影贴图:光源的视图矩阵
渲染阴影时,要从光源的位置看一遍场景,记录深度:
// 平行光:沿光方向放一个虚拟摄像机,配正交投影mat4 lightView=lookAt(sceneCenter-lightDir*50.0f,sceneCenter,vec3(0,1,0));mat4 lightProj=ortho(-40,40,-40,40,0.1f,100.0f);mat4 lightVP=lightProj*lightView;点光源要用 6 个视图矩阵(立方体贴图的 6 个面);级联阴影(CSM)则为每一级算一个光源视图矩阵,并让它包住主摄像机视锥的一段。
5. 平面反射(水面、镜子)
把摄像机按反射平面镜像,生成反射视图矩阵:
// 水面 y = h:摄像机位置镜像到水下,俯仰角取反vec3 reflEye=vec3(eye.x,2*h-eye.y,eye.z);vec3 reflTarget=vec3(target.x,2*h-target.y,target.z);mat4 reflView=lookAt(reflEye,reflTarget,vec3(0,1,0));用通用反射矩阵view × Reflect(plane)时,镜像会翻转三角形绕序,需要把背面剔除方向反过来,并用斜裁剪平面裁掉水面以下的物体。
6. 视锥剔除
从proj × view直接提取 6 个裁剪平面(Gribb-Hartmann 方法),然后用包围盒或包围球测试,视野外的物体不提交渲染:
mat4 m=transpose(proj*view);// 按行取planes[0]=m[3]+m[0];// 左planes[1]=m[3]-m[0];// 右planes[2]=m[3]+m[1];// 下planes[3]=m[3]-m[1];// 上planes[4]=m[3]+m[2];// 近planes[5]=m[3]-m[2];// 远// 每个平面再除以 length(xyz) 归一化7. 摄像机震动
爆炸、受击时的屏幕震动,在构建视图矩阵之前叠加一个局部偏移,不要改摄像机的逻辑位置:
vec3 shakeOffset=camRight*noise(t*25)*trauma+camUp*noise(t*25+100)*trauma;mat4 view=lookAt(position+shakeOffset,target+shakeOffset,up);这样震动只影响画面,不影响射线检测、AI 视线等游戏逻辑。
8. 观察空间中的效果
很多效果在观察空间里计算更方便,因为摄像机在原点、深度就是 −z:
- 距离雾:直接用观察空间深度计算雾浓度;
- SSAO、SSR:从深度缓冲重建观察空间位置,在该空间采样;
- 前向+ / 分簇光照:把光源变换到观察空间后按屏幕格子和深度分组。
9. 大世界精度:摄像机相对渲染
世界坐标很大时(例如距原点几十公里),float 精度不足,顶点会抖动。做法是让视图矩阵的平移部分为零,在 CPU 上用 double 先减去摄像机位置:
dvec3 camPos=camera.positionDouble;mat4 view=mat4(mat3(camera.rotationInverse));// 只有旋转for(auto&obj:objects){vec3 relPos=vec3(obj.positionDouble-camPos);// double 相减后再转 floatmat4 model=translate(relPos)*obj.rotationScale;Draw(obj,proj*view*model);}《坎巴拉太空计划》、各类飞行模拟和开放世界游戏都使用这种方法(或"浮动原点"方案)。
10. 分屏与多视口
本地多人分屏、小地图、后视镜:同一帧里用不同的视图矩阵渲染多次,每次设置不同的视口或渲染目标即可。小地图通常是正上方的视图矩阵配正交投影。
五、常见坑
| 问题 | 原因 / 解决 |
|---|---|
| 画面上下颠倒或左右镜像 | 左手 / 右手坐标系混用;glm 默认右手,DirectXMath 有LH/RH两套函数 |
| 矩阵乘法顺序错误 | 列向量约定是P × V × M × v;行向量约定(HLSL 常见)是v × M × V × P |
| 俯仰到 90° 时画面乱转 | LookAt 中前方向与上方向平行,需限制角度或改用四元数 |
| 长时间旋转后画面变形 | 反复累乘旋转矩阵产生误差,应每帧从角度或四元数重新构建 |
| 插值矩阵导致缩放 | 分别插值位置和四元数,再构建矩阵 |
| 法线变换错误 | 观察空间法线要用transpose(inverse(mat3(view × model))) |
六、总结
| 应用 | 视图矩阵的用法 |
|---|---|
| FPS / 第三人称 / 俯视 / 过场 | 不同方式计算摄像机位置和朝向,再构建视图矩阵 |
| 天空盒 | 去掉平移,只保留旋转 |
| 公告板 | 取矩阵行得到摄像机的右、上方向 |
| 鼠标拾取 | 求逆,把屏幕射线还原到世界空间 |
| 阴影 | 从光源位置构建视图矩阵 |
| 反射 | 镜像摄像机 |
| 视锥剔除 | 从proj × view提取裁剪平面 |
| 屏幕震动 | 构建前叠加临时偏移 |
| 大世界 | 平移清零,摄像机相对渲染 |
核心只有一句话:视图矩阵是摄像机变换的逆,它决定了"从哪里、朝哪个方向看世界"。游戏里所有摄像机、以及阴影、反射这类"从另一个视角看"的效果,本质上都是在构建不同的视图矩阵。