/ Ch05 表面着色

第5章:表面着色 — 零基础讲义

讲义说明
本讲义基于 Steve Marschner & Peter Shirley 所著《虎书5》第5版第5章(p.97-122)。
表面着色(Surface Shading)是计算机图形学中"画颜色"的核心——它回答了一个看似简单的问题:给定一个表面点,它应该是什么颜色?
本版插画采用 Guizang 材质插画风格重新绘制。

学习目标

  1. 理解"着色"的本质——从几何到颜色的映射
  2. 掌握Lambert漫反射模型的数学直觉和代码实现
  3. 掌握Blinn-Phong高光模型——半程向量的巧妙设计
  4. 理解逐顶点 vs 逐像素着色的工程权衡
  5. 了解环境光和阴影的简单实现
  6. 建立从经验模型(Blinn-Phong)到物理模型(PBR)的过渡认知

前置知识:向量运算必读

在正式进入着色之前,我们必须先掌握几个基本数学工具。如果你已经有基础,可以直接跳过;如果你是零基础,请仔细阅读这一节。

1. 法线(Normal)

法线是垂直于表面的单位向量。它是着色中最基础的概念——所有的光照计算都依赖于法线。

n = (p - c) / ||p - c||(球体表面法线)
类型法线方向举例
平面法线垂直于平面水平地面法线 = (0, 1, 0)
球面法线从球心指向表面点p处的法线 = normalize(p - 球心)
曲面法线垂直于切平面在光滑曲面上每个点的法线都不同

2. 点积(Dot Product)

两个向量的点积返回一个标量(单个数值)。这是着色中最重要的运算——没有之一。

a·b = ||a|| · ||b|| · cosθ
n·l的几何意义 — 法线与光线方向的夹角余弦
图5-1:n·l的几何意义——法线n与光线方向l的夹角余弦,决定着色的核心计算
用法公式几何意义
求夹角cosθ = (a·b)/(||a||·||b||)两个方向之间夹角的余弦值
投影长度a在b上的投影 = a·(b/||b||)一个向量在另一个方向上的"分量"
判断朝向a·b > 0两向量夹角 < 90°(朝向同一侧)
判断背向a·b < 0两向量夹角 > 90°(朝向反侧)
关键直觉:当两个向量都是单位长度时,a·b 就是 cosθ。这个值在θ=0°时为1(完全对齐),θ=90°时为0(垂直),θ=180°时为-1(完全相反)。

3. 方向关系——着色中的"四大方向"

着色涉及四个关键方向向量,理解它们的关系是理解本章全部内容的前提:

符号名称含义
n法线垂直于表面的单位向量,指向表面外侧
l光线方向从表面点指向光源的单位向量
v视线方向从表面点指向相机/眼睛的单位向量
h半程向量h = (l + v) / ||l + v||,光源和视线之间的"中间方向"
        光源              相机
          ↑ l              ↑ v
           \              /
            \            /
             \          /
              \   h    /
               \  ↑   /
                \ |  /
                 \| /
    ────────────●──────→ 表面
                  |
                  | n(法线)
                  ↓

5.1 什么是着色(Shading)?

5.1.1 着色的核心问题

着色 = 决定一个表面点应该呈现什么颜色

为什么着色是一个独立的问题?
光线追踪回答了"光线碰到哪里了"(几何问题),但同一点因材质不同可以呈现完全不同的颜色。着色回答的是"碰到的点看起来是什么样"(光学问题)。

一个生活类比:想象你用手电筒照一个白色墙壁、一面镜子和一块红砖。三个表面在同一个位置,但呈现完全不同的颜色和质感。这是因为每个表面的"材质"对光的反应不同——这就是着色要计算的。

材质视觉特征物理机制
木质/石膏漫反射(各个方向亮度相同)光线被吸收后从微观凹凸中均匀散射
金属强反射,颜色为反射色自由电子立即反射光线,仅有高光
塑料漫反射底色 + 白色高光底层漫反射 + 表层光滑反射

5.1.2 局部着色 vs 全局光照

渲染中的"着色"包含两个层次:

  1. 局部着色(Local Shading):只考虑表面点、光源和相机的直接关系。本章全部内容。
  2. 全局光照(Global Illumination):考虑光线在场景中的多次弹射和间接照明。第4章的光线追踪和第23章的路径追踪处理此问题。
