Bloom辉光效果

一直都想做一个Bloom效果,Bloom是一个很简单的效果,几乎所有介绍后处理的教程里都会提到Bloom效果的制作,但是Bloom又是一个不那么简单的效果,大部分教程制作出来的Bloom看上去都不太好看。

想要做好Bloom,首先得认识到什么是Bloom效果。Bloom是由于透镜不能完美地让光线聚焦与同一点而导致图像上的高亮区域的颜色向周围溢出的效果,和体积雾这样的由于多次散射和折射形成的溢出效果在原理上就不相同。在计算机图形学里往往使用多次模糊的方式来表现这种效果。

而在讨论什么是好的Bloom之前,我们先来看看差的Bloom的效果。Matthew Gallant在他的文章Bloom Disasters中就给出了很多当时的极糟糕的Bloom效果的例子。可以看到Bloom很重要的一点是,Bloom之前的画面必须要是HDR的画面,如果整个画面被限制在01之间,那么白色的T恤和特别亮以至于看上去是白色的太阳带来的Bloom效果就会相同。在LearnOpenGL上有那么一篇文章,其中说到,为了模拟我们眼睛的工作原理,我们不对颜色进行阈值限制,而是直接对HDR画面进行模糊再和原HDR画面进行插值。我认为这是一种十分错误的方式。最合理的方式应当是,画面上的每个颜色确实会向周围溢出自己的颜色,但是更亮的颜色的溢出半径会更大,对于较暗的颜色,由于溢出半径小于半个像素宽,在最后的画面中就看不到颜色的溢出了。但是根据明度来控制溢出的半径是一件很复杂的事情(这和景深的原理是一样的,所以我到现在都没有掌握一个很好的景深的算法),因此我们在计算的时候通过仅模糊超出阈值的颜色来模拟这种效果。模糊半径也是一个决定Bloom质量的关键要素,如果模糊的半径比较小,看上去就像高光套了一个稍弱的圈一样,不够美观。

Jorge Jimenez在2014年Siggraph多的Advances in Real-Time Rendering课程上介绍了他为使命召唤现代战争所做的次世代后处理效果。他的PPT里介绍了使命召唤现代战争中运动模糊、散景、次表面散射、Bloom和阴影采样的做法,十分值得一看。本文在整体的算法上就使用了他介绍的方法,而采样则使用了Dual Kawase Blur的算法,可以看我之前的文章

Bloom的算法

主流的Bloom算法都会使用一个阈值,第一个Pass提取出大于这个阈值的颜色(使用减法,这样能够和小于阈值的颜色形成自然的过渡),然后进行一系列的降采样升采样以减少采样的次数,最后将之前所有的升采样的模糊结果(就相当于是Mip的每一级)叠加到一开始的颜色上。为了减少最后一步采样所有的Mip等级带来的消耗,根据Jorge Jimenez的做法,我们会在每一步升采样时叠加当前Mip的颜色。但是最后叠加不是一个很好的处理方法,由于叠加了各个Mip的颜色,会导致原来高光的区域的亮度会被提高到原来的两倍甚至更多,不过我们之后的Tone Mapping能够一定程度上缓解这个问题。然后是对微小的高亮物体的处理,Jorge Jimenez使用了1/(1 + Luma)的方式进行加权处理,不过如果我们将Bloom移动到TAA之后,这个问题能够很好的解决掉。至于模糊,在我之前那么多文章的铺垫下,也就不是什么难点了。

我自己在实现的时候,会在最后一步叠加Mip到最一开始的图像时,将模糊后的颜色除以所有的降采样次数,这样能够稍微弥补一下多个Mip带来的亮度剧烈增加的问题。事实上我也想过将因为阈值而丢失的亮度储存在透明通道里,和颜色一起参与模糊,在最后的时候加回之前丢失的亮度,最后和原始颜色线性插值,不过似乎不那么好做。

这里可以对比一下Unity自带的Bloom和我的Bloom之间的效果差异。Unity第一个Pass预过滤会进行13次采样,之后每一次降采样分成横竖两个方向,横向9次采样,竖向5次采样,升采样则是在2次采样中线性插值。我的则是每次降采样进行5次采样,每次升采样进行8+1次采样,不需要分横竖采样。下图上边是Unity自带的Bloom,下边是我的Bloom,最后均使用Aces Tonemapping,场景里的大立方体的大小是相邻小立方体的1.5倍,而小立方体的亮度是相邻大立方体的1.5倍。我尽量地将参数调的差不多,Unity一共22个Draw最高Mip为7,我的一共17个Draw最高Mip为8,可以观察到Unity的会有稍微明显一点的Banding,中间亮度的中间大小的物体带来的Bloom比我的稍微大一些。

