games101 二、着色
着色(Shading)
根据不同材质和光照条件,绘制物体的颜色,这个过程叫着色(Shading)。
着色是作用于一个个着色点(shading point)的。
Blinn-Phong 反射模型(Blinn-Phong Reflectance Model)
- Specular highlight(镜面高光)
- Diffuse reflection(漫反射)
- Ambient reflection(环境光反射)
shading定义

- Viewer direction \(\vec{v}\),观察者方向向量
- Surface normal \(\vec{n}\),表面法线向量
- Light direction \(\vec{l}\),光源方向向量
- Surface parameters,表面材质参数
- color
- Shininess
上面这些向量都是单位向量。
Shading考虑的是,在不同的光照和表面材质的作用下,表面上每个点的颜色是怎样的。
Shading只考虑一个物体自己和光线的交互,不考虑物体之间的相互影响,如阴影(Shadow)、反射(Reflection)等。
法线变换
我们已经知道,顶点位置需要经过MV变换后,再做反射计算,那么自然,法线也需要经过变换后才能参与反射计算。
但是法线不能直接用MV矩阵变换,因为法线是垂直于表面的向量,直接用MV矩阵变换后,可能不再垂直于表面。
为了保证法线变换后仍然垂直于表面,我们需要用MV矩阵的逆转置矩阵(Inverse Transpose Matrix)来变换法线。
逆转置矩阵,即:
漫反射(Diffuse Reflection)
漫反射模拟光线照射到粗糙表面上的效果。
漫反射的入射光线会均匀地向各个方向反射。
考虑光线接收
Lambert的余弦定律(Lambert's Cosine Law):
假设入射光线是平行的,物体表面的法线和入射光线夹角越大,单位表面接收到的光就越少,用 \(\theta\) 表示入射光线和法线的夹角,\(cos\theta=\vec{l} \cdot \vec{n}\)。
考虑光线发射
有一个点光源,我们可以以光源为中心,画出多个半径不同的球面,同一个球面上每个点的光照强度相同,不同球面的总光照强度相同,因此球面上的单个点的光照强度与距离光源的距离有关,距离越远强度越低。
定义 \(I\) 为半径为1的球面上某一点的光照强度(Intensity),球面总光照强度为 \(4\pi I\),那么距离光源 \(r\) 处的一点上光照强度为 \(I/r^2\)。
漫反射公式
由此可得一个shading point上的漫反射公式:
其中,\(k_d\) 为漫反射系数 (Diffuse coefficient),为1时表示完全反射,亮度最亮,为0时表示完全吸收,亮度最暗,当它为向量时,表示的就是颜色系数。
由于漫反射均匀地向各个方向反射,所以不管从哪个方向观测,看到的颜色都是一样的。
高光(Specular Highlight)
只有当观察方向和镜面反射方向接近时,才会看到高光,因此光照强度(Intensity)与观察方向有关。
观察方向和镜面反射方向接近也也意味着半程向量 \(\vec{h}\) 和法线方向接近,半程向量是指光线入射方向和观察方向的角平分线方向。
高光的反射公式为:
其中,\(\vec{h} \cdot \vec{n}\) 为 半程向量和法线的夹角余弦 \(\cos{\alpha}\),\(k_s\) 为高光系数(Specular coefficient),\(p\) 是为了加快 \(\cos{\alpha}\) 的衰减,\(p\) 的值较大时,夹角 \(\alpha\) 稍微大一点,\(\cos{\alpha}\) 的值就会降低很多,高光就会衰减很快,体现在视觉上就是高光比较集中。
Blinn-Phong 模型将光线接收项 \(\vec{l} \cdot \vec{n}\) 简化掉了。
环境光(Ambient Light)
环境光来自四面八方,我们假任何一个点接收到的环境光永远相同。
\(I_a\) 为环境光强度(Ambient Intensity),是一个恒定值,\(k_a\) 为环境光系数(Ambient coefficient)。
合起来
将三种光照模型合起来:
着色频率(Shading Frequency)
着色频率研究的是如何让渲染结果更精细,更平滑,比如,一个球体实际上是由很多三角形组成的,如果每个三角形只计算一个着色点,那么渲染出来的球体就会有棱角,为了解决这个问题,我们可以增加着色点的数量,比如在每个三角形内部进行插值计算,以提高着色频率,这样就能让渲染结果更平滑。
注意下面的方法提到了计算顶点法线,实际上,有些模型格式,例如obj格式,已经包含了顶点法线数据。
Flat Shading (Face)
一个三角形面只有一个法线,把三角形面视为一个平坦的平面。
Gouraud Shading (Vertex)
求出几个顶点的法线,计算出顶点的颜色,然后在三角形面内进行颜色插值。
Phong Shading (Pixel)
注意这个并不是Blinn-Phong反射模型。
求出几个顶点的法线,在三角形面内进行法线插值,然后计算出每个像素点的颜色。
题外话(一点思考)
使用顶点法线插值,不会改变几何,只是光照时让几何体表面显得更平滑。
这叫做法线平滑(Normal Smoothing)。
如果我有个比较复杂的几何体,我希望它某一部分的渲染使用面法线,让他的棱角更分明,而其他部分使用顶点法线插值,让他的表面更圆滑,图形学上有这种技术吗?或者说,这对于图形学来说,是个问题吗?
gpt告诉我:
有,并且非常常见。这不是问题,而是标准做法。
可用的技术与做法:
硬边/平滑组(Smoothing Groups)/法线拆分(Normal Split):在硬边处“拆分顶点”,给该边两侧三角形的顶点写入各自的面法线;其余区域用平滑顶点法线。大多数DCC/引擎支持(OBJ/FBX可存“面-顶点法线”)。 按角度阈值生成法线:导入时按夹角>阈值就断边,用面法线;否则合并求平均法线。 着色器里混合:在片元着色器同时拿到“插值法线”(平滑)和“几何面法线”,用掩码(顶点属性或纹理)选择或混合二者。
示例(GLSL 片元,世界或视空间一致):
// ...插值进来的数据...
in vec3 vPosWS; // 片元位置(世界/视空间)
in vec3 vNormalWS; // 插值后的平滑法线
in vec2 vUV; // 可选,用于区域掩码
uniform sampler2D uMask; // R=1 用面法线,R=0 用平滑
// ...existing code...
void main() {
vec3 N_smooth = normalize(vNormalWS);
vec3 N_flat = normalize(cross(dFdx(vPosWS), dFdy(vPosWS))); // 面法线
float w = texture(uMask, vUV).r; // 或者用顶点属性/材质开关
vec3 N = normalize(mix(N_smooth, N_flat, w));
// 用 N 做后续光照
}
其他要点:
- 用 flat 插值限定符也可让某个 varying 按“每面”传值,但要保证三顶点携带相同数据或用几何着色器/顶点复制。
- 硬边会增加顶点数;若用 dFdx/dFdy 求面法线,请确保用同一空间的 vPos,并注意导数在分支/多采样处的连续性。
- 若用法线贴图,平滑区应保持一致的切线空间(常用 MikkTSpace)。
结论:同一几何体中混合“面法线的硬朗”和“顶点法线的圆滑”是行业常规工作流,直接用硬边/平滑组或着色器掩码即可实现。
以上全部都是gpt告诉我的。
如何计算顶点的法线?
对顶点所在面的法线进行平均。
如何对法线插值?
Barycentric coordinates interpolation(重心坐标插值)
实时渲染管线(Real-time Rendering Pipeline)
- Vertex Processing,将顶点从模型空间变换到屏幕空间(MVP变换)
- Triangle Processing,将顶点划分为一个个三角形
- Rasterization,光栅化,将三角形离散化为一个个采样点(Fragments),抗锯齿
- Fragment Processing,根据采样结果对每个像素进行着色,深度测试 / 可见性测试
- Framebuffer Operations
着色(Shading)主要发生在Vertex Processing 和 Fragment Processing,控制着色的程序叫做 Shader。
图形管线运行在GPU上,充分利用GPU的并行计算能力。
Shader
Shader 需要用硬件上执行的语言来编写,比如 GLSL、HLSL 等。
Shader 只需要关心单个顶点或像素的着色,不需要写循环等控制结构。
Shader 分为两种:
- Vertex Shader,顶点着色器
- Fragment Shader,片段着色器/像素着色器
纹理映射(Texture Mapping)
纹理是对表面上的每个点赋予一个属性。
纹理映射是把一张二维的图片映射到三维物体表面上的过程,物体表面的每一个顶点都要对应纹理上的一个点。
纹理上的坐标系通常称为uv坐标系,u表示水平方向,v表示垂直方向。
Tilable texture(可平铺纹理),纹理在上下左右方向上重复使用的时候,起到一种无缝衔接的效果。
纹理映射只规定了三维顶点和二维纹理坐标之间的映射关系,而对三角形面内的某一点来说,它的纹理坐标需要通过重心坐标插值来计算。
重心坐标插值(Barycentric Coordinates Interpolation)
重心坐标插值可以用来做纹理坐标插值,法线插值,颜色插值等。
重心坐标的计算方法如下:
对于一个三角形 \(\triangle ABC\),以及三角形内的一个点 \(P\),我们可以将 \(P\) 表示为 \(A\),\(B\),\(C\) 的线性组合:
其中,\(\alpha + \beta + \gamma = 1\),且 \(\alpha, \beta, \gamma \geq 0\)。
由 \(\alpha\)、 \(\beta\)、\(\gamma\) 这三个系数构成的向量 \((\alpha, \beta, \gamma)\) 就是点 \(P\) 的重心坐标。

\(\alpha\)、 \(\beta\)、\(\gamma\) 可以通过以下方法求出(\(A\) 代表面积):
三角形的重心坐标为 \((\frac{1}{3}, \frac{1}{3}, \frac{1}{3})\),即,三角形的重心刚好将三角形分成三个等面积的小三角形。
假设点 \(P\) 的坐标为 \((x,y)\),下面是计算 \(\alpha\)、 \(\beta\)、\(\gamma\) 的公式(利用叉乘就可以推导出):
对于任意向量的插值,也是一样的:
由于在投影变换过程中,三角形会被拉伸变换,点的重心坐标会变,所以什么时候做插值,要区分清楚。
应用纹理
- 光栅化后,对屏幕上的每一个采样点进行纹理坐标插值,得到对应的纹理坐标(\(u,v\)),\(u,v\) 坐标的值通常在0~1之间。
- 着色时,将纹理图片上的颜色作为漫反射系数 \(k_d\)。
纹理反走样
纹理放大(Texture Magnification)和纹理缩小(Texture Minification)
纹理太大或太小,有可能发生在场景有一定深度时,近处的物体需要用大纹理,远处的物体需要用小纹理。
texture太小怎么办?
纹理图像中的一个像素叫做texel,纹理放大时需要对texel进行插值。
插值解决的是,给定一个非整数的纹理坐标,如何计算出对应的颜色。
Bilinear Interpolation(双线性插值)
如果是线性插值,很简单,比如我们想在 \(\vec{v_0}\) 和 \(\vec{v_1}\) 之间插值,插值系数为 \(x\),那么插值结果为:
双线性插值是在二维平面上的四个点构成的矩形区域内进行插值,实际上就是做三次线性插值,先在水平方向上做两次线性插值,然后在垂直方向上做一次线性插值。
texture太大怎么办?
texture太大,要把它映射到一个小的区域上,这个过程实际上是对纹理降低采样率采样,和覆盖测试类似,同样会出现走样问题(摩尔纹,锯齿等)。
从现象上看,出现走样是因为一个像素(即一个采样点)对应了一大块纹理区域。
解决方法: 1. 增加采样点 2. 不做采样,给定一个纹理区域,立刻得到这个区域的平均值
这实际上是两个理念的区别,Point Query 和 Range Query。
Mipmap
Mipmap可以用来做正方形范围查询(Range Query)
Mipmap的原理是这样的,在渲染之前预先生成一系列不同分辨率的纹理图像,然后在渲染时根据需要选择合适分辨率的纹理图像进行采样。
我们把每个分辨率的图像称为1层,用 D 表示,D0 层为原始图像,D1 层为原始图像宽高各减半后的图像,D2 层为 D1 层宽高各减半后的图像,以此类推,每个层的图像大小为上一层的四分之一。
经过级数求和,可以知道总共需要4/3倍的存储空间,也就是说,Mipmap 仅需要额外的 1/3 存储空间。
如何知道选择哪一层的图像?或者说,如何知道一个像素对应的纹理区域有多大?
选择一个像素相邻的两个像素,计算它们在纹理空间中的距离,距离越大,说明纹理区域越大,选择的层数就越高。
以下公式可以近似计算出像素对应的Mipmap层数D,L代表像素对应的纹理空间的边长,它是取像素分别距离x方向和y方向上的像素在纹理空间中的距离的最大值,其中,\(x\)和\(y\)代表屏幕空间的坐标,\(u\)和\(v\)代表纹理空间的坐标。
举个例子,假如算出来的L是4,意味着,一个像素对应的原始纹理(D0层)区域是4x4的,而D2层的一个像素正好对应原始纹理的4x4区域,因此,我们应该选择D2层的图像进行采样。
如果D不是整数怎么办?比如D=1.8,很简单,使用插值,先在D1层和D2层分别做双线性插值,然后再对结果做线性插值,这叫做三线性插值(Trilinear Interpolation)。
Mipmap的限制
Mipmap只能处理正方形区域的查询,不能处理任意形状的区域查询。
这会导致深度较大的场景中,远处的物体可能会过于模糊。
各向异性过滤(Anisotropic Filtering)

各项异性过滤预先计算出不同长宽比例的纹理图像,然后在渲染时根据需要选择合适的图像进行采样。
这叫Ripmap。
EWA Filtering
EWA Filtering(Elliptical Weighted Average Filtering,椭圆加权平均过滤)可以处理任意形状的区域查询。
它的原理是把一个不规则的纹理区域近似为多个椭圆,需要查询多次,然后对结果进行加权平均。
环境贴图(Environment Mapping)
环境贴图是为了模拟物体表面的反射效果。
它是把不同方向的环境光照信息预先保存到纹理中,然后在渲染时根据物体表面的法线方向,从纹理中采样出对应的环境光照信息,作为物体表面的反射光照。
假设环境光都是来自无限远处,只记录环境光的方向信息。
球面环境贴图(Spherical Environment Mapping)是将环境光照信息映射到一个球面上。
为了解决球面贴图展开为矩形时的扭曲问题,可以使用立方体贴图(Cubemap),将环境光照信息映射到一个立方体的六个面上。
凹凸贴图 / 法线贴图(Bump Mapping / Normal Mapping)
贴图中记录表面上每个点的相对高度信息,在渲染时根据高度调整表面法线,从而模拟出表面的凹凸效果。
如何计算一点 \(P\) 新的法线呢?
假设原始的法线为 \((0,0,1)\),高度函数为 \(h(u,v)\),\(u\) 和 \(v\) 是纹理坐标。
先求点 \(P\) 分别在 \(u\) 和 \(v\) 方向的导数,分别在 \(u\) 和 \(v\) 方向上移动一个单位长度,点 \(P\) 的高度分别为 \(h(u+1)-h(u)\) 和 \(h(v+1)-h(v)\),那么点 \(P\) 在 \(u\) 方向上的导数为,其中,\(c_1\) 和 \(c_2\) 分别是两个固定的系数:
根据导数,可以求出切线方向向量,然后可以求出法线方向向量:
注意这里的法线是局部空间的法线,需要经过逆转置矩阵变换到世界空间。
位移贴图(Displacement Mapping)
位移贴图同样也是为了模拟表面的凹凸效果,不同的是,位移贴图直接改变表面上每个点的位置,而不是改变法线。
位移贴图要求三角形网格足够密集,使得着色时的采样率能够跟得上贴图的顶点变化速度。
directx有一套近似机制,动态曲面细分(Dynamic Tessellation),可以在渲染时动态增加三角形网格的密度,而不需要建模时就建好密集的网格。
纹理的其他应用
- 三维噪声(3D Procedural Noise),是一种三维纹理。
- 环境光遮蔽(Ambient Occlusion),用于模拟环境光照射下在物体表面产生的阴影效果,比如物体表面倒映出屋子里的窗户,只有玻璃是亮的,其他地方是暗的。
- 用于体积渲染(Volume Rendering)的三维纹理(3D Texture)。
阴影贴图(Shadow Mapping)
如何运用光栅化技术来实现阴影效果?
答案是Shadow Mapping(阴影贴图)
- 在计算阴影的过程中,不需要知道场景中的几何信息
- 可能会产生走样问题
要点是只有当light和camera都能看到这个点的时候,我们才认为它不在阴影里,这个很好理解,camera能不能看到这个点决定这个点能不能着色,而light能不能看到这个点决定这个点着什么颜色。
Shadow mapping只能处理点光源。
只能生成硬阴影(Hard Shadow)
- 硬阴影:阴影的轮廓清晰,一个点要么在阴影里,要么不在阴影里
- 软阴影:阴影的轮廓比较模糊
如何实现
第一步,假设光源的位置有个相机,我们先用光源相机渲染一张深度图(Depth Map),深度图中记录的是从光源看到的每个像素点的深度值。
第二步,从真正的相机位置再次看向场景,对于每个像素点,拿到这个像素点对应的几何点,将这个点变换到光源相机空间,计算出这个点的深度值,然后比较这个深度值和深度图中的深度值之间的关系,如果光源和相机都能看到这个点,这个点不在阴影里,否则,这个点在阴影里。
这个方案存在几个问题:
- 深度图是有一个具体的分辨率的,可能会存在走样问题。
- 每个光源都会增加一次渲染过程,提高了计算成本。
软阴影
一个完全看不到光源的地方,叫本影(Umbra),一个部分看得到光源的地方,叫半影(Penumbra)。
这是个自然现象,在自然界中有很多例子,比如日食。
这是由于光源本身有一定大小导致的,如果是点光源,则不存在半影。