文章

TinyRenderer(四):Shading,从明暗到材质

TinyRenderer(四):Shading,从明暗到材质

上一篇我们把模型顶点从局部空间一路送到了屏幕空间:Model -> View -> Projection -> Perspective Divide -> Viewport。此时三角形已经能被光栅化器接住,Z-Buffer 也能决定每个像素最后归谁。

但是,只知道“哪个像素属于哪个三角形”还远远不够。一个像素最终应该是红色、蓝色、暗一点、亮一点,还是带有镜面高光?这些问题不属于光栅化本身,而属于 Shading(着色)

本文参考 tinyrenderer 的 shading 章节,按“问题、模型、算法、代码”的顺序展开:什么是 shading,为什么需要 shading,常见 shading 算法之间有什么区别,以及这些概念如何落到 TinyRenderer 的代码里。

本文会介绍 Flat、Gouraud、Phong 和 Blinn-Phong 四种 shading 思路,并把它们都接进 TinyRenderer 的 shader 管线。


1. 什么是 Shading

在光栅化渲染器里,渲染过程大致可以拆成两个问题:

  1. 几何问题:一个三角形覆盖了屏幕上的哪些像素?
  2. 外观问题:被覆盖的这些像素应该显示什么颜色?

前者由光栅化和深度测试处理,后者就是 shading 要解决的事情。

更具体地说,shading 是根据物体表面的几何信息、材质信息、光照信息和相机位置,计算屏幕上每个可见点颜色的过程。一个片段最终的颜色可能取决于很多因素:

  • 表面本身的颜色,也就是 albedo 或漫反射颜色。
  • 表面朝向,也就是法线 $\vec n$。
  • 光源方向 $\vec l$ 和光源强度。
  • 相机方向 $\vec v$,尤其是计算高光时。
  • 纹理坐标 uv,用于从贴图中采样颜色。
  • 材质参数,例如粗糙度、镜面反射强度、高光指数等。

如果把光栅化理解为“把三角形变成片段”,那么 shading 就是“让片段拥有颜色”。这里的片段可以先理解为光栅化产生的候选像素;它通过深度测试后,才会成为屏幕上最终可见的像素。

这里还要区分两个容易混淆的词:

  • Shading:计算颜色的过程或阶段。
  • Shader:执行 shading 逻辑的程序或接口。

在现代 GPU 中,shader 是真正运行在硬件上的小程序;在软件渲染器中,shader 通常就是 C++ 里的一个结构体或类。二者形式不同,但抽象上非常接近:顶点阶段准备数据,片段阶段计算颜色。


2. 为什么需要 Shading

如果没有 shading,渲染器仍然可以画出模型,但画面会非常贫乏。

最朴素的做法是给每个三角形一个固定颜色。这样只能看到模型的轮廓和遮挡关系,却很难感受到三维形体。一个球如果所有像素都是同一种颜色,它看起来就像一个平面圆片;一个人脸如果只有贴图没有光照,鼻梁、眼窝、下巴的空间关系也会显得很弱。

人眼判断三维形状,很大程度上依赖明暗变化。表面朝向光源时更亮,背向光源时更暗;光滑材质会出现高光,粗糙材质则高光分散甚至不可见。shading 正是用这些视觉线索,让二维屏幕上的像素重新带回三维空间感。

从渲染管线角度看,shading 也让渲染器获得了可扩展性。光栅化器不应该关心“颜色从哪里来”,因为颜色可能来自:

  • 固定纯色。
  • 顶点颜色。
  • 纹理采样。
  • 漫反射光照。
  • 镜面高光。
  • 法线贴图。
  • 阴影。
  • 更复杂的 PBR 材质模型。

如果每增加一种效果,就写一个新的 DrawTriangleWithXXX,代码很快会失控。更合理的设计是:光栅化器只负责遍历片段、计算重心坐标、做深度测试;颜色计算交给 shader。

这也是 shader 抽象存在的原因。它并不是为了模拟 OpenGL 的语法,而是为了把渲染器从“固定功能代码”推进到“可编程管线”的思维方式。


3. 光照模型:从环境光、漫反射到高光

讨论具体 shading 算法前,先建立一个简单的光照模型。最经典、也最适合入门的是 Phong 反射模型。它把光照拆成三部分:

