光照模型与实时阴影:Phong、PBR、阴影贴图与级联阴影

光照与阴影是渲染真实感的核心。本文系统梳理 Lambert、Phong、Blinn-Phong 经典光照模型,深入 Cook-Torrance PBR BRDF、光源类型与衰减,再讲解阴影贴图原理、锯齿与 Peter Panning 问题,以及级联阴影 CSM、软阴影 PCSS 的现代实时解决方案。

光照(Lighting)与阴影(Shadow)共同决定了场景的立体感与真实感。一个只有纹理没有光照的 3D 场景看起来像"贴纸",而阴影则是人类视觉系统判断空间关系的核心线索——没有阴影,物体仿佛悬浮在空中。实时渲染经过四十年的演进,形成了从简单经验模型(Phong)到物理基渲染(PBR)的光照理论,以及从平面阴影贴图到级联阴影、PCSS 软阴影的完整技术栈。本文按"从简单到工业级"的顺序,系统梳理这些技术背后的数学原理与工程实践。


一、经典光照模型:从 Lambert 到 Blinn-Phong

一句话:经典光照模型将光照拆解为环境光、漫反射、镜面高光三项,用少量参数快速近似真实光学,是理解 PBR 的跳板。

1.1 Lambert 漫反射

Lambert 模型假设表面是理想漫反射体(朗伯体),反射光强度与入射光和法线夹角的余弦成正比,与观察方向无关。其 BRDF 是一个常量 ρ / π,能量守恒下任意方向反射率总和为 1。

L_diffuse = kd * albedo * max(dot(N, L), 0.0) * lightColor

max(dot(N, L), 0.0) 称为 N·L 项,是实时渲染中最常见的几何项。它意味着当光与表面接近平行时,光照贡献趋近于零——这也是正午阳光与清晨阳光亮度差异的来源。

1.2 Phong 高光

Phong 在漫反射基础上引入镜面高光:当观察方向 V 接近光线的反射方向 R 时,看到强烈高光。反射向量计算如下:

R = 2 * dot(N, L) * N - L
spec = ks * pow(max(dot(R, V), 0.0), shininess)

shininess(镜面指数)控制高光锐度:指数越大,高光斑越小越亮。Phong 的缺点是需要每像素计算反射向量 R,且当 N·L < 0 时(光在表面背面)反射向量方向反转,会产生不自然的亮点。

1.3 Blinn-Phong 半程向量

Blinn-Phong 用半程向量 H = normalize(L + V) 代替反射向量 R,高光变为:

H = normalize(L + V)
spec = ks * pow(max(dot(N, H), 0.0), shininess)

半程向量是 L 与 V 的角平分线,物理含义是"若存在一个微表面法线恰好指向 H,则会产生镜面反射"。Blinn-Phong 更高效、更稳定,是 2000 年代游戏渲染的事实标准。

1.4 光照模型对比

模型数学复杂度高光质量物理真实性适用场景
Lambert低无高光仅漫反射白模、预览、AO 渲染
Phong中依赖 R 计算经验模型老式引擎、卡通渲染
Blinn-Phong中H 计算更稳定经验模型移动端前向渲染
Cook-Torrance (PBR)高能量守恒、金属度物理基现代引擎标准
// GLSL:Blinn-Phong 片段着色器核心
uniform vec3 uLightDir;
uniform vec3 uViewPos;
uniform vec3 uLightColor;

vec3 blinnPhong(vec3 N, vec3 V, vec3 albedo, float shininess) {
    vec3 L = normalize(uLightDir);
    vec3 H = normalize(L + V);

    float diff = max(dot(N, L), 0.0);
    float spec = pow(max(dot(N, H), 0.0), shininess);

    vec3 ambient  = 0.05 * albedo;              // 环境项(粗略近似)
    vec3 diffuse  = albedo * diff;
    vec3 specular = vec3(1.0) * spec;
    return ambient + (diffuse + specular) * uLightColor;
}

二、PBR 光照:Cook-Torrance BRDF

一句话:PBR 用微表面理论 + 能量守恒的 Cook-Torrance BRDF 替代经验模型,将材质参数抽象为 Albedo、金属度、粗糙度三个物理量。

