拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

软渲染光栅化核心算法:重心坐标、Edge Function与透视校正

软渲染光栅化核心算法:重心坐标、Edge Function与透视校正

我当年第一次写软光栅化器的时候,以为最难的部分在数学——顶点变换、矩阵乘法、透视投影,样样都能把人绕晕。可真正把代码跑起来才发现,最折腾人的永远是那一步:屏幕上几百万个像素,到底哪些落在三角形内?边界上的像素算不算?颜色和深度又该怎么插值才不会出错?

这一小步就是光栅化。翻到这里的朋友,多半是正在写软件渲染器,或者被图形学作业追着跑。光栅化是整个图形管线从几何阶段迈向像素阶段的分水岭,也是软渲染里公认的瓶颈所在。这篇笔记,是我把光栅化相关的公式从头推了一遍之后整理的完整记录,覆盖NDC到屏幕的映射、像素中心对齐、重心坐标、Edge Function、透视正确深度插值,以及我踩过的那些坑。能让你少走弯路。

1. 光栅化到底在解决什么问题

1.1 从顶点到像素的完整链路

讲渲染管线,经常有人把"光栅化"和"画三角形"混为一谈。严格说起来,光栅化干的事情是:把已经完成顶点变换、位于屏幕连续坐标系里的几何图元(通常是三角形),离散成屏幕上的像素片元集合,并为后续的深度测试、颜色写入准备好数据。

完整链路大概是这样的:

  1. 物体坐标中的顶点经过模型、视图、投影矩阵变换,得到裁剪坐标。
  2. 在裁剪阶段做视锥剔除,保留落在可视范围内的几何。
  3. 做透视除法,顶点进入NDC(标准设备坐标)。
  4. 视口变换,把NDC坐标映射到屏幕连续坐标。
  5. 光栅化器拿到屏幕坐标下的三个顶点,确定覆盖了哪些像素。
  6. 对每个被覆盖的像素计算重心坐标,并以此插值出深度、颜色、纹理坐标等属性。
  7. 写入帧缓冲(通常还要经过深度测试和混合)。

第5和第6步就是光栅化的全部精髓。本质上,这是一个"连续到离散"的转化问题。连续世界里三角形有无数个点,屏幕上却只有有限个像素,光栅化器的职责就是做出一套精确、高效且跟硬件行为一致的取舍。

1.2 为什么需要一套像素级公式

很多刚接触图形学的同学会想:判断一个点在不在三角形内,初中数学就会啊,直接解三边方程不就行了?有点道理,但如果真去实现,马上会碰到三个问题。

第一是效率。一个高清屏有约200万个像素,一帧场景可能有上万个三角形,最坏情况下就是上十亿次点与三角形的判定。任何多余的乘除法都可能在性能上放大几十倍。

第二是精度。浮点数在连续坐标里足够精确,但屏幕像素是整数网格。顶点坐标经过多次矩阵变换后,一两个ulp的误差就可能让三角形边缘出现裂缝或重叠。

第三是"约定"。光栅化不只是判断"在不在内",还要决定边界上的像素归谁。两个相邻三角形共享一条边,如果边界规则不一致,这条边上的像素就会被谁都不画,形成一条恼人的细线。

所以光栅化需要一套完备、自洽、可增量优化的公式系统,而不是临场手算。这篇后面的内容,就是把这套公式一个一个拆开给你看。

2. NDC到屏幕坐标的映射,不能只乘个半宽就算完

2.1 标准设备坐标的烦恼

大多数API在透视除法后给出的NDC坐标,x和y都落在[-1, 1]区间,z落在[0, 1](这是OpenGL风格的约定)。而屏幕坐标系的x范围是[0, width),y范围是[0, height),而且y轴方向一般朝下。这两个坐标系差距太大,必须做一次映射。

最基本的映射公式是:

screen_x = (ndc_x + 1) * 0.5 * width screen_y = (ndc_y + 1) * 0.5 * height

