高质量Bloom算法分析以及在URP中的实现

高质量Bloom算法分析以及在URP中的实现

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


效果展示

好光好画面,无论在哪种游戏或影视中,Bloom一直是深受玩家或观众喜爱的效果之一,用于再现真实摄像机成像瑕疵,能让图像中的明亮区域产生向外延伸的光线条纹,营造出超亮光线压制摄像机的视觉效果。

对比开Bloom和不开Bloom:


关闭Bloom


开启Bloom

Bloom效果:


@MaxHay 赛博立方体

算法理论

1. HDR概念

HDR(High-Dynamic-Range),高动态范围。它是一种能够显示更大的亮度范围和对比度的图像技术。详细的介绍请看: 《我们说的HDR也许不是一个东西》[1]

众所周知,我们的屏幕是由一个一个的像素组成,每个像素都由RGB三种颜色编码,而我们每一个颜色通道只能记录0到255范围内的数值,如果超出255或者低于0,都将会钳制到这个范围,也就导致了我们所能表示的亮度是有限的。HDR技术通过记录并展示远超传统SDR(标准动态范围)的图像信息,包括每个像素点的亮度值,来实现更大的曝光动态范围,即更大的明暗差别。它可以正确地表示真实世界中从太阳光直射到最暗的阴影的范围亮度,提供更多的动态范围和图像细节。

下面是使用SDR拍摄的一系列照片:


来自 https://zhuanlan.zhihu.com/p/120054579

每一张图片记录的颜色范围一样,但是左上角图片记录的颜色通道值较低,导致我们虽然能看清屋内的装饰(低亮度值),但看不见窗外的风景(高亮度值),而右下角图片记录的颜色通道值较高,我们可以轻易看清窗外的风景,但看不见屋内的装饰了。

通过观察图像我们可以知道,SDR由于亮度范围较窄,难以同时呈现极暗阴影和极亮高光的细节,容易出现暗部“死黑”高光“过曝丢失细节”的问题。

HDR对此的解决方法为:
1.将整个场景渲染到一张浮点纹理上 —— 线性空间中,像素中的颜色允许超过1,作为中间值记录下来,方便后续计算。
2.色调映射 —— ToneMapping,简单来说就是从高动态范围映射到标准动态范围,让其显示在屏幕上。

步骤1很好理解,重点说一下步骤2:
色调映射是在有限动态范围媒介上近似显示高动态范围图像的技术。对于人眼来说也有类似的映射方式,因为人眼对亮度的感知范围远低于自然界的亮度范围,只能感知到某个范围内的光照。和显示设备不同的是,人眼对光的感知范围是动态变化的,例如从光亮的室外环境突然走入一个黑暗的室内环境,刚刚开始一片漆黑,过一会儿才可以看清周围环境,人眼的这个调节过程叫做光适应(Light Adaptation)。所以要模拟出真实的光照效果,除了表现出合适的光照,还需要模拟出人眼对光线的调节过程。

最简单的色调映射是将亮度超过1的值置为1,这种做法会出现文章开始提到的墙壁和阳光一样亮的问题;另一种简单的色调映射是将每个像素除以最高亮度像素的亮度值,可以很好的将所有像素的亮度映射到[0, 1]之间,这种方法会使场景中某些像素特别亮而导致场景中的其他部分特别暗。比较好的方式是采用平均亮度值进行调节,由于平均亮度值反映了场景中的整体亮度,所以受到场景中少部分过亮或过暗的像素影响不大。

一般的色调映射分四个步骤:
1)计算当前原图像的平均亮度
平均亮度公式为:


来自 https://zhuanlan.zhihu.com/p/142101134

注: 由于人眼对三种颜色亮度感知不一样, 亮度换算公式为:
L = 0.27 * R + 0.62 * G + 0.06 * B

将此公式作为过滤器,通过降采样快速得到图像的平均亮度。

2)计算自适应之后的平均亮度
为了模拟人眼对于不同光强会自动调节适应范围的效果,需要对这一帧求出的平均亮度Lavg与上一帧的平均亮度Lavg进行插值:

float fNewAdaptation = fAdaptedLum + (fCurrentLum - fAdaptedLum) * ( 1 - pow( 0.98f, 30 * g_fElapsedTime ) );

其中,fAdaptedLum为上一帧的Lavg,fCurrentLum为当前帧的Lavg,g_fElapsedTime为当前帧和上一帧的时间间隔,fNewAdaptation为最终Lavg的计算结果。

3)计算当前图像映射之后的亮度


Lscale(x,y)是当前像素的亮度值,Key一般取0.18(和Gamma校正有关)。

4)将亮度映射到颜色中


将Lscale(x,y)映射到[0, 1]范围内。

更详细的介绍可以看看:《高动态范围(High-Dynamic-Range,简称HDR)》[2]

之所以介绍这个HDR,是因为我们Bloom计算需要HDR图像的数据,即允许像素颜色范围超过1,这样才能让亮的部分散发出去,并且,我们的Bloom效果一定要在ToneMapping之前做完,因为ToneMapping做完之后就已经是SDR了。

注:ToneMapping不止一种,下面展示ACES色调映射函数:


横坐标为像素某个颜色通道

ACES(Academy Color Encoding System)色调映射是一种基于电影行业标准的色彩映射技术,用于将高动态范围(HDR)图像或视频的颜色信息准确地映射到标准动态范围(SDR)显示器上,同时保持颜色的一致性和准确性。

补充:RGBM格式
RGBM是一种针对高动态范围(HDR)图像或视频的压缩存储格式,主要用于在有限的存储空间或传输带宽下,高效保留HDR内容的亮度和色彩信息。

  • 分离亮度与色彩信息:将RGB通道中的亮度信息提取出来,用一个额外的“M”分量(Multiplier,乘数)来表示全局或局部的亮度缩放因子。
  • 压缩动态范围:RGB通道存储经过M分量归一化后的色彩数据(通常范围较小,如0~1),而M分量单独存储亮度的缩放比例,通过“最终亮度 = RGB值 × M”恢复原始动态范围。
  • 低精度存储:RGB通道和M分量通常使用较低的位深(如8bit或16bit浮点)存储,相比直接存储高bit深度的HDR数据(如32bit浮点RGB),大幅减少了数据量。

2. 卷积概念


此图出处:Convolutional Neural Networks - Basics

关于卷积,详细介绍请见:【官方双语】那么……什么是卷积?[3]

