ShaderLab:PBR+IBL(ShaderToy Translation)

本篇文章主要分析学习ShaderToy上的一个PBR+IBL 代码,它使用了较传统着色器代码更新的内容。在其描述的场景中包含了9个球体,被赋予了不同的材质。摄像机总是面向原点,在一个球面上移动、旋转。

ShaderToy源代码:https://www.shadertoy.com/view/3tlBW7

学习这个代码对于理解现代渲染管线有很大帮助,其中包含了很多概念实践的代码,包括但不限于:CubeMap采样、球谐函数SH系数计算、PBR利用CubeMap的漫反射和镜面反射计算、GGX采样、IBL、BRDF等。

我首先利用大模型将这个ShaderToy源代码转换成我熟悉的ShaderLab代码,并且在Unity中已经编译、运行,并证明转换后的代码是可运行的,并且效果不错。效果上的偏差,我将会在接下来的文章进行修改。

效果预览

源代码效果:

ShaderLab代码效果:

关键逻辑解析

翻译后的文件有6个,其中有4个shader文件(BufferA_Camera、BufferB_DiffuseIBL、BufferC_SpecularIBL、Image_PBR)、1个HLSL头文件(PBR_IBL_Common)、1个C#脚本(PBRIBLRunner),接下来我将按顺序逐个解析每个文件。

本篇文章将详细解释三个独立 .shader 文件:BufferA_Camera、BufferB_DiffuseIBL、BufferC_SpecularIBL,并且将 PBR_IBL_Common 中的部分函数在关键用到的地方单独拿出来解释。

这套 PBR + IBL 管线 大概是:

1、BufferB_DiffuseIBL.shader 负责环境贴图的漫反射 IBL,生成球谐 SH 数据,同时输出一张equirectangular 环境图。

2、BufferC_SpecularIBL.shader 读取 BufferB 的环境图,生成不同 roughness 下的高光反射预滤波结果,以及 BRDF 积分图。

3、Image_PBR.shader 最终渲染场景,读取:BufferB:漫反射 irradiance / 环境背景 BufferC:specular IBL / BRDF LUT

BufferA_Camera.shader

这个文件的核心作用是:每帧把相机交互状态写入一张 RenderTexture,供后续 PBR/IBL 渲染 pass 读取。它不是最终画面 shader,也不直接做 PBR 光照。它更像 Shadertoy 里的 Buffer A:用纹理的几个像素当"小型状态内存",保存鼠标、相机角度、相机位置、分辨率变化等信息。

这个着色器输出的 RenderTexture 的前 4 个像素被当作状态表:

cpp 复制代码
pixel (0,0): polar angles xy + normalized mouse pos zw
pixel (0,1): world-space camera position xyz
pixel (0,2): resolution-change flag x + current resolution yz
pixel (0,3): mouse button down this frame

具体含义是:

像素 内容 用途
(0, 0) xy = 极坐标角度,zw = 上一帧鼠标归一化位置 用于根据鼠标拖拽更新相机角度
(0, 1) xyz = 世界空间相机位置 最终渲染 pass 用它生成视线
(0, 2) x = 分辨率是否变化,yz = 当前分辨率 给其他 buffer 判断是否需要重置/重算
(0, 3) 鼠标左键是否按下 判断当前是否处于连续拖拽

_Channel0 表示上一帧渲染出来的 BufferA。因为 shader 本身没有持久内存,所以要靠 RenderTexture 保存历史状态。

对于一个典型的全屏 Blit pass的设置:

cpp 复制代码
ZTest Always // 永远通过深度测试
ZWrite Off   // 表示不写入深度
Cull Off     // 表示不剔除正反面

因为这个 shader 是用来写 RenderTexture 的,不是在场景里画实体网格,所以不需要深度和剔除。

顶点 shader 很简单:把输入顶点从对象空间变换到裁剪空间,并把 UV 传给片元 shader。

对于 Graphics.Blit 来说,它通常会绘制一个覆盖目标 RenderTexture 的全屏四边形。

fragment shader
cpp 复制代码
float2 res = _Resolution.xy;
int2 px = int2(floor(IN.uv * res));

这里把 UV 转成整数像素坐标,例如分辨率是 1280 x 720,某个 fragment 的 UV 是 (0.0, 0.0014),乘以分辨率后大概落到某个具体像素。floor 后得到整数坐标。

fragment中的核心逻辑只处理 Render Texture 的第一行的前四列,也就是只处理(0, 0)、(0, 1)、(0, 2)、(0, 3)四个像素,其余像素颜色为:float4(0, 0, 0, 1)。

读取上一帧状态:

cpp 复制代码
// 读取上一帧的 pixel(0, 0)
float4 oldData       = _Channel0.Load(int3(0, 0, 0));
// oldData.xy = 上一帧相机极坐标角度
float2 oldPolarAngles = oldData.xy;
// oldData.zw = 上一帧鼠标位置
float2 oldMouse       = oldData.zw;

