https://catlikecoding.com/unity/tutorials/custom-srp/hdr/

1 High Dynamic Range
之前的教程,都是用低动态颜色范围,即 LDR 进行渲染.每个颜色通道被限定到 0 - 1 ,该模式下,(0,0,0)代表黑色,(1,1,1)代表白色.尽管我们的 shader 可以产生超过 1 的颜色值,但是 GPU 在存储时也会限制到 1 .
可以用 Frame Debugger 来查看 render target 的类型. 普通相机通常用 B8G8R8A8_SRGB,也就是每个通道 8 位的 RGBA buffer ,每个像素就是 32 位,同时RGB是存储在 sRGB 空间的.我们当前是工作在 linear space ,因此GPU在从 buffer 读取和写入时,都会自动帮我们进行转换.当渲染完成,显示到显示器时,会将 Buffer 当作 sRGB 颜色数据.
HDR 通常是 R16G16B16A16,每个通道16位,每个像素64位.这时每个通道都是一个有符号的浮点数,而不是0-1.
有些情况下,当亮度会超过1 时,LDR就不合适了,比如太阳,或距离光源特别近时,光的强度让我们不敢直视,这时就需要用 high-dynamic-range HDR buffer 来进行渲染,以存储超过 1 的颜色值.
1.1 HDR Reflection Probes

反射探针有选项,用来在渲染反射图时,使用HDR.这通常体现在反射的高光上.如下图左侧是 HDR 反射,右侧的 LDR 反射,高光就弱了不少

1.2 HDR Cameras

摄像机也有HDR 属性,包含两个选项: Off 和 Use Graphics Settings.
Use Graphics Settings 表示摄像机允许 HDR 渲染,但是最终是不是 HDR,取决于渲染管线.因此我们向 CustomRenderPipelineAsset 增加 HDR 开关,并一路传递到 CameraRenderer,并根据参数和Camera.allowHDR 来最终确定是否启用 HDR.
1.3 HDR Render Textures
HDR 只有在后效中才有意义,因为我们无法改变 frame buffer 的格式,因此在 CameraRenderer 中,如果启用的后效,则创建 HDR RenderTarget,否则还是用LDR.
cs
//*******************
// CameraRenderer.cs
void Setup()
{
...
// 如果开启了后处理,则渲染到临时 RT 上
if (postFXStack.IsActive)
{
// 获取的 RenderTexture 数据是随机的,因此要清除
if (clearFlags > CameraClearFlags.Color)
clearFlags = CameraClearFlags.Color;
buffer.GetTemporaryRT(frameBufferId,
camera.pixelWidth, camera.pixelHeight,
32, FilterMode.Bilinear,
useHDR ? RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default);
buffer.SetRenderTarget(frameBufferId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);
}
...
}
其实,如果没有后效,在渲染过程中希望用HDR,那么也是需要创建中间 Render Texture 进行渲染,渲染完成后拷贝到 final frame buffer.
场景渲染完后(在FrameDebugger)中,可以看到渲染结果偏暗,这是因为我们的 RT 是 HDR linear space 的,被错误的当sRGB 直接显示到屏幕上了.

1.4 HDR Post Processing
到目前为止,渲染结果看起来没什么不同,因为我们没有针对 HDR 做任何处理,当 RT 被渲染到 LDR 时,被 clamp 到 0-1 了.Bloom 可能会稍微亮一点,但也不会亮太多,因为后面的 prefiltering pass 过程会 clamp 颜色,因此我们需要从 CameraRenderer 将 HDR 开关传递给后效模块,并为 prefiltering pass 在需要时使用 HDR 格式的RT.同时为 DoBloom 过程中的 RT,也使用 HDR.
cs
void DoBloom(int sourceId)
{
...
RenderTextureFormat format = useHDR ? RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default;
// 源图 -> 半分辨率图
buffer.GetTemporaryRT(bloomPreFilterID, width, height, 0, FilterMode.Bilinear, format);
Draw(sourceId, bloomPreFilterID, Pass.BloomPrefilter);
...
}
HDR/LDR bloom 之间的区别可能很明显,也可能不明显,这取决于场景的亮度.我们通常将 bloom threshold 设置为 1, 这样只有 HDR 颜色会参与到 bloom 中.这样只有那些特别亮的地方才会有辉光.