简单理解,对于我们接下来用的高斯函数来说,就是用一个函数将一片区域映射成另一片区域,这个函数我们称为卷积核。不同的卷积核会有不同的结果,或是对图像进行锐化,或是对其进行模糊,一切取决于你的卷积核。

3. 高斯模糊

这个函数实在是太经典了,下面这个视频清晰地展示了高斯模糊的运作原理:


高斯模糊

4. Bloom效果分析
做Bloom效果,我们首先要考虑的就是怎么把亮的部分选出来,其次就是怎么让亮的部分发散出去

基于我们的目的,我们就可以理解一套完整的Bloom算法流程了。

Bloom效果的执行分为4个关键步骤,整体基于多分辨率金字塔(Pyramid)实现:

  • 预过滤(Prefilter):提取图像中亮度超过阈值的高光区域;
  • 下采样与高斯模糊(Blur Horizontal/Vertical):对高光区域进行多级别下采样,并在每个级别执行高斯模糊,模拟光晕扩散;
  • 上采样与混合(Upsample):将模糊后的低分辨率光晕图逐级放大,并与高分辨率图像混合,增强光晕的层次感;
  • 最终合成(UberPost):将处理后的光晕图与原图叠加,输出最终效果。

为什么要用下采样呢?单纯地模糊可以实现将亮的地方发散出去的效果吗?答案是可以产生光晕,但光晕扩散范围、过渡质感与分层金字塔方案差距明显。


原图像(无Bloom)


这是在PS中使用高斯模糊之后叠加到原图像的结果

可以看出来确实不尽人意,首先就是单纯模糊的扩散效果很差,要么就是模糊大了把亮的部分全散没了,要么就是模糊不够,导致亮的部分散不出去。如果一味地分层模糊而不进行下采样,会导致性能降低(画n张图和画4/3张图的差距还是很明显的),基于此,我们必须要使用下采样方式进行处理,既然提到下采样了,那就不得不提我们的老朋友MipMap了。

Mipmap图解:


无需多言

默认的MipMap下采样的过滤方式是盒状过滤(这个卷积核的效果是求四个像素的平均值),即用一个2x2的卷积核进行下采样,这种过滤范围是比较小的,不能满足我们的扩散需求,而且,如果你使用这种盒状过滤进行下次采样,会出现十分严重的盒状效果:


来自 https://zhuanlan.zhihu.com/p/525500877

所以我们必须使用高斯函数作为我们的卷积核进行下采样。

What's the cost?

如果要保证质量,我们不得不至少使用9x9高斯卷积核,需要进行81次采样和计算,这是一个相对较大的计算,那么我们的优化手段也很简单,就是将一个Pass走完的高斯模糊拆分成横竖两个Pass,原理如下图所示:

先走一半竖直方向Pass,再跑一边水平方向的Pass。

这个算法对不对呢?从数学的角度来分析,它俩绝对不是相等的(本质上是一次二维运算,坍缩到两次一维运算了,写出数学公式就知道是什么意思了),但是从效果的角度来看,它俩效果是差不多的,所以我们认为这是可行的。

引擎实战

1. 基础配置
新建工程,选择URP模板,新建一个Render Pipeline配置到项目中,新建RenderFeature注册到Renderer Data中,我将Pass部分解耦合出来,单独新建一个C#写RenderPass。

RenderFeature框架:

using System;
using UnityEngine;
using UnityEngine.Rendering.Universal;
using UnityEngine.Serialization;

namespace Bloom.Render
{
    public class BloomRenderFeature : ScriptableRendererFeature
    {
        // 渲染通道执行时机:控制Bloom效果在渲染管线的哪个阶段执行
        public RenderPassEvent m_RenderPassEvent;
        
        // 可序列化的Bloom配置类:在Inspector面板中显示可调节的参数
        [Serializable]
        public class BloomSettings
        {
            // Bloom效果使用的Shader:包含泛光计算的像素着色器逻辑
            public Shader m_BloomShader;
 
            // 亮度阈值:范围0-2,只有亮度超过此值的像素才会产生泛光
            [Range(0f, 2f)] public float threshold = 0.9f;
            
            // 泛光强度:范围0-5,控制泛光效果的明亮程度
            [Range(0f, 5f)] public float intensity = 1.0f;

            // 下采样模式:控制处理时图像缩小的比例(半尺寸/四分之一尺寸)
            public CustomBloomDownscaleMode downscale = CustomBloomDownscaleMode.Half;
        }
        
        // Bloom渲染通道实例:实际执行渲染逻辑的对象
        BloomRenderPass m_BloomRenderPass;
        
        // Bloom配置实例:存储在Inspector中设置的参数
        public BloomSettings m_BloomSettings = new();

        // 创建方法:初始化渲染特性,在渲染管线启动时调用
        public override void Create()
        {
            // 懒加载:如果渲染通道未创建,则初始化它
            m_BloomRenderPass ??= new BloomRenderPass();

            // 设置渲染通道的执行时机(与特性中配置的一致)
            m_BloomRenderPass.renderPassEvent = m_RenderPassEvent;
            
            // 初始化渲染通道,传入配置中的Shader
            m_BloomRenderPass.Create(m_BloomSettings.m_BloomShader);
        }

        // 添加渲染通道:将Bloom渲染通道加入渲染队列
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            // 只对游戏相机(CameraType.Game)应用Bloom效果,忽略预览窗口、Scene视图等相机
            if (!IsGameCamera(renderingData)) return;
            
            // 将Bloom渲染通道加入渲染器的执行队列
            renderer.EnqueuePass(m_BloomRenderPass);
        }

        // 配置渲染通道:在每个帧渲染前更新渲染通道的参数
        public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
        {
            // 只对游戏相机生效
            if (!IsGameCamera(renderingData)) return;
            
            // 将Inspector中配置的参数传递给渲染通道
            m_BloomRenderPass.Setup(
                m_BloomSettings.threshold,    // 亮度阈值
                m_BloomSettings.intensity     // 泛光强度
                );
        }

        // 释放资源:当渲染特性被销毁时调用,清理资源避免泄漏
        protected override void Dispose(bool disposing)
        {
            // 释放渲染通道的资源(纹理句柄等)
            m_BloomRenderPass?.Dispose();
            // 清理渲染通道的材质
            m_BloomRenderPass?.ClearUp();
            // 置空渲染通道引用
            m_BloomRenderPass = null;
        }
        