2.1 微表面理论

真实表面在微观尺度上是凹凸不平的,每个微平面都是理想镜面反射体。BRDF 描述的是这些微平面统计反射的结果。Cook-Torrance 将反射分解为漫反射项与镜面反射项:

f = kd * (albedo / π) + kspec * (DFG / (4 * (N·V) * (N·L)))

三项关键函数:

  • D(法线分布函数 NDF):微表面法线朝向半程向量 H 的概率。GGX/Trowbridge-Reitz 是实时渲染标准:
    D = α² / (π * ((N·H)² * (α² - 1) + 1)²)
    其中 α = roughness²
    
  • G(几何遮蔽函数):微表面间的自遮挡。Smith 方法 + Schlick-GGX 近似,加上可见性项 V = G / (4·(N·V)·(N·L))。
  • F(菲涅尔项):反射率随视角变化,Schlick 近似:
    F = F0 + (1 - F0) * pow(1 - (V·H), 5)
    F0 = 0.04(电介质)或 albedo(金属)
    

2.2 金属度工作流

金属度(metallic)将材质分为两类:金属(漫反射为 0,镜面反射带颜色 F0 = albedo)与电介质(F0 ≈ 0.04,漫反射 albedo)。关键实现点是 F(菲涅尔)本身就是 kS(镜面反射占比),漫反射 kD = 1 - kS 后再乘 (1 - metallic)。

// GLSL:Cook-Torrance PBR 光照
const float PI = 3.14159265359;

float distributionGGX(vec3 N, vec3 H, float roughness) {
    float a  = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float d = (NdotH * NdotH * (a2 - 1.0) + 1.0);
    return a2 / (PI * d * d);
}

float geometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
    float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float g1 = NdotV / (NdotV * (1.0 - k) + k);
    float g2 = NdotL / (NdotL * (1.0 - k) + k);
    return g1 * g2;
}

vec3 fresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

vec3 pbrDirectLight(vec3 N, vec3 V, vec3 L, vec3 albedo,
                    float metallic, float roughness) {
    vec3 H = normalize(V + L);
    float NDF = distributionGGX(N, H, roughness);
    float G   = geometrySmith(N, V, L, roughness);
    vec3 F    = fresnelSchlick(max(dot(H, V), 0.0), mix(vec3(0.04), albedo, metallic));

    vec3 kS = F;
    vec3 kD = (1.0 - kS) * (1.0 - metallic);
    vec3 specular = (NDF * G * F) / (4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001);

    float NdotL = max(dot(N, L), 0.0);
    return (kD * albedo / PI + specular) * NdotL;
}

2.3 IBL 补充

现代 PBR 还需基于图像的光照(IBL):环境贴图经预过滤(Pre-filtered Mipmap)对应不同粗糙度,漫反射部分用辐照度图(Irradiance Map)。镜面 IBL 通过 split-sum 近似,将 BRDF 积分拆分为"预过滤环境光 × 预计算 BRDF 查表",在实时中一次纹理采样完成。IBL 的引入使得物体天然继承环境光照,是 PBR 工作流不可或缺的一环。


三、光源类型与衰减

一句话:实时引擎的光源按几何形态分为平行光、点光、聚光灯,其区别在于方向向量与衰减函数的差异。

光源类型方向/位置衰减典型用途
平行光 (Directional)方向固定、无位置无衰减太阳、月亮
点光 (Point)有位置、全方位1 / (d²) 或逆向平方灯泡、火把
聚光灯 (Spot)有位置、有锥角位置衰减 × 角衰减手电、舞台灯
面积光 (Area)面状几何软阴影、昂贵影视级室内

点光理想衰减为逆向平方律 1 / d²,但纯逆向平方在 d → 0 时会发散到无穷大,实时实现通常用有界形式:

// GLSL:有界衰减(避免 d→0 发散)
float attenuation(float dist, float lightRadius) {
    float d = dist / lightRadius;           // 归一化距离
    return clamp(1.0 / (d * d + 1.0), 0.0, 1.0);
    // 也可用 smoothstep 过渡:saturate(1.0 - (d*d) ),常见于移动端
}

