HDR/LDR的IBL实现

HDR/LDR的IBL实现

铁名_IronName Lv4

对应Unity项目场景名:SimpleIBL
简易能量守恒光照模型 的基础上做的。

(美丽的磨砂金属感,是几天的努力换来的口牙)


一些参考

IBL相关文章: https://zhuanlan.zhihu.com/p/66518450
Youtube 教程 Freya Holmér 的 Normal Maps, Tangent Space & IBL • Shaders for Game Devs [Part 3]
@苏格拉没有底。的笔记

跑到 @苏格拉没有底。 那里看笔记,他还学了 Yt 的那个视频。结论是这个实践真超纲了。。。硬着头皮上呗

IBL

IBL就是用一张 环境贴图(通常为立方体贴图,Cubemap) 来模拟整个场景对物体的光照,替代之前用的那个常数 UNITY_LIGHTMODEL_AMBIENT

  • 漫反射IBL:计算来自环境各个方向的间接漫反射光。通常预计算为一张较模糊的辐照度图
  • 镜面反射IBL:计算来自环境的镜面反射(即高光)。由于它随粗糙度变化剧烈,通常需要一套预滤波的Mipmap链和一张BRDF积分贴图

预计算贴图

为了得到:
- Irradiance Map: 漫反射辐照度图
- Prefiltered Env Map: 预滤波环境图
- BRDF LUT: BRDF查找表

方法
  • Unity 的 URP 和 HDRP 中有内置的IBL贴图生成功能
  • 使用C#写个工具脚本(本次实践选用)
  • 使用Compute Shader(高级)
  • 第三方工具
.hdr?.dds?

.dds是在图形程序中被广泛使用的格式。它相比 .hdr 的优势主要体现在以下几个方面:

  • 原生支持Mip链.dds 文件可以预生成并存储完整的mip链。这意味着你的 _PrefilteredEnvMap 的模糊层级是直接写在文件里的,无需引擎在运行时额外计算,加载后就能直接使用,从而节省了运行时的开销和纹理内存。
  • GPU硬件压缩.dds 支持GPU硬件直接解码的压缩格式(例如 DXT1/BC1 等)。这使得它无论在磁盘上还是内存中,所占用的空间都远小于 .hdr 文件。同时,在渲染时GPU可以快速解压,极大提升了加载和渲染效率。
  • 原生立方体贴图支持.dds 格式原生支持存储立方体贴图,因此用它保存的贴图能直接被 Unity 识别和使用,无需额外配置。
  • 保留高动态范围数据:这一点你可能会有些意外,.dds 本身也完全支持高动态范围数据。对于需要高精度 HDR 信息的 PBR 流程,可以将 cmftStudio 输出的格式设置为 R16G16B16A16_FLOAT 或 R32G32B32A32_FLOAT。这样既保留了 .dds 的所有性能优势,又能获得足够精度。
    而如果直接使用 .hdr 格式,就难以兼顾上面的优势了。因为 PrefilteredEnvMap 的核心在于其 mip 链,而 .hdr 文件本身无法原生包含 mip 信息。虽然引擎可以在加载时动态生成 mip,但这会增加加载耗时,并且无法保证生成的 mip 质量与 cmftStudio 这类离线工具的精确滤波效果完全一致。此外,如果操作不当,还可能出现HDR 范围信息丢失或数值被钳位(clamp)的问题,导致高光部分过曝或细节缺失
RGB8/RGBA8

8位整数通道。这是低动态范围(LDR)格式,颜色会发灰,亮部也缺乏层次,会让 PBR 材质看起来“很平”。
所以我选择 RGBA16F.(再往上还有RGBA32F)

使用BRDF LUT小贴士

  • 记得把伽马矫正关了,因为要直接用数值。Unity 就是取消 sRGB 空间的勾选。
  • Repeat Mode 设置为 Clamp 以免边缘异常。
  • 发现贴图采样异常记得看看Property里的和着色器里变量名一不一样。改完记得重新拖放。

(图中为 BRDF LUT在不同粗糙度上的结果)

别问,问就是教训。

