可达可达 發表於 2025-8-16 05:09:00

《Fundamentals of Computer Graphics》第九章 图形管线

<h1 id="开篇">开篇</h1>
<p>  前几个章节为第二种也就是基于物体顺序的渲染搭好了数学的脚手架,稍微回顾一下之前的内容,和基于像素顺序的渲染不同的是,基于物体顺序的渲染以几何物体为中心,为每个几何物体找到它能影响的像素。为每个几何物体找到它所占据的图像上的像素的过程被称为<strong>光栅化</strong>(<strong>Rasterization</strong>),因此基于物体顺序的渲染也被叫做<strong>光栅化渲染</strong>。需要一序列操作,从物体出发到更新图像上的像素结束则被称为<strong>图形管线</strong>(<strong>Graphics Pipeline</strong>)。<br>
  这章的标题可能让你觉得只有一种方法来实现基于物体顺序的渲染,其实不是这样的。例如有两种截然不同的图形管线的例子,为不同的目的打造。第一种是硬件管线用于支持交互式渲染,通过<strong>OpenGL</strong>和<strong>Direct3D</strong>等<strong>API</strong>实现。第二种是软件管线用于影视制作,支持<strong>RenderMan</strong>等<strong>API</strong>。硬件管线必须运行的足够快来为游戏和可视化以及用户界面进行实时响应。而软件管线必须尽可能渲染出最高质量的动画和视觉效果并且能应对庞大的场景,不过这会花费更多的时间就是了。尽管是出于不同决定设计的,不过大部分图形管线都有着非常多共同点,这一章将聚焦于这些共同点并且更贴近于硬件管线。<br>
  基于物体顺序的渲染所需要的任务可以组织成光栅化自己的任务、在光栅化之前要对几何进行的操作、在光栅化之后要对像素进行的操作。最常见的几何操作就是施加矩阵变换,就如同前两章所讨论的把定义在物体空间的几何体映射到屏幕空间。对于像素来说最常见的操作是<strong>隐藏表面去除</strong>(<strong>Hidden Surface Removal</strong>),它能让更近的表面呈现在观察者面前。在每个阶段也能有其它操作添加,由此可以使用相同的流程来达到许多不同的渲染效果。<br>
  对于这一章节来说,就是讨论图形管线的四个阶段,如下图所示<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202507/2774734-20250718050821510-232899392.png"></p>
<p>从交互式应用程序或者场景描述文件中被送进管线的几何物体通常都是用一些顶点描述的,这些顶点会在<strong>顶点处理阶段</strong>(<strong>Vertex-Processing Stage</strong>)被处理。接着,使用这些顶点的图元会被送入<strong>光栅化阶段</strong>(<strong>Rasterization Stage</strong>)。光栅器会把每个图元分解成一些<strong>片段</strong>(<strong>Fragment</strong>),每个片段对应着被图元覆盖的一个像素。片段会在<strong>片段处理阶段</strong>(<strong>Fragment Processing Stage</strong>)被处理,然后对应着相同像素的不同片段会进入<strong>片段混合阶段</strong>(<strong>Fragment Blending Stage</strong>)从而被组合。</p>
<h1 id="光栅化rasterization">光栅化(Rasterization)</h1>
<p>  光栅化在基于物体顺序的渲染中处于中心地位,而<strong>光栅器</strong>(<strong>Rasterizer</strong>)是所有图形管线的核心。对于每个被送入的图元,光栅器有两个工作,首先是枚举图元覆盖的像素,接着在整个图元上插值叫做属性(看到后面就懂了)的东西。光栅器的输出是一些片段,每个片段对应着图元覆盖的一个像素。每个片段“存活”于一个特定的像素中并且携带着属于它自己的一系列属性值。</p>
<h2 id="直线绘制line-drawing">直线绘制(Line Drawing)</h2>
<p>  绝大多数图形包都有一个指令能绘制有屏幕坐标的两个端点构成的线段。比如给定两个端点<span class="math inline">\((1,1)\)</span>和<span class="math inline">\((3,2)\)</span>,则会取像素<span class="math inline">\((1,1)\)</span>和<span class="math inline">\((3,2)\)</span>以及这两个像素之间的一个像素作为一条线段。那么对于更加一般的端点<span class="math inline">\((x_0,y_0)\)</span>和<span class="math inline">\((x_1,y_1)\)</span>来说就要取一定合理数量的像素来接近理想的线段,画这种线段得基于直线方程。直线方程有隐式形式和参数形式可选,下面介绍使用隐式形式的方法。</p>
<h3 id="使用隐式直线方程绘制直线line-drawing-using-implicit-line-equations">使用隐式直线方程绘制直线(Line Drawing Using Implicit Line Equations)</h3>
<p>  最常用的使用隐式方程绘制直线的方法就是<strong>中点</strong>算法,而中点算法最终和<strong>布雷森汉姆算法</strong>(<strong>Bresenham Algorithm</strong>)绘制了相同的直线,不过中点算法显得更直接一些。使用这个方法首先要找到直线的隐式方程</p>
<p></p><div class="math display">\[f(x,y) \equiv (y_0-y_1)x+(x_1-x_0)y+x_0y_1-x_1y_0
\]</div><p></p><p>假设<span class="math inline">\(x_0 \leq x_1\)</span>,如果不成立交换两个点即可。直线的斜率<span class="math inline">\(m\)</span>为</p>
<p></p><div class="math display">\[m = \frac{y_1-y_0}{x_1-x_0}
\]</div><p></p><p>下面的讨论假设<span class="math inline">\(m \in (0,1]\)</span>,当然了还有其它三种情况即<span class="math inline">\(m \in (-\infty,-1]\)</span>、<span class="math inline">\(m \in (-1,0]\)</span>、<span class="math inline">\(m \in (1,\infty)\)</span>。对于<span class="math inline">\(m \in(0,1)\)</span>来说水平移动比竖直升高要多,在直线上移动的点的<span class="math inline">\(x\)</span>坐标比<span class="math inline">\(y\)</span>坐标变化快,这个时候你有可能觉得<span class="math inline">\(y\)</span>轴的正半轴如果朝下会让处理变得麻烦起来,实际上代数运算并不关心<span class="math inline">\(y\)</span>轴的朝向问题。中点算法的关键设想就是尽可能绘制细的没有间隙的直线(斜着连接的两个像素不算有间隙)。<br>
  当从左端点往右绘制线段时只有两种可能,要么绘制当前像素位置右边的像素要么绘制当前像素位置右上的像素,这样每一列都会有一个像素被绘制从而保证在尽可能细的情况下无间隙,下面几张图展示了相同线段的不同绘制方法<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202507/2774734-20250720035545206-1047125740.png"></p>
