返回知识库

渲染

光照与着色模型

光照与着色模型 封面
WebGPU/WebGLShaderGLSL/WGSL光照模型BRDFPBR法线贴图

直觉问题:为什么不同材质看起来不一样?

Q1: 为什么金属在掠射角会变亮?为什么粗糙表面反射的光更柔和?

Q2: 一块石头和一块镜子,为什么同样的光照下反射效果天差地别?


核心概念白话讲

BRDF(双向反射分布函数)

BRDF 描述了光线如何从表面反射:给定一个入射方向 ω_i,计算反射到出射方向 ω_o 的光强比例。

fr(ωi,ωo)=dLo(ωo)dEi(ωi)=dLo(ωo)Li(ωi)cos(θi)dωif_r(\omega_i, \omega_o) = \frac{dL_o(\omega_o)}{dE_i(\omega_i)} = \frac{dL_o(\omega_o)}{L_i(\omega_i) \cos(\theta_i) d\omega_i}

物理意义

  • 输入:入射光方向 ω_i、出射观察方向 ω_o
  • 输出:反射光亮度 L_o
  • 单位:sr⁻¹(每立体弧度)

TIP

BRDF 是一个辐射率比,而非绝对值。它描述了材质的”反射特性”,不直接给出亮度。

光照模型对比

模型年份漫反射镜面反射物理真实计算复杂度
Lambert1790极低
Phong1975
Blinn-Phong1977
PBR2010
graph LR
    A[Lambert<br/>漫反射] --> B[Phong<br/>镜面反射]
    B --> C[Blinn-Phong<br/>半角向量优化]
    C --> D[PBR<br/>物理真实]
    D --> E[Ray Tracing<br/>光线追踪]

Lambert 漫反射(1790)

假设光线向所有方向均匀散射。适用于纸张、布料等粗糙表面。

Lo=kdρπLicos(θi)L_o = \frac{k_d \cdot \rho}{\pi} \cdot L_i \cdot \cos(\theta_i)

Phong 镜面反射(1975)

模拟平滑表面的镜面高光。高光区域由视线与反射向量的夹角控制。

Lspecular=ksLimax(0,cos(α))shininessL_{specular} = k_s \cdot L_i \cdot \max(0, \cos(\alpha))^{shininess}

Blinn-Phong 改进版(1977)

引入半角向量 H(Light View 的角平分线),避免计算反射向量 R

Lspecular=ksLimax(0,NH)shininessL_{specular} = k_s \cdot L_i \cdot \max(0, N \cdot H)^{shininess}

NOTE

Blinn-Phong 在性能和效果之间取得平衡,是传统游戏引擎的默认选择。

PBR(物理真实渲染)

核心原则

  1. 能量守恒:反射光 + 透射光 = 入射光
  2. 菲涅尔效应:掠射角反射率增加
  3. 微表面理论:表面由无数微小的镜面构成

Metallic-Roughness 工作流

  • Albedo:基础颜色(金属为反射色,非金属为漫反射色)
  • Metallic:金属度(0 = 绝缘体,1 = 金属)
  • Roughness:粗糙度(0 = 镜面,1 = 完全漫反射)
  • Normal:法线贴图(细节凹凸)
  • AO(Ambient Occlusion):环境光遮蔽(缝隙变暗)

法线贴图(Normal Map)

切线空间(Tangent Space)的法线存储在纹理中,模拟表面凹凸细节而不增加几何复杂度。

TBN 矩阵:将切线空间法线转换为世界空间。

TBN=[TxBxNxTyByNyTzBzNz]\text{TBN} = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix}

环境光遮蔽(Ambient Occlusion)

模拟几何遮挡对环境光的影响:缝隙、角落、褶皱处接收到的环境光更少。

  • SSAO(Screen-Space AO):基于深度缓冲的后处理技术
  • HBAO(Horizon-Based AO):考虑地平线遮挡,效果更准确
  • GTAO(Ground-Truth AO):基于全局光照的近似

原理与数学机制

Blinn-Phong 完整公式

Lout=kaIambient+kdIdiffusemax(0,NL)+ksIspecularmax(0,NH)shininessL_{out} = k_a \cdot I_{ambient} + k_d \cdot I_{diffuse} \cdot \max(0, N \cdot L) + k_s \cdot I_{specular} \cdot \max(0, N \cdot H)^{shininess}