这里使用的是 Load,不是普通的 SAMPLE_TEXTURE2D。原因是它要精确读取某个整数像素,不希望经过双线性过滤。

SAMPLE_TEXTURE2D : 是按 UV 采样纹理,会经过 SamplerState,受纹理的过滤模式、Wrap 模式、MipMap 影响。
Load: 是按像素坐标直接读取 texel,更像"数组读取",不走双线性过滤,不走 Wrap,不自动根据 UV 选择 Mip,而是自己指定(所以其输入是一个 int3,第三个数用来指定 Mip)。Microsoft 的 HLSL 文档里,Sample 是 texture object 的采样函数,而 Load 是直接读取指定位置的纹理数据。

cpp 复制代码
// 把当前鼠标坐标归一化到 0 ~ 1
// 这样相机旋转素嘟就不直接依赖于屏幕分辨率
float2 mouse = _Mouse.xy / res

// 读取上一帧鼠标是否按下
float mouseDownLastFrame = _Channel0.Load(int3(0, 3, 0)).x;

// 鼠标拖拽更新相机视角
if (_Mouse.z > 0.0 && mouseDownLastFrame > 0.0){
	// 鼠标移动距离
    float2 mouseMove = mouse - oldMouse;
    // 鼠标更新后的位置,鼠标灵敏度: x-5.0, y-3.0
    polarAngles = oldPolarAngles + float2(5.0, 3.0) * mouseMove;
}else
{
    polarAngles = oldPolarAngles;
}

// 水平角,限制在 0 ~ 2PI-0.01 之间,防止万向节锁死
polarAngles.x = modulo(polarAngles.x, TWO_PI - angleEps);
// 垂直角,限制在 0.01 ~ PI-0.01 之间,防止万向节锁死和翻转
polarAngles.y = min(PI - angleEps, max(angleEps, polarAngles.y));

接下来计算 pixel(0, 0):极坐标水平角和垂直角 + 鼠标位置

cpp 复制代码
// pixel (0,0): polar angles + mouse position
if(px.y == 0){
	// 前 10 帧强制初始化:水平角 = 45 度,垂直角 = 约 76.2 度
	if (_Frame < 10) {
          polarAngles = float2(PI / 4.0, 1.33);
          mouse = float2(0, 0);
    }
    // 在 pixel (0,0) 写入极角和鼠标位置,供下一帧读取
    return float4(polarAngles, mouse);
}

然后计算 pixel(0, 1):相机的世界位置计算

cpp 复制代码
// pixel (0,1): camera world position
if(px.y == 1){
	float3 camDir = normalize(float3(
		-sin(polarAngles.y) * cos(polarAngles.x),
		cos(polarAngles.y),
		-sin(polarAngles.y) * sin(polarAngles.x)));
	return float4(CAMERA_DIST * camDir, 1.0);
}

然后计算 pixel(0, 2):分辨率改变标志 + 当前分辨率

cpp 复制代码
// pixel (0,2): resolution change flag + current resolution
if(px.y == 2){
	float2 oldRes = _Channel.Load(int3(0, 2, 0)).yz;
	float flag = (any(res != oldRes)) ? 1.0 : 0.0;
	return float4(CAMERA_DIST * camDir, 1.0);
}

最后计算 pixel(0, 3):鼠标是否按下标志

cpp 复制代码
float down = (_Mouse.z > 0) ? 1.0 : 0.0;
return float4(down, down, down, 1.0);

BufferB_DiffuseIBL.shader

这个文件的核心作用是:把环境 Cubemap 转换成 PBR 渲染后续需要的"环境光数据"。它同时做两件事:

1、把 _EnvCubemap 展开成一张经纬图,存在整张 RenderTexture 里,供背景显示和 BufferC 的镜面 IBL 预滤波使用。

2、在左上角极少数像素里偷偷存储漫反射 IBL 用的球谐光照矩阵,也就是 diffuse irradiance 的压缩表示。

简单说,BufferB 是整个 PBR + IBL 链路中的"环境光预处理 Pass"。这个 shader 不是直接画最终画面,而是渲染 RenderTexture。后面的 Image_PBR.shader 会读取它来算最终 PBR 光照。

这个 BufferB 输出的 RenderTexture 大概的样子:

1、 整张图大部分区域:

环境 Cubemap 的 equirectangular 经纬展开图

2、左上角特殊像素:

(0,0)-(0,3): 红色通道的 SH irradiance 矩阵 4 行

(1,0)-(1,3): 绿色通道的 SH irradiance 矩阵 4 行

(2,0)-(2,3): 蓝色通道的 SH irradiance 矩阵 4 行

3、额外元数据:

(4,4): 当前 Cubemap 的一个采样颜色,用于检测环境贴图变化

(5,5): 当前渲染分辨率

也就是说,它把"图片"和"数据表"混存在同一张浮点 RenderTexture 里,这是 ShaderToy 风格的常见技巧。

