Part III · 第 12 章 三维表示与配准 · 12.3

3D 图形学渲染管线基础

仿真器要把世界画成相机图像,感知模型要吃合成数据,可视化界面要把每个连杆的网格摆到正确的位置:这些都是渲染,即已知三维模型和一台虚拟相机,算出图像。它是本部分其余内容的逆过程,用的是同一套针孔相机数学,只多了两件事:哪个表面在前面(可见性),以及它看起来是什么颜色(着色)。本课把光栅化管线拆开,让你逐个像素地看它如何工作。

约 35 分钟 前置:1.4 矩阵复合 · 1.8 叉积 · 5.3 SE(3) · 7.3 正运动学 · 10.1–10.3 相机 6 个交互

    1为什么机器人需要它

    机器人学里到处都有渲染器。仿真器(Isaac Sim、MuJoCo、Gazebo、PyBullet)要给仿真里的机器人"拍照",让视觉策略能在虚拟世界里训练和测试。合成数据让感知模型不必人工标注:渲染器可以免费地给出 RGB、深度图和每个像素属于哪个物体(实例分割)。可视化工具(RViz 等)要把机器人每一节连杆的网格放到当前关节角对应的位置。可微渲染与神经渲染(12.4 的 NeRF 与 3D Gaussian Splatting)把"渲染"放进优化循环,通过比较渲染图与真实图像来恢复位姿和场景。

    渲染是本部分其余内容的逆过程。第 10–11 章从图像出发,估计相机、深度和三维点;渲染则从三维模型和相机出发,生成图像。相机数学完全一样:10.1 的针孔模型、10.3 的内参 \(K\) 和外参 \({}^{c}T_{w}\)。多出来的只有两件事:可见性(同一个像素上有好几个表面,哪个在前面?)和着色(这个表面在这个光照下是什么颜色?)。

    本课走一遍实时图形学里最经典的做法,光栅化管线:先用矩阵链把每个顶点从模型坐标变到屏幕像素(FIG 1、FIG 4),再逐像素地判断三角形盖住了谁、深度是多少、属性怎么插值(FIG 2、FIG 3),最后着色并与光线追踪对照(FIG 5),并用它生成机器人训练数据(FIG 6)。12.4 里的 NeRF 沿相机光线积分、3D Gaussian Splatting 则是把高斯椭球"光栅化"到屏幕上,两者都建立在这里的思想之上。

    2直觉:一个顶点要经过六个空间

    一个三维模型就是一堆三角形,每个三角形有三个顶点。渲染的第一半工作是把每个顶点变成屏幕上的一个像素位置,办法是依次换坐标系,每一步都是你熟悉的一个矩阵:

    1. 模型 → 世界:模型矩阵 \(M\)。每个网格在自己的坐标系里建模(连杆的原点就在它的关节上),\(M\) 把它摆到世界里。对机器人,\(M_i = {}^{w}T_i(\boldsymbol\theta)\) 就是 7.3 的正运动学:第 \(i\) 个连杆网格的位姿。
    2. 世界 → 眼(相机):视图矩阵 \(V\),即相机位姿的逆(10.3 的外参),再加一次坐标轴翻转。
    3. 眼 → 裁剪:投影矩阵 \(P\)。它是一个 \(4\times4\) 矩阵,把"视锥"(相机看得见的那块棱台)变形,同时让第四个分量 \(w_c\) 等于深度。
    4. 裁剪 → NDC:透视除法,每个分量除以 \(w_c\)。这一步才产生透视(远处变小),并把视锥压成立方体 \([-1,1]^3\)。
    5. NDC → 屏幕:视口变换,把立方体拉伸成 \(w\times h\) 的像素窗口,深度放进 \([0,1]\)。

    在 FIG 1 里亲手走一遍。机械臂是 7.3 的两连杆加底座和一个方块,每个部件都有自己的模型矩阵。拖动"阶段"滑块,顶点会在 6 个空间之间连续变形;紫色框是相机的视锥(在每个空间里它长什么样)。右边是同一条管线最终渲染出的图像。琥珀色的点 \(p\) 是连杆 2 末端的一个顶点,读数列出它在每个空间里的坐标。

    FIG 1变换链:模型 → 世界 → 眼 → 裁剪 → NDC → 屏幕
    当前空间(拖动旋转)
    整条管线的结果:128×96 像素,原点在左上,v 向下
    阶段 0:每个网格在自己的模型坐标系中(为了看清,并排摆开,每个网格各有一套坐标轴)。0→1:各自乘以模型矩阵 \(M_i\)(刚体运动,按 \(\exp(s\log T)\) 插值)。1→2:乘以视图矩阵 \(V\)(仍是刚体运动),此时相机在原点,朝显示坐标的 +y 方向看,\(-z_e\) 是深度。2→3:乘以 \(P\)(紫色框仍是棱台,但近面宽 \(n\)、远面宽 \(f\))。3→4:除以 \(w_c\),棱台一下变成立方体:近处物体在深度方向被拉长,远处被压扁。4→5:视口变换。图中只画了每个盒子的面,不含视锥以外的裁剪;右图里近平面把挡在前面的三角形切掉了(背面剔除打开,所以被切开的盒子是"空心"的)。
    试一试
    • 点"从头走一遍",或把阶段拖到 NDC(4)。哪一步把棱台变成了立方体:乘以 \(P\),还是除以 \(w_c\)?(对照阶段 3 与 4 里紫色框的形状。)
    • 把近平面 \(n\) 调大,直到它切进机械臂(读数里"被近平面裁剪的三角形"大于 0)。右图里发生了什么?把阶段设为 NDC 再看:被切掉的顶点落在立方体的哪一侧?
    核心洞见

    整条几何管线就是一串矩阵乘法加一次除法:\(\tilde{\mathbf p}_{\text{clip}}=P\,V\,M\,\tilde{\mathbf p}_{\text{model}}\),再除以 \(w_c\),再做视口变换。矩阵部分是线性的(1.3、1.4),因此可以先把 \(PVM\) 乘成一个矩阵,再对所有顶点一次性使用;非线性的透视只来自那一次除法,这正是第四行 \((0,0,-1,0)\) 的作用。

    3动手玩:逐个像素的光栅化

    顶点到了屏幕坐标,下一步要回答的是:哪些像素被这个三角形盖住了?光栅化器的回答很朴素:对三角形包围盒里的每个像素,检查像素中心在不在三角形里。检查用的是边函数。对有向边 \(a\to b\),

    $$ \hl{E}{E_{ab}(\mathbf p)}=(b_x-a_x)(p_y-a_y)-(b_y-a_y)(p_x-a_x)=(\mathbf b-\mathbf a)\times(\mathbf p-\mathbf a) $$

    就是 1.8 的二维叉积,等于三角形 \((a,b,p)\) 有向面积的两倍。点 \(\mathbf p\) 在三角形内,当且仅当三条边的 \(E\) 符号一致。而三个子三角形的面积之比正是重心坐标 \(\hl{lam}{\lambda_0,\lambda_1,\lambda_2}\)(和为 1,在内部都大于 0)。有了它,三角形上任何量都可以从顶点值插值出来:\(f(\mathbf p)=\sum_i\lambda_i f_i\),不管 \(f\) 是颜色、深度还是纹理坐标。

    FIG 2 是一台真正在页面里跑的小型软件光栅化器(64×48 像素,放大后每个像素都看得见)。拖动三个顶点;移动鼠标选取"探针"像素,右边的读数给出它的边函数、\(\lambda\) 和插值结果。右图是深度缓冲(越亮越近)。把顶点深度 \(z_0,z_1,z_2\) 设得不同,深度图就是同一组 \(\lambda\) 插值出来的渐变。

    FIG 2软件光栅化器:边函数、重心坐标、深度缓冲
    颜色缓冲(拖动顶点,悬停选像素)
    深度缓冲(亮 = 近)
    黄框是探针像素,小圆点是像素中心(光栅化器只在这里取样)。"一个三角形"模式下,虚线框是包围盒,三个半透明小三角形是探针点把三角形分成的三块,面积之比就是 \(\lambda\):颜色对应对面顶点(\(\lambda_0\) 红,对应红色顶点,是顶点 0 对面那块子三角形的面积占比)。其余三个模式用的是三个带深度的三角形:风车是三块互相压住对方的板(A 压 B、B 压 C、C 压 A);叠放是三个深度恒定的三角形;相互穿插是两个互相穿过的三角形。"画家算法"把三角形按平均深度从远到近排序再依次涂;"z-buffer"逐像素比较深度。红点标出和 z-buffer 结果不同的像素。像素恰好落在共享边上时的取舍用"左上规则",节 5 会讲。
    试一试
    • 在"一个三角形"模式下,把探针移到靠近某个顶点的像素:该顶点的 \(\lambda\) 趋近 1,颜色变成该顶点的颜色。再把探针移出三角形:哪些量变成了负数?
    • 把深度设为 \(z_0<0.2\),\(z_1>0.8\),\(z_2>0.8\)。深度图在哪个顶点最亮?沿着一条边,亮度是线性变化的吗?
    • 切到"风车",选"画家算法"。红点告诉你它在哪里画错了。点"打乱绘制顺序",再换"叠放":画家算法对哪种场景总是对的?
    • 现在选"z-buffer":无论怎么打乱顺序、无论是风车还是相互穿插,红点都消失。z-buffer 凭什么能处理循环遮挡?
    两种"谁在前面"

    画家算法(Newell、Newell 和 Sancha,1972)在图元层面决定先后:整个三角形排一个序。本课程里的 View3D 画半透明多边形用的就是这个捷径,对线框、坐标系和少量多边形足够,但三角形一旦互相穿插或循环遮挡,任何排序都不对。z-buffer(Catmull,1974)在像素层面决定:每个像素存下目前最近的深度,新片元更近才覆盖。代价是一张和图像一样大的深度图,换来与绘制顺序无关、可以处理任意相交,且每个像素只需一次比较,GPU 因此天然适合它。

    半透明:alpha 混合

    z-buffer 每个像素只留一个"最近"的片元,所以只适合不透明表面。半透明片元带一个不透明度 \(\alpha\in[0,1]\),它挡住后面 \(\alpha\) 比例的光,自己贡献 \(\alpha\,\mathbf c\)。从后往前画时,每个新片元用"over"运算盖在已有颜色上:\(C\leftarrow\alpha\,\mathbf c+(1-\alpha)\,C\),这就是画家算法加上混合。也可以从前往后累积,同时记下还剩多少光能穿过去(透射率 \(T\),初值 1):\(C\leftarrow C+T\alpha\,\mathbf c\),\(T\leftarrow T(1-\alpha)\)。两种顺序结果相同,展开后都是 \(C=\sum_i \mathbf c_i\alpha_i\prod_{j<i}(1-\alpha_j)\)(下标从近到远)。从前往后的好处是 \(T\) 小到可以忽略时就能提前停下。无论哪种顺序,片元都必须按深度排好,这正是半透明物体不能交给 z-buffer 的原因。12.4 的 NeRF 和 3D Gaussian Splatting 用的就是这个从前往后的公式。

    属性插值:线性还是"透视校正"?

    重心坐标是在屏幕上算的,可是颜色、纹理坐标这些属性是定义在三维表面上的。屏幕上的等距不等于表面上的等距(远处的一段距离在屏幕上更短),所以直接用屏幕空间的 \(\lambda_i\) 线性插值纹理坐标是错的。FIG 3 渲染一块铺满棋盘格的地面(OpenCV 相机,地面在相机下方,每格 0.45 m):左图用屏幕空间的 \(\lambda\) 直接插值纹理坐标,中图用透视校正插值,右图是真值,即对每个像素发出一条光线与地面求交。线性插值的格子沿深度方向被拉得一样大,并在两个三角形的对角线处出现折痕。

    FIG 3透视校正插值:对照光线投射真值
    线性(屏幕空间)插值
    透视校正:Σλf/w ÷ Σλ/w
    真值:逐像素光线投射
    场景:宽 1.8 m 的地面带,近边距相机 2.2 m,远边由滑块给出;相机高 \(h\),焦距 100 px,图像 96×72。两个三角形的顶点属性是棋盘格坐标 \((U,V)\)(单位:一个格子)。误差单位也是格子:透视校正的误差只有浮点舍入(\(10^{-14}\) 量级),线性插值最大差好几个格子。把远端距离拖大,\(w_1/w_0\) 变大,线性插值越来越糟。
    试一试
    • 把远端距离拖到 40 m,对比线性插值的最大误差(几个格子)和透视校正的误差。再拖到 3 m:线性插值的误差为什么几乎消失?(提示:\(w_1/w_0\) 接近 1 时 \(1/w\) 几乎是常数。)

    4第二、第三视角:投影矩阵就是 K,以及着色

    视角一:\(P\) 是换了装的 \(K\)

    在 10.1 和 10.3 里,相机用内参 \(K\)(\(f_x,f_y,c_x,c_y\))和图像尺寸描述,像素 \((u,v)\) 的原点在左上角,\(v\) 向下,相机看向 \(+z\)。图形学则用视场角、宽高比、近远平面构造 \(P\),眼空间里相机看向 \(-z\),\(y\) 向上,NDC 的 \(y\) 向上。两套语言描述同一台相机,可以互相换算。FIG 4 同时显示两边:拖动视场角和主点偏移,看 \(K\) 与 \(P\) 的各项如何一起变;拖动琥珀色的点,两条路线(OpenCV 的 \(u=f_xx/z+c_x\),对 OpenGL 的 \(P\,V\,\tilde{\mathbf p}\) 再 ÷\(w\) 再视口变换)给出同一个像素。右图是深度缓冲值 \(d(z)\):它是 \(z\) 的非线性函数,近平面附近占掉了绝大部分数值范围。

    FIG 4投影矩阵 ⇔ 内参 K,以及非线性的深度
    相机坐标系俯视图 (x_c, z_c),拖动 p
    图像固定为 640×480。紫色梯形是视锥的俯视图:近、远平面是粗线,青色线是 \(z=1\) 的归一化像平面,主点偏移会让它左右不对称(离轴投影,\(P\) 的第三列出现非零项 \(1-2c_x/w\))。右图的实线是 \(d(z)=\frac{f(z-n)}{z(f-n)}\),虚线是把近平面放大 10 倍后的曲线。读数里的"深度分辨率"是 \(d\) 量化成 \(b\) 位以后,在距离 \(z\) 处能分辨的最小深度差 \(\Delta z\approx 2^{-b}z^2(f-n)/(fn)\);"可分辨到 z"是两个相距 \(\delta\) 的表面在深度缓冲里还不会混成一个值的最大距离。
    试一试
    • 把主点偏移 \(c_x-w/2\) 拖到 \(\pm60\) px 以上。\(K\) 的哪一项变了、\(P\) 的哪一项变了?视锥的形状怎么变?你能写出 \(P_{13}=1-2c_x/w\) 吗?
    • 切到 16 位深度,把点 \(p\) 拖远,或把间距 \(\delta\) 缩小,直到读数变成"z-fighting"。再把近平面 \(n\) 减小到 0.01,看"可分辨到 z"怎么变:为什么近平面不能随便取很小?
    OpenCV / 本课程 (10.x)OpenGLDirect3D / Metal / Vulkan
    相机朝向+z (x 右, y 下)−z (x 右, y 上)取决于约定(左手或右手)
    深度范围z > 0 (米)NDC z ∈ [−1, 1]NDC z ∈ [0, 1]
    图像 y像素 v 向下,原点左上NDC y 向上;窗口原点通常在左下D3D/Metal:NDC y 向上;Vulkan:NDC y 向下

    视角二:着色,以及另一种渲染——光线追踪

    可见性解决了,剩下的是颜色。最简单的局部光照模型把表面颜色拆成三项:环境光(一个常数,代替无处不在的间接光)、漫反射(Lambert:与 \(\mathbf n\cdot\mathbf l\) 成正比,正是 1.7 的点积,法向和光线夹角越小越亮)和镜面反射(Blinn–Phong:\((\mathbf n\cdot\mathbf h)^m\),\(\mathbf h\) 是光线方向与视线方向的半程向量,\(m\) 越大高光越小)。法向 \(\mathbf n\) 来自网格:每个面一个法向给出平面着色(看得见棱角);每个顶点一个法向再插值,则网格看上去是光滑的。把光照在顶点算好再插值颜色叫 Gouraud 着色,把法向插值后逐像素算光照叫 Phong 着色,后者才能让高光落在两个顶点之间。

    FIG 5 用一个球面网格和一块棋盘格地面比较两种渲染。左图是上面的光栅化管线;中图是光线追踪:每个像素发一条光线,与解析球面和平面求交,再用一条朝光源的阴影光线判断该点是否被球挡住;右图是两者之差(放大 4 倍)。把球面细分 \(N\) 加大,网格逼近真正的球,两图越来越接近,差别只剩多边形轮廓。打开阴影,地面上的影子在差异图里亮起,因为光栅化器只知道"这个片元的法向和光向",不知道路上有没有东西挡着。

    FIG 5着色:光栅化 vs 光线追踪
    光栅化(球面网格 + z-buffer)
    光线追踪(解析球面,每像素一条光线)
    差异 ×4
    两张图用同一台相机(128 px 级的小图,按 10.1 的针孔模型取射线)、同一套光照公式 \(I=c\,(k_a+k_d\max(0,\mathbf n\cdot\mathbf l))+k_s\max(0,\mathbf n\cdot\mathbf h)^m\)(环境光 \(k_a=0.12\),\(k_d=0.85\))。"gamma 编码"开着时,线性光强经过 \(x^{1/2.2}\) 才输出,这是显示器期望的编码;关掉它,同样的光照看起来又暗又硬。地面的棋盘格颜色由插值得到的世界坐标算出,用的是透视校正插值,所以和光线追踪的地面完全吻合。
    试一试
    • 选 Gouraud,把 \(N\) 降到 10 以下,高光指数 \(m\) 调到 60 以上,转动光源:高光会忽隐忽现或变成一块块。换成 Phong,同一个网格上的高光为什么就稳定了?
    • 打开"阴影"。影子出现在中图和差异图里,左图没有。要在光栅化管线里得到阴影,需要再多做哪一件事?(节 5 末尾给出提示:阴影映射。)
    同一个问题,两层循环的不同次序

    光栅化:for 每个三角形: for 它盖住的每个像素。三角形是主角,工作量约正比于三角形数加上被覆盖的像素数,访存规则,GPU 擅长;但全局信息(阴影、反射)要额外的技巧。光线追踪:for 每个像素: for 每个三角形(用加速结构)。像素是主角,每个像素沿光线找最近的交点,再继续发光线,阴影、镜面反射、折射自然得到,代价是需要 BVH 之类的空间加速结构,且每像素要追踪很多光线。两者都是"在每个像素上回答:看到的是什么表面",12.4 的体渲染则把这个问题换成"沿这条光线,各处的密度和颜色累积起来是什么"。

    5再看数学

    提示:把鼠标悬停在公式中带底色的符号上,FIG 1、FIG 2 或 FIG 4 中对应的元素会被高亮。

    矩阵链、透视除法与视口

    $$ \tilde{\mathbf p}_{\text{clip}}=\hl{P}{P}\,\hl{V}{V}\,\hl{M}{M}\,\tilde{\mathbf p}_{\text{model}},\qquad \begin{bmatrix}x_n\\y_n\\z_n\end{bmatrix}=\frac{1}{\hl{div}{w_c}}\begin{bmatrix}x_c\\y_c\\z_c\end{bmatrix},\qquad \begin{bmatrix}u\\v\\d\end{bmatrix}=\begin{bmatrix}\tfrac{w}{2}(x_n+1)\\ \tfrac{h}{2}(1-y_n)\\ \tfrac12(z_n+1)\end{bmatrix} $$

    \(\tilde{\mathbf p}=(x,y,z,1)\) 是齐次坐标(10.2)。模型矩阵 \(M={}^{w}T_{m}\in\mathrm{SE}(3)\)(5.3)把模型坐标变到世界;机器人的第 \(i\) 个连杆有 \(M_i={}^{w}T_{i}(\boldsymbol\theta)\),就是 FK 的前 \(i\) 个因子之积(7.3)。视图矩阵 \(V=\operatorname{diag}(1,-1,-1,1)\,{}^{c}T_{w}\),其中 \({}^{c}T_{w}=({}^{w}T_{c})^{-1}\) 是 OpenCV 外参(相机位姿的逆,10.3),对角阵把 OpenCV 的 \((x,y,z)\) 轴(右、下、前)换成 OpenGL 眼空间的轴(右、上、后)。视口里的 \(v\) 带负号,是因为 NDC 的 \(y\) 向上而像素 \(v\) 向下;这就是图形学管线里那一次"翻转"。

    推导透视矩阵

    设眼空间点 \((x_e,y_e,z_e)\),深度 \(d=-z_e>0\)。OpenCV 针孔模型给出 \(u=f_x x_e/d+c_x\);NDC 的 \(x_n=2u/w-1\)。要让除法恰好是除以 \(d\),就令 \(w_c=d=-z_e\)(第四行 \((0,0,-1,0)\)),于是 \(x_c=x_nw_c=\tfrac{2f_x}{w}x_e+(1-\tfrac{2c_x}{w})z_e\),\(y_c\) 同理(注意 \(y_n=1-2v/h\),\(v=f_y y_e/z_e+c_y\))。深度行取 \(z_c=Az_e+B\),要求 \(d=n\) 时 \(z_n=-1\)、\(d=f\) 时 \(z_n=+1\),解得 \(A=-\frac{f+n}{f-n}\)、\(B=-\frac{2fn}{f-n}\),于是

    $$ P=\begin{bmatrix}\frac{2f_x}{w}&0&1-\frac{2c_x}{w}&0\\[2pt]0&\frac{2f_y}{h}&\frac{2c_y}{h}-1&0\\[2pt]0&0&-\frac{f+n}{f-n}&-\frac{2fn}{f-n}\\[2pt]0&0&-1&0\end{bmatrix},\qquad \frac{2f_y}{h}=\cot\tfrac{\varphi}{2},\ \ \frac{2f_x}{w}=\frac{\cot(\varphi/2)}{a}\ \ (c_x=\tfrac w2,\ c_y=\tfrac h2) $$

    右边是常见的"视场角 \(\varphi\)、宽高比 \(a=w/h\)"写法,它就是主点在图像中心、像素为正方形的特例。Direct3D、Metal、Vulkan 要求 \(z_n\in[0,1]\),第三行换成 \((0,0,\tfrac{f}{n-f},\tfrac{nf}{n-f})\);Vulkan 的 NDC \(y\) 向下,视口里就不用再翻 \(v\)。本课程每一张图都用 OpenGL 的约定,并在 FIG 4 里与 \(K\) 逐项对照,两条路线的像素差在 \(10^{-11}\) 以下。

    深度为什么是 1/z,以及精度

    $$ z_n=\frac{f+n}{f-n}-\frac{2fn}{f-n}\cdot\frac1d,\qquad d_{\text{buf}}=\tfrac12(z_n+1)=\frac{f\,(d-n)}{d\,(f-n)},\qquad \Delta d\;\approx\;2^{-b}\,\frac{d^{2}\,(f-n)}{f\,n}\;\approx\;\frac{2^{-b}d^{2}}{n}\ \ (f\gg n) $$

    \(z_n\) 对 \(1/d\) 是线性的,所以它对屏幕坐标也是仿射的(见下面的推导),这使得 z-buffer 可以像颜色一样用重心坐标插值深度,而不用逐像素做除法。代价是深度的分辨率:\(b\) 位定点深度缓冲在距离 \(d\) 处只能分辨 \(\Delta d\) 的间距,正比于 \(d^2/n\)。\(n=0.1\) m 时区间 \([n,2n]\) 就用掉一半的数值范围,\([n,1\text{ m}]\) 用掉 90%;把 \(n\) 缩小 10 倍,所有距离处的分辨率都差 10 倍(FIG 4 右图)。间距小于 \(\Delta d\) 的两个表面在缓冲里存成同一个值,谁赢取决于光栅化顺序和舍入,于是出现闪烁的 z-fighting。实践中近平面取尽量大、远平面不要无谓地大;现代引擎还常用反向 Z(把 \([n,f]\) 映射到 \(1\to 0\))加浮点深度来均衡精度。

    边函数、重心坐标与"左上规则"

    $$ \lambda_0=\frac{E_{12}(\mathbf p)}{E_{12}(\mathbf v_0)}=\frac{A_0}{A},\quad \lambda_1=\frac{E_{20}(\mathbf p)}{E_{20}(\mathbf v_1)},\quad \lambda_2=\frac{E_{01}(\mathbf p)}{E_{01}(\mathbf v_2)},\qquad E_{ab}(\mathbf p+\hat{\mathbf x})=E_{ab}(\mathbf p)-(b_y-a_y) $$

    \(E_{12}(\mathbf v_0)\) 是整个三角形的有向面积的两倍,\(E_{12}(\mathbf p)\) 是 \(\mathbf p\) 与对边 \(\mathbf v_1\mathbf v_2\) 构成的子三角形的面积的两倍。在三角形内三者同号,\(\lambda_i\ge0\) 且 \(\sum\lambda_i=1\);在顶点 \(\mathbf v_i\) 处 \(\lambda_i=1\)、其余为 0;对顶点值为仿射函数 \(f=\mathbf g\cdot\mathbf p+c\) 的量,\(\sum\lambda_if_i=f(\mathbf p)\) 精确成立(本课在 node 里验证过)。公式最右边说明,沿一行像素走一步,\(E\) 只需做一次加法,这是 Pineda(1988)并行光栅化的基础:硬件对包围盒里所有像素同时计算三个 \(E\)。朝向也藏在符号里:屏幕坐标(\(v\) 向下)里 \(E_{01}(\mathbf v_2)<0\) 对应逆时针即正面,\(>0\) 是背面,背面剔除就用这个符号。

    像素中心恰好落在两个三角形的共享边上时,必须保证恰好一个三角形拥有它:都取(\(E\ge0\))会画两次(半透明会变深),都不取(\(E>0\))会留下一条缝。左上规则规定:边是"左边"(三角形在它的右侧)或"顶边"(三角形在它的下方)时,才算拥有边上的像素。我们在 node 里对 297 个随机的整数坐标四边形(沿对角线拆成两个三角形)验证了:左上规则下 0 个像素被画两次、0 个像素漏掉;"取等号"画了 193 个重复像素,"不取等号"留下 193 个洞。

    为什么 \(f/w\) 和 \(1/w\) 才能线性插值

    三角形所在的平面上,眼空间点满足 \(\mathbf n\cdot\mathbf p_e=c\)。写 \(\mathbf p_e=w\,(\tilde x,\tilde y,-1)\),其中 \(\tilde x,\tilde y\) 是屏幕坐标的仿射函数(\(w=d\) 是深度),代入得 \(w\,\mathbf n\cdot(\tilde x,\tilde y,-1)=c\),即 \(1/w=\mathbf n\cdot(\tilde x,\tilde y,-1)/c\) 是屏幕坐标的仿射函数。若属性 \(f\) 在表面上是眼空间坐标的仿射函数 \(f=f_0+\mathbf g\cdot\mathbf p_e\),则 \(f/w=f_0/w+\mathbf g\cdot(\tilde x,\tilde y,-1)\) 也是屏幕坐标的仿射函数。仿射函数可以用重心坐标精确插值,于是

    $$ f(\mathbf p)=\frac{\sum_i\lambda_i\,f_i/w_i}{\sum_i\lambda_i\,/\,w_i},\qquad\text{e.g. } w_A=1,\ w_B=4,\ f_A=0,\ f_B=1,\ \lambda=(\tfrac12,\tfrac12):\quad f=\frac{\tfrac12\cdot\tfrac14}{\tfrac12\cdot1+\tfrac12\cdot\tfrac14}=0.2\ \ (\text{not }0.5) $$

    FIG 3 在 node 里与光线投射真值逐像素比较:透视校正的最大误差为 \(1\times10^{-14}\) 个格子,线性插值是 8.4 个格子。对近平面裁剪后新生成的顶点同样成立:裁剪在齐次裁剪空间里线性插值属性,我们也验证了裁剪后地面的世界坐标与光线投射一致(误差 \(3\times10^{-13}\))。

    着色、法向变换与光线追踪的代价

    $$ I=c\big(k_a+k_d\max(0,\mathbf n\cdot\mathbf l)\big)+k_s\max(0,\mathbf n\cdot\mathbf h)^{m},\quad \mathbf h=\frac{\mathbf l+\mathbf v}{\|\mathbf l+\mathbf v\|};\qquad \mathbf n'=(M^{-1})^{\!\top}\mathbf n\ \ \text{(normalize afterwards)} $$

    法向是协变量:表面上的切向 \(\mathbf t\) 按 \(\mathbf t'=M\mathbf t\) 变换,而要保持 \(\mathbf n'\cdot\mathbf t'=0\),需要 \(\mathbf n'=M^{-\top}\mathbf n\)。当 \(M\) 是刚体运动(如机器人连杆)时 \(M^{-\top}\!=M\) 的旋转部分,直接乘 \(M\) 就行;一旦有非均匀缩放,就必须用逆转置(例:\(S=\operatorname{diag}(2,1,1)\) 作用在法向 \((1,1,0)\) 上应得 \((\tfrac12,1,0)\propto(1,2,0)\),直接乘 \(S\) 会得到 \((2,1,0)\))。光线追踪与光栅化的复杂度对比:光栅化约 \(O(T+\text{覆盖的像素})\),光线追踪暴力法 \(O(PT)\),用 BVH 约 \(O(P\log T)\),其中 \(P\) 是像素数、\(T\) 是三角形数。统一两者的是渲染方程(Kajiya,1986)

    $$ L_o(\mathbf x,\omega_o)=L_e(\mathbf x,\omega_o)+\int_{\Omega}f_r(\mathbf x,\omega_i,\omega_o)\,L_i(\mathbf x,\omega_i)\,(\mathbf n\cdot\omega_i)\,d\omega_i $$

    出射光 = 自发光 + 半球上"入射光 × 材质 × 余弦"的积分。Lambert 是 \(f_r=k_d/\pi\)(我们把 \(\pi\) 吸收进 \(k_d\));上面的 Phong 模型只对点光源或方向光求这个积分,不含间接光。路径追踪用蒙特卡洛估计整个积分,光栅化管线则靠阴影贴图(Williams,1978:先从光源处渲染一张深度图,着色时比较深度)、环境贴图等技巧近似其中的局部项。可微渲染(SoftRas、nvdiffrast、PyTorch3D)把"覆盖测试"做成可微的,使渲染结果对顶点位置、材质、相机位姿有梯度。12.4 会看到两条后续路线:NeRF 用沿光线的体渲染积分代替"找最近表面",3D Gaussian Splatting 则把大量带颜色的三维高斯投影成屏幕上的椭圆再按深度排序混合,也就是对高斯做光栅化。

    6机器人中的它:合成数据与域随机化

    最直接的应用是用虚拟相机拍训练数据。FIG 6 是一个桌面抓取场景的缩影:一块桌面和四个物体,一台斜向下看的虚拟相机。同一次渲染输出三种东西:RGB;深度图,由 z-buffer 的 \(d\) 经 \(z=\frac{fn}{f-d(f-n)}\) 还原成米(在 node 里和光线投射的深度对照,相对误差 \(10^{-6}\));实例分割,即每个像素属于哪个物体。从分割图取每个物体的最小外接矩形,目标检测的边框标注也是免费的(RGB 图里的彩色矩形)。

    仿真到真实 (sim-to-real) 的难点是域差距:渲染的图像和真实照片在光照、材质、传感器噪声上不一样,训练在仿真上的网络到真机上会失灵。域随机化(Tobin 等,2017)的办法是在仿真里把这些因素随机变化,让网络见到足够宽的分布,使真实世界成为其中一个样本。拖动"随机化强度",下方 4 张缩略图是同一个场景的四次随机采样:光照方向和强度、物体颜色、相机位姿和视场角、传感器噪声都在变,而标注(边框、分割、深度)始终与图像严格对应。深度图里的噪声模型(方差随 \(z^2\) 增大,并有少量缺失像素)只是示意,真实传感器的噪声形态因类型而异。

    FIG 6虚拟机器人相机:RGB、深度、实例分割与域随机化
    RGB + 由分割得到的边框
    深度(亮 = 近,黑 = 无效)
    实例分割(每个 id 一种颜色)
    相机 128×96 px,焦距由 FOV 给出;管线与前面各图是同一套代码(模型矩阵、视图矩阵、投影矩阵、光栅化、z-buffer、Phong 着色),只是每个物体绘制时带上自己的 id。下面一排是同一场景的另外四次随机采样(64×48)。强度 \(s=0\) 时所有因素回到默认值,\(s=1\) 时相机位置每个轴扰动约 ±18 cm(物体本身不动)、光源方位约 ±70°、颜色大幅变化。
    试一试
    • 把随机化强度设到 0.5 以上,再点两次"重新采样"。边框、深度和分割有没有因为颜色和光照变化而错位?把强度设为 0 再看:四张缩略图为什么变得一样了?
    真实系统里

    仿真器的渲染器:Isaac Sim 基于 NVIDIA Omniverse,用 RTX 渲染(实时光线追踪与路径追踪模式),并带有 Replicator 做合成数据和域随机化;MuJoCo 自带基于 OpenGL 的渲染,MJX 一侧有基于 Madrona 的 GPU 批量渲染器用于视觉强化学习;ManiSkill 3 / SAPIEN 在 GPU 上并行渲染许多环境;Gazebo 的渲染基于 OGRE;PyBullet 的 getCameraImage 可以用内置的软件渲染器 TinyRenderer,也可以用 OpenGL,并同时返回 RGB、深度和分割。合成数据管线:BlenderProc 等工具用 Blender 生成带位姿、深度和分割标注的图像;NVIDIA 的合成数据训练工作(Tremblay 等,2018)展示了只用域随机化的渲染图训练目标检测。可视化:RViz 的 RobotModel 显示读取 URDF 里每个 link 的 <visual> 网格,再用 TF 给出的各连杆位姿放置它们,也就是 FIG 1 里的 \(M_i\);Foxglove 和 Rerun 提供类似的三维面板。可微渲染与"渲染再比较":把渲染图与观测图像比较并反向传播或迭代修正,可以估计物体位姿(如 DeepIM、CosyPose 一类方法)、标定相机,或做可微仿真;NeRF/3DGS(12.4)是这一思路的场景级版本。

    7常见误区

    误区 1:混用 OpenGL 与 OpenCV 的约定

    OpenCV 的相机坐标是(右、下、前),像素 \(v\) 向下;OpenGL 是(右、上、后),NDC \(y\) 向上。把 OpenCV 的外参 \([R|t]\) 直接当成视图矩阵,渲染出来的图像会上下颠倒并且相机看向了错误的一边(物体在相机后面,全被裁掉);把 \(K\) 的元素直接填进 \(P\) 而忘了 \(2/w\)、\(1-2c_x/w\) 的换算,视场角会差一大截。出现"图像翻了、镜像了、一片空白"时,先对照 FIG 4 的表:每个轴、每个符号逐个检查。仿真器的相机参数(如 Isaac Sim、Gazebo 的相机传感器)常用 OpenGL 约定,而你的位姿估计代码用 OpenCV 约定,接口处必须显式乘上 \(\operatorname{diag}(1,-1,-1)\)。另外,主点和像素中心也有半像素的约定差别:像素 \((i,j)\) 的中心在 \((i+\tfrac12,j+\tfrac12)\) 还是 \((i,j)\),不同库不同。

    误区 2:近平面取得太小

    "近平面设成 0.001 总没错,反正能看到更近的东西"是错的。深度分辨率 \(\Delta d\approx 2^{-b}d^2/n\) 与 \(n\) 成反比:把 \(n\) 从 0.1 降到 0.001,远处的深度精度坏 100 倍,原本分得清的两个表面(桌面和上面的贴纸,地板和地毯)开始互相闪烁。把 \(n\) 取在场景里真正需要的最近距离,远平面按需取,必要时用 32 位浮点深度加反向 Z。深度图转成点云时(12.1)同样要注意:由 \(d\) 还原 \(z\) 在远处会放大量化误差。

    误区 3:忘了裁剪和透视校正

    相机后面的顶点有 \(w_c<0\),除以它会把点翻到对面:一个跨过相机平面的大三角形(比如脚下的地面)投影成一块巨大的镜像多边形。所以除法之前必须在裁剪空间里把三角形对近平面裁掉(本课的渲染器就是这么做的,FIG 5 的地面有一半在相机后面)。第二个常见错误是直接用屏幕空间 \(\lambda\) 插值纹理坐标或世界坐标(FIG 3),纹理会"游泳",在对角线上出现折痕,大三角形上尤其明显。GPU 默认做透视校正;自己写光栅化器、CUDA 渲染器或可微渲染器时要自己加上 \(1/w\)。

    误区 4:绕向、背面剔除与非均匀缩放下的法向

    背面剔除靠三角形顶点的绕向判断正反面,所以网格的顶点顺序(逆时针为正面是 OpenGL 的默认)必须统一向外。镜像变换(\(\det M<0\),例如把左手的连杆网格镜像成右手)会把绕向反过来,整个模型"由里向外":只看得到内壁。法向也要小心:用 \(M\) 而不是 \((M^{-1})^\top\) 去变换法向,在均匀缩放和刚体下没问题,非均匀缩放下就是错的(例:\(\operatorname{diag}(2,1,1)\) 把 \((1,1,0)\) 变成 \((2,1,0)\) 而不是 \((1,2,0)\)),光照出现莫名其妙的明暗。URDF 里的 scale 属性允许对网格做非均匀缩放,渲染器要用法线矩阵。

    误区 5:在 gamma 空间里做光照,以及"渲染出来就是真的"

    显示器和 8 位图像文件用的是 sRGB 编码,近似于线性光强的 \(1/2.2\) 次方。光照的加法、乘法(环境光、漫反射、多个光源叠加)只在线性空间里有物理意义:把纹理(sRGB)解码成线性,着色,最后再编码一次。FIG 5 的 gamma 开关就是这个差别:不编码,同样的光照看上去又暗又"硬";对 sRGB 纹理重复编码则画面发白。对机器人,另一层"误区"是域差距本身:渲染图再逼真,也有材质、光照、噪声的差异,在仿真里 100% 准确的检测器在真机上完全可能掉点;域随机化、真实数据微调或引入真实传感器噪声模型都是缓解手段,不是免费午餐。

    8小练习

    1. 某个顶点的裁剪空间坐标是 \((x_c,y_c,z_c,w_c)=(3,\,-1.5,\,2,\,4)\)。它的 NDC 坐标是什么?它在视锥内吗?
    NDC = \((x_c,y_c,z_c)/w_c=(0.75,-0.375,0.5)\),三个分量都在 \([-1,1]\) 内,所以在视锥内(等价地:\(|x_c|,|y_c|,|z_c|\le w_c\))。选项 A 忘了除以 \(w_c\);C 把"\(w_c>1\)"当成了条件,其实 \(w_c\) 就是眼空间深度,没有这个限制;D 乘了 \(w_c\) 而不是除。
    2. 一条屏幕上的边,端点 A 的 \(w=1\)、属性 \(f=0\),端点 B 的 \(w=4\)、\(f=1\)。屏幕上正中点(\(\lambda_A=\lambda_B=\tfrac12\))的透视校正属性值是?
    \(f=\dfrac{\tfrac12\cdot0/1+\tfrac12\cdot1/4}{\tfrac12\cdot1/1+\tfrac12\cdot1/4}=\dfrac{0.125}{0.625}=0.2\)。屏幕中点对应的三维点离近端 A 更近(远处的一段在屏幕上被压短),所以属性值更接近 A 的 0,不是线性插值的 0.5。0.8 是把 A、B 弄反了。
    3. 图像宽 \(w=640\),OpenCV 内参 \(f_x=500\),\(c_x=400\)。OpenGL 投影矩阵的 \(P_{11}\) 与 \(P_{13}\)(第一行第一、第三列)是?
    \(P_{11}=2f_x/w=1000/640=1.5625\),\(P_{13}=1-2c_x/w=1-800/640=-0.25\)。主点偏右(\(c_x>w/2\)),所以右边的视野更窄、左边更宽,离轴项非零。B 的符号错;C 漏了因子 2;D 假定了主点在中心。
    4. 其他参数不变,把近平面从 \(n=0.1\) 改成 \(n=1\)(远平面 \(f\gg n\))。在距离 \(d\) 处深度缓冲能分辨的最小深度差 \(\Delta d\) 如何变化?
    \(\Delta d\approx 2^{-b}d^2/n\),与 \(n\) 成反比,\(n\) 增大 10 倍,\(\Delta d\) 缩小为 1/10。直觉:近平面附近挤满了数值,近平面推远一点,这些数值就分给了更远处。代价是比 \(n\) 近的东西会被裁掉。
    5. 三个三角形循环遮挡(A 在 B 前、B 在 C 前、C 在 A 前)。哪一种说法正确?
    循环遮挡里不存在使每个像素都正确的三角形整体顺序(FIG 2 的风车),画家算法只能把三角形拆开才能处理;z-buffer 对每个像素独立地保留最近的片元,结果与绘制顺序无关,也不需要排序或拆分(半透明物体例外,需要按顺序混合)。

    9延伸阅读

    • S. Marschner & P. Shirley, Fundamentals of Computer Graphics (4th ed., CRC Press, 2016) — 光线追踪、变换矩阵、观察变换、图形管线、表面着色、纹理映射各章(4th 版的第 4、6–8、10、11 章);本课的矩阵链与光栅化部分最适合配合阅读
    • T. Akenine-Möller, E. Haines, N. Hoffman, Real-Time Rendering (4th ed., CRC Press, 2018) — 第 2 章(渲染管线)、第 4 章(变换,含各种投影矩阵和深度精度的讨论);实时渲染的标准参考书
    • J. Foley, A. van Dam, S. Feiner, J. Hughes, Computer Graphics: Principles and Practice (2nd ed. in C, Addison-Wesley, 1996) — 经典教材,包含画家算法、z-buffer 和扫描转换的完整推导
    • J. Pineda, "A parallel algorithm for polygon rasterization", SIGGRAPH '88 (Computer Graphics 22(4)), 17–20 — 边函数与并行光栅化的出处
    • P. Heckbert & H. Moreton, "Interpolation for polygon texture mapping and shading", in State of the Art in Computer Graphics, Springer, 1991 — 透视校正插值的经典论述
    • LearnOpenGL, "Coordinate Systems" — 模型/视图/投影矩阵与 NDC 的图文教程,约定是 OpenGL 的
    • Scratchapixel — 从零实现光栅化与光线追踪的教程,对边函数和透视校正插值有详细推导
    • B. T. Phong, "Illumination for computer generated pictures", CACM 18(6), 1975; J. F. Blinn, "Models of light reflection for computer synthesized pictures", SIGGRAPH '77; H. Gouraud, "Continuous shading of curved surfaces", IEEE Trans. Computers C-20(6), 1971 — 三种着色模型的原始论文
    • J. T. Kajiya, "The rendering equation", SIGGRAPH '86; T. Whitted, "An improved illumination model for shaded display", CACM 23(6), 1980 — 渲染方程与递归光线追踪
    • S. Liu, T. Li, W. Chen, H. Li, "Soft Rasterizer", ICCV 2019; S. Laine et al., "Modular primitives for high-performance differentiable rendering" (nvdiffrast), ACM TOG 39(6), 2020; N. Ravi et al., "Accelerating 3D deep learning with PyTorch3D", arXiv:2007.08501 — 可微光栅化
    • J. Tobin et al., "Domain randomization for transferring deep neural networks from simulation to the real world", IROS 2017; J. Tremblay et al., "Training deep networks with synthetic data: bridging the reality gap by domain randomization", CVPR Workshops 2018 — 域随机化