这个公式的含义很简单:把[-1, 1]区间线性变换到[0, width]和[0, height]。ndc_x = -1 对应屏幕最左边,ndc_x = 1 对应屏幕最右边。从数学上看,这个映射没错,但直接拿去光栅化,画面会有半个像素的偏移,三角形边缘看起来就像蒙了一层雾。

问题出在坐标系的基准上。

2.2 像素中心对齐与那个0.5

屏幕上的像素不是几何意义上的"点",而是一块有面积的方块。编号为 i 的那个像素,它占据的连续空间范围是 [i, i+1),所以它的中心位置是 i + 0.5。

图形学采样约定是:当我们要判断一个像素是否被三角形覆盖时,取的是这个像素中心点,也就是 (i + 0.5, j + 0.5),而不是左下角 (i, j)。这跟真实硬件行为一致。

于是问题来了:顶点坐标经过视口变换拿到的是连续坐标,比如sx = 100.0,这到底对应哪个像素?如果采用"像素中心在整数坐标"的简化约定,那这个点会落在像素100上;但按照硬件中心采样约定,100.0 是像素99和像素100的边界,真正应该对应的是像素99的中心99.5。两种约定关于对齐的偏移差正好是0.5。

正确的做法是:先明确你用的像素中心约定,然后把采样点坐标设为(i + 0.5, j + 0.5),并把三角形顶点也放在同一个连续坐标系里进行判定。

如果你看到的公式写成:

pixel_center_x = (ndc_x + 1) * 0.5 * width - 0.5 pixel_center_y = (ndc_y + 1) * 0.5 * height - 0.5

它其实是在做同一件事:把NDC直接映射到"像素索引编号坐标",这样像素中心就落在整数坐标上。两个写法只要统一,最后算出的覆盖像素集合完全一致。

提示:最稳妥的方案是你的光栅化循环遍历像素编号 i, j,采样点一律用 (i + 0.5, j + 0.5),三角形顶点先投影到连续屏幕坐标,不做取整。在后面Edge Function判定时,所有坐标都放进同一个坐标系里算,就不会出现半像素偏差。

3. 重心坐标:光栅化里最实用的插值工具

3.1 重心坐标的来历与几何意义

一旦确定某个像素在三角形内,下一个问题就是:这个像素对应的深度、颜色、纹理坐标是什么?总不能每个属性都单独列一个方程去解,所以图形学里统一使用重心坐标(Barycentric Coordinates)来做插值。

对于三角形三个顶点 A、B、C,任意点 P 可以表示为:

P = α * A + β * B + γ * C α + β + γ = 1

这里的 (α, β, γ) 就是重心坐标。当三个值都在 [0, 1] 内时,P 在三角形内部或边界上。

几何上,重心坐标可以理解为面积比:

α = Area(PBC) / Area(ABC) β = Area(APC) / Area(ABC) γ = Area(ABP) / Area(ABC)

想象一下:点 P 越靠近 A,三角形 PBC 的面积就越大(因为它的底边 BC 不变,高接近 A 到 BC 的高),所以 α 也越大。P 正好等于 A 时,α = 1,其它两个为 0。

实际操作中不会真的去算面积,而是用叉积。设:

u = B - A v = C - A w = P - A

其中 u 和 v 是三角形的两条边向量,w 是从 A 指向 P 的向量。把 P 的表达式改写成:

w = α * u + β * v

这其实是一个二元一次方程组。解它很简单:两边同时对 v 做二维叉积,因为 v 叉积自己等于 0,式子立刻变成:

cross(w, v) = α * cross(u, v) + β * cross(v, v) = α * cross(u, v) α = cross(w, v) / cross(u, v)

同理,两边同时对 u 做叉积:

cross(u, w) = β * cross(u, v) β = cross(u, w) / cross(u, v)

二维叉积就是cross(a, b) = a.x * b.y - a.y * b.x。三个重心坐标分别为 (1 - α - β, α, β),因为 γ 不需要单独算。

