1. 光晕不是错觉:Bloom 解决的其实是显示精度问题
做夜景项目的时候,导演反复强调霓虹灯要“亮得散开一点”。一开始我以为他在说灯牌贴图太暗,后来才意识到他要的是那种经典泛光(Bloom)质感:亮部像在发光,颜色会温和地晕染到周围暗部。这套效果在真实摄影里根本不需要额外做,镜头玻璃和眼球晶状体天然会散射强光,夜景照片里路灯周围那圈光晕就是这么来的。可在游戏渲染里,画面是数学算出来的,没有镜头散射,也没有视网膜残像,高光区域会硬邦邦地杵在那里,看着就像一块过曝的白斑。
Bloom 要解决的问题,说穿了是显示精度问题。HDR 渲染流程里,场景中真正发光的区域像素值可以超过 1.0,但显示器能呈现的亮度范围也就 0 到 1 左右。直接把超亮像素截断到 1.0,画面会瞬间丢信息:一盏 5 倍曝光的灯,和一盏 2 倍曝光的灯,最终屏幕上都变成纯白,完全没有区别。Bloom 的思路不是去压缩这些超亮像素,而是把溢出的那部分能量“借”到周围像素上,让高光以光晕的形态扩散出去。这样既保住了亮部的主观强度,又让画面过渡变得柔和,这也正是人眼和相机看待强光源的真实方式。
这篇内容我会从原理讲到 URP 下的完整实现,适合正在从内置渲染管线迁移到 URP、或者想把手动后处理玩清楚的 Unity 开发者。你不需要有很深的图形学基础,但最好知道 CommandBuffer、RenderTexture 这些基本概念。读完你就能在自己项目里挂一个可调参的 Bloom,也能明白市面上那些 URP 泛光插件内部大概在折腾什么。
2. 一条滤镜流水线:阈值、降采样与高斯模糊怎么配合
2.1 从输入到输出,Bloom 的完整环节
Bloom 的完整流程可以压缩成三个动作:先提取画面里足够亮的像素,然后把这些像素组成的区域模糊成大范围光晕,最后把光晕加回原图。所有 Bloom 实现,不管多复杂,本质上都是这三步的排列组合。
提取这步用一句话描述就是“亮的地方才准发光”。你要先定一个阈值,比如 0.8,画面中所有亮度超过这个值的像素被抠出来,其余地方变黑。抠出来的结果是一张只有高光点、没有阴影和中间调的图,通常叫 bright pass。
接着是模糊,但这里有个容易被忽略的细节:你应该模糊的是已经抠出来的高光图,而不是直接模糊完整画面。如果对整个画面做模糊,暗部也会跟着晕开,画面会变得雾蒙蒙。只有先提取再模糊,光晕才会出现在高光周围,暗部依然保持干净。
最后把模糊结果按强度叠加回原图。这里的“叠加”选择很关键,直接决定画面是“发光”还是“起雾”,后面会专门展开。
2.2 阈值提取:和“亮度”较劲而不是和“颜色”较劲
阈值怎么判定,很多新手第一次写会犯同一个错误:拿颜色通道最大值和阈值比,比如max(r, g, b) > 阈值。这样会提取出大量纯色高饱和像素,明明暗度不高也会被误判为发光。真正合理的方式是用亮度公式,把 RGB 换算成感知亮度:
luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b人眼对绿色最敏感,红色次之,蓝色最弱,所以绿色通道权重最高。你拿亮度阈值筛选,提取出来的区域才符合视觉直觉。
另一个细节是提取时用什么函数。硬性step会让画面出现明显断层,高光区域边界整齐得像刀切一样,模糊后也救不回来。我习惯用平滑过渡,比如saturate((luminance - threshold) / softRange),softRange 给 0.1 到 0.2,提取出来的高光形状会自然柔和很多。
代码里我还会顺手把超过阈值的部分剪掉再乘以掩膜,也就是max(color - threshold, 0) * mask。这样做的效果是把所有高光统一摆到“零起点”上,后面模糊叠加时强度只跟像素相对亮度和范围有关,调参更直观。
| 提取方式 | 效果特征 | 适用场景 |
|---|---|---|
step(threshold, luminance) | 高光边界锐利,容易脏 | 风格化强、故意要锐边时 |
max(color - threshold, 0) | 只保留超亮部分 | 物理感更强的默认做法 |
max(...) * smoothMask | 过渡柔和,可控性好 | 我推荐的大部分项目 |
2.3 高斯模糊为什么是默认算子
模糊算子有很多种,均值模糊、径向模糊、运动模糊都有各自的舞台,但 Bloom 的默认选择几乎总是高斯模糊。原因有两个:一是高斯核的形状接近真实镜头光晕的能量分布,中心贡献大、边缘衰减平滑;二是高斯函数是可分离的。
可分离意味着一个二维高斯卷积可以拆成两次一维卷积:先水平方向,再垂直方向。计算量从 O(n²) 降成 O(2n)。再加上降采样配合,性能上非常划算。
实际 Shader 里我常用一个 5 tap 的高斯近似,用 9 个采样点的权重分布在 5 个位置上:
权重分布(水平方向): 中心:0.2270270270 偏移 1.3846153846 像素左右:各 0.3162162162 偏移 3.2307692308 像素左右:各 0.0702702703偏移距离是经过归一化计算的,不是随便填的,保证三个采样点叠加后能近似覆盖 9 tap 的高斯核。直接用这个权重表即可,做水平模糊时偏移只加在 X 方向,垂直模糊时偏移只加在 Y 方向。
2.4 降采样金字塔:用分辨率换半径
如果只做一次模糊,光晕范围最多也就十几像素,再多就要把采样偏移拉得很远,高斯权重会失真,性能也扛不住。所以真正的 Bloom 要用降采样金字塔。
思路是这样的:先把全屏画面降到一半分辨率,再做模糊。此时一个 3 像素偏移,在原始分辨率里相当于 6 像素,模糊半径直接翻倍。再把结果降成四分之一分辨率,偏移 3 像素相当于原图 12 像素。每降一级,相同的采样开销换来的光晕半径就翻一倍。
这就像你在地图上找一片区域的光照范围:在 1:100 的地图上画一个 1 厘米的圆,实际对应 1 米;换到 1:500 的地图上,同样画 1 厘米,覆盖的却是 5 米。降采样金字塔等于手动放大了“画笔”的覆盖范围。
同时性能收益非常明显:半分辨率面积是原来的四分之一,四分之一分辨率是十六分之一。后期处理主要在低分辨率上进行,最终再升采样合并,这也是移动端 Bloom 能跑起来的关键原因。
2.5 合成:加法和“加法”还不一样
光晕提取并模糊完之后,最后一步是把光晕叠回原图。最朴素的加法就是:
finalColor = originalColor + bloomColor * intensity这个实现忠实、简单,也是大多数 Bloom 插件的默认方式。但“加法”不止这一种,有人会用 Screen Blend:
finalColor = 1 - (1 - original) * (1 - bloom)这个公式会把暗部也提亮一点,光晕和底色融合更自然,但整体偏灰,需要配合较高的阈值使用。还有 Soft Light 之类更软的算法,常用于美颜类效果,游戏里做 Bloom 很少用,因为会削弱高光的锐度。
我自己的习惯是先按默认加法做,把 intensity 调到一个舒服的值,然后再对比 Screen Blend,看场景风格适合哪种。记住:加法让高光往外“涌”,Screen 混合让光“浸入”周围,两者视觉性格完全不同。Bloom 的最后一个参数之所以是“强度”而不是“透明度”,就是因为这个叠加不是简单的 alpha 混合,而是亮度能量叠加。
3. URP 里跑后处理,先忘掉 OnRenderImage
3.1 为什么这套 API 在 URP 中失灵了
从内置渲染管线迁移过来的人,第一个遇到的就是OnRenderImage不生效的问题。内置管线里后处理逻辑很简单,引擎渲染完场景后调用这个回调,你拿到 screen texture,处理完再用Graphics.Blit写回去,完事。
URP 不是这么工作的。它把渲染过程拆成了一长串可编程的 RenderPass,相机、阴影、不透明物体、天空盒、后处理各自是独立节点。渲染流程由 Renderer 对象统一调度,脚本想在某个时机插入自己的处理,必须把自己包装成ScriptableRendererFeature和ScriptableRenderPass,然后注册到 Renderer 的 Feature 列表里。
换句话说,在内置管线里你是“被引擎调用的员工”,在 URP 里你是“主动把自己塞进流水线的零件”。思路完全反过来了。
3.2 RendererFeature 的“挂载点”逻辑
一个 URP 后处理功能通常由两个类组成:
ScriptableRendererFeature:负责创建设置项和 Pass,Unity 编辑器中你在 Renderer 组件上看到的“Add Renderer Feature”按钮,加的就是这个类。ScriptableRenderPass:负责真正执行渲染逻辑,每一帧都会在指定时机被调用。
Feature 的Create方法在添加功能时调用一次,用来 new 出 Pass 对象,缓存材质。AddRenderPasses每帧调用,把 Pass 加入渲染队列。Pass 的Execute方法里拿到 CommandBuffer,写入你要执行的 Blit 操作。
有个很常见的坑:有人把所有 GL 操作写在Execute里后,发现画面变了,但编辑器里看不到任何输出,或者一进 Play 模式就闪退。这通常是因为没有在Create里创建材质,也没有检查Shader.Find返回结果。URP 后处理材质必须用支持 URP Shader 的材质,而且 Shader 要被包含在构建中,不然Shader.Find可能返回空,后续cmd.Blit全部失效。
3.3 RenderPassEvent 选错时机的结果
URP 有一张渲染事件表,比如BeforeRenderingOpaques、AfterRenderingOpaques、BeforeRenderingPostProcessing、AfterRenderingPostProcessing。选错时机,效果不会彻底失效,但会出现各种诡异问题。
如果你把 Bloom 放在AfterRenderingOpaques,那么透明物体还没渲染,透明件的自发光不会参与泛光,但泛光结果会被后续透明物体盖住,出现明显的层次错乱。如果放在AfterRenderingPostProcessing,此时后处理栈已经做过颜色校正和 Tonemapping,你的 Bloom 是在 LDR 图像上叠加的,超亮像素已经被压缩过了,提取阈值会极难控制,画面动不动就过曝。
最合理的位置是BeforeRenderingPostProcessing。Bloom 不应该和 Tonemapping 挤在一起,它应该在 HDR 空间中提取高光、做模糊,然后把结果交还给后处理栈做最终的颜色映射。这样 Bloom 处理的始终是原始高动态范围数据,画面表现才正确。
3.4 资源生命周期与 MSAA 兼容性
URP 里自己分配临时 RenderTexture 有个隐蔽问题:你要是直接拿cameraTargetDescriptor来分配,默认会带上 MSAA 采样数和深度缓冲位。后处理基本不需要 MSAA,深度缓冲也用不上。
所以创建 descriptor 时必须显式设置:
desc.msaaSamples = 1; desc.depthBufferBits = 0;不关 MSAA 的话,后处理 RT 会带着多重采样,采样时要么得到奇怪的颜色边缘,要么 Blit 回去时效率暴跌。这个我几乎在每次 URP 后处理教程里都会写,但还是总有人踩到。
资源释放也同样重要。Execute里创建的 RT 不要随手释放,尤其不要在同一帧GetTemporary后又立刻ReleaseTemporary,再拿给 CommandBuffer 用。正确做法是让 RT 活到 Pass 结束,并在合适的生命周期节点统一释放。为了篇幅我这里先不做展开,后面的代码里也会用相对简单的方式来处理,生产环境推荐使用 RTHandle 池,这个放到最后再说。
4. 手写一个可运行的 Bloom:脚本与 Shader 的完整拼装
4.1 工程结构与 C# 骨架
先建一个文件夹,比如Assets/PostProcess/Bloom/,里面放两个文件:BloomFeature.cs和Bloom.shader。这是最直接的教学结构,不引第三方库,不依赖后处理栈,你只要把这个 Feature 加到 URP Renderer 的 Feature 列表就能看到效果。
BloomFeature.cs承担两件事:定义暴露在 Inspector 上的参数,以及把执行 Pass 注册进渲染流程。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public sealed class BloomFeature : ScriptableRendererFeature { [System.Serializable] public class BloomSettings { [Range(0f, 5f)] public float threshold = 1.1f; [Range(0f, 3f)] public float intensity = 1.5f; [Range(1, 8)] public int iteration = 4; public Color tint = Color.white; } public BloomSettings settings = new BloomSettings(); private BloomPass m_Pass; public override void Create() { m_Pass = new BloomPass(settings); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { renderer.EnqueuePass(m_Pass); } }BloomPass才是干活的主体。我在里面用了List<RenderTexture>当作简易金字塔,每层模糊结果存下来,最后统一从低分辨率向高分辨率合并。
using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class BloomPass : ScriptableRenderPass { private readonly BloomFeature.BloomSettings m_Settings; private Material m_Material; private readonly List<RenderTexture> m_Pyramid = new List<RenderTexture>(); public BloomPass(BloomFeature.BloomSettings settings) { m_Settings = settings; renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; m_Material = CoreUtils.CreateEngineMaterial(Shader.Find("Hidden/Custom/Bloom")); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_Material == null) { return; } CommandBuffer cmd = CommandBufferPool.Get("CustomBloom"); RenderTextureDescriptor baseDesc = renderingData.cameraData.cameraTargetDescriptor; baseDesc.msaaSamples = 1; baseDesc.depthBufferBits = 0; RenderTexture source = renderingData.cameraData.renderer.cameraColorTarget; RenderTextureDescriptor halfDesc = baseDesc; halfDesc.width = Mathf.Max(1, baseDesc.width / 2); halfDesc.height = Mathf.Max(1, baseDesc.height / 2); // Pass 0:提取高亮,同时降到半分辨率 RenderTexture rt = RenderTexture.GetTemporary(halfDesc); cmd.Blit(source, rt, m_Material, 0); // 逐层降采样 + 模糊 for (int i = 0; i < m_Settings.iteration; i++) { RenderTexture blurA = RenderTexture.GetTemporary(rt.descriptor); RenderTexture blurB = RenderTexture.GetTemporary(rt.descriptor); cmd.Blit(rt, blurA, m_Material, 1); // 水平模糊 cmd.Blit(blurA, blurB, m_Material, 2); // 垂直模糊 m_Pyramid.Add(blurB); if (i < m_Settings.iteration - 1) { RenderTextureDescriptor nextDesc = rt.descriptor; nextDesc.width = Mathf.Max(1, rt.width / 2); nextDesc.height = Mathf.Max(1, rt.height / 2); RenderTexture next = RenderTexture.GetTemporary(nextDesc); cmd.Blit(blurB, next, m_Material, 4); // 降采样 RenderTexture.ReleaseTemporary(rt); rt = next; } else { RenderTexture.ReleaseTemporary(rt); } RenderTexture.ReleaseTemporary(blurA); } // 从最低分辨率开始,把各层累加上去 RenderTexture accum = RenderTexture.GetTemporary(m_Pyramid[m_Pyramid.Count - 1].descriptor); cmd.Blit(m_Pyramid[m_Pyramid.Count - 1], accum); cmd.SetGlobalFloat("_Intensity", 1f); cmd.SetGlobalColor("_Tint", Color.white); for (int i = m_Pyramid.Count - 2; i >= 0; i--) { RenderTexture up = RenderTexture.GetTemporary(m_Pyramid[i].descriptor); cmd.SetGlobalTexture("_BloomTex", accum); cmd.Blit(m_Pyramid[i], up, m_Material, 3); // Composite RenderTexture.ReleaseTemporary(accum); accum = up; } // 最后把光晕加到原图上 RenderTexture resultRT = RenderTexture.GetTemporary(baseDesc); cmd.SetGlobalTexture("_BloomTex", accum); cmd.SetGlobalFloat("_Intensity", m_Settings.intensity); cmd.SetGlobalColor("_Tint", m_Settings.tint); cmd.Blit(source, resultRT, m_Material, 3); cmd.Blit(resultRT, source); context.ExecuteCommandBuffer(cmd); cmd.Clear(); RenderTexture.ReleaseTemporary(accum); RenderTexture.ReleaseTemporary(resultRT); foreach (var tex in m_Pyramid) { RenderTexture.ReleaseTemporary(tex); } m_Pyramid.Clear(); } }这段代码没有做花哨的优化,但流程是完整的:提取、降采样、水平模糊、垂直模糊、金字塔合并、叠加回原图。你直接拿过去改一改参数就能跑。
4.2 Shader 五个 Pass 的职责划分
对应 C# 里的 Pass 索引,Shader 需要五个 Pass:
| Pass 索引 | 名称 | 源码里做了什么 |
|---|---|---|
| 0 | Threshold | 提取高亮,同时完成第一次半分辨率降采样 |
| 1 | BlurH | 水平方向高斯模糊 |
| 2 | BlurV | 垂直方向高斯模糊 |
| 3 | Composite | 两路输入合并输出 |
| 4 | Downsample | 2x2 平均降采样 |
Shader 代码里最需要注意的是输入约定:cmd.Blit(source, dest, material, passIndex)时,URP 会自动把 source 绑定到_MainTex。另一个输入(比如光晕累积图)就要自己用_BloomTex指定。Pass 3 同时读两张图,所以我在 C# 里每次用它之前都会SetGlobalTexture指定_BloomTex。
Shader "Hidden/Custom/Bloom" { Properties { _MainTex ("Source", 2D) = "white" {} _BloomTex ("Bloom", 2D) = "black" {} } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Cull Off ZWrite Off ZTest Always HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); TEXTURE2D(_BloomTex); SAMPLER(sampler_LinearClamp); half _Threshold; half _Intensity; half4 _Tint; float4 _MainTex_TexelSize; Varyings Vert(Attributes input) { Varyings o; o.positionCS = float4(input.positionOS.xy, 0.0, 1.0); o.uv = input.uv; return o; } ENDHLSL Pass { Name "Threshold" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragThreshold half4 FragThreshold(Varyings input) : SV_Target { half3 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv).rgb; half lum = Luminance(col); half softMask = saturate((lum - _Threshold) / 0.1h); half3 bright = max(col - _Threshold, 0) * softMask; return half4(bright, 1); } ENDHLSL } Pass { Name "BlurH" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragBlurH half4 FragBlurH(Varyings input) : SV_Target { float2 uv = input.uv; half3 sum = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).rgb * 0.2270270270; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv + float2(_MainTex_TexelSize.x * 1.3846153846, 0)).rgb * 0.3162162162; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv - float2(_MainTex_TexelSize.x * 1.3846153846, 0)).rgb * 0.3162162162; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv + float2(_MainTex_TexelSize.x * 3.2307692308, 0)).rgb * 0.0702702703; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv - float2(_MainTex_TexelSize.x * 3.2307692308, 0)).rgb * 0.0702702703; return half4(sum, 1); } ENDHLSL } Pass { Name "BlurV" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragBlurV half4 FragBlurV(Varyings input) : SV_Target { float2 uv = input.uv; half3 sum = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).rgb * 0.2270270270; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv + float2(0, _MainTex_TexelSize.y * 1.3846153846)).rgb * 0.3162162162; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv - float2(0, _MainTex_TexelSize.y * 1.3846153846)).rgb * 0.3162162162; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv + float2(0, _MainTex_TexelSize.y * 3.2307692308)).rgb * 0.0702702703; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv - float2(0, _MainTex_TexelSize.y * 3.2307692308)).rgb * 0.0702702703; return half4(sum, 1); } ENDHLSL } Pass { Name "Composite" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragComposite half4 FragComposite(Varyings input) : SV_Target { half3 src = SAMPLE_TEXTURE2D(_MainTex, sampler_LinearClamp, input.uv).rgb; half3 bloom = SAMPLE_TEXTURE2D(_BloomTex, sampler_LinearClamp, input.uv).rgb; return half4(src + bloom * _Intensity * _Tint.rgb, 1); } ENDHLSL } Pass { Name "Downsample" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragDownsample half4 FragDownsample(Varyings input) : SV_Target { float2 texel = _MainTex_TexelSize.xy; half3 sum = 0; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_LinearClamp, input.uv + float2(-texel.x, -texel.y) * 0.5).rgb; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_LinearClamp, input.uv + float2( texel.x, -texel.y) * 0.5).rgb; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_LinearClamp, input.uv + float2(-texel.x, texel.y) * 0.5).rgb; sum += SAMPLE_TEXTURE2D(_MainTex, sampler_LinearClamp, input.uv + float2( texel.x, texel.y) * 0.5).rgb; return half4(sum * 0.25, 1); } ENDHLSL } } }4.3 执行链路的逐步拆解
把 C# 和 Shader 拼起来看执行链路会清晰很多。
第一轮,cmd.Blit(source, rt, material, 0)把摄像头颜色目标读进来,用 Pass 0 提取高亮。因为rt是半分辨率,这一步同时完成了第一次降采样,等于高亮图也降了一级。
然后进入循环。第一次循环,对这张半分辨率高亮图做水平模糊和垂直模糊,结果存进m_Pyramid的第一项。这就是整个泛光的最细颗粒度光晕。接着用 Pass 4 把模糊结果再降一半分辨率,放入rt作为下一层的输入。
第二次循环,对四分之一分辨率的高亮模糊结果再做两次高斯模糊。注意这个模糊半径,是在已经缩小的图上执行的,实际覆盖原始像素的范围比第一次大得多。这一层会存进m_Pyramid的第二项,代表更大范围的光晕。
循环做到 iteration 次,m_Pyramid里就有从细到粗的多层光晕。合并阶段从最小的那层开始,向上逐层叠加。因为低分辨率层的尺寸比上一层小,Blit到高分辨率目标时默认采样器会自动做双线性放大,顺手完成了升采样,视觉上就是大光晕上叠中光晕、中光晕上叠小光晕,最后形成从亮部往外连续衰减的发光效果。
最后一步把合并结果送到 Pass 3,与原始画面做src + bloom * intensity * tint,完成输出。整个流程跑下来,一张全屏 HDR 颜色图,最终变成一张带着柔和光晕的漂亮画面。
4.4 教学版的局限与生产化方向
我故意把这段代码写得很直白,方便理解,但它离生产级还有一些距离。
最明显的问题是临时 RT 的分配和释放。教学版在Execute里GetTemporary最后又ReleaseTemporary,在高频率调用下可能会触发资源池抖动。URP 官方推荐的思路是用RTHandle配合RTHandleAllocator,让 RT 按需复用,RenderPass 结束时不需要手动释放,由管线统一管理。你可以先跑通这个版本,等对流程熟悉了再换成 RTHandle。
另一个问题是它没有考虑多相机渲染。如果项目里有主相机、UI 相机、反射探针相机,RenderFeature 会被所有适用相机执行,导致 UI 也被泛光处理。正规做法要在AddRenderPasses里判断renderingData.cameraData.cameraType,只对特定相机执行。
还有个方向是色调映射顺序。这个版本刻意放在 Post Processing 之前,适合默认管线。如果项目使用 ACES 色调映射,泛光会在 Tonemapping 之前参与计算,光晕颜色更自然;如果用中性色调映射或自定义 LUT,可能要微调强度。
5. 调参实录与三个容易翻车的坑
5.1 阈值、强度、迭代次数到底怎么配
参数调了无数个项目之后,我总结出一套相对通用的初始值,你可以拿这个当起点,再根据画面微调。
| 参数 | 推荐范围 | 经验 |
|---|---|---|
| threshold | 0.9 - 1.2 | 低于 0.9 容易整屏发光;高于 1.5 基本只剩点光源 |
| intensity | 1.0 - 2.0 | 想要“贵气”感再加到 2.5,但注意别让高光糊成一片 |
| iteration | 4 - 6 | 4 层够 1080P,6 层适合大屏幕和远景光晕 |
| tint | 默认白色 | 夜景想偏暖可以给橙色调,科幻场景可给冷蓝 |
threshold 的设置要看你场景里 HDR 光源的强度。有些美术为了让灯更亮,把自发光材质强度设到了 10 以上,threshold 还在 1.0 以下,结果就是全屏白茫茫。反过来,场景里所有材质最高亮度只有 0.8,threshold 却设到 1.2,那 Bloom 就像没装一样——它根本没有内容可以提取。我的习惯是先在场景里看一眼 Camera 的 HDR buffer 峰值亮度,再决定阈值。
iteration 也不是越大越好。层数越多,光晕最外圈越大,但中间层和高光核心层的比例也会变化。你把 iteration 从 4 调到 8,往往不是“更亮”,而是“光晕范围更大”,调整幅度一定要配合 intensity 一起看。
5.2 坑一:阈值设低了,整个画面像起了雾
这是新手最常见的翻车现场。明明只是给灯加了 Bloom,结果整个画面灰蒙蒙一层,细节全被吃掉,像镜头没擦干净。
原因不是 Bloom 本身的问题,而是阈值太低,画面里大量中等亮度的像素也被提取进了高光图。比如天空亮度 0.6,阈值 0.5,整个天空全被当作发光体,模糊之后就是一层雾。
解决思路有三个:一是把 threshold 往上抬,压住中间调;二是确认场景里是否有真正的超亮光源,Bloom 是给“超出显示范围”的像素服务的,不是给所有亮部服务的;三是检查 Tonemapping,如果管线里已经做了很激进的曝光调整,HDR 峰值被压缩得非常低,阈值也要跟着降低,否则什么都提不出来。
这里有个实用技巧:把 Shader 的 Pass 0 输出临时改成纯灰,看看高光图里到底是什么样的。如果大面积都是灰色,说明阈值太低;如果只有星星点点,说明阈值合理。这个 Debug 习惯能帮你省下很多猜来猜去的时间。
5.3 坑二:透明材质不该发光却发光
Bloom 是全屏后处理,它不知道物体是什么材质,只认颜色亮度。所以一个水晶玻璃球,明明本身不发光,只是反射了太阳的强光,反射区域亮度超过阈值,Bloom 就会给它套上光晕,看起来很违和。
解决这个问题的标准做法是控制进入 Bloom 的输入源。在 URP 里可以通过 Stencil 标记或者特定 Renderer Feature 只对指定物体做“预标记”。更粗暴的做法是微调材质,让反射高光不要高到超过阈值,但这会影响 PBR 正确性。
如果你只是做个人项目,最简单的思路是接受这种“物理上不严谨但视觉上还行”的结果。很多游戏的光晕其实来自镜面反射,人眼不会特别察觉。但如果你在做写实风格项目,并且对美术质量有要求,建议给角色和关键道具单独做一层 light buffer,只有这层参与 Bloom,其他物体一律不进高光提取。
第三个方法是调整软阈值范围。把 softRange 从 0.1 放大到 0.3,透明物体的误发光会被柔化,高光边缘不会那么刺眼,但不会有质的改变。真正干净的方案还是分层。
5.4 坑三:自定义后处理与抗锯齿的顺序错乱
另一个隐蔽问题是 Bloom 和抗锯齿的先后关系。在 URP 后处理栈里,FXAA 或 SMAA 通常放在最后,而 Bloom 在 Tonemapping 之前。如果你把自定义 Bloom 放在AfterRenderingPostProcessing,就会在 FXAA 之后做泛光,模糊会把抗锯齿的成果重新弄脏,画面边缘出现抖动和像素颗粒。
这个问题的判断标准是:泛光作用下物体边缘是否出现了彩色的“碎边”或者锯齿感被重新“激活”。如果出现了,把renderPassEvent改回BeforeRenderingPostProcessing,问题基本消失。
还有一点要注意 MSAA。在 URP 某些版本里,如果开了 MSAA,自定义后处理放在 Opaque 和 Transparent 之间的 Pass 可能需要cameraTargetDescriptor.msaaSamples = 1,否则边缘会有紫边。这就是我在第 3.4 节强调的原因。
5.5 还可以往哪里延伸
跑通这个版本后,你还可以继续往两个方向延伸。
一个方向是给光晕加动态响应。用音频强度驱动 intensity 或 threshold,音乐响起时光晕变强,节奏落下时收敛,适合做律动类场景。另一个方向是方向性泛光,把模糊从圆对称改成径向或按风向定向,爆炸、传送门、风场都能有更性格的视觉效果。
我个人觉得 Bloom 最难的还不是实现,而是“克制”。很多项目第一次加上 Bloom 会觉得惊艳,然后越调越重,最后画面腻得不行。我现在的习惯是:先把 threshold 调高到“几乎不发光”的程度,再一点点往下降,直到刚好能看到霓虹灯边缘渗出一点光色,然后停手。这个位置,往往就是最耐看的位置。