\[I = I_{ambient} + I_{diffuse} + I_{specular}\]

也就是:

  1. 环境光(Ambient):让背光区域不至于完全死黑。
  2. 漫反射(Diffuse):描述粗糙表面被光照亮后的主体明暗。
  3. 镜面反射(Specular):描述光滑表面上的高光。

环境光

环境光的直觉来自现实中的间接光。房间里即使只有一扇窗,背对窗户的桌腿也不会完全漆黑,因为光会在墙面、地板、天花板之间多次反弹,最后从各个方向照到物体上。

完整模拟这些反弹需要全局光照,教学渲染器通常先用一个常数近似它:不考虑表面朝向,也不考虑光从哪里来,只给所有可见点一个最低亮度。

\[I_{ambient}=k_a I_a\]

其中 $k_a$ 是材质的环境光系数,$I_a$ 是环境光强度。它不是一个真实的物理计算,更像是“场景里到处都有一点散射光”的简化假设。没有全局光照时,如果完全不加环境光,模型背光面会直接黑成一片,细节全部丢失。

漫反射

漫反射的直觉来自粗糙表面。粉笔、纸张、未抛光的木头都不是镜子,光打到表面后会向很多方向散开,所以从不同方向看,表面颜色变化不大。真正决定亮度的,是这块表面“迎着光”还是“斜着吃光”。

可以想象同一束光照到两块面积相同的平面:一块正对光源,光能集中落在这块面积上;另一块斜着摆,同样的光被摊到更大的投影区域里,单位面积得到的能量就少了。这个“摊开”的比例正好由表面法线和光照方向的夹角决定,也就是 Lambert 余弦定律。

数学上就是法线方向和光照方向的点积:

\[I_{diffuse}=k_d I_l \max(0,\vec n\cdot \vec l)\]

这里 $\vec n$ 是单位法线,$\vec l$ 是从表面点指向光源的单位方向。后面的 $\vec v$ 也采用同样约定:它表示从表面点指向相机的单位方向。点积的结果就是二者夹角的余弦:

\[\vec n\cdot \vec l=\cos\theta\]

当 $\theta=0^\circ$ 时,表面正对光源,亮度最高;当 $\theta=90^\circ$ 时,光线擦着表面过去,亮度为 0;当夹角超过 $90^\circ$ 时,光在表面背面,所以用 max(0, ...) 截断。

漫反射是最重要的一项。即使只做这一项,模型也会立刻从“平面剪影”变成有体积感的物体。

镜面反射

镜面反射的直觉来自光滑表面。镜子、金属、抛光塑料不会把光均匀散到所有方向,而是更偏向沿某个方向集中反射。于是高光不只取决于表面有没有被光照到,还取决于观察者是不是站在“反射光刚好射过来”的方向上。

这也是镜面反射和漫反射最重要的区别:漫反射只看表面朝不朝向光源;镜面反射还要看相机在哪里。你移动视角时,物体的 diffuse 主体明暗变化不大,但高光会在表面上移动。

Phong 模型常见写法是:

\[I_{specular}=k_s I_l \max(0,\vec r\cdot \vec v)^s\]

本文使用 $\vec l$ 表示从表面点指向光源,所以入射光方向是 $-\vec l$。$\vec r$ 是这个入射方向关于法线反射后的方向:

\[\vec r=reflect(-\vec l,\vec n)\]

点积 $\vec r\cdot \vec v$ 衡量“相机方向和理想反射方向有多接近”:越接近 1,高光越亮;越接近 0,高光越弱。

$s$ 是高光指数,用来模拟表面光滑程度。$s$ 越大,只有非常接近反射方向的视角才能看到高光,因此高光更小、更锐利;$s$ 越小,允许偏离反射方向的范围更大,因此高光更宽、更柔和。

Blinn-Phong 对镜面反射做了一个常用变体:不直接比较反射方向 $\vec r$ 和视线方向 $\vec v$,而是先计算光照方向和视线方向的半程向量 $\vec h$:

\[\vec h = normalize(\vec l + \vec v)\]

然后比较法线和半程向量:

\[I_{specular}=k_s I_l \max(0,\vec n\cdot \vec h)^s\]

