Shader Graph实现自定义Bloom:从原理到性能优化的完整指南
1. 项目概述:为什么我们需要在URP/HDRP中自己动手做Bloom?
在Unity的通用渲染管线(URP)和高清渲染管线(HDRP)中,Post Processing(后处理)堆栈已经内置了非常强大且高质量的Bloom(泛光)效果。那么,为什么我们还需要费劲地用Shader Graph去重新实现一个呢?这个问题,在我接手一个风格化渲染的独立游戏项目时,变得尤为突出。项目要求Bloom效果不仅要能产生光晕,还需要能根据物体的特定材质属性(比如自发光贴图的某个通道)来动态控制泛光的强度和颜色,甚至需要将Bloom效果与一些自定义的屏幕扭曲效果进行混合。内置的Bloom虽然质量高,但作为一个“黑盒”,其可定制性在面对这些非标准需求时就显得捉襟见肘了。
这正是Shader Graph的价值所在。它允许我们以可视化的方式,深入到后处理效果的底层逻辑,从屏幕缓冲区的采样开始,一步步构建出我们想要的视觉效果。通过Shader Graph实现Bloom,你获得的不只是一个效果,而是一套完整的、可任意拆解和重组的光效流水线。你可以精确控制高亮区域的提取阈值、模糊的迭代次数与采样权重、颜色叠加的方式,甚至可以将Bloom Mask(泛光遮罩)与游戏逻辑(如角色技能、场景交互)绑定。这对于追求独特艺术风格、需要高度定制化后期效果,或者希望深入理解图像处理原理的开发者来说,是一个不可或缺的技能。
简单来说,用Shader Graph做Bloom,核心目标是:将效果的控制权从“能用”提升到“精控”。接下来,我将以一个可调节的、基于Shader Graph的Bloom效果为例,拆解其从原理到实现的完整过程,并分享其中那些官方文档里不会写的“坑”和技巧。
2. 核心原理拆解:Bloom效果是如何“算”出来的?
在动手连接节点之前,我们必须先搞清楚Bloom效果的算法骨架。一个标准的Bloom实现,通常遵循一个经典的“提取-模糊-合成”三步流水线。理解每一步的数学和视觉意图,是后续在Shader Graph中灵活调整的关键。
2.1 高亮区域提取:决定“什么在发光”
Bloom的第一步,是从渲染完成的场景图像中,分离出那些足够亮、应该产生光晕的部分。我们不会让整个画面都泛光,那样会失去对比度,显得画面“发灰”。
实现原理: 我们通常对屏幕图像进行一次“亮度提取”操作。最常见的方法是计算每个像素的RGB值转换到亮度(Luminance)的公式,例如:Luminance = 0.2126*R + 0.7152*G + 0.0722*B。这个权重基于人眼对绿光最敏感、对蓝光最不敏感的特性。
得到亮度值后,我们用一个阈值(Threshold)进行过滤。只有亮度高于此阈值的像素才会被保留,低于阈值的则置为黑色。公式可以简化为:Highlight = max(Luminance - Threshold, 0)。但直接这样做的边缘会很生硬,因此通常会配合一个柔化阈值(Soft Threshold)或使用平滑函数,让高亮区域的边缘有一个平滑的过渡。
在Shader Graph中的对应思路: 我们将从Scene Color节点获取屏幕图像,通过一系列乘法和加法节点模拟亮度计算,然后与一个Threshold属性进行比较和钳制(Clamp),最终输出一张只有高亮区域的单色或彩色纹理。
2.2 模糊处理:制造“光晕”的扩散感
提取出的高亮区域是一张锐利的、边缘清晰的图。Bloom的“光晕”感,正是通过对这张高亮图进行多次模糊处理来实现的。模糊的本质是让每个像素的颜色向其周围像素“扩散”。
高斯模糊(Gaussian Blur)是最常用的方法: 它不是一个简单的均匀模糊,而是根据距离,按照高斯分布(正态分布)的权重来混合周围像素的颜色。距离中心像素越远的像素,权重越低。这能产生非常平滑、自然的模糊效果。
由于性能考虑,我们通常使用“分离式高斯模糊”。它将一个二维的高斯模糊核,分解为两个一维的模糊操作:先进行一次水平方向的模糊,再在结果上进行一次垂直方向的模糊。这样,假设模糊核大小为N,计算复杂度从N²降低到了2N,性能提升巨大。
在Shader Graph中的实现挑战: Shader Graph本身没有提供一键式的高斯模糊节点。我们需要手动构建一个模糊链。这通常意味着:
- 创建一个可调节的
Blur Offset(模糊偏移量)和Iterations(迭代次数)属性。 - 使用
Sample Texture 2D节点,通过多次采样,每次采样时对UV坐标进行微小的偏移(UV + float2(offset, 0)用于水平,UV + float2(0, offset)用于垂直),并赋予不同的权重,来模拟模糊核。 - 为了达到较强的模糊效果,我们往往需要多次(如4次、8次)执行这个分离式模糊过程,将上一次模糊的结果作为下一次的输入。这需要在Shader Graph中妥善组织节点,或者借助
Sub Graph来封装可重用的模糊单元。
2.3 合成叠加:将光晕“加回”原画面
经过模糊处理后,我们得到了一张柔和、扩散的高亮纹理,这就是光晕层。最后一步,是将这个光晕层以某种方式与原始的场景颜色进行混合。
最常见的合成方式是“加法混合(Additive Blending)”: 即Final Color = Original Scene Color + Bloom Texture * Intensity。这里的Intensity是一个强度系数,用于控制泛光的整体亮度。加法混合能很好地模拟光的叠加效果,让发光区域显得更亮、更通透。
更高级的合成方式:
- 屏幕混合(Screen Blend):
Final Color = 1 - (1 - Original) * (1 - Bloom)。这种混合方式能防止颜色过曝(超过1.0),在某些HDR工作流中可能更合适,但视觉上通常比加法混合更柔和。 - 颜色控制:我们可以对Bloom纹理进行色调调整(Tint),例如让所有泛光带上一点暖黄色,模拟黄昏的太阳光晕。
在Shader Graph中的操作: 这一步相对直观。我们将原始的Scene Color与处理后的Bloom纹理,通过一个Add或自定义的混合节点进行结合,并通过一个Intensity属性控制混合强度。最终输出到Fragment节点的Base Color(如果这是整个后处理的最终输出)或一个临时的渲染纹理。
理解了这三步核心原理,我们就有了在Shader Graph中搭建效果的“设计图”。接下来,我们将进入具体的实现环节。
3. Shader Graph 实战:一步步构建可调节Bloom
这里我们将在URP环境下进行创建。HDRP的流程基本一致,只是部分节点名称和渲染管线接口可能略有不同。我们将创建一个Unlit Shader Graph,因为它最适合用于屏幕后处理效果。
3.1 创建Shader Graph与基础设置
- 创建资源:在Project窗口中右键 -> Create -> Shader -> Unlit Shader Graph。命名为“SG_CustomBloom”。
- 关键图形设置:在Graph Inspector中(如果没打开,在Shader Graph窗口左上角点击“Graph Inspector”按钮):
- 将“Graph Type”设置为
Sub Graph或Sprite Unlit?等等,对于全屏后处理,我们通常先创建一个Sub Graph作为Bloom的核心算法,再创建一个主Shader Graph来调用它。但为了教程连贯,我们先在一个主图中实现。更推荐的方法是先做Sub Graph。 - 实际上,更清晰的流程是:创建一个主Shader Graph(如“FullscreenBloom”)用于最终合成,并创建一个Sub Graph(如“SG_GaussianBlur”)封装模糊逻辑。这里我们先按主图实现。
- 将“Graph Type”设置为
- 添加必需属性:在Blackboard中创建以下
Property,这些都是我们后期需要调节的参数:_Threshold(Vector1): 高亮提取阈值,默认值0.8。_SoftThreshold(Vector1): 柔化阈值范围,默认值0.1。用于平滑过渡。_Intensity(Vector1): Bloom强度,默认值1.0。_TintColor(Color): 泛光色调,默认白色。_BlurOffset(Vector1): 单次模糊的采样偏移距离,默认值0.005。这个值需要根据屏幕分辨率调整。_Iterations(Vector1): 模糊迭代次数,默认值4。整数类型,但Shader Graph中通常用Float,我们后面会取整。
3.2 实现高亮提取(Highlight Extraction)
- 获取屏幕颜色:添加一个
Scene Color节点。这是URP提供的节点,可以直接采样当前渲染的屏幕图像。 - 计算亮度:
- 添加一个
Dot Product节点。 - 将
Scene Color的RGB输出连接到Dot Product的A端口。 - 创建一个
Vector3常量,值为(0.2126, 0.7152, 0.0722),连接到Dot Product的B端口。 Dot Product节点的输出就是该像素的近似亮度值(Luminance)。
- 添加一个
- 应用阈值与柔化:
- 添加一个
Subtract节点,将上一步的亮度值减去_Threshold属性。 - 添加一个
Saturate节点(其本质是Clamp(0, 1)),但我们需要一个平滑过渡。这里采用一个常见的技巧:
// 伪代码,用于理解节点连接 half brightness = dot(sceneColor.rgb, half3(0.2126, 0.7152, 0.0722)); half knee = _SoftThreshold * 0.5; // 柔化区间的一半 half soft = brightness - _Threshold + knee; soft = clamp(soft, 0, _SoftThreshold*2); // 钳制到柔化范围 soft = soft * soft / (4 * knee + 1e-5); // 二次曲线平滑 half highlight = max(brightness - _Threshold, soft); // 最终高亮值- 在Shader Graph中,我们需要用多个
Multiply、Add、Subtract、Divide和Power节点来构建上述公式。虽然看起来复杂,但连接完成后可以封装成一个Sub Graph,名为“SG_HighlightExtract”,方便复用。
- 添加一个
- 重建彩色高亮图:最终的高亮图不应该是黑白的。我们将计算出的
highlight值(一个0-1的标量)与原始的Scene Color相乘:Add一个Multiply节点,A端口接Scene Color,B端口接上一步计算出的highlight标量。输出就是彩色的高亮纹理。
实操心得:阈值和柔化阈值的调节需要配合场景的整体亮度。在明亮的室外场景,阈值可能需要调高(如1.2)以避免过多物体泛光;在昏暗的室内,阈值可以很低(如0.5),让微弱的灯光也能产生光晕。
_SoftThreshold通常设为_Threshold的10%-25%,能获得比较自然的过渡。
3.3 构建高斯模糊子图(Sub Graph)
这是最复杂的一步。我们将创建一个可重用的高斯模糊Sub Graph。
- 创建Sub Graph:在Project窗口右键 -> Create -> Shader -> Sub Graph。命名为“SG_GaussianBlur”。
- 定义接口:
- Inputs: 添加
Texture2D输入(命名为InputTex),SamplerState输入(命名为Sampler),Vector2输入(命名为UV),Float输入(命名为BlurOffset)。 - Outputs: 添加
Vector4输出(命名为OutputColor)。
- Inputs: 添加
- 实现分离式高斯模糊逻辑:
- 高斯模糊需要一组权重和偏移。一个经典的5-tap(采样5次)高斯核的权重近似为
[0.227, 0.194, 0.121, 0.054, 0.016],对应的偏移是[0, ±1, ±2] * offset。 - 在Sub Graph中,我们需要进行两次这样的操作。
- 水平模糊:
// 伪代码逻辑 Vector4 color = SampleTexture(InputTex, Sampler, UV) * 0.227; color += SampleTexture(InputTex, Sampler, UV + float2(BlurOffset, 0)) * 0.194; color += SampleTexture(InputTex, Sampler, UV - float2(BlurOffset, 0)) * 0.194; color += SampleTexture(InputTex, Sampler, UV + float2(BlurOffset*2, 0)) * 0.121; color += SampleTexture(InputTex, Sampler, UV - float2(BlurOffset*2, 0)) * 0.121; // ... 可以继续增加更多tap以获得更平滑模糊,但性能开销也增大 - 在Shader Graph中,你需要添加多个
Sample Texture 2D节点,将它们的UV输入分别设置为UV、UV + (BlurOffset, 0)等,然后将采样结果乘以对应的权重常量,最后用一个Add节点将所有结果相加。 - 垂直模糊:将水平模糊的结果(作为一个虚拟的纹理)作为输入,重复上述过程,但偏移方向改为
(0, BlurOffset)。
- 高斯模糊需要一组权重和偏移。一个经典的5-tap(采样5次)高斯核的权重近似为
- 在主图中调用模糊:
- 回到主Shader Graph,将高亮提取的输出颜色连接到我们自定义的
SG_GaussianBlur子图的InputTex。 - 将
Tiling And Offset节点的UV输出(或直接用UV节点)连接到子图的UV。 - 将
_BlurOffset属性连接到子图的BlurOffset。 - 为了实现多次迭代,我们需要将模糊子图串联起来。最简单但低效的方法是复制粘贴多个子图节点,将前一个的
OutputColor连接到后一个的InputTex。迭代次数由_Iterations控制,但Shader Graph不支持动态循环,我们通常固定一个较大的迭代次数(比如4次),然后用一个Branch节点根据_Iterations来选择使用第几次迭代的结果。更高效的方法是在C#脚本中动态生成材质属性并控制渲染次数,但这超出了纯Shader Graph的范围。
- 回到主Shader Graph,将高亮提取的输出颜色连接到我们自定义的
注意事项:模糊偏移量
_BlurOffset的值非常敏感。它通常与屏幕分辨率成反比。一个经验公式是:BlurOffset ≈ 1.0 / _ScreenParams.x(水平方向)或1.0 / _ScreenParams.y(垂直方向)。_ScreenParams是一个内置变量,包含了屏幕宽度和高度。你可以创建一个Vector2参数_BlurRadius,然后在节点中计算offset = _BlurRadius / _ScreenParams.xy,这样调节起来更直观。
3.4 最终合成与输出
- 色调调整:将多次模糊后的最终Bloom纹理,与
_TintColor属性相乘,实现颜色过滤。 - 强度控制:再将上一步的结果与
_Intensity属性相乘。 - 叠加到原画面:添加一个
Add节点。将原始的Scene Color连接到A端口,将经过色调和强度调整的Bloom纹理连接到B端口。 - 输出:将
Add节点的结果连接到Fragment节点的Base Color(如果你的Shader是用于全屏Blit)或Emission(如果作为某个物体的材质)。对于全屏后处理,我们通常创建一个使用此Shader的材质,然后通过脚本(如CommandBuffer或URP的RenderFeature)在相机渲染完成后,用这个材质将整个屏幕绘制一次。
至此,一个基础的可调节Bloom效果在Shader Graph中就搭建完成了。你可以通过调节_Threshold、_Intensity、_BlurOffset等参数,实时在材质面板上看到效果变化。
4. 性能优化与进阶技巧
用Shader Graph实现的Bloom,虽然灵活,但性能开销需要密切关注,尤其是模糊操作。
4.1 性能优化策略
降低分辨率进行模糊(Downsample):这是最有效的优化手段。我们不需要在全分辨率下进行昂贵的多次模糊。通常的做法是:
- 先将高亮提取的结果渲染到一张原图1/2或1/4大小的渲染纹理(Render Texture)中。
- 在这张低分辨率纹理上进行模糊操作。
- 最后将模糊结果上采样(Upsample)到全分辨率,再与原始场景颜色合成。
- 这样做,模糊操作的像素处理量减少了3/4(1/2下采样)或15/16(1/4下采样),性能提升巨大,而视觉损失在合理的模糊强度下几乎不可察觉。
- 在Shader Graph中实现下采样,可以通过调整
Sample Texture 2D节点的UV缩放,或者更规范地,在C#脚本端创建不同尺寸的Render Texture来实现。
控制迭代次数与采样数:
_Iterations(模糊次数)和每次模糊的采样点数(Tap数)是性能的主要消耗点。对于移动平台,可以从迭代2次、每遍5-tap模糊开始测试。PC平台可以适当增加到4次迭代、7-tap或9-tap模糊以获得更平滑的效果。使用双线性滤波(Bilinear Filtering):在创建用于Bloom的Render Texture时,确保其过滤模式设置为
Bilinear。这样在模糊和下/上采样时,硬件纹理过滤能提供一定的平滑效果,有时可以减少模糊迭代次数。
4.2 进阶效果拓展
散景(Bokeh)效果:可以通过在模糊过程中,使用一个自定义的形状(如六边形、圆形)的采样核,而不是标准的高斯核,来模拟相机镜头的散景光斑。这需要在模糊子图中修改采样偏移和权重分布。
基于深度的Bloom:有时我们不希望远景或某些特定物体(如UI)产生Bloom。可以结合
Camera Depth Texture。在提取高亮时,先采样深度图,通过比较深度值来决定是否提取该像素的高亮信息。这能做出景深般的Bloom效果,让光晕更聚焦于前景物体。Bloom Mask贴图:除了亮度,我们还可以用一张额外的遮罩贴图(Mask Texture)来控制Bloom的强度。例如,让场景中特定的发光符文或魔法阵区域产生更强烈的Bloom。只需将遮罩贴图的采样值乘到高亮提取的结果上即可。
与辉光(Glow)通道结合:在制作科幻或霓虹风格场景时,通常会在模型材质中直接输出一个“Glow”或“Emission”通道到渲染纹理(如使用URP的
Renderer Features自定义渲染目标)。我们的Bloom Shader可以直接采样这个Glow通道作为高亮源,而不是从场景颜色中提取,这样控制更加精确和高效。
5. 常见问题与调试实录
在实际操作中,你肯定会遇到各种问题。以下是我踩过的一些坑和解决方法:
问题1:Bloom效果闪烁或抖动。
- 原因:最常见的原因是
_BlurOffset值在每帧计算时,依赖于像_Time这样的变量,或者UV计算有精度问题。更隐蔽的原因是下采样的Render Texture尺寸不是偶数,导致采样位置在整数像素间来回跳动。 - 解决:
- 确保用于下采样的Render Texture的宽高是偶数。
- 检查模糊计算中的UV偏移,确保是稳定的。避免使用
frac、sin(_Time)等会产生周期性变化的函数直接控制核心偏移量。 - 可以考虑对Bloom纹理使用
Clamp或Repeat的包裹模式,但Clamp通常更安全。
问题2:Bloom边缘有黑色或颜色不正常的硬边。
- 原因:高亮提取的阈值设置得太高,或者柔化过渡(Soft Threshold)没做好,导致高亮区域与非高亮区域边界对比过于强烈。在模糊后,这个硬边被扩散开,形成了黑圈或光晕断层。
- 解决:
- 适当降低
_Threshold,提高_SoftThreshold,让提取区域边缘更平滑。 - 检查亮度计算公式是否正确,确保没有因为权重错误导致某些颜色通道被过度抑制。
- 尝试在模糊前,对提取的高亮图进行一次轻微的“侵蚀”(Erode)或“膨胀”(Dilate)操作(可通过额外的采样实现),来平滑其形状。
- 适当降低
问题3:性能开销巨大,游戏帧率下降明显。
- 原因:模糊迭代次数过多,或在下采样前就在全分辨率下进行了模糊。
- 解决:
- 务必使用下采样。这是性能优化的第一步,也是最重要的一步。
- 逐步减少
_Iterations。很多时候,2-3次迭代配合下采样,效果已经足够好。 - 在Unity编辑器的
Stats窗口或Profiler的Rendering部分,查看SetPass Calls和Batches,确认Bloom绘制命令的消耗。确保你的全屏绘制只发生一次。
问题4:如何将这个Shader Graph应用到相机上?
- 纯Shader Graph方案局限:单个Shader Graph文件无法直接作为后处理效果挂在相机上。它需要被一个材质球使用。
- 标准实现流程:
- 将上面完成的Shader Graph保存,它会生成一个
.shader文件。 - 创建一个新的材质球,使用这个
.shader。 - 编写一个C#脚本,继承自
MonoBehaviour。在OnRenderImage方法中(在Built-in RP中),或使用URP的RenderFeature(推荐)。 - 在URP中,创建一个
Scriptable Renderer Feature,在其AddRenderPasses方法中,配置一个ScriptableRenderPass。在这个Pass中,使用CommandBuffer.Blit或CoreUtils.DrawFullScreen方法,用你创建的Bloom材质来绘制全屏四边形。 - 将这个Renderer Feature添加到URP Asset的Renderer列表中。这样,每个使用该Render的相机都会应用你的Bloom效果。
- 将上面完成的Shader Graph保存,它会生成一个
这个过程涉及一定的脚本编写,但它将Shader Graph的计算结果与Unity的渲染管线完美衔接,是生产环境中的标准做法。虽然步骤稍多,但一旦配置完成,所有参数都可以通过材质球或脚本进行动态调节,非常灵活。
通过这一整套从原理到实现,再到优化和问题排查的流程,你应该已经掌握了在URP/HDRP中用Shader Graph打造自定义Bloom效果的核心能力。这不仅仅是实现了一个效果,更重要的是,你拥有了对“光”进行像素级编程控制的工具和思路。下次当你需要实现某种特殊的镜头光晕、能量护盾辉光或者风格化的发光特效时,这套方法可以随时被你拆解、重组和扩展。