Unity Default Bloom

Unity Default Bloom

My High Quality Bloom

My High Quality Bloom

Bloom的具体实现

HQBloomComputeShader.compute

这里有四个Kernel,HQBloomWeightedDownsample用于第一次降采样时减去阈值并加权进行模糊,HQBloomDownsample是和Dual Kawase Blur一样的降采样的模糊,HQBloomAdditiveUpsample是在Dual Kawase Blur的升采样的基础上和低一级的Mip叠加,HQBloomComposite则是将最低一级Mip和原始颜色进行混合。

#pragma kernel HQBloomDownsample
#pragma kernel HQBloomWeightedDownsample
#pragma kernel HQBloomAdditiveUpsample
#pragma kernel HQBloomComposite

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

Texture2D<float4> _SourceTexture;
Texture2D<float4> _ColorTexture;
RWTexture2D<float4> _RW_TargetTexture;
SamplerState sampler_LinearClamp;
float4 _SourceSize;
float4 _TargetSize;
float _Threshold;
float _InvDownsampleCount;
float _BloomIntensity;

float3 applyThreshold(float3 color, out float luma)
{
	luma = Luminance(color);
	return color * max(0.0f, luma - _Threshold);
}

float getLumaWeight(float luma)
{
	return rcp(1.0f + luma);
}

float getLumaWeight(float3 color)
{
	float luma = Luminance(color);
	return rcp(1.0f + luma);
}

float3 sampleSource(float2 center, float2 offset)
{
    return _SourceTexture.SampleLevel(sampler_LinearClamp, center + offset, 0.0f).rgb;
}

[numthreads(8, 8, 1)]
void HQBloomDownsample(uint3 id : SV_DispatchThreadID)
{
	float2 uv = (float2(id.xy) + 0.5f) * _TargetSize.zw;
	float2 halfPixel = 0.5f * _TargetSize.zw;

	float3 c = sampleSource(uv, float2(0.0f, 0.0f));
	float3 tl = sampleSource(uv, halfPixel * float2(-1.0f, +1.0f));
	float3 tr = sampleSource(uv, halfPixel * float2(+1.0f, +1.0f));
	float3 bl = sampleSource(uv, halfPixel * float2(-1.0f, -1.0f));
	float3 br = sampleSource(uv, halfPixel * float2(+1.0f, -1.0f));

	float3 color = (tl + tr + bl + br + c * 4.0f) / 8.0f;
	_RW_TargetTexture[id.xy] = float4(color, 1.0f);
}

[numthreads(8, 8, 1)]
void HQBloomWeightedDownsample(uint3 id : SV_DispatchThreadID)
{
	float2 uv = (float2(id.xy) + 0.5f) * _TargetSize.zw;
	float2 halfPixel = 0.5f * _TargetSize.zw;

	float lumac, lumatl, lumatr, lumabl, lumabr;
	float3 c = applyThreshold(sampleSource(uv, float2(0.0f, 0.0f)), lumac);
	float3 tl = applyThreshold(sampleSource(uv, halfPixel * float2(-1.0f, +1.0f)), lumatl);
	float3 tr = applyThreshold(sampleSource(uv, halfPixel * float2(+1.0f, +1.0f)), lumatr);
	float3 bl = applyThreshold(sampleSource(uv, halfPixel * float2(-1.0f, -1.0f)), lumabl);
	float3 br = applyThreshold(sampleSource(uv, halfPixel * float2(+1.0f, -1.0f)), lumabr);

	float3 wc = getLumaWeight(lumac);
	float3 wtl = getLumaWeight(lumatl);
	float3 wtr = getLumaWeight(lumatr);
	float3 wbl = getLumaWeight(lumabl);
	float3 wbr = getLumaWeight(lumabr);

	float3 colorSum = tl * wtl + tr * wtr + bl * wbl + br * wbr + c * wc * 4.0f;
	float3 weightSum = wtl + wtr + wbl + wbr + wc * 4.0f;

	float3 color = colorSum / weightSum;
	_RW_TargetTexture[id.xy] = float4(color, 1.0f);
}