重要区分:局部着色计算的是"点本身看起来是什么颜色",而全局光照计算的是"点接收到的总光量"。两者结合才是最终的渲染颜色。

5.2 Lambert漫反射模型

5.2.1 什么是漫反射?

漫反射(Diffuse Reflection)是光线照射到粗糙表面后,向所有方向均匀反射的现象。这是我们在日常生活中看到的大多数物体(墙壁、衣服、纸张、石头)呈现颜色的方式。

核心概念:漫反射的颜色不随观察角度变化——无论你从哪个方向看,同一表面的亮度相同。
Lambert漫反射—光线均匀散射
图5-2:Lambert漫反射——光线照射到粗糙表面后向所有方向均匀散射

5.2.2 兰伯特余弦定律(Lambert's Cosine Law)

漫反射的亮度由兰伯特余弦定律描述:

L_diffuse = k_d · I_light · max(0, n·l)

这个公式到底在说什么?我们逐项拆解:

符号名称具体含义单位/范围
L_diffuse漫反射亮度最终呈现的漫反射颜色,RGB三通道值[0, 1]³
k_d漫反射系数表面自身的颜色/反射率(又称albedo、漫反射反照率)[0, 1]³(RGB)
I_light入射光强度光源在表面处的辐射强度[0, ∞)(RGB)
n表面法线垂直于表面的单位向量,指向外侧单位向量
l光源方向从表面指向光源的单位向量单位向量
n·l点积法线与光线方向夹角的余弦值 = cosθ[-1, 1]
max(0, ...)裁剪负值只考虑来自表面"正面"的光,背面光源不计[0, 1]

5.2.3 直观理解:为什么n·l决定了亮度?

想象一束平行光照射一个单位面积的正方形:

阳光直射(θ=0°):          阳光斜射(θ=60°):
      |                          /
      |                         /  光线束
      |                        /
    ●───── 表面              ●───── 表面
    cos0° = 1                 cos60° = 0.5
    全部能量集中在1单位面积   相同能量分散到2单位面积
    → 表面亮                   → 表面暗

核心直觉:相同的光通量,照射的面积越大,单位面积接收到的光越少。而n·l正好等于cosθ,衡量了"光线在法线方向的有效能量"。这解释了为什么中午的太阳比傍晚的更亮——因为太阳直射(θ小)时,每平方米地面接收的能量更多。

🤔 想一想:为什么Lambert模型中没有视角方向v?
因为漫反射的光线均匀地向所有方向散射——无论你从哪个角度看,亮度完全一样。这就是"漫"的含义——"散漫"地、均匀地向四面八方反射。所以Lambert模型不需要视角方向v,这是它与高光模型的根本区别。

5.2.4 完整的Lambert着色函数(逐行中文注释)

function lambert_shade(surface_point, normal, light_pos, light_color, kd):
    // === 第1步:计算指向光源的单位方向 ===
    // light_pos - surface_point 是从表面指向光源的向量
    // 必须归一化,这样点积的结果才等于cosθ
    l = normalize(light_pos - surface_point)
    
    // === 第2步:计算漫反射因子 ===
    // n·l = cosθ,衡量光线垂直入射的程度
    // max(0, ...) 剔除了从背面照来的光
    n_dot_l = dot(normal, l)
    diffuse_factor = max(0.0, n_dot_l)
    
    // === 第3步:计算最终漫反射颜色 ===
    // kd × light_color = 在光源颜色下看到的基础色
    // × diffuse_factor = 根据入射角度衰减
    diffuse_color = kd * light_color * diffuse_factor
    
    return diffuse_color

5.2.5 数值例子

假设红色球体(k_d = (1, 0, 0)),白色光源(I_light = (1, 1, 1)):

角度θn·l = cosθdiffuse_factor最终颜色物理含义
0°(直射)1.01.0(1, 0, 0)最亮,100%能量
30°0.8660.866(0.866, 0, 0)略暗
60°0.50.5(0.5, 0, 0)一半亮度
90°(平行)0.00.0(0, 0, 0)全黑,光线完全平行于表面
120°(背面)-0.50.0(0, 0, 0)max(0,...)裁剪为0,无光照

5.2.6 多点光源的漫反射

实际场景中通常有多个光源,每个光源独立计算漫反射后累加:

function lambert_multi_light(surface_point, normal, lights, kd):
    // 初始化为黑色(无光照)
    final_color = Color(0, 0, 0)
    
    // 遍历每个光源,累加它们的贡献
    for light in lights:
        // 每个光源独立计算Lambert着色
        l = normalize(light.position - surface_point)
        n_dot_l = max(0.0, dot(normal, l))
        
        // 累加当前光源的漫反射贡献
        final_color += kd * light.color * n_dot_l
    
    // 注意:累积可能超过1.0,需要clamp
    return clamp(final_color, 0.0, 1.0)
物理直觉:多个光源叠加时,颜色是相加的。一个红色球体被一个白光和一个蓝光照亮时,同时收到(1,0,0)和(0,0,1)的贡献,所以接受光照的一半呈紫色混合。

5.3 Blinn-Phong高光模型

5.3.1 从漫反射到高光——为什么需要它?

Lambert模型能很好地表现粗糙材质,但你肯定见过光滑表面的高光点(Specular Highlight)——比如塑料球上的那个亮白点、金属表面的明亮反光。这些高光不能用漫反射解释,因为它们:

  • 依赖于观察角度——高光会随你的移动而变化
  • 通常呈白色(对绝缘体而言)——高光是光源的颜色,不是材质的颜色
  • 范围集中——只在特定角度附近出现,不像漫反射那样均匀
Blinn-Phong半程向量h=(l+v)/2
图5-3:Blinn-Phong半程向量——h = (l+v)/2,当h与n对齐时产生最强高光

5.3.2 从Phong到Blinn-Phong——一段历史

Phong模型(1975年):最早的高光模型之一。

L_specular_Phong = k_s · I_light · max(0, v·r)^p

其中 r 是反射方向:r = 2(n·l)n - l(光线被表面反射后的方向)。

问题是:计算反射方向 r 需要两次点积和一次向量加减,在1970年代的计算机上开销不小。

Blinn-Phong模型(1977年,改进版)
Jim Blinn(没错,就是那个电影《星际迷航2:可汗之怒》的视觉特效总监)提出了一个巧妙的改进——用半程向量 h 代替反射方向 r:

L_specular = k_s · I_light · max(0, n·h)^p

5.3.3 半程向量h的几何直觉

半程向量定义为:

h = (l + v) / ||l + v||

h 是光线方向 l 和视线方向 v 的"中间方向"——如果法线 n 恰好与 h 对齐,那么根据反射定律,视线方向 v 恰好能看到光源的反射像。

         h = normalize(l + v)
                  ↑
                 /
                /
    l ←───────●──────→ v
    光源方向   |     观察方向
              n(法线)
比较项原始Phong(r = 反射方向)Blinn-Phong(h = 半程向量)
计算方式需要计算反射方向r只需要l+v然后归一化
点积运算v·rn·h
高光对齐条件v与r对齐(即v正好看到反射)n与h对齐(即法线在l和v中间)
物理真实度高光偏硬更柔和,更接近真实
性能略慢(需额外计算r)略快

5.3.4 光泽指数p对高光的影响

指数 p(shininess exponent,也称光泽指数)控制高光的聚散程度:

specular_factor = max(0, n·h)^p

p的作用就是"放大" n·h 的差异:

  • 当 n·h = 1 时(法线与半程向量完全对齐),1^p = 1 — 此时无论p多大,高光都是最亮的
  • 当 n·h = 0.9 时(稍微偏离),0.9^10 = 0.349(只剩35%),0.9^100 = 0.000027(几乎没了!)
p = 1          p = 10         p = 100        p = 1000
  ╱‾‾‾╲        ╱╲              │               │
  │    │        │  │            │               │
  │    │        │  │            │               │
  └────┘        └──┘           └┘              └┘
  漫散射         粗糙塑料       光滑塑料         镜面金属
p值材质例子视觉效果n·h=0.9时的值
1粗糙石膏大面积柔和反光0.9
10粗糙塑料/磨砂表面中小面积高光0.349
100一般塑料/油漆小而亮的高光0.000027
500光泽塑料极小的亮斑≈ 2.6×10^(-23)
1000+金属/镜面几乎就是镜面反射≈ 0

5.3.5 完整的Blinn-Phong高光函数

function blinn_phong_specular(
    normal,         // 表面法线(单位向量)
    light_dir,      // 指向光源的方向(已归一化)
    view_dir,       // 指向相机的方向(已归一化)
    ks,             // 高光反射系数(彩色的如金属金,白色的如塑料)
    p               // 光泽指数(越大高光越集中)
):
    // === 第1步:计算半程向量 h ===
    // h是l和v的"中间方向",当法线与h对齐时产生最强高光
    half_vec = normalize(light_dir + view_dir)
    
    // === 第2步:计算法线与半程向量的点积 ===
    // 衡量当前观察方向与完美反射方向的接近程度
    n_dot_h = dot(normal, half_vec)
    
    // === 第3步:裁剪负值 ===
    // 负值意味着高光在表面背面,不可见
    n_dot_h_clamped = max(0.0, n_dot_h)
    
    // === 第4步:施加光泽指数 p ===
    // p越大 → 角度容忍度越小 → 高光越集中
    specular_factor = pow(n_dot_h_clamped, p)
    
    // === 第5步:乘以高光系数和光源颜色 ===
    // 金属ks是彩色的(如金的ks是黄色),电介质(塑料等)是白色
    specular_color = ks * light_color * specular_factor
    
    return specular_color

5.3.6 Lambert + Blinn-Phong = 完整局部着色

将环境光(背景光)、漫反射、高光三者叠加,就是完整的Blinn-Phong着色模型:

L = k_a · I_ambient + Σ_i[ k_d · I_i · max(0, n·l_i) + k_s · I_i · max(0, n·h_i)^p ]
function blinn_phong_shade(
    point, normal,        // 表面点的位置和法线
    view_dir,             // 指向相机的方向
    lights,               // 场景中的光源列表
    kd, ks, p,            // 材质参数:漫反射系数、高光系数、光泽指数
    ambient_color         // 环境光颜色(防止全黑)
):
    // === 第1步:环境光(间接光照的"底光") ===
    // 没有直接光照时,表面不会全黑
    final_color = kd * ambient_color
    
    // === 第2步:遍历每个光源 ===
    for light in lights:
        // 指向光源的方向
        l = normalize(light.position - point)
        
        // === 漫反射部分 ===
        // 只有n·l > 0(表面正面朝向光源)时才计算
        n_dot_l = max(0.0, dot(normal, l))
        diffuse = kd * light.color * n_dot_l
        
        // === 高光部分 ===
        // 计算半程向量,法线与h越接近高光越强
        h = normalize(l + view_dir)
        n_dot_h = max(0.0, dot(normal, h))
        specular = ks * light.color * pow(n_dot_h, p)
        
        // 累加当前光源的贡献
        final_color += diffuse + specular
    
    // === 第3步:颜色裁减防止溢出 ===
    // 多个光源叠加可能超过1.0,必须限制在合法范围
    final_color = clamp(final_color, 0.0, 1.0)
    
    return final_color

5.3.7 完整公式的符号速查

符号含义影响
L最终着色颜色(RGB)输出的像素值
k_a环境光反射系数控制表面的最低亮度
I_ambient环境光强度场景整体亮度
Σ对所有光源求和多光源叠加
k_d漫反射系数(albedo)表面固有颜色
I_light光源强度各光源的颜色和亮度
n·l光线入射角余弦决定漫反射的明暗变化
k_s高光反射系数高光的颜色和强度
n·h半程向量对齐度决定高光的位置
p光泽指数(shininess)高光的集中程度

5.4 着色频率:逐顶点 vs 逐像素

5.4.1 问题的起源

在GPU出现之前,工程师必须仔细权衡质量与性能。着色的"位置选择"是第一个也是最重要的工程决策。

逐顶点着色 vs 逐像素着色对比
图5-4:逐顶点着色(Gouraud)vs 逐像素着色(Phong)——左侧丢失高光细节,右侧完全保留

核心问题:着色计算应该在哪个阶段执行?

  • 顶点处算(每个三角形算3次),然后在三角形内部插值颜色
  • 在每个像素处算(每个覆盖像素算1次),但需要插值法线

5.4.2 逐顶点着色(Gouraud Shading)

Gouraud着色由Henri Gouraud在1971年提出。基本思想:在顶点处计算颜色,然后通过重心坐标插值到三角形内部像素。

// GPU顶点着色器:在顶点处计算着色
function vertex_shader(vertex, model_matrix, view_dir, lights, kd, ks, p):
    // 变换到世界坐标系
    world_pos = model_matrix * vertex.position
    
    // 法线变换(需要逆转置矩阵,详见第7章)
    world_normal = normalize(transpose(inverse(model_matrix)) * vertex.normal)
    
    // 在顶点处计算完整着色
    color = blinn_phong_shade(world_pos, world_normal, view_dir, lights, kd, ks, p)
    
    return color  // 输出到光栅化器进行插值
        三角形顶点
           ● (颜色=红)
          / \
         /   \
        /     \     三角形内部像素的颜色 = 
       /       \    α·颜色0 + β·颜色1 + γ·颜色2
      /         \   (α, β, γ是重心坐标)
     ●───────────●
  (颜色=绿)     (颜色=蓝)
优点缺点
速度快——每个三角形算3次着色高光可能完全丢失(高光发生在三角形内部时)
GPU硬件实现简单需要大量三角形细分才能捕捉细节
所需带宽小插值颜色可能导致马赫带效应(视觉条纹)
🤔 想一想:一个很大的三角形,中心有高光但三个顶点都没有。逐顶点着色会是什么结果?
高光完全丢失!因为三个顶点的颜色都是"没有高光",插值后内部像素自然也没有高光。这就是为什么早期3D游戏中的物体往往看起来"塑料感"强——它们的模型被分割得不够细,高光细节都丢失了。

5.4.3 逐像素着色(Phong Shading)

注意:这里的"Phong"是指Bui Tuong Phong(Phong光照模型的提出者),他名字的第二次出现。逐像素着色在1975年由Phong提出。

基本思想:不在顶点处计算颜色,而是将法线从顶点插值到每个像素,然后在每个像素处独立计算着色。

// 顶点着色器——只计算需要插值的数据
function vertex_shader(vertex, model_matrix):
    world_pos = model_matrix * vertex.position
    world_normal = normalize(transpose(inverse(model_matrix)) * vertex.normal)
    return (world_pos, world_normal)  // 这些数据将由GPU插值后传给片元

// 片元着色器(GPU fragment shader)——在每个像素上独立计算
function fragment_shader(
    world_pos,          // 插值后的世界坐标
    world_normal,       // 插值后的法线
    view_dir,           // 指向相机的方向
    lights,             // 场景光源
    kd, ks, p           // 材质参数
):
    // 插值后法线长度可能不是1,必须重新归一化
    normal = normalize(world_normal)
    
    // 在像素处完整计算Blinn-Phong
    color = blinn_phong_shade(world_pos, normal, view_dir, lights, kd, ks, p)
    
    return color

5.4.4 插值过程详解

// 三个顶点的插值数据
v0 = (pos0, normal0)
v1 = (pos1, normal1)
v2 = (pos2, normal2)

// 对每个像素(x, y)计算重心坐标
// 重心坐标是三角形内的点相对于三个顶点的权重
(α, β, γ) = barycentric_coords(x, y, v0, v1, v2)
// 其中 α + β + γ = 1,且都非负

// 插值位置(用于后续计算光线方向)
interp_pos = α·pos0 + β·pos1 + γ·pos2

// 插值法线(需要在每个像素重新归一化!)
interp_normal = α·normal0 + β·normal1 + γ·normal2
interp_normal = normalize(interp_normal)  // 关键步骤!
优点缺点
每个像素独立计算,高光细节完整慢——每个像素都要执行完整的着色计算
低多边形模型也能表现得细腻GPU带宽要求高——需要传递法线等插值数据
没有马赫带效应(现代GPU通过SIMD并行已基本解决性能问题)

5.4.5 对比总结

对比项逐顶点(Gouraud)逐像素(Phong)
计算位置顶点处每个像素(片元)处
插值内容颜色法线、位置
计算次数每个三角形3次每个覆盖像素1次
高光质量差(可能完全丢失)好(每个像素独立)
锯齿现象严重轻微
带宽需求低(只需传颜色)高(需传法线和位置)
适用场景性能关键、材质简单高质量渲染
图形API时代固定管线(OpenGL 1.x)可编程管线(GLSL/HLSL)

5.5 环境光(Ambient Term)

5.5.1 为什么需要环境光?

想象一个房间里的球体,只有一扇窗户透入阳光。球体面向窗户的一面被照亮,但背向的一面呢?在真实世界中,它会被墙壁反射的间接光照亮——不会全黑。

但在局部着色模型中,我们没有计算间接光照。所以没有直接光照的表面就是全黑的——这在视觉上不自然。

只靠直接光:                    加上环境光:
   ●             ●
   │   (黑)      │ (暗,但不是全黑)
   │             │
   └──光源       └──光源
   背面 = 黑色    背面 = 环境色

5.5.2 环境光的数学

环境光是最简单的近似——给所有表面加一个均匀的"底光":

L_ambient = k_a · I_ambient
符号含义典型值
k_a环境光反射系数通常设为k_d(漫反射系数)
I_ambient环境光强度(RGB)(0.1, 0.1, 0.1) 暗灰或 (0.05, 0.05, 0.1) 暗蓝
重要说明:环境光是一个"作弊"。它不依赖于法线方向、不依赖于光照方向、不依赖于观察方向——就是给所有表面加上一个固定颜色。虽然物理上不精确,但它简单有效地防止了"死黑"区域。

5.5.3 环境光的局限

真正的间接光照远比简单环境光复杂:

  • 墙是红色的 → 它反射的光也是偏红的 → 附近的白色物体会染上粉色
  • 天花板的光不会被地面"吸入" → 面朝上的表面应该接收更多间接光

更精确的间接光照需要环境光遮蔽(Ambient Occlusion)球谐函数(Spherical Harmonics)路径追踪。但这些都超出了第5章的范围。


5.6 阴影实现

5.6.1 阴影测试的原理

有了着色公式后,我们要问:如果光源到表面点的路径上有其他物体挡住呢?——那就产生阴影

阴影测试极其简单:从表面点向光源发射一条"阴影光线",如果中途碰到物体,说明该点在阴影中。

function shade_with_shadow(point, normal, view_dir, lights, kd, ks, p, ambient, scene):
    // 基础环境光
    final_color = kd * ambient
    
    for light in lights:
        // === 阴影测试 ===
        // 从表面点向光源发射一条探测光线
        l = normalize(light.position - point)
        
        // 关键:加一个小偏移ε防止"自相交"
        // 由于浮点精度,光线可能与自己刚离开的表面相交
        shadow_ray = Ray(point + 1e-4 * normal, l)
        shadow_hit = scene.intersect(shadow_ray)
        
        // 计算光源距离,判断阻挡物是否在光源之前
        light_dist = distance(point, light.position)
        if shadow_hit and shadow_hit.t < light_dist:
            continue  // 在阴影中!跳过光照贡献
        
        // === 漫反射 ===
        n_dot_l = max(0.0, dot(normal, l))
        final_color += kd * light.color * n_dot_l
        
        // === 高光 ===
        h = normalize(l + view_dir)
        n_dot_h = max(0.0, dot(normal, h))
        final_color += ks * light.color * pow(n_dot_h, p)
    
    return clamp(final_color, 0.0, 1.0)

5.6.2 关于ε偏移的深入解释

什么是"自相交"?
在计算机中,浮点数不是精确的。当你计算光线与表面的交点时,得到的是一个"近似"位置。这个位置可能在表面的"内部"一点点。当你从该点发射阴影光线时,它可能一出发就"碰到"了自己所在的表面——产生错误的阴影条纹。

解决方法:沿法线方向加一个极小偏移(通常 ε = 1e-4 到 1e-6),让阴影光线的起点稍微离开表面。


5.7 各向异性着色(扩展)

5.7.1 各向同性 vs 各向异性

Blinn-Phong模型假设表面在所有方向上的反射特性相同——这称为各向同性(Isotropic)。但有些材质不是这样:

材质类型视觉效果原因
拉丝金属各向异性高光沿拉丝方向拉长表面有微观的沟槽方向
CD/DVD碟面各向异性彩虹色条纹螺旋数据轨道
丝绸布料各向异性顺滑光泽随角度变化纤维的定向排列
毛发/头发各向异性沿发丝方向的高光圆柱形纤维

各向异性着色需要引入切线方向(tangent)参与计算,但本章不深入展开。


5.8 从Blinn-Phong到PBR的过渡

5.8.1 Blinn-Phong的局限性

Blinn-Phong是"经验模型"——它的公式看起来不错,但不严格遵循物理:

  • 不保证能量守恒:漫反射+高光的量可能超过入射光
  • 参数无物理意义:kd、ks、p是"调出来的",不是从真实材质测量得到的
  • 没有Fresnel效应:掠射角时反射应该增强(见Fresnel效应图)
Fresnel效应—掠射角反射增强
图5-5:Fresnel效应——视线接近掠射角时,反射显著增强

5.8.2 PBR(基于物理的渲染)简介

现代渲染(20世纪10年代后)使用基于物理的着色模型(PBR)

对比项Blinn-PhongPBR (Cook-Torrance)
理论基础经验公式(好看就行)微表面理论(物理正确)
能量守恒不保证严格保证
参数kd, ks, p(无量纲)roughness, metallic, albedo(有物理单位)
反射类型漫反射 + 经验高光漫反射 + 微表面BRDF
Fresnel效应Schlick近似(第五个公式)
效果稳定性随视角可能"过曝"物理正确,任何角度都自然
使用难度容易(三个参数调色即可)稍难(需理解微表面理论)

PBR的复杂性超出本章范围,感兴趣可阅读第20章(光与材质)。


5.9 完整着色器代码示例

下面是一个完整的GPU着色器伪代码,整合了本章学到的所有知识:

// ============================
// 顶点着色器(Vertex Shader)
// ============================
// 负责将顶点数据变换到世界坐标系并传递到片元阶段

void vertex_main(InputVertex v):
    // 模型矩阵变换:从局部坐标系 → 世界坐标系
    world_pos = model_matrix * v.position
    
    // 法线变换:使用法线矩阵(模型矩阵的逆转置的3×3部分)
    // 注意:不能用model_matrix直接变换法线!
    world_normal = normalize(normal_matrix * v.normal)
    
    // 输出到光栅化器进行插值
    output.world_pos = world_pos
    output.world_normal = world_normal
    output.texcoord = v.texcoord

// ============================
// 片元着色器(Fragment Shader)
// ============================
// 在每个像素上执行完整的Blinn-Phong着色

// uniform变量:由CPU传入的全局常量
uniform vec3 camera_pos;       // 相机在世界空间的位置
uniform vec3 light_pos;        // 光源位置
uniform vec3 light_color;      // 光源颜色和强度
uniform vec3 ambient;          // 环境光颜色
uniform vec3 kd;               // 漫反射颜色(RGB albedo)
uniform vec3 ks;               ">// 高光颜色(RGB)
uniform float p;               // 光泽指数(shininess)

void fragment_main(FragmentInput f):
    // === 第1步:归一化插值后的法线 ===
    // 插值会改变向量长度,必须重新归一化
    vec3 n = normalize(f.world_normal)
    
    // === 第2步:计算视角方向 ===
    // 从表面点指向相机
    vec3 v = normalize(camera_pos - f.world_pos)
    
    // === 第3步:计算指向光源的方向 ===
    // 从表面点指向光源
    vec3 l = normalize(light_pos - f.world_pos)
    
    // === 第4步:环境光 ===
    // 给所有表面加点"底光",防止背面全黑
    vec3 color = kd * ambient
    
    // === 第5步:漫反射(Lambert) ===
    // n·l = cosθ,控制入射光的有效能量
    float n_dot_l = max(0.0, dot(n, l))
    color += kd * light_color * n_dot_l
    
    // === 第6步:高光(Blinn-Phong) ===
    // h = normalize(l + v) 半程向量
    // n·h ≈ 1 时高光最强
    vec3 h = normalize(l + v)
    float n_dot_h = max(0.0, dot(n, h))
    color += ks * light_color * pow(n_dot_h, p)
    
    // === 第7步:输出最终颜色 ===
    // clamp确保颜色值不超过[0,1]范围
    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0)

全章总结

核心洞察:表面着色的本质是"用数学公式模拟光与材质的交互"。通过三个简单成分的组合——环境光、漫反射、高光——就能表达从粗糙石膏到光滑金属的广泛材质范围。

概念核心公式关键参数依赖关系
环境光L = k_a·I_ak_a方向无关(物理不精确但实用)
漫反射L = k_d·I·max(0, n·l)k_d依赖法线和光源方向
高光L = k_s·I·max(0, n·h)^pk_s, p依赖法线、光源和视角方向
逐顶点顶点 → 插值颜色N_tri × 3次速度快但丢失高光细节
逐像素插值法线 → 每像素计算N_tri × N_pixels质量高但计算量大
阴影阴影光线检测遮挡每次着色多一次求交产生硬阴影的简单方法

一句话总结:着色 = 环境光(防止死黑)+ 漫反射(粗糙材质感)+ 高光(光泽感),逐像素着色用更多计算换取更好的视觉效果;从Blinn-Phong到PBR是图形学从"看起来对"到"物理上对"的进化。

目录 X
章节 X