GAMES101 Shading 学习笔记:光照、纹理映射与应用梳理
GAMES101 Shading 学习笔记:光照、纹理映射与应用梳理
前言:Shading 到底在做什么?
在前面的变换和光栅化部分中,我们已经完成了一件非常具体的事情:把三维空间中的三角形,正确地投影到二维屏幕上,并判断出每个像素被哪个三角形覆盖、哪个三角形离相机最近。到这一步为止,我们得到的只是"哪些像素应该被填上颜色",但具体填什么颜色,我们还没说。
Shading(着色)就是回答这个问题的。它的核心任务是:根据光源、材质和观察方向,计算每个可见点应当呈现的颜色。这个"颜色"不是纯色,而是要模拟光线和物体表面交互后,进入人眼的光的强度和色彩。一个红色的金属球在白光照射下,正面亮、侧面暗、高光处几乎是白色——这些丰富的视觉效果,全部来自 Shading 计算。
GAMES101 课程中 Shading 部分的内容大致可以分为三块:第一块是光照模型,核心是 Blinn-Phong 反射模型,解决"一个点在给定光照条件下该有多亮"的问题;第二块是着色频率,解决"在三角形的哪些位置上执行光照计算"的问题;第三块是纹理映射,解决"物体表面的颜色、法线等属性从哪来"的问题,同时引入了纹理采样中的走样与滤波技术。
一、Blinn-Phong 反射模型:局部光照的数学描述
Blinn-Phong 模型是 GAMES101 中重点讲解的光照模型,它是一个经验模型(Empirical Model),不是从物理第一性原理推导出来的,而是通过对真实光照现象的观察和总结,用一组简洁的数学公式来近似模拟光照效果。虽然它不是物理精确的(后面的 PBR 才是),但它足够直观、计算量小、效果对实时渲染来说够用,因此至今仍被广泛使用在教学和简单的渲染场景中。
Blinn-Phong 模型把到达人眼的光分成三个独立的分量:漫反射(Diffuse)、高光反射(Specular)、环境光(Ambient),最终颜色是三者的简单相加。在正式推导之前,我们需要先定义几个在整个 Shading 计算中反复使用的核心向量和量。
1.1 着色点的定义与核心向量
Shading 的计算总是围绕一个**着色点(Shading Point)**展开的。这个着色点是物体表面上的一个微小区域,我们假设在这个微小尺度上,表面是一个平坦的小平面。这个假设非常重要——它意味着我们在每个着色点上,都可以定义一个唯一的表面法线,并且用简单的向量运算来计算光照。
在每个着色点上,我们需要定义以下几个核心量,它们是整个光照模型的输入:
表面法线 n\mathbf{n}n:着色点所在表面的单位法向量,垂直于表面向外。法线的方向决定了这个点"朝向哪边"。法线的来源有多种:可以是三角形面的几何法线,可以是顶点法线经过插值得到的,也可以是通过法线贴图从纹理中读取的——这些我们后面都会详细讲。
光源方向 l\mathbf{l}l:从着色点指向光源的单位向量。注意这个方向的定义:是从着色点出发,指向光源,不是光线传播的方向。很多初学者会搞反这个方向,导致后续点积计算的符号出错。对于点光源,l\mathbf{l}l 就是光源位置减去着色点位置后归一化;对于方向光(如太阳光),l\mathbf{l}l 是一个全局恒定的方向。
观察方向 v\mathbf{v}v:从着色点指向观察者(相机)的单位向量。同样是从着色点出发的方向。
这三个单位向量——n\mathbf{n}n、l\mathbf{l}l、v\mathbf{v}v,加上材质本身的属性参数,就构成了 Blinn-Phong 模型的全部输入。后续的所有计算,本质上都是这几个向量之间的点积和幂运算。
1.2 光的能量衰减:为什么距离越远越暗?
在推导反射模型之前,必须先解决一个前置问题:光源发出的能量,到达着色点时还剩多少?
GAMES101 中用点光源来讲解这个问题。一个点光源向所有方向均匀地辐射能量,在距光源为 rrr 的球面上,光的强度(intensity)必须均匀分布在整个球面上。球面面积为 4πr24\pi r^24πr2,所以距光源距离为 rrr 处,单位面积上接收到的光强为:
I(r)=Ir2 I(r) = \frac{I}{r^2} I(r)=r2I
其中 III 是光源的原始强度(通常定义为距光源单位距离处的强度)。这就是光照的平方反比衰减定律——距离翻倍,光强变为原来的四分之一。同样的能量,扩散到了更大的球面上,自然每个位置分到的就更少了。
在后续的漫反射和高光反射公式中,我们使用的光强都是经过距离衰减后、到达着色点表面时的光强 I/r2I/r^2I/r2,而不是光源本身的原始强度 III。
1.3 漫反射(Diffuse Reflection)
漫反射描述的是粗糙表面的反射行为。当光线照射到一个粗糙的表面(比如墙壁、纸张、粉笔)时,光线会被表面的微观凹凸均匀地散射到所有方向,因此从任何角度看这个表面,亮度都一样——只取决于入射光的强度和光线与表面的夹角,与观察者的位置无关。
如果你仔细想一下,会发现"从任何角度看亮度都一样"这个说法似乎有些反直觉:既然光线被均匀散射到所有方向,那不是应该所有方向都收到等量的光吗?为什么还需要考虑入射角?答案在于入射角决定了"有效接收面积"。
(1)Lambert 余弦定律
考虑一束平行光线照射到一个平面上。如果光线垂直于表面射入(入射角为 0°0°0°),那么光束截面的全部能量,都落在了表面上一块和光束截面等大的区域内。但如果光线以角度 θ\thetaθ 倾斜入射,同样的光束截面会"摊开"到一块更大的表面区域上。具体来说,这块接收区域的面积,是垂直入射时的 1/cosθ1/\cos\theta1/cosθ 倍。换句话说,单位表面面积上接收到的光能量,正比于 cosθ\cos\thetacosθ,其中 θ\thetaθ 是入射光方向与表面法线的夹角。
用向量的语言表达:
cosθ=n⋅l \cos\theta = \mathbf{n} \cdot \mathbf{l} cosθ=n⋅l
其中 n\mathbf{n}n 是表面法线,l\mathbf{l}l 是光源方向(从着色点指向光源),两者都是单位向量。当光线垂直入射时,cosθ=1\cos\theta = 1cosθ=1,接收能量最大;当光线平行于表面时,cosθ=0\cos\theta = 0cosθ=0,表面完全接收不到光。当 cosθ<0\cos\theta < 0cosθ<0 时,意味着光线从表面背面射来,物理上不应该有贡献,所以实际计算中取 max(0, n⋅l)\max(0,\, \mathbf{n} \cdot \mathbf{l})max(0,n⋅l)。
这就是Lambert 余弦定律,它是漫反射计算的物理基础。有了这个定律,漫反射分量的完整公式就可以写出来了。
(2)漫反射公式
结合光的距离衰减和 Lambert 余弦定律,Blinn-Phong 模型中漫反射分量的完整公式为:
Ld=kd⋅Ir2⋅max(0, n⋅l) L_d = k_d \cdot \frac{I}{r^2} \cdot \max(0,\, \mathbf{n} \cdot \mathbf{l}) Ld=kd⋅r2I⋅max(0,n⋅l)
其中:
LdL_dLd 是漫反射分量的出射辐亮度(可以直接理解为"这个分量贡献了多少亮度");
kdk_dkd 是漫反射系数(Diffuse Coefficient),取值范围 [0,1][0, 1][0,1],描述材质吸收了多少光、反射了多少光。kd=1k_d = 1kd=1 表示所有入射光都被反射(完美白色表面),kd=0k_d = 0kd=0 表示所有光都被吸收(完美黑体)。在彩色渲染中,kdk_dkd 是一个三维向量 (kdr,kdg,kdb)(k_d^r, k_d^g, k_d^b)(kdr,kdg,kdb),分别控制红、绿、蓝三个通道的反射率,这就是材质的"颜色"——一个红色物体的 kdrk_d^rkdr 大、kdgk_d^gkdg 和 kdbk_d^bkdb 小,所以它主要反射红色光;
I/r2I/r^2I/r2 是到达着色点的光强(经过距离衰减);
max(0,n⋅l)\max(0, \mathbf{n} \cdot \mathbf{l})max(0,n⋅l) 是 Lambert 余弦项,保证光从背面射来时不贡献负能量。
提示: 与观察方向无关:注意漫反射公式中完全没有出现观察方向 v\mathbf{v}v。这正是漫反射的核心特征——粗糙表面把光均匀散射到所有方向,所以从哪个角度看,接收到的反射光都一样。在现实中可以找到很好的对应:一面白墙,无论你从正面看还是侧面看,同一个位置的亮度是一样的。
1.4 高光反射(Specular Reflection)
漫反射模型能很好地描述粗糙表面,但对于光滑表面(如金属、塑料、水面),我们还会观察到一个明显的现象:在特定的观察角度下,表面上会出现一个非常亮的光斑——这就是高光(Specular Highlight)。高光的位置和观察角度密切相关,当你移动视线时,高光也会跟着移动,这说明高光反射是与观察方向有关的。
高光的物理成因,是表面的微观结构足够平滑,使得入射光线在表面发生了接近于镜面反射的行为:大部分反射能量集中在镜面反射方向附近,只有当观察方向接近镜面反射方向时,才能看到强烈的反射光。
(1)Phong 模型的思路:反射方向
最初的 Phong 模型(1975年,由 Bui Tuong Phong 提出)的做法很直接:先计算光线关于法线的镜面反射方向 R\mathbf{R}R,然后用观察方向 v\mathbf{v}v 和反射方向 R\mathbf{R}R 的夹角来衡量高光强度——夹角越小,越接近镜面反射,高光越强。
反射方向 R\mathbf{R}R 的计算公式为:
R=2(n⋅l)n−l \mathbf{R} = 2(\mathbf{n} \cdot \mathbf{l})\mathbf{n} - \mathbf{l} R=2(n⋅l)n−l
这个公式的几何意义很清晰:入射光方向 l\mathbf{l}l 关于法线 n\mathbf{n}n 做镜像翻转。推导方式也很简单:把 l\mathbf{l}l 分解为沿法线的分量 (n⋅l)n(\mathbf{n} \cdot \mathbf{l})\mathbf{n}(n⋅l)n 和垂直于法线的分量 l−(n⋅l)n\mathbf{l} - (\mathbf{n} \cdot \mathbf{l})\mathbf{n}l−(n⋅l)n,镜面反射时沿法线的分量不变,垂直分量取反,合并后就得到了上面的公式。
Phong 模型的高光公式为 Ls=ks⋅(I/r2)⋅max(0,v⋅R)pL_s = k_s \cdot (I/r^2) \cdot \max(0, \mathbf{v} \cdot \mathbf{R})^pLs=ks⋅(I/r2)⋅max(0,v⋅R)p。但 GAMES101 课程中重点讲的不是原始 Phong 模型,而是它的改进版——Blinn-Phong 模型。后者不再直接比较观察方向 v\mathbf{v}v 与反射方向 R\mathbf{R}R,而是改为比较法线 n\mathbf{n}n 与半程向量 h\mathbf{h}h,这样计算更简单,在实时渲染中数值表现也通常更稳定。
(2)Blinn-Phong 模型的改进:半程向量
Blinn(1977年)提出了一个巧妙的替代方案:不去计算反射方向 R\mathbf{R}R,而是引入一个新的向量——半程向量(Half Vector)h\mathbf{h}h,定义为光源方向 l\mathbf{l}l 和观察方向 v\mathbf{v}v 的角平分线方向:
h=l+v∥l+v∥ \mathbf{h} = \frac{\mathbf{l} + \mathbf{v}}{\lVert\mathbf{l} + \mathbf{v}\rVert} h=∥l+v∥l+v
半程向量的几何意义非常直观:它是光源方向和观察方向"中间"的方向。当观察者恰好处在镜面反射方向上时,h\mathbf{h}h 恰好和法线 n\mathbf{n}n 重合;当观察者偏离镜面反射方向时,h\mathbf{h}h 和 n\mathbf{n}n 的夹角变大。需要注意的是,h\mathbf{h}h 和 n\mathbf{n}n 的夹角并不是在逐点上与 v\mathbf{v}v 和 R\mathbf{R}R 的夹角完全相同;两者只在高光最强的方向上对应同一个几何条件,但在偏离高光中心后衰减方式不同。Blinn-Phong 正是用 n⋅h\mathbf{n} \cdot \mathbf{h}n⋅h 作为一个更方便、也更稳定的高光近似量。
如果把 n\mathbf{n}n、h\mathbf{h}h、R\mathbf{R}R 和 v\mathbf{v}v 放在同一个法平面里观察,会更容易看出它们的关系。设 β=∠(R,v)\beta = \angle(\mathbf{R}, \mathbf{v})β=∠(R,v),α=∠(n,h)\alpha = \angle(\mathbf{n}, \mathbf{h})α=∠(n,h)。在理想镜面几何中,反射方向 R\mathbf{R}R 与入射光关于法线 n\mathbf{n}n 对称,而半程向量 h\mathbf{h}h 是 l\mathbf{l}l 和 v\mathbf{v}v 的角平分线;在高光附近的共面情况下,可以得到 β≈2α\beta \approx 2\alphaβ≈2α。也就是说,Phong 比较的是"观察方向离理想反射方向差多少",而 Blinn-Phong 比较的是"半程向量离法线差多少"。两者在高光中心对应同一个几何条件:v=R\mathbf{v} = \mathbf{R}v=R 与 h=n\mathbf{h} = \mathbf{n}h=n 是等价的;但离开高光中心后,Phong 使用的是 cospβ\cos^p \betacospβ,而 Blinn-Phong 使用的是 cospα≈cosp(β/2)\cos^p \alpha \approx \cos^p (\beta/2)cospα≈cosp(β/2),因此在相同指数下,Blinn-Phong 的高光通常会更宽、更平滑。
为什么 Blinn 的方法更常用?一方面,计算反射方向 R\mathbf{R}R 需要做一次完整的向量反射运算,而计算半程向量 h\mathbf{h}h 只需要一次向量加法和一次归一化,计算量更小;另一方面,从上面的 222 倍关系也能看出,Blinn-Phong 对角度偏离的衰减通常更温和,在很多实时渲染场景中数值表现更稳定、更容易控制。需要注意的是,两者若使用相同的高光指数,得到的高光宽度通常并不完全一致;若想让 Blinn-Phong 的高光锐度接近原始 Phong,往往需要使用更大的高光指数。
(3)高光公式
Blinn-Phong 模型中高光反射分量的完整公式为:
Ls=ks⋅Ir2⋅max(0, n⋅h)p L_s = k_s \cdot \frac{I}{r^2} \cdot \max(0,\, \mathbf{n} \cdot \mathbf{h})^p Ls=ks⋅r2I⋅max(0,n⋅h)p
其中:
LsL_sLs 是高光反射分量的出射辐亮度;
ksk_sks 是高光反射系数(Specular Coefficient),控制高光的强度。对于金属材质,ksk_sks 通常有颜色(反射光带有金属的色调);对于非金属材质(如塑料),ksk_sks 通常是灰色或白色(高光是白色的);
max(0,n⋅h)p\max(0, \mathbf{n} \cdot \mathbf{h})^pmax(0,n⋅h)p 是高光项的核心部分,n⋅h\mathbf{n} \cdot \mathbf{h}n⋅h 衡量半程向量和法线的接近程度,取值范围在 [0,1][0, 1][0,1]。
指数 ppp 的作用:ppp 是高光指数(Shininess / Specular Exponent),它控制高光光斑的大小和锐利程度。cosα\cos\alphacosα 的取值范围是 [0,1][0, 1][0,1],对它取 ppp 次方后,值会急剧衰减。ppp 越大,cospα\cos^p\alphacospα 衰减得越快,只有 α\alphaα 非常接近 000(即 h\mathbf{h}h 和 n\mathbf{n}n 几乎重合)时才有明显的值,对应高光光斑小而锐利,模拟的是非常光滑的表面(如镜面金属);ppp 越小,衰减越慢,高光光斑大而模糊,模拟的是稍微粗糙一些的表面(如塑料)。
在实际使用中,Blinn-Phong 的 ppp 值通常在 64∼25664 \sim 25664∼256 之间。如果 p=1p = 1p=1,高光几乎覆盖了整个半球,完全没有"高光"的感觉;如果 p=256p = 256p=256,高光只是一个极小的亮点。
提示: **高光公式中为什么没有 Lambert 余弦项?**初学者经常会困惑:漫反射公式里有 n⋅l\mathbf{n} \cdot \mathbf{l}n⋅l(Lambert 余弦),高光公式里为什么没有?原因在于这是一个经验模型。在 Blinn-Phong 模型中,高光项关注的是"反射方向是否对准观察者",所以用 n⋅h\mathbf{n} \cdot \mathbf{h}n⋅h 来衡量;而入射光对表面的能量贡献(n⋅l\mathbf{n} \cdot \mathbf{l}n⋅l),在这个经验模型里被省略了。有些变体会在高光项中乘上 n⋅l\mathbf{n} \cdot \mathbf{l}n⋅l,但 GAMES101 课程中讲解的标准 Blinn-Phong 不包含这一项。如果后续学习基于物理的渲染(PBR),会发现 BRDF 中的 Fresnel 项、几何遮蔽项等会以更物理正确的方式处理这些因素。
1.5 环境光(Ambient Reflection)
现实世界中,物体不仅受到直接光源的照射,还会接收到来自环境中其他物体反射的间接光。比如即使房间里只有一个灯泡,桌子底部虽然不直接面对灯泡,也不是完全黑的——因为墙壁、地板反射的光会间接照亮它。这种间接光照在真实世界中无处不在,要精确计算它需要全局光照算法(如光线追踪中的路径追踪),计算量极其庞大。
Blinn-Phong 模型用一种极其简化的方式来近似间接光:假设环境中存在一个均匀的、来自所有方向的恒定光照,称为环境光(Ambient Light)。环境光的公式为:
La=ka⋅Ia L_a = k_a \cdot I_a La=ka⋅Ia
其中 kak_aka 是环境光系数,IaI_aIa 是环境光强度。这个公式非常简单——它既不依赖法线方向,也不依赖光源方向和观察方向,就是给物体表面"加上一层均匀的底色亮度",保证没有被直接光照到的区域也不会完全黑暗。
环境光是一个非常粗暴的近似,它完全丢失了间接光照的空间变化和方向信息。但在实时渲染中,这种简化是可以接受的,因为它的计算成本几乎为零,而且能有效避免画面中出现大片死黑的区域。如果后续学习全局光照、环境光遮蔽(Ambient Occlusion)、光照贴图(Lightmap)等技术,它们本质上都是在用更精确的方法来替代这个粗暴的常数近似。
1.6 Blinn-Phong 模型的完整公式
把漫反射、高光反射、环境光三个分量相加,就得到了 Blinn-Phong 反射模型的完整公式:
L=La+Ld+Ls=kaIa+kdIr2max(0, n⋅l)+ksIr2max(0, n⋅h)p L = L_a + L_d + L_s = k_a I_a + k_d \frac{I}{r^2} \max(0,\, \mathbf{n} \cdot \mathbf{l}) + k_s \frac{I}{r^2} \max(0,\, \mathbf{n} \cdot \mathbf{h})^p L=La+Ld+Ls=kaIa+kdr2Imax(0,n⋅l)+ksr2Imax(0,n⋅h)p
如果场景中有多个光源,只需要对每个光源分别计算漫反射和高光反射分量,然后全部加起来即可(环境光只加一次,因为它和具体光源无关):
L=kaIa+∑i[kdIiri2max(0, n⋅li)+ksIiri2max(0, n⋅hi)p] L = k_a I_a + \sum_{i} \left[ k_d \frac{I_i}{r_i^2} \max(0,\, \mathbf{n} \cdot \mathbf{l}_i) + k_s \frac{I_i}{r_i^2} \max(0,\, \mathbf{n} \cdot \mathbf{h}_i)^p \right] L=kaIa+i∑[kdri2Iimax(0,n⋅li)+ksri2Iimax(0,n⋅hi)p]
其中 iii 遍历所有光源,IiI_iIi 是第 iii 个光源的强度,rir_iri 是着色点到第 iii 个光源的距离,li\mathbf{l}_ili 和 hi\mathbf{h}_ihi 是对应的光源方向和半程向量。
注意: Blinn-Phong 的局限性:Blinn-Phong 是一个局部光照模型,它只考虑了光源→着色点→观察者的直接光路,完全忽略了物体之间的相互光照(间接光照)、阴影投射、光的折射和次表面散射等现象。这些限制在后续学习全局光照和光线追踪时会被逐步解决。另外,Blinn-Phong 的高光项不满足能量守恒——反射的能量可能超过入射的能量,这在物理上是不正确的。基于物理的渲染(PBR)模型如 Cook-Torrance BRDF 解决了这个问题。
二、着色频率:在哪些位置上执行光照计算?
有了 Blinn-Phong 模型后,我们知道了"给定法线、光源方向、观察方向,如何算出颜色"。但紧接着就有一个问题:我们应该在三角形的哪些位置上执行这个计算? 一个三角形覆盖了很多像素,我们是只在三角形的面上算一次,还是在每个顶点上算一次然后插值,还是在每个像素上都算一次?
这就是 着色频率(Shading Frequency) 的问题,不同的着色频率会产生截然不同的视觉效果,对应的计算量也天差地别。GAMES101 中介绍了三种经典的着色频率。
2.1 平面着色(Flat Shading)
平面着色是最简单的方案:对每个三角形只计算一次光照,整个三角形都用同一个颜色。法线使用三角形的几何法线(通过两条边的叉乘计算)。
三角形的几何法线计算方式非常直接:设三角形三个顶点为 P0,P1,P2P_0, P_1, P_2P0,P1,P2,两条边向量为 e1=P1−P0\mathbf{e}_1 = P_1 - P_0e1=P1−P0 和 e2=P2−P0\mathbf{e}_2 = P_2 - P_0e2=P2−P0,那么法线为:
nface=normalize(e1×e2) \mathbf{n}_{\text{face}} = \text{normalize}(\mathbf{e}_1 \times \mathbf{e}_2) nface=normalize(e1×e2)
平面着色的优点是计算量极小(每个三角形只算一次),缺点非常明显:相邻三角形之间会出现明显的颜色跳变,因为每个面的法线不同,计算出的光照也不同。这使得模型表面看起来"一块一块"的,完全没有平滑的感觉。对于真正的平面物体(如立方体)这是合理的,但对于需要看起来平滑的曲面(如球体),平面着色的效果很差。
2.2 Gouraud 着色(Gouraud Shading)
Gouraud 着色(1971年,Henri Gouraud 提出)引入了一个关键的改进:在每个顶点上计算光照,然后在三角形内部通过插值得到每个像素的颜色。
Gouraud 着色的关键前提是:每个顶点需要有一个"顶点法线(Vertex Normal)"。顶点法线不是某个三角形面的法线,而是该顶点所属的所有相邻三角形的面法线的加权平均。最简单的做法是直接取算术平均:
nv=normalize(∑infi) \mathbf{n}_v = \text{normalize}\left(\sum_{i} \mathbf{n}_{f_i}\right) nv=normalize(i∑nfi)
其中 nfi\mathbf{n}_{f_i}nfi 是与该顶点相邻的第 iii 个三角形的面法线。这个平均后的法线,代表了曲面在该顶点处的近似法线方向,它比任何一个单独的面法线都更能反映曲面的真实朝向。更精确的做法是用面积加权或角度加权(面积大的三角形、与该顶点对角大的三角形,贡献的法线权重更大),但基本思路一样。
有了顶点法线后,在每个顶点上执行 Blinn-Phong 光照计算,得到顶点颜色 C0,C1,C2C_0, C_1, C_2C0,C1,C2,然后三角形内部每个像素的颜色,就通过重心坐标插值得到:
CP=αC0+βC1+γC2 C_P = \alpha C_0 + \beta C_1 + \gamma C_2 CP=αC0+βC1+γC2
Gouraud 着色的效果比平面着色好得多——由于颜色在三角形内部是连续插值的,相邻三角形之间的颜色过渡变得平滑,模型表面看起来不再是"一块一块"的。但它仍然有一个问题:高光效果可能会丢失。因为光照只在顶点上计算,如果一个高光恰好落在三角形内部但不在任何顶点附近,那么三个顶点的颜色都不包含高光信息,插值的结果自然也没有高光。三角形越大、顶点越稀疏,这个问题越严重。
2.3 Phong 着色(Phong Shading)
Phong 着色(注意这里的 Phong 是着色频率的名字,和前面的 Phong 反射模型是同一个人提出的,但是不同的东西)通常是三种经典着色频率中效果最好的一种:在每个片元上都执行一次光照计算。
它的做法是:不在顶点上计算颜色,而是在模型导入、网格生成或预处理阶段先为每个顶点准备好法线(这些顶点法线通常由邻面法线按一定规则加权得到,并作为顶点属性存储),然后通过重心坐标在三角形内部插值法线,为每个片元得到一个插值后的法线方向,最后用这个逐片元的法线去执行 Blinn-Phong 光照计算。
nP=normalize(α n0+β n1+γ n2) \mathbf{n}_P = \text{normalize}(\alpha \, \mathbf{n}_0 + \beta \, \mathbf{n}_1 + \gamma \, \mathbf{n}_2) nP=normalize(αn0+βn1+γn2)
注意这里插值后必须重新归一化——三个单位向量的线性组合,结果一般不是单位向量。归一化后的 nP\mathbf{n}_PnP 就是这个片元用于光照计算的法线方向,然后用它参与 Blinn-Phong 计算。
Phong 着色通常比 Gouraud 着色效果更好:由于每个片元都会通过插值得到自己的法线并据此执行光照计算,高光不再受限于只在顶点处被采样,因此即使高光峰值位于三角形内部,也更有机会被正确表现出来。代价当然是计算量更高——光照计算从"每个顶点一次"变成了"每个片元一次",在高分辨率下片元数量通常远大于顶点数量。但在现代 GPU 上,逐片元着色已经是标配;需要注意的是,实际渲染管线中的 Fragment Shader(片元着色器)只是提供了逐片元计算能力,其中采用的具体光照模型未必是经典的 Phong 或 Blinn-Phong,也可能是 PBR 或其他自定义模型。
三种着色频率的核心区别总结:平面着色 = 每个面一个法线、一次光照计算;Gouraud 着色 = 每个顶点一个法线、顶点上算光照、片元上插值颜色;Phong 着色 = 每个顶点一个法线、片元上插值法线、片元上算光照。着色频率越高(从面→顶点→片元),效果通常越好但计算量也越大。不过当模型面数足够高(三角形足够小)时,即使用平面着色,视觉效果也可以很好——因为每个三角形本身就已经很小了,颜色跳变在像素级别上不可见。这也是为什么现代游戏中高面数模型即使用相对简单的着色频率也能获得不错的效果。
三、图形渲染管线(Graphics Pipeline)
到这里,我们已经积累了图形学实时渲染的所有核心知识:MVP变换、光栅化、着色。现在是时候把这些知识串联成一条完整的流水线了。GAMES101 中介绍的实时渲染管线(Graphics Pipeline 或 Real-Time Rendering Pipeline),描述了从输入三维模型数据到输出二维屏幕图像的完整处理流程。
3.1 管线的五个核心阶段
第一阶段:顶点处理(Vertex Processing)
输入是模型的顶点数据(局部空间坐标、法线、纹理坐标等),在这个阶段执行 MVP 变换(Model → View → Projection),把顶点从局部空间变换到裁剪空间。在现代 GPU 中,这个阶段的核心计算由 顶点着色器(Vertex Shader) 完成,开发者可以编写自定义的顶点着色器程序来控制顶点的变换逻辑。除了空间变换外,Gouraud 着色中的"在顶点上计算光照"也在这个阶段执行。
第二阶段:三角形处理(Triangle Processing)
顶点变换完成后,GPU 根据索引数据将顶点组装成三角形。这个阶段还包括裁剪(Clipping,把超出视锥体的三角形裁剪掉)和背面剔除(Back-Face Culling,把背对相机的三角形剔除)等优化操作。
第三阶段:光栅化(Rasterization)
这就是我们在光栅化部分详细讲解的内容:把三角形转化为屏幕上的像素(片元),包括包围盒计算、覆盖测试、MSAA采样等。光栅化阶段的输出是一组片元(Fragments)——每个片元对应屏幕上一个像素位置,并携带了通过重心坐标插值得到的属性(插值后的法线、纹理坐标、深度等)。
第四阶段:片元处理(Fragment Processing)
这是 Shading 真正发生的阶段。每个片元在 片元着色器(Fragment Shader / Pixel Shader) 中执行光照计算,使用插值得到的法线、纹理坐标等数据,结合光源信息和材质参数,计算出这个片元的最终颜色。Phong 着色就是在这个阶段执行的。纹理采样(从纹理图像中读取颜色)也在这个阶段完成。
第五阶段:输出合并(Output Merging)
片元着色完成后,还需要进行深度测试(Z-Buffer)、模板测试(Stencil Test)、颜色混合(Blending,用于实现半透明效果)等操作,最终把通过所有测试的片元颜色写入帧缓冲。
3.2 可编程 vs 固定功能
在现代 GPU 架构中,管线的各个阶段分为可编程阶段和固定功能阶段。可编程阶段允许开发者编写自定义的着色器程序(使用GLSL、HLSL等着色器语言),包括顶点着色器和片元着色器;固定功能阶段(如光栅化、深度测试)由GPU硬件直接执行,开发者只能设置参数但不能改变其逻辑。
着色器的引入是图形学发展史上的一个里程碑——它让开发者可以在 GPU 上运行自定义的并行计算程序,从而实现远超 Blinn-Phong 模型的复杂着色效果,包括法线贴图、环境光遮蔽、屏幕空间反射、后处理特效等等。GAMES101 课程中对此做了简要介绍,更深入的内容属于 GPU 编程和实时渲染优化的范畴。
四、纹理映射(Texture Mapping):给表面穿上"衣服"
到目前为止,我们在 Blinn-Phong 模型中使用的 kdk_dkd(漫反射系数)、ksk_sks(高光系数)等参数,都是对整个物体表面均匀的常数。但现实中的物体表面是丰富多彩的:一件衣服有花纹,一面砖墙有纹路,一只木桌有木纹——这些空间上变化的颜色和材质细节,不可能用几个常数来描述。
纹理映射就是解决这个问题的核心技术:用一张二维图像(纹理贴图)来存储物体表面每个点的属性信息,然后在着色时根据表面位置去查找对应的纹理值,用它来替代光照模型中的常数参数。最基本的用法是用纹理贴图存储漫反射颜色,也就是 kdk_dkd 不再是一个常数,而是对表面上每个点都不同的值:kd=texture(u,v)k_d = \text{texture}(u, v)kd=texture(u,v)。
4.1 纹理坐标与 UV 映射
一张纹理贴图本身是一个二维图像,用二维坐标 (u,v)(u, v)(u,v) 来索引,其中 u,v∈[0,1]u, v \in [0, 1]u,v∈[0,1](归一化坐标),(0,0)(0, 0)(0,0) 是纹理的左下角,(1,1)(1, 1)(1,1) 是右上角。纹理映射的核心问题就是:物体表面上的每一个点,对应纹理图像上的哪个位置?
这个"三维表面点 → 二维纹理坐标"的映射关系,称为UV映射。在实际工作中,UV映射通常由美术人员在建模软件中手动设定,或者用算法自动生成,本质上就是把三维模型的表面"展开"到二维平面上。展开后,模型的每个顶点都会被赋予一个纹理坐标 (u,v)(u, v)(u,v),这个坐标作为顶点属性存储在模型数据中。
在光栅化阶段,三角形内部每个像素的纹理坐标 (u,v)(u, v)(u,v),通过三个顶点的纹理坐标 (u0,v0)(u_0, v_0)(u0,v0)、(u1,v1)(u_1, v_1)(u1,v1)、(u2,v2)(u_2, v_2)(u2,v2) 做重心坐标插值得到。然后在片元着色器中,用这个 (u,v)(u, v)(u,v) 去纹理图像中查找对应的颜色值。
纹理坐标可以超出 [0,1][0,1][0,1] 范围:当纹理坐标超出 [0,1][0, 1][0,1] 时,我们需要一个规则来决定如何处理。最常见的规则是重复(Repeat):对坐标取模,让纹理不断重复铺贴,适合砖墙、地板等规则图案。还有钳制(Clamp):超出范围的坐标被截断到 [0,1][0, 1][0,1],边缘像素被拉伸。不同的处理方式会产生完全不同的视觉效果。
4.2 纹理采样的基本问题:从连续坐标到离散像素
纹理坐标 (u,v)(u, v)(u,v) 经过插值后,通常是一个连续值(比如 u=0.3742,v=0.6891u = 0.3742, v = 0.6891u=0.3742,v=0.6891),但纹理贴图本身是一个离散的像素网格(纹理中的像素被称为texel,纹素)。如何从连续坐标中获取颜色值?这就是纹理采样的问题,它又分为两种情况:
纹理放大(Magnification):当纹理分辨率比屏幕上需要覆盖的像素数少时(纹理"太小"了,需要被放大显示),一个texel会对应到屏幕上多个像素——此时我们需要在texel之间做插值来"补出"中间的值。
纹理缩小(Minification):当纹理分辨率远大于屏幕上需要覆盖的像素数时(纹理"太大"了,需要被缩小显示),一个屏幕像素可能对应纹理上一大片区域——此时我们需要某种方式来"汇总"这片区域的信息。
这两种情况各有不同的处理方法和走样问题,我们分别详细讲解。
4.3 纹理放大与双线性插值
当纹理分辨率太低、需要被放大时,最朴素的方法是最近邻采样(Nearest Neighbor):直接取离 (u,v)(u, v)(u,v) 最近的那个texel的颜色。这种方法速度最快,但效果最差——放大后会看到明显的"马赛克"块状效果,因为相邻的多个屏幕像素都取了同一个texel的值。
更好的方法是双线性插值(Bilinear Interpolation)。它的做法是:找到 (u,v)(u, v)(u,v) 周围最近的四个texel(构成一个 2×22 \times 22×2 的小正方形),然后用 (u,v)(u, v)(u,v) 相对于这四个texel的位置做两次线性插值。
具体步骤如下:设 (u,v)(u, v)(u,v) 在纹理坐标系中对应的连续位置为 (x,y)(x, y)(x,y)(将 (u,v)(u, v)(u,v) 乘以纹理宽高后的值),周围四个texel的坐标分别是 (x0,y0)(x_0, y_0)(x0,y0)、(x0+1,y0)(x_0+1, y_0)(x0+1,y0)、(x0,y0+1)(x_0, y_0+1)(x0,y0+1)、(x0+1,y0+1)(x_0+1, y_0+1)(x0+1,y0+1),其中 x0=⌊x⌋x_0 = \lfloor x \rfloorx0=⌊x⌋,y0=⌊y⌋y_0 = \lfloor y \rfloory0=⌊y⌋。定义偏移量:
s=x−x0,t=y−y0 s = x - x_0, \quad t = y - y_0 s=x−x0,t=y−y0
s,t∈[0,1)s, t \in [0, 1)s,t∈[0,1),表示 (x,y)(x, y)(x,y) 在四个texel构成的单元格内的相对位置。然后分三步插值:
第一步,沿 xxx 方向做两次线性插值:
u0=texel(x0,y0)+s⋅[texel(x0+1,y0)−texel(x0,y0)] \begin{aligned} u_0 &= \text{texel}(x_0, y_0) + s \cdot [\text{texel}(x_0+1, y_0) - \text{texel}(x_0, y_0)] \end{aligned}u0=texel(x0,y0)+s⋅[texel(x0+1,y0)−texel(x0,y0)]
u1=texel(x0,y0+1)+s⋅[texel(x0+1,y0+1)−texel(x0,y0+1)]\begin{aligned} \ u_1 &= \text{texel}(x_0, y_0+1) + s \cdot [\text{texel}(x_0+1, y_0+1) - \text{texel}(x_0, y_0+1) ] \end{aligned} u1=texel(x0,y0+1)+s⋅[texel(x0+1,y0+1)−texel(x0,y0+1)]
第二步,沿 yyy 方向对上面两个结果再做一次线性插值:
result=u0+t⋅(u1−u0) \text{result} = u_0 + t \cdot (u_1 - u_0) result=u0+t⋅(u1−u0)
双线性插值的效果比最近邻采样好得多,放大后的图像边缘变得平滑,不会出现块状伪影。它的计算量也很小——只需要4次texel读取和3次线性插值,在GPU上有硬件级别的原生支持。
还有一种更高质量的方法叫双三次插值(Bicubic Interpolation),它用周围 4×4=164 \times 4 = 164×4=16 个texel做三次多项式插值,效果更平滑,但计算量也更大。GAMES101 中提到了它的存在但不做详细展开。
4.4 纹理缩小与 Mipmap
纹理缩小的问题比放大复杂得多。当一个屏幕像素对应纹理上一大片区域时(比如远处的地板,一个像素可能覆盖了纹理上数百个texel),如果仍然只取一个texel的值,就会产生严重的走样——和光栅化中的锯齿问题本质一样:采样率(每个像素只取一个点)远远不够表达信号的复杂度(纹理区域内的高频变化)。
视觉上的表现是:远处的纹理会出现**摩尔纹(Moiré Pattern)**和闪烁——纹理中的规则图案(如格子、条纹)和像素网格之间发生了频率干涉,产生了原本不存在的低频图案。
从信号处理的角度看(和光栅化中走样的分析完全一致),解决方案是在采样之前做低通滤波——也就是在取值之前,先对纹理区域做一个"平均"(模糊),把高频信息去掉。
最直接的做法是超采样:对每个像素,在纹理上取多个样本点然后平均。比如取 512512512 个样本点做平均——效果肯定好,但计算量爆炸。我们需要的是一种不增加采样次数、却能快速得到区域平均值的技术。这就是Mipmap。
(1)Mipmap 的核心思想
Mipmap(MIP 来自拉丁文 “multum in parvo”,意为"在小空间中有很多")的核心思想极其精妙:提前把纹理在不同分辨率下的"模糊版本"都算好并存下来,运行时直接查表,不用现场做平均。
具体来说,对于一张 N×NN \times NN×N 的原始纹理(Level 0),我们依次生成:
Level 1:N/2×N/2N/2 \times N/2N/2×N/2,原始纹理每 2×22 \times 22×2 个texel取平均,得到一个texel;
Level 2:N/4×N/4N/4 \times N/4N/4×N/4,Level 1 的每 2×22 \times 22×2 个texel再取平均;
……
Level kkk:N/2k×N/2kN/2^k \times N/2^kN/2k×N/2k,每一级都是上一级的 2×22 \times 22×2 降采样平均;
……
Level log2N\log_2 Nlog2N:1×11 \times 11×1,整张纹理的平均值。
每一级 Mipmap 存储的,就是原始纹理在对应尺度下的"预模糊"版本。Level 越高,模糊程度越大,纹理越"糊"。
存储开销:Mipmap 的额外存储是多少?Level 0 有 N2N^2N2 个texel,Level 1 有 N2/4N^2/4N2/4,Level 2 有 N2/16N^2/16N2/16……总存储量是:
N2+N24+N216+⋯=N2⋅11−1/4=43N2 N^2 + \frac{N^2}{4} + \frac{N^2}{16} + \cdots = N^2 \cdot \frac{1}{1 - 1/4} = \frac{4}{3} N^2 N2+4N2+16N2+⋯=N2⋅1−1/41=34N2
也就是说,Mipmap 只增加了 1/31/31/3 的额外存储,非常划算。
(2)如何确定使用哪一级 Mipmap?
在渲染时,对于每个屏幕像素,我们需要确定"这个像素在纹理上覆盖了多大的区域",从而选择合适的 Mipmap 级别。GAMES101 中给出的方法是:估算屏幕像素在纹理空间中的"脚印"(Footprint)大小。
具体做法是:取当前像素和它右边相邻像素、上方相邻像素的纹理坐标,计算纹理坐标的差分:
L=max((∂u∂x)2+(∂v∂x)2,(∂u∂y)2+(∂v∂y)2) L = \max\left(\sqrt{\left(\frac{\partial u}{\partial x}\right)^2 + \left(\frac{\partial v}{\partial x}\right)^2}, \\ \sqrt{\left(\frac{\partial u}{\partial y}\right)^2 + \left(\frac{\partial v}{\partial y}\right)^2}\right) L=max
(∂x∂u)2+(∂x∂v)2,(∂y∂u)2+(∂y∂v)2