        // 辅助方法:判断当前渲染的相机是否为游戏相机
        private bool IsGameCamera(in RenderingData renderingData)
        {
            return renderingData.cameraData.cameraType == CameraType.Game;
        }
    }
}

RenderPass框架:

using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace Bloom.Render
{
    // 下采样模式枚举:控制Bloom效果中图像缩小的比例
    public enum CustomBloomDownscaleMode
    {
        Half = 0,   // 半尺寸:宽度和高度缩小为原来的1/2
        Quarter = 1 // 四分之一尺寸:宽度和高度缩小为原来的1/4
    }
    
    // 静态类:存储Bloom效果所需的Shader属性ID(全局共享)
    static class BloomShaderConstants
    {
        public static int[] _BloomMipUp;   // 上采样纹理的Shader属性ID数组
        public static int[] _BloomMipDown; // 下采样纹理的Shader属性ID数组
    }

    public class BloomRenderPass : ScriptableRenderPass
    {
        // 性能分析标签:用于Profiler识别Bloom渲染过程
        private const string m_BloomProfilingTag = "BloomRenderPass";
        // 性能采样器:精细化追踪渲染性能
        private ProfilingSampler m_ProfilingSampler = new(m_BloomProfilingTag + "_Sampler");
        // 最大金字塔层级:控制Bloom模糊的最大层级(16级足以覆盖大多数场景)
        private const int k_MaxPyramidSize = 16;

        // 内部类:存储Bloom效果的配置参数
        private class BloomSettings
        {
            public float threshold;                 // 亮度阈值:仅处理超过此值的像素
            public float intensity;                 // 泛光强度:控制最终效果的亮度
            public CustomBloomDownscaleMode downscale; // 下采样模式:控制图像缩小比例
        }

        // 成员变量
        private BloomSettings m_Bloom = new();     // Bloom参数实例
        private Material m_BloomMaterial;          // 用于Bloom效果的材质(关联Shader)
        private RTHandle source;                   // 源纹理句柄(相机渲染的原始图像)
        private RTHandle[] m_BloomMipDown;         // 下采样纹理句柄数组(构建模糊金字塔)
        private RTHandle[] m_BloomMipUp;           // 上采样纹理句柄数组(恢复图像大小)
        private RenderTextureDescriptor m_Descriptor; // 渲染纹理描述符(定义纹理属性)

        // 初始化方法:创建Bloom所需的资源
        public void Create(Shader bloomShader)
        {
            // 创建Bloom材质(基于指定的Shader)
            m_BloomMaterial = CreateMaterial(bloomShader);  
            
            // 初始化Shader属性ID数组和纹理句柄数组
            BloomShaderConstants._BloomMipUp = new int[k_MaxPyramidSize];
            BloomShaderConstants._BloomMipDown = new int[k_MaxPyramidSize];
            m_BloomMipDown = new RTHandle[k_MaxPyramidSize];
            m_BloomMipUp = new RTHandle[k_MaxPyramidSize];

            // 为每个金字塔层级分配资源
            for (int i = 0; i < k_MaxPyramidSize; i++)
            {
                // 生成Shader属性ID(用于在Shader中访问对应纹理)
                BloomShaderConstants._BloomMipUp[i] = Shader.PropertyToID("_BloomMipUp" + i);
                BloomShaderConstants._BloomMipDown[i] = Shader.PropertyToID("_BloomMipDown" + i);
                
                // 分配纹理句柄(RTHandle是URP中高效管理渲染纹理的方式)
                m_BloomMipUp[i] = RTHandles.Alloc(BloomShaderConstants._BloomMipUp[i], name: "_BloomMipUp" + i);
                m_BloomMipDown[i] = RTHandles.Alloc(BloomShaderConstants._BloomMipDown[i], name: "_BloomMipDown" + i);
            }
        }

        // 设置方法:更新Bloom参数(外部调用,传递配置值)
        public void Setup(
            float threshold,    // 亮度阈值
            float intensity)    // 泛光强度
        {
            m_Bloom.threshold = threshold;
            m_Bloom.intensity = intensity;
        }

        // 相机设置回调:在相机开始渲染时初始化资源
        public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
        {
            // 获取相机的颜色渲染目标作为源纹理
            source = renderingData.cameraData.renderer.cameraColorTargetHandle;

            // 初始化渲染纹理描述符(基于相机目标纹理属性)
            m_Descriptor = renderingData.cameraData.cameraTargetDescriptor;
            m_Descriptor.useMipMap = false;      // 禁用MipMap(Bloom使用手动下采样)
            m_Descriptor.autoGenerateMips = false; // 禁用自动生成MipMap
        }

        // 执行方法:Bloom效果的核心渲染逻辑
        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            // 获取命令缓冲区(批量执行渲染命令,提高效率)
            CommandBuffer cmd = CommandBufferPool.Get(m_BloomProfilingTag+ "_CommandBuffer");
            
            // 性能分析作用域:在Profiler中标记这段代码的执行时间
            using (new ProfilingScope(cmd, m_ProfilingSampler))
            {
                // 根据下采样模式计算缩放系数(位运算右移实现除法,效率更高)
                int downres = 1; // 默认半尺寸
                switch (m_Bloom.downscale)
                {
                    case CustomBloomDownscaleMode.Half:
                        downres = 1; // 右移1位 → 除以2
                        break;
                    case CustomBloomDownscaleMode.Quarter:
                        downres = 2; // 右移2位 → 除以4
                        break;
                    default:
                        throw new System.ArgumentOutOfRangeException();
                }
                
                // 配置下采样纹理的描述符
                var desc = m_Descriptor;
                desc.width = m_Descriptor.width >> downres;  // 按比例缩小宽度
                desc.height = m_Descriptor.height >> downres; // 按比例缩小高度
                desc.depthBufferBits = (int)DepthBits.None;  // 禁用深度缓冲(后处理不需要)
                desc.msaaSamples = 1;                        // 禁用MSAA(后处理在解析后执行)
                desc.graphicsFormat = GraphicsFormat.B10G11R11_UFloatPack32; // 使用HDR格式保存高亮度信息
                
                // 重新分配第一个下采样纹理(确保格式和大小正确)
                RenderingUtils.ReAllocateIfNeeded(ref m_BloomMipDown[0], desc, FilterMode.Bilinear,
                    TextureWrapMode.Clamp,  // 纹理边缘 clamping 避免采样错误
                    name: m_BloomMipDown[0].name);
                
                // 第一步:将源纹理渲染到下采样纹理,使用材质的第0个Pass
                // 使用Blitter工具类将临时渲染纹理(tempSource)绘制到源纹理(source)
                // 参数说明:
                // - cmd: 命令缓冲区,用于批处理GPU命令
                // - tempSource: 输入的临时渲染纹理
                // - source: 输出的目标纹理
                // - RenderBufferLoadAction.DontCare: 加载动作,不关心当前缓冲区内容
                // - RenderBufferStoreAction.Store: 存储动作,保留渲染结果
                // - m_BloomMaterial: 使用的泛光材质
                Blitter.BlitCameraTexture(cmd, source, m_BloomMipDown[0], RenderBufferLoadAction.DontCare,
                    RenderBufferStoreAction.Store, m_BloomMaterial, 0);
                
                // 第二步:将处理后的下采样纹理渲染回源纹理
                Blitter.BlitCameraTexture(cmd, m_BloomMipDown[0], source);
            }

            // 提交命令并释放资源
            context.ExecuteCommandBuffer(cmd);  // 执行命令缓冲区
            cmd.Clear();                        // 清空命令
            CommandBufferPool.Release(cmd);     // 释放命令缓冲区到对象池
        }

        // 相机清理回调:相机渲染结束后调用
        public override void OnCameraCleanup(CommandBuffer cmd)
        {
        }

        // 释放方法:释放所有RTHandle资源(防止内存泄漏)
        public void Dispose()
        {
            foreach (var handle in m_BloomMipDown)
                handle?.Release();
            foreach (var handle in m_BloomMipUp)
                handle?.Release();
        }

        // 清理方法:销毁材质资源
        public void ClearUp()
        {
            CoreUtils.Destroy(m_BloomMaterial);
        }

        // 辅助方法:创建Bloom材质(检查Shader有效性)
        Material CreateMaterial(Shader shader)
        {
            if (shader == null)
            {
                Debug.LogErrorFormat(
                    $"缺少Shader。Bloom渲染通道将无法执行,请检查渲染资源引用。");
                return null;
            }
            else if (!shader.isSupported)
            {
                return null;  // Shader不支持当前平台
            }

            // 创建符合URP规范的引擎材质
            return CoreUtils.CreateEngineMaterial(shader);
        }
    }
}

