1. 项目概述:为什么我们需要泛光
在游戏和实时渲染的世界里,视觉冲击力是抓住玩家眼球的第一要素。你肯定见过那些令人印象深刻的场景:阳光透过树叶缝隙洒下的光晕、霓虹灯牌在雨夜中散发的朦胧光感、科幻武器充能时刺眼的核心光芒。这些效果,很大程度上都归功于一个经典的后处理特效——泛光。
泛光,或者说Bloom,模拟的是现实世界中高亮度光源溢出其物理边界,在相机或人眼中产生的一种“光晕”或“辉光”现象。在物理上,这是由于相机镜头或人眼晶状体的散射、衍射以及胶片或传感器的过曝造成的。在数字渲染中,我们通过算法来模拟这种物理现象,其核心目的只有一个:极大地增强画面的真实感、氛围感和视觉冲击力。没有泛光的画面,高光区域往往显得“干瘪”和“死板”;而恰到好处的泛光,则能让画面瞬间“活”起来,充满空气感和戏剧张力。
在Unity的通用渲染管线中,实现一个高质量、高性能的泛光效果,是每个图形程序员或技术美术的必修课。这不仅仅是调用一个API那么简单,它涉及到对渲染流程的深刻理解、对图像处理算法的灵活运用,以及对性能与效果之间平衡的精准拿捏。今天,我们就来彻底拆解URP中泛光的原理,并从零开始,一步步实现一个属于我们自己的、可高度定制的Bloom后处理效果。
2. 泛光效果的核心原理拆解
要自己动手实现泛光,首先得弄明白它的“工作原理图”。整个流程可以看作一个精密的图像处理流水线,其核心思想是“提取-模糊-合成”。
2.1 亮度阈值提取:找到“会发光”的像素
泛光不是对整个屏幕进行模糊,那样会导致画面整体变糊,失去细节。正确的做法是,只对那些足够亮、我们认为“应该发光”的像素进行处理。这一步就是亮度阈值提取。
原理:我们为渲染完成的场景图像(通常称为源纹理,Source Texture)的每一个像素计算其亮度值。亮度值可以通过一个简单的公式得到,最常见的是取RGB通道的加权平均:Luminance = 0.2126 * R + 0.7152 * G + 0.0722 * B。这个权重系数基于人眼对不同颜色光线的敏感度(绿色最敏感,蓝色最不敏感)。
然后,我们设定一个阈值。只有亮度高于这个阈值的像素,才会被保留下来,进入后续的模糊流程;低于阈值的像素,则被置为黑色(RGB=0)。这样,我们就得到了一张只包含高亮区域的“亮部掩码”图。
注意:在实际应用中,直接使用“硬阈值”会导致效果生硬,在阈值边界出现不自然的突变。因此,更高级的做法是使用“软阈值”或“膝关节曲线”。软阈值引入了两个参数:
threshold(阈值)和softThreshold(软过渡范围)。亮度在[threshold, threshold + softThreshold]区间内的像素,其强度会从0平滑地过渡到1,从而产生更柔和的提取边缘。这是实现高质量泛光的第一步关键技巧。
2.2 多级降采样与模糊:制造光晕的扩散感
得到了亮部掩码图后,下一步就是让它“扩散”开来,形成光晕。这是通过模糊操作实现的。但直接对原图分辨率进行高强度模糊,性能开销巨大,且效果难以控制。
这里就用到了一个核心技巧:多级降采样与高斯模糊。
- 降采样:我们将亮部掩码图不断进行降采样(通常是每次尺寸减半),生成一系列分辨率依次减半的纹理,称为Mip链。例如,从1080p降到540p,再到270p,以此类推。降采样本身就是一个低通滤波过程,能快速模糊掉高频细节。
- 高斯模糊:在每一级降采样后的纹理上,我们进行高斯模糊。高斯模糊的本质是用一个符合正态分布的卷积核(滤波器)对图像进行卷积,让每个像素的颜色向其周围像素“扩散”。模糊的半径(核的大小)决定了光晕扩散的范围。在降采样后的低分辨率上进行模糊,等效于在原分辨率上进行更大范围的模糊,但计算量呈平方级下降,这是性能优化的关键。
为什么是多级?单一级别的模糊产生的光晕层次单一。而多级降采样后分别模糊,再组合起来,能够模拟出光线在介质中散射时,由近及远、由强到弱的丰富层次感。低分辨率层级贡献大范围、柔和的底层光晕,高分辨率层级贡献小范围、锐利的核心光晕。
2.3 上采样与合成:将光晕叠加回原画面
经过多级模糊后,我们得到了一系列不同模糊程度、不同尺寸的亮部纹理。接下来需要将它们合并成一个最终的光晕纹理。
上采样与混合:从最模糊的、分辨率最低的那一级开始,将其上采样到上一级的大小,然后与上一级的纹理进行混合(通常是加法混合)。重复这个过程,直到混合回原始分辨率。这种自底向上的混合方式,能有效地平滑各级之间的过渡,避免出现明显的接缝或色带。
最终合成:得到最终分辨率的光晕纹理后,最后一步就是将它叠加到原始的场景渲染图像上。合成公式通常是简单的加法:FinalColor = SourceColor + BloomColor * Intensity。其中Intensity是一个强度系数,用于控制泛光的整体亮度。更高级的合成可能会使用屏幕混合模式,或者根据源像素亮度进行调制,以避免在已经过亮区域产生“曝光过度”的感觉。
3. URP中实现自定义泛光渲染器特性
了解了原理,我们开始在URP框架下动手实现。URP的后处理需要通过编写ScriptableRendererFeature和ScriptableRenderPass来集成到渲染管线中。
3.1 创建Bloom渲染器特性
首先,我们创建一个BloomRenderFeature类,它继承自ScriptableRendererFeature。它的主要职责是在渲染管线中插入我们自定义的BloomRenderPass。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class BloomRenderFeature : ScriptableRendererFeature { // 定义一个可序列化的设置类,用于在Inspector中调整参数 [System.Serializable] public class BloomSettings { public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; public Material bloomMaterial; // 用于Bloom处理的Shader材质 [Range(0.0f, 2.0f)] public float threshold = 1.0f; // 亮度阈值 [Range(0.0f, 1.0f)] public float softThreshold = 0.5f; // 软阈值范围 [Range(0.0f, 10.0f)] public float intensity = 1.0f; // 泛光强度 [Range(1, 10)] public int iterations = 5; // 降采样/模糊迭代次数 [Range(0.0f, 10.0f)] public float scatter = 0.7f; // 散射系数,影响模糊范围 } public BloomSettings settings = new BloomSettings(); private BloomRenderPass bloomRenderPass; // 类似于MonoBehaviour的Awake,用于初始化 public override void Create() { if (bloomRenderPass == null) { bloomRenderPass = new BloomRenderPass(settings); } } // 每帧调用,在这里将RenderPass加入到渲染器中 public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.bloomMaterial == null) { Debug.LogWarning("Bloom Material is not assigned."); return; } // 配置RenderPass使用的源和目标纹理(相机颜色纹理) bloomRenderPass.ConfigureInput(ScriptableRenderPassInput.Color); bloomRenderPass.Setup(renderer.cameraColorTargetHandle); renderer.EnqueuePass(bloomRenderPass); } // 清理资源 protected override void Dispose(bool disposing) { bloomRenderPass?.Dispose(); bloomRenderPass = null; } }这个BloomRenderFeature可以在URP渲染器资产的Renderer Features列表中添加,并暴露参数供美术或策划调整。
3.2 实现核心渲染通道
核心逻辑都在BloomRenderPass中。它继承自ScriptableRenderPass,负责执行具体的渲染命令。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class BloomRenderPass : ScriptableRenderPass { private BloomRenderFeature.BloomSettings settings; private RTHandle sourceHandle; // 相机颜色纹理的句柄 private Material bloomMaterial; private int[] bloomPyramidIds; // 用于存储多级Bloom纹理的Shader属性ID private const int MaxPyramidSize = 12; // 最大迭代次数,对应最多12级Mip public BloomRenderPass(BloomRenderFeature.BloomSettings settings) { this.settings = settings; this.renderPassEvent = settings.renderPassEvent; bloomPyramidIds = new int[MaxPyramidSize * 2]; // *2 是因为需要ping-pong纹理 // 预计算Shader属性ID,避免在渲染循环中使用字符串查找,提升性能 for (int i = 0; i < MaxPyramidSize * 2; i++) { bloomPyramidIds[i] = Shader.PropertyToID("_BloomMip" + i); } } public void Setup(RTHandle source) { this.sourceHandle = source; this.bloomMaterial = settings.bloomMaterial; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (bloomMaterial == null) return; CommandBuffer cmd = CommandBufferPool.Get("Custom Bloom"); using (new ProfilingScope(cmd, new ProfilingSampler("Bloom Pass"))) { RenderTextureDescriptor descriptor = renderingData.cameraData.cameraTargetDescriptor; descriptor.depthBufferBits = 0; // Bloom不需要深度信息 descriptor.msaaSamples = 1; // 后处理阶段通常关闭MSAA // 1. 设置阈值参数 float knee = settings.threshold * settings.softThreshold; Vector4 filterParams = new Vector4(settings.threshold, settings.threshold - knee, knee * 2, 0.25f / (knee + 0.00001f)); cmd.SetGlobalVector("_BloomFilterParams", filterParams); cmd.SetGlobalFloat("_BloomIntensity", settings.intensity); cmd.SetGlobalFloat("_BloomScatter", settings.scatter); // 2. 降采样与预滤波(提取亮部并初步降采样) int width = descriptor.width / 2; int height = descriptor.height / 2; int fromId = bloomPyramidIds[0]; int toId = bloomPyramidIds[1]; // 第一级降采样,同时应用亮度阈值提取 descriptor.width = width; descriptor.height = height; cmd.GetTemporaryRT(fromId, descriptor, FilterMode.Bilinear); cmd.Blit(sourceHandle, fromId, bloomMaterial, 0); // 使用Shader的第0个Pass进行提取和降采样 // 3. 构建Bloom金字塔(多级降采样与模糊) int i; for (i = 1; i < settings.iterations; i++) { width = Mathf.Max(width / 2, 1); height = Mathf.Max(height / 2, 1); descriptor.width = width; descriptor.height = height; toId = bloomPyramidIds[i + 1]; cmd.GetTemporaryRT(toId, descriptor, FilterMode.Bilinear); // 进行降采样和模糊(使用Shader的第1个Pass) cmd.Blit(fromId, toId, bloomMaterial, 1); // 交换纹理ID,为下一轮做准备 fromId = toId; } // 4. 上采样与混合 for (i -= 2; i >= 0; i--) { int baseId = bloomPyramidIds[i]; int highId = fromId; // 当前已上采样/混合的较模糊纹理 fromId = baseId; // 准备混合的目标纹理(更清晰的一级) // 上采样并混合(使用Shader的第2个Pass) cmd.Blit(highId, baseId, bloomMaterial, 2); // 释放不再需要的高层级纹理 cmd.ReleaseTemporaryRT(highId); } // 5. 最终合成(将Bloom结果加回原图) cmd.Blit(sourceHandle, sourceHandle, bloomMaterial, 3); // 使用Shader的第3个Pass进行合成 // 释放最后使用的Bloom纹理 cmd.ReleaseTemporaryRT(fromId); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { // 清理逻辑 } }这个RenderPass清晰地勾勒出了我们之前讨论的流程:设置参数、提取降采样、构建模糊金字塔、上采样混合、最终合成。所有具体的图像处理算法,都封装在接下来要编写的Shader中。
4. 编写核心Bloom着色器
Shader是实现图像处理算法的载体。我们创建一个Unlit Shader,它包含多个Pass来对应渲染通道中的不同步骤。
Shader "Hidden/Custom/Bloom" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline"} LOD 100 ZTest Always ZWrite Off Cull Off Pass // Pass 0: 亮度提取与预降采样 { Name "Bloom Prefilter" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragPrefilter #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); float4 _MainTex_TexelSize; float4 _BloomFilterParams; // x: threshold, y: threshold - knee, z: knee*2, w: 0.25/knee struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); output.uv = input.uv; return output; } // 软阈值函数 float3 SoftThreshold(float3 color, float threshold, float3 curve) { // curve.xyz 通常相同,是 softThreshold 计算出的平滑系数 half brightness = Max3(color.r, color.g, color.b); half soft = brightness - threshold + curve.x; soft = clamp(soft, 0, curve.y); soft = soft * soft * curve.z; half multiplier = max(soft, brightness - threshold) / max(brightness, 0.00001); return color * multiplier; } half4 FragPrefilter(Varyings input) : SV_Target { float2 texelSize = _MainTex_TexelSize.xy; // 简单的2x2 tent filter进行降采样,同时采样周围像素 float4 d = texelSize.xyxy * float4(-1.0, -1.0, 1.0, 1.0); half3 s0 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv + d.xy).rgb; half3 s1 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv + d.zy).rgb; half3 s2 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv + d.xw).rgb; half3 s3 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv + d.zw).rgb; // 盒式滤波降采样 half3 color = (s0 + s1 + s2 + s3) * 0.25; // 应用软阈值,提取亮部 color = SoftThreshold(color, _BloomFilterParams.x, _BloomFilterParams.yzw); return half4(color, 1.0); } ENDHLSL } Pass // Pass 1: 降采样与模糊(高斯模糊近似) { Name "Bloom Downsample & Blur" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragDownsample #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); float4 _MainTex_TexelSize; float _BloomScatter; // 控制采样偏移,影响模糊半径 struct Attributes { ... }; // 同Pass0 struct Varyings { ... }; // 同Pass0 Varyings Vert(Attributes input) { ... } // 同Pass0 half4 FragDownsample(Varyings input) : SV_Target { float2 texelSize = _MainTex_TexelSize.xy * _BloomScatter; // 采用一个简单的5-tap高斯核进行模糊,性能与质量折中 // 权重近似为 [1, 4, 6, 4, 1] / 16 float2 offsets[5] = { 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) }; float weights[5] = {0.375, 0.125, 0.125, 0.125, 0.125}; // 归一化后的权重 half3 color = 0; for (int i = 0; i < 5; i++) { float2 uv = input.uv + offsets[i] * texelSize; color += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).rgb * weights[i]; } return half4(color, 1.0); } ENDHLSL } Pass // Pass 2: 上采样与混合 { Name "Bloom Upsample & Blend" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragUpsample #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_MainTex); // 当前较高分辨率级 SAMPLER(sampler_MainTex); TEXTURE2D(_BloomTex); // 上一级较低分辨率(更模糊)的Bloom结果 SAMPLER(sampler_BloomTex); float4 _MainTex_TexelSize; float _BloomScatter; struct Attributes { ... }; struct Varyings { ... }; Varyings Vert(Attributes input) { ... } half4 FragUpsample(Varyings input) : SV_Target { // 采样当前级(较清晰)的亮部 half3 highFreq = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv).rgb; // 采样上一级(较模糊)的Bloom,并应用散射系数影响混合权重 float2 texelSize = _MainTex_TexelSize.xy * _BloomScatter; half3 lowFreq = SAMPLE_TEXTURE2D(_BloomTex, sampler_BloomTex, input.uv).rgb; // 将模糊的上一级结果叠加到当前级,实现光晕层次累积 // 这里使用加法混合,也可以尝试其他混合方式如屏幕混合(Screen) half3 result = highFreq + lowFreq; return half4(result, 1.0); } ENDHLSL } Pass // Pass 3: 最终合成 { Name "Bloom Composite" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragComposite #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_MainTex); // 原始场景颜色 SAMPLER(sampler_MainTex); TEXTURE2D(_BloomTex); // 最终计算出的Bloom纹理 SAMPLER(sampler_BloomTex); float _BloomIntensity; struct Attributes { ... }; struct Varyings { ... }; Varyings Vert(Attributes input) { ... } half4 FragComposite(Varyings input) : SV_Target { half3 sceneColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv).rgb; half3 bloomColor = SAMPLE_TEXTURE2D(_BloomTex, sampler_BloomTex, input.uv).rgb; // 将Bloom结果按强度叠加到原场景 half3 finalColor = sceneColor + bloomColor * _BloomIntensity; return half4(finalColor, 1.0); } ENDHLSL } } }这个Shader包含了四个关键的Pass,分别对应了提取、下采样模糊、上采样混合和最终合成四个阶段。其中,模糊算法我们使用了一个简化的5-tap滤波器来模拟高斯模糊,在实际项目中,可以根据需求使用更精确的分离高斯模糊或Kawase模糊。
5. 性能优化与参数调校实战心得
实现功能只是第一步,让它在项目中跑得又快又好,才是真正的挑战。以下是我在多个项目实践中总结的关于Bloom性能与效果调校的核心经验。
5.1 性能优化关键点
- 迭代次数与分辨率:
Iterations(迭代次数)是性能影响最大的参数。每增加一次迭代,纹理像素数减少到1/4,但Draw Call和采样次数增加。对于移动平台或VR,3-4次迭代通常足够;PC平台可以开到5-6次以获得更柔和的光晕。务必根据目标平台调整。 - Render Texture格式:Bloom处理的中间纹理不需要高精度。在
RenderTextureDescriptor中,可以将graphicsFormat设置为GraphicsFormat.R16G16B16A16_SFloat甚至GraphicsFormat.B10G11R11_UFloatPack32(如果支持)。后者每个像素仅32位,比标准的RGBAHalf(64位)或RGBAFloat(128位)节省大量带宽和显存,对性能提升显著,且对Bloom的视觉质量影响很小。 - 利用双线性滤波:在降采样和上采样时,纹理的FilterMode设置为
Bilinear。GPU的双线性硬件滤波效率极高,能有效平滑像素,避免出现锯齿。我们的Shader中简单的采样已经利用了这一点。 - 临时纹理管理:在
CommandBuffer中,使用GetTemporaryRT和ReleaseTemporaryRT配对管理临时纹理。确保在Pass结束时释放所有不再需要的纹理,防止显存泄漏。我们的示例代码在循环中妥善处理了这一点。 - 基于亮度的提前终止:一个高级优化策略是,在提取亮部后,可以计算整张亮部掩码图的平均亮度。如果平均亮度低于一个极小值(例如0.01),则说明场景中几乎没有高光区域,可以跳过后续所有模糊和合成步骤,直接返回原图,节省大量计算。
5.2 效果调校艺术
调出一个好看的Bloom,更像是一门艺术,需要反复微调。
| 参数 | 作用 | 调校技巧与常见“坑” |
|---|---|---|
| 阈值 | 决定多亮的像素开始产生泛光。 | 设得太低,整个画面都会蒙上一层光雾,显得“脏”且对比度下降。设得太高,只有极少数光源(如太阳)有效,泛光存在感弱。技巧:通常从1.0开始,观察画面中最亮的光源(如灯泡、窗户),调整到只有你希望“发光”的物体被提取出来。 |
| 软阈值 | 控制阈值边缘的过渡硬度。 | 0为硬阈值,边界锐利,可能产生光晕断层。增大值会使提取边缘更柔和,光晕过渡更自然。技巧:通常设置为阈值的0.3-0.5倍。对于风格化渲染(如赛博朋克),可以尝试更硬的阈值来获得光晕轮廓感。 |
| 强度 | 控制最终叠加光晕的亮度。 | 最容易“用力过猛”的参数。过高的强度会让画面泛白,失去细节。技巧:遵循“少即是多”原则。先调到0,慢慢增加,直到光晕能被清晰感知但又不喧宾夺主。在HDR下,强度值可能需要大于1。 |
| 散射 | 控制每次模糊时采样的偏移距离,影响光晕的“扩散”范围。 | 值越大,单次模糊范围越大,光晕扩散感越强,但也越容易变糊。技巧:它与迭代次数共同作用。想要大而柔和的光晕,可以适当增加散射并减少迭代次数;想要层次丰富、核心清晰的光晕,可以减小散射并增加迭代次数。 |
| 迭代次数 | 决定Bloom金字塔有多少层,影响光晕的层次感和最大范围。 | 层数越多,光晕能扩散到的范围越远,层次越丰富,但性能开销也线性增长。技巧:观察画面中最大光源(如天空中的太阳)的光晕是否自然衰减到消失。如果光晕在边缘被“切断”,就需要增加迭代次数或散射。 |
实操心得:HDR与色调映射的配合:Bloom效果在HDR渲染管线中才能发挥最大威力。因为HDR允许颜色值超过1.0,亮部提取更准确。关键点:确保你的Bloom处理发生在色调映射之前。色调映射(如ACES, Reinhard)会将HDR值压缩到LDR显示范围。如果先做色调映射,亮部信息已经被压缩,再提取Bloom效果会大打折扣。在URP中,通过设置
RenderPassEvent.BeforeRenderingPostProcessing,可以确保Bloom在大多数内置色调映射之前执行。
6. 常见问题排查与进阶技巧
即使按照步骤实现了,在实际项目中还是会遇到各种奇怪的问题。这里记录一些典型的“坑”和解决方法。
6.1 画面闪烁或抖动
- 问题描述:Bloom光晕在相机移动时剧烈闪烁或抖动。
- 原因分析:这通常是时间稳定性问题。由于每帧的降采样、模糊计算是独立的,微小的数值差异在逐帧累积后,会导致低分辨率层级的像素颜色剧烈变化,反映到最终画面上就是闪烁。
- 解决方案:
- 启用临时纹理历史:这是最有效的方案。在
GetTemporaryRT时,使用RenderTextureMemoryless标志或手动管理上一帧的Bloom纹理,并在当前帧模糊时,将上一帧对应层级的纹理作为一个微弱的输入混合进来。这能极大提高时间稳定性,Unity内置的Post Processing v2包中的Bloom就采用了此技术。 - 抖动采样:在Shader的采样偏移中,引入一个基于帧索引的、非常微小的随机旋转或偏移,可以打破规则采样带来的结构化闪烁,将其转化为不易察觉的噪声。
- 降低精度:有时将中间纹理格式从Float改为Half,反而能减少因浮点数精度波动带来的闪烁。
- 启用临时纹理历史:这是最有效的方案。在
6.2 泛光颜色异常或过饱和
- 问题描述:白色光源产生的泛光带上了奇怪的色偏(如品红色),或者颜色过于鲜艳。
- 原因分析:
- 色差:现实世界中,不同波长的光在透镜中折射率不同,会导致光晕边缘出现彩虹色散,这被称为色差。有些Bloom实现会模拟这种效果。如果你的Shader无意中引入了类似计算,可能导致色偏。检查模糊采样时的RGB通道是否被分别处理。
- HDR颜色空间:在HDR线性空间下进行计算是正确的。但如果你的Shader或后期处理流程错误地假设是在Gamma空间,颜色混合会出错。
- 色调映射后处理:强烈的色调映射(特别是那些带有鲜艳色彩曲线的)会显著改变Bloom的颜色。需要调整Bloom强度或色调映射参数来匹配。
- 解决方案:
- 确保所有Shader计算都在线性空间(
UnityColorSpace.Linear)下进行。在URP中,纹理采样通常会自动处理,但自定义计算需留意。 - 简化初始实现,确保模糊时RGB通道使用相同的权重和偏移,避免引入色差。
- 在最终合成Pass中,可以尝试对Bloom颜色先进行一次简单的颜色校正或去饱和度,再叠加到原图。公式如:
bloomColor = lerp(bloomColor, Luminance(bloomColor), _DesaturationFactor);。
- 确保所有Shader计算都在线性空间(
6.3 性能热点定位
如果发现开启Bloom后帧率下降明显,需要定位瓶颈。
- 使用Unity Profiler:在GPU Profiler中,查看
RenderPass的执行时间。我们的BloomRenderPass应该作为一个独立的条目出现。观察其耗时。 - 检查迭代次数:如果
Iterations设置过高(如10),会创建大量临时纹理和Draw Call。尝试逐步降低,观察性能与效果的平衡点。 - 检查纹理分辨率:确认降采样的起点分辨率是否正确。它应该是相机目标分辨率的一半。如果错误地从全分辨率开始,第一级模糊的消耗会非常大。
- 简化模糊Shader:示例中的5-tap模糊是一个折中方案。如果仍感性能压力,可以尝试更激进的3-tap模糊,或者使用更快的Kawase Blur,它在固定迭代下比高斯模糊性能更好。
6.4 进阶:添加镜头脏迹与自定义核
要让Bloom更具电影感或风格化,可以引入更多元素:
- 镜头脏迹纹理:在最终合成前,用一张模拟镜头灰尘、指纹的灰度图对Bloom纹理进行调制(乘法)。这样光晕会在“脏迹”处更亮,模拟真实镜头效果。
- 自定义模糊核:替换掉标准的高斯模糊核。例如,使用一个十字形的核来模拟某些老式镜头的散景,或者使用各向异性的核来制作条纹状的光晕(如电影中常见的镜头耀斑拉丝效果)。这只需要修改Shader中
FragDownsamplePass的采样权重和偏移数组即可。
实现一个完整的、高质量的Bloom效果,就像打磨一件光学仪器。你需要理解光线的物理原理,掌握图像处理的数学工具,并具备工程师般的性能优化意识。从最基础的阈值提取、多级模糊到合成,每一步都有大量的细节和调校空间。本文提供的代码框架是一个坚实的起点,你可以在此基础上,根据项目的美术风格和性能要求,深入探索每一个环节,打造出独一无二的视觉氛围。记住,最好的泛光效果,是让玩家沉浸其中却感受不到它存在的效果。