分量分解

  • k_a:环境光系数
  • k_d:漫反射系数
  • k_s:镜面反射系数
  • N:表面法线
  • L:光照方向
  • H:半角向量 normalize(L + V)
  • V:视线方向

PBR Cook-Torrance BRDF

fr=kdflambert+ksfspecularf_r = k_d \cdot f_{lambert} + k_s \cdot f_{specular}

漫反射项

flambert=cπf_{lambert} = \frac{c}{\pi}

镜面反射项

fspecular=D(h)F(v,h)G(l,v,h)4(nl)(nv)f_{specular} = \frac{D(h) \cdot F(v, h) \cdot G(l, v, h)}{4 \cdot (n \cdot l) \cdot (n \cdot v)}

三个核心函数

  1. D(h) - 法线分布函数(GGX/Trowbridge-Reitz)
D(h)=α2π((nh)2(α21)+1)2D(h) = \frac{\alpha^2}{\pi \cdot ((n \cdot h)^2 \cdot (\alpha^2 - 1) + 1)^2}
  1. F(v, h) - 菲涅尔函数(Schlick 近似)
F(v,h)=F0+(1F0)(1(vh))5F(v, h) = F_0 + (1 - F_0) \cdot (1 - (v \cdot h))^5
  1. G(l, v, h) - 几何遮蔽函数(Smith GGX)
G(l,v,h)=G1(n,l)G1(n,v)G(l, v, h) = G_1(n, l) \cdot G_1(n, v) G1(n,x)=nx(nx)(1k)+kG_1(n, x) = \frac{n \cdot x}{(n \cdot x) \cdot (1 - k) + k}

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]

解包公式

Ntangent=texture(normalMap,UV)×2.01.0N_{tangent} = \text{texture}(normalMap, UV) \times 2.0 - 1.0

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 定义常量

常见误区与陷阱

  1. 法线贴图未解包

    • 陷阱:直接使用 [0, 1] 范围的法线纹理
    • 解决:normal * 2.0 - 1.0 解包到 [-1, 1] 范围
  2. PBR 能量不守恒

    • 陷阱:kD + kS > 1 导致反射光超过入射光
    • 解决:确保 kS = F0(菲涅尔),kD = 1.0 - kS * (1.0 - metallic)
  3. ** Metallic-Roughness 参数范围错误**

    • 陷阱:metallic 超出 [0, 1]roughness 为负数
    • 解决:在材质编辑器中强制 clamp 到 [0, 1] 范围
  4. TBN 矩阵未归一化

    • 陷阱:切线、副切线、法线未归一化,导致光照计算错误
    • 解决:T = normalize(T); B = normalize(B); N = normalize(N)
  5. 菲涅尔 F0 值错误

    • 陷阱:所有材质使用相同 F0(如 0.04)
    • 解决:根据材质物理属性调整 F0(水 0.02,金 1.0,铜 0.95)
  6. 环境光遮蔽(AO)未应用

    • 陷阱:仅使用环境光,AO 纹理被忽略
    • 解决:ambient = 0.1 * albedo * ao,将 AO 纹理采样到环境光项
  7. 高光指数过大

    • 陷阱:shininess 设置为 256+,导致高光过锐或消失
    • 解决:根据材质调整(塑料 8-32,金属 64-128)
  8. SSAO/HBAO 采样数过高

    • 陷阱:采样 64+ 次导致帧率暴跌
    • 解决:使用分层采样 + 旋转噪声,或降分辨率计算

延伸阅读与自测

权威资料

开源实现参考

自测题

  1. 思考题: 为什么 PBR 中的 roughness 值要平方(a = roughness * roughness)?

  2. 对比题: Blinn-Phong 与 PBR 在高光计算上的根本区别是什么?

  3. 实践题: 如何实现一个支持多光源的 PBR Shader?如何优化 for 循环?

  4. 扩展题: SSAO、HBAO、GTAO 三种 AO 技术的优缺点对比?

  5. 优化题: PBR 环境光如何使用预过滤立方体贴图(Prefiltered Cube Map)加速?


参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。