ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

画一个三角形,彻底理解固定功能渲染管线

画一个三角形,彻底理解固定功能渲染管线 1. 为什么要从“画一个三角形”开始理解固定功能管线在图形学里三角形的地位相当于编程里的Hello World——但它远比Hello World更接近真实原理。我第一次在CPU上手动实现固定功能管线Fixed Function Pipeline时才真正明白显卡拿到一堆顶点之后到底干了多少活。很多人一开始就被Shader带走急着去调“顶点着色器”和“片元着色器”却对整条渲染流程只有一个模糊概念数据进去像素出来。中间那些阶段——坐标变换、裁剪、光栅化、深度测试、混合——到底是谁在什么时间点做了什么反而一知半解。固定功能管线恰恰是理解这一切最好的教材。所谓“固定功能”指的是早期GPU把渲染流程拆成一系列处理阶段这些阶段在硬件上已经“焊死”了开发者能通过设置参数调节行为但不能改变处理逻辑本身。比如OpenGL 1.x时代你调用glRotatef、glLightfv、glTexEnvi去配置矩阵、光照和纹理驱动把这些状态翻译成硬件的固定指令顶点进去、像素出来中间你的自由度非常有限。但正因为没得选你反而会被迫看清“管线到底长什么样”。为什么偏偏拿三角形举例这不是随便选的。三角形是光栅化硬件的基本处理单元理由至少有两条。第一三角形是最简单的平面多边形三个点一定共面这保证了后续插值和裁剪在数学上干净利落四边形在透视投影下可能被掰成两个不共面的半片而三角形不会。第二三角形内部任意一点都能用重心坐标barycentric coordinates唯一表示这个坐标天然适合做颜色、纹理、深度等属性的插值。实际渲染里一个复杂的角色模型动辄几十上百万个三角形GPU干的核心活就是“把三角形的顶点变成一组覆盖屏幕的像素”然后再决定这些像素以什么颜色写进画面。这篇文章不打算带你深入某个现代图形API的Demo而是想完整拆解“画一个三角形”这条链路上每一步的原理和隐藏细节固定功能管线在顶点阶段做了什么在光栅化阶段做了什么在逐片元阶段又做了什么。你可能是刚接触图形学的新手也可能写了一阵Shader但总觉得哪里隔着一层纸这篇文章都适合你。我会把每一步背后的数学逻辑和实际踩坑经验都摊开讲最后还会给你一个可以在CPU上运行的极简“固定功能版”渲染器框架让你亲手看到那条流水线是怎么运转的。2. 固定功能管线究竟做了哪几件事从顶点到像素的流水线拆解2.1 顶点坐标的变形记本地坐标到屏幕坐标的五次变换你传入GPU的第一个数据通常是一个顶点数组里面至少包含位置坐标可能还有颜色、法线、纹理坐标等属性。固定功能管线的Vertex阶段顶点处理阶段要做的第一件事就是把顶点从“模型自己的本地坐标系”搬到“屏幕像素坐标系”。这段路程大致要经历五个小站任何一站出问题三角形要么不显示要么显示在错误位置。第一站是模型变换Model Transform。物体在美术手里是在本地坐标系里建模的一个角色模型的“原点”可能在肚脐位置手臂顶点坐标是相对于这个原点的。模型变换矩阵M负责把它放到世界坐标系里完成平移、旋转、缩放这些摆位操作。第二站是视图变换View Transform矩阵V把世界坐标转换成以相机为中心、以相机朝向为基础的观察坐标相机位置变成原点相机正前方变成-z轴OpenGL约定。第三站是投影变换Projection Transform矩阵P把观察坐标的视锥体裁剪成标准立方体这一步同时决定了你能看到多大范围、近远裁剪面在哪以及透视还是正交。第四站关键且容易被忽略透视除法。矩阵P的输出是齐次裁剪坐标clip coordinates这个坐标带有一个w分量而真正的归一化坐标NDC要求x、y、z都除以w。这一步才是“近大远小”的真正来源离相机近的顶点w小除以w之后坐标变化幅度大离相机远的顶点w大坐标被压缩得厉害。最后一次变换是视口变换Viewport Transform把NDC里的[-1, 1]映射到实际的窗口像素坐标比如1920x1080的屏幕x从-1映射到01920y从1映射到01080注意y轴方向是上下翻转的。这五个步骤在固定功能管线上自动发生开发者只需要通过矩阵状态告诉驱动“用哪组参数”。我当时学的时候总觉得这一步太抽象后来用了个土办法理解想象你手里拿着一张照片第一步决定照片里的人在房间的哪个位置第二步决定用什么角度拍第三步决定用广角还是长焦第四步决定照片的立体感第五步决定把照片洗成多大尺寸。五种变换对应着同一套数学逻辑一连串矩阵乘法。2.2 光栅化三角形覆盖了哪些像素顶点处理完后三角形已经变成了屏幕坐标系下的三个二维点同时每个顶点身上挂着一堆属性深度值、颜色、纹理坐标。接下来登场的是光栅化Rasterization阶段任务是找出“这个三角形覆盖了哪些像素中心点”并为每一个被覆盖的像素生成一个片元fragment。固定功能时代的光栅化硬件主要用的是扫描转换scan conversion的思路现代GPU则更偏向基于包围盒的并行测试。无论哪种实现核心数学都是同一个给定三角形三个顶点v0、v1、v2和像素点p判断p是否落在三角形内部。最常用的方法是叉积符号测试或者用重心坐标判断把p表示成v0、v1、v2的加权平均p λ0 * v0 λ1 * v1 λ2 * v2要求λ0 λ1 λ2 1且三个λ都大于等于0p就在三角形内部。这三个λ值同时就是插值系数顶点颜色乘以各自权重再相加就是片元的颜色。听起来不复杂但光栅化有两个地方值得注意。第一像素中心点通常取整数坐标三角形的边却是连续的浮点坐标所以边缘会出现锯齿。固定功能管线的年代硬件里会提供多重采样MSAA或者纹理锯齿消除方案但最朴素的做法就是不处理这也是为什么老游戏的画面边缘总是“狗牙”明显。第二三角形覆盖像素时两个三角形共享的边必须只被计入一次否则同一像素会被写入两次产生闪烁和脏边这在光栅化里叫“top-left rule”之类的一致性规则属于硬件层面的细节应用层一般不关心。光栅化完成之后每个像素位置上产生了一个或若干个片元。片元可以理解成“候选像素”它带有从顶点插值得到的所有属性但还不能直接上屏后面还有一道关卡。2.3 逐片元操作像素最终能不能画上去片元从光栅化出来后进入逐片元操作per-fragment operations阶段。这一步在现代图形API里很多仍然保留为固定功能硬件单元——它没有变成可编程的shader因为这些东西本质上是硬件回答“这个像素能不能写、怎么写”的判断题。固定功能管线里这一串操作通常包括裁剪测试scissor test、Alpha测试、模板测试stencil test、深度测试depth test、混合blending、抖动dithering和逻辑操作。其中对“画三角形”这件事最重要的两个是深度测试和混合。深度测试的逻辑一句话就能说清每个片元带有一个深度值z它代表这个像素点在相机空间里离相机的远近帧缓冲区外还挂着一张深度缓冲区记录着当前已经写入的最近片元的深度。新片元到来时先拿它的z值和深度缓冲里对应位置的值比一比默认模式是“小于等于就通过”通过后再用新z覆盖旧z颜色也覆盖。这意味着即使两个三角形在代码层面一先一后递交最终谁显示在前面取决于谁离相机更近而不是谁后画。固定功能管线里深度测试和深度写入是可以分别开关的这个组合在透明物体渲染时非常重要透明物体通常要关掉深度写入、保留深度测试否则后面的半透明层会挡住前面的。混合blending则负责解决“半透明颜色怎么合成”的问题。固定功能管线提供一套固定的混合模式比如src alpha、one、one minus src alpha这几种因子组合用来计算新片元颜色和帧缓冲里已有颜色的加权和。OpenGL里的glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)就是最标准的标准alpha混合。到这里一个片元才真正获得“上屏资格”被写进颜色缓冲。3. 画三角形时最容易翻车的几个细节以及背后的数学原理3.1 矩阵乘法顺序左乘还是右乘结果天差地别固定功能管线时代你在OpenGL里要设置三组矩阵GL_MODELVIEW和GL_PROJECTION然后代码大致长这样glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(45.0f, aspect, 0.1f, 100.0f); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); gluLookAt(eye, center, up);看起来没问题但很多人刚开始都会犯一个错把M、V、P的乘法顺序写成p_local * M * V * P而不是P * V * M * p_local。区别在于矩阵乘法不满足交换律而这个顺序恰恰是初学者最大的坑之一。如果你把第一个三角形放在原点乘以一个“看起来正确”的MVP矩阵但顺序反了结果就是三角形在屏幕上的位置和预期完全对不上甚至直接落在裁剪范围外画面白屏一片。我当时排查过这类问题最后发现就是矩阵顺序。为什么顺序必须是“先M再V再P”因为每一步变换的输入是前一步的输出模型矩阵把本地坐标变到世界视图矩阵把世界坐标变到相机空间投影矩阵把相机空间变到裁剪空间。写完整就是// 列向量约定顶点写在右边 vec4 clipPos projectionMatrix * viewMatrix * modelMatrix * localPos;如果你用的是行向量约定比如某些数学库自带转置存储那就全反过来。团队协作时这个约定必须统一不然今天你的三角形在左边他改了一行代码之后跑右边去了。固定功能管线的好处是矩阵状态由驱动管理但坏处是你很难直接看到中间数据。现代可编程管线里你在Shader里手动算反而容易调试。3.2 裁剪必须在透视除法之前做一个三角形经过透视投影之后可能一部分在视锥体内、一部分在视锥体外。比如一个很大的三角形某个顶点跑到相机背后去了。这时候如果直接做透视除法那个位于相机后面的顶点的坐标会被翻转三角形形状彻底乱掉。所以硬件必须在透视除法之前完成视锥裁剪。裁剪空间clip space里判断顶点是否在可见区域用的是一组简单不等式-w x w-w y w-w z w。任何不满足条件的顶点都要被裁剪而一条边穿过裁剪面时三角形需要被切成一个或多个新三角形。固定功能管线里的硬件裁剪器clip unit干的就是这活而且它对每个三角形的输出可能是零个、一个、两个甚至更多个三角形。想一想就知道这跟“画单个三角形”的直觉很不一样你以为只需要填充三个顶点围成的区域实际上GPU内部可能先做了几何分裂。新手在软件光栅化器里最常偷懒的做法是“先透视除法再判断NDC是否越界”这几乎一定会出现三角形在地平线附近撕裂、翻转的现象。正确做法是计算裁剪坐标 clip P * V * M * localPos检查clip的x、y、z是否都在[-w, w]内都合格才做透视除法不合格做裁剪或丢弃。3.3 深度测试不是“按画家算法”而是“按深度缓冲”很多刚接触3D的人脑子里的遮挡模型是“谁后画谁在上面”这其实更像画家算法painters algorithm先画远处的山再画近处的树后面的覆盖前面的。画家算法在深度排序完全正确时能工作但三角形互相穿插、或者运动物体快速交换前后关系时排序成本高且结果不稳定。深度缓冲区z-buffer彻底解决了这个问题它为每个像素存一个深度值片元能不能写颜色取决于它带过来的深度值是否比缓冲里的更小离相机更近。固定功能管线的深度测试单元就是干这个的。这里有一个实际中频繁踩的坑深度缓冲的精度和数值范围。OpenGL的NDC z范围是[-1, 1]DirectX是[0, 1]而深度缓冲通常把z映射到[0, 1]存储。透视投影的z经过除法后不是线性分布的离相机近的部分精度高、远的部分精度低。所以近裁剪面和远裁剪面如果比值特别悬殊地平线附近就会出现深度冲突z-fighting表现为模型表面上闪烁的碎花——两个几乎共面的表面在远处互相争夺像素。我当时调一个地形项目时地面和道路铺层出现大面积闪烁调了半天就是远处深度精度不够。解决办法是压缩近远裁剪面的距离或者在shader里用反深度缓冲而不是盲目调偏移量。3.4 屏幕空间的线性插值藏着透视校正的坑三角形光栅化时顶点属性颜色、纹理坐标、法线都是靠重心坐标插值到每个片元上的。但直接在每个片元上对屏幕空间坐标做线性插值得到的结果是不符合透视规律的。举个例子一个由近到远延伸的地面纹理如果简单地在屏幕空间里对纹理坐标做线性插值远处的纹理线会被拉伸得奇宽无比近处的却挤在一起产生一种“纹理在甩动”的不真实感。正确做法是透视校正插值perspective-correct interpolation屏幕空间里真正线性插值的对象不是属性本身而是属性除以w后的值以及1/w。最后把两个插值结果相除才得到正确的属性。固定功能管线内部自动做了这一层校正所以你直接用固定管线贴纹理通常不会遇到这个问题。但一旦你开始自己写软件光栅化器、或者在现代Shader里手动计算插值这个坑就立刻暴露。判断方法很简单看纹理在地平线附近有没有不该有的波浪形扭曲。如果有八成是插值没做透视校正。4. 今天写Shader的人为什么还离不开固定功能的底层逻辑很多人会有个疑问现在谁还在用固定功能管线OpenGL 3.2之后核心profile直接移除了固定功能WebGL 2.0时代大家写的是GLSL ShaderVulkan和Metal更是一上来就要你手写全套渲染状态。那这篇文章讲这些“老古董”是不是过时了恰恰相反。固定功能管线不是被淘汰了而是被拆开了——它把一条完整流水线里需要逻辑判断的部分做成可编程单元把那些不需要逻辑判断的硬件操作继续固化下来。你今天写的顶点着色器取代的正是固定功能里“顶点变换和光照”那一段你写的片元着色器取代的正是固定功能里“纹理采样、雾计算、alpha测试”那一段。但深度测试、模板测试、混合、光栅化这些环节在现代渲染管线上依然由固定硬件完成你只能配置参数不能改写逻辑。这张对应关系在任何入门教材里都成立但真正让我意识到它价值的是调试Shader的时候。有一次我写一个粒子效果的片元着色器画面粒子总是被场景里的墙体挡得严严实实。我开始以为是混合模式配错了查了半天最后发现是深度写入没关。如果我对“固定功能管线哪些环节由硬件接管”没有概念一定会像无头苍蝇一样在Shader代码里翻。但因为我脑子里有那套流水线图瞬间就能定位片元着色器输出颜色之后后面还有一层深度测试单元在等着我。固定功能时期还有个经常被老教程一笔带过的坑就是法线变换。在固定功能管线的顶点变换阶段如果你开启了光照硬件会自动把法线从模型空间变换到世界空间或视图空间。但一个关键规则是遇到非均匀缩放时法线不能直接用模型矩阵变换否则法线和表面的垂直关系会被破坏。正确做法是使用模型矩阵的逆转置矩阵。在固定功能管线上驱动默默帮你处理了这个细节到了可编程着色器时代这个任务落到你头上但你如果没学过固定功能的“标准行为”甚至不知道有这个坑存在。这就是为什么我依然建议新人先理解固定功能管线再学Shader它告诉你哪些事是硬件默默替你做的这些事也正是你在写Shader时最容易忘记亲自去做的。5. 亲手在CPU上写一个“固定功能管线版”三角形渲染器理解了原理不动手做一遍总觉得缺了点东西。我建议你亲手在CPU上实现一个极简的软件固定功能渲染器目标只有一个把一个彩色三角形正确显示在屏幕上。这个练习会强迫你走一遍完整的变换→光栅化→深度测试流程做完之后你对管线的理解会扎实很多。以下代码是C风格伪代码聚焦核心逻辑完整工程留作练习5.1 数据结构与变换函数首先定义顶点、三角形和帧缓冲struct Vertex { float x, y, z, w; // 本地坐标w初始为1 float r, g, b; // 顶点颜色 }; struct Triangle { Vertex v[3]; }; struct Framebuffer { int width, height; float* depth; // 深度缓冲 unsigned char* color; // 颜色缓冲RGB各8bit };接下来实现MVP矩阵和变换函数。为了简单这里直接预设一个透视投影矩阵和一组视图矩阵你不需要自己推导但必须知道每个矩阵的职责// 先把整个窗口的像素填成背景色 void clear(Framebuffer fb, float r, float g, float b) { for (int i 0; i fb.width * fb.height; i) { fb.color[i * 3 0] (unsigned char)(r * 255); fb.color[i * 3 1] (unsigned char)(g * 255); fb.color[i * 3 2] (unsigned char)(b * 255); fb.depth[i] 1e30f; // 初始深度设为无穷远 } } // 顶点变换本地坐标 - 屏幕坐标 深度 bool transformVertex(const Vertex in, const Matrix4 mvp, int fbWidth, int fbHeight, /* out */ Vertex out) { // 矩阵乘法clip mvp * local float cx mvp.m[0] * in.x mvp.m[4] * in.y mvp.m[8] * in.z mvp.m[12] * in.w; float cy mvp.m[1] * in.x mvp.m[5] * in.y mvp.m[9] * in.z mvp.m[13] * in.w; float cz mvp.m[2] * in.x mvp.m[6] * in.y mvp.m[10] * in.z mvp.m[14] * in.w; float cw mvp.m[3] * in.x mvp.m[7] * in.y mvp.m[11] * in.z mvp.m[15] * in.w; // 裁剪测试x/y/z 是否都在 [-w, w] 内不在就丢弃 if (fabs(cx) cw || fabs(cy) cw || fabs(cz) cw) return false; // 透视除法 float invW 1.0f / cw; float nx cx * invW; // NDC范围约[-1,1] float ny cy * invW; float nz cz * invW; // 视口变换NDC - 屏幕像素 out.x (nx 1.0f) * 0.5f * fbWidth; out.y (1.0f - ny) * 0.5f * fbHeight; // y翻转 out.z nz; // z保留给深度测试 out.w in.w; // 顺便存一下原始w用于透视校正 out.r in.r; out.g in.g; out.b in.b; return true; }注意这个裁剪测试极其简化了真正的裁剪器要做三角形切分这里直接丢弃不合格三角形——对于“屏幕中央放一个三角形”的场景完全够用。如果你想支持部分顶点在屏幕外就需要写真正的多边形裁剪算法这是后话。5.2 光栅化主循环光栅化的目标是遍历三角形屏幕包围盒内的每个像素用重心坐标判断像素中心是否在三角形内部然后插值颜色和深度执行深度测试最后写颜色缓冲。void drawTriangle(Framebuffer fb, const Triangle tri, const Matrix4 mvp) { Vertex sv[3]; for (int i 0; i 3; i) { if (!transformVertex(tri.v[i], mvp, fb.width, fb.height, sv[i])) return; } // 计算屏幕空间包围盒 int minX (int)floor(fminf(sv[0].x, fminf(sv[1].x, sv[2].x))); int maxX (int)ceil(fmaxf(sv[0].x, fmaxf(sv[1].x, sv[2].x))); int minY (int)floor(fminf(sv[0].y, fminf(sv[1].y, sv[2].y))); int maxY (int)ceil(fmaxf(sv[0].y, fmaxf(sv[1].y, sv[2].y))); minX max(minX, 0); maxX min(maxX, fb.width - 1); minY max(minY, 0); maxY min(maxY, fb.height - 1); for (int y minY; y maxY; y) { for (int x minX; x maxX; x) { // 用重心坐标判断像素中心 (x0.5, y0.5) 是否在三角形内 float px x 0.5f, py y 0.5f; float area edgeFunction(sv[0], sv[1], sv[2]); if (area 0.0f) continue; float w0 edgeFunction(sv[1], sv[2], Vertex{px, py}); float w1 edgeFunction(sv[2], sv[0], Vertex{px, py}); float w2 edgeFunction(sv[0], sv[1], Vertex{px, py}); // 归一化重心坐标 w0 / area; w1 / area; w2 / area; // 所有系数 0像素在三角形内 if (w0 0 w1 0 w2 0) { // 插值深度简单场景顶点z已经算好 float depth sv[0].z * w0 sv[1].z * w1 sv[2].z * w2; // 深度测试通过才写颜色 if (depth fb.depth[y * fb.width x]) { fb.depth[y * fb.width x] depth; float r sv[0].r * w0 sv[1].r * w1 sv[2].r * w2; float g sv[0].g * w0 sv[1].g * w1 sv[2].g * w2; float b sv[0].b * w0 sv[1].b * w1 sv[2].b * w2; fb.color[(y * fb.width x) * 3 0] (unsigned char)(r * 255); fb.color[(y * fb.width x) * 3 1] (unsigned char)(g * 255); fb.color[(y * fb.width x) * 3 2] (unsigned char)(b * 255); } } } } } // 边缘函数判断(p,q,r)逆时针/顺时针方向 float edgeFunction(const Vertex a, const Vertex b, const Vertex c) { return (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x); }这套代码虽然简单但它完整复现了固定功能管线的关键环节顶点经过MVP变换、透视除法、视口变换光栅化阶段用重心坐标判断覆盖并插值逐片元阶段执行深度测试再写入颜色缓冲。运行时你会发现把两个不同深度、不同颜色的三角形丢进去显示结果完全由深度关系决定而不是绘制顺序这正是你理解固定功能管线后应得的“直觉”。5.3 输出与后续你可以扩展什么写完帧缓冲之后如何看结果最简单的办法是输出PPM图像文件。PPM是一种极其简单的文本图像格式你只需要写文件头“P3\n宽 高\n255\n”然后逐行输出RGB数值就能用看图软件打开。零依赖两分钟搞定。如果你想做实时窗口再去接SDL或GLFW这类库但练习阶段没必要——能看见一个正确处理遮挡的彩色三角形你已经走通了整个管线。这个极简渲染器非常简陋但它就是一张完整的“图纸”。后续扩展方向很多给光栅化加上透视校正插值插值属性时带上1/w最后还原三角形边缘的抗锯齿对覆盖面积做加权采样纹理映射在片元阶段采样一张贴图逐顶点光照在顶点阶段算好颜色再插值。每加一个功能你都会更清楚它应该出现在管线的哪个位置、由哪个阶段负责。我自己做完这个练习最大的感受是很多在现代API里被抽象掉的复杂度并不是不存在了而是被驱动和硬件藏起来了。固定功能管线的价值就是替你把盖子掀开让你看见流水线内部的每个齿轮都在转。画一个三角形不难难的是清楚知道这个三角形在屏幕上出现的每一步都被谁做了什么——这份清楚会陪你走完之后所有图形学相关的路。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表