1. 从“模型在哪儿”说起:空间变换到底在解决什么问题
很多人第一次接触游戏引擎,看到“空间变换”四个字,脑子里浮现的是一堆矩阵乘法,觉得这不过是数学课内容的搬运。但如果你真的动手写过一个能跑能看的3D场景,就会发现空间变换根本不是“数学练习”,它解决的是一个非常具体、非常要命的问题:一个模型从建模软件里出来,到最终出现在屏幕上,中间要经历多少次“坐标系搬家”,每一次搬家如果搞错了,模型要么消失、要么变形、要么光照全乱。
我在早期做小场景Demo的时候,踩过一个特别典型的坑:把一个立方体从Blender导出,放进引擎里,位置、旋转、缩放都设好了,结果渲染出来发现它“飘”在离原点很远的地方,而且光照方向完全不对。排查了半天,最后发现是模型导出时没有应用变换,顶点数据里已经烘焙了位移,而我又在引擎里叠加了一次世界变换,等于搬了两次家。这个问题本质上就是空间变换的层级没理清楚。
所以这一篇,我想把空间变换和3D渲染流水线这两件事串起来讲。不是单纯讲矩阵怎么乘,而是讲清楚:一个顶点从模型空间出发,经过哪些坐标系,每一步变换到底在干什么,为什么要有这些步骤,以及在实际写引擎或使用引擎时,哪些地方最容易出错。适合已经了解基本向量和矩阵运算、但还没把整条流水线打通的朋友。如果你正在自己写渲染器,或者用Unity、Unreal、Godot做项目时对“为什么模型位置不对”感到困惑,这篇内容应该能帮你把思路理顺。
2. 五个坐标系与它们的“搬家”顺序
2.1 模型空间:顶点的出生地
模型空间(Model Space),有时候也叫局部空间(Local Space)或对象空间(Object Space),是顶点数据在建模软件里被创建时所在的坐标系。它的核心特征是:原点通常在模型的某个逻辑中心位置,坐标轴方向由建模者决定,和场景无关。
比如你在Blender里建一个立方体,默认原点在立方体中心,顶点坐标大概是(±1, ±1, ±1)。这个坐标就是模型空间坐标。它只描述“顶点相对于模型自身原点的位置”,不包含这个模型在场景里放在哪、朝哪、多大。
这里有一个容易被忽略的点:模型空间的原点位置是人为约定的,不是固定的。有的美术会把角色原点放在脚底,有的放在胯部,有的放在模型几何中心。这个约定会直接影响后续动画和物理模拟。比如原点在脚底的角色,做旋转时是以脚底为轴,看起来就像在原地转身;原点在中心的角色,旋转时会像陀螺一样绕自身中心转。所以我在项目里通常会要求美术统一规范:角色原点放脚底,道具原点放几何中心或抓握点,场景物件原点放底面中心。这个规范看起来是小事,但后期做交互和动画时能省掉大量修正工作。
2.2 世界空间:所有物体汇合的舞台
世界空间(World Space)是整个场景的统一坐标系。模型空间到世界空间的变换,就是通常说的模型矩阵(Model Matrix),它由模型的平移、旋转、缩放三个分量组合而成。
这里的关键在于:模型矩阵的乘法顺序不能乱。常见的组合顺序是先缩放、再旋转、最后平移,写成矩阵形式就是M = T * R * S。为什么是这个顺序?因为矩阵乘法是从右往左作用于向量的。顶点先被S缩放,再被R旋转,最后被T平移。如果你把顺序写成M = S * R * T,那顶点会先平移,再旋转,再缩放,结果就是模型绕着世界原点转,而不是绕自身原点转,位置会完全乱掉。
我见过不少新手在这里翻车:明明只想让模型自转,结果它像行星绕太阳一样公转。原因就是矩阵顺序写反了。一个简单的验证方法是:把平移分量设为零,看模型是否还在原点附近旋转。如果旋转时模型位置发生偏移,那基本就是顺序问题。
另外,模型矩阵里的缩放如果是非均匀缩放(比如x轴放大2倍,y轴不变),再叠加旋转,可能会产生**切变(Shear)**效果,模型看起来像被斜着拉扯。这是因为缩放和旋转不可交换。如果项目里需要非均匀缩放,通常建议把缩放放在旋转之前,或者直接用更复杂的变换分解方式处理。
2.3 观察空间:把摄像机变成原点
观察空间(View Space),也叫摄像机空间(Camera Space),是以摄像机为原点的坐标系。从世界空间到观察空间的变换叫观察矩阵(View Matrix),它的本质是“把摄像机移动到原点,并让它朝向-z方向”。
这里有一个非常实用的理解方式:观察矩阵就是摄像机世界变换的逆矩阵。如果摄像机在世界空间的位置是eye,朝向是forward,上方向是up,那么观察矩阵就是把世界坐标变换到“摄像机在原点、看向-z、上方向为+y”的坐标系。
为什么是-z?这是OpenGL等图形API的传统约定,摄像机默认看向负z方向。DirectX那边有些差异,但原理一样。实际写代码时,构造观察矩阵通常用lookAt函数,传入摄像机位置、目标点和上方向。这个函数内部会做叉积运算,构造出三个正交基向量,再组合成矩阵。
我在实际项目里遇到过一个典型问题:上方向向量和视线方向平行时,lookAt会退化。比如摄像机垂直向下看,上方向还是(0,1,0),叉积结果为零向量,矩阵就废了。解决办法是换一个不平行于视线方向的上向量,比如(0,0,1)。这个细节在写自由摄像机时特别容易忽略,尤其是做飞行视角的时候。
2.4 裁剪空间:投影与齐次坐标的舞台
裁剪空间(Clip Space)是顶点经过**投影矩阵(Projection Matrix)**变换后的空间。投影矩阵分两种:透视投影和正交投影。
透视投影模拟人眼近大远小的效果,它的矩阵构造依赖四个参数:视场角(FOV)、宽高比(Aspect)、近裁剪面(Near)、远裁剪面(Far)。这里有一个常见误区:很多人以为FOV越大,看到的范围越广,物体应该越小。但实际上,FOV越大,视野越广,但物体在屏幕上的投影也会越小,因为同样的物体占据了更小的视角比例。这个关系在调整摄像机时经常需要反复试。
正交投影则没有近大远小的效果,常用于2D游戏或工程制图。它的矩阵构造相对简单,主要指定左右、上下、远近六个裁剪面。
投影矩阵的输出是齐次坐标(x, y, z, w),其中w分量在透视投影下等于-z(观察空间下的深度)。裁剪空间的核心作用是:把视锥体内的顶点保留,视锥体外的顶点裁掉。判断条件就是-w <= x, y, z <= w。这个判断在GPU的裁剪阶段自动完成,但理解它有助于排查“为什么物体突然消失”的问题。
我踩过一个坑:近裁剪面设得太小,比如0.01,导致深度精度严重下降,远处物体出现z-fighting(深度冲突),闪烁不停。后来把近裁剪面调到0.1,问题就消失了。近裁剪面不是越小越好,它和远裁剪面的比值直接影响深度缓冲的精度。一般来说,近裁剪面尽量设大一点,远裁剪面根据场景规模设,比值控制在10000以内比较稳妥。
2.5 屏幕空间:最后的像素映射
屏幕空间(Screen Space)是顶点经过透视除法(Perspective Divide)和视口变换(Viewport Transform)后的空间。透视除法就是把齐次坐标的x, y, z分别除以w,得到归一化设备坐标(NDC)。NDC的x和y范围是[-1, 1],z范围在OpenGL是[-1, 1],在DirectX是[0, 1]。
然后视口变换把NDC映射到屏幕像素坐标:screenX = (ndcX * 0.5 + 0.5) * width,screenY = (1 - (ndcY * 0.5 + 0.5)) * height。注意y轴要翻转,因为屏幕坐标原点在左上角,而NDC原点在中心。
这一步之后,顶点就变成了屏幕上的像素位置,接下来进入光栅化阶段,把三角形离散成像素,再经过片元着色器上色,最终写入帧缓冲。
3. 渲染流水线的四个阶段与可编程点
3.1 应用阶段:CPU在忙什么
应用阶段是CPU主导的阶段,主要工作包括:场景管理、视锥剔除、渲染批次排序、设置渲染状态、提交绘制调用(Draw Call)。
视锥剔除是这里最影响性能的环节之一。它的原理很简单:用摄像机的视锥体去测试场景中每个物体的包围盒,完全在视锥体外的物体直接跳过,不提交绘制。我做过一个测试,在一个有5000个物体的场景里,开启视锥剔除后Draw Call从5000降到800左右,帧率直接翻倍。包围盒的精度很关键,用AABB(轴对齐包围盒)比OBB(有向包围盒)快,但剔除精度低;用OBB精度高,但计算量大。实际项目里通常先用AABB做粗剔除,再用OBB做细剔除。
渲染批次排序则是为了减少状态切换。比如把使用同一材质的物体排在一起,这样GPU不用反复切换着色器和纹理。这个优化在移动端尤其重要,因为移动GPU对状态切换的开销更敏感。
3.2 几何阶段:顶点着色器与图元装配
几何阶段从顶点着色器开始。顶点着色器的输入是单个顶点数据(位置、法线、UV、颜色等),输出是裁剪空间坐标和传递给后续阶段的插值数据。
顶点着色器里最核心的操作就是把模型空间顶点变换到裁剪空间,也就是依次乘以模型矩阵、观察矩阵、投影矩阵。通常写成gl_Position = MVP * vec4(position, 1.0)。这个MVP矩阵可以在CPU算好传进来,也可以在GPU里分别乘,前者更高效。
这里有一个实操经验:如果顶点着色器里需要用到世界空间坐标(比如做世界空间光照),不要用modelMatrix * position反复算,而是把世界坐标作为varying变量传出去。因为顶点着色器每个顶点执行一次,重复计算浪费不大,但片元着色器里如果每个像素都算一遍世界坐标,开销就很可观了。
图元装配阶段把顶点组装成三角形、线段或点。然后进入裁剪阶段,把部分在视锥体内的三角形裁掉,生成新的顶点。这个阶段是固定功能,不可编程,但理解它有助于明白为什么有时候三角形会被“切掉一块”。
3.3 光栅化阶段:从三角形到像素
光栅化的任务是把三角形转换成片元(Fragment)。每个片元对应屏幕上的一个像素位置,但还不一定是最终像素,因为后面还有深度测试和混合。
光栅化会计算每个片元的重心坐标,用来插值顶点着色器输出的varying变量。比如UV坐标、法线、世界坐标都是通过重心坐标插值得到的。这里有一个经典问题:透视校正插值。在透视投影下,直接线性插值会导致纹理在远处扭曲。GPU默认会做透视校正,用1/w作为插值权重。如果你在写软渲染器,忘了这一步,纹理就会看起来像被拉伸了一样。
3.4 片元阶段:着色与输出合并
片元着色器是真正决定像素颜色的地方。它的输入是插值后的varying变量,输出是颜色值。这里可以做光照计算、纹理采样、阴影映射、后处理效果等。
片元着色器里最常见的性能陷阱是过度绘制(Overdraw)。比如场景里有很多半透明物体层层叠加,每个像素被着色多次,GPU填充率直接爆掉。解决办法是尽量把不透明物体从前到后排序,利用早期深度测试(Early-Z)提前丢弃被遮挡的片元。早期深度测试的前提是片元着色器不写深度、不丢弃片元。如果你的着色器里有discard语句,Early-Z就失效了,性能会明显下降。
输出合并阶段做深度测试、模板测试和混合。深度测试决定片元是否被保留,混合决定片元颜色如何与帧缓冲已有颜色混合。半透明物体必须在不透明物体之后渲染,并且关闭深度写入,否则会出现透明排序错误。
4. 手写一个最小MVP变换的完整流程
4.1 构造模型矩阵:平移、旋转、缩放的组合
假设我们要把一个立方体放在世界坐标(3, 0, 5)的位置,绕y轴旋转45度,缩放为(2, 2, 2)。模型矩阵的构造步骤如下:
- 构造缩放矩阵S,对角线为(2, 2, 2, 1)。
- 构造旋转矩阵R,绕y轴旋转45度。
- 构造平移矩阵T,平移分量为(3, 0, 5)。
- 模型矩阵
M = T * R * S。
用代码表示(以列主序为例):
import numpy as np def translate(x, y, z): m = np.eye(4) m[0, 3] = x m[1, 3] = y m[2, 3] = z return m def rotate_y(angle_deg): rad = np.radians(angle_deg) c, s = np.cos(rad), np.sin(rad) m = np.eye(4) m[0, 0] = c m[0, 2] = s m[2, 0] = -s m[2, 2] = c return m def scale(x, y, z): m = np.eye(4) m[0, 0] = x m[1, 1] = y m[2, 2] = z return m M = translate(3, 0, 5) @ rotate_y(45) @ scale(2, 2, 2)注意@是矩阵乘法,顺序是T * R * S。如果你用行主序的API(比如DirectX),顺序可能要反过来写,但逻辑一样:先缩放,再旋转,最后平移。
4.2 构造观察矩阵:lookAt的三种实现方式
lookAt函数的目标是构造一个矩阵,把世界坐标变换到摄像机坐标。假设摄像机位置eye,目标点target,上方向up:
def look_at(eye, target, up): f = normalize(target - eye) # 前方向 s = normalize(cross(f, up)) # 右方向 u = cross(s, f) # 上方向 m = np.eye(4) m[0, 0], m[0, 1], m[0, 2] = s[0], s[1], s[2] m[1, 0], m[1, 1], m[1, 2] = u[0], u[1], u[2] m[2, 0], m[2, 1], m[2, 2] = -f[0], -f[1], -f[2] m[0, 3] = -dot(s, eye) m[1, 3] = -dot(u, eye) m[2, 3] = dot(f, eye) return m这里的关键是:观察矩阵的旋转部分是摄像机基向量的转置,平移部分是基向量与eye的点积的负值。这个构造方式比直接求逆矩阵更高效,也更稳定。
4.3 构造投影矩阵:透视与正交的取舍
透视投影矩阵的构造:
def perspective(fov_deg, aspect, near, far): f = 1.0 / np.tan(np.radians(fov_deg) / 2.0) m = np.zeros((4, 4)) m[0, 0] = f / aspect m[1, 1] = f m[2, 2] = (far + near) / (near - far) m[2, 3] = (2 * far * near) / (near - far) m[3, 2] = -1 return m正交投影矩阵:
def orthographic(left, right, bottom, top, near, far): m = np.eye(4) m[0, 0] = 2 / (right - left) m[1, 1] = 2 / (top - bottom) m[2, 2] = -2 / (far - near) m[0, 3] = -(right + left) / (right - left) m[1, 3] = -(top + bottom) / (top - bottom) m[2, 3] = -(far + near) / (far - near) return m选择透视还是正交,取决于项目需求。3D游戏几乎都用透视投影,2D游戏和UI用正交投影。有些引擎支持混合使用,比如3D场景用透视,UI层用正交,通过多个摄像机叠加实现。
4.4 顶点变换的完整计算与验证
把上面的矩阵串起来,一个顶点的完整变换是:
def transform_vertex(position, model, view, proj): world = model @ np.append(position, 1.0) view_pos = view @ world clip = proj @ view_pos ndc = clip[:3] / clip[3] return ndc验证方法:取一个已知位置的顶点,手动算一遍,看结果是否符合预期。比如原点(0,0,0)经过模型矩阵后应该变成(3,0,5),再经过观察矩阵应该变成相对于摄像机的位置。如果结果不对,就逐矩阵检查。
我习惯在调试时把中间结果打印出来,尤其是w分量。如果w分量是负数,说明顶点在摄像机后面,会被裁剪掉。这个检查在排查“物体突然消失”时非常有用。
5. 实际项目中最容易翻车的五个变换问题
5.1 法线变换不能用模型矩阵直接乘
法线是方向向量,不是位置向量,它的变换规则和顶点不同。如果模型矩阵包含非均匀缩放,直接用法线乘以模型矩阵会导致法线方向错误。正确的做法是用模型矩阵的逆转置矩阵来变换法线。
推导过程:法线垂直于切线,切线用模型矩阵变换后,法线必须保持与切线垂直。设切线为t,法线为n,模型矩阵为M,则变换后的切线为M*t,变换后的法线为N*n,要求(M*t) · (N*n) = 0。由于t · n = 0,可以推导出N = (M^-1)^T。
在实际着色器里,如果模型矩阵只有旋转和平移,没有缩放,那直接用模型矩阵的3x3部分变换法线也没问题。但只要有非均匀缩放,就必须用逆转置。我见过一个项目里,美术把角色模型在x轴缩放了1.5倍,结果光照看起来像被“压扁”了一样,排查了半天才发现是法线没正确处理。
5.2 深度缓冲的精度分配与z-fighting
深度缓冲的精度不是均匀分布的。在透视投影下,近处的深度精度高,远处的深度精度低。这是因为深度值经过透视除法后,z_ndc和1/z_view成正比,导致近处变化剧烈,远处变化平缓。
z-fighting通常发生在两个面非常接近时,深度值差异小于深度缓冲的精度,导致GPU无法判断哪个在前。解决办法有几种:
- 增大近裁剪面,缩小远裁剪面,提高整体精度。
- 使用对数深度缓冲(Logarithmic Depth Buffer),重新分配深度精度。
- 在物体层面做偏移,比如给其中一个面加一个微小的深度偏移。
- 使用反向深度缓冲(Reversed-Z),把深度范围从[0,1]翻转为[1,0],配合浮点深度缓冲可以大幅提高精度。
我在一个开放世界项目里,远裁剪面设了10000,近裁剪面0.1,结果远处的地形接缝处全是z-fighting。后来改用反向深度缓冲,问题基本消失。反向深度缓冲在支持浮点深度缓冲的平台上几乎是零成本,强烈推荐。
5.3 透明物体的排序与混合顺序
透明物体必须在不透明物体之后渲染,并且从远到近排序。如果顺序错了,混合结果就会不对。比如两个半透明玻璃重叠,先画近的再画远的,远的颜色会被近的覆盖,看起来像近的玻璃不透明。
排序的代价是CPU开销。如果透明物体很多,每帧排序可能成为瓶颈。优化方法包括:按材质分组减少状态切换,使用深度预通道(Depth Pre-Pass)先写深度再画透明,或者用OIT(Order-Independent Transparency)技术如加权混合(Weighted Blended)。
我在移动端项目里通常尽量避免大量透明物体,因为移动GPU的填充率有限,透明叠加很容易导致过热降频。如果必须用,尽量控制透明层数在3层以内。
5.4 骨骼动画中的蒙皮矩阵与空间变换
骨骼动画的蒙皮计算涉及多个空间:模型空间、骨骼空间、绑定姿势空间。每个顶点受多根骨骼影响,最终位置是各骨骼变换的加权和。
核心公式是:skinnedPos = Σ(weight_i * (boneMatrix_i * bindPoseMatrix_i * position))。其中boneMatrix_i是骨骼当前世界变换,bindPoseMatrix_i是骨骼绑定时的世界变换的逆。
这里最容易出错的是绑定姿势矩阵的逆矩阵计算。如果绑定姿势和当前姿势的坐标系不一致,蒙皮结果就会扭曲。我踩过的坑是:美术在绑定骨骼后调整了模型变换,但没有更新绑定姿势矩阵,导致动画播放时模型像被“揉碎”了一样。解决办法是重新绑定,或者在导出时确保绑定姿势矩阵正确。
5.5 多摄像机渲染中的视口与坐标映射
多摄像机渲染(比如分屏、小地图、UI叠加)需要正确设置视口(Viewport)。视口定义了NDC到屏幕的映射区域。如果视口设置不对,画面会拉伸或偏移。
比如分屏游戏,左右两个摄像机各占屏幕一半。左摄像机的视口是(0, 0, width/2, height),右摄像机是(width/2, 0, width/2, height)。每个摄像机的宽高比要单独计算,否则画面会变形。
小地图通常用正交投影,视口设在屏幕角落。UI摄像机用正交投影,视口覆盖全屏,但深度测试要关闭或单独设置,确保UI永远在最上层。
我在做分屏时遇到过一个坑:两个摄像机的宽高比都用了全屏的宽高比,结果左右画面都被横向拉伸。后来改成各自视口的宽高比,问题解决。视口宽高比和投影矩阵的宽高比必须一致,这是铁律。
6. 从流水线视角看性能优化与调试手段
6.1 用RenderDoc抓帧看每个阶段的输入输出
RenderDoc是我最常用的图形调试工具。它可以抓取一帧的完整渲染过程,查看每个Draw Call的顶点数据、着色器代码、纹理绑定、渲染状态,以及每个阶段的输出。
比如你怀疑某个物体的顶点变换有问题,可以在RenderDoc里找到对应的Draw Call,查看顶点着色器的输入和输出。输入是模型空间的顶点坐标,输出是裁剪空间坐标。如果输出不对,就往前检查MVP矩阵的值。RenderDoc的Mesh Viewer可以直观看到顶点在变换前后的位置,非常方便定位问题。
另一个常用功能是像素历史(Pixel History),可以查看某个像素被哪些Draw Call影响过,每次的深度测试和混合结果是什么。排查透明排序问题时特别有用。
6.2 减少Draw Call与状态切换的实操策略
Draw Call是CPU向GPU提交绘制命令的开销。Draw Call太多,CPU会成为瓶颈。减少Draw Call的方法包括:
- 合批(Batching):把使用相同材质的静态物体合并成一个网格,一次绘制。Unity的Static Batching和Dynamic Batching就是干这个的。
- 实例化(Instancing):把相同网格但不同变换的物体用一次Draw Call绘制。适合大量重复物体,比如草地、树木。
- 纹理图集(Texture Atlas):把多张小纹理合并成一张大纹理,减少纹理切换。
- GPU Driven Pipeline:把剔除和排序放到GPU做,CPU只提交一次。适合大规模场景。
我在一个场景里把1000棵树从单独Draw Call改成实例化渲染,Draw Call从1000降到1,帧率从30提升到120。实例化的关键是每个实例的数据(变换矩阵、颜色等)要放在实例缓冲区里,着色器里用gl_InstanceID索引。
6.3 顶点着色器与片元着色器的负载平衡
顶点着色器和片元着色器的负载要平衡。如果顶点数很多但屏幕覆盖小,顶点着色器是瓶颈;如果顶点少但覆盖大,片元着色器是瓶颈。
优化顶点着色器的方法:减少顶点数(LOD)、简化顶点计算、避免在顶点着色器里做纹理采样。优化片元着色器的方法:减少纹理采样次数、简化光照模型、使用Early-Z提前丢弃片元。
我在移动端项目里发现,片元着色器里的discard操作对性能影响很大,因为会禁用Early-Z。如果必须用,尽量把discard放在着色器早期,减少后续计算。
6.4 深度预通道与Early-Z的配合
深度预通道(Depth Pre-Pass)是先渲染一遍场景,只写深度不写颜色,然后再渲染一遍,这次开启深度测试,利用Early-Z丢弃被遮挡的片元。
这个技术在不透明物体重叠严重时效果明显。比如一个室内场景,墙壁、家具、角色层层遮挡,深度预通道可以把被遮挡的片元提前丢弃,减少片元着色器的执行次数。
但深度预通道本身也有开销,它多了一遍顶点变换和光栅化。所以是否使用要看场景的过度绘制程度。如果过度绘制率低于2倍,深度预通道可能得不偿失;如果高于3倍,收益就很明显了。
6.5 移动端与桌面端的流水线差异
移动端GPU和桌面端GPU在架构上有很大差异。移动端是Tile-Based Rendering(TBR),把屏幕分成小块,每块在片上内存里渲染完再写回主存。这意味着移动端对带宽更敏感,对状态切换更敏感。
在移动端,避免频繁的Render Target切换,因为每次切换都要把Tile写回主存。尽量把使用同一Render Target的绘制放在一起。另外,移动端的Early-Z更激进,但discard和alpha test会破坏它。
桌面端是Immediate Mode Rendering(IMR),直接渲染到帧缓冲,对带宽不那么敏感,但对Draw Call和状态切换敏感。所以桌面端优化重点是合批和减少状态切换,移动端优化重点是减少带宽和Render Target切换。
我在移动端项目里通常会把不透明物体和透明物体分开渲染,不透明物体用深度预通道,透明物体最后画。这样能最大化利用Early-Z,减少带宽消耗。
7. 把流水线串起来:一个完整帧的变换之旅
7.1 从场景图到渲染队列的完整链路
一个完整的帧,从场景图开始。场景图里的每个节点有局部变换,通过父子关系累积成世界变换。然后遍历场景图,做视锥剔除,把可见物体加入渲染队列。
渲染队列通常分几个阶段:不透明队列、透明队列、UI队列。不透明队列按材质和深度排序,透明队列按深度从远到近排序,UI队列按层级排序。
然后依次提交Draw Call,每个Draw Call设置渲染状态、绑定着色器和纹理、提交顶点数据。GPU执行顶点着色器、光栅化、片元着色器、输出合并,最终写入帧缓冲。
7.2 每个阶段的输入输出与调试检查点
| 阶段 | 输入 | 输出 | 调试检查点 |
|---|---|---|---|
| 模型变换 | 模型空间顶点 | 世界空间顶点 | 检查模型矩阵是否正确组合 |
| 观察变换 | 世界空间顶点 | 观察空间顶点 | 检查lookAt的基向量是否正交 |
| 投影变换 | 观察空间顶点 | 裁剪空间顶点 | 检查w分量是否为正 |
| 透视除法 | 裁剪空间顶点 | NDC | 检查NDC范围是否在[-1,1] |
| 视口变换 | NDC | 屏幕坐标 | 检查视口宽高比是否匹配 |
| 光栅化 | 屏幕坐标三角形 | 片元 | 检查重心坐标插值是否正确 |
| 片元着色 | 插值varying | 颜色 | 检查纹理采样和光照计算 |
| 输出合并 | 颜色+深度 | 帧缓冲 | 检查深度测试和混合状态 |
这个表格可以作为调试清单,逐阶段排查问题。
7.3 一个顶点从模型空间到屏幕的完整数值示例
假设一个顶点在模型空间是(1, 0, 0, 1),模型矩阵是平移(0, 0, -5),观察矩阵是摄像机在原点看向-z,投影矩阵是透视投影,FOV 60度,宽高比1,近裁剪面0.1,远裁剪面100。
计算过程:
- 模型变换:
(1, 0, 0, 1)->(1, 0, -5, 1) - 观察变换:摄像机在原点看向-z,观察矩阵是单位矩阵,结果不变
(1, 0, -5, 1) - 投影变换:
f = 1/tan(30°) ≈ 1.732,m[0,0] = 1.732,m[1,1] = 1.732,m[2,2] = (100+0.1)/(0.1-100) ≈ -1.002,m[2,3] = (2*100*0.1)/(0.1-100) ≈ -0.2002,m[3,2] = -1。 计算:x_clip = 1.732 * 1 = 1.732,y_clip = 0,z_clip = -1.002 * (-5) + (-0.2002) = 5.01 - 0.2002 = 4.8098,w_clip = -(-5) = 5。 - 透视除法:
ndc = (1.732/5, 0, 4.8098/5) = (0.3464, 0, 0.9620) - 视口变换:假设屏幕1920x1080,
screenX = (0.3464 * 0.5 + 0.5) * 1920 ≈ 1292.5,screenY = (1 - (0 * 0.5 + 0.5)) * 1080 = 540。
最终顶点在屏幕上的位置是(1292.5, 540),在屏幕右侧中间。这个结果符合预期:顶点在模型空间x正方向,摄像机在原点看向-z,所以顶点出现在屏幕右侧。
7.4 常见数值异常与排查思路
如果计算结果异常,比如w_clip为负,说明顶点在摄像机后面,会被裁剪。如果ndc超出[-1,1],说明顶点在视锥体外,也会被裁剪。如果screenX或screenY超出屏幕范围,说明视口变换有问题。
排查时,我习惯从后往前查:先看屏幕坐标是否合理,再看NDC,再看裁剪空间,最后看世界空间。每一步都打印数值,和预期对比。最忌讳的是跳过中间步骤,直接看最终结果,那样很难定位问题。
8. 写在最后:一些踩坑之后的个人体会
空间变换和渲染流水线这两块内容,刚接触时觉得是纯数学,枯燥得很。但真正做过几个项目之后,我发现它们其实是整个引擎里最“诚实”的部分——你哪里算错了,画面立刻就会告诉你,不会骗人。模型位置不对、光照方向反了、透明排序乱了、远处闪烁不停,这些问题背后几乎都能追溯到某个矩阵乘错了、某个坐标系搞混了、某个参数设得不合理。
我自己的习惯是,每做一个新场景,先放一个参考立方体,贴上带方向标识的纹理,然后手动调整摄像机,观察立方体在不同位置和角度下的表现。这个简单的测试能快速验证MVP矩阵是否正确。如果立方体看起来正常,再往里加复杂模型和光照。
另外,RenderDoc和Spector这类抓帧工具一定要熟练使用。它们能让你看到GPU实际执行的每一步,比在代码里加打印语句高效得多。尤其是排查着色器问题时,抓帧工具几乎是唯一的选择。
最后说一个容易被忽略的点:不同图形API的坐标系约定不同。OpenGL的NDC是[-1,1],DirectX是[0,1];OpenGL的纹理原点在左下角,DirectX在左上角;OpenGL的深度范围是[-1,1],DirectX是[0,1]。跨平台项目里,这些差异必须统一处理,否则会出现画面上下颠倒、深度测试失效等问题。我通常会在引擎层做一次坐标转换,把不同API的差异屏蔽掉,上层逻辑只用一套约定。这个转换虽然简单,但漏掉任何一处都会导致难以排查的渲染错误。