这个式子本质上是在估计:屏幕上的一个像素,在纹理空间里到底跨了多大一块区域。
其中 (u,v)(u,v)(u,v) 是纹理坐标,(x,y)(x,y)(x,y) 是屏幕坐标。∂u∂x\frac{\partial u}{\partial x}∂x∂u、∂v∂x\frac{\partial v}{\partial x}∂x∂v 表示屏幕上向右移动一个像素时,纹理坐标在 uuu、vvv 两个方向分别变化多少;因此向量 (∂u∂x,∂v∂x)\left(\frac{\partial u}{\partial x}, \frac{\partial v}{\partial x}\right)(∂x∂u,∂x∂v) 就描述了"屏幕横向走一个像素,在纹理空间里走了多远"。同理,向量 (∂u∂y,∂v∂y)\left(\frac{\partial u}{\partial y}, \frac{\partial v}{\partial y}\right)(∂y∂u,∂y∂v) 描述了屏幕纵向走一个像素时在纹理空间中的位移。
公式里的两个平方和开根号,其实就是在求这两个二维位移向量的长度:
∥(∂u∂x,∂v∂x)∥,∥(∂u∂y,∂v∂y)∥ \lVert(\frac{\partial u}{\partial x}, \frac{\partial v}{\partial x})\rVert, \qquad \lVert(\frac{\partial u}{\partial y}, \frac{\partial v}{\partial y})\rVert ∥(∂x∂u,∂x∂v)∥,∥(∂y∂u,∂y∂v)∥
它们分别对应这个像素 footprint 在纹理空间中的两个局部跨度。由于真实 footprint 往往更像一个斜着的平行四边形,而不是完美正方形,GAMES101 这里取两者的较大值作为统一尺度 LLL,相当于用一个边长约为 LLL 的正方形去保守地包住该 footprint。这样做虽然会丢掉方向信息,但足够用来选择一个"别太清、也别太糊"的 Mipmap 级别。
因此,LLL 是"这个屏幕像素在纹理空间里的最大局部放大倍数"。如果 L≈1L \approx 1L≈1,说明一个像素大约只覆盖一个 texel;如果 L≈4L \approx 4L≈4,就说明一个像素在纹理上已经覆盖了大约 4×44 \times 44×4 这个量级的区域,此时就应该去更低分辨率的 Mipmap 中采样。那么我们需要使用的 Mipmap 级别就是:
D=log2L D = \log_2 L D=log2L
当 D=0D = 0D=0 时,一个像素刚好对应一个texel,使用原始纹理;当 D=1D = 1D=1 时,一个像素对应 2×22 \times 22×2 个texel,使用 Level 1;以此类推。DDD 越大,说明像素覆盖的纹理区域越大,需要使用更模糊的 Mipmap 级别。
(3)三线性插值(Trilinear Interpolation)
上面计算出的 DDD 通常不是整数(比如 D=1.7D = 1.7D=1.7),而 Mipmap 级别只有整数。如果直接取最近的整数级别(比如取 Level 2),就会在不同级别的切换边界上产生可见的跳变——画面上突然变清楚或变模糊。
解决方案是三线性插值:分别在 Level ⌊D⌋\lfloor D \rfloor⌊D⌋ 和 Level ⌈D⌉\lceil D \rceil⌈D⌉ 两个相邻级别上做双线性插值(查询同一个 (u,v)(u, v)(u,v) 位置的值),然后再对这两个结果做一次线性插值,权重由 DDD 的小数部分决定:
result=(1−frac(D))⋅BilinearSample(Level⌊D⌋,u,v)+frac(D)⋅BilinearSample(Level⌈D⌉,u,v) \text{result} = (1 - \text{frac}(D)) \cdot \text{BilinearSample}(\text{Level}_{\lfloor D \rfloor}, u, v) + \text{frac}(D) \cdot \text{BilinearSample}(\text{Level}_{\lceil D \rceil}, u, v) result=(1−frac(D))⋅BilinearSample(Level⌊D⌋,u,v)+frac(D)⋅BilinearSample(Level⌈D⌉,u,v)
三线性插值保证了在 Mipmap 级别之间的平滑过渡,消除了跳变伪影。它需要8次texel读取(两个级别各4次双线性插值)和7次线性插值,在现代GPU上同样有硬件原生支持。
(4)Mipmap 的局限性与各向异性过滤
Mipmap 有一个重要的局限性:它在 level 选择时,本质上把像素在纹理空间中的 footprint 近似成一个各向同性的正方形。但实际上,由于透视投影的变形,像素在纹理空间中的"脚印"往往更像一个被拉伸、旋转过的平行四边形,很多时候长轴和短轴差别很大(比如以极小角度看一个地板时,近处像素在纹理上可能覆盖一个细长条,只有更远处才逐渐接近正方形)。
当像素脚印很狭长时,普通 Mipmap 就容易出现短轴方向的过度模糊(Over-Blur):为了覆盖长轴方向,不得不按较大的尺度选择 Mipmap 级别;可一旦 level 变低,短轴方向本来还能保留的细节也会一起被抹掉。视觉上就表现为远处斜视的纹理变得异常模糊,丢失了本不该丢失的细节。
解决这个问题的思路叫各向异性过滤(Anisotropic Filtering)。它不再假设 footprint 在所有方向上的模糊程度都相同,而是允许"长轴方向多模糊一些、短轴方向少模糊一些"。换句话说,普通 Mipmap 只有一个统一的 level 参数;各向异性过滤则希望分别处理 footprint 的长轴和短轴。
课程上先介绍一种经典的各向异性预过滤结构——RipMap。RipMap 可以看成把普通 Mipmap 的"一维 level 链"扩展成"二维 level 表":第 (i,j)(i,j)(i,j) 层表示纹理在 uuu 方向缩小了 2i2^i2i 倍、在 vvv 方向缩小了 2j2^j2j 倍。这样当一个像素在纹理空间中大致覆盖"横向很长、纵向较短"的区域时,就可以在 uuu 和 vvv 两个方向分别选不同的预过滤尺度,而不是被迫使用同一个正方形 level。由于需要同时存储两方向的缩小版本,RipMap 的总存储量通常会达到原始纹理的约 444 倍。
但要注意,RipMap 也不是万能的。它擅长处理的是与纹理坐标轴对齐的矩形 footprint:也就是说,它能分别决定"uuu 方向模糊多少、vvv 方向模糊多少",却不能完美处理任意旋转的细长 footprint。若 footprint 在纹理空间里明显斜着旋转,RipMap 仍然只能用一个轴对齐矩形去近似,因此对角方向上的情况仍可能模糊过头。
从实现上看,RipMap 选择的也不是"某一张固定方向的特殊纹理",而是先估计当前像素 footprint 在 uuu、vvv 两个轴上的跨度,再分别得到两个 level。例如,如果 footprint 大致覆盖 uuu 方向 888 个 texel、vvv 方向 222 个 texel,那么就更接近选择 (3,1)(3,1)(3,1) 这一层;如果跨度落在两个整数级别之间,还需要在相邻的 (i,j)(i,j)(i,j) 之间做插值。也就是说,RipMap 的本质是"分别为两个轴选缩放级别",而不是"在任意方向里挑一张旋转过的矩形纹理"。
现代 GPU 中常见的各向异性过滤实现通常不会额外存整套方向性缩略图,而是仍以普通 Mipmap 为基础,在采样时根据 footprint 的长短轴关系,沿长轴方向取多个样本再做平均。因此引擎里常见的 2×2\times2×、4×4\times4×、8×8\times8×、16×16\times16× 各向异性等级,更多表示允许使用的最大采样强度,而不意味着纹理资源一定会额外膨胀到同样倍数。可以把它理解为:Mipmap 负责先选一个大致合适的清晰度,各向异性过滤负责在 footprint 很细长时沿长轴多"看几眼",从而在斜视表面上尽量保住细节。
GAMES101 中还提到了一种更通用的方法叫EWA(Elliptical Weighted Average)过滤,它直接用椭圆来近似像素 footprint,可以同时处理旋转和不同纵横比的拉伸情况,理论上比 RipMap 更接近真实 footprint,但计算量也更大,因此在实时渲染中通常不作为基础默认方案。
五、纹理的高级应用:不只是颜色
纹理映射的概念远比"给表面贴一张彩色图片"要广泛得多。在现代图形学中,纹理被用来存储各种各样的表面属性和环境信息。GAMES101 中介绍了几种典型的高级纹理应用。
5.1 凹凸贴图 / 法线贴图(Bump Mapping / Normal Mapping)
法线贴图的核心思想极其巧妙:不改变物体的实际几何形状,只通过修改每个着色点的法线方向,来"欺骗"光照计算,制造出表面凹凸不平的视觉效果。
比如一面平坦的墙壁,如果我们在着色时把每个像素的法线方向做一些扰动(有些地方法线偏左,有些偏右),那么 Blinn-Phong 的漫反射和高光计算就会产生明暗变化,看起来就像表面有凹凸一样——但实际的几何形状仍然是一个平面。
(1)从高度场到扰动法线
GAMES101 中先从 凹凸贴图(Bump Map) 讲起。凹凸贴图存储的是一个高度场 h(u,v)h(u, v)h(u,v),它定义了表面上每个点的"虚拟高度偏移"。关键要注意的是:凹凸贴图并不真的移动几何顶点,而是把表面局部看成一个被高度函数轻微扰动过的小曲面,再根据这个小曲面的切线方向重新计算法线。
在二维情况下,先把问题简化成一条曲线。假设原始表面是一条水平线,它可以写成:
p(u)=(u,0) \mathbf{p}(u) = (u, 0) p(u)=(u,0)
如果高度扰动由 h(u)h(u)h(u) 给出,那么扰动后的曲线变成:
p(u)=(u,h(u)) \mathbf{p}(u) = (u, h(u)) p(u)=(u,h(u))
曲线在参数 uuu 方向上的切线,就是对位置函数求导:
t=dpdu=(1,∂h∂u) \mathbf{t} = \frac{d\mathbf{p}}{du} = \left(1, \frac{\partial h}{\partial u}\right) t=dudp=(1,∂u∂h)
二维中法线方向只要与切线垂直即可。如果切线是 (a,b)(a,b)(a,b),那么一个垂直方向可以取成 (−b,a)(-b,a)(−b,a)。因此二维扰动法线可以写成:
nperturbed=normalize(−∂h∂u, 1) \mathbf{n}_{\text{perturbed}} = \text{normalize}\left(-\frac{\partial h}{\partial u},\ 1\right) nperturbed=normalize(−∂u∂h, 1)
三维时思路完全相同,只是表面不再是曲线,而是一个由 (u,v)(u,v)(u,v) 参数化的局部小曲面。为了推导方便,我们先在 切线空间(Tangent Space) 里思考:在切线空间中,xxx 轴对应表面的切线方向,yyy 轴对应副切线方向,zzz 轴对应原始法线方向,因此未扰动的局部表面可写成
p(u,v)=(u,v,0) \mathbf{p}(u,v) = (u, v, 0) p(u,v)=(u,v,0)
加上高度场以后,局部表面变成
p(u,v)=(u,v,h(u,v)) \mathbf{p}(u,v) = (u, v, h(u,v)) p(u,v)=(u,v,h(u,v))
接下来分别对 uuu 和 vvv 求偏导,得到两个局部切向量:
pu=∂p∂u=(1,0,∂h∂u) \mathbf{p}_u = \frac{\partial \mathbf{p}}{\partial u} = \left(1, 0, \frac{\partial h}{\partial u}\right) pu=∂u∂p=(1,0,∂u∂h)
pv=∂p∂v=(0,1,∂h∂v) \mathbf{p}_v = \frac{\partial \mathbf{p}}{\partial v} = \left(0, 1, \frac{\partial h}{\partial v}\right) pv=∂v∂p=(0,1,∂v∂h)
法线就是两个切向量的叉乘:
nt=pu×pv \mathbf{n}_t = \mathbf{p}_u \times \mathbf{p}_v nt=pu×pv
把叉乘展开:
nt=∣ijk10∂h∂u01∂h∂v∣=(−∂h∂u, −∂h∂v, 1) \mathbf{n}_t = \begin{vmatrix} \mathbf{i} & \mathbf{j} & \mathbf{k} \\ 1 & 0 & \frac{\partial h}{\partial u} \\ 0 & 1 & \frac{\partial h}{\partial v} \end{vmatrix} = \left(-\frac{\partial h}{\partial u},\ -\frac{\partial h}{\partial v},\ 1\right) nt=
i10j01k∂u∂h∂v∂h
=(−∂u∂h, −∂v∂h, 1)
再归一化以后,就得到常见的三维扰动法线公式:
nperturbed=normalize(−∂h∂u, −∂h∂v, 1) \mathbf{n}_{\text{perturbed}} = \text{normalize}\left(-\frac{\partial h}{\partial u},\ -\frac{\partial h}{\partial v},\ 1\right) nperturbed=normalize(−∂u∂h, −∂v∂h, 1)
这个式子的几何意义很直观:∂h∂u\frac{\partial h}{\partial u}∂u∂h 和 ∂h∂v\frac{\partial h}{\partial v}∂v∂h 分别描述了表面沿 uuu、vvv 两个方向的坡度;坡度越大,法线在对应方向上偏得越厉害;最后那个 111 表示它总体仍然主要朝原始法线方向。
(2)切线空间、T\mathbf{T}T、B\mathbf{B}B 到底是什么
上面的推导是在切线空间里完成的。但切线空间不是凭空给定的,它来自表面本身的局部参数化。把表面看成一个由纹理坐标参数化的曲面:
p=p(u,v) \mathbf{p} = \mathbf{p}(u,v) p=p(u,v)
那么沿着 uuu、vvv 两个参数方向的局部基向量自然定义为
T=∂p∂u,B=∂p∂v \mathbf{T} = \frac{\partial \mathbf{p}}{\partial u}, \qquad \mathbf{B} = \frac{\partial \mathbf{p}}{\partial v} T=∂u∂p,B=∂v∂p
这里的 T\mathbf{T}T 是 tangent(切线),表示纹理坐标 uuu 增加时,表面位置在空间里如何变化;B\mathbf{B}B 是 bitangent(副切线),表示纹理坐标 vvv 增加时,表面位置在空间里如何变化。于是任意一个很小的 UV 位移 (du,dv)(du,dv)(du,dv),对应的空间位移都可以写成:
dp=∂p∂udu+∂p∂vdv=T du+B dv d\mathbf{p} = \frac{\partial \mathbf{p}}{\partial u}du + \frac{\partial \mathbf{p}}{\partial v}dv = \mathbf{T}~du + \mathbf{B}~dv dp=∂u∂pdu+∂v∂pdv=T du+B dv
这就是后面三角形上那组公式的来源:参数曲面的微分关系。
(3)为什么会有 e1=Δu1T+Δv1B\mathbf{e}_1 = \Delta u_1\mathbf{T} + \Delta v_1\mathbf{B}e1=Δu1T+Δv1B 这组式子
设一个三角形的三个顶点位置分别为 p0,p1,p2\mathbf{p}_0, \mathbf{p}_1, \mathbf{p}_2p0,p1,p2,对应纹理坐标分别为 (u0,v0)(u_0,v_0)(u0,v0)、(u1,v1)(u_1,v_1)(u1,v1)、(u2,v2)(u_2,v_2)(u2,v2)。定义两条边向量:
e1=p1−p0,e2=p2−p0 \mathbf{e}_1 = \mathbf{p}_1 - \mathbf{p}_0, \qquad \mathbf{e}_2 = \mathbf{p}_2 - \mathbf{p}_0 e1=p1−p0,e2=p2−p0
同时定义对应的 UV 差分:
Δu1=u1−u0,Δv1=v1−v0 \Delta u_1 = u_1 - u_0, \qquad \Delta v_1 = v_1 - v_0 Δu1=u1−u0,Δv1=v1−v0
Δu2=u2−u0,Δv2=v2−v0 \Delta u_2 = u_2 - u_0, \qquad \Delta v_2 = v_2 - v_0 Δu2=u2−u0,Δv2=v2−v0
由于从顶点 000 走到顶点 111 时,纹理坐标变化了 (Δu1,Δv1)(\Delta u_1, \Delta v_1)(Δu1,Δv1),而空间位移正是由局部基底 T,B\mathbf{T},\mathbf{B}T,B 线性组合得到,所以有:
e1=Δu1T+Δv1B \mathbf{e}_1 = \Delta u_1\mathbf{T} + \Delta v_1\mathbf{B} e1=Δu1T+Δv1B
e2=Δu2T+Δv2B \mathbf{e}_2 = \Delta u_2\mathbf{T} + \Delta v_2\mathbf{B} e2=Δu2T+Δv2B
也就是说,三角形在空间中的边向量,其实就是 UV 空间中边向量在局部基底 T,B\mathbf{T},\mathbf{B}T,B 下的展开结果。这里的 T,B\mathbf{T},\mathbf{B}T,B 对当前三角形来说是未知量,而 e1,e2\mathbf{e}_1,\mathbf{e}_2e1,e2 和 UV 差分都是已知量,因此我们可以反过来把局部切线基底解出来。
(4)从三角形边与 UV 差分推导 T\mathbf{T}T、B\mathbf{B}B
把上面两式写成矩阵形式更清楚:
[e1e2]=[TB][Δu1Δu2Δv1Δv2] \begin{bmatrix} \mathbf{e}_1 & \mathbf{e}_2 \end{bmatrix} = \begin{bmatrix} \mathbf{T} & \mathbf{B} \end{bmatrix} \begin{bmatrix} \Delta u_1 & \Delta u_2 \\ \Delta v_1 & \Delta v_2 \end{bmatrix} [e1e2]=[TB][Δu1Δv1Δu2Δv2]
设
Muv=[Δu1Δu2Δv1Δv2] \mathbf{M}_{uv} = \begin{bmatrix} \Delta u_1 & \Delta u_2 \\ \Delta v_1 & \Delta v_2 \end{bmatrix} Muv=[Δu1Δv1Δu2Δv2]
如果它可逆,那么
[TB]=[e1e2]Muv−1 \begin{bmatrix} \mathbf{T} & \mathbf{B} \end{bmatrix} = \begin{bmatrix} \mathbf{e}_1 & \mathbf{e}_2 \end{bmatrix} \mathbf{M}_{uv}^{-1} [TB]=[e1e2]Muv−1
而 2×22\times22×2 矩阵的逆满足:
[abcd]−1=1ad−bc[d−b−ca] \begin{bmatrix} a & b \\ c & d \end{bmatrix}^{-1} = \frac{1}{ad-bc} \begin{bmatrix} d & -b \\ -c & a \end{bmatrix} [acbd]−1=ad−bc1[d−c−ba]
于是定义
r=1Δu1Δv2−Δu2Δv1 r = \frac{1}{\Delta u_1\Delta v_2 - \Delta u_2\Delta v_1} r=Δu1Δv2−Δu2Δv11
就能得到:
T=r(e1Δv2−e2Δv1) \mathbf{T} = r\left(\mathbf{e}_1\Delta v_2 - \mathbf{e}_2\Delta v_1\right) T=r(e1Δv2−e2Δv1)
B=r(−e1Δu2+e2Δu1) \mathbf{B} = r\left(-\mathbf{e}_1\Delta u_2 + \mathbf{e}_2\Delta u_1\right) B=r(−e1Δu2+e2Δu1)
这就是工程中最常见的 tangent / bitangent 计算公式。这里算出来的首先是三角形级别的局部基底:它描述的是当前这个三角形上,UV 的 u/vu/vu/v 方向分别在空间里朝哪边。
进一步地,如果一个顶点被多个三角形共享,那么通常会把相邻三角形的 tangent 累加到该顶点,再做归一化与正交化,得到顶点级 tangent。因此从工程角度看,tangent 往往会作为顶点属性存储;但从来源上看,它确实是由位置、UV 和三角形拓扑预处理算出来的派生量,而不是最原始的几何数据。
(5)TBN 矩阵怎么构造,为什么它能把法线变到世界空间
当我们已经得到着色点处的切线 T\mathbf{T}T、副切线 B\mathbf{B}B 和原始法线 N\mathbf{N}N 后,就可以用它们构造一个局部基底矩阵:
TBN=[∥∥∥TBN∥∥∥] \text{TBN} = \begin{bmatrix} \| & \| & \| \\ \mathbf{T} & \mathbf{B} & \mathbf{N} \\ \| & \| & \| \end{bmatrix} TBN=
∥T∥∥B∥∥N∥
要注意,TBN 的三列必须处在同一个坐标空间里。实际开发中如果光照是在世界空间计算的,那么常见做法就是把它们都变到世界空间,得到世界空间下的 TBN。
切线空间中的法线向量 nt=(nx,ny,nz)T\mathbf{n}_t = (n_x, n_y, n_z)^Tnt=(nx,ny,nz)T,本质上表示的是:它沿切线方向有 nxn_xnx 的分量,沿副切线方向有 nyn_yny 的分量,沿原始法线方向有 nzn_znz 的分量。既然 TBN 的三列正是这三个基向量在世界空间中的表达,那么把切线空间法线变到世界空间,只需要做一次基变换:
nw=normalize(TBNnt) \mathbf{n}_w = \text{normalize}(\text{TBN}\mathbf{n}_t) nw=normalize(TBNnt)
把矩阵乘法展开,就更直观了:
nw=normalize(nxT+nyB+nzN) \mathbf{n}_w = \text{normalize}\left(n_x\mathbf{T} + n_y\mathbf{B} + n_z\mathbf{N}\right) nw=normalize(nxT+nyB+nzN)
这说明 TBN 并不是什么神秘技巧,它只是把"切线空间坐标"翻译成"世界空间向量"。例如,当 nt=(0,0,1)\mathbf{n}_t=(0,0,1)nt=(0,0,1) 时,乘出来的结果自然就是原始法线方向 N\mathbf{N}N;当 nt=(1,0,0)\mathbf{n}_t=(1,0,0)nt=(1,0,0) 时,对应的就是世界空间里的切线方向 T\mathbf{T}T。
在实现上,很多引擎只显式存储顶点法线 N\mathbf{N}N 和切线 T\mathbf{T}T,再额外存一个符号位 w∈{+1,−1}w\in\{+1,-1\}w∈{+1,−1} 来表示左右手系。副切线通常通过
B=cross(N,T)⋅w \mathbf{B} = \text{cross}(\mathbf{N}, \mathbf{T}) \cdot w B=cross(N,T)⋅w
在运行时重建。为了让基底更稳定,实际使用前还会做一次正交化,例如先将 T\mathbf{T}T 在 N\mathbf{N}N 的方向上投影去掉,再归一化:
T′=normalize(T−N(N⋅T)) \mathbf{T}' = \text{normalize}\left(\mathbf{T} - \mathbf{N}(\mathbf{N}\cdot\mathbf{T})\right) T′=normalize(T−N(N⋅T))
然后再用 T′\mathbf{T}'T′ 与 N\mathbf{N}N 求出更稳健的 B\mathbf{B}B。
(6)法线贴图 vs 凹凸贴图
凹凸贴图存储的是高度值 h(u,v)h(u, v)h(u,v),需要在运行时通过差分或偏导估计梯度,再根据
nperturbed=normalize(−∂h∂u, −∂h∂v, 1) \mathbf{n}_{\text{perturbed}} = \text{normalize}\left(-\frac{\partial h}{\partial u},\ -\frac{\partial h}{\partial v},\ 1\right) nperturbed=normalize(−∂u∂h, −∂v∂h, 1)
得到切线空间法线。而法线贴图(Normal Map)更进一步:它直接存储扰动后的法线方向 (nx,ny,nz)(n_x, n_y, n_z)(nx,ny,nz),省去了运行时的梯度计算。法线贴图中的每个像素存储一个三维法线向量,编码方式通常是把 [−1,1][-1, 1][−1,1] 的法线分量映射到 [0,255][0, 255][0,255] 的颜色值(因此法线贴图看起来通常是蓝紫色的——因为未扰动的法线 (0,0,1)(0, 0, 1)(0,0,1) 对应 RGB 值 (128,128,255)(128, 128, 255)(128,128,255))。
因此,从完整流程上看:
- 先根据位置和 UV 为网格建立局部的 T,B,N\mathbf{T},\mathbf{B},\mathbf{N}T,B,N 基底;
- 在切线空间中,通过高度图得到 nt\mathbf{n}_tnt,或者直接从法线贴图读取 nt\mathbf{n}_tnt;
- 再利用 TBN 把 nt\mathbf{n}_tnt 变到世界空间,参与光照计算。
法线贴图和凹凸贴图在视觉效果上的目标是一致的,都是通过修改法线来模拟凹凸;区别只在于:凹凸贴图存的是高度,需要运行时再推导法线,而法线贴图直接给出最终的切线空间法线,因此在实际开发中更常用。
5.2 位移贴图(Displacement Mapping)
法线贴图虽然效果很好,但它有一个根本性的局限:它只改变了法线方向,没有改变实际的几何形状。这意味着在轮廓边缘处、阴影投射时、以及侧面极端角度观察时,"假凹凸"的破绽就会暴露出来——轮廓线仍然是光滑的,阴影也是平的。
位移贴图(Displacement Map) 则真的修改了顶点的位置:根据高度贴图的值,把每个顶点沿法线方向移动对应的距离。这样物体的实际几何形状真的发生了变化,轮廓、阴影、遮挡关系都是正确的。
位移贴图的代价是需要足够高的几何精度——如果模型的三角形太大,位移的细节就无法被正确表达。实际使用中,通常需要对模型做曲面细分(Tessellation),生成足够密的三角形,再做位移。现代GPU提供了硬件级别的曲面细分支持(Tessellation Shader阶段),使得位移贴图在实时渲染中变得可行。
5.3 环境贴图(Environment Mapping)
环境贴图这一节最容易让人困惑的地方,是文中经常直接说“根据方向去查环境颜色”,却没有先说明这个方向到底是从哪里出发、由谁定义的。更清楚的说法应该是:我们始终从当前正在着色的表面点出发讨论问题。
设当前正在渲染的表面点为 p\mathbf{p}p,它的单位法线为 N\mathbf{N}N,相机位置为 c\mathbf{c}c。我们约定视线方向 V\mathbf{V}V 为从表面点 p\mathbf{p}p 指向相机的单位向量,即
V=c−p∥c−p∥ \mathbf{V} = \frac{\mathbf{c}-\mathbf{p}}{\lVert \mathbf{c}-\mathbf{p}\rVert} V=∥c−p∥c−p
此时,从相机射向表面点的入射观察方向就是 −V-\mathbf{V}−V。如果这个点是理想镜面,那么入射方向 −V-\mathbf{V}−V 关于法线 N\mathbf{N}N 的镜面反射方向 R\mathbf{R}R 为
R=2(N⋅V)N−V \mathbf{R} = 2(\mathbf{N}\cdot \mathbf{V})\mathbf{N} - \mathbf{V} R=2(N⋅V)N−V
这个式子的几何意义很简单:我们先把 V\mathbf{V}V 沿法线方向的分量取出来,再利用“入射角等于反射角”的规则,把观察方向关于法线镜像过去,就得到反射方向 R\mathbf{R}R。环境贴图真正要做的事情就是:已知当前表面点的反射方向 R\mathbf{R}R,去查找“朝这个方向看出去时,环境是什么颜色”。
所以,环境贴图本质上存储的是一个方向到颜色的查找表:
color=E(ω) \mathrm{color} = E(\omega) color=E(ω)
其中 ω\omegaω 是一个单位方向向量。对镜面反射来说,我们把 ω\omegaω 取成反射方向 R\mathbf{R}R,于是这个表面点的环境反射颜色就近似写成
Lenv≈E(R) L_{\mathrm{env}} \approx E(\mathbf{R}) Lenv≈E(R)
这里一定要注意:环境贴图记录的不是“某个位置周围的完整几何”,而是“从某个参考点朝各个方向看出去的大致环境颜色”。也就是说,它不真的从表面点 p\mathbf{p}p 向场景中发射一条反射射线并求交,而是直接用方向 R\mathbf{R}R 去查表。因此它是一种便宜但带近似的反射方法,而不是真正的光线追踪。
环境贴图之所以能够用纹理存储,是因为我们只需要记录方向信息;但方向是三维的,而纹理是二维的,所以需要一种“把三维方向编码到二维图像”的方式。常见的两种表示如下。
球面贴图(Spherical Map):把所有方向先看成球面上的点,再把球面展开并投影到二维图像上。它的优点是概念直观,但因为“球面铺平到平面”本身会带来变形,所以在极点处(球面的上下两端)会出现严重拉伸。