聚光灯在点光基础上增加角衰减——从光锥中心到边缘的平滑过渡:

// GLSL:聚光灯角度衰减
float spotAtten(vec3 L, vec3 spotDir, float innerCos, float outerCos) {
    float cosAngle = dot(normalize(-L), spotDir); // 注意方向取反
    return smoothstep(outerCos, innerCos, cosAngle); // 边缘→中心渐变
}

光照累加时务必在**线性空间(HDR)**进行:颜色先反 Gamma(sRGB → linear),累加完再做色调映射与 Gamma 编码,否则物理强度无法正确叠加,高光区域会出现灰暗(banding)问题。


四、阴影贴图(Shadow Map)原理

一句话:阴影贴图从光源视角渲染一张深度图,遮挡测试退化为一次深度比较——简单却存在分辨率与精度两大瓶颈。

阴影贴图(Shadow Mapping)是最主流的实时阴影技术,其流程分为两步:

  1. Light Pass:以光源位置为视点、光源方向为视线方向,渲染场景到一张深度纹理(Shadow Map),记录每个 texel 处离光源最近的深度。
  2. Main Pass:从相机视角渲染像素时,将该像素变换到光源空间,比较其"光空间深度"与阴影贴图中记录的值:若更远则被遮挡(阴影中),否则可见。
// GLSL:Main Pass 中的阴影采样
uniform sampler2D uShadowMap;
uniform mat4 uLightSpaceMatrix;

float shadowCalculation(vec4 fragPosLightSpace) {
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; // 透视除法 → NDC
    projCoords = projCoords * 0.5 + 0.5;                           // NDC [-1,1] → UV [0,1]

    float closestDepth = texture(uShadowMap, projCoords.xy).r;
    float currentDepth = projCoords.z;

    // 深度偏移避免自遮挡(shadow acne)
    float bias = max(0.002 * (1.0 - dot(normal, lightDir)), 0.001);
    return currentDepth - bias > closestDepth ? 1.0 : 0.0;
}

关键的实现细节:

  • 透视除法:光照投影矩阵若是透视投影(点光/聚光灯),需除以 w;平行光用正交投影,w = 1。
  • 深度偏移(Depth Bias):由于深度纹理精度有限,被照亮面自身采样时会出现深度"自遮挡"噪声,称为阴影粉刺(Shadow Acne),用 bias 推开。
  • 采样器比较:现代 API 可用 VK_COMPARE_OP_LESS + shadow2DProj / texture() 的比较模式采样,返回硬布尔结果,无需手动分支。

五、阴影质量问题:锯齿、Peter Panning 与级联

一句话:阴影贴图的两大经典 artifact——锯齿(分辨率不足)与 Peter Panning(bias 过大导致阴影脱离接触点)——本质上是分辨率和精度的此消彼长。

5.1 锯齿(Aliasing)与 PCF

硬阴影在低分辨率下呈现严重锯齿。PCF(Percentage-Closer Filtering) 的思路是:不采样单个 texel,而是对周围 3×3 / 5×5 邻域做遮挡比较后取平均,产生柔和边缘:

// HLSL:PCF 软边缘(与硬件 PCF 采样器异曲同工)
float shadowPCF(float4 shadowCoord, float depth) {
    float2 texelSize = 1.0 / float2(textureSize(shadowMap, 0));
    float shadow = 0.0;
    [unroll]
    for (int x = -1; x <= 1; x++) {
        for (int y = -1; y <= 1; y++) {
            float2 uv = shadowCoord.xy + float2(x, y) * texelSize;
            shadow += (depth > shadowMap.SampleCmp(sampler, uv, depth)) ? 1.0 : 0.0;
        }
    }
    return shadow / 9.0;
}

硬件支持比较采样时(SampleCmp / texture() with compare sampler),过滤由固定功能单元完成,性能远优于 9 次手动比较。

5.2 Peter Panning 与深度偏移的矛盾