两个菲涅尔项 F

  • 直接光照
    • 拥有信息:明确的光线方向 L和视线方向 V
    • 计算方式:可以精确计算半角向量 H = normalize(L + V),并使用 VdotH来计算当前这一束光当前这个微平面上的菲涅尔反射率 F。这个 F同时用于计算镜面反射BRDF和漫反射比例 kD,确保了能量在该束光的镜面与漫反射分量间正确分配。
  • 基于图像的光照 (IBL)
    • 信息缺失:IBL没有单一的、明确的光线方向 L。它代表来自整个半球的所有可能入射光线的积分。

    • 核心问题:我们无法为环境光计算出一个通用的 HVdotH

    • 解决方案:采用被称为 “分割和近似” (Split Sum Approximation) 的方法。它将镜面反射IBL的积分拆解为两部分,并分别进行预计算或近似。 fresnelSchlickRoughness函数正是这个近似的一部分。

提醒

下面这些看起来很难,确实不简单。知道这三个贴图就够了。这几张纹理的生成卡了我好久,最终放弃自己鼓捣这个代码了。使用 cmftStudio得到辐照图和预过滤图。(风扇转冒烟了)BRDF LUT 是从 LearnOpenGL 网站上下载的 512*512 的 PNG。

*IBL的计算

对于 Cook-Torrance 模型,我们要解的方程为:(已省略中间过程)

Lo(p,ωo)=Ld(ωo)+Ls(ωo)L_o(p,\omega_o)=L_d(\omega_o)+L_s(\omega_o)

方程拆成了两部分,漫反射项和镜面反射项。

离线渲染中我们可以用蒙特卡洛积分法去求解这个积分值,但计算量很大。所以实时渲染中面临的问题就是如何快速计算。
主要思路就是预计算,把复杂的积分都先算好。我们会分别预计算漫反射项和镜面项,最终在实时渲染中只需通过简单的纹理采样即可得到结果。

Irradiance Map: 漫反射辐照度图

原作者是个数学硕士,所以我就不深究他的公式推导了。这里有他写的 代码

积分方法就是蒙特卡洛积分,我们可以简单地在半球面上均匀采样。

1πΩLi(p,ωi)nωidωi\frac{1}{\pi}\int_{\Omega}L_{i}(p,\omega_i)n\cdot\omega_id\omega_i

1π\frac{1}{\pi}放入irradiance map中,使用时就不用再除以 π\pi

specular 镜面项

值取决于很多参数。最简单粗暴的方法是在实施渲染中直接使用蒙特卡洛积分求解,但是性能很差。虚幻给出了近似解决的方法 spilt sum approximation。

Pre-filtered Environment Map: 预滤波环境图

Lc(R)kNLi(ωi(k))(nωi(k))kN(nωi(k))L_c^*(R)\approx\frac{\sum_k^NL_i(\omega_i^{(k)})(n\cdot\omega_i^{(k)})}{\sum_k^N(n\cdot\omega_i^{(k)})}

 Lc(R)L_c^*(R) 有两个变量,为粗糙度和 RR 。对于一个特定的粗糙度,变量就只剩  RR,因此我们可以像 irrandiance map 那样,预计算得到一个 cubemap。我们均匀的取粗糙度为 0, 0.25, 0.5, 0.75, 1.0,这样得到 5 个 cubemap。实时渲染时,就用 RR 和粗糙度进行三线性插值。

这个预计算有时需要实时更新,那么快速计算就十分重要。这个预计算关键的就是计算积分。为了加速收敛,我们可以用重要性采样,但还是需要不少的样本。Krivanek 的 Pre-filtered importance sampling 可以减少样本数量,收敛提升明显,只引入了一小些偏差。

这个预计算可以用CPU或者GPU计算。原作者的 GLSL实现

BRDF LUT: BRDF查找表

Ωfs(ωi,ωo)nωidωi=F0scale+bias\int_{\Omega}f_s(\omega_i,\omega_o)n\cdot\omega_id\omega_i=F_0*scale+bias

我们将积分过程拆成了两部分:scale 和 bias(这俩是多项式),它们都消去了 F,因此变量只剩下 θ\theta 和粗糙度,为了方便,用cosθ\cos\theta做变量。scale 和 bias 可以根据法线分布函数进行重要性采样的蒙特卡洛积分来解决。
这两部分可以提前算好然后存在一个2D纹理中,也就是 BRDF LUT。

blue