重要变量
cpp 复制代码
_EnvCubemap   // 环境 Cubemap 输入
_EnvExposure  // 控制环境亮度
_EnvBloom     // 做一个简单的高亮增强
采样环境光函数
cpp 复制代码
float3 getRadiance(float3 dir)
{
	// 采样 CubeMap 的 dir 方向上的环境颜色
    float3 col = SAMPLE_TEXTURECUBE_LOD(_EnvCubemap, sampler_EnvCubemap, dir, 0.0).rgb;
    // _DecodeGamma 决定是否把采样到的颜色从 gamma 空间转回 linear 空间
    if (_DecodeGamma > 0.5)
        col = inv_gamma(col);
    // 修正环境亮度
    col *= _EnvExposure;
    // 简单的亮度增强,亮的更亮、暗的更暗
    col += _EnvBloom * col * col;
    return col;
}
fragment shader
cpp 复制代码
// 采样一个固定方向上的环境光颜色作为哨兵,来判断环境贴图是否被替换
float3 currentColour = SAMPLE_TEXTURECUBE_LOD(_EnvCubemap, sampler_EnvCubemap, normalize(float3(1, 1, 1)), 0.0).rgb;

接下来将 Cubemap 展开成经纬图:

cpp 复制代码
// 将 uv 转化成球面上的极坐标 site = (r, thetaphi.x, thetaphi.y)
// ((uv * 2.0) - 1.0) 将 uv 映射到 [-1.0, 1.0]
// thetaphi.x ∈ [-PI, PI], 表示水平角度
// thetaphi.y ∈ [-PI/2.0, PI/2.0], 表示垂直角度
float2 thetaphi = ((uv * 2.0) - 1.0) * float2(PI, HALF_PI);
// 这里就是通过极坐标两个角度来获取,从球心指向球面位置的向量(反转y轴)
float3 rayDir = float3(
     cos(thetaphi.y) * cos(thetaphi.x),
    -sin(thetaphi.y),
     cos(thetaphi.y) * sin(thetaphi.x));
// 通过角度采样 Cubemap 获取环境光颜色 col, 并限制其大于 0
float4 col = float4(getRadiance(rayDir), 1.0);
// 为了避免颜色为 0。很多 PBR 计算里会有除法、log 或能量补偿,极小正数比纯 0 更稳定。
col.x = max(col.x, 1e-5);
col.y = max(col.y, 1e-5);
col.z = max(col.z, 1e-5);
计算**球谐函数(SH)**矩阵

球谐可以理解成"用少量系数近似整个环境光照"。对于漫反射 IBL 来说,高频细节不重要,因为 Lambert 漫反射本来就会把光照模糊掉。所以不需要每次都对半球大量采样,可以把环境光压缩成 9 个 SH 系数。

首先定义 9 个系数:

cpp 复制代码
// 每个都是 float3,因为 RGB 三个颜色通道各有一套球谐系数。
float3 L00;
float3 L1_1;
float3 L10;
float3 L11;
float3 L2_2;
float3 L2_1;
float3 L20;
float3 L21;
float3 L22;

然后使用 Fibonacci spiral 在球面上均匀采样:

cpp 复制代码
// 每次采样都再上一个采样点的水平角度上加 goldenAngle
float goldenAngle = PI * (3.0 - sqrt(5.0));
// 根据屏幕水平分辨率设置采样次数
// 也就是说分辨率较低时用 1024 次采样,分辨率非常高时反而用 128 次,应该是为了避免高分辨率时这个 pass 太贵。
int sampleCount = (res.x < 2000.0) ? SH_SAMPLE_COUNT : SH_LOW_SAMPLE_COUNT;

采样循环求和:

cpp 复制代码
for (int si = ZERO; si < sampleCount; si++)
{
	// 从球体 y = 1.0 开始采样,一直到 y = -1.0
    float y      = 1.0 - (i / sampleCount) * 2.0;
    // 在 Y = y 平面处对球体进行切割,切面圆的半径 radius
    float radius = sqrt(1.0 - y * y);
    // 计算本次采样的水平旋转角
    float theta  = goldenAngle * i;
    // 本次采样的 x、y 坐标
    float x      = cos(theta) * radius;
    float z      = sin(theta) * radius;

	// 本次采样的方向向量
    float3 dir      = normalize(float3(x, y, z));
    // 在 Cubemap 上对方向向量 dir 采样,获取该方向上的辐照度
    float3 radiance = getRadiance(dir);

	// Y00 ~ Y22 是球谐基函数常量,它们定义在公共文件里
	// 采样并对每一个系数进行累加
	L00  += radiance * Y00;
	L1_1 += radiance * Y1n * dir.y;
	L10  += radiance * Y1n * dir.z;
	L11  += radiance * Y1n * dir.x;
	L2_2 += radiance * Y2n * dir.x * dir.y;
	L2_1 += radiance * Y2n * dir.y * dir.z;
	L20  += radiance * Y20 * (3.0 * dir.z * dir.z - 1.0);
	L21  += radiance * Y2n * dir.x * dir.z;
	L22  += radiance * Y22 * (dir.x * dir.x - dir.y * dir.y);
}