bias 过小 → 阴影粉刺;bias 过大 → 阴影"飘"离物体,出现悬空阴影(Peter Panning)。这是因为 bias 相当于把被照亮面在深度上"抬高",抬得越多,接触阴影越远离遮挡体。解决方案:

  • Slope-Scale Bias:bias 随表面与光线的夹角变化(dot(N, L) 越小 bias 越大),配合深度纹理精度使用。
  • Normal Offset:沿法线方向推开采样点(PCF 时尤其重要)。
  • Z-fighting 检查:阴影场景若大面积自遮挡,优先怀疑 bias 方向或深度格式精度(D16 → D32F)。

5.3 单一贴图在大型场景中的失效

一个固定分辨率的阴影贴图(如 2048²)覆盖整片地图时,近处每像素对应的阴影 texel 可能超过 1 个屏幕像素(欠采样锯齿),远处则分辨率浪费。这就引出了级联阴影。


六、级联阴影(Cascaded Shadow Maps, CSM)

一句话:CSM 把视锥按深度切成多个级联,每个级联用独立的高分辨率阴影贴图覆盖,让近处阴影清晰、远处阴影覆盖范围大。

CSM 是目前大型场景阳光阴影的标准方案。其核心步骤:

  1. 分割视锥:将主相机视锥沿深度分成 34 段。分割点常用对数-线性混合公式:dist = lerp(log_dist, lin_dist, lambda),λ 通常取 0.50.8,兼顾近处精度与远处覆盖。
  2. 每级联构建光源正交投影:计算该级联子视锥的世界空间包围盒,构建光源空间的正交矩阵,将场景渲染到该级联对应的阴影贴图。
  3. 采样时选择级联:根据像素深度确定所属级联,采样对应贴图;级联交界处按权重混合避免接缝。
// C++:计算级联分割距离
std::vector<float> computeCascadeSplits(int count, float nearZ, float farZ, float lambda) {
    std::vector<float> splits(count + 1);
    splits[0] = nearZ;
    for (int i = 1; i <= count; i++) {
        float t = (float)i / (float)count;
        float logSplit = nearZ * std::pow(farZ / nearZ, t);   // 对数分布:近处密集
        float linSplit = nearZ + (farZ - nearZ) * t;          // 线性分布
        splits[i] = std::lerp(linSplit, logSplit, lambda);    // 加权混合
    }
    return splits;
}

6.1 级联稳定与 Texel Snapping

相机移动时,若不锁定阴影贴图 texel 与场景的对应关系,阴影会"漂移抖动"。经典解决法是Texel Snapping:把光源正交矩阵的平移量对齐到 texel 尺寸的整数倍:

// C++:Texel Snapping 稳定阴影
glm::mat4 snapLightMatrix(const glm::mat4& lightViewProj, float shadowMapSize) {
    glm::mat4 proj = lightViewProj;
    float texelSize = 2.0f / shadowMapSize;            // NDC 范围 [-1,1] 的 texel 尺寸
    glm::vec4 origin = proj * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f);
    glm::vec2 snapped = glm::floor(glm::vec2(origin) / texelSize) * texelSize;
    proj[3][0] -= (snapped.x - origin.x);
    proj[3][1] -= (snapped.y - origin.y);
    return proj;
}

6.2 级联参数配置参考

参数推荐值说明
级联数量3~43 级兼顾质量与开销,4 级用于大型开放世界
贴图分辨率1024² ~ 4096²/级主级联 2048² 起,与屏幕像素 1:1 匹配
分割 λ0.5 ~ 0.8越大近处越密、远处越稀
深度格式D32_FLOAT大深度范围下精度优于 D24
Blend 过渡带2~3 texel级联间 blend 平滑

七、软阴影与 PCSS

一句话:真实光源是面光源,阴影边缘必然柔和;PCSS 用"遮挡深度均值 → 估算半影大小 → 自适应 PCF 半径"模拟物理软阴影。

PCF 的滤波半径是固定的,因此远近阴影边缘硬度一致。真实场景中,物体离地面越远,半影(Penumbra)越宽。PCSS(Percentage-Closer Soft Shadows) 分三步解决:

  1. 遮挡搜索:在给定半径内采样阴影贴图,统计比当前深度更近的遮挡者,计算其平均深度 d_blocker。
  2. 半影估计:由相似三角形推得滤波半径 w_penumbra = (d_receiver - d_blocker) / d_blocker * w_light,物体越远半影越大。
  3. PCF 滤波:用上述半径做 PCF,得到软阴影边缘。