它的直觉是:如果某个表面的法线正好接近光线和视线的中间方向,这个表面就能把光反射到相机。Blinn-Phong 避免了显式计算反射方向,写法更直接,半程向量的思路也更接近后续微表面模型。

把三项放在一起,就得到完整的 Phong 反射模型:

\[I = k_a I_a + k_d I_l \max(0,\vec n\cdot \vec l) + k_s I_l \max(0,\vec r\cdot \vec v)^s\]

实际渲染时,还会把这个光照强度乘到物体本身的颜色或纹理颜色上:

\[color = albedo \times (I_{ambient}+I_{diffuse}) + color_{light} \times I_{specular}\]

实现里还会加一个判断:只有 $\vec n\cdot\vec l>0$,也就是表面确实朝向光源时,才计算镜面高光。否则背光面可能出现不合理的亮斑。

这就是后面各种 shading 算法的共同基础。它们的区别不在于“有没有光照公式”,而在于这个公式是在三角形、顶点还是片段上计算。


4. Flat Shading:一个三角形一个亮度

Flat shading(平面着色) 是最简单的算法:每个三角形只计算一次法线和光照,然后整个三角形使用同一个颜色或亮度。

三角形的面法线可以由两个边向量叉乘得到:

\[\vec n_{face}=normalize((v_2-v_0)\times(v_1-v_0))\]

再用面法线计算漫反射:

\[I=\max(0,\vec n_{face}\cdot \vec l)\]

最后整个三角形都使用同一个光照结果:

\[color = baseColor \times (I_{ambient}+I_{diffuse})\]

Flat shading 的优点是极其简单,而且能清楚显示模型的三角形结构。对于低多边形风格、硬表面模型、调试法线和背面剔除,它都很好用。

缺点也很明显:相邻三角形通常有不同的面法线,因此亮度会在三角形边界处突然跳变。一个本应光滑的人脸或球体,会呈现明显的“块面感”。


5. Gouraud Shading:在顶点上算光照

Flat shading 的问题来自“一个三角形只有一个法线”。如果模型想表达光滑曲面,通常会在 OBJ 这类模型文件中提供顶点法线。顶点法线不是某个三角形的面法线,而是多个相邻面的平均方向,用来表达光滑表面在该顶点处的朝向。

Gouraud shading 的思路是:在每个顶点上计算光照,然后在三角形内部插值这个光照结果。

为了先看清主线,可以从顶点漫反射写起。设三角形三个顶点法线为 $\vec n_0,\vec n_1,\vec n_2$,先分别计算三个顶点的漫反射强度:

\[I_0=\max(0,\vec n_0\cdot \vec l)\] \[I_1=\max(0,\vec n_1\cdot \vec l)\] \[I_2=\max(0,\vec n_2\cdot \vec l)\]

对于三角形内部某个片段,光栅化器可以得到它的重心坐标 $(\alpha,\beta,\gamma)$,于是片段亮度为:

\[I_p=\alpha I_0+\beta I_1+\gamma I_2\]

如果还有纹理,就先插值得到 UV,再采样纹理:

\[uv_p=\alpha uv_0+\beta uv_1+\gamma uv_2\] \[color_p=texture(uv_p)\times I_p\]

如果 Gouraud shader 也要表现镜面高光,做法仍然一样:在三个顶点上先用 Phong 反射模型算出 $S_0,S_1,S_2$,再插值得到 $S_p$。本文的 TinyRenderer 实现也这样处理。Gouraud 不是不算高光,而是不在片段内部重新寻找高光。

Gouraud shading 比 flat shading 平滑很多,因为亮度在三角形内部连续变化,相邻三角形共享顶点时也更容易保持连续。

但它有一个经典问题:高光可能丢失。镜面高光往往很小,如果高光峰值落在三角形内部,而三个顶点都没有落在高光区域,那么 Gouraud shading 只能插值三个较弱的顶点高光,片段内部不会重新计算光照,也就“发现不了”这块高光。

所以 Gouraud shading 的计算量低,效果比 flat 好,但对高光不够可靠。


6. Phong Shading:在每个片段上算光照

Phong shading 解决了 Gouraud shading 的核心问题。它不是插值已经算好的亮度,而是插值法线,然后在每个片段上重新计算光照。