[numthreads(8, 8, 1)]
void HQBloomAdditiveUpsample(uint3 id : SV_DispatchThreadID)
{
	float2 uv = (float2(id.xy) + 0.5f) * _TargetSize.zw;
	float2 onePixel = 1.0f * _TargetSize.zw;

	// float3 c = sampleSource(uv, float2(0.0f, 0.0f));
	float3 t2 = sampleSource(uv, onePixel * float2(+0.0f, +2.0f));
	float3 b2 = sampleSource(uv, onePixel * float2(+0.0f, -2.0f));
	float3 l2 = sampleSource(uv, onePixel * float2(-2.0f, +0.0f));
	float3 r2 = sampleSource(uv, onePixel * float2(+2.0f, +0.0f));
	float3 tl = sampleSource(uv, onePixel * float2(-1.0f, +1.0f));
	float3 tr = sampleSource(uv, onePixel * float2(+1.0f, +1.0f));
	float3 bl = sampleSource(uv, onePixel * float2(-1.0f, -1.0f));
	float3 br = sampleSource(uv, onePixel * float2(+1.0f, -1.0f));

	float3 color = (t2 + b2 + l2 + r2 + 2.0f * (tl + tr + bl + br)) / 12.0f;
	float3 prevTarget = _RW_TargetTexture.Load(uint3(id.xy, 0));

	_RW_TargetTexture[id.xy] = float4(color + prevTarget, 1.0f);
}

[numthreads(8, 8, 1)]
void HQBloomComposite(uint3 id : SV_DispatchThreadID)
{
	float2 uv = (float2(id.xy) + 0.5f) * _TargetSize.zw;
	float2 onePixel = 1.0f * _TargetSize.zw;

	// float3 c = sampleSource(uv, float2(0.0f, 0.0f));
	float3 t2 = sampleSource(uv, onePixel * float2(+0.0f, +2.0f));
	float3 b2 = sampleSource(uv, onePixel * float2(+0.0f, -2.0f));
	float3 l2 = sampleSource(uv, onePixel * float2(-2.0f, +0.0f));
	float3 r2 = sampleSource(uv, onePixel * float2(+2.0f, +0.0f));
	float3 tl = sampleSource(uv, onePixel * float2(-1.0f, +1.0f));
	float3 tr = sampleSource(uv, onePixel * float2(+1.0f, +1.0f));
	float3 bl = sampleSource(uv, onePixel * float2(-1.0f, -1.0f));
	float3 br = sampleSource(uv, onePixel * float2(+1.0f, -1.0f));

	float3 color = (t2 + b2 + l2 + r2 + 2.0f * (tl + tr + bl + br)) / 12.0f;
	float3 colorTexture = _ColorTexture.Load(uint3(id.xy, 0));

	float3 bloomColor = colorTexture + color * _BloomIntensity * _InvDownsampleCount;
	_RW_TargetTexture[id.xy] = float4(bloomColor, 1.0f);
}

HQBloom.cs

没啥好说的。

using System;

namespace UnityEngine.Rendering.Universal
{
    [Serializable, VolumeComponentMenuForRenderPipeline("Post-processing/HQ Bloom", typeof(UniversalRenderPipeline))]
    public sealed class HQBloom : VolumeComponent, IPostProcessComponent
    {
        public BoolParameter isEnabled = new BoolParameter(false);
        public ClampedFloatParameter intensity = new ClampedFloatParameter(1.0f, 0.0f, 1.0f);
        public ClampedFloatParameter threshold = new ClampedFloatParameter(0.9f, 0.0f, 5.0f);
        public ClampedIntParameter downsampleCount = new ClampedIntParameter(7, 3, 10);

        public bool IsActive()
        {
            return isEnabled.value && intensity.value > 0.0f;
        }

        public bool IsTileCompatible()
        {
            return false;
        }
    }
}

HQBloomRenderPass.cs

这个脚本相较于Dual Kawase Blur来说要稍微简单一点,因为Bloom效果往往只会调整其强弱,而不会调整半径,不需要在两个降采样之间再做线性插值了。

using System.Collections.Generic;

namespace UnityEngine.Rendering.Universal
{
    public class HQBloomRenderPass : ScriptableRenderPass
    {
        static readonly string passName = "HQ Bloom Render Pass";