这里我们配置的是URP包内自己配置的Blit Shader:

检查一下内存:

2. 预过滤(Prefilter)

namespace Bloom.Render
{
    static class BloomShaderConstants
    {
        public static readonly int _Params = Shader.PropertyToID("_Params");
        
        public static int[] _BloomMipUp;
        public static int[] _BloomMipDown;
    }

    public class BloomRenderPass : ScriptableRenderPass
    {
        ......

        private class BloomSettings
        {
            public bool useRGBM;
            public bool highQualityFiltering;    
            public float threshold;
            public float intensity;
            public float scatter;
            public int maxIterations;
            public CustomBloomDownscaleMode downscale; 
            public float clamp;
        }

        // 多级采样流程示意图:
        //   原始图像(Camera输出)
        //      |
        // 下采样 0 → 1 → 2 → 3 → ... → n(逐级缩小并模糊)
        //                               |    
        // 上采样 0 ← 1 ← 2 ← 3 ← ... ← n(逐级放大并与上一级混合)
        //      |
        //   最终图像(叠加泛光效果)

        public void Setup(
            bool useRGBM,                // 是否启用RGBM编码
            bool highQualityFiltering,   // 是否启用高质量过滤
            float threshold,             // 亮度阈值
            float intensity,             // 泛光强度
            float scatter,               // 散射范围
            float clamp,                 // 亮度钳制值
            int maxIterations,           // 最大迭代次数
            CustomBloomDownscaleMode downscale // 下采样模式
            )
        {
            m_Bloom.useRGBM = useRGBM;
            m_Bloom.highQualityFiltering = highQualityFiltering;
            m_Bloom.threshold = threshold;
            m_Bloom.intensity = intensity;
            m_Bloom.scatter = scatter;
            m_Bloom.clamp = clamp;
            m_Bloom.maxIterations = maxIterations;
            m_Bloom.downscale = downscale;
        }

        ......

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            CommandBuffer cmd = CommandBufferPool.Get(m_BloomProfilingTag + "_CommandBuffer");
            using (new ProfilingScope(cmd, m_ProfilingSampler))
            {
                ......

                // 计算初始下采样纹理的宽高(基于源纹理尺寸和下采样模式)
                int tw = m_Descriptor.width >> downres;
                int th = m_Descriptor.height >> downres;

                // 计算最大迭代次数(下采样层级):根据初始下采样尺寸动态调整
                // 原理:每次下采样尺寸减半,直到最小尺寸为1x1,迭代次数=log2(最大尺寸)-1
                int maxSize = Mathf.Max(tw, th);
                int iterations = Mathf.FloorToInt(Mathf.Log(maxSize, 2f) - 1);
                // 限制迭代次数(不超过用户设置的最大迭代次数,避免过度模糊)
                int mipCount = Mathf.Clamp(iterations, 1, m_Bloom.maxIterations);

                // 预过滤参数处理
                float clamp = m_Bloom.clamp; // 亮度钳制值(限制泛光最大亮度)
                // 将阈值从伽马空间转换到线性空间(统一颜色计算空间,避免亮度偏差)
                float threshold = Mathf.GammaToLinearSpace(m_Bloom.threshold);
                // 软阈值过渡范围(固定为阈值的一半,使阈值边缘更平滑)
                float thresholdKnee = threshold * 0.5f;

                // 材质参数设置
                // 散射范围映射:将0-1的scatter映射到0.05-0.95(避免极端值导致效果异常)
                float scatter = Mathf.Lerp(0.05f, 0.95f, m_Bloom.scatter);
                // 向Shader传递核心参数:x=散射范围,y=亮度钳制,z=阈值,w=软阈值范围
                m_BloomMaterial.SetVector(BloomShaderConstants._Params, new Vector4(scatter, clamp, threshold, thresholdKnee));
                // 设置Shader关键字:启用高质量过滤(HQ)模式
                CoreUtils.SetKeyword(m_BloomMaterial, ShaderKeywordStrings.BloomHQ, m_Bloom.highQualityFiltering);
                // 设置Shader关键字:启用RGBM编码(适配低精度设备存储高亮度值)
                CoreUtils.SetKeyword(m_BloomMaterial, ShaderKeywordStrings.UseRGBM, m_Bloom.useRGBM);
                
                // 分配多级下采样和上采样纹理
                // 创建基础纹理描述符(HDR格式,保留高亮度信息)
                var desc = GetCompatibleDescriptor(tw, th, GraphicsFormat.B10G11R11_UFloatPack32);
                // 为每个迭代层级分配纹理(每次循环尺寸减半)
                for (int i = 0; i < mipCount; i++)
                {
                    // 分配上采样纹理(用于后续放大混合)
                    RenderingUtils.ReAllocateIfNeeded(ref m_BloomMipUp[i], desc, FilterMode.Bilinear,
                        TextureWrapMode.Clamp, // 边缘Clamp避免采样越界
                        name: m_BloomMipUp[i].name);
                    // 分配下采样纹理(用于当前层级缩小模糊)
                    RenderingUtils.ReAllocateIfNeeded(ref m_BloomMipDown[i], desc, FilterMode.Bilinear,
                        TextureWrapMode.Clamp, name: m_BloomMipDown[i].name);
                    // 缩小下一级纹理尺寸(宽高各减半)
                    desc.width  = Mathf.Max(1, desc.width >> 1); // 确保最小尺寸为1
                    desc.height = Mathf.Max(1, desc.height >> 1);
                }

                // 预过滤
                Blitter.BlitCameraTexture(cmd, source, m_BloomMipDown[0], RenderBufferLoadAction.DontCare,
                    RenderBufferStoreAction.Store, m_BloomMaterial, 0);

                Blitter.BlitCameraTexture(cmd, m_BloomMipDown[0], source);
            }
            
            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();
            CommandBufferPool.Release(cmd);
        }