这是一张2D贴图,与具体的环境无关,是所有PBR材质共享的。也就是说,可以直接去下载一张png来用。

原作者 GLSL实现

ToneMapping

从 HDR 映射到 LDR。
adapted_lum曝光参数,基于场景平均亮度可以做自适应曝光之类的。

  • Reinhard:柔和,高光压缩平滑。
  • CryEngine2:指数曲线,对比度较高。
  • Filmic:S 形曲线,中间对比增强,高光柔和。
  • ACES:电影级,色彩饱和度好,最常用。
为什么这里不配个对比图?

懒了。就相信已有结论吧。此外,adapted_lum这个变量在不同的ToneMapping函数里的位置都不同,也不知道设为相同值时算不算“控制变量”。

做阶段性成果总结的时候会做一下。

代码(部分省略)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
Shader "Test/SimplePBR With IBL" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
//_MainTex ("Main Tex", 2D) = "white" {}
_Specular ("Specular Color", Color) = (1, 1, 1, 1)
_Metalness ("Metalness", Range(0.0, 1.0)) = 0.5
_Roughness ("RoughNess", Range(0.05, 0.99)) = 0.5

// IBL相关
_EnvironmentMap ("Environment Cubemap", Cube) = "Skybox" {}
_EnvIntensity ("Environment Intensity", Range(0, 5)) = 1.0
_ReflectionIntensity ("Reflection Intensity", Range(0, 5)) = 1.0

// 使用预计算贴图
_IrradianceMap ("Irradiance Map", Cube) = "" {}
_PrefilteredEnvMap ("Prefiltered Env Map", Cube) = "" {}
_PrefilteredMipCount ("Prefiltered Mip Count", Float) = 10.0
_BRDFIntegrationMap ("BRDF Integration Map", 2D) = "white" {}

[KeywordEnum(Reinhard, CryEngine2, Filmic, ACES)] _ToneMapType ("Tone Mapping Type", Float) = 0
_Exposure ("Exposure", Range(0.2, 5.0)) = 1.0
}
SubShader {
Tags { "RenderType"="Opaque" "Queue"="Geometry"}

Cull Off

// 辅助函数
CGINCLUDE
// 菲涅尔近似(Schlick)
//...
// GGX 法线分布函数
//...
// Smith 几何遮蔽函数(GGX 联合形式)
//...


// ----四种 HDR 到 LDR 的 ToneMapping
// Reinhard
float3 ReinhardToneMapping(float3 color, float adapted_lum)
{
const float MIDDLE_GREY = 0.18;
// color *= MIDDLE_GREY / adapted_lum;
// 为了统一,我们修改了 adapted_lum 的用法
color *= MIDDLE_GREY * adapted_lum;
return color / (1.0f + color);
}

// CryEngine2
float3 CEToneMapping(float3 color, float adapted_lum)
{
return 1 - exp(-adapted_lum * color);
}

// Filmic
float3 F(float3 x)
{
const float A = 0.22f;
const float B = 0.30f;
const float C = 0.10f;
const float D = 0.20f;
const float E = 0.01f;
const float F = 0.30f;

return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
}

float3 Uncharted2ToneMapping(float3 color, float adapted_lum)
{
const float WHITE = 11.2f;
return F(1.6f * adapted_lum * color) / F(WHITE);
}

// ACES
float3 ACESToneMapping(float3 color, float adapted_lum)
{
const float A = 2.51f;
const float B = 0.03f;
const float C = 2.43f;
const float D = 0.59f;
const float E = 0.14f;

color *= adapted_lum;
return (color * (A * color + B)) / (color * (C * color + D) + E);
}
ENDCG


Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM
#pragma multi_compile_fwdbase
#pragma multi_compile _TONEMAPTYPE_REINHARD _TONEMAPTYPE_CRYENGINE2 _TONEMAPTYPE_FILMIC _TONEMAPTYPE_ACES

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"

fixed4 _Color;
//sampler2D _MainTex;
//float4 _MainTex_ST;
fixed4 _Specular;
float _Metalness;
float _Roughness;

samplerCUBE _EnvironmentMap;
float _EnvIntensity;
float _ReflectionIntensity;

// 预计算贴图
samplerCUBE _IrradianceMap;
samplerCUBE _PrefilteredEnvMap;
float _PrefilteredMipCount;
sampler2D _BRDFIntegrationMap;

float _Exposure;

struct a2v {
//...
};

struct v2f {
//...
};

v2f vert(a2v v) {
//...
}

// 使用预计算的辐照度图
float3 SampleIrradiance(float3 normal) {
return texCUBE(_IrradianceMap, normal).rgb * _EnvIntensity;
}

// 粗糙度调整的菲涅尔项
float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness) {
return F0 + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
}