        private HQBloomRendererFeature.HQBloomSettings settings;
        private HQBloom hqBloom;
        private ComputeShader computeShader;

        static readonly string cameraColorTextureName = "_CameraColorAttachmentA";
        static readonly int cameraColorTextureID = Shader.PropertyToID(cameraColorTextureName);
        RenderTargetIdentifier cameraColorIden;

        private Vector2Int textureSize;
        private RenderTextureDescriptor desc;

        public HQBloomRenderPass(HQBloomRendererFeature.HQBloomSettings settings)
        {
            profilingSampler = new ProfilingSampler(passName);

            this.settings = settings;
            renderPassEvent = settings.renderPassEvent;
            computeShader = settings.computeShader;

            cameraColorIden = new RenderTargetIdentifier(cameraColorTextureID);
        }

        public void Setup(HQBloom hqBloom)
        {
            this.hqBloom = hqBloom;
        }

        public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
        {
            textureSize = new Vector2Int(cameraTextureDescriptor.width, cameraTextureDescriptor.height);
            desc = cameraTextureDescriptor;
            desc.enableRandomWrite = true;
            desc.msaaSamples = 1;
            desc.depthBufferBits = 0;
        }

        private Vector4 GetTextureSizeParams(Vector2Int size)
        {
            return new Vector4(size.x, size.y, 1.0f / size.x, 1.0f / size.y);
        }

        private void DoHQBloomDownsample(CommandBuffer cmd, RenderTargetIdentifier sourceid, RenderTargetIdentifier targetid,
                                        Vector2Int sourceSize, Vector2Int targetSize,
                                        bool firstDownsample, ComputeShader computeShader)
        {
            if (!computeShader) return;
            string kernelName = firstDownsample ? "HQBloomWeightedDownsample" : "HQBloomDownsample";
            int kernelID = computeShader.FindKernel(kernelName);
            computeShader.GetKernelThreadGroupSizes(kernelID, out uint x, out uint y, out uint z);
            cmd.SetComputeTextureParam(computeShader, kernelID, "_SourceTexture", sourceid);
            cmd.SetComputeTextureParam(computeShader, kernelID, "_RW_TargetTexture", targetid);
            cmd.SetComputeVectorParam(computeShader, "_SourceSize", GetTextureSizeParams(sourceSize));
            cmd.SetComputeVectorParam(computeShader, "_TargetSize", GetTextureSizeParams(targetSize));
            cmd.SetComputeFloatParam(computeShader, "_Threshold", hqBloom.threshold.value);
            cmd.DispatchCompute(computeShader, kernelID,
                                Mathf.CeilToInt((float)targetSize.x / x),
                                Mathf.CeilToInt((float)targetSize.y / y),
                                1);
        }

        private void DoHQBloomAdditiveUpsample(CommandBuffer cmd, RenderTargetIdentifier sourceid, RenderTargetIdentifier targetid,
                                        Vector2Int sourceSize, Vector2Int targetSize,
                                        ComputeShader computeShader)
        {
            if (!computeShader) return;
            string kernelName = "HQBloomAdditiveUpsample";
            int kernelID = computeShader.FindKernel(kernelName);
            computeShader.GetKernelThreadGroupSizes(kernelID, out uint x, out uint y, out uint z);
            cmd.SetComputeTextureParam(computeShader, kernelID, "_SourceTexture", sourceid);
            cmd.SetComputeTextureParam(computeShader, kernelID, "_RW_TargetTexture", targetid);
            cmd.SetComputeVectorParam(computeShader, "_SourceSize", GetTextureSizeParams(sourceSize));
            cmd.SetComputeVectorParam(computeShader, "_TargetSize", GetTextureSizeParams(targetSize));
            cmd.DispatchCompute(computeShader, kernelID,
                                Mathf.CeilToInt((float)targetSize.x / x),
                                Mathf.CeilToInt((float)targetSize.y / y),
                                1);
        }