计算球面积分近似:

cpp 复制代码
// 从离散求和到球面积分近似的系数(球表面积 = 4 * PI)
float factor = (4.0 * PI) / sampleCount;
// 计算
L00 *= factor;
...
L22 *= factor;

把球谐系数转换成 irradiance 矩阵:

cpp 复制代码
// c1 ~ c5 定义在公共头文件中
// float c1 = 0.5;      // 或 0.247
// float c2 = 0.5;      // 或 0.429
// float c3 = 0.5;      // 或 0.315
// float c4 = 0.282;    // 1/(2*sqrt(pi))
// float c5 = 0.315;    // 0.5*sqrt(5/4π)
float2 px = int2(floor(uv * res));
int row = px.y;
if(px.x == 0){ // 红色通道 SH 系数
	float4x4 M;
	M[0] = float4(c1*L22.r,  c1*L2_2.r, c1*L21.r,  c2*L11.r);
	M[1] = float4(c1*L2_2.r,-c1*L22.r,  c1*L2_1.r, c2*L1_1.r);
	M[2] = float4(c1*L21.r,  c1*L2_1.r, c3*L20.r,  c2*L10.r);
	M[3] = float4(c2*L11.r,  c2*L1_1.r, c2*L10.r,  c4*L00.r - c5*L20.r);
	col  = M[row];
}else if(px.x == 1) // 绿色通道 SH 系数
{...}
else if(px.x == 2) // 蓝色通道 SH 系数
{...}
// pixel(4, 4)设置为哨兵,如果哨兵颜色改变表示 Cubemap 发生改变
if(px.x == 4 && px.y == 4){
	col = float4(currentColour, 1.0);
}
// pixel(5, 5)存储当前屏幕分辨率
if(px.x == 5 && px.y == 5){
	col = float4(res, 0.0, 1.0);
}

BufferC_SpecularIBL.shader

这个文件把 BufferB 生成的环境贴图预处理成"高光 IBL"需要的两类缓存:预滤波环境贴图 atlas 和 BRDF 积分 LUT,供最终的 Image_PBR.shader 做 PBR 间接高光反射使用。BufferC 输出的是一整张 RenderTexture,但不同区域存不同数据:

1、上半部分 uv.y < 0.5 存高光环境预滤波图:

Upper half, left 1/2: roughness 0.0

Upper half, next 1/4: roughness 0.25

Upper half, next 1/8: roughness 0.5

Upper half, next 1/16: roughness 0.75

Upper half, next 1/32: roughness 1.0

也就是 roughness 越大,分配的 atlas 区域越小。因为粗糙表面对环境细节需求更低,可以用更低分辨率保存。

2、下半部分左半边 uv.y >= 0.5 && uv.x < 0.5 存 BRDF 积分图:

Lower half, left 1/2: BRDF integration map

这个 BRDF LUT 的横轴是 NdotV,纵轴是 roughness,输出两个通道,用于 split-sum approximation。

Shader 属性
cpp 复制代码
 _Channel1   // 来自 BufferB 的输出。这里主要使用其中的 equirectangular 环境贴图。
_Channel2    // BufferC 上一帧自己的结果,用于缓存。
_Frame       // 当前帧编号。
_Resolution  // 渲染纹理分辨率。

BufferC 输出不是每一帧都完整重算。完整计算很贵,所以它会在特定时机更新,否则直接复制上一帧。

顶点阶段
cpp 复制代码
Varyings vert(Attributes IN)
{
    Varyings OUT;
    OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz);
    OUT.uv = IN.uv;
    return OUT;
}

标准 full-screen pass 顶点函数。

采样 BufferB 环境图
cpp 复制代码
// 根据方向采样 BufferB 输出的 Cubemap 
float3 getEnvironmentB1(float3 rayDir, float level)
{
	// 将方向向量转化为采样 BufferB输出的 RT 的 uv
    float2 tc = float2((atan2(rayDir.z, rayDir.x) / TWO_PI) + 0.5,
                        acos(rayDir.y) / PI);
    // 采样并返回当前方向的环境光
    return SAMPLE_TEXTURE2D_LOD(_Channel1, sampler_Channel1, tc, level).rgb;
}

其中:

atan2(x, y): 计算点 (x, y) 相对于原点的方位角(极角),返回 [-π, π] 范围内的弧度值

acos(x): 计算余弦值为 x 的角度,返回 [0, π] 弧度

预滤波环境贴图

公共文件函数:
hammersley 函数

