ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

3D渲染流水线中的空间变换:从模型坐标到屏幕像素

3D渲染流水线中的空间变换:从模型坐标到屏幕像素 很多刚接触游戏引擎的朋友都会问我同一个问题引擎里那么多的坐标系、矩阵、裁剪、光栅化我到底是先学哪个、怎么串起来坦白说我在入门阶段也被这些概念绕晕过很久直到真正把一个三角形手动送进渲染流水线才明白所谓3D渲染本质就是一场关于“空间变换”的接力赛。这一篇我把空间变换和3D渲染流水线放在一起讲因为它们是同一件事的两面变换决定了物体在哪个位置、以什么形状进入相机视野流水线决定了这些数据如何一步步变成屏幕上的像素。这篇内容适合刚写过第一个三角形、对矩阵有点概念但还没完全吃透的开发者也适合在引擎里调了半天transform参数却搞不清为什么旋转会乱掉的同学。1. 为什么说空间变换是所有渲染效果的“地基”1.1 从模型坐标到屏幕像素一段绕不开的路你打开任何一个3D建模软件模型文件里保存的顶点坐标都是在它自己的“模型空间”里定义的。这个空间里模型中心通常在原点附近长度、方向都只跟它自己有关系。但场景里有几十个物体你不可能把它们全都放在同一个位置。于是引擎要做的事情就是给每个物体一个平移、旋转、缩放把它从自己的小世界搬到整个场景的大世界再让相机决定“从哪个角度看过去最合适”最后把3D场景压成2D图片。这一整套从模型空间到世界空间、视图空间、裁剪空间再到屏幕坐标的转换链条就是空间变换。你可能觉得这些概念太“数学”离实际做游戏很远。但实际上你每次在Unity里拖动GameObject的Transform组件每次调整摄像机的Field of View每次看到物体进入视野边缘被裁剪掉背后都是这一套坐标系在运作。理解这条链路你就看懂了引擎场景面板里所有位置、旋转、缩放参数的本质。1.2 渲染流水线的整体框架每个阶段到底在干什么空间变换不是散落的数值计算它是被嵌在渲染流水线里的。一条典型的3D流水线大致分这么几步CPU准备好顶点数据和绘制命令GPU先执行顶点着色器把顶点从模型空间一路变换到裁剪空间然后硬件做透视除法和裁剪把裁剪空间坐标变成标准化设备坐标接着光栅化阶段把三角形拆成一个个像素片元再经过片元着色器计算每个像素的颜色最后经过深度测试、混合等输出合并阶段颜色才真正写入渲染目标。这条流水线看起来很复杂但核心只有两类工作第一算位置主要是空间变换部分第二算颜色是光照、纹理、阴影等部分。对于初学者我的建议是先不要一上来啃完整条流水线而是把“位置从哪来到哪去”这件事彻底搞清楚再回头去看颜色计算你会发现顺畅很多。本文重点讲空间变换但也把流水线关键阶段串联起来方便你建立一个整体认知。2. 空间变换的理论核心四个空间是怎么串起来的2.1 模型空间与世界空间让每个物体都在自己的正确位置先看模型空间到世界空间这个过程。每个顶点在模型空间里有一个坐标比如一个茶壶模型壶嘴的位置可能是(0.3, 0.5, 0.0)。你在场景里把这个茶壶放到(10, 0, -5)的位置旋转30度缩放2倍那壶嘴在世界空间里就不再是原来的值了。这个从模型空间到世界空间的转换靠的就是模型矩阵通常也叫世界矩阵由平移、旋转、缩放三个基本变换组合而成。这里有个特别容易踩的坑平移和旋转、缩放的先后顺序会影响最终结果。举个生活中的例子你把一张纸先旋转45度再往右平移和先往右平移再旋转45度得到的最终位置完全不一样。图形学里通用的做法是先缩放、再旋转、最后平移也就是矩阵乘法写作T * R * S。这样缩放和旋转是围绕着物体自身原点进行的平移则是把旋转缩放后的结果整体挪到世界坐标的指定位置。如果你把顺序搞反物体就会绕着一个奇怪的点旋转或者被拉伸到意想不到的方向。实现层面模型矩阵的构建并不复杂。使用常见的数学库时你可以先乘出缩放和旋转再乘平移矩阵。我见过不少人在手写引擎时为了省事直接用单个矩阵存Transform然后忘记每次修改后重新计算结果物体拖拽后位置正确但旋转全乱了。建议的做法是始终维护位置、旋转、缩放三个独立参数在需要上传到GPU时临时计算模型矩阵。这样既清晰也方便做动画插值。2.2 视图空间镜头背后的坐标变换世界空间里摆放好了所有物体接下来要把它们“从相机的角度看”出来。视图空间就是以相机为原点、以相机朝向为观察方向的空间。你可以把相机想象成你的眼睛你在房间里到处走动房间里的物体本身没有变但你的眼睛位置和朝向变了看到的画面就完全不同。视图矩阵干的事情就是把世界空间里的物体坐标重新换算成相对于“眼睛”的坐标。怎么构造视图矩阵最标准的方法是LookAt。你提供三个信息相机位置eye、目标点center、上方方向up。数学上通过叉积算出相机的右方向、上方向和前方向然后构造一个旋转加平移的矩阵。这个矩阵的本质是一个刚体变换的逆变换先让相机移到原点再让相机的三个坐标轴与世界坐标轴对齐。有个细节要特别注意在常见的右手坐标系里相机默认看向-Z方向即视图空间里物体的可见z值通常是负数。这意味着当你从相机位置往远处看时远处的物体z坐标更小。很多新手在这个地方会搞混以为相机朝Z结果物体全部消失或者被裁剪到奇怪的位置。判断方法也很简单如果你写的视图矩阵导致物体全部不在视野里先检查相机的朝向向量是不是指向-Z。2.3 投影矩阵怎么把3D压成2D以及“齐次坐标”为什么存在物体进入视图空间之后还要经过投影矩阵从视图空间变换到裁剪空间。投影分两种透视投影和正交投影。透视投影模拟人眼近大远小的效果适合大多数3D游戏正交投影没有远近变化适合UI、2D场景和某些编辑器视图。透视投影的核心参数有四个视野角度FOV、宽高比Aspect、近裁剪面Near、远裁剪面Far。FOV决定了你能看到多大的锥形范围宽高比决定画面是否被拉伸变形近远裁剪面决定哪些深度的物体被保留。透视投影矩阵本质上做的事情是把视锥体里的点映射到一个盒状裁剪空间里并且把深度信息编码到z分量。这里就应该理解为什么图形学非要引入四维向量和齐次坐标因为透视投影需要让远处的物体看起来更小这依赖一个把位置值除以深度的过程。不做成四维向量就很难在矩阵乘法里统一表达“除深度”这个非线性操作。正交投影相对简单它相当于一个平移加缩放把长方体范围的坐标映射到裁剪空间。很多初学者调试自己的渲染器时会先用正交投影替代透视投影因为正交投影不容易出现锥体外的裁剪问题排查起来更直观。等到正交投影下的结果完全正确再切换到透视投影能少踩很多坑。2.4 从裁剪空间到屏幕透视除法与视口变换顶点经过投影矩阵后已经被变换到了裁剪空间。裁剪空间里的坐标通常不是最终屏幕坐标它的x、y、z分量还在一个宽泛的范围内并且w分量里保存着视图空间的深度信息。接下来硬件会做透视除法把x、y、z都除以w得到标准化设备坐标也就是NDC。在OpenGL里NDC的x、y、z范围都是-1到1。这一步完成后超出范围的点会被判定为在屏幕外硬件会做视锥体裁剪。最后是视口变换GPU根据你设置的视口宽高把NDC坐标映射到实际的像素坐标。例如一个1920x1080的视口x从-1映射到0到1920y从-1映射到0到1080。这一步通常是硬件自动完成的了解它有助于你理解屏幕坐标的来源。我建议你在深入代码之前先拿一个点手工算一遍整个变换链。比如顶点坐标(1,1,0)经过你写的模型矩阵、视图矩阵、投影矩阵之后手动乘一下看看最后落到屏幕的哪个像素。这一遍算下来你对矩阵乘法顺序、w分量的含义、裁剪空间的范围会形成非常牢固的印象。比看十篇教程都管用。3. 亲手过一遍流水线一个三角形是怎么变成像素的3.1 CPU侧的准备工作顶点缓冲、索引缓冲和常量缓冲理解了理论之后我们把视角转到实操。以OpenGL风格的API为例渲染一个三角形CPU侧要准备三块数据。第一是顶点缓冲里面存放顶点位置、法线、纹理坐标等属性第二是索引缓冲告诉GPU三角形由哪三个顶点组成第三是常量缓冲或者Uniform变量存放模型矩阵、视图矩阵、投影矩阵这些“全局参数”。准备好这三块数据之后还需要设置顶点属性指针也就是告诉GPU缓冲里的数据从哪里开始读、每几个float是一个顶点、前三个float是位置、接下来三个是法线。这一步如果犯错GPU读数据就会出现偏移画面里经常出现乱七八糟的三角形几何体比如顶点坐标被错位读取。这也是初学者最头疼的调试场景之一。MVP矩阵通常在CPU端算好一次性传给GPU。注意不同的渲染API对于矩阵存储格式的约定不一样OpenGL习惯用列主序DirectX习惯用行主序。你在用数学库的时候一定要确认矩阵在内存里的排布方式否则计算结果会错得很离谱并且难以排查。我的习惯是在初始化阶段就统一规定使用列主序并且写一个打印矩阵的函数上传前把矩阵打印出来核对一次。3.2 顶点着色器变换的真正执行者顶点着色器在GPU上以每个顶点为单位并行执行核心任务就是计算顶点最终的裁剪空间坐标同时把顶点属性传递给后面的阶段。一个最简单的顶点着色器长这样#version 330 core layout(location 0) in vec3 aPos; layout(location 1) in vec2 aUV; uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProj; out vec2 vUV; void main() { gl_Position uProj * uView * uModel * vec4(aPos, 1.0); vUV aUV; }注意这里矩阵乘法的顺序顶点先被模型矩阵变换到世界空间再被视图矩阵变换到视图空间最后被投影矩阵变换到裁剪空间。对应到矩阵表达就是最右边的矩阵最先作用于顶点。这个顺序如果写反了比如写成uModel * uView * uProj整个画面就会完全错乱因为你先做了投影又做视图变换等于是把已经投影的数据再拿去平移旋转毫无意义。顶点着色器里另外一个重要需求是法线变换。世界空间里的法线不能直接用模型矩阵变换因为如果物体做了非均匀缩放法线方向会偏离正确的表面方向。正确做法是用模型矩阵的逆转置矩阵去变换法线。这个知识点在光照计算里至关重要很多初学者在这一步忽略了导致光照在拉伸后的物体上出现奇怪的条纹或者断层。3.3 光栅化从三角形边界到像素片元顶点着色器输出的是一堆裁剪空间顶点坐标这时候GPU会做裁剪、透视除法然后把三角形交给光栅化单元。光栅化的任务就是把三角形盖住的像素找出来。它把每个像素的中心放进去做包含性测试判断这个像素是否在三角形内部如果在就在这个位置生成一个片元然后把三个顶点的属性比如uv坐标、法线、颜色按照像素到三个顶点的距离关系做线性插值。插值这一步用到的数学工具叫重心坐标。你可以想象成三角形三个顶点分别持有三种颜料越靠近哪个顶点那个顶点的颜料就越浓最终颜色比重心坐标计算。纹理坐标的插值也同理这也是为什么一个三角形面片足够多的时候纹理看起来变形的程度会越小。这里有个视觉层面的重要细节光栅化是对屏幕像素进行离散化采样所以三角形的边缘必然会出现锯齿。想要消除锯齿就需要做多重采样抗锯齿让每个像素覆盖多个采样点。这是单独的话题但了解光栅化的采样机制后你会明白MSAA为什么能有效缓和边缘锯齿而不是一股脑依赖后处理。3.4 片元着色与输出合并决定像素的颜色光栅化生成片元之后片元着色器会以每个片元为单位执行计算这个像素的最终颜色。最简单的片元着色器就是输出一个固定颜色。在实际游戏里这里会做纹理采样、光照计算、阴影采样、透明度处理等多个步骤。片元着色器执行完之后片元还要经过一系列输出合并测试。最重要的就是深度测试GPU用深度缓冲里保存的值跟当前片元的深度值比较如果当前片元更靠近相机就覆盖旧颜色和更新深度否则就丢弃。这个机制保证了不透明物体之间的遮挡关系正确。测试的开关、比较函数的设置都是引擎暴露给开发者的常用参数。还有混合阶段它决定片元颜色跟缓冲里已有颜色怎么叠加。比如半透明玻璃先渲染不透明物体再渲染半透明物体混合模式下alpha值决定颜色权重。如果把顺序颠倒透明物体就会挡在不透明物体后面看起来非常奇怪。这一类“谁先被渲染”的问题在引擎里对应的就是渲染队列和排序设置。4. 空间变换与渲染流程里常见的坑4.1 矩阵乘法顺序先转再移还是先移再转我在前面的章节反复提到矩阵乘法顺序这里专门集中说明。图形学业界广泛采用列向量约定也就是一个点表示成列向量变换结果是矩阵乘列向量。在这种约定下如果你希望一个物体先缩放再旋转最后平移正确的复合矩阵就是M T * R * S。计算顶点时gl_Position P * V * M * vertex最右边的S最先作用于顶点。你可能会在有些代码里看到相反的顺序比如M S * R * T。这不一定错只是那个人用的是行向量约定。关键是自己项目里从头到尾保持一致。我调试过一台渲染器画面里的物体一直在绕着一个莫名的点旋转排查了半天最后发现是因为模型矩阵的构建在某个步骤里把平移和旋转搞反了一次导致变换结果跟预期差了十万八千里。建议你在项目里写一个断言工具对一个单位立方体做你期望的变换然后打印变换后的顶点坐标用肉眼核对是否符合直觉。4.2 透视矩阵参数设置近裁剪面不是越小越好很多人在搭相机时会想近裁剪面设成0.01远裁剪面设成100000这样最近最远的物体都能看到肯定没错。结果运行起来发现距离稍远的墙面和地面疯狂闪烁抖动近距离物体也偶尔出现重叠面闪烁。这个问题的根本原因是深度缓冲的精度分布是非线性的。为了理解这一点你需要知道深度值在透视投影下不是线性分布的靠近近裁剪面的区域占据了深度精度的绝大部分而远裁剪面的精度可能低到完全不可用。例如near0.1、far10000时你会发现在距离相机几十米之后深度值几乎都在0.999以上相邻遮挡面的深度间隔小到超出缓冲区的分辨能力于是发生Z-fighting。合理的做法是near值尽量大一些比如0.5或1far值不要超过场景实际需要的可见距离太多。渲染大型室外场景时用对数深度缓冲或者反向深度缓冲也能缓解精度问题但那是进阶话题。入门阶段先把near和far设得合理一点能省掉大量让人崩溃的调试时间。4.3 常见故障速查表下面这个表格是我在实际调试过程里总结出来的一些典型故障现象以及对应的排查方向。你可以把它贴在显示器旁边遇到问题时先过一遍。故障现象常见原因排查思路物体完全消失顶点坐标超出了裁剪空间范围或者视图矩阵方向反了检查视图矩阵的朝向确认朝向-Z检查模型矩阵有没有异常缩放物体变形、拉伸视口宽高比和投影矩阵的aspect参数不一致确认窗口resize后有没有更新投影矩阵物体绕原点旋转模型矩阵把旋转和平移顺序搞混改用T * R * S统一构建矩阵光照出现条纹用了模型矩阵直接变换法线改为使用逆转置矩阵远处的贴图抖动near/far范围不合适深度精度不足调大near或调小far透明物体遮挡错乱没有正确排序渲染顺序先渲染不透明物体半透明物体从远到近绘制多个物体位置错乱常量缓冲里矩阵上传时机不对确认每个物体的MVP是否在绘制前重新赋值这些坑我基本都踩过一遍尤其是法线变换和aspect参数两个排查过程都很煎熬所以把它们列出来希望能让你少走弯路。5. 关于“看向屏幕”这件事我的几个习惯5.1 用最小场景验证变换链路我自己的经验是每次搭新的渲染框架不要一上来就加载一个复杂的模型。先用两个三角形拼一个四边形顶点坐标写死颜色写死套一个最简单的正交投影。如果这个四边形能在屏幕上正确显示说明顶点数据路径没问题然后加入相机移动验证视图矩阵再加入透视投影验证管线整体没有逻辑错误。这个“由简入繁”的过程虽然看起来慢但每一步失败时你都能准确定位到是渲染哪一层的bug。直接加载一个漫反射带上材质的角色模型出了问题你根本分不清是变换错了还是着色器瑕疵。调试空间变换还有一个很实用的方法把矩阵运算的结果直接输出成文本。比如你手动算好一个顶点的世界坐标把它和着色器里的变换结果做一个对比。你也可以在CPU端用glReadPixels那样读取特定片元的深度再反算世界坐标。正经引擎开发里这种调试手段也经常出现在渲染功能开发的前期。5.2 旋转顺序与欧拉角引擎里最容易让人迷惑的细节说到空间变换就不得不提旋转的表示方式。引擎的Inspector面板里你通常看到的是欧拉角的三个值但引擎内部处理旋转时真正的数学载体是四元数或者矩阵。欧拉角的好处是直观坏处是存在万向锁问题并且在插值时会出现不可预期的情况。我自己在项目里定的规矩是如果只是静态摆放物体直接用引擎面板调欧拉角没问题但只要涉及动画、摄像机跟随、物体追逐旋转一律使用四元数插值。另外要注意不同引擎的欧拉角旋转顺序不一样例如某些引擎先绕Y轴再绕X轴再绕Z轴另一些引擎顺序不同。如果你在Shader里自己构造旋转矩阵一定要先确认引擎的约定否则同一个Transform参数在引擎里看到的旋转和你在Shader里算的旋转会不一致那个排查过程会非常辛苦。5.3 调试输出与可视化把矩阵变得“看得见”调试空间变换的时候最怕就是瞎猜。我强烈建议你在开发渲染器时就实现一个简单的DebugDrawing功能能在场景里绘制一个坐标系红绿蓝三条线分别表示XYZ轴能绘制一个球体表示点坐标能绘制一个截头锥体表示相机范围。每次写空间变换代码时都用这些可视化图元对照检查。举个例子当你的物体显示位置莫名其妙时你先绘制模型矩阵的位置看看物体实际在世界空间的哪里再绘制相机视锥体确认物体是否在可视范围内然后再检查投影矩阵的aspect和FOV。这一套可视化做完大部分渲染问题都能定位到具体阶段。我在带新人做渲染demo时也经常发现他们卡在空间变换的某一步一旦教会他们使用调试绘制功能问题排查效率提升了非常多。5.4 从引擎源码和工具中反推知识最后分享一个我自己的学习方法研究空间变换不一定要闭门造车。你可以打开任意一款成熟引擎的源码看看SceneView的相机是怎么定位的看看Transform组件update的时候矩阵是怎么组装的。你也可以用GPU帧调试工具在某个draw call上观察到顶点着色器输入输出的完整数据。对比自己的实现和引擎实现的差异往往能发现很多自己认知模糊的地方。这套方法听起来有点“偷师”的意味但实际上正是很多从业者的常态。造轮子的意义不是为了重复劳动而是通过复现核心逻辑把别人封装好的黑盒打开看清楚里面的每一条线。等你把空间变换和渲染流水线彻底弄明白之后再看引擎文档里的各种设置项你会忽然发现那些密密麻麻的参数不再是零散的知识点而是能串联成一张完整的地图。就我个人的体会来说在这张地图里迷路几次并不可怕因为每次迷路之后的排查都是对这套体系理解加深的机会。
返回列表