本篇文章主要分析学习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;
}
整体流程可以总结为:
- 输入方向 N 和粗糙度 roughness
- 用 Hammersley 生成均匀采样点
- 用 GGX 重要性采样生成半程向量 H
- 根据 V 和 H 反推出环境采样方向 L
- 判断 L 是否在表面上半球
- 根据 pdf 估算 mip level
- 从环境贴图中采样颜色
- 按 NdotL 加权累加
- 最后除以总权重,得到预过滤后的环境反射颜色
这个函数是在模拟"不同粗糙度的表面对环境光的模糊反射",粗糙度越大,采样方向越分散,最终环境反射越模糊。
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);
}