<p>对于<span class="math inline">\(m \in (0,1]\)</span>的情况,中点算法会从最左侧的像素中心位置开始,往右循环为每列挑选一个像素。基础的算法应该长这样<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250807035408880-1165857423.png"></p>
<p>要注意的是<span class="math inline">\(x\)</span>和<span class="math inline">\(y\)</span>都是整数,这个算法最核心的地方就在分支语句这里,应该有个十分效率的方法来判断是否要“上升”。有个好的方法是查看两个待选像素中心连线的中点和直线的关系。以像素中心位置<span class="math inline">\((x,y)\)</span>来说,两个待选像素中心为<span class="math inline">\((x+1,y)\)</span>和<span class="math inline">\((x+1,y+1)\)</span>,中点为<span class="math inline">\((x+1,y+0.5)\)</span>,如果直线在中点上方则绘制上面的待选像素,否则绘制下面的待选像素。我们可以通过<span class="math inline">\(f(x,y)\)</span>的取值来判断这一关系,如果<span class="math inline">\(f(x,y) = 0\)</span>那么中点在直线上,如果<span class="math inline">\(f(x,y)&lt;0\)</span>那么直线在中点上方,否则直线在中点下方。因此上面的伪代码的分支部分就替换成了这样<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250807035504189-410250275.png"></p>
<p>在此基础上我们还可以进一步优化算法,观察<span class="math inline">\(f(x+1,y)\)</span>和<span class="math inline">\(f(x+1,y+1)\)</span>我们可以得到</p>
<p></p><div class="math display">\[\begin{align*}
f(x+1,y)&amp;=f(x,y)+(y_0-y_1) \\
f(x+1,y+1)&amp;=f(x,y)+(y_0-y_1)+(x_1-x0)
\end{align*}
\]</div><p></p><p>上面两个式子和<span class="math inline">\(f(x,y)\)</span>之间实际上就相差了个常数,因此我们只需要在开始时计算<span class="math inline">\(f(x_0+1,y_0+0.5)\)</span>,接着循环累加常数来绘制直线。<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250807035539335-195376928.png"></p>
<p>上面的做法可能会累积更多的数值错误,当绘制一条非常长的线段时会有很多次的常量累加,不过绘制线段需要的像素一般很少超过几千个,因此这种错误不是特别严重。</p>
<h2 id="三角形光栅化triangle-rasterization">三角形光栅化(Triangle Rasterization)</h2>
<p>  我们通常可能会想绘制由有屏幕坐标的三个二维点<span class="math inline">\(\mathbf{p}_0 = (x_0,y_0)\)</span>、<span class="math inline">\(\mathbf{p}_1=(x_1,y_1)\)</span>、<span class="math inline">\(\mathbf{p}_2=(x_2,y_2)\)</span>构成的二维三角形,这和直线绘制有相似的地方不过也有它自己的特点。和直线绘制一样我们可能想通过顶点携带的数据获得插值后的颜色或者其它属性,如果我们有重心坐标<span class="math inline">\((\alpha,\beta,\gamma)\)</span>,三个顶点分别携带颜色<span class="math inline">\(\mathbf{c}_0\)</span>、<span class="math inline">\(\mathbf{c}_1\)</span>、<span class="math inline">\(\mathbf{c}_2\)</span>,那么可以直接得到插值后的颜色<span class="math inline">\(\mathbf{c}\)</span>为</p>
<p></p><div class="math display">\[\mathbf{c} = \alpha \mathbf{c}_0 + \beta \mathbf{c}_1 + \gamma \mathbf{c}_2
\]</div><p></p><p>颜色的这一类型的插值在图形学中被称为<strong>高洛德插值法</strong>(<strong>Gouraud Interpolation</strong>)。和直线绘制不同的是我们可能想光栅化有着共享顶点和边的三角形,这意味着光栅化相接的三角形从而没有孔洞产生。我们可以通过中点算法来绘制每个三角形的轮廓接着填充内部像素,不过这意味着有着共享边的两个三角形会覆盖一些相同的像素,如果这两个三角形有不同的颜色,那么渲染出的图像会取决于这两个三角形的绘制顺序。最常用的方法是只有像素中心在三角形内时才会绘制对应的像素,不过仅仅这么做是不够的,在大多数情况下像素中心的重心坐标都会在区间<span class="math inline">\((0,1)\)</span>内,当像素中心正好在边上时又会引发问题,有一些方法能解决这个问题不过将在后续讨论。一个关键的观察是重心坐标会决定像素是否会被绘制并且确定从顶点插值后的颜色,所以问题来到了要有效率的方法找到像素中心的重心坐标,首先给出暴力光栅算法<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250803035649002-1580572579.png"></p>
<p>我们可以用第二章提供的方法来计算重心坐标</p>
<p></p><div class="math display">\[\begin{align*}
\beta &amp;= \frac{f_{ac}(x,y)}{f_{ac}(x_b,y_b)} \\
f_{ac}(x,y) &amp;= (y_a-y_c)x+(x_c-x_a)y+x_ay_c-x_cy_a
\end{align*}
\]</div><p></p><p>此外我们还能找到一个不用遍历图像上所有像素的优化方法,那就是为三角形找到一个最小包围矩形,这样就只需遍历包围矩形中的像素。因此可以得到如下的伪代码<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250803040432805-484530798.png"></p>
<p>其中<span class="math inline">\(f_{ij}\)</span>为</p>
<p></p><div class="math display">\[\begin{align*}
f_{01}(x,y)&amp;=(y_0-y_1)x+(x_1-x_0)y+x_0y_1-x_1y_0 \\
f_{12}(x,y)&amp;=(y_1-y_2)x+(x_2-x_1)y+x_1y_2-x_2y_1 \\
f_{20}(x,y)&amp;=(y_2-y_0)x+(x_0-x_2)y+x_2y_0-x_0y_2
\end{align*}
\]</div><p></p><p>一个绘制有着插值颜色的三角形的例子如下<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250804014522840-245565357.png"></p>
<h3 id="处理正好在边上的像素dealing-with-pixels-on-triangle-edges">处理正好在边上的像素(Dealing with Pixels on Triangle Edges)</h3>
<p>  一旦像素中心正好在边上,就没有明显的方法来解决这一情况。最坏的决定是不绘制这种像素,这会导致相接的三角形绘制出来可能有孔洞。一个更好的处理是不管这个情况让两个三角形都绘制一遍,但是在共享边上的像素会取决于三角形的绘制顺序。其实最好选择其中一个三角形的顶点的属性进行插值然后绘制像素,而这个选择的过程一定要简单。<br>
  一个方法是先挑选任意一离屏的点,因为离屏的点总是在共享边的某一侧,选择三角形可以看与共享边相对的点是否和离屏点在共享边同侧,下图是一张示例图<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250805004044000-278435452.png"></p>