给定重心坐标 $(\alpha,\beta,\gamma)$,先插值得到片段法线:

\[\vec n_p=normalize(\alpha \vec n_0+\beta \vec n_1+\gamma \vec n_2)\]

然后在片段上计算 Phong 反射模型:

\[I_p = k_a I_a + k_d I_l \max(0,\vec n_p\cdot \vec l) + k_s I_l \max(0,\vec r_p\cdot \vec v_p)^s\]

如果使用纹理,片段颜色可以写成:

\[color_p = texture(uv_p)\times (I_{ambient}+I_{diffuse}) + color_{light}\times I_{specular}\]

Phong shading 的效果明显优于 Gouraud shading,尤其是高光会更稳定、更细腻。代价也很直接:每个片段都要做法线归一化、点积、反射、指数运算,计算量更大。

这也正是现代 GPU 擅长的事情。顶点数量通常远少于片段数量,但 GPU 有大量并行执行单元,可以同时处理海量 fragment shader。软件渲染器用 CPU 写这一步,会更直观地感受到“更好的画面往往意味着更多计算”。


7. Blinn-Phong Shading:用半程向量算高光

Blinn-Phong shading 和 Phong shading 的主体流程相同:都在片段上插值法线,都在片段上重新计算漫反射和镜面反射。区别只在镜面高光的计算方式。

Phong 使用反射方向:

1
2
R = reflect(-L, N);
specular = pow(max(0, dot(R, V)), shininess);

Blinn-Phong 使用半程向量:

1
2
H = normalize(L + V);
specular = pow(max(0, dot(N, H)), shininess);

这里的 H 位于光照方向 L 和视线方向 V 中间。法线 N 越接近 H,说明这个表面越接近“刚好能把光反射到相机”的朝向,高光越强。

同样的 shininess 下,Blinn-Phong 的高光通常比 Phong 更宽,所以实践中经常给 Blinn-Phong 使用更大的指数。本文实现中,Phong 使用 32,Blinn-Phong 使用 64,让两者的高光范围更接近。

为了比较这几种 shading,可以简单总结为:

算法计算位置插值内容优点缺点
Flat Shading每个三角形不插值光照简单,适合硬表面和调试块面感明显
Gouraud Shading每个顶点插值光照结果平滑,计算较少小高光可能丢失
Phong Shading每个片段插值法线高光稳定,效果更好计算量较高
Blinn-Phong Shading每个片段插值法线避免反射向量,常用且便于扩展高光范围需要重新调参

8. Shading 离不开插值

前面多次提到“插值”,这正是光栅化 shading 的核心桥梁。

光栅化器遍历三角形内部像素时,会为每个片段计算重心坐标:

\[p=\alpha v_0+\beta v_1+\gamma v_2,\qquad \alpha+\beta+\gamma=1\]

只要某个属性定义在三个顶点上,就可以用同样的权重插值到片段:

\[a_p=\alpha a_0+\beta a_1+\gamma a_2\]

这里的 $a$ 可以是 UV、颜色、亮度、法线,甚至世界空间坐标。深度也会参与插值和测试,只是 TinyRenderer 存的是投影后的 z

这也是为什么 shader 接口通常会把重心坐标交给 fragment shader。光栅化器负责算出“这个片段在三角形里的相对位置”,shader 再决定要用这个位置插值什么属性。

在透视投影下,UV、法线、世界坐标等属性严格来说不能只做屏幕空间线性插值,而应该做透视校正插值(perspective-correct interpolation)。上一篇讲透视除法时提到过,GPU 在光栅化阶段仍然会保留和 $w$ 有关的信息,就是为了正确插值这些属性。

TinyRenderer 当前实现为了教学简洁,UV、法线和世界坐标仍然使用直接重心插值。它足够展示 shading 主线,但如果模型离相机很近、透视变形很强,纹理和高光都可能出现轻微扭曲。后续如果要继续完善渲染器,透视校正插值会是绕不开的一步。


9. TinyRenderer 的四种 shader 模式

我们的 TinyRenderer 已经有了 shading 所需的大部分零件:OBJ 中的 UV / 法线、屏幕空间重心坐标、Z-Buffer、TGA 纹理采样,以及基础的 shader 接口。现在把四种 shading 模式都接进来。