由于 bloom 会平均颜色值,因此即使一个很亮的像素,也会隐形到较大的一个区域,可以通过 prefiltering 的结果和最终屏幕结果对比,看到这种光现象,即使一个像素,也会产生一个圆形的辉光.

例如,对于一个 2x2 的块其颜色是 0,0,0,1,下采样平均后是 0.25.而如果是 HDR 中的 0,0,0,10 平均值则是2.5,到LDR时就全变成 1 了.
1.5 Fighting Fireflies
HDR的一个缺点就是会产生一个比周围亮很多的小的图像区域,当这个区域只有一个像素大小,或者更小,随摄像机的移动或旋转,会有强烈的闪烁。

https://catlikecoding.com/unity/tutorials/custom-srp/hdr/high-dynamic-range/hdr-bloom-fireflies.mp4
解决该问题需要无限大的分辨率,而这是不可能的。更好的办法是在 prefiltering 时,更进一步的模糊图像来消除闪烁,同时为该操作增加一个开关,并实现第二种 prefiltering pass,根据开关选择用哪个。
cs
void DoBloom(int sourceId)
{
...
// 源图 -> 半分辨率图
buffer.GetTemporaryRT(bloomPreFilterID, width, height, 0, FilterMode.Bilinear, format);
Draw(sourceId, bloomPreFilterID, bloom.fadeFireflies ? Pass.BloomPrefilterFireflies : Pass.BloomPrefilter);
...
}
最直接的消除闪烁的办法是在 pre filtering 中将2x2下采样提升为 6x6 box filter,这需要9次采样,并在平均之前应用 bloom threshold.

仅仅如此还不能很好的解决问题,高亮像素扩展到了更大的区域.我们需要一个基于颜色亮度的权重的平均算法.颜色亮度是其感知亮度,通过 Core RP library 中的 color.hlsl 中定义的 Luminance 函数来获得.
我们取权重为,其中 l 是 luminance,因此对于亮度 0 权重是 0, 1 是 1/2, 3 是 1/4, 7 是 1/8 ...

最后,我们还要用采样值的和,除权重和,实际上就是让所有像素都进行了分散,这样那些暗的像素的闪烁就消失了.例如对于 0,0,0,10 的权重平均值就是.
因为 pre filtering 之后我们会执行高斯模糊,因此我们可以跳过直接相邻的4次采样,将采样次数降低到5次

cs
//*****************
// PostFXStackPasses.hlsl
float4 BloomPrefilterFirefliesPassFragment(Varyings input) : SV_Target
{
float3 color = 0.0;
float weightSum = 0.0;
float2 offset[] = { float2(-1, 1), float2(1, 1),
float2(0, 0),
float2(-1, -1), float2(1, -1) };
for (int i = 0; i < 5; ++i)
{
float3 c = GetSource(input.screenUV + offset[i] * _PostFXSource_TexelSize.xy * 2.0).rgb;
c = ApplyBloomThreshold(c);
float w = 1.0 / (Luminance(c) + 1);
color += c*w;
weightSum += w;
}
color /= weightSum;
return float4(color, 1.0);
}
请自行基于该着色器,在 PostFXStack.shader 中,创建 BloomPrefilterFireflies Pass
这并不能完全消除闪烁,只是让其不那么明显.当将 bloom intensity 超过 1 时,闪烁会再次变得明显.