float3 ApproximateIrradiance(float3 normal) {
float3 envColor = texCUBE(_EnvironmentMap, normal).rgb;
return envColor * _EnvIntensity * 0.5;
}

fixed4 frag(v2f i) : SV_Target {

float3 worldPos = i.worldPos;
float3 worldNormal = normalize(i.worldNormal);

fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float3 reflDir = reflect(-viewDir, worldNormal);

fixed3 halfDir = normalize(lightDir + viewDir);

fixed3 albedo = _Color.rgb;
UNITY_LIGHT_ATTENUATION(atten, i, worldPos);

float NdotL = max(0, dot(worldNormal, lightDir));
float NdotH = max(0, dot(worldNormal, halfDir));
float VdotH = max(0, dot(viewDir, halfDir));
float NdotV = max(0, dot(worldNormal, viewDir));

// 漫反射部分(Lambertian, 能量守恒)
float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, _Metalness); // 基础反射率
float3 F = fresnelSchlick(VdotH, F0); // 菲涅尔项
float3 kD = (1.0 - F) * (1.0 - _Metalness); // 漫反射比例

float3 diffuse = kD * albedo / 3.14159;

// 镜面反射部分(GGX + Smith + Schlick)
float alpha = _Roughness * _Roughness;
float D = GGX_Distribution(NdotH, alpha); // 法线分布
float G = SmithGeometry(NdotV, NdotL, alpha); // 几何遮蔽
float3 specular = F * D * G / (4.0 * NdotV * NdotL + 0.0001);

// 直接光照
float3 radiance = _LightColor0 * NdotL * atten;
float3 directLight = (diffuse + specular) * radiance;

// 环境光照
// 漫反射IBL
float3 fixNormal = worldNormal;
fixNormal.z *= -1;
fixNormal.x *= -1;
float3 diffuseIBL = SampleIrradiance(fixNormal) * albedo;

// 根据粗糙度选择预滤波贴图的Mip层级
float maxMip = max(0, _PrefilteredMipCount - 1);
float mipLevel = _Roughness * maxMip;
float3 fixReflDir = reflDir;
fixReflDir.z *= -1;
fixReflDir.x *= -1;
float3 prefilteredColor = texCUBElod(_PrefilteredEnvMap, float4(fixReflDir, mipLevel)).rgb;

// 采样BRDF积分贴图
float2 brdfSamplePoint = float2(max(NdotV, 0.0), _Roughness);
float2 brdfValue = tex2D(_BRDFIntegrationMap, brdfSamplePoint).rg;

// 使用粗糙度调整的菲涅尔项
float3 F1 = fresnelSchlickRoughness(max(NdotV, 0.0), F0, _Roughness);

// 组合镜面反射IBL
float3 specularIBL = prefilteredColor * (F1 * brdfValue.x + brdfValue.y) * _ReflectionIntensity;

float3 finalColor = directLight + diffuseIBL + specularIBL;

#if defined(_TONEMAPTYPE_REINHARD)
finalColor = ReinhardToneMapping(finalColor, _Exposure);
#elif defined(_TONEMAPTYPE_CRYENGINE2)
finalColor = CEToneMapping(finalColor, _Exposure);
#elif defined(_TONEMAPTYPE_FILMIC)
finalColor = Uncharted2ToneMapping(finalColor, _Exposure);
#elif defined(_TONEMAPTYPE_ACES)
finalColor = ACESToneMapping(finalColor, _Exposure);
#endif

return fixed4(finalColor, 1.0);
}
ENDCG
}

}
FallBack "Specular"
}

  • 标题: HDR/LDR的IBL实现
  • 作者: 铁名_IronName
  • 创建于 : 2026-04-09 11:45:58
  • 更新于 : 2026-04-18 11:02:52
  • 链接: https://blog.ironname.top/2026/HDR-LDR的IBL实现/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论