<p>点<span class="math inline">\(\mathbf{a}\)</span>与离屏点在共享边同侧,因此可以取点<span class="math inline">\(\mathbf{a}\)</span>所属的三角形的顶点的属性进行插值,然后绘制共享边上的像素。接下来可以推广到更加一般的情况,三角形的三条边可能都有像素正好在上面。我们就看与每一条边相对的点是否和离屏点在每条一边同侧,如果同侧就绘制正好在这条边上的像素否则不绘制,这样就简单地解决了这一问题。修改后的代码如下图所示<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250805015812677-773136914.png"></p>
<p>要注意的是对于每个三角形来说,两个共享的顶点在绘制调用中必须有相同的顺序,否则直线方程会翻转符号,因此需要有健壮的实现并且需要检查编译器的细节和算数单元。除此之外处理像素中心正好在边上的代码应该被谨慎地编写。</p>
<h2 id="透视修正的插值perspective-correct-interpolation">透视修正的插值(Perspective Correct Interpolation)</h2>
<p>  把位于三维空间中的三角形投影到二维图像上进行绘制时,继续使用上个部分的插值代码会出现问题。经过前面几个章节的学习你可能猜出来了是透视中近大远小的原因,上个部分的插值代码并不能正确地反映这一特点。下图是正确插值和不正确插值的示例<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250805031550347-1749800829.png"></p>
<p>  通过前面几章的学习你可能已经想到了一个方法,可以首先通过逆变换把屏幕空间的坐标变换到规范视图体内的归一化坐标,有了归一化坐标接着再通过逆变换把归一化坐标变换到世界坐标,有了逆变换求出的世界坐标再利用三个顶点的世界坐标就能求出真正的重心坐标。不过这个方法稍微有点复杂,实际上有更加便捷的方法。我们首先以三维直线绘制的透视修正为例,后续可直接推广到三维三角形绘制的透视修正。<br>
  前一个章节有个部分证明了世界空间的直线被变换到归一化坐标空间后依然是直线,而这个部分有三个非常重要的公式</p>
<p></p><div class="math display">\[\begin{align}
\mathbf{Q}^\prime &amp;= \mathbf{q}+t(\mathbf{Q-q}) \\
\mathbf{S}^\prime &amp;= \frac{\mathbf{MQ}^\prime}{w^\prime} = \frac{\mathbf{R}^\prime}{w^\prime} = \frac{\mathbf{r}+t(\mathbf{R-r})}{w_r+t(w_R-w_r)} = \frac{\mathbf{r}}{w_r}+\alpha(t)(\frac{\mathbf{R}}{w_R}-\frac{\mathbf{r}}{w_r}) \\
\alpha(t) &amp;= \frac{w_Rt}{w_r+t(w_R-w_r)}
\end{align}
\]</div><p></p><p>稍加修改后这三个式子可重写成</p>
<p></p><div class="math display">\[\begin{align}
\mathbf{Q}^\prime &amp;= \mathbf{q} + t(\alpha)(\mathbf{Q-q}) \\
\mathbf{S}^\prime &amp;= \frac{\mathbf{MQ}^\prime}{w^\prime} = \frac{\mathbf{R}^\prime}{w^\prime} = \frac{\mathbf{r}+t(\alpha)(\mathbf{R-r})}{w_r+t(\alpha)(w_R-w_r)} = \frac{\mathbf{r}}{w_r}+\alpha(\frac{\mathbf{R}}{w_R}-\frac{\mathbf{r}}{w_r}) \\
t(\alpha) &amp;= \frac{w_r\alpha}{w_R+\alpha(w_r-w_R)}
\end{align}
\]</div><p></p><p>因为从屏幕空间内的坐标逆变换到规范视图体内的归一化坐标只是一次线性变换,所以归一化坐标可以在屏幕空间内进行线性插值。因此,上三式告诉了我们在屏幕空间内进行线性插值的归一化坐标<span class="math inline">\(\mathbf{R}^\prime/w^\prime\)</span>,与它被逆变换到世界空间的坐标<span class="math inline">\(\mathbf{Q}^\prime\)</span>的关系。这个时候你可能会想,如果我们能求出<span class="math inline">\(t(\alpha)\)</span>,那么就能轻易求出顶点的任意属性插值后的结果。但是这样不太好,因为对于三维三角形顶点的插值来说,<span class="math inline">\(t(\alpha)\)</span>的公式会很复杂,因此得找个更加简单且便捷的计算方法。<br>
  截取式<span class="math inline">\((5)\)</span>的最后两个部分有</p>
