渲染
光照与着色模型
直觉问题:为什么不同材质看起来不一样?
Q1: 为什么金属在掠射角会变亮?为什么粗糙表面反射的光更柔和?
Q2: 一块石头和一块镜子,为什么同样的光照下反射效果天差地别?
核心概念白话讲
BRDF(双向反射分布函数)
BRDF 描述了光线如何从表面反射:给定一个入射方向 ω_i,计算反射到出射方向 ω_o 的光强比例。
物理意义:
- 输入:入射光方向
ω_i、出射观察方向ω_o - 输出:反射光亮度
L_o - 单位:
sr⁻¹(每立体弧度)
TIP
BRDF 是一个辐射率比,而非绝对值。它描述了材质的”反射特性”,不直接给出亮度。
光照模型对比
| 模型 | 年份 | 漫反射 | 镜面反射 | 物理真实 | 计算复杂度 |
|---|---|---|---|---|---|
| Lambert | 1790 | ✓ | ✗ | ✗ | 极低 |
| Phong | 1975 | ✓ | ✓ | ✗ | 低 |
| Blinn-Phong | 1977 | ✓ | ✓ | ✗ | 中 |
| PBR | 2010 | ✓ | ✓ | ✓ | 高 |
graph LR
A[Lambert<br/>漫反射] --> B[Phong<br/>镜面反射]
B --> C[Blinn-Phong<br/>半角向量优化]
C --> D[PBR<br/>物理真实]
D --> E[Ray Tracing<br/>光线追踪]
Lambert 漫反射(1790)
假设光线向所有方向均匀散射。适用于纸张、布料等粗糙表面。
Phong 镜面反射(1975)
模拟平滑表面的镜面高光。高光区域由视线与反射向量的夹角控制。
Blinn-Phong 改进版(1977)
引入半角向量 H(Light View 的角平分线),避免计算反射向量 R。
NOTE
Blinn-Phong 在性能和效果之间取得平衡,是传统游戏引擎的默认选择。
PBR(物理真实渲染)
核心原则:
- 能量守恒:反射光 + 透射光 = 入射光
- 菲涅尔效应:掠射角反射率增加
- 微表面理论:表面由无数微小的镜面构成
Metallic-Roughness 工作流:
Albedo:基础颜色(金属为反射色,非金属为漫反射色)Metallic:金属度(0 = 绝缘体,1 = 金属)Roughness:粗糙度(0 = 镜面,1 = 完全漫反射)Normal:法线贴图(细节凹凸)AO(Ambient Occlusion):环境光遮蔽(缝隙变暗)
法线贴图(Normal Map)
将切线空间(Tangent Space)的法线存储在纹理中,模拟表面凹凸细节而不增加几何复杂度。
TBN 矩阵:将切线空间法线转换为世界空间。
环境光遮蔽(Ambient Occlusion)
模拟几何遮挡对环境光的影响:缝隙、角落、褶皱处接收到的环境光更少。
- SSAO(Screen-Space AO):基于深度缓冲的后处理技术
- HBAO(Horizon-Based AO):考虑地平线遮挡,效果更准确
- GTAO(Ground-Truth AO):基于全局光照的近似
原理与数学机制
Blinn-Phong 完整公式
分量分解:
k_a:环境光系数k_d:漫反射系数k_s:镜面反射系数N:表面法线L:光照方向H:半角向量normalize(L + V)V:视线方向
PBR Cook-Torrance BRDF
漫反射项:
镜面反射项:
三个核心函数:
- D(h) - 法线分布函数(GGX/Trowbridge-Reitz)
- F(v, h) - 菲涅尔函数(Schlick 近似)
- G(l, v, h) - 几何遮蔽函数(Smith GGX)
WARNING
PBR 计算量大,需在预过滤环境贴图(Pre-filtered Environment Map)和积分贴图(Irradiance Map)辅助下才能实时渲染。
法线贴图坐标变换流程
graph LR
A[切线空间法线<br/>0.5, 0.5, 1.0] --> B[解包到 -1~1<br/>remap 2x-1]
B --> C[TBN 矩阵变换<br/>TBN * normal]
C --> D[世界空间法线<br/>归一化]
D --> E[光照计算<br/>N dot L]
解包公式:
GLSL vs WGSL 代码对照
Blinn-Phong 光照实现
GLSL 版本
#version 300 es
precision highp float;
in vec3 v_position;
in vec3 v_normal;
in vec2 v_texcoord;
uniform vec3 u_light_position;
uniform vec3 u_view_position;
uniform vec3 u_light_color;
uniform sampler2D u_diffuse_map;
uniform sampler2D u_normal_map;
uniform sampler2D u_ao_map;
out vec4 frag_color;
void main() {
vec3 albedo = texture(u_diffuse_map, v_texcoord).rgb;
float ao = texture(u_ao_map, v_texcoord).r;
vec3 normal = texture(u_normal_map, v_texcoord).rgb;
normal = normalize(normal * 2.0 - 1.0); // 解包法线
vec3 light_dir = normalize(u_light_position - v_position);
vec3 view_dir = normalize(u_view_position - v_position);
vec3 half_dir = normalize(light_dir + view_dir);
float diffuse = max(dot(normal, light_dir), 0.0);
float specular = pow(max(dot(normal, half_dir), 0.0), 32.0);
vec3 ambient = 0.1 * albedo * ao;
vec3 lighting = ambient + (diffuse + specular) * u_light_color * albedo;
frag_color = vec4(lighting, 1.0);
}
WGSL 版本
struct FragmentInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) texcoord: vec2<f32>,
}
struct FragmentOutput {
@location(0) color: vec4<f32>,
}
@group(0) @binding(0)
var diffuse_map: texture_2d<f32>;
@group(0) @binding(1)
var diffuse_sampler: sampler;
@group(0) @binding(2)
var normal_map: texture_2d<f32>;
@group(0) @binding(3)
var normal_sampler: sampler;
@group(0) @binding(4)
var ao_map: texture_2d<f32>;
@group(0) @binding(5)
var ao_sampler: sampler;
@group(0) @binding(6)
var<uniform> light_position: vec3<f32>;
@group(0) @binding(7)
var<uniform> view_position: vec3<f32>;
@group(0) @binding(8)
var<uniform> light_color: vec3<f32>;
@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
var output: FragmentOutput;
let albedo = textureSample(diffuse_map, diffuse_sampler, input.texcoord).rgb;
let ao = textureSample(ao_map, ao_sampler, input.texcoord).r;
let normal_sample = textureSample(normal_map, normal_sampler, input.texcoord).rgb;
let normal = normalize(normal_sample * 2.0 - 1.0);
let light_dir = normalize(light_position - input.position);
let view_dir = normalize(view_position - input.position);
let half_dir = normalize(light_dir + view_dir);
let diffuse = max(dot(normal, light_dir), 0.0);
let specular = pow(max(dot(normal, half_dir), 0.0), 32.0);
let ambient = vec3<f32>(0.1) * albedo * ao;
let lighting = ambient + (diffuse + specular) * light_color * albedo;
output.color = vec4<f32>(lighting, 1.0);
return output;
}
差异点:
- WGSL 需要显式绑定纹理和采样器(
texture_2d+sampler) textureSample()替代texture()vec3<f32>(0.1)需要显式指定类型
PBR Metallic-Roughness 实现
GLSL 版本
const float PI = 3.14159265359;
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}
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 NdotH2 = NdotH * NdotH;
float num = a2;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom;
return num / denom;
}
float GeometrySchlickGGX(float NdotV, float roughness) {
float r = (roughness + 1.0);
float k = (r * r) / 8.0;
float num = NdotV;
float denom = NdotV * (1.0 - k) + k;
return num / denom;
}
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float ggx2 = GeometrySchlickGGX(NdotV, roughness);
float ggx1 = GeometrySchlickGGX(NdotL, roughness);
return ggx1 * ggx2;
}
vec3 calculatePBR(vec3 albedo, float metallic, float roughness, vec3 N, vec3 V, vec3 L, vec3 H, vec3 light_color) {
vec3 F0 = vec3(0.04);
F0 = mix(F0, albedo, metallic);
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 numerator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
vec3 specular = numerator / denominator;
vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
kD *= 1.0 - metallic;
float NdotL = max(dot(N, L), 0.0);
vec3 Lo = (kD * albedo / PI + specular) * light_color * NdotL;
return Lo;
}
WGSL 版本
const PI: f32 = 3.14159265359;
fn fresnelSchlick(cosTheta: f32, F0: vec3<f32>) -> vec3<f32> {
return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}
fn distributionGGX(N: vec3<f32>, H: vec3<f32>, roughness: f32) -> f32 {
let a = roughness * roughness;
let a2 = a * a;
let NdotH = max(dot(N, H), 0.0);
let NdotH2 = NdotH * NdotH;
let num = a2;
var denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom;
return num / denom;
}
fn geometrySchlickGGX(NdotV: f32, roughness: f32) -> f32 {
let r = (roughness + 1.0);
let k = (r * r) / 8.0;
let num = NdotV;
let denom = NdotV * (1.0 - k) + k;
return num / denom;
}
fn geometrySmith(N: vec3<f32>, V: vec3<f32>, L: vec3<f32>, roughness: f32) -> f32 {
let NdotV = max(dot(N, V), 0.0);
let NdotL = max(dot(N, L), 0.0);
let ggx2 = geometrySchlickGGX(NdotV, roughness);
let ggx1 = geometrySchlickGGX(NdotL, roughness);
return ggx1 * ggx2;
}
fn calculatePBR(
albedo: vec3<f32>,
metallic: f32,
roughness: f32,
N: vec3<f32>,
V: vec3<f32>,
L: vec3<f32>,
H: vec3<f32>,
light_color: vec3<f32>
) -> vec3<f32> {
var F0 = vec3<f32>(0.04);
F0 = mix(F0, albedo, metallic);
let F = fresnelSchlick(max(dot(H, V), 0.0), F0);
let NDF = distributionGGX(N, H, roughness);
let G = geometrySmith(N, V, L, roughness);
let numerator = NDF * G * F;
let denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
let specular = numerator / denominator;
let kS = F;
let kD = vec3<f32>(1.0) - kS;
let kD = kD * (1.0 - metallic);
let NdotL = max(dot(N, L), 0.0);
let Lo = (kD * albedo / PI + specular) * light_color * NdotL;
return Lo;
}
差异点:
- WGSL 需要显式函数参数类型标注(
f32,vec3<f32>) var用于可变变量,let用于不可变const替代#define定义常量
常见误区与陷阱
-
法线贴图未解包
- 陷阱:直接使用
[0, 1]范围的法线纹理 - 解决:
normal * 2.0 - 1.0解包到[-1, 1]范围
- 陷阱:直接使用
-
PBR 能量不守恒
- 陷阱:
kD + kS > 1导致反射光超过入射光 - 解决:确保
kS = F0(菲涅尔),kD = 1.0 - kS * (1.0 - metallic)
- 陷阱:
-
** Metallic-Roughness 参数范围错误**
- 陷阱:
metallic超出[0, 1],roughness为负数 - 解决:在材质编辑器中强制 clamp 到
[0, 1]范围
- 陷阱:
-
TBN 矩阵未归一化
- 陷阱:切线、副切线、法线未归一化,导致光照计算错误
- 解决:
T = normalize(T); B = normalize(B); N = normalize(N)
-
菲涅尔 F0 值错误
- 陷阱:所有材质使用相同 F0(如 0.04)
- 解决:根据材质物理属性调整 F0(水 0.02,金 1.0,铜 0.95)
-
环境光遮蔽(AO)未应用
- 陷阱:仅使用环境光,AO 纹理被忽略
- 解决:
ambient = 0.1 * albedo * ao,将 AO 纹理采样到环境光项
-
高光指数过大
- 陷阱:
shininess设置为 256+,导致高光过锐或消失 - 解决:根据材质调整(塑料 8-32,金属 64-128)
- 陷阱:
-
SSAO/HBAO 采样数过高
- 陷阱:采样 64+ 次导致帧率暴跌
- 解决:使用分层采样 + 旋转噪声,或降分辨率计算
延伸阅读与自测
权威资料
- Physically Based Rendering - Disney BRDF - Disney PBR 工作流官方论文
- Real-Time Rendering 4th Edition - Chapter 7 - 光照与着色模型权威教材
- LearnOpenGL - PBR Theory - PBR 入门教程
- The Bumpy Road - Normal Mapping - 法线贴图深度解析
开源实现参考
- bgfx/shaders - 多语言 shader 示例库
- Filament - Google PBR 渲染引擎
- Godot Engine - PBR Shaders - 开源游戏引擎的 PBR 实现
自测题
-
思考题: 为什么 PBR 中的
roughness值要平方(a = roughness * roughness)? -
对比题: Blinn-Phong 与 PBR 在高光计算上的根本区别是什么?
-
实践题: 如何实现一个支持多光源的 PBR Shader?如何优化
for循环? -
扩展题: SSAO、HBAO、GTAO 三种 AO 技术的优缺点对比?
-
优化题: PBR 环境光如何使用预过滤立方体贴图(Prefiltered Cube Map)加速?
参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。