shader 接口只保留片段阶段。光栅化器算出重心坐标后,把它交给当前 shader,由 shader 决定这个片段的最终颜色:

1
2
3
4
5
6
7
struct IShader
{
    virtual ~IShader() = default;
    virtual bool Fragment(
        const math::Vec3f& barycentric,
        math::Color& color) = 0;
};

首先在配置里增加一个 shading 枚举:

1
2
3
4
5
6
7
8
9
10
11
12
struct Config
{
    enum Shading
    {
        flat,
        gouraud,
        phong,
        blinnPhong
    };

    Shading shading = flat;
};

四种 shader 都共享纹理采样、颜色混合、向量插值和向量归一化等基础逻辑,所以先抽出一个 BaseLightingShader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct BaseLightingShader : IShader
{
protected:
    math::Color SampleTexture(const math::Vec3f& barycentric) const;
    math::Color ApplyLighting(
        const math::Color& baseColor,
        float diffuse,
        float specular = 0.f) const;

    static math::Vec3f InterpolateVec3(
        const math::Vec3f* values,
        const math::Vec3f& barycentric);
    static math::Vec3f NormalizeSafe(const math::Vec3f& value);
    static math::Vec3f Reflect(
        const math::Vec3f& incident,
        const math::Vec3f& normal);
};

ApplyLighting 把环境光、漫反射和镜面高光合成到最终颜色里:

1
2
3
4
5
6
float diffuseTerm =
    AmbientStrength + DiffuseStrength * clamp(diffuse, 0, 1);
float specularTerm =
    SpecularStrength * clamp(specular, 0, 1);

color = baseColor * diffuseTerm + white * specularTerm;

这样每个具体 shader 主要提供两个值:diffusespecular

Flat shader

Flat shader 仍然使用三角形面法线,每个三角形只算一次 diffuse:

1
2
3
float diffuse = max(0, dot(faceNormal, lightDir));
FlatLambertShader shader(diffuse, vt, texture);
DrawTriangle(tri, width, height, shader);

片段阶段只负责采样贴图并应用这个固定亮度:

1
2
3
4
5
bool FlatLambertShader::Fragment(Vec3f bar, Color& color)
{
    color = ApplyLighting(SampleTexture(bar), m_diffuse);
    return false;
}

Gouraud shader

Gouraud shader 使用 OBJ 顶点法线。构造 shader 时,先在三个顶点上分别计算 diffuse 和 specular:

1
2
3
4
5
6
7
for (int i = 0; i < 3; i++)
{
    Vec3f normal = normalize(normals[i]);
    m_diffuse[i] = max(0, dot(normal, lightDir));
    m_specular[i] =
        m_diffuse[i] > 0 ? ComputeSpecular(normal, worldPositions[i]) : 0;
}

到了片段阶段,只插值这三个顶点光照结果:

1
2
3
4
5
6
7
8
9
10
11
float diffuse =
    bar.x * m_diffuse[0] +
    bar.y * m_diffuse[1] +
    bar.z * m_diffuse[2];

float specular =
    bar.x * m_specular[0] +
    bar.y * m_specular[1] +
    bar.z * m_specular[2];

color = ApplyLighting(SampleTexture(bar), diffuse, specular);

这正是 Gouraud 的特征:光照在顶点上算,片段里只插值结果。

Phong shader

Phong shader 保存三个顶点法线和三个世界坐标。片段阶段先插值法线和世界坐标,再重新计算 diffuse 和 specular:

1
2
3
4
5
6
7
Vec3f normal = normalize(interpolate(normals, bar));
Vec3f worldPosition = interpolate(worldPositions, bar);

float diffuse = max(0, dot(normal, lightDir));
float specular =
    diffuse > 0 ? ComputeSpecular(normal, worldPosition) : 0;
color = ApplyLighting(SampleTexture(bar), diffuse, specular);

Phong 高光使用反射方向:

1
2
3
4
5
Vec3f viewDir = normalize(cameraPos - worldPosition);
Vec3f reflectDir = normalize(reflect(-lightDir, normal));

float specular =
    pow(max(0, dot(reflectDir, viewDir)), PhongShininess);