        private void DoHQloomComposite(CommandBuffer cmd,
                                        RenderTargetIdentifier sourceid, RenderTargetIdentifier colorid, RenderTargetIdentifier targetid,
                                        Vector2Int sourceSize, Vector2Int targetSize,
                                        ComputeShader computeShader)
        {
            if (!computeShader) return;
            string kernelName = "HQBloomComposite";
            int kernelID = computeShader.FindKernel(kernelName);
            computeShader.GetKernelThreadGroupSizes(kernelID, out uint x, out uint y, out uint z);
            cmd.SetComputeTextureParam(computeShader, kernelID, "_SourceTexture", sourceid);
            cmd.SetComputeTextureParam(computeShader, kernelID, "_ColorTexture", colorid);
            cmd.SetComputeTextureParam(computeShader, kernelID, "_RW_TargetTexture", targetid);
            cmd.SetComputeVectorParam(computeShader, "_SourceSize", GetTextureSizeParams(sourceSize));
            cmd.SetComputeVectorParam(computeShader, "_TargetSize", GetTextureSizeParams(targetSize));
            cmd.SetComputeFloatParam(computeShader, "_InvDownsampleCount", 1.0f / hqBloom.downsampleCount.value);
            cmd.SetComputeFloatParam(computeShader, "_BloomIntensity", hqBloom.intensity.value);
            cmd.DispatchCompute(computeShader, kernelID,
                                Mathf.CeilToInt((float)targetSize.x / x),
                                Mathf.CeilToInt((float)targetSize.y / y),
                                1);
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            CommandBuffer cmd = CommandBufferPool.Get();
            using (new ProfilingScope(cmd, profilingSampler))
            {
                List<int> rtIDs = new List<int>();
                List<Vector2Int> rtSizes = new List<Vector2Int>();

                RenderTextureDescriptor tempDesc = desc;
                string bloomRT = "_BloomRT";
                int bloomRTID = Shader.PropertyToID(bloomRT);
                cmd.GetTemporaryRT(bloomRTID, tempDesc);

                rtIDs.Add(bloomRTID);
                rtSizes.Add(textureSize);

                Vector2Int lastSize = textureSize;
                int lastID = cameraColorTextureID;
                int downsampleCount = hqBloom.downsampleCount.value;
                for (int i = 0; i < downsampleCount; i++)
                {
                    string rtName = "_BloomRT" + i.ToString();
                    int rtID = Shader.PropertyToID(rtName);
                    Vector2Int rtSize = new Vector2Int((lastSize.x + 1) / 2, (lastSize.y + 1) / 2);
                    tempDesc.width = rtSize.x;
                    tempDesc.height = rtSize.y;
                    cmd.GetTemporaryRT(rtID, tempDesc);

                    rtIDs.Add(rtID);
                    rtSizes.Add(rtSize);

                    DoHQBloomDownsample(cmd, lastID, rtID, lastSize, rtSize, i == 0, computeShader);
                    lastID = rtID;
                    lastSize = rtSize;
                }

                for (int i = downsampleCount; i >= 1; i--)
                {
                    int sourceID = rtIDs[i];
                    Vector2Int sourceSize = rtSizes[i];
                    int targetID = rtIDs[i-1];
                    Vector2Int targetSize = rtSizes[i-1];

                    if(i == 1)
                    {
                        DoHQloomComposite(cmd, sourceID, cameraColorIden, targetID, sourceSize, targetSize, computeShader);
                        cmd.Blit(targetID, cameraColorIden);
                        cmd.ReleaseTemporaryRT(targetID);
                    }
                    else
                    {
                        DoHQBloomAdditiveUpsample(cmd, sourceID, targetID, sourceSize, targetSize, computeShader);
                    }
                    cmd.ReleaseTemporaryRT(sourceID);
                }
            }
            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();
            CommandBufferPool.Release(cmd);
        }
    }
}

HQBloomRendererFeature.cs

没啥好说的。

namespace UnityEngine.Rendering.Universal
{
    public class HQBloomRendererFeature : ScriptableRendererFeature
    {
        [System.Serializable]
        public class HQBloomSettings
        {
            public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
            public ComputeShader computeShader;
        }

        public HQBloomSettings settings = new HQBloomSettings();
        private HQBloomRenderPass hqBloomRenderPass;

        public override void Create()
        {
            hqBloomRenderPass = new HQBloomRenderPass(settings);
        }

        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            HQBloom HQBloom = VolumeManager.instance.stack.GetComponent<HQBloom>();
            if (HQBloom != null && HQBloom.IsActive())
            {
                hqBloomRenderPass.Setup(HQBloom);
                renderer.EnqueuePass(hqBloomRenderPass);
            }
        }
    }
}