3.2 从重心坐标到透视正确插值

拿到重心坐标后,最直观的用法是直接插值属性:

attr = α * attrA + β * attrB + γ * attrC

这个公式在屏幕空间做线性插值时是对的,但有一个关键前提:直接在屏幕空间做线性插值,只对屏幕空间里本来就呈线性的量才成立。颜色、法线这类量通常没问题,但深度和纹理坐标必须小心。

深度和纹理坐标的问题来自透视投影本身。透视投影把观察空间的直线映射到屏幕空间时,深度和纹理坐标在屏幕空间的变化率并不是线性的,而是与顶点 w 的倒数相关。换句话说,你在屏幕空间对观察空间深度做线性插值,等于假设这两个空间之间是线性关系,但透视变换不是线性的。

正确的透视校正插值公式是:

depth_view = 1 / ( α * (1/zA) + β * (1/zB) + γ * (1/zC) )

其中 zA、zB、zC 是顶点在观察空间里的线性深度(一般是相机空间里的 z 分量)。对于任意属性 I:

I_view = ( α * IA / zA + β * IB / zB + γ * IC / zC ) / ( α / zA + β / zB + γ / zC )

为什么是除以 z?因为屏幕空间坐标 (x_s, y_s) 等于观察空间坐标 (x_view, y_view) 除以 -z_view(再经过视口缩放)。所以观察空间里线性变化的属性,在屏幕空间里的函数形式是 "某常量除以 z" 的线性组合。把 α / zA 这种项当成屏幕空间里对 1/z 的插值权重,再用它归一化,就恢复了真实的变化规律。

注意:这里说的 z 是观察空间的线性深度,不是深度缓存里存的 NDC z。NDC 的 z 已经被透视矩阵做了非线性映射,直接拿 NDC z 来透视校正插值会得到错误结果。如果你只有 NDC z,需要先做逆变换还原出观察空间深度。

4. Edge Function 判定公式推导

4.1 用叉积方向判断点在三角形内

重心坐标可以用来判断内外,但更高效、更符合光栅化习惯的做法,是直接对三条边做符号判定。这个方法的正式名字叫 Edge Function,也叫半平面判定。

对于三角形的一条边,从顶点 V0 指向 V1,向量 e = V1 - V0。对于任意点 P,从 V0 指向 P 的向量 w = P - V0。两个向量的叉积 z 分量,几何上表示 P 相对于这条有向边的位置:

E(P) = e.x * w.y - e.y * w.x = (x1 - x0) * (y - y0) - (y1 - y0) * (x - x0)

如果 E(P) > 0,P 在边 V0->V1 的左侧;如果 E(P) < 0,P 在右侧;如果 E(P) = 0,P 恰好落在边所在的直线上。

一个三角形有三条边,逆时针方向排列 V0, V1, V2 时,三角形内部恰好是三条有向边左侧的交集。所以判定条件就是:

E0 >= 0 && E1 >= 0 && E2 >= 0

如果三角形顶点是顺时针排列,内部就变成三条边右侧的交集,判定条件变成E <= 0 && E1 <= 0 && E2 <= 0。这里最容易犯的错误是不统一顶点绕向。同一个模型里既有顺时针三角形又有逆时针三角形,符号判定直接乱掉,画面就会出现各种奇怪的缺面或裂缝。

4.2 从公式到扫描循环:增量优化

光栅化三角形最朴素的实现,是找出三角形的轴对齐包围盒(AABB),然后遍历包围盒里的每个像素中心点,对它计算三条边的 E 值,全大于零就写入。

包围盒可以这样算:

minX = floor(min(x0, x1, x2)) maxX = ceil(max(x0, x1, x2)) minY = floor(min(y0, y1, y2)) maxY = ceil(max(y0, y1, y2))

循环内部如果每次都完整计算三条 E,每个像素要做6次减法6次乘法,性能堪忧。但观察一下公式,E 对坐标是线性的,平移一个像素后变化量是常数:

E(x + 1, y) = E(x, y) + (y1 - y0) E(x, y + 1) = E(x, y) - (x1 - x0)

这意味着每行的第一个采样点算出 E 初值后,往右走只需要做加法,往下走只需要更新行的初值。一整轮遍历下来,每个像素只做三次加法,效果立竿见影。

这个优化的原理很朴素:既然 E 是一条直线方程,它沿 x 和 y 方向的变化率就是固定的斜率。光栅化里这叫 Edge Function Incremental Update,几乎所有软光栅器都在用。

// 伪代码:增量更新三角形光栅化 float edge0 = computeEdge0(minX + 0.5f, minY + 0.5f); float edge1 = computeEdge1(minX + 0.5f, minY + 0.5f); float edge2 = computeEdge2(minX + 0.5f, minY + 0.5f); float rowEdge0 = edge0, rowEdge1 = edge1, rowEdge2 = edge2; for (int j = minY; j <= maxY; j++) { edge0 = rowEdge0; edge1 = rowEdge1; edge2 = rowEdge2; for (int i = minX; i <= maxX; i++) { if (edge0 >= 0 && edge1 >= 0 && edge2 >= 0) { // 通过重心坐标计算插值属性,写入片元 } edge0 += dE0_dx; edge1 += dE1_dx; edge2 += dE2_dx; } rowEdge0 += dE0_dy; rowEdge1 += dE1_dy; rowEdge2 += dE2_dy; }

这里的dE0_dx = y1 - y0,dE0_dy = -(x1 - x0),其余两条边同理。只要在初始化阶段把这些增量算一次,扫描循环瞬间变得非常轻量。

5. 实操中的坐标翻转与精度问题

5.1 y轴翻转导致的图像颠倒

很多第一次写软渲染器的朋友会看到一个诡异现象:三角形画出来了,但上下颠倒。问题几乎都出在视口变换上。

NDC 坐标的 y 轴朝上,而屏幕坐标的 y 轴朝下(窗口坐标原点在左上角)。如果直接用(ndc_y + 1) * 0.5 * height,得到的结果是 y 越大越靠上,到了屏幕坐标体系里就变成越靠下,整个画面自然就反了。

正确的做法是在视口变换里翻转 y:

screen_x = (ndc_x + 1) * 0.5 * width screen_y = (1 - ndc_y) * 0.5 * height

这相当于把 NDC 的 y 轴做了一次镜像。还有一种做法是直接在投影矩阵里给 y 加一个负缩放,但那样容易把整个矩阵的意义弄乱,我建议还是在视口变换这层处理,代码逻辑最清晰。

做完这一步,光栅化循环遍历像素时,采样点依然用(i + 0.5, j + 0.5),不要因为翻转就顺手把采样点改成别的坐标。只要三角形顶点坐标跟像素采样坐标在同一个坐标系,内外判定就是自洽的。

5.2 MSAA 的实现思路

光栅化器辛辛苦苦做内外判定,最终面对的问题始终是锯齿。硬件里最常见的抗锯齿方案是 MSAA(多重采样抗锯齿),它的核心思想其实很简单:一个像素内部放多个采样点,分别做内外判定,然后统计覆盖率。

4x MSAA 就是每个像素里放 2x2 个采样点,采样点位置在像素内部按下图方式分布:

offset[0] = (0.25, 0.25) offset[1] = (0.75, 0.25) offset[2] = (0.25, 0.75) offset[3] = (0.75, 0.75)

光栅化时,对每个像素的每个采样点都做一次 Edge Function 判定,统计落在三角形内的采样点数量 count。这个像素的覆盖率就是:

coverage = count / sampleCount

再拿这个覆盖率去混合颜色。如果场景里有多个物体,需要为每个采样点维护独立的颜色和深度,最后再做 resolve 操作,把那几个样本合并成最终像素颜色。软渲染器实现这套方案的开销是实打实的 sampleCount 倍,但效果立竿见影——边缘不再是生硬的阶梯,而会呈现出平滑的过渡。