<p></p><div class="math display">\[\frac{\mathbf{r}+t(\alpha)(\mathbf{R-r})}{w_r+t(\alpha)(w_R-w_r)} = \frac{\mathbf{r}}{w_r}+\alpha(\frac{\mathbf{R}}{w_R}-\frac{\mathbf{r}}{w_r}) \tag{7}
\]</div><p></p><p>稍微观察下式<span class="math inline">\((7)\)</span>,发现如左侧式子这种形式都可以被改写成右侧式子的形式,比如我们可以把<span class="math inline">\(\mathbf{r}\)</span>和<span class="math inline">\(\mathbf{R}\)</span>替换成顶点携带的世界空间坐标<span class="math inline">\(\mathbf{q}\)</span>、<span class="math inline">\(\mathbf{Q}\)</span>从而得到</p>
<p></p><div class="math display">\[\frac{\mathbf{Q}^\prime}{w^\prime} = \frac{\mathbf{q}+t(\alpha)(\mathbf{Q-q})}{w_r+t(\alpha)(w_R-w_r)} = \frac{\mathbf{q}}{w_r}+\alpha(\frac{\mathbf{Q}}{w_R}-\frac{\mathbf{q}}{w_r}) \tag{8}
\]</div><p></p><p>因此利用式<span class="math inline">\((7)\)</span>的性质还有之前提到的归一化坐标可以在屏幕空间内进行线性插值的结论,可以得出一个非常重要的结论:除了归一化坐标可以在屏幕空间内进行线性插值外,顶点的任意属性只要除以齐次坐标<span class="math inline">\(w\)</span>,就能在屏幕空间内进行线性插值,比如式<span class="math inline">\((8)\)</span>提到的<span class="math inline">\(\mathbf{Q}^\prime/w^\prime\)</span>。除了把<span class="math inline">\(\mathbf{r}\)</span>和<span class="math inline">\(\mathbf{R}\)</span>替换成世界坐标得到式<span class="math inline">\((8)\)</span>外,还可以把式<span class="math inline">\((7)\)</span>的<span class="math inline">\(\mathbf{r}\)</span>和<span class="math inline">\(\mathbf{R}\)</span>替换成实数<span class="math inline">\(1\)</span>,从而得到<span class="math inline">\(1/w^\prime\)</span>为</p>
<p></p><div class="math display">\[\frac{1}{w^\prime} = \frac{1}{w_r+t(\alpha)(w_R-w_r)} = \frac{1}{w_r}+\alpha(\frac{1}{w_R}-\frac{1}{w_r})
\]</div><p></p><p>有了<span class="math inline">\(1/w^\prime\)</span>后,我们就能直接求出顶点的任意属性正确插值后的结果,比如我们能求出<span class="math inline">\(\mathbf{Q}^\prime\)</span>为</p>
<p></p><div class="math display">\[\mathbf{Q}^\prime = \mathbf{q} + t(\alpha)(\mathbf{Q-q}) = \frac{\frac{\mathbf{Q}^\prime}{w^\prime}}{\frac{1}{w^\prime}} =\frac{\frac{\mathbf{q}}{w_r}+\alpha(\frac{\mathbf{Q}}{w_R}-\frac{\mathbf{q}}{w_r})}{\frac{1}{w_r}+\alpha(\frac{1}{w_R}-\frac{1}{w_r})}
\]</div><p></p><p>假设一个三维三角形的三个顶点有世界坐标<span class="math inline">\(\mathbf{Q}_0\)</span>、<span class="math inline">\(\mathbf{Q}_1\)</span>、<span class="math inline">\(\mathbf{Q}_2\)</span>,变换到4D空间后的齐次坐标分别为<span class="math inline">\(w_0\)</span>、<span class="math inline">\(w_1\)</span>、<span class="math inline">\(w_2\)</span>,在屏幕空间内进行线性插值求得的重心坐标为<span class="math inline">\((\alpha,\beta,\gamma)\)</span>。我们可以得到世界坐标<span class="math inline">\(\mathbf{Q}\)</span>的正确插值方法为</p>
<p></p><div class="math display">\[\begin{align*}
\frac{\mathbf{Q}^{\prime}}{w^\prime} &amp;= \alpha\frac{\mathbf{Q}_0}{w_0}+\beta\frac{\mathbf{Q}_1}{w_1}+\gamma\frac{\mathbf{Q}_2}{w_2} \\
\frac{1}{w^\prime} &amp;= \alpha\frac{1}{w_0}+\beta\frac{1}{w_1}+\gamma\frac{1}{w_2} \\
\mathbf{Q}^\prime &amp;= \frac{\mathbf{Q}^{\prime}}{w^\prime} / \frac{1}{w^\prime} \end{align*}
\]</div><p></p><p>那么对于顶点的任意属性<span class="math inline">\(\mathbf{A}\)</span>可得到</p>
<p></p><div class="math display">\[\begin{align*}
\frac{\mathbf{A}^{\prime}}{w^\prime} &amp;= \alpha\frac{\mathbf{A}_0}{w_0}+\beta\frac{\mathbf{A}_1}{w_1}+\gamma\frac{\mathbf{A}_2}{w_2} \\
\frac{1}{w^\prime} &amp;= \alpha\frac{1}{w_0}+\beta\frac{1}{w_1}+\gamma\frac{1}{w_2} \\
\mathbf{A}^\prime &amp;= \frac{\mathbf{A}^{\prime}}{w^\prime} / \frac{1}{w^\prime} \end{align*}
\]</div><p></p><p>如果我们想要插值纹理坐标<span class="math inline">\((u,v)\)</span>并利用纹理坐标绘制三维三角形时可直接这么做<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250806184034856-433689545.png"></p>
<h2 id="裁剪clipping">裁剪(Clipping)</h2>
<p>  仅仅把图元变换到屏幕空间接着再光栅化它们并不是总是有效的,这是因为图元有可能在可视范围外。比如在“眼睛”后的图元如果被光栅化会导致错误,上一章提到我们使用的<span class="math inline">\(z\)</span>坐标变换公式为</p>
<p></p><div class="math display">\[z^\prime = \frac{f+n}{f-n}-\frac{2fn}{f-n} \frac{1}{z}
\]</div><p></p><p>当<span class="math inline">\(z\)</span>大于<span class="math inline">\(0\)</span>也就是在“眼睛”后面时<span class="math inline">\(z^\prime\)</span>会大于<span class="math inline">\(1\)</span>,在“眼睛”后面的点会被变换到前面的一个完全不合理的位置,这就是问题所在。因此,在进行光栅化之前必须执行一个叫<strong>裁剪</strong>(<strong>Clipping</strong>)的操作,这个操作用于移除图元超出视野范围的那一部分。<br>
  事实上裁剪在图形学中是很常见的操作,会在一个几何体“切开”另外一个几何体时用到。比如让<span class="math inline">\(x=0\)</span>平面裁剪一个三角形,如果这个三角形的顶点的<span class="math inline">\(x\)</span>坐标符号相反,那么这个平面会把这个三角形剪成两个部分。在裁剪的大部分应用中被认为是错误的那一部分都会被抛弃,对于一个平面来说的裁剪操作如下图所示<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250809022036493-1060709774.png"><br>
  在为光栅化做准备的裁剪操作中,被认为错误的是在可视体之外的那部分。那么裁剪一般要用到可视体的六个面,不过有许多系统只会用近平面进行裁剪。这个部分会讨论基础的裁剪模块的实现,两个常用的方法是</p>