        ......

        RenderTextureDescriptor GetCompatibleDescriptor(int width, int height, GraphicsFormat format,
            DepthBits depthBufferBits = DepthBits.None)
            => GetCompatibleDescriptor(m_Descriptor, width, height, format, depthBufferBits);

        internal static RenderTextureDescriptor GetCompatibleDescriptor(RenderTextureDescriptor desc, int width,
            int height, GraphicsFormat format, DepthBits depthBufferBits = DepthBits.None)
        {
            desc.depthBufferBits = (int)depthBufferBits;
            desc.msaaSamples = 1;
            desc.width = width;
            desc.height = height;
            desc.graphicsFormat = format;
            return desc;
        }
    }
}

RenderFeature中我们这样设置:

namespace Bloom.Render
{
    public class BloomRenderFeature : ScriptableRendererFeature
    {
        [Serializable]
        public class BloomSettings
        {
            public Shader m_BloomShader;
            public bool useRGBM = false;
            public bool highQualityFiltering = false;    
            [Range(0f, 2f)] public float threshold = 0.9f;
            [Range(0f, 5f)] public float intensity = 1.0f;
            public Color Tint = Color.white;
            public float clamp = 65472f;
            [Range(0f, 1f)] public float scatter = 0.7f;
            public int maxIterations = 8;
            public CustomBloomDownscaleMode downscale = CustomBloomDownscaleMode.Half; // 0: half, 1: quarter
        }
       
        ............

        public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
        {
            if (!IsGameCamera(renderingData)) return;
            
            m_BloomRenderPass.Setup(
                m_BloomSettings.useRGBM,
                m_BloomSettings.highQualityFiltering,
                m_BloomSettings.threshold,
                m_BloomSettings.intensity,
                m_BloomSettings.scatter,
                m_BloomSettings.clamp,
                m_BloomSettings.maxIterations,
                m_BloomSettings.downscale
                );
        }

        ............
    }
}

这里新建一个Shader,配置到RenderFeature中:

float4 _BlitTexture_TexelSize;

TEXTURE2D_X(_SourceTexLowMip);
float4 _SourceTexLowMip_TexelSize;

float4 _Params; // x: scatter, y: clamp, z: threshold (linear), w: threshold knee

#define Scatter             _Params.x
#define ClampMax            _Params.y
#define Threshold           _Params.z
#define ThresholdKnee       _Params.w

// 预过滤片段着色器:用于提取图像中超过亮度阈值的区域,为后续模糊处理做准备
// 返回值为经过HDR编码的半精度颜色值
half4 FragPrefilter(Varyings input) : SV_Target
{
    // 初始化立体渲染眼睛索引(用于VR等双摄像头场景,确保左右眼渲染正确)
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    
    // 获取经过立体渲染变换的纹理坐标(处理左右眼视差,确保采样位置正确)
    float2 uv = UnityStereoTransformScreenSpaceTex(input.texcoord);

    // 高质量Bloom模式:使用多像素采样实现初步平滑,减少后续模糊的噪点
#if _BLOOM_HQ
    .......(见下文)
#else
    // 低质量Bloom模式:直接采样当前像素,不进行多像素混合(性能优先)
    half3 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv).xyz;
#endif

    // 钳制颜色最大值,避免极端高光导致的视觉异常(用户可配置的上限)
    color = min(ClampMax, color);

    // 亮度阈值处理:分离出需要产生泛光的高亮区域
    // 1. 计算当前像素的亮度(取RGB通道最大值,简化亮度模型)
    half brightness = Max3(color.r, color.g, color.b);
    
    // 2. 计算软阈值过渡(使阈值边缘的亮度变化更平滑,避免硬 cutoff 导致的生硬边缘)
    // 公式解释:当亮度超过阈值时,逐渐增强过渡效果;阈值范围内则平滑衰减
    half softness = clamp(brightness - Threshold + ThresholdKnee, 0.0, 2.0 * ThresholdKnee);
    softness = (softness * softness) / (4.0 * ThresholdKnee + 1e-4);  // 平方运算增强平滑度
    
    // 3. 计算亮度乘数:只保留超过阈值的亮度部分
    // 当亮度高于阈值时,multiplier≈1;低于阈值时,multiplier≈0(通过softness实现平滑过渡)
    half multiplier = max(brightness - Threshold, softness) / max(brightness, 1e-4);
    
    // 应用乘数:过滤掉低于阈值的亮度,只保留高亮区域
    color *= multiplier;

    // 确保颜色值为非负数:避免后续HDR编码(可能含平方根运算)出现NaN(负数开方无意义)
    color = max(color, 0);
    
    // 对处理后的颜色进行HDR编码(适配HDR渲染管线,保留高动态范围信息)并返回
    return EncodeHDR(color);
}