https://catlikecoding.com/unity/tutorials/custom-srp/hdr/high-dynamic-range/faded-fireflies.mp4
2 Scattering Bloom
在实现了HDR bloom 之后,让我们来考虑更加真实的应用,思想是真实世界中的摄像机并没有那么完美,它们的镜片不可能对所有光线同时正确的对焦.部分光会扩散到较大的区域,就像我们现在的 bloom.越好的摄像机扩散越少.与我们的 Additive bloom 效果最大的不同,是扩散不会叠加光,而是漫反射这些光.从轻微的辉光,到扩散到整个图像的光雾,有明显的不同.
光在人的眼睛内同样会以某种复杂的方式发生扩散。进入眼睛的光都会散射,但是只有那些很亮的光才会特别明显。例如,看一个在黑色背景下的小光源,像晚上的灯笼,或者太阳光的反射。
我们的眼睛看到的,并不是圆形的辉光,而是有很多角的非对称星形图案的辉光,同时还会有一些色调的变化,我们现在的 bloom 无法表现这些。
如下图是摄像机捕捉到的辉光

2.1 Bloom Mode
我们需要支持经典的 additive bloom 和 能量守恒的散射 bloom,因此定义相应的枚举。同时定义 0 - 1 的散射控制参数。
cs
[System.Serializable]
public struct BloomSettings
{
...
public enum Mode
{
Additive,
Scattering
}
public Mode mode;
// 散射模式下的散射系数
[Range(0.05f,0.95f)]
public float scatter;
}
现在有两种 bloom mode,因此将以前的 BloomCombine pass 改名为 BloomAdd pass,然后添加新的 BloomScatter pass.接着在 DoBloom 中,根据选项,应用对应的 bloom mode. Scatter 模式下,我们用 intensity 来上传 scatter 参数。
因为 Scatter 参数在 0 和 1 时,仅使用了一层 bloom,这明显是不合理的,因此我们将该参数的值限定到 0.05 - 0.95 之间,并以 0.7 作为默认值(也是URP HDRP中使用的默认值).
同样, Scatter 模式下最终强度超过 1 也不合适,因此限定其最大值为 0.95,这样原始图像多少会对 bloom 做些贡献.
cs
void DoBloom(int sourceId)
{
...
// 根据配置选择合适的 pass: add/scatter
Pass combinePass;
// 不同模式下,最终强度也不同
float finalIntensity;
if(bloom.mode == PostFXSettings.BloomSettings.Mode.Additive)
{
combinePass = Pass.BloomAdd;
buffer.SetGlobalFloat(bloomIntensityID, 1f);
finalIntensity = bloom.intensity;
}
else
{
combinePass = Pass.BloomScatter;
// 传递散射系数,直接用强度传递
buffer.SetGlobalFloat(bloomIntensityID, bloom.scatter);
// 模式下最终强度超过 1 也不合适,因此限定其最大值为 0.95,这样原始图像多少会对 bloom 做些贡献.
finalIntensity = Mathf.Min(0.95f, bloom.intensity);
}
// 上采样至少需要执行2次迭代才可以
if (i > 1)
{
// 释放最后一次迭代的水平 blur RT
buffer.ReleaseTemporaryRT(fromId - 1);
// 循环上采样,叠加颜色
toId -= 5;
for (i -= 1; i > 0; i--)
{
buffer.SetGlobalTexture(fxSource2ID, toId + 1);
Draw(fromId, toId, combinePass);
buffer.ReleaseTemporaryRT(fromId);
buffer.ReleaseTemporaryRT(toId + 1);
fromId = toId;
toId -= 2;
}
}
else
{
buffer.ReleaseTemporaryRT(bloomPyramidID);
}
// 传递 bloom 强度
buffer.SetGlobalFloat(bloomIntensityID, finalIntensity);
buffer.SetGlobalTexture(fxSource2ID, sourceId);
// 绘制到屏幕
Draw(fromId, BuiltinRenderTextureType.CameraTarget, combinePass);
...
}
BloomScatter pass 同 BloomAdd 类似,不同的是 scatter 模式是在 low resolution 和 high resolumtion 之间基于 intensity 进行插值,而不是叠加.Scatter 为 0 时意味着 bloom 最高分辨率,为 1 时则是 bloom 最低分辨率.当值为 0.5 时,对于4级 bloom, 其权重分别为 0.5, 0.25, 0.125, 0.125.
cs
float4 BloomScatterPassFragment(Varyings input) : SV_TARGET
{
float3 lowRes;
if (_BloomBicubicUpsampling)
lowRes = GetSourceBicubic(input.screenUV).rgb;
else
lowRes = GetSource(input.screenUV).rgb;
float3 highRes = GetSource2(input.screenUV).rgb;
return float4(lerp(highRes, lowRes, _BloomIntensity), 1.0);
}
Scattering bloom 不会让图像变亮,甚至可能会变暗.能量守恒并不完美,因为高斯过滤在图像边缘, 执行的是 clamp 采样,意味着边缘像素的贡献被放大了.可以对此进行一些补偿,但是因为不是很明显,因此可以忽略.
2.2 Threshold
Scatter bloom 参数比 Add 模式要微妙很多,通常会使用较低的强度.这就像真实的摄像机,只有非常亮的地方才会有 bloom.
通过应用一个阈值,消除较暗的像素的扩散,这样可以在使用较强的 bloom 时依然保持图像的对比度.但这样会让图像变暗.
如下图:Threshold 1, knee 0, and Intensity 1.