cpp 复制代码
// 把一个 32 位整数的二进制位完全反转
uint ReverseBits32(uint bits){
    bits = (bits << 16u) | (bits >> 16u);
    bits = ((bits & 0x55555555u) << 1u)  | ((bits & 0xAAAAAAAAu) >> 1u);
    bits = ((bits & 0x33333333u) << 2u)  | ((bits & 0xCCCCCCCCu) >> 2u);
    bits = ((bits & 0x0F0F0F0Fu) << 4u)  | ((bits & 0xF0F0F0F0u) >> 4u);
    bits = ((bits & 0x00FF00FFu) << 8u)  | ((bits & 0xFF00FF00u) >> 8u);
    return bits;
}
// 将反转后的32位整数映射到[0, 1]
// 2.3283064365386963e-10 ≈ 2^(-32)
float radicalInverse(uint bits){
    return float(ReverseBits32(bits)) * 2.3283064365386963e-10;
}
// 输入遍历序号 i 和 遍历总数 sampleCount
// 输出 i/sampleCount 和 反转后被映射到[0, 1]的 i 
float2 hammersley(int i, int N){
    return float2(float(i) / float(N), radicalInverse(uint(i)));
}
i sampleCount ReverseBit4 radicalInverse hammersley
0 8 0000->0000 0 (0, 0)
1 8 0001->1000 0.5 (0.125, 0.5)
2 8 0010->0100 0.25 (0.25, 0.25)
3 8 0011->1100 0.75 (0.375, 0.75)
4 8 0100->0010 0.125 (0.5, 0.125)
5 8 0101->1010 0.625 (0.625, 0.625)
6 8 0110->0110 0.375 (0.75, 0.375)
7 8 0111->1110 0.875 (0.875, 0.875)

由上表可以看出,总采样数为 8 时,会生成伪随机平均分布在[0, 1]范围内的二维向量。
importanceSampleGGX 函数

cpp 复制代码
// 把局部空间中的方向 L 旋转到以 N 为法线的世界空间
float3 rotateToNormal(float3 L, float3 N)
{
	// 根据法线 N 构建一组局部坐标轴
	// tangent:切线方向、bitangent:副法线方向、N:法线方向
    float3 tangent, bitangent;
    pixarONB(N, tangent, bitangent);
    // 将切线和副切线归一化
    tangent   = normalize(tangent);
    bitangent = normalize(bitangent);
    // L 是在坐标系 N、tangent、bitangent 下的方向向量
    // 返回的是 L 在世界坐标系下的坐标
    return normalize(tangent * L.x + bitangent * L.y + N * L.z);
}
// 根据 GGX 微表面分布生成一个半程向量 H
// 输入 Xi:二维采样点、N:法线向量、roughness:粗糙度
float3 importanceSampleGGX(float2 Xi, float3 N, float roughness)
{
	// 将粗糙度转换成 GGX 参数
	// roughness 越小,采样越集中在法线附近; roughness 越大,采样越分散
    float a = roughness * roughness;
    // GGX 重要采样公式: 根据 Xi.x 生成极角 theta 的正、余弦值
    float cosTheta = sqrt(
    	(1.0 - Xi.x) / 
    	(1.0 + (a * a - 1.0) * Xi.x));
    float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
    // 将 Xi.y 映射到方向角
    float phi = Xi.y * TWO_PI;
    // 根据球坐标生成局部半球方向
    float3 L = normalize(float3(
    	cos(phi) * sinTheta, 
    	sin(phi) * sinTheta, 
    	cosTheta));
	// L 是一个"以 z 轴为法线"的局部方向,将其旋转到真实法线 N 的周围
    return rotateToNormal(L, N);
}

利用生成的伪随机二维向量 Xi 的 x 分量,来生成 theta 角,这个 theta 角是生成向量与 n = (0, 0, 1) 之间的夹角;利用 Xi 的 y 分量,来生成 phi 角,这个 phi 角是水平角,用于设置生成向量绕 n 一周。

当 roughness = 0.5 时,有如下表格:

hammersley cosTheta sinTheta cosPhi sinPhi L
(0.125, 0.5) 0.996 0.009 -1 0 (-0.009, 0, 0.996)
(0.25, 0.25) 0.990 0.141 0 1 (0, 0.141, 0.990)
(0.375, 0.75) 0.982 0.190 0 -1 (0, -0.190, 0.982)
(0.5, 0.125) 0.970 0.243 0.707 0.707 (0.172, 0.172, 0.970)
(0.625, 0.625) 0.952 0.307 -0.707 -0.707 (-0.217, -0.217, 0.952)
(0.75, 0.375) 0.918 0.394 -0.707 0.707 (-0.279, 0.279, 0.918)
(0.875, 0.875) 0.834 0.551 0.707 -0.707 (0.390, -0.390, 0.834)

函数的到的是若干围绕在 n = (0, 0, 1) 附近的向量。
预滤波函数