如果是高质量Bloom模式,我们会先进行一次高斯模糊(这个步骤可以有效地减小画面中的闪烁,详情见下文)。

效果 :

可以看到亮的部分已经可以被过滤出来了。

3. 下采样与高斯模糊(Blur Horizontal/Vertical)
RenderPass:

       public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            CommandBuffer cmd = CommandBufferPool.Get(m_BloomProfilingTag + "_CommandBuffer");
            using (new ProfilingScope(cmd, m_ProfilingSampler))
            {
                // Downsample - gaussian pyramid
                var lastDown = m_BloomMipDown[0];
                for (int i = 1; i < mipCount; i++)
                {
                    // 经典的双 - pass 高斯模糊 —— 使用 mipUp 作为临时目标
                    // 第一遍执行 2 倍下采样 + 9-tap 高斯滤波
                    // 第二遍使用 5-tap 滤波器结合双线性插值执行 9-tap 高斯滤波
                    Blitter.BlitCameraTexture(cmd, lastDown, m_BloomMipUp[i], RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store, m_BloomMaterial, 1);
                    Blitter.BlitCameraTexture(cmd, m_BloomMipUp[i], m_BloomMipDown[i], RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store, m_BloomMaterial, 2);

                    lastDown = m_BloomMipDown[i];
                }
                
                Blitter.BlitCameraTexture(cmd, m_BloomMipDown[0], source);
            }
            
            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();
            CommandBufferPool.Release(cmd);
        }

Shader:

// HDR颜色编码函数:将线性空间的HDR颜色转换为适合存储/传输的格式
// 输入:线性空间的RGB颜色值(half3),输出:编码后的HDR颜色(half4)
half4 EncodeHDR(half3 color)
{
    // 根据是否启用RGBM编码选择不同的存储方式
#if _USE_RGBM
    // 使用RGBM编码压缩HDR颜色(适用于不支持浮点纹理的设备)
    // RGBM通过将高亮度值存储在Alpha通道,用RGB存储颜色比例,实现有限范围的HDR
    half4 outColor = EncodeRGBM(color);
#else
    // 不使用RGBM:直接存储线性颜色,Alpha通道固定为1.0(适用于支持浮点纹理的设备)
    half4 outColor = half4(color, 1.0);
#endif

    // 根据当前颜色空间(Gamma/Linear)进行转换
#if UNITY_COLORSPACE_GAMMA
    // 若项目使用Gamma颜色空间:将线性颜色转换为Gamma空间(通过平方根近似γ=2.0转换)
    // 原因:Gamma空间下纹理采样会自动线性化,此处预转换确保最终颜色正确
    return half4(sqrt(outColor.xyz), outColor.w); // linear to γ(线性→伽马)
#else
    // 若项目使用Linear颜色空间:直接返回线性颜色,无需额外转换
    return outColor;
#endif
}

// HDR颜色解码函数:将存储的HDR颜色转换回线性空间的RGB值
// 输入:编码后的HDR颜色(half4),输出:线性空间的RGB颜色值(half3)
half3 DecodeHDR(half4 color)
{
    // 根据当前颜色空间(Gamma/Linear)进行逆转换
#if UNITY_COLORSPACE_GAMMA
    // 若项目使用Gamma颜色空间:将Gamma空间颜色转换回线性空间(平方运算逆转平方根)
    color.xyz *= color.xyz; // γ to linear(伽马→线性)
#endif

    // 根据编码方式选择对应的解码逻辑
#if _USE_RGBM
    // 使用RGBM解码:从RGBM格式恢复原始HDR颜色
    return DecodeRGBM(color);
#else
    // 不使用RGBM:直接提取RGB通道(Alpha通道无用)
    return color.xyz;
#endif
}

// 水平方向高斯模糊片段着色器
// 输入:顶点着色器传递的纹理坐标等数据,输出:水平模糊后的HDR颜色
half4 FragBlurH(Varyings input) : SV_Target
{
    // 初始化立体渲染眼睛索引(用于VR等双摄像头场景,确保左右眼采样正确)
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    
    // 计算纹理像素水平尺寸(乘以2.0与下采样后的纹理尺寸匹配,确保采样步长正确)
    // 注:因前面可能进行了2x下采样,此处步长放大2倍以覆盖原尺寸范围
    float texelSize = _BlitTexture_TexelSize.x * 2.0;
    
    // 获取经过立体渲染变换的纹理坐标(处理左右眼视差,确保采样位置准确)
    float2 uv = UnityStereoTransformScreenSpaceTex(input.texcoord);

    // 9-tap水平高斯模糊:在水平方向采样9个点(当前像素左右各4个),覆盖更大范围
    // 每个采样点先通过DecodeHDR解码HDR颜色(输入纹理是HDR编码格式)
    half3 c0 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - float2(texelSize * 4.0, 0.0))); // 左4步
    half3 c1 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - float2(texelSize * 3.0, 0.0))); // 左3步
    half3 c2 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - float2(texelSize * 2.0, 0.0))); // 左2步
    half3 c3 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - float2(texelSize * 1.0, 0.0))); // 左1步
    half3 c4 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv                               )); // 当前像素
    half3 c5 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(texelSize * 1.0, 0.0))); // 右1步
    half3 c6 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(texelSize * 2.0, 0.0))); // 右2步
    half3 c7 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(texelSize * 3.0, 0.0))); // 右3步
    half3 c8 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(texelSize * 4.0, 0.0))); // 右4步

    // 高斯权重混合:使用符合高斯分布的权重混合9个采样点的颜色
    // 权重特点:中间像素权重最大(0.227),向两边对称递减,总和≈1(确保亮度守恒)
    half3 color = c0 * 0.01621622 + c1 * 0.05405405 + c2 * 0.12162162 + c3 * 0.19459459
                + c4 * 0.22702703  // 中心像素权重最高
                + c5 * 0.19459459 + c6 * 0.12162162 + c7 * 0.05405405 + c8 * 0.01621622;

    // 将混合后的颜色编码为HDR格式并输出(适配HDR渲染管线)
    return EncodeHDR(color);
}