因此需要对暗处做一些补偿,因此创建 BloomScatterFinal pass
cs
// 根据配置选择合适的 pass: add/scatter
Pass combinePass, finalPass;
// 不同模式下,最终强度也不同
float finalIntensity;
if(bloom.mode == PostFXSettings.BloomSettings.Mode.Additive)
{
combinePass = Pass.BloomAdd;
finalPass = Pass.BloomAdd;
buffer.SetGlobalFloat(bloomIntensityID, 1f);
finalIntensity = bloom.intensity;
}
else
{
combinePass = Pass.BloomScatter;
// Scatter 过程中,暗的像素也会扩散,因此用一个专门的 pass 在最后进行补偿
finalPass = Pass.BloomScatterFinal;
// 传递散射系数,直接用强度传递
buffer.SetGlobalFloat(bloomIntensityID, bloom.scatter);
// Scatter 模式下最终强度超过 1 也不合适,因此限定其最大值为 0.95,这样原始图像多少会对 bloom 做些贡献
finalIntensity = Mathf.Min(0.95f, bloom.intensity);
}
...
// 传递 bloom 强度
buffer.SetGlobalFloat(bloomIntensityID, finalIntensity);
buffer.SetGlobalTexture(fxSource2ID, sourceId);
// 绘制到屏幕
Draw(fromId, BuiltinRenderTextureType.CameraTarget, finalPass);
从 Scatter pass 复制创建该 pass,然后向 low res pass 叠加基于 hi res pass 计算的颜色(如下面的代码片段).这不是一个完美的解决方案,因为它不是一个带权重的平均,并且忽略了在处理 fading fireflies 过程中丢失的亮度,但是已经足够接近原图(没有 bloom 的部分).
cs
float4 BloomScatterFinalPassFragment(Varyings input) : SV_TARGET
{
float3 lowRes;
if (_BloomBicubicUpsampling)
lowRes = GetSourceBicubic(input.screenUV).rgb;
else
lowRes = GetSource(input.screenUV).rgb;
float3 highRes = GetSource2(input.screenUV).rgb;
lowRes += highRes - ApplyBloomThreshold(highRes);
return float4(lerp(highRes, lowRes, _BloomIntensity), 1.0);
}
下图为应用 final pass 后

3 Tone Mapping
尽管我们以HDR进行渲染,但通常摄像机的 frame buffer 都是 LDR 的,因此颜色还是会被限制到 1,也就是 1 是白色。那些特别亮,超过1的区域,最终也是1,使这些区域看起来没有区别。例如一个场景中,有多个不同亮度的光源,以及不同程度的自发光的对象,它们的亮度都超过了1,最强的自发光是8,最亮的光源强度是200

可以看到,在没有后效时,根本无法看出哪个光源或对象的亮度是最高的。通过应用 bloom 可以更加明显:threshold = 1,knee = 0.5, intensity = 0.2, scatter = 0.7,迭代次数最大:

有辉光的对象肯定是很亮的,但是依然无法跟其它区域的亮度进行比较。我们需要对图像进行调整,让最亮的颜色不会超过1。可以让图像整体变暗,但是这样会导致原本的暗处变得不清晰。实际上我们希望对亮的地方调整的幅度大一些,暗的地方调整幅度要小,因此我们需要一个不均匀的颜色调整。这种调整跟光的物理变化无关,而是基于它是如何被看到的。例如,我们的眼睛对于暗色调的敏感度,要高于亮色调。
将 HDR 转换到 LDR 的过程,叫做 Tone Mapping,源于摄影和电影的发展。传统的摄影和电影也有受限的范围和不均匀的感光度的问题,因此发展出很多技术来进行转换。没有唯一正确的执行色调映射的方法,不同的方法可以用来构建某些氛围,像经典电影风格。
3.1 Extra Post FX Step
在 bloom 之后,添加一个新的步骤来完成 tone mapping:DoToneMapping().目前先实现为将源拷贝到 camera target
cs
void DoToneMapping(int sourceId)
{
Draw(sourceId, BuildinRenderTextureType.CameraTarget, Pass.Copy);
}
我们要对 bloom 的结果进行调整,因此需要创建一个全分辨率的 render texture, DoBloom 最后将绘制到该 texture 上.当没有执行 bloom 时,不需要绘制到 camera target,并且返回该状态.
同时调整 render 流程,如果执行了 bloom 则以 bloom final RT 作为 tone mapping 的目标,并在处理完后释放 bloom final RT.如果没有 bloom 则直接在源图上执行 tone mapping.
cs
...
// bloom final render texture
int bloomResultId = Shader.PropertyToID("_BloomResult");
...
public void Render(int sourceId)
{
// 执行了 bloom,对 bloom resutl 执行 tone mapping 后,释放 bloom result
if(DoBloom(sourceId))
{
DoToneMapping(bloomResultId);
buffer.ReleaseTemporaryRT(sourceId);
}
// 直接对 sourceId 执行 tone mapping
else
{
DoToneMapping(sourceId);
}
context.ExecuteCommandBuffer(buffer); DoToneMapping(bloomResultId);
buffer.Clear();
}
...
// 如果执行了 bloom 返回 true,否则返回 false
bool DoBloom(int sourceId)
{
...
// 如果不需要 bloom,直接拷贝,返回
// *2 是因为我们要跳过第一次迭代,从半分辨率开始
if (bloom.intensity == 0 || // 强度为0,不需要 bloom
bloom.maxIterations == 0 || // 最大迭代次数为0,不需要 bloom
width < bloom.downscaleLimit * 2 || height < bloom.downscaleLimit * 2)
{
buffer.EndSample("Bloom");
return false;
}
...
// 绘制到结果图像
buffer.GetTemporaryRT(bloomResultId, camera.pixelWidth, camera.pixelHeight, 0, FilterMode.Bilinear, format);
Draw(fromId, bloomResultId, finalPass);
buffer.ReleaseTemporaryRT(fromId);
buffer.ReleaseTemporaryRT(bloomPreFilterID);
buffer.EndSample("Bloom");
return true;
}
3.2 Tone Mapping Mode
Tone Mapping 有多种实现方式,我们将支持其中的几种,因此需要加入相关的配置,定义模式枚举.None 表示不执行 tone mapping.
cs
[System.Serializable]
public struct ToneMappingSettings
{
public enum Mode
{
None = -1,
Reinhard,
Neutral,
ACES,
}
public Mode mode;
}
[SerializeField]
ToneMappingSettings toneMapping = default;
public ToneMappingSettings ToneMapping => toneMapping;