提示:MSAA 只是对几何边缘做抗锯齿,对纹理边缘的锯齿无能为力。纹理锯齿需要的是纹理过滤(mipmap)和各项异性过滤,这是另一套体系了。

6. 常见问题与排查技巧实录

6.1 三角形裂缝与边界规则

最让人抓狂的问题,是三角形之间出现一道道细缝,透过缝隙能看到背景色。这通常不是数学错了,而是边界规则没统一。

两个相邻三角形共享一条边,一个三角形用逆时针方向排列顶点,另一个顺时针排列。对同一条边而言,两个三角形的 Edge Function 符号恰好相反。如果内外的判定条件里有一条边是>= 0,另一条也是>= 0,这条边上的像素虽然几何上属于两个三角形,却可能因为符号不一致被两边的判定同时排除,于是没人画它,裂缝就产生了。

解决方案是统一约定。比如 D3D 的 Top-Left Rule:规定在E > 0时算内部,E == 0时只有当边属于"上边"或"左边"才算内部。具体实现时,对每条边判断它的方向,是向上还是向左(y 方向递减,或 y 相等时 x 方向递减),进而决定== 0的情况是否接受。OpenGL 也有类似的规则。真正重要的是:全工程只用一种约定,别在数学边界条件上玩花活。

如果只是做一次作业、不追求像素完美,也可以简单给判定加一个很小的 epsilon:

if (edge0 >= -1e-6 && edge1 >= -1e-6 && edge2 >= -1e-6)

但是这么做有风险,epsilon 太大会把本来不应该覆盖的像素也纳入进来,形成胖边。所以这只适合调试,不适合当最终方案。

6.2 画面整体偏了半个像素

出现整体偏移半个像素,最常见的原因是采样坐标没对齐。比如只用(i, j)做判定,而不是(i + 0.5, j + 0.5)。这样整个三角形的覆盖区域会往左下角挪半个像素,三角形边缘呈现明显的不对称虚化。

排查方法很简单:把画面放大看,如果三角形的左边界和右边界模糊程度不一样,偏移方向还跟三角形形状无关,基本就是采样点偏移问题。把循环里的采样坐标全部改成(i + 0.5f, j + 0.5f),问题立解。

6.3 深度插值错误导致前后遮挡乱套

软渲染里如果出现"后面的物体盖住前面的物体",深度插值嫌疑最大。很多同学直接对观察空间 z 用屏幕空间重心坐标线性插值,这在三角形特别大、透视明显时会出大问题——远处的纵深被压缩得不够,近处的又过度拉伸,遮挡关系就错了。

解决办法就是用前面第3.2节讲的透视校正插值。如果你只是写一个简单的 z-buffer 渲染器,而且不搞大三角形场景,可能看不出差异,但只要把画面切到一个从侧面看过去的大平面,错误立刻暴露。

6.4 常见问题速查表

现象最可能原因排查/修复思路
图像上下颠倒视口变换中 y 未翻转screen_y = (1 - ndc_y) * 0.5 * height
整体偏移半像素采样点用了 (i, j)改为 (i + 0.5, j + 0.5)
三角形接缝处有细线边界规则不一致统一顶点绕向和边界规则
后面物体盖住前面深度插值没有透视校正使用 1/z 加权插值
三角形大面积缺角顶点绕向混乱检查模型,统一为同一绕向

我做这个项目最大的体会是:光栅化公式没有一个是真正困难的,真正考验人的是坐标系约定、边界条件和精度管理这些"旁边的东西"。每一处 0.5 的偏移、每一个符号的正负,背后都对应着一条真实的硬件规则。如果把这篇笔记里的推导亲手在纸上过一遍,再对照代码逐行检查,你一定会对"像素到底是怎么被画出来的"这件事,有一个完全不同层次的理解。希望这条推导之路,也能帮你少踩几个我当年踩过的坑。

返回列表