// 垂直方向高斯模糊片段着色器
// 输入:顶点着色器传递的纹理坐标等数据,输出:垂直模糊后的HDR颜色
half4 FragBlurV(Varyings input) : SV_Target
{
    // 初始化立体渲染眼睛索引(同水平模糊,确保立体渲染正确)
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    
    // 计算纹理像素垂直尺寸(垂直方向步长,无需放大,因水平已处理下采样)
    float texelSize = _BlitTexture_TexelSize.y;
    
    // 获取经过立体渲染变换的纹理坐标
    float2 uv = UnityStereoTransformScreenSpaceTex(input.texcoord);

    // 优化的5-tap垂直高斯模糊:使用5个采样点+双线性插值,等效于9-tap效果(减少计算量)
    // 采样点位置经过数学优化(偏移值为特定小数),使5-tap覆盖原9-tap的模糊范围
    half3 c0 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - float2(0.0, texelSize * 3.23076923))); // 上3.23步
    half3 c1 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - float2(0.0, texelSize * 1.38461538))); // 上1.38步
    half3 c2 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv                                      )); // 当前像素
    half3 c3 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(0.0, texelSize * 1.38461538))); // 下1.38步
    half3 c4 = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(0.0, texelSize * 3.23076923))); // 下3.23步

    // 高斯权重混合:5-tap权重同样符合高斯分布,与水平9-tap形成互补
    // 权重总和≈1,中间像素权重最高,上下对称递减
    half3 color = c0 * 0.07027027 + c1 * 0.31621622
                + c2 * 0.22702703  // 中心像素权重最高
                + c3 * 0.31621622 + c4 * 0.07027027;

    // 编码为HDR格式并输出
    return EncodeHDR(color);
}

效果:

4. 上采样与混合(Upsample)
RenderPass:

// 上采样过程(默认使用双线性插值,高质量过滤模式下使用双三次插值)
// 从最高层级(最模糊)开始,逐级向上采样并与上一级纹理混合,恢复图像尺寸
for (int i = mipCount - 2; i >= 0; i--)
{
    // 确定低层级纹理(更模糊的纹理):
    // - 当i是倒数第二层时,使用下采样纹理数组的i+1层(因为此时上采样数组的i+1层尚未计算)
    // - 其他情况使用上采样纹理数组的i+1层(已计算好的更高级上采样结果)
    var lowMip = (i == mipCount - 2) ? m_BloomMipDown[i + 1] : m_BloomMipUp[i + 1];
    
    // 高层级纹理(当前层级的下采样纹理,细节更多但模糊度低)
    var highMip = m_BloomMipDown[i];
    
    // 目标纹理(当前层级的上采样结果,存储混合后的纹理)
    var dst = m_BloomMipUp[i];

    // 将低层级纹理设置到Shader中(供混合计算使用)
    cmd.SetGlobalTexture(BloomShaderConstants._SourceTexLowMip, lowMip);
    
    // 执行上采样混合:
    // - 源纹理:highMip(当前层级下采样纹理)
    // - 目标纹理:dst(当前层级上采样纹理)
    // - 使用材质的第3个Pass(上采样混合Pass)
    // - 该Pass会将低层级模糊纹理与高层级细节纹理混合,既保留模糊效果又恢复细节
    Blitter.BlitCameraTexture(cmd, highMip, dst, RenderBufferLoadAction.DontCare, 
                             RenderBufferStoreAction.Store, m_BloomMaterial, 3);
}

Shader:

// 上采样混合函数:将高层级细节纹理与低层级模糊纹理融合,恢复模糊细节
// 输入:当前像素的纹理坐标(uv),输出:混合后的RGB颜色
half3 Upsample(float2 uv)
{
    // 采样高层级纹理(highMip):当前层级的下采样纹理,分辨率较高、模糊度低,保留细节
    // 1. 从_BlitTexture(绑定为当前层级下采样纹理)中采样
    // 2. 通过DecodeHDR解码HDR颜色,转换为线性空间
    half3 highMip = DecodeHDR(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv));

    // 采样低层级纹理(lowMip):更高层级的模糊纹理,分辨率低、模糊度高,提供泛光基础
#if _BLOOM_HQ && !defined(SHADER_API_GLES)
    // 高质量模式(_BLOOM_HQ开启)且非GLES平台:使用双三次插值采样
    // SampleTexture2DBicubic:双三次插值函数,相比双线性插值能产生更平滑的边缘过渡
    // 参数说明:
    // - TEXTURE2D_X_ARGS:绑定低层级纹理_SourceTexLowMip及其采样器
    // - uv:采样坐标
    // - _SourceTexLowMip_TexelSize.zwxy:纹理像素尺寸(用于计算插值范围)
    // - (1.0).xx:采样偏移量(无偏移)
    // - unity_StereoEyeIndex:立体渲染眼睛索引(VR场景适配)
    half3 lowMip = DecodeHDR(SampleTexture2DBicubic(TEXTURE2D_X_ARGS(_SourceTexLowMip, sampler_LinearClamp), uv, _SourceTexLowMip_TexelSize.zwxy, (1.0).xx, unity_StereoEyeIndex));
#else
    // 非高质量模式或GLES平台:使用双线性插值采样(性能更优,边缘稍硬)
    // 从_SourceTexLowMip(绑定为低层级模糊纹理)中采样,解码HDR颜色
    half3 lowMip = DecodeHDR(SAMPLE_TEXTURE2D_X(_SourceTexLowMip, sampler_LinearClamp, uv));
#endif

    // 混合高层级细节与低层级模糊:使用Scatter参数控制混合比例
    // Scatter(散射)值越高,低层级模糊纹理占比越大,泛光越分散;反之细节越清晰
    return lerp(highMip, lowMip, Scatter);
}

half4 FragUpsample(Varyings input) : SV_Target
{
     UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
     half3 color = Upsample(UnityStereoTransformScreenSpaceTex(input.texcoord));
     return EncodeHDR(color);
}

效果:

5. 最终合成
RenderPass(接上文的上采样与混合之后):

// 设置Uber(可能是指主渲染器或全局渲染器)上的泛光效果
var tint = m_Bloom.tint;  // 获取泛光效果的色调颜色
var luma = ColorUtils.Luminance(tint);  // 计算色调颜色的亮度(明度)值

// 归一化色调颜色:如果亮度大于0,则保持色调但标准化亮度为1;否则使用白色
tint = luma > 0f ? tint * (1f / luma) : Color.white;