3.3 Reinhard
tone mapping 的目标是降低图像的亮度,使全白区域能够显示不同的颜色,显示那些丢失的细节.就像人的眼睛,突然从暗处进入一个很亮的环境,从"晃眼"状态重新看清楚.但是我们不能整体上让图像变暗,因为这会让暗的颜色失去区分度.因此我们需要一种非线性的转换,对与暗的像素颜色值降低的少,而对于亮的像素颜色值降低的多.极端情况下,0依然是0,而极大值趋向于1.最简单的方式是让颜色等于,其中 c 的颜色通道的值.该最简形式的函数被称为 Reinhard tone mapping operation,最初是由 Reinhard 提出的.只不过他说应用于亮度,我们说应用到每个颜色通道上.

定义新的 pass,然后在 DoToneMapping 中,根据模式选择对应的 pass.
cs
public enum Pass
{
...
ToneMappingReinhard,
ToneMappingNeutral,
ToneMappingACES,
}
...
void DoToneMapping(int sourceId)
{
Pass pass = settings.ToneMapping.mode < 0 ? Pass.Copy : Pass.ToneMappingReinhard + (int)settings.ToneMapping.mode;
Draw(sourceId, BuiltinRenderTextureType.CameraTarget, pass);
}
当颜色值很大时,由于精度问题(部分平台下采用了 half)会出错,大的值比趋向无限大的值更早变成1.因此我们把颜色最大值限制到 60 以内.
pass shader 代码如下
cs
float4 ToneMappingReinhardPassFragment(Varyings input) : SV_TARGET
{
float3 color = GetSource(input.screenUV).rgb;
color /= color + 1.0;
return color;
}

3.4 Neutral
Reinhard 的白色值理论上可以无限大,但是可以调整使其更快的到达最大值,从而弱化调整效果,该方程为,w 是白色值
下图是 reinhadr 白色值是无限以及4时的曲线

可以为该方程增加配置参数,但是除了 Reinhard 还有其它方法,采用更多的一种方法是,x 是输入的颜色通道,其它值是一些常量,用来构建曲线。最终的颜色为
,c 是颜色通道,e 是曝光 exposure 参数,w 是白色值。它能够生成一条 S 形曲线,该曲线有一个从黑色开始向上弯曲的起始区域(toe region),中间为线性部分,最后以接近白色时逐渐变平缓的结束区域(shoulder region)。该曲线是由 John Hable 设计的,在 Uncharted 2 中首次使用。
下图是 Reinhard 和 Uncharted2 tone mapping

URP 和 HDRP 使用了该函数的一个变体,他们定义了自己的配置参数值,白色值取 5.3,同时使用 white scale 作为曝光参数 exposure bias,因此最终的曲线是。这使得白色值大致为 4.035。该函数用于 Neutral tone mapping ,并且在 Color Core Library HLSL file 中实现。

cs
float4 ToneMappingNeutralPassFragment(Varyings input) : SV_TARGET
{
float3 color = GetSource(input.screenUV).rgb;
color.rgb = min(color.rgb, 60);
color = NeutralTonemap(color.rgb);
return float4(color, 1.0);
}

如果需要,也可以增加我们自己的配置参数来控制曲线。
3.5 ACES
最后介绍ACES tone mapping,该方法也被 URP 和 HDRP 所使用。ACES 是 Academy Color Encoding System,一种用于数字图像文件转换、管理色彩工作流程以及创建用于交付和存档的有效方式的全球标准。我们只使用其中的 tone mapping 方法,该方法已经在 Core Library 中实现,可以直接调用。该方法的参数中的颜色,需要在 ACES 颜色空间,通过 unity_to_ACES 函数完成转换
cs
float4 ToneMappingACESPassFragment(Varyings input) : SV_TARGET
{
float3 color = GetSource(input.screenUV).rgb;
color.rgb = min(color.rgb, 60);
color = AcesTonemap(unity_to_ACES(color.rgb));
return float4(color, 1.0);
}

ACES 跟其它方法最明显的区别,是其向很亮的颜色增加了一个色调偏移(hue shift),使其偏向白色。这种情况也会在相机或眼睛因光线过强而无法正常工作时出现(眩光)。现在其与 bloom 结合后,表现得很亮但是也很清楚。同时 ACES tone mapping 会对降低暗的颜色值来增加对比度,呈现一种电影的感觉。