新闻详情

新闻详情

首页 / 资讯中心 / 详情

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

发布时间:2026/9/30 5:19:26来源:尧图网络
软渲染光栅化核心算法:重心坐标、Edge Function与透视校正
我当年第一次写软光栅化器的时候以为最难的部分在数学——顶点变换、矩阵乘法、透视投影样样都能把人绕晕。可真正把代码跑起来才发现最折腾人的永远是那一步屏幕上几百万个像素到底哪些落在三角形内边界上的像素算不算颜色和深度又该怎么插值才不会出错这一小步就是光栅化。翻到这里的朋友多半是正在写软件渲染器或者被图形学作业追着跑。光栅化是整个图形管线从几何阶段迈向像素阶段的分水岭也是软渲染里公认的瓶颈所在。这篇笔记是我把光栅化相关的公式从头推了一遍之后整理的完整记录覆盖NDC到屏幕的映射、像素中心对齐、重心坐标、Edge Function、透视正确深度插值以及我踩过的那些坑。能让你少走弯路。1. 光栅化到底在解决什么问题1.1 从顶点到像素的完整链路讲渲染管线经常有人把光栅化和画三角形混为一谈。严格说起来光栅化干的事情是把已经完成顶点变换、位于屏幕连续坐标系里的几何图元通常是三角形离散成屏幕上的像素片元集合并为后续的深度测试、颜色写入准备好数据。完整链路大概是这样的物体坐标中的顶点经过模型、视图、投影矩阵变换得到裁剪坐标。在裁剪阶段做视锥剔除保留落在可视范围内的几何。做透视除法顶点进入NDC标准设备坐标。视口变换把NDC坐标映射到屏幕连续坐标。光栅化器拿到屏幕坐标下的三个顶点确定覆盖了哪些像素。对每个被覆盖的像素计算重心坐标并以此插值出深度、颜色、纹理坐标等属性。写入帧缓冲通常还要经过深度测试和混合。第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, i1)所以它的中心位置是 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 分量。对于任意属性 II_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) 0P 在边 V0-V1 的左侧如果 E(P) 0P 在右侧如果 E(P) 0P 恰好落在边所在的直线上。一个三角形有三条边逆时针方向排列 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 - y0dE0_dy -(x1 - x0)其余两条边同理。只要在初始化阶段把这些增量算一次扫描循环瞬间变得非常轻量。5. 实操中的坐标翻转与精度问题5.1 y轴翻转导致的图像颠倒很多第一次写软渲染器的朋友会看到一个诡异现象三角形画出来了但上下颠倒。问题几乎都出在视口变换上。NDC 坐标的 y 轴朝上而屏幕坐标的 y 轴朝下窗口坐标原点在左上角。如果直接用(ndc_y 1) * 0.5 * height得到的结果是 y 越大越靠上到了屏幕坐标体系里就变成越靠下整个画面自然就反了。正确的做法是在视口变换里翻转 yscreen_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 也有类似的规则。真正重要的是全工程只用一种约定别在数学边界条件上玩花活。如果只是做一次作业、不追求像素完美也可以简单给判定加一个很小的 epsilonif (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 的偏移、每一个符号的正负背后都对应着一条真实的硬件规则。如果把这篇笔记里的推导亲手在纸上过一遍再对照代码逐行检查你一定会对像素到底是怎么被画出来的这件事有一个完全不同层次的理解。希望这条推导之路也能帮你少踩几个我当年踩过的坑。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

Modbus RTU协议详解:从RS485物理层到数据解析与调试实战 2026/9/30 6:19:28

Modbus RTU协议详解:从RS485物理层到数据解析与调试实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
嵌入式Linux开发学习路线:从驱动入门到项目实战的关键路径 2026/9/30 6:19:28

嵌入式Linux开发学习路线:从驱动入门到项目实战的关键路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Windows11网页版:前端模拟与noVNC真机推流两条路线 2026/9/30 6:19:28

Windows11网页版:前端模拟与noVNC真机推流两条路线

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
PyTorch Normalize()全解析:参数、原理与踩坑实践 2026/9/30 6:19:21

PyTorch Normalize()全解析:参数、原理与踩坑实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
后仿状态记录:X态、收敛失败与checkpoint续跑实战 2026/9/30 6:19:21

后仿状态记录:X态、收敛失败与checkpoint续跑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Ubuntu 18.04 安装 Halcon 21.05 完整指南:环境变量与 Python 接口配置 2026/9/30 6:19:21

Ubuntu 18.04 安装 Halcon 21.05 完整指南:环境变量与 Python 接口配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