// 创建泛光参数向量:x分量为强度,y/z/w分量为归一化后的色调RGB值
var bloomParams = new Vector4(m_Bloom.intensity, tint.r, tint.g, tint.b);
// 将泛光参数设置到材质的 shader 变量中
m_BloomMaterial.SetVector(BloomShaderConstants._Bloom_Params, bloomParams);
// 设置是否使用 RGBM 格式:1表示启用,0表示禁用
m_BloomMaterial.SetFloat(BloomShaderConstants._Bloom_RGBM, m_Bloom.useRGBM ? 1f : 0f);
// 设置泛光纹理为全局纹理变量,供 shader 使用
cmd.SetGlobalTexture(BloomShaderConstants._Bloom_Texture, m_BloomMipUp[0]);

// 关键字设置 - 为了在Uber中节省一些变体,逻辑稍微复杂一些
if (m_Bloom.highQualityFiltering)
    // 如果启用高质量过滤,启用BloomHQ(高质量泛光)关键字
    m_BloomMaterial.EnableKeyword(ShaderKeywordStrings.BloomHQ);
else
    // 否则启用BloomLQ(低质量泛光)关键字
    m_BloomMaterial.EnableKeyword(ShaderKeywordStrings.BloomLQ);


// - 4: 材质中使用的pass索引(第5个pass,索引从0开始)
Blitter.BlitCameraTexture(cmd, tempSource, source, RenderBufferLoadAction.DontCare,
    RenderBufferStoreAction.Store, m_BloomMaterial, 4);

Shader:

half4 FragUberPost(Varyings input) : SV_Target
{
    float2 uv = SCREEN_COORD_APPLY_SCALEBIAS(UnityStereoTransformScreenSpaceTex(input.texcoord));
    float2 uvBloom = DistortUV(uv);
    half3 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, SCREEN_COORD_REMOVE_SCALEBIAS(uvBloom)).xyz;
    #if _BLOOM_HQ && !defined(SHADER_API_GLES)
        half4 bloom = SampleTexture2DBicubic(TEXTURE2D_X_ARGS(_Bloom_Texture, sampler_LinearClamp), SCREEN_COORD_REMOVE_SCALEBIAS(uvBloom), _Bloom_Texture_TexelSize.zwxy, (1.0).xx, unity_StereoEyeIndex);
    #else
    half4 bloom = SAMPLE_TEXTURE2D_X(_Bloom_Texture, sampler_LinearClamp,SCREEN_COORD_REMOVE_SCALEBIAS(uvBloom));
    #endif
    #if UNITY_COLORSPACE_GAMMA
        bloom.xyz *= bloom.xyz; // γ to linear
    #endif
    UNITY_BRANCH
    if (BloomRGBM > 0)
    {
        bloom.xyz = DecodeRGBM(bloom);
    }
    bloom.xyz *= BloomIntensity;
    color += bloom.xyz * BloomTint;
    
    return half4(color, 1.0);
}

不足与改进

Bloom算法缺点:

1.熟悉PBR的开发者都知道,BRDF镜面反射D项在粗糙度很小、NdotL趋近于1时会输出极高亮度,光源强度越高现象越明显。即高光部分非常亮,如果使用了法线贴图等高频法线信息,会导致画面闪烁得很厉害:


Unity的Bloom效果(未开启过滤)

视频中可以看到,当我们相机视角旋转时候,我们的细小辉光部分会闪烁,和真实相机的成像表现存在偏差,(视频有点糊,可以去Unity中自行尝试)。

2.后期边缘光过小、过强,会导致前后几帧算出的东西视觉上不连续,就感觉一直闪,尤其是运动镜头时:


来自AveMujica最后一集24分21秒的live镜头

可以看到祥子后面这个红台子上面一直闪(感觉WSD这一段故意这么整的...)。

针对以上两点,我们可以在PreFilter中进行一次盒状过滤:

// 获取纹理像素尺寸(用于计算相邻像素的偏移量)
    float texelSize = _BlitTexture_TexelSize.x;
    
    // 采样当前像素周围13个相邻像素(形成3x3范围的扩展采样,覆盖更多细节)
    half4 A = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(-1.0, -1.0)); // 左上
    half4 B = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(0.0, -1.0));  // 上中
    half4 C = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(1.0, -1.0));  // 右上
    half4 D = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(-0.5, -0.5));// 上左半偏移
    half4 E = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(0.5, -0.5)); // 上右半偏移
    half4 F = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(-1.0, 0.0));  // 左中
    half4 G = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);                                 // 当前像素
    half4 H = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(1.0, 0.0));  // 右中
    half4 I = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(-0.5, 0.5)); // 下左半偏移
    half4 J = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(0.5, 0.5));  // 下右半偏移
    half4 K = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(-1.0, 1.0));  // 左下
    half4 L = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(0.0, 1.0));   // 下中
    half4 M = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + texelSize * float2(1.0, 1.0));   // 右下

    // 定义采样权重(通过不同权重混合相邻像素,实现平滑效果)
    half2 div = (1.0 / 4.0) * half2(0.5, 0.125);  // 0.5和0.125为权重系数,除以4是归一化
    
    // 混合采样结果:对不同位置的像素赋予不同权重,平衡中心与边缘像素的影响
    half4 o = (D + E + I + J) * div.x;  // 半偏移像素(D/E/I/J)权重较高
    o += (A + B + G + F) * div.y;       // 左上区域像素(A/B/G/F)权重较低
    o += (B + C + H + G) * div.y;       // 右上区域像素(B/C/H/G)权重较低
    o += (F + G + L + K) * div.y;       // 左下区域像素(F/G/L/K)权重较低
    o += (G + H + M + L) * div.y;       // 右下区域像素(G/H/M/L)权重较低

    // 提取混合后的RGB颜色(忽略Alpha通道)
    half3 color = o.xyz;

效果:

结语:
代码是我参考Unity的,主要是熟悉这套URP后处理原生框架,所以这个代码适配性内容有很多,看起来比较繁琐,整体还是比较好懂的,希望我的分享对你有帮助,谢谢。

代码:
https://github.com/MrZhou-art/Bloom-in-URP

参考:

[1] 我们说的HDR也许不是一个东西

[2] 高动态范围(High-Dynamic-Range,简称HDR)

[3] 【官方双语】那么……什么是卷积?


这是侑虎科技第2035篇文章,感谢作者毅毅供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/20-85-47-35

再次感谢毅毅的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)