HDR Scattering and Tone Mapping
渲染到HDR纹理
减少泛光萤火虫现象
添加散射泛光效果
支持多种色调映射模式
这是关于创建自定义可编程渲染管线的教程系列第12部分。本教程增加了对高动态范围渲染、基于散射的泛光以及色调映射的支持。
本教程基于Unity 2019.4.8f1版本创建,并已升级至2022.3.5f1版本。
A combination of dark, bright, and very bright areas
暗部、亮部与极亮区域的组合效果
1. High Dynamic Range 高动态范围
到目前为止,我们在渲染相机时一直使用低动态颜色范围(简称LDR),这是默认设置。这意味着每个颜色通道的值被限制在0-1之间。在此模式下,(0,0,0)代表黑色,(1,1,1)代表白色。虽然我们的着色器可以产生超出此范围的结果,但GPU在存储颜色时会进行钳位处理,就像在每个片段函数末尾使用了saturate一样
(1,1,1)真的是白色吗?
这是理论上的白点,但其实际观测颜色取决于显示器及其配置方式。调整显示器亮度会改变其白点位置。同时,人眼会根据观察对象的整体光照水平进行自适应,调整自身的相对白点感知。例如,当室内光照变暗时,即使观测到的亮度已发生变化,您对色彩的感知方式仍会保持一致。此外,人眼还能在一定程度上补偿光照的色偏。当光照突然改变时,这种适应性调整会逐渐显现,使得色彩感知的变化过程尤为明显。
您可以使用帧调试器检查每个绘制调用的渲染目标类型。普通相机的目标被描述为B8G8R8A8_SRGB,这意味着它是一个每通道8位的RGBA缓冲区(即每像素32位),且RGB通道存储在sRGB色彩空间中。由于我们在线性色彩空间中进行处理,GPU在读写缓冲区时会自动进行色彩空间转换。渲染完成后,缓冲区会发送到显示器,显示器将其解析为sRGB色彩数据。
那HDR显示器呢?
Unity 2022确实支持HDR显示器,但要获得理想的HDR输出效果并非易事,而且需要HDR显示器进行测试。因此我们假定所有显示器都是LDR sRGB规格。
只要光照强度不超过单通道最大值1,当前设定就能正常工作。但入射光的强度本身并无上限——太阳就是极亮光源的典型例子(这也是不应直视它的原因),其强度远超眼睛受损前的感知极限。事实上,许多常规光源产生的光照强度也能超过观测极限,尤其在近距离观察时。要正确处理这类强度值,我们必须渲染到支持大于1数值的高动态范围缓冲区。
1.1 HDR Reflection Probes HDR反射探针
HDR渲染需要HDR渲染目标。这不仅适用于常规相机,反射探针也同样如此。反射探针包含HDR还是LDR数据可通过其HDR切换选项控制(该选项默认启用)。
Reflection probe with HDR enabled 启用HDR的反射探针
当反射探针使用HDR时,它可以包含高强度颜色(主要是其捕获的镜面反射)。您可以通过场景中这些颜色引起的反射间接观察它们。不完美的反射会减弱探针的颜色,这使得HDR值更加突出。
Reflections with and without HDR 启用与未启用HDR的反射对比
1.2 HDR Cameras HDR相机
相机也配有HDR配置选项,但该选项本身不产生任何效果。可设置为关闭或使用图形设置
Camera HDR depending on graphics settings 相机HDR模式取决于图形设置
“使用图形设置”模式仅表示相机允许HDR渲染。具体是否启用取决于渲染管线的配置。我们将通过向CustomRenderPipelineAsset添加切换选项来控制此功能,并将其传递给管线构造函数。
[SerializeField]bool allowHDR = true;…protected override RenderPipeline CreatePipeline () {return new CustomRenderPipeline(allowHDR, useDynamicBatching, useGPUInstancing, useSRPBatcher,useLightsPerObject, shadows, postFXSettings);}
让CustomRenderPipeline持续跟踪该设置,并与其他选项一同传递给相机渲染器。
bool allowHDR;…public CustomRenderPipeline (bool allowHDR,…) {this.allowHDR = allowHDR;…}…protected override void Render (ScriptableRenderContext context, List<Camera> cameras) {for (int i = 0; i < cameras.Count; i++) {renderer.Render(context, cameras[i], allowHDR,useDynamicBatching, useGPUInstancing, useLightsPerObject,shadowSettings, postFXSettings);}}
CameraRenderer随后会持续追踪是否应使用HDR——当相机和渲染管线均允许时才会启用
bool useHDR;public void Render (ScriptableRenderContext context, Camera camera, bool allowHDR,bool useDynamicBatching, bool useGPUInstancing, bool useLightsPerObject,ShadowSettings shadowSettings, PostFXSettings postFXSettings) {…if (!Cull(shadowSettings.maxDistance)) {return;}useHDR = allowHDR && camera.allowHDR;…}
HDR allowed 允许HDR
1.3 HDR Render Textures HDR渲染纹理
HDR渲染仅在与后期处理结合时才有意义,因为我们无法更改最终帧缓冲区的格式。因此,当我们在CameraRenderer.Setup中创建自己的中间帧缓冲区时,会在适当时机使用默认的HDR格式(而非常规的LDR默认格式)。
buffer.GetTemporaryRT(frameBufferId, camera.pixelWidth, camera.pixelHeight,32, FilterMode.Bilinear, useHDR ?RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default);
帧调试器将显示默认HDR格式为R16G16B16A16_SFloat,这意味着它是一个每通道16位的RGBA缓冲区(即每像素64位,是LDR缓冲区的两倍大小)。此时每个值都是线性空间中的有符号浮点数,不受0-1范围限制
我们可以使用不同的渲染纹理格式吗?
是的,但需要确保目标平台支持该格式。本教程我们将沿用默认的HDR格式,该格式具有普适性。
逐步查看绘制调用时,您会注意到场景显示效果比最终结果更暗。这是因为中间步骤存储在HDR纹理中——其线性色彩数据被直接显示(被错误解析为sRGB格式)导致画面变暗。
HDR and LDR, before post processing via frame debugger
通过帧调试器查看后期处理前的HDR与LDR对比
为什么亮度会发生变化?
sRGB格式使用非线性传输函数。显示器会对此进行调节,执行所谓的伽马校正。伽马校正函数通常近似为原色(尽管实际传输函数略有不同)。
Incorrect adjustment of linear data, approximated
线性数据的错误校正示意图(近似表示)
1.4 HDR Post Processing HDR后期处理
目前结果看起来与之前并无差异,因为我们尚未利用扩展的动态范围,且在渲染到LDR目标时数值仍会被钳制。泛光效果可能稍亮一些,但由于颜色在预滤波通道后仍被钳制,变化并不明显。要充分发挥HDR优势,我们还必须在HDR空间执行后期处理。因此,在CameraRenderer.Render中调用PostFXStack.Setup时需传递是否使用HDR的信息
postFXStack.Setup(context, camera, postFXSettings, useHDR);
现在PostFXStack也能持续追踪是否应使用HDR模式
bool useHDR;…public void Setup (ScriptableRenderContext context, Camera camera, PostFXSettings settings,bool useHDR) {this.useHDR = useHDR;…}
这样我们就能在DoBloom中使用合适的纹理格式了
RenderTextureFormat format = useHDR ?RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default;
HDR与LDR泛光效果的差异可能非常显著,也可能难以察觉,这取决于场景的亮度。通常泛光阈值设置为1,这样只有HDR颜色会参与泛光计算,使得辉光效果能标示出超出显示器显示范围的过亮颜色。
HDR bloom; threshold 1 and knee 0 HDR泛光效果:阈值1,拐点0
由于泛光会对颜色进行平均计算,即使单个极亮像素最终也会在视觉上影响极大区域。通过对比预滤波步骤与最终结果可以看出,单个像素就能产生巨大的圆形光晕
HDR bloom pre-filtering step HDR泛光预滤波阶段
例如,当数值为0、0、0和1的2×2像素块因降采样进行平均计算时,结果为0.25。但若HDR版本对0、0、0和10进行平均,结果将是2.5。与LDR相比,这就像0.25的结果被提升到了1
1.5 Fighting Fireflies 对抗萤火虫噪点
HDR的一个缺点是可能产生比周围区域亮得多的小范围图像区域。当这些区域约像素尺寸或更小时,会因移动而剧烈改变相对大小并时隐时现,导致闪烁现象。这些区域被称为萤火虫噪点。当泛光效果作用于它们时,可能产生频闪效应
(视频)
HDR泛光萤火虫噪点
要完全消除这个问题需要无限分辨率,这是不可能的。我们能做的次优方案是在预滤波阶段更 aggressively 地模糊图像,以淡化萤火虫噪点。让我们为此在PostFXSettings.BloomSettings中添加一个切换选项
public bool fadeFireflies;
Fade fireflies enabled 启用萤火虫噪点淡化功能
为此新增一个预滤波萤火虫处理通道。此处不再展示将该通道添加到PostFXStack着色器及PostFXStack.Pass枚举的过程。请在DoBloom中选择合适的通道进行预滤波处理
Draw(sourceId, bloomPrefilterId, bloom.fadeFireflies ?Pass.BloomPrefilterFireflies : Pass.BloomPrefilter);
淡化萤火虫噪点最直接的方法是将预滤波通道的2×2降采样滤波器扩展为6×6盒式滤波器。我们可以通过九次采样实现,在求平均前对每个样本单独应用泛光阈值。请将所需的BloomPrefilterFirefliesPassFragment函数添加到PostFXStackPasses中
6×6 box filter 6×6盒式滤波器
float4 BloomPrefilterFirefliesPassFragment (Varyings input) : SV_TARGET {float3 color = 0.0;float2 offsets[] = {float2(0.0, 0.0),float2(-1.0, -1.0), float2(-1.0, 1.0), float2(1.0, -1.0), float2(1.0, 1.0),float2(-1.0, 0.0), float2(1.0, 0.0), float2(0.0, -1.0), float2(0.0, 1.0)};for (int i = 0; i < 9; i++) {float3 c =GetSource(input.screenUV + offsets[i] * GetSourceTexelSize().xy * 2.0).rgb;c = ApplyBloomThreshold(c);color += c;}color *= 1.0 / 9.0;return float4(color, 1.0);
}
但这还不足以解决问题,因为极亮像素只是被扩散到更大区域。为了淡化萤火虫噪点,我们将改用基于颜色亮度的加权平均值。颜色亮度即其感知明亮度,我们将使用Core库Color HLSL文件中定义的Luminance函数来实现
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Filtering.hlsl"
样本权重公式为 ,其中l代表亮度值。因此当亮度为 0 时权重为 1,亮度1时权重为 ½ ,亮度3时权重为 ¼ ,亮度7时权重为 ⅛ ,依此类推。
Luminance-based weights 基于亮度的权重
最终我们将样本总和除以这些权重之和。这实际上将萤火虫噪点的亮度扩散到所有其他样本中。如果其他样本较暗,萤火虫噪点就会淡化。例如,0、0、0和10的加权平均值为
float4 BloomPrefilterFirefliesPassFragment (Varyings input) : SV_TARGET {float3 color = 0.0;float weightSum = 0.0;…for (int i = 0; i < 9; i++) {…float w = 1.0 / (Luminance(c) + 1.0);color += c * w;weightSum += w;}color /= weightSum;return float4(color, 1.0);
}
Luminance-based weighed average 基于亮度的加权平均值
由于在初始预滤波步骤后我们会执行高斯模糊,因此可以跳过与中心直接相邻的四个采样点,将采样数量从九次减少到五次。
6×6 cross filter 6×6十字滤波器
float2 offsets[] = {float2(0.0, 0.0),float2(-1.0, -1.0), float2(-1.0, 1.0), float2(1.0, -1.0), float2(1.0, 1.0)//,//float2(-1.0, 0.0), float2(1.0, 0.0), float2(0.0, -1.0), float2(0.0, 1.0)};for (int i = 0; i < 5; i++) { … }
这会将单像素萤火虫噪点转化为X形图案,并在预滤波阶段将单像素水平或垂直线条拆分成两条独立线条,但在首次模糊处理后这些图案就会消失。
Pre-filtering step with five and nine samples; half resolution
预滤波阶段采用五次与九次采样对比;半分辨率效果
这并不能完全消除萤火虫噪点,但会大幅削弱其强度,使其不再明显刺眼——除非将泛光强度设置得远高于1。
(视频)
已淡化的萤火虫噪点
2. Scattering Bloom 散射泛光
现在我们已实现HDR泛光,让我们探讨其更贴近实际的应用场景。原理在于相机并非完美——其镜头无法完全精准聚焦所有光线,部分光线会扩散到更大区域(类似我们当前的泛光效果)。相机品质越高,散射现象越轻微。与我们的叠加式泛光效果最大区别在于:散射不会增加光线总量,仅对其进行漫射。视觉上可表现为从微光晕到笼罩整幅图像的朦胧光雾。
人眼同样存在缺陷,光线在眼内会以复杂方式散射。虽然所有入射光都会发生此现象,但仅在强光下尤为显著。例如观察暗背景中的小亮光源时(如夜间灯笼或晴日下的阳光反射)便显而易见。
与均匀圆形模糊光晕不同,人眼会观察到独特的多角不对称星状图案并伴随色偏。但我们的泛光效果将呈现具有均匀散射特性的无特征相机成像。
Bloom caused by scattering in camera 由相机内部散射引起的泛光现象
2.1 Bloom Mode 泛光模式
我们将同时支持经典叠加式和能量守恒散射式泛光模式。在PostFXSettings.BloomSettings中添加这两种模式的枚举选项,同时增加0-1范围的滑块来控制光线散射程度
public enum Mode { Additive, Scattering }public Mode mode;[Range(0f, 1f)]public float scatter;
所选散射模式已设置为0.5
将现有的 BloomCombine 通道重命名为 BloomAdd,并引入一个新的 BloomScatter 通道。确保枚举类型和通道顺序保持按字母顺序排列。然后在 DoBloom 的合成阶段使用相应的通道。在散射模式下,我们将使用散射量作为强度值而非 1。我们仍然使用配置的强度进行最终绘制
Pass combinePass;if (bloom.mode == PostFXSettings.BloomSettings.Mode.Additive) {combinePass = Pass.BloomAdd;buffer.SetGlobalFloat(bloomIntensityId, 1f);}else {combinePass = Pass.BloomScatter;buffer.SetGlobalFloat(bloomIntensityId, bloom.scatter);}if (i > 1) {buffer.ReleaseTemporaryRT(fromId - 1);toId -= 5;for (i -= 1; i > 0; i--) {buffer.SetGlobalTexture(fxSource2Id, toId + 1);Draw(fromId, toId, combinePass);…}}else {buffer.ReleaseTemporaryRT(bloomPyramidId);}buffer.SetGlobalFloat(bloomIntensityId, bloom.intensity);buffer.SetGlobalTexture(fxSource2Id, sourceId);Draw(fromId, BuiltinRenderTextureType.CameraTarget, combinePass);
BloomScatter通道的功能与BloomAdd相似,不同之处在于它基于强度在高分辨率源和低分辨率源之间进行插值,而不是简单相加。因此,散射量为零意味着仅使用最低的Bloom金字塔层级,而散射量为1意味着仅使用最高的层级。在四级金字塔的情况下,当散射量为0.5时,各层级的贡献最终分别为0.5、0.25、0.125、0.125。
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);
}
(视频)
arying bloom scatter; intensity 20 light inside structure; max iterations 16
调节泛光散射:建筑内部光线强度20;最大迭代次数16
散射泛光不会增亮图像。它可能看起来让上面的示例变暗了,但那是因为只显示了原图的裁剪部分。然而,能量守恒并不完美,因为高斯滤波器在图像边缘处被截断,这意味着边缘像素的贡献被放大了。我们可以对此进行补偿,但通常不会这样做,因为这种现象一般不明显
2.2 Scatter Limits 散射限制
由于散射值为0和1时会消除除一个金字塔层级外的所有层级,使用这些数值并不合理。因此我们将散射滑块的范围缩小到0.05–0.95。这使得默认值零失效,因此需要显式初始化BloomSettings的数值。我们将使用0.07作为默认值,这与URP和HDRP使用的散射默认值相同
public struct BloomSettings {…[Range(0.05f, 0.95f)]public float scatter;}[SerializeField]BloomSettings bloom = new BloomSettings {scatter = 0.7f};
此外,大于1的强度值不适用于散射泛光,因为那会导致增亮效果。因此我们将在DoBloom中对其进行限制,将最大值设为0.95,这样原始图像始终会对最终结果有所贡献
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);finalIntensity = Mathf.Min(bloom.intensity, 0.95f);}if (i > 1) {…}else {buffer.ReleaseTemporaryRT(bloomPyramidId);}buffer.SetGlobalFloat(bloomIntensityId, finalIntensity);
Intensity 0.5 and scatter 0.7 强度0.5,散射0.7
2.3 Threshold 阈值
散射泛光的效果比加法泛光要微妙得多。通常它也会配合较低的强度值使用。这意味着——就像真实相机一样——尽管所有光线都会产生散射,但泛光效果实际上只在光线非常明亮时才会明显。
虽然这不符合物理真实,但我们仍然可以设置阈值来消除较暗像素的散射效果。这样在使用较强泛光时能保持图像清晰度。然而,这种方法会削减光线从而使得图像变暗
Threshold 1, knee 0, and Intensity 1 阈值1,拐点0,强度1
我们需要对缺失的散射光线进行补偿。为此,我们创建一个额外的BloomScatterFinal通道,用于散射泛光的最终绘制
Pass combinePass, finalPass;float finalIntensity;if (bloom.mode == PostFXSettings.BloomSettings.Mode.Additive) {combinePass = finalPass = Pass.BloomAdd;buffer.SetGlobalFloat(bloomIntensityId, 1f);finalIntensity = bloom.intensity;}else {combinePass = Pass.BloomScatter;finalPass = Pass.BloomScatterFinal;buffer.SetGlobalFloat(bloomIntensityId, bloom.scatter);finalIntensity = Mathf.Min(bloom.intensity, 1f);}…Draw(fromId, BuiltinRenderTextureType.CameraTarget, finalPass);}
该通道的功能是复制另一个散射通道的函数,但有一个区别:它通过先添加高分辨率光源,再减去经过泛光阈值处理后的同一光源,将缺失的光线补偿到低分辨率通道中。这不是完美的重建——它既不是加权平均,也忽略了因萤火虫效应衰减而损失的光线——但已足够接近,且不会向原始图像添加额外光线
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);
}
Threshold with scatter final pass 使用散射最终通道的阈值
3. Tone Mapping 色调映射
虽然我们可以使用HDR进行渲染,但常规摄像机的最终帧缓冲区始终是LDR格式。因此颜色通道会在数值1处被截断。实际上最终图像的白点就设定在1。这意味着极端明亮的颜色最终看起来与完全饱和的颜色毫无区别。例如,我创建了一个包含多级光照强度和不同自发光度物体的场景,其亮度值远超1。最强的自发光度达到8,而最亮的光源强度高达200
Scene without post FX; only realtime lighting
未启用后期特效的场景;仅使用实时光照
在不启用任何后期特效的情况下,很难甚至无法分辨哪些物体和光源属于极高亮度。我们可以利用泛光效果来凸显这些区域。例如,我使用了阈值1、过渡范围0.5、强度0.2和散射值0.7,并设置了最大迭代次数
With bloom, additive and scattering
启用泛光效果,包含加法泛光与散射泛光
发光物体显然应该是明亮的,但我们仍然无法感知它们相对于场景其他部分的实际亮度。为此,我们需要调整图像的整体亮度——提高其白点值——使最亮颜色的数值不再超过1。虽然可以通过统一调暗整个图像来实现,但这会导致大部分场景过暗而无法清晰呈现。理想情况下,我们需要对高亮色彩进行大幅调整,而对暗部色彩仅作轻微改动。因此,必须采用非均匀的色彩调整方式。这种色彩调整并不代表光线本身的物理变化,而是反映了人眼对光线的感知方式——例如,我们的视觉系统对暗调色调的敏感度远高于亮调。
从HDR到LDR的转换过程被称为色调映射,这一概念源自摄影和胶片显影技术。传统照片和胶片同样存在动态范围限制和非均匀的光敏特性,因此业界已开发出多种转换技术。色调映射并不存在唯一正确的实现方式,不同的处理方法可为最终画面营造迥异的视觉氛围,例如经典的电影胶片质感
3.1 Extra Post FX Step 额外后期特效步骤
我们在泛光效果之后新增一个后期特效步骤来执行色调映射。为此,在PostFXStack中添加DoToneMapping方法,该方法最初仅将源纹理复制到相机目标
void DoToneMapping(int sourceId) {Draw(sourceId, BuiltinRenderTextureType.CameraTarget, Pass.Copy);}
我们需要调整泛光处理的结果,因此需要获取一个新的全分辨率临时渲染纹理,并将其作为DoBloom的最终目标。同时修改该方法,使其返回是否执行了绘制操作,而不是在跳过特效时直接绘制到相机目标上
intbloomBicubicUpsamplingId = Shader.PropertyToID("_BloomBicubicUpsampling"),bloomIntensityId = Shader.PropertyToID("_BloomIntensity"),bloomPrefilterId = Shader.PropertyToID("_BloomPrefilter"),bloomResultId = Shader.PropertyToID("_BloomResult"),…;…bool DoBloom (int sourceId) {//buffer.BeginSample("Bloom");PostFXSettings.BloomSettings bloom = settings.Bloom;int width = camera.pixelWidth / 2, height = camera.pixelHeight / 2;if (bloom.maxIterations == 0 || bloom.intensity <= 0f ||height < bloom.downscaleLimit * 2 || width < bloom.downscaleLimit * 2) {//Draw(sourceId, BuiltinRenderTextureType.CameraTarget, Pass.Copy);//buffer.EndSample("Bloom");return false;}buffer.BeginSample("Bloom");…buffer.SetGlobalFloat(bloomIntensityId, finalIntensity);buffer.SetGlobalTexture(fxSource2Id, sourceId);buffer.GetTemporaryRT(bloomResultId, camera.pixelWidth, camera.pixelHeight, 0,FilterMode.Bilinear, format);Draw(fromId, bloomResultId, finalPass);buffer.ReleaseTemporaryRT(fromId);buffer.EndSample("Bloom");return true;}
调整Render方法,使其在泛光效果启用时对泛光结果执行色调映射,然后释放泛光结果纹理。否则,直接对原始源应用色调映射,完全跳过泛光处理
public void Render (int sourceId) {if (DoBloom(sourceId)) {DoToneMapping(bloomResultId);buffer.ReleaseTemporaryRT(bloomResultId);}else {DoToneMapping(sourceId);}context.ExecuteCommandBuffer(buffer);buffer.Clear();}
我们能否将色调映射与最终的泛光通道合并?
是的,URP和HDRP确实会在Uber通道中实现这类及更多功能的整合。不过,将特效完全分离会使逻辑更清晰,也更容易单独修改,这正是本教程采用的方法
3.2 Tone Mapping Mode 色调映射模式
存在多种色调映射的实现方法,我们将支持其中几种。为此,需要在PostFXSettings中添加一个ToneMappingSettings配置结构体,其中包含一个Mode枚举选项,该枚举初始仅包含“无”模式
[System.Serializable]public struct ToneMappingSettings {public enum Mode { None }public Mode mode;}[SerializeField]ToneMappingSettings toneMapping = default;public ToneMappingSettings ToneMapping => toneMapping;
色调映射模式设置为“无”
3.3 Reinhard 色调映射算法
我们色调映射的目标是降低图像亮度,让原本均匀的白色区域呈现出丰富的色彩变化,从而还原原本丢失的细节。这就像人眼突然进入明亮环境时会自动调节直到重新看清物体的过程。但我们不希望整体均匀地降低亮度,因为那样会使暗部色彩难以区分,相当于用曝光不足替换了过曝问题。因此我们需要一种非线性转换:对暗值影响轻微,但对高值大幅压缩。在极端情况下,零值保持为零,趋近无穷大的值会被压缩到1。实现此功能的简单公式是c/(1+c),其中c代表颜色通道。该函数是最简形式的Reinhard色调映射运算(由Mark Reinhard最初提出),不同之处在于我们将其独立应用于每个颜色通道,而他当时是针对亮度值设计的
Reinhard tone mapping
Reinhard 色调映射
在ToneMappingSettings.Mode中的None之后添加Reinhard选项。然后将枚举起始值设为-1,使Reinhard对应的值为零
public enum Mode { None = -1, Reinhard }
接下来添加ToneMappingReinhard通道,并让PostFXStack.DoTonemapping在适当时机使用它。具体来说,当模式为负值时执行简单复制,否则应用Reinhard色调映射
void DoToneMapping(int sourceId) {PostFXSettings.ToneMappingSettings.Mode mode = settings.ToneMapping.mode;Pass pass = mode < 0 ? Pass.Copy : Pass.ToneMappingReinhard;Draw(sourceId, BuiltinRenderTextureType.CameraTarget, pass);}
ToneMappingReinhardPassFragment 着色器函数直接应用该映射公式
float4 ToneMappingReinhardPassFragment (Varyings input) : SV_TARGET {float4 color = GetSource(input.screenUV);color.rgb /= color.rgb + 1.0;return color;
}
Top no tone mapping, bottom Reinhard, both with additive and scattering bloom
上方无色调映射,下方Reinhard模式,均采用叠加式与散射式泛光效果
这种方法可行,但由于精度限制,在处理极大数值时可能出现问题。同理,极大值会远在达到无穷大之前就被压缩到1。因此我们在执行色调映射前先对颜色进行钳位处理。将上限设为60可以避免我们支持的所有模式出现潜在问题。
color.rgb = min(color.rgb, 60.0);color.rgb /= color.rgb + 1.0;
When is precision an issue?
It can become a problem for some functions when half values are used. Due to a bug in the shader compiler this happens in some cases with the Metal API, even when float is used explicitly. This also affects some MacBooks, not only mobiles.
3.4 Neutral 中性
Reinhard色调映射的白点理论上是无限大的,但可以通过调整使最大值提前达到,从而减弱调整强度。其替代函数为 ,其中w代表白点值
Reinhard with white point at infinity and 4
Reinhard 色调映射:白点设为无限大与4的对比
我们可以为此添加配置选项,但Reinhard并非唯一可用的函数。一个更常用且有趣的函数是。其中x是输入颜色通道,其他值为配置曲线的常数。最终颜色为
,其中c是颜色通道,e是曝光偏差,w是白点。该函数可生成S形曲线:趾部区域从黑色向上弯曲至中段线性部分,最终在肩部区域随着接近白色而逐渐平缓。
上述函数由John Hable设计,首次应用于《神秘海域2》(参见幻灯片142和143)
Reinhard and Uncharted 2 tone mapping
Reinhard与《神秘海域2》色调映射对比
URP和HDRP使用该函数的变体,采用自有配置值且白点为5.3,但同时将白点缩放用于曝光偏差,因此最终曲线为 。这会产生约4.035的有效白点。该函数用于中性色调映射选项,可通过Color Core库HLSL文件中的NeutralTonemap函数调用
Reinhard white point infinite and 4, and neutral tone mapping
Reinhard白点(无限大与4)与中性色调映射对比
让我们为此色调映射模式添加选项。将其置于Mode枚举中None之后、Reinhard之前
public enum Mode { None = -1, Neutral, Reinhard }
随后为其创建另一个通道。现在PostFXStack.DoToneMapping可通过在非None模式时将中性选项加入模式值来找到正确的通道
Pass pass =mode < 0 ? Pass.Copy : Pass.ToneMappingNeutral + (int)mode;
ToneMappingNeutralPassFragment 函数随后只需调用 NeutralTonemap 即可
float4 ToneMappingNeutralPassFragment (Varyings input) : SV_TARGET {float4 color = GetSource(input.screenUV);color.rgb = min(color.rgb, 60.0);color.rgb = NeutralTonemap(color.rgb);return color;
}
Top Reinhard, bottom neutral
上方Reinhard,下方中性色调映射
您可以添加配置选项来调整自定义曲线,但我们将继续介绍最终的色调映射模式
3.5 ACES 学院色彩编码系统
本教程支持的最终模式是ACES色调映射,URP和HDRP也采用此方案。ACES是学院色彩编码系统的简称,作为全球标准用于数字图像文件交换、色彩工作流程管理以及交付和归档母版制作。我们仅使用Unity实现的色调映射方法。
首先将其添加到Mode枚举中,直接放在None之后以保持其余选项按字母顺序排列
public enum Mode { None = -1, ACES, Neutral, Reinhard }
添加该通道并调整PostFXStack.DoToneMapping,使其从ACES模式开始处理
Pass pass =mode < 0 ? Pass.Copy : Pass.ToneMappingACES + (int)mode;
新的ToneMappingACESPassFragment函数可直接使用Core库中的AcesTonemap函数。该函数通过Color包含,但您还可以查看独立的ACES HLSL文件。函数的输入颜色需处于ACES色彩空间,为此我们可以使用unity_to_ACES函数进行转换
float4 ToneMappingACESPassFragment (Varyings input) : SV_TARGET {float4 color = GetSource(input.screenUV);color.rgb = min(color.rgb, 60.0);color.rgb = AcesTonemap(unity_to_ACES(color.rgb));return color;
}
Top neutral, middle ACES, bottom no tone mapping
上方中性模式,中间ACES模式,下方无色调映射
ACES与其他模式最显著的区别在于它对高亮色彩添加了色相偏移,使其趋近白色。这种现象在相机或人眼受到强光过曝时也会发生。结合泛光效果后,可以清晰分辨出最亮的表面。此外,ACES色调映射会略微压暗深色区域,从而增强对比度,最终形成电影般的视觉效果。
The next tutorial is Color Grading