立方体贴图(Cube Map):用 6 张正方形纹理分别记录上、下、左、右、前、后六个方向的环境。查找时不是先算出一个统一的二维坐标,而是先看方向向量 R=(rx,ry,rz)\mathbf{R}=(r_x,r_y,r_z)R=(rx,ry,rz) 的三个分量中哪个绝对值最大,从而确定它更接近立方体的哪一个面;然后再利用另外两个分量,换算成该面内部的二维纹理坐标。这种方式避免了球面贴图在极点处的严重拉伸,因此是实际应用中更常见的方案。
例如,如果 ∥rx∥\|r_x\|∥rx∥ 最大,那么说明方向更接近立方体的左右两个面:当 rx>0r_x>0rx>0 时落到右面,当 rx<0r_x<0rx<0 时落到左面;再根据 ryr_yry 和 rzr_zrz 计算这张面的 (u,v)(u,v)(u,v) 坐标。其余几个面同理。虽然实际 API 已经帮我们封装好了这些细节,但从原理上看,Cube Map 做的事情就是:先按方向选择面,再在面内做二维采样。
环境贴图有一个非常重要、也非常强的假设:环境只和方向有关,而和位置无关。换句话说,它把环境近似为“位于无穷远处的背景”。只要方向相同,不管当前表面点 p\mathbf{p}p 在空间中处于什么位置,查到的环境颜色都认为相同。
这个假设在室外远景中通常还算合理。比如天空、远山、地平线和非常远的建筑,即使相机和物体在局部范围内移动一点点,朝同一个方向看,景象变化也不大;这时用方向查表来近似真实反射,视觉上通常能成立。
但在室内场景或者近物体环境中,这个假设就会明显失效。因为近处墙壁、桌椅、柜子等物体与表面点之间的相对位置变化会显著影响反射结果。真实情况下,表面上两个位置稍有不同的点,即使反射方向大致相同,也可能看到完全不同的近处物体;而普通环境贴图仍然只按方向查表,所以会丢失这些位置依赖信息,导致反射看起来“不贴合场景”。
因此,环境贴图更准确的理解方式应该是:它不是在回答“这个点真实会反射到什么物体”,而是在回答“如果只根据反射方向查一个预先记录的远景环境,那么大致应该返回什么颜色”。它非常适合表现天空、远景和粗略的镜面环境反射,但不适合精确描述近处几何体带来的位置相关反射。
5.4 其他纹理应用
GAMES101 中还简要提到了纹理在其他领域的应用,体现了"纹理就是一种通用的数据查找表"这个核心思想:
环境光遮蔽贴图(Ambient Occlusion Map):预计算每个表面点被周围几何体遮挡的程度,存储为纹理。被遮挡越严重的位置,接收到的间接光照越少,应该更暗。这比 Blinn-Phong 中的常数环境光要精确得多。
三维纹理与程序化纹理:不是所有纹理都存储在二维图像中。三维纹理(3D Texture)在三维空间中定义值,适合用来表现像大理石、木头这种"纹理延伸到物体内部"的材质。程序化纹理(Procedural Texture)则不存储任何图像数据,而是用数学公式在运行时实时计算纹理值,最著名的例子是Perlin噪声,它可以生成极其自然的云朵、山脉、水面等图案。
六、Shading 与渲染管线其他部分的联系
最后,我们把 Shading 放回到整个 GAMES101 的知识体系中,梳理它和前后内容的关系。
与变换部分的联系:Shading 计算中用到的法线向量,在经过 Model 和 View 变换时需要特殊处理。法线不能直接用模型矩阵 MMM 来变换——如果 MMM 包含非均匀缩放(比如 xxx 方向缩放2倍,yyy 方向不变),直接用 MMM 变换法线会导致法线不再垂直于表面。正确的做法是用 MMM 的逆转置矩阵 (M−1)T(M^{-1})^T(M−1)T 来变换法线。这个结论的推导很简单:设变换前表面上的切向量为 t\mathbf{t}t,法线为 n\mathbf{n}n,有 nTt=0\mathbf{n}^T \mathbf{t} = 0nTt=0;变换后要求 n′Tt′=0\mathbf{n'}^T \mathbf{t'} = 0n′Tt′=0,其中 t′=Mt\mathbf{t'} = M\mathbf{t}t′=Mt,代入可得 n′=(M−1)Tn\mathbf{n'} = (M^{-1})^T \mathbf{n}n′=(M−1)Tn。
与光栅化部分的联系:光栅化负责确定哪些像素被三角形覆盖,并通过重心坐标插值提供每个像素的法线、纹理坐标等数据;Shading 则接过这些数据,执行光照计算,最终确定每个像素的颜色。两者是上下游的关系——光栅化的输出,就是Shading的输入。特别是重心坐标插值和透视校正插值,既是光栅化部分的内容,也直接影响 Shading 中法线插值和纹理坐标插值的正确性。
与后续光线追踪部分的联系:Blinn-Phong 是一个经验模型,光照计算发生在物体表面的着色点上,只考虑了直接光照。在后续的光线追踪部分,GAMES101 会引入 BRDF(双向反射分布函数)、渲染方程、路径追踪等概念,它们从物理的角度出发,用更严谨的数学框架来描述光和材质的交互,能够自然地处理全局光照(间接光照、焦散、次表面散射等),是 Blinn-Phong 模型的"正确的物理升级版"。
附录:补充内容
A. 关于伽马校正(Gamma Correction)
在实际渲染中,一个经常被忽略但影响很大的问题是:光照计算必须在线性空间中完成,而纹理存储和屏幕显示往往不是线性的。伽马校正的作用,就是把“用于显示的非线性编码”与“用于光照计算的线性物理量”区分开来。
所谓线性空间,指的是数值与真实光强成正比:若一盏灯的亮度翻倍,线性空间中的数值也应翻倍;两盏同样亮的灯叠加,结果也应是两倍亮度。因此,漫反射、镜面反射、环境光以及多光源叠加,都应该在线性空间中进行。
问题在于,实际图像存储和显示通常并不满足这种线性关系。显示设备输出亮度与输入信号之间往往更接近幂律关系:
L∝Vγ,γ≈2.2 L \propto V^{\gamma},\qquad \gamma \approx 2.2 L∝Vγ,γ≈2.2
这里,VVV 表示送给显示设备的数值,LLL 表示最终显示出来的亮度。它们不是简单的 L=VL=VL=V,而更像是一个非线性的幂函数关系。于是,在线性空间中代表“半亮”的数值 0.50.50.5,若直接原样送到屏幕上,显示出来的亮度会更接近 0.52.2≈0.220.5^{2.2}\approx 0.220.52.2≈0.22,比真正的“半亮”暗得多。这就是为什么如果不做伽马校正,画面通常会偏暗。
因此,正确流程不是“算完什么就直接显示什么”,而是分成三步。第一步:如果输入纹理是颜色纹理(如 albedo、base color),并且它以 sRGB 形式存储,那么采样后要先把它解码回线性空间。第二步:所有光照和颜色混合都在线性空间中进行。第三步:在线性空间得到最终结果后,再把它编码回适合显示的非线性空间,输出到屏幕。
把这个过程写成公式,粗略可以理解为:颜色纹理采样后先从 sRGB 变回线性空间,大致可近似为
Llinear≈VsRGBγ L_{\text{linear}} \approx V_{\text{sRGB}}^{\gamma} Llinear≈VsRGBγ
然后在线性空间完成光照计算;最终输出到屏幕前,再做反向编码:
Vout≈Llinear1/γ V_{\text{out}} \approx L_{\text{linear}}^{1/\gamma} Vout≈Llinear1/γ
这里把 sRGB 近似写成幂函数,是为了帮助理解;严格来说,sRGB 的传递函数是分段定义的,但在入门阶段把它近似看作 γ≈2.2\gamma \approx 2.2γ≈2.2 已经足够。
这也解释了为什么“在 sRGB 纹理上直接做光照计算”会出错:sRGB 中存储的数值不是线性的光强。比如某个纹理通道值为 0.50.50.5,它并不对应“线性空间中的一半亮度”,而更接近 0.52.20.5^{2.2}0.52.2 对应的线性亮度。如果你直接拿它去做乘法、插值和光照叠加,中间色调就会被压暗,阴影过渡、颜色混合和高光强度都会出现偏差。
在实际开发中,颜色纹理通常以 sRGB 存储,采样后先转线性;所有光照在线性空间里做;最后输出到屏幕前再转回 sRGB。与之相对,法线贴图、金属度、粗糙度、高度图、遮罩图等“数据纹理”通常不应该做 sRGB 解码,因为它们存储的不是给人眼直接看的颜色,而是参与数学计算的数据。
B. 关于能量守恒与 PBR
Blinn-Phong 模型的一个重要缺陷是不满足能量守恒:物体反射的光能量可能超过接收到的入射光能量,这在物理上是不合理的。基于物理的渲染(Physically Based Rendering, PBR)模型通过 Cook-Torrance BRDF 等公式,严格保证了能量守恒,同时引入了 Fresnel 效应(掠射角反射率增加)、微表面理论(用统计分布描述微观粗糙度)等物理机制,能够更真实地模拟金属、电介质等不同材质的光照行为,是现代游戏引擎的标准着色模型。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)