cpp 复制代码
// 通过法线方向 N 和粗糙度 roughness, 通过重要性采样 GGX 分布得到"预过滤环境光颜色"
// 通常用于 PBR IBL 的镜面反射环境光
float3 getPreFilteredColour(float3 N, float roughness, int sampleCount)
{
	// R:反射向量、N:法线向量、V:视角向量(被初始化为法线向量)
	// 假设观察方向正好等于反射方向,也等价于假设视角方向和表面法线对齐。
	float3 R = N;
    float3 V = R;

	// 权重累计,最后做归一化,防止颜色过亮或过暗
    float  totalWeight      = 0.0;
    // 用于累加采样到的环境颜色
    float3 prefilteredColor = float3(0,0,0);

	// #define ZERO 0
    for (int i = ZERO; i < sampleCount; i++)
    {
    	// 生成一个伪随机、平均分布的二维采样点 Xi.x、Xi.y ∈ [0, 1]
    	// Hammersley 序列是一种低差异,比纯随机采样更均匀
    	float2 Xi = hammersley(i, sampleCount);
    	// 根据 Xi 和 roughness 生成一个在法线 N 的附近的半程向量 H
    	// 其中 roughness 越大,生成的半程向量离法线 N 越远
        float3 H  = importanceSampleGGX(Xi, N, roughness);
        // H = normalize(L + V), 所以根据 V 和 H 可以计算出 L
        float3 L  = normalize(reflect(-V, H));

		// dot_c = max(dot(a, b), 0)
        float NdotL = dot_c(N, L);
        // 只保留表面上半球方向的采样,下半的反射看不到
        if (NdotL > 0.0)
        {
        	// 初始化环境贴图的 mip level, level 越大,采样越模糊的 mip
        	float level = 0.0;
        	// 条件编译,如果定义了就启用这部分代码,计算合适的 mip level
			#if ENV_FILTERING == 1
				// 计算两个点积
				// 法线和半程向量的夹角余弦
            	float NdotH = dot_c(N, H);
            	// 观察方向和半程向量的夹角余弦
            	float VdotH = dot_c(V, H);
            	// 计算采样概率密度 pdf
            	float pdf    = distributionIBL(NdotH, roughness * roughness) * NdotH / (4.0 * VdotH);
            	// 当前采样大约覆盖的立体角
                float omegaS = 1.0 / (float(sampleCount) * pdf);
                // 表示环境 cubemap 中一个像素大约覆盖的立体角
                float omegaP = 4.0 * PI / (6.0 * 512.0 * 512.0);
                level = max(0.5 * log2(omegaS / omegaP) + 1.0, 0.0);
			#endif
			// 沿着方向 L,在指定 mip level 上采样环境颜色
			// NdotL 这是 Lambert 余弦权重,方向越接近法线,贡献越大;越贴近边缘,贡献越小
            prefilteredColor += getEnvironmentB1(L, level) * NdotL;
            // 累加权重
            totalWeight      += NdotL;
        }
    }
    // 返回归一化后的颜色
    return prefilteredColor / totalWeight;
}

整体流程可以总结为:

  1. 输入方向 N 和粗糙度 roughness
  2. 用 Hammersley 生成均匀采样点
  3. 用 GGX 重要性采样生成半程向量 H
  4. 根据 V 和 H 反推出环境采样方向 L
  5. 判断 L 是否在表面上半球
  6. 根据 pdf 估算 mip level
  7. 从环境贴图中采样颜色
  8. 按 NdotL 加权累加
  9. 最后除以总权重,得到预过滤后的环境反射颜色

这个函数是在模拟"不同粗糙度的表面对环境光的模糊反射",粗糙度越大,采样方向越分散,最终环境反射越模糊。

BRDF_LUT

公共函数库:

cpp 复制代码
// 单方向遮蔽
// cosTheta 通常是 NdotV、NdotL
float geometryIBL(float cosTheta, float k)
{
	// cosTheta 越小,方向越贴近表面,遮蔽越强,返回值越小
    return cosTheta / (cosTheta * (1.0 - k) + k);
}
// 双方向遮蔽
float smithShadowing(float NdotV, float NdotL, float roughness)
{
	// 由粗糙度转换来的遮蔽强度参数, roughness 越大, k 越强, 遮蔽越强
    float k = (roughness * roughness) * 0.5;
    // 分别计算: 视线方向遮蔽 masking, 光照方向遮蔽 shadowing
    return geometryIBL(NdotV, k) * geometryIBL(NdotL, k);
}

BRDF interation map 函数

这个 BRDF interation map 是一个二维表,横轴 NdotV,纵轴 roughness,每个像素存两个值:result.x = Fresnel 边缘补偿,result.y = 基础反射缩放。