// GLSL:PCSS 遮挡搜索
float findBlocker(sampler2D shadowMap, vec2 uv, float zReceiver) {
    float blockerDepth = 0.0;
    int blockerCount = 0;
    vec2 texelSize = 1.0 / vec2(textureSize(shadowMap, 0));
    for (int x = -2; x <= 2; x++) {
        for (int y = -2; y <= 2; y++) {
            float d = texture(shadowMap, uv + vec2(x, y) * texelSize).r;
            if (d < zReceiver) {                // 该 texel 是遮挡者
                blockerDepth += d;
                blockerCount++;
            }
        }
    }
    return blockerCount == 0 ? -1.0 : blockerDepth / float(blockerCount);
}

PCSS 每像素采样次数较多(遮挡搜索 + 自适应 PCF),常用半分辨率计算再上采样、或与 TAA 时序累积配合,以控制性能。

其他软阴影方案

  • VSM / EVSM(方差阴影贴图):存储深度与深度平方,用 Chebyshev 不等式求遮挡概率,天然可过滤,但易漏光(Light Leaking)。
  • MSM(矩阴影贴图):VSM 改进,用更高阶矩减少漏光。
  • 光线追踪阴影:直接对光源发射阴影射线,精度最高,见 光线追踪与混合渲染。

八、光照与阴影的工程实践

一句话:光照与阴影的选择是渲染架构的一环——前向渲染适合少光源 + MSAA,延迟渲染适合大量光源 + CSM + 后处理。

8.1 前向 vs 延迟的阴影集成

  • 前向渲染(Forward):每个物体一次 pass,阴影贴图直接采样。半透明与 MSAA 天然支持,但光源多了开销爆炸。
  • 延迟渲染(Deferred):G-Buffer 存储几何信息,光照 pass 全屏处理。CSM 采样在光照 pass 进行,每个像素只执行一次级联选择;配合 Tiled/Clustered 光源剔除,可承载数百光源。

8.2 性能预算

技术主要开销优化策略
Shadow Map 渲染每光源一次场景 pass只渲不透明物体、级联间共享渲染
PCF / PCSS多次阴影采样半分辨率 + 上采样、硬件比较采样
CSM4 个 shadow pass视锥剔除、动态分辨率、级联数自适应
PBR 直接光BRDF 函数计算预计算 IBL、half precision、移动端降级

8.3 移动端与 Web 的取舍

移动端(以及 WebGPU)倾向:2 级 CSM + 128²~512² 阴影贴图 + 3×3 PCF,PBR 用简化的 GGX 或 Blinn-Phong;避免大半径 PCF 与 PCSS。桌面端则可用 2048²+ 4 级 CSM + PCSS + 光线追踪阴影。


总结

实时光照与阴影经过了"经验模型 → 物理模型 → 高精度阴影"的三级跳。Blinn-Phong 提供廉价的可读性,Cook-Torrance PBR 提供物理一致性与艺术统一性,阴影贴图家族(PCF/CSM/PCSS)则在分辨率、精度与性能之间做精细权衡。

层次核心技术工程目标
光照模型Lambert / Blinn-Phong / Cook-Torrance物理正确 + 艺术可控
光源管理平行光/点光/聚光灯衰减、HDR 线性空间多光源叠加不溢出
基础阴影Shadow Map + Bias + PCF无粉刺、无 Peter Panning
大规模阴影CSM + Texel Snapping + 级联混合大场景远近皆清晰
软阴影PCSS / VSM / RT shadow半影随距离变化

学习建议:先在 GLSL 里用 Blinn-Phong 点亮一个场景,再逐函数替换为 Cook-Torrance 并加入 IBL;阴影部分从单贴图加 PCF 起步,逐步演进到 CSM 与 PCSS。每一步都建议用渲染截图对比 artifact 变化,阴影调试是培养图形学直觉的最佳实践。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 光线追踪与混合渲染:BVH、路径追踪与 RTX
  2. 骨骼动画与蒙皮:骨骼层级、动画混合与 GPU 蒙皮
  3. 大地形与开放世界渲染:LOD、分块与实例化