这一步让高光真正变成逐片段计算,不再依赖三角形顶点有没有落在高光区域里。

Blinn-Phong shader

Blinn-Phong 和 Phong 使用同样的插值法线、插值世界坐标和 diffuse 计算。区别只在高光函数:

1
2
3
4
5
Vec3f viewDir = normalize(cameraPos - worldPosition);
Vec3f halfDir = normalize(lightDir + viewDir);

float specular =
    pow(max(0, dot(normal, halfDir)), BlinnPhongShininess);

本文实现中,Phong 的 shininess 使用 32,Blinn-Phong 使用 64,因为同样指数下 Blinn-Phong 的高光通常更宽。

渲染循环中的选择

主循环为每个三角形准备屏幕坐标、世界坐标、UV 和世界空间法线,然后根据配置选择 shader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
switch (m_config.shading)
{
case Config::gouraud:
{
    GouraudShader shader(worldNormals, worldPositions,
                         lightDir, viewPos, vt, texture);
    DrawTriangle(tri, width, height, shader);
    break;
}
case Config::phong:
{
    PhongShader shader(worldNormals, worldPositions,
                       lightDir, viewPos, vt, texture);
    DrawTriangle(tri, width, height, shader);
    break;
}
case Config::blinnPhong:
{
    BlinnPhongShader shader(worldNormals, worldPositions,
                            lightDir, viewPos, vt, texture);
    DrawTriangle(tri, width, height, shader);
    break;
}
case Config::flat:
default:
{
    float diffuse = max(0, dot(faceNormal, lightDir));
    FlatLambertShader shader(diffuse, vt, texture);
    DrawTriangle(tri, width, height, shader);
    break;
}
}

DrawTriangle 本身没有变化:它仍然负责 bounding box、inside test、重心坐标和 Z-Buffer,然后调用当前 shader 的 Fragment

1
2
3
4
5
6
7
8
9
10
11
if (inteZ < m_zBuffer[offset])
{
    math::Color color;
    if (shader.Fragment(Vec3f(alpha, beta, gamma), color))
    {
        continue;
    }

    m_zBuffer[offset] = inteZ;
    writeFramebuffer(x, y, color);
}

现在 TinyRenderer 的结构已经更接近现代 GPU 管线:

阶段负责什么
Vertex Shader坐标变换,准备要插值的顶点属性
Rasterizer判断三角形覆盖哪些像素,计算重心坐标
Depth Test处理遮挡,只保留更近片段
Fragment Shader插值属性,计算最终颜色
Framebuffer写入颜色结果

这也是 shading 这一章最值得带走的东西:它不仅是一组光照公式,更是一种组织渲染器的方式。光栅化器回答“哪里可见”,shader 回答“看起来像什么”。当这两个问题被清楚分开时,Flat、Gouraud、Phong、Blinn-Phong 都只是不同的 Fragment 实现,而不是四套光栅化器。


小结

本文先梳理 shading 的基本概念,再把四种 shading 模式接回 TinyRenderer 的代码实现:

  1. Shading 是计算可见片段颜色的过程,它把几何、材质、光照和相机信息结合起来。
  2. 需要 shading 是因为轮廓和遮挡不足以表达三维形体,明暗和高光才是人眼理解形状的重要线索。
  3. Phong / Blinn-Phong 反射模型把光照拆成环境光、漫反射和镜面反射三项,并用不同方式计算高光。
  4. Flat、Gouraud、Phong、Blinn-Phong shading 的主要区别,是光照分别在三角形、顶点还是片段上计算,以及高光用反射向量还是半程向量。
  5. 重心坐标插值是从顶点属性走向片段属性的桥梁。
  6. TinyRenderer 的 shader 实现统一了纯色和贴图路径,让 DrawTriangle 调用 shader 计算片段颜色,并支持 Flat、Gouraud、Phong 和 Blinn-Phong 模式切换。

至此,TinyRenderer 已经不只是能把三角形画到屏幕上,而是开始具备可切换的“材质”和“光照”表达能力。后续继续加入法线贴图、阴影或 PBR 时,也可以沿着同一条 shader 管线扩展。

本文由作者按照 CC BY 4.0 进行授权