<ol>
<li>
<p>在世界坐标空间中用六个平面包围视锥台进行裁剪</p>
</li>
<li>
<p>在齐次除法之前的4D空间中进行裁剪</p>
</li>
</ol>
<p>任意一个方法都能被有效率地实现,只要为每个三角形的裁剪跟随下列步骤<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250809033953278-958950071.png"></p>
<h3 id="在变换前裁剪clipping-before-the-transform">在变换前裁剪(Clipping Before the Transform)</h3>
<p>  这个方法看起来很直接,不过唯一的问题是如何得到6个平面。其实答案很简单,可以通过逆变换把规范视图体的8个角点变换到世界坐标空间。当然了还有一种方式也可以,那就是通过观察参数例如近平面、观察位置以及FOV等数据求出6个平面。</p>
<h3 id="在四维空间中进行裁剪clipping-in-homogeneous-coordinate">在四维空间中进行裁剪(Clipping in Homogeneous Coordinate)</h3>
<p>  出人意料的是,这个方法是通常被实现的。在四维空间中可视体是四维的,并且会被三维体(超平面)包围,分别为</p>
<p></p><div class="math display">\[\begin{align*}
-x+lw&amp;=0 \\
x-rw&amp;=0 \\
-y+bw&amp;=0 \\
y-tw&amp;=0 \\
-z+nw&amp;=0 \\
z-fw&amp;=0
\end{align*}
\]</div><p></p><p>这样就能发现实际上比前一个方法一效率高。</p>
<h3 id="让平面裁剪clipping-against-a-plane">让平面裁剪(Clipping against a Plane)</h3>
<p>  不论用哪种方法求出包围平面,最后都得用求出的平面裁剪几何体。第二章提到过点<span class="math inline">\(\mathbf{q}\)</span>且法线为<span class="math inline">\(\mathbf{n}\)</span>的平面的隐式方程为</p>
<p></p><div class="math display">\[f(\mathbf{p})=\mathbf{n} \cdot (\mathbf{p-q}) = 0
\]</div><p></p><p>这个方程通常也写作</p>
<p></p><div class="math display">\[f(\mathbf{p})=\mathbf{n} \cdot \mathbf{p} + D = 0
\]</div><p></p><p>对于有着<span class="math inline">\(\mathbf{a}\)</span>和<span class="math inline">\(\mathbf{b}\)</span>两个端点的线段来说,我们先判断<span class="math inline">\(f(\mathbf{a})\)</span>和<span class="math inline">\(f(\mathbf{b})\)</span>是否异号。如果异号就说明平面与线段相交,那么就得找到交点<span class="math inline">\(\mathbf{p}\)</span>。因为点<span class="math inline">\(\mathbf{p}\)</span>在线段上,首先可以给出一条参数直线</p>
<p></p><div class="math display">\[\mathbf{p}=\mathbf{a}+t(\mathbf{b-a})
\]</div><p></p><p>求交点就令<span class="math inline">\(f(\mathbf{p})=0\)</span>,因此得到</p>
<p></p><div class="math display">\[\mathbf{n} \cdot (\mathbf{a} + t(\mathbf{b-a})) + D = 0
\]</div><p></p><p>解得<span class="math inline">\(t\)</span>为</p>
<p></p><div class="math display">\[t = \frac{\mathbf{n} \cdot \mathbf{a} + D}{\mathbf{n} \cdot (\mathbf{a-b})}
\]</div><p></p><p>对于裁剪三角形来说,得在第十二章了解完图形学中的数据结构的时候才会进行了解。</p>
<h1 id="光栅化之前和之后的操作operations-before-and-after-rasterization">光栅化之前和之后的操作(Operations Before and After Rasterization)</h1>
<p>  在光栅化之前,顶点必须处于屏幕空间中,而且在图元上被插值的属性得是已知的。准备这些数据是图形管线中<strong>顶点处理阶段</strong>的任务。在这个阶段中,输入的顶点会进行模型、视图、投影变换,从它们位于的原始坐标空间中被映射到屏幕空间。在同一时间如果有需要的话,其它信息例如颜色、表面法线等可能也会被变换,下面将会讨论这些额外的属性。<br>
  在光栅化后,后续的处理是为每个片段计算颜色还有深度。这些处理可以简单到仅仅传递由光栅器计算的颜色和深度值,或者可以包含复杂的着色操作。最后的混合阶段会结合对应着相同像素的片段,计算出最终的颜色。最通常的混合操作是选择深度值最小(最接近眼睛)的片段。下面将用一些例子讲解这些不同的阶段。</p>