cpp 复制代码
// 
float2 integrateBRDF(float NdotV, float roughness, int sampleCount)
{
	// 因为BRDF LUT 只跟角度有关,不需要真实世界方向,所以固定法线方向为(0, 0, 1)
	float3 N = float3(0, 0, 1);
	// NdotV = N dot V, 知道 NdotV 和 N 就有了 V 
	float3 V = normalize(float3(sqrt(1.0 - NdotV * NdotV), 0.0, NdotV));
	// 最终的累加结果
	float2 result = float2(0, 0);
	// 循环采样次数 sampleCount, 并将结果累加
	for (int i = ZERO; i < sampleCount; i++)
	{
		// 同样通过 hammersley 计算出伪随机的二维点 Xi
		float2 Xi = hammersley(i, sampleCount);
		// 再根据随机二维点 Xi 和 roughness 生成一个在 N 附近的一个半角向量
		// H 是 GGX 计算出来的,代表微表面法线方向
		float3 H  = importanceSampleGGX(Xi, N, roughness);
		// H = normalize(L + (-V)), 知道 H 和 V 就能计算出 L
		float3 L  = normalize(reflect(-V, H));
		// dot_c = max(dot(x, y), 0);
		// NdotL、NdotH 用于计算视线和光线方向遮蔽比例
		float NdotL = dot_c(N, L);
		float NdotH = dot_c(N, H);
		// VdotH 表示视线方向和微表面法向之间夹角的余弦值
		float VdotH = dot_c(V, H);
		// 如果 NdotL ≤ 0 则表示光线方向和法线方向夹角大于等于 90°, 光线贡献度为0
		if (NdotL > 0.0)
		{
			// 计算菲涅尔反射的部分
			// G 是几何遮蔽,G = masking * shadowing
			// G 表示 视线方向可见比例 x 光线方向可见比例
			float G = smithShadowing(NdotV, NdotL, roughness);
			// S 是当前这个采样方向对镜面反射积分的贡献权重
			float S = (G * VdotH) / (NdotH * NdotV);
			// F 是 Fresnel 中随角度变化的那部分,表达的是: 越接近边缘视角,反射越强
			float F = pow(1.0 - VdotH, 5.0);
			// 把镜面 IBL 积分结果拆成两个系数,之后用 F0 重新组合
			// 考虑边缘 Fresnel 增强后,这个采样额外贡献多少
			result.x += F * S;
			// 不考虑菲涅尔变化时,这个采样本身贡献多少镜面反射
			result.y += S;
		}
	}
	return result / float(sampleCount);
}

几何遮蔽项 G ,也叫 Geometry Term / Shadowing-Masking Term

它描述的是微表面之间相互遮挡的程度,所以 G 会降低掠射角、粗糙表面上的镜面反射贡献:

  • NdotV:描述视线与微表面之间的角度关系,越小,看到的越容易被微表面挡住;
  • NdotL:描述光线与微表面之间的角度关系,越小,光线越容易被微表面挡住;
  • roughness:描述微表面的粗糙度,越大,表面越粗糙,微表面遮挡越明显。

贡献权重 S ,可以理解为:当前这个采样方向对镜面反射积分的贡献权重
Fresnel 边缘反射增强比例 F

Fresnel 通常写成 Schlick 近似:

F = F0 + (1 - F0) * pow(1 - VdotH, 5) = F0 * (1 - pow(1 - VdotH, 5)) + pow(1- VdotH, 5)

其中:

  • result.x ≈ 1-pow(1 - VdotH, 5)
  • result.y ≈ pow(1 - VdotH, 5)
  • F0 : 当视线垂直看向表面时(入射角 = 0°)的基础反射率。也叫 Base Reflectivity(正视角反射率),它表示:材质在"正面观察"时会反射多少光。

例如:

非金属:F0 = 0.04,表示正面观察时只有 4% 光被反射,其余 96% 进入材质内部。

金属:F0 很高,说明金属即使正面观察也会强烈反射。

常见物体的 F0,其中三维向量是 RGB 每个分量反射程度不一样:

材质 F0
塑料 0.04
0.02
玻璃 0.04 ~ 0.08
(1.0, 0.71, 0.29)
(0.97, 0.96, 0.91)
(0.95, 0.64, 0.54)
片元着色器 fragment shader:

片元着色器的几个变量:

cpp 复制代码
// 渲染分辨率
float2 res = _Resolution.xy;
// uv值
float2 uv  = IN.uv;
// 根据 uv 值定位到像素坐标
int2   px  = int2(floor(uv * res));
// 获取当前 BufferB 输出 RT 的哨兵像素
float3 currentColour = _Channel1.Load(int3(4, 4, 0)).rgb;
// 获取上一帧 BufferC(自己) 输出 RT 的哨兵像素
float3 storedColour  = _Channel2.Load(int3(4, 4, 0)).rgb;
// 判断 Cubemap 是否被改变
bool cubemapChanged  = any(storedColour != currentColour);
// 判断是否执行下面的代码,如果 Cubemap 被改变才执行
bool run = (_Frame == 0) || (_Frame == 10) || cubemapChanged;
// Cubemap 未改变,直接输出上一帧的内容
// 这很重要,因为预滤波和 BRDF 积分都很贵,每帧全量算会非常耗。
if(!run){
	return SAMPLE_TEXTURE2D(_Channel2, sampler_Channel2, uv);
}

BufferB 会在 (4,4) 存一个当前 cubemap 的采样颜色。BufferC 也把这个颜色存到自己的 (4,4)。下一帧比较两者,如果不同,说明环境变了,需要重新计算。