<h2 id="简单的二维绘制simple-2d-drawing">简单的二维绘制(Simple 2D Drawing)</h2>
<p>  最简单的管线在顶点或片段阶段以及混合阶段时会什么也不做,每个片段的颜色会直接覆盖原来的颜色。应用程序会用屏幕坐标来提供图元,而光栅器会包揽所有工作。这种基础的安排是许多简单的老的用于绘制用户界面和图表以及其它二维内容的API的精髓。</p>
<h2 id="一个极简的三维管线a-minimal-3d-pipeline">一个极简的三维管线(A Minimal 3D Pipeline)</h2>
<p>  为了绘制三维中的物体,需要对二维绘制管线做的修改就是矩阵变换。在顶点处理阶段把输入的顶点的位置变换到屏幕空间,从而得到屏幕空间中的三角形,这样可以使用用于二维图形绘制的方法。<br>
  一个极简的三维管线会遇到的问题是获得正确的遮蔽关系,近处的物体应该显示在远处的物体前方。一个简单的方法是从后往前绘制图元,这种通常被称为用于移除隐藏表面的<strong>画家算法</strong>(<strong>Painter's Algorithm</strong>),和绘画师一样从背景一路往前绘制。不过这个方法有一些缺点,比如三角形相交的时候还有三角形之间形成遮蔽循环关系的时候就不能得出绘制顺序,下图是一个符合上述情况示例图<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250810022102735-494041092.png"></p>
<p>除上面的缺点之外就算理论上可以找到绘制顺序,使用深度值来排序图元实际上是比较慢的,特别是对于大场景来说,这样会拖慢原本非常快的渲染速度。</p>
<h2 id="为隐藏表面使用一个z缓冲using-a-z-buffer-for-hidden-surfaces">为隐藏表面使用一个z缓冲(Using a z-Buffer for Hidden Surfaces)</h2>
<p>  在实践中画家算法很少被使用,反而使用的是另一个简单的有效率的隐藏表面移除算法,这个算法被称为<strong>z缓冲</strong>(<strong>z-Buffer</strong>)算法。方法很简单,只需要为每个像素追踪迄今最近的片段的距离值,那些代表着更远表面的片段自然地就会被抛弃。要这么做得为每个像素分配一个额外的值用来存储迄今为止最近的距离,而这个值则被称为深度或<span class="math inline">\(z\)</span>值。<strong>深度缓冲</strong>或z缓冲是深度值网格的名称。<br>
  z缓冲算法会在片段混合阶段被实现,通过比较片段的深度值和z缓冲当前存储的值。如果片段的深度更近,那么这个片段的颜色和深度值会分别覆盖颜色缓冲和深度缓冲中对应的值,反之这个片段会被抛弃。为了确保第一个片段总是能通过深度测试,深度缓冲一般都会用最大深度值来初始化,不管绘制顺序是怎么样的,最终都会是相同的片段通过深度测试,从而保证渲染出的图像一致。<br>
  z缓冲算法需要每个片段携带一个深度值,可以通过简单地插值顶点的归一化坐标的<span class="math inline">\(z\)</span>坐标来实现,就像其它属性被插值一样。<br>
  z缓冲是个简单以及实际的方法用于解决基于物体顺序渲染中的隐藏表面问题,而且是目前处于主导地位的方法。它比那些把表面分成更小的片段再进行深度排序的几何方法要简单得多,因为它解决了任何不需要被解决的问题。它不仅被普遍的硬件图形管线支持,而且在软件图形管线中也是被使用最多的。下图展示了使用z缓冲的效果。<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250811023304326-925826216.png"></p>
<h3 id="精度问题">精度问题</h3>
<p>  在实践中,在z缓冲中存储的<span class="math inline">\(z\)</span>值一般都是非负的整数,且范围一般都在<span class="math inline">\(\)</span>。选择整数而不是浮点数的原因是因为z缓冲所需要的快速内存有点昂贵,而且需要被保持在最低限度。<br>
  不过使用整数会导致一些精度问题,如果我们使用有<span class="math inline">\(B\)</span>个值的整数范围<span class="math inline">\(\{0,1,...,B-1\}\)</span>,就能把这里面的整数一一映射到<span class="math inline">\(\{0,1/B-1,...,1\}\)</span>。下面的讨论假设<span class="math inline">\(z\)</span>、<span class="math inline">\(n\)</span>、<span class="math inline">\(f\)</span>都为正,不过都为负也能有相同的结果,只是都为正讨论起来更简单。对于每个输入的<span class="math inline">\(z\)</span>值来说,都会先把<span class="math inline">\(z\)</span>值舍入到最近的整数深度<span class="math inline">\(\mathrm{round}(z \cdot (B-1))\)</span>,如果小于当前存储的整数深度就直接替换原来存储的值,反之则什么都不做。因此得分配足够的比特位让有距离的任意两个三角形的深度舍入到不同的整数深度值,从而渲染出正确的图像。<br>
  打个比方来说,假设使用正交投影渲染一个场景,里面的三角形都隔着至少<span class="math inline">\(1\)</span>米。由于使用的是正交投影,我们可以得到相机空间中的距离间隔<span class="math inline">\(\Delta z_\mathrm{cam}=(f-n)/B\)</span>,为了不产生错误那么就得让<span class="math inline">\(\Delta z_\mathrm{cam}\)</span>越小越好,因为间隔为<span class="math inline">\(1\)</span>米因此可知<span class="math inline">\(\Delta z_\mathrm{cam}&lt;1\)</span>。有两个方法可以让<span class="math inline">\(\Delta z_\mathrm{cam}\)</span>更小,首先可以让近平面和远平面的距离变短,然后还可以分配更多的比特位来存储<span class="math inline">\(z\)</span>值。<br>
  当渲染透视图象时z缓冲的精度应该被更加小心地处理,因为这里的<span class="math inline">\(\Delta z_\mathrm{cam}\)</span>和透视除法相关。回想上一个章节再加上之前做的假设可以得到相机空间中的z坐标到归一化空间中的z坐标的变换公式为</p>
<p></p><div class="math display">\[z_\mathrm{norm} = \frac{f+n}{f-n}-\frac{2fn}{f-n} \frac{1}{z_\mathrm{cam}}
\]</div><p></p><p>由上式可以得到</p>
<p></p><div class="math display">\[\frac{\Delta z_\mathrm{norm}}{\Delta z_\mathrm{cam}} \approx \frac{2fn}{(f-n)z^2_\mathrm{cam}}
\]</div><p></p><p>首先可以得到<span class="math inline">\(\Delta z_\mathrm{norm}\)</span>为</p>
<p></p><div class="math display">\[\Delta z_\mathrm{norm} \approx \frac{2fn\Delta z_\mathrm{cam}}{(f-n)z^2_\mathrm{cam}}
\]</div><p></p><p>当<span class="math inline">\(n=0\)</span>时<span class="math inline">\(\Delta z_\mathrm{norm}\)</span>为极小的一个值,这就导致要用近乎无限的比特位来存储深度值,这也是最坏的一个情况。此外由上式还能得到<span class="math inline">\(\Delta z_\mathrm{cam}\)</span>为</p>
<p></p><div class="math display">\[\Delta z_\mathrm{cam} \approx \frac{(f-n)z^2_\mathrm{cam} \Delta z_\mathrm{norm}}{2fn}
\]</div><p></p><p>这下还看不出什么,不过由上式可知当<span class="math inline">\(z_\mathrm{cam}=f\)</span>时<span class="math inline">\(\Delta z_\mathrm{cam}\)</span>有最大值</p>
<p></p><div class="math display">\[\Delta z^\mathrm{max}_\mathrm{cam} \approx \frac{(f-n)f \Delta z}{2n}
\]</div><p></p><p>和正交投影类似,对于透视投影来说应该让<span class="math inline">\(\Delta z^\mathrm{max}_\mathrm{cam}\)</span>越小越好,这样渲染就不容易出错。稍微观察上式可得出应该最小化<span class="math inline">\(f\)</span>最大化<span class="math inline">\(n\)</span>。总之,为了保证精度应该总是小心地选择<span class="math inline">\(n\)</span>和<span class="math inline">\(f\)</span>的值。</p>
<h2 id="逐顶点着色per-vertex-shading">逐顶点着色(Per-vertex Shading)</h2>
<p>  目前,把三角形送入管线的应用程序会提前设置好颜色,光栅器只需插值颜色接着直接写入图像。对于某些应用场合来说是足够的,不过在很多情况下我们也许想为三维物体的表面着色,比如使用第四章提到的光照公式进行着色。<br>
  第一种方法是在顶点阶段的时候进行着色计算,应用会为顶点提供法线和位置以及其它的光照计算需要让顶点携带的值,光源的颜色和位置则会被分开提供。对于每个顶点我们就能利用它的属性以及光源属性还有摄像机的位置进行光照计算,计算出来颜色后就可以让光栅器进行插值,最后对图像写入插值后的颜色。这种逐顶点着色有时也被称为<strong>高洛德着色</strong>(<strong>Gouraud Shading</strong>)。<br>
  关于着色计算一个可以做的决定是着色在哪个坐标系统中进行,回顾上一个章节的内容就知道一般有世界空间和相机空间可选。在相机空间中进行着色的优势是不需要追踪相机的位置和朝向,因为在相机空间中相机永远处于原点位置并且朝向固定。<br>
  逐顶点着色的劣势是它不能在着色中产生比用于绘制表面的图元还要小的细节,因为它只为顶点计算颜色而不是在顶点之间。比如在房间中的地板是由两个大三角构成的,它会被房间中的灯点亮。如果使用逐顶点着色,这个时候就会观察到明显的视觉瑕疵。此外弯曲的表面如果需要绘制镜面高光,那么绘制时就必须用到足够小的图元来产生正确的高光。<br>
  下图展示了使用逐顶点着色绘制的两个球体<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250814224720400-1450185578.png"></p>
<h2 id="逐片段着色per-fragment-shading">逐片段着色(Per-fragment Shading)</h2>
<p>  为了避免逐顶点着色带来的插值缺陷,可以在插值后的片段阶段进行着色。在逐片段着色中使用的是和逐顶点着色一样的着色公式,不过着色公式需要的值都是从顶点插值后得到的。<br>
  在逐片段着色中着色需要的几何信息都是作为属性被传递给光栅器,因此顶点阶段必须和片段阶段协调配合来恰当地准备数据。如果选择在相机空间中进行着色计算,这个时候就可以让光栅器插值处于相机空间的法线和顶点位置。<br>
  下图展示了使用逐片段着色绘制的两个球体<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250814231943401-1069842644.png"></p>
<h2 id="纹理映射texture-mapping">纹理映射(Texture Mapping)</h2>
<p>  <strong>纹理</strong>(<strong>Texture</strong>)是图像,它被用于给着色的表面增加额外的细节。背后的想法很简单,在每次着色时读取纹理的值用于着色计算。比如漫反射颜色,我们就能从纹理中读取而不是直接用顶点携带的属性。这个读取的操作则被称为<strong>纹理查找</strong>(<strong>Texture Lookup</strong>),通过着色代码声明的<strong>纹理坐标</strong>(<strong>Texture Coordinate</strong>)也就是纹理域中的一个点,纹理映射系统会找到位于那个点的值然后返回它,纹理存储的值就是这样被用于着色计算的。<br>
  最通常的用于定义纹理坐标的方法就是让纹理坐标作为顶点的属性,这样每个图元就知道它在纹理的哪个位置。</p>
<h2 id="着色频率shading-frequency">着色频率(Shading Frequency)</h2>
<p>  在哪里进行着色计算取决于颜色有多快改变,即被计算的细节的<strong>尺度</strong>。着色大尺度的细节例如在曲面的漫反射着色可以被不那么频繁地计算然后进行插值,也就是采用低着色频率进行计算。那些产生小尺度细节的着色比如锐利的高光或者细致的纹理,得采用高着色频率进行计算。<br>
  因此大尺度的细节可以被安全地在顶点阶段进行计算,甚至当定义图元的顶点相隔了许多像素都行。那些需要高着色频率的效果也能在顶点阶段被计算,只要顶点在图像中离得够近。<br>
  被用于计算机游戏的硬件管线通常会使用占据了一些像素的图元来确保高效率,绝大多数着色计算通常都是对每个片段进行的。在另一方面,PhotoRealistic RenderMan系统所有的着色都是在每个顶点上计算的,在第一次细分或切割后,所有表面都会变成一些只有像素大小的被称作<strong>微多边形</strong>(<strong>Micropolygon</strong>)的小四边形。在这种情况下图元会变得非常小,因此逐顶点着色在这个系统中能达到一个适合精细着色的高着色频率。</p>
<h1 id="简单的抗走样simple-antialiasing">简单的抗走样(Simple Antialiasing)</h1>
<p>  如果我们仅通过判断像素是否在图元内来绘制图元,将会产生锯齿状线和三角形边缘。事实上在这个章节中描述的能生成一系列像素的简单的三角形光栅化算法有时候被称为标准或<strong>走样</strong>(<strong>Aliased</strong>)的光栅化。<br>
  有一些不同的方法可以在光栅化应用中进行抗走样,比如有个方法是对于每个像素以像素中心为原点计算一定范围内的平均颜色,接着把结果写入到一张新的图像中,这样我们就能得到一张抗走样的图像,这个方法被称为方框滤波。不过使用这个方法意味着所有可绘制的实体必须要有明确定义的区域,例如下方的图可以被认为是接近一像素宽的矩形。<br>
<img src="https://img2024.cnblogs.com/blog/2774734/202508/2774734-20250815034607626-77844417.png"></p>
<p>  最简单的实现方框滤波的方法是通过<strong>超采样</strong>(<strong>Supersampling</strong>),即先渲染一张非常高分辨率的图像接着再降采样。比如我们的最终目标是一张有着1.2像素宽直线的256x256图像,我们可以先渲染一张有着4.8像素宽直线的1024x1024图像,接着把图像划分到4x4的像素组并求每组像素的平均颜色,最后把结果写入到256x256的图像。这其实是对真正的线框滤波图像的一种接近,当物体没有极端地小到比像素还小的时候,这个方法效果还是不错的。<br>
  因为是升分辨率渲染,超采样操作的开销实际上是非常昂贵的。由于导致走样的非常尖锐的边缘通常在图元边缘出现,因此有一个被广泛使用的优化方法,通过比着色更高频率地采样可见性来实现。如果在每个像素中的一些点上存储了覆盖值和深度值,那么尽管在只有一个颜色被计算的情况下也能能实现非常好的抗走样。在像RenderMan一样的使用逐顶点着色的系统中,是通过在高分辨率下进行光栅化实现的。因为开销实际上不是特别昂贵,着色仅仅是为片段插值生成的颜色或能见度样本。在硬件管线这种逐片段着色系统中,<strong>多重采样抗锯齿</strong>(<strong>Multisample Antialiasing</strong>)是通过为每个片段存储一个单独的颜色再加上一个覆盖值蒙板以及一系列深度值做到的。</p>
<h1 id="为了效率剔除图元culling-primitives-for-efficiency">为了效率剔除图元(Culling Primitives for Efficiency)</h1>
<p>  基于物体顺序的渲染的需要在一次Pass中渲染场景中的所有几何体,这也是对于复杂场景来说的弱点。比如在一个完整的城市模型中,对于一个给定的视点来说,只有少量建筑物是可见的。我们可以调用drawcall一次性绘制所有的物体,但是这会导致大部分在处理几何体上所作的工作都会被浪费掉,因为有很多几何体会在可视建筑的后面或者在观察者后面,因此这些几何体实际上对最后的图像没有任何贡献。<br>
  识别以及丢弃不可见的几何体来节约花费在处理上的时间的操作就叫<strong>剔除</strong>(<strong>Culling</strong>)。有三个通常被实现的剔除策略为</p>
<ul>
<li><strong>视图体剔除</strong>(<strong>View Volume Culling</strong>):移除在视图体之外的几何体。</li>
<li><strong>遮蔽剔除</strong>(<strong>Occlusion Culling</strong>):移除那些可能在视图体内但是被更近的几何体遮挡或遮蔽的几何体。</li>
<li><strong>背面剔除</strong>(<strong>Backface Culling</strong>):移除那些相对于相机朝外的图元。</li>
</ul>
<p>接下来简要地讨论下视图体剔除和背面剔除,在高性能系统中的剔除是个复杂的话题。</p>
<h2 id="视图体剔除view-volume-culling">视图体剔除(View Volume Culling)</h2>
<p>  当整个图元都在视图体外时就能被剔除,因为这种图元被光栅化后产生不了任何片段。如果我们能通过一次快速的测试来确定是否要剔除大量的图元,那么就能显著地加快绘制速度。在另一方面,为每个图元单独地判断是否在视图体内的开销可能比让光栅器来剔除的开销要大。<br>
  视图体剔除也被称为<strong>视锥台剔除</strong>(<strong>View Frustum Culling</strong>),当许多三角形组成了一个物体时,这个时候就能得到一个与物体相关联的包围体。当包围体完全在视图体外时,那么组成这个物体的三角形都会在视图体外,这个时候就能一次性剔除大量图元。如果我们有1000个三角形被一个中心在<span class="math inline">\(\mathbf{c}\)</span>点半径为<span class="math inline">\(r\)</span>的球包围时,我们可以检查球是否在裁剪平面外,通过下面的公式就可以做到</p>
<p></p><div class="math display">\[\frac{(\mathbf{c-a}) \cdot \mathbf{n}}{||\mathbf{n}||} &gt; r
\]</div><p></p><p>这个公式实际上就是检查圆心<span class="math inline">\(\mathbf{c}\)</span>到平面的距离是否要大于半径<span class="math inline">\(r\)</span>。要注意的是当球与平面重叠时,所有三角形可能还是会在平面外,因此这实际上是一次保守的测试,有多么保守取决于球包围物体包围的有多好。</p>
<h2 id="背面剔除backface-culling">背面剔除(Backface Culling)</h2>
<p>  当多边形模型是封闭的时候,也就是包围一个封闭空间的时候,组成模型的多边形的朝向一般都被认为是朝外的。绘制这种模型时,那些相对于相机朝外的多边形都会被相对于相机朝内的多边形覆盖,因此可以在一开始就剔除这种朝外的图元。</p>


</div>
<div id="MySignature" role="contentinfo">
    <p>本文来自博客园,作者:TiredInkRaven,转载请注明原文链接:https://www.cnblogs.com/TiredInkRaven/p/18990611</p><br><br>
来源:https://www.cnblogs.com/TiredInkRaven/p/18990611
頁: [1]
查看完整版本: 《Fundamentals of Computer Graphics》第九章 图形管线