当检测到 Cubemap 发生改变时执行:

cpp 复制代码
// 采样数量选择
// 如果分辨率较低,用 SAMPLE_COUNT,也就是 1024
// 如果宽度大于等于 2000,用 LOW_SAMPLE_COUNT,也就是 128
int sampleCount = (res.x < 2000.0) ? SAMPLE_COUNT : LOW_SAMPLE_COUNT;
// 默认
float3 col      = float3(0.0, 0.0, 0.0);
float factor    = 0.5;
float roughness = 0.0;

计算的输出分为上下两半部分,下半部分存储有 5 个不同 roughness 的环境光采样贴图:

cpp 复制代码
// 处理 RenderTexture 的下半部分逻辑
if(uv.y < 0.5){
	// 根据 uv.x 判断当前像素属于哪个 roughness tile(下半部分的右侧还有部分未使用)
	// uv.x <= 0.5     { factor = 0.5;     roughness = 0;}
	if (uv.x > 0.5)    { factor = 0.25;    roughness = 0.25; }
    if (uv.x > 0.75)   { factor = 0.125;   roughness = 0.5;  }
    if (uv.x > 0.875)  { factor = 0.0625;  roughness = 0.75; }
    if (uv.x > 0.9375) { factor = 0.03125; roughness = 1.0;  }
    
    // 在限定区间放缩后的 uv, 区间长度为 2
	float2 tileUV = uv / factor;
	// 转换到角度后,每个独立的 uv 区间都转换到 [-PI, PI]
	float2 thetaphi = ((tileUV * 2.0) - 1.0) * float2(PI, HALF_PI);
	// 从 uv 转换到空间方向 rayDir
	float3 rayDir = float3(
		cos(thetaphi.y) * cos(thetaphi.x),
		-sin(thetaphi.y),
		cos(thetaphi.y) * sin(thetaphi.x));
	// 当 uv.x < 0.5 时, 采样原图
	if(uv.x < 0.5){
		col = getEnvironmentB1(rayDir, 0.0);
	}else // 当 uv.x > 0.5 时, 采样原图并计算出随机采样的像素颜色模糊平均
	{
		col = getPreFilteredColour(rayDir, roughness, sampleCount);
	}
}

上半部分存储一个 BRDF integration map (BRDF LUT) :

cpp 复制代码
// 处理 RenderTexture 的上半部分逻辑
if(uv.y >= 0.5){
	// 上半部分的左半区域用于存储 BRDF integration map
	if(uv.x < 0.5){
		// 第 0 帧和第 10 帧应用
		if(_Frame == 0 || _Frame == 10){
			// 将 uv.x 和 uv.y 线性变换到 [0, 1]
			float2 brdfUV = float2(2.0 * uv.x, 2.0 * (uv.y - 0.5));
			// 根据横向分辨率设置采用次数
			int brdfSamples = (res.x < 2000.0) ? BRDF_SAMPLE_COUNT : BRDF_LOW_SAMPLE_COUNT;
			// 计算 BRDF LUT
			float2 c = integrateBRDF(brdfUV.x, brdfUV.y, brdfSamples);
			col = float3(c.x, c.y, 0.0);
		}else
		{
			// 如果不是第 0 帧和第 10 帧就直接读取并输出前一帧的结果
			col = SAMPLE_TEXTURE2D(_Channel2, sampler_Channel2, uv).rgb;
		}
	}
	// 上半部分的右半区域未被使用
}

特殊位置像素处理:

cpp 复制代码
// 将当前 BufferB 传入的哨兵像素继续存储到同一位置并输出
if(px.x == 4 && px.y == 4){
	col = currentColour;
}
// pixel(5, 5) 存储屏幕分辨率
if(px.x == 5 && px.y == 5){
	col = float3(res, 0.0);
}
相关推荐
Shadow(⊙o⊙)6 小时前
前缀和:和可被K整除的子数组(normal)
数据结构·c++·算法
世纪末的小黑6 小时前
【LeetCode自用】LeetCode自用记录贴,题目一:两数之和
数据结构·算法·leetcode
兰令水6 小时前
topcode【随机算法题】【2026.5.22打卡-java版本】
java·算法·leetcode
Brilliantwxx6 小时前
【C++】 认识STL set与map(基础接口+题目OJ运用)
开发语言·数据结构·c++·笔记·算法
05候补工程师6 小时前
【线性代数】核心考点复习笔记:二次型配方法、施密特正交化步骤与特征值经典题型详解
经验分享·笔记·线性代数·考研·算法
Deep-w6 小时前
【MATLAB】基于遗传算法的直流电机 PI 控制器参数优化研究
开发语言·算法·matlab
暴力求解6 小时前
数据结构---二叉树及堆的实现
数据结构·算法·二叉树
超梦dasgg6 小时前
并查集(Union-Find)详解 + Java 完整实现
java·数据结构·算法·图搜索
仍然.6 小时前
算法题目---队列+宽搜(BFS)
算法·宽度优先