Bloom模糊光照(翻译二十四)
- 渲染到临时纹理。
- 通过降采样和升采样进行模糊。
- 执行渐进式采样。
- 应用盒式滤波器(Box Filter)。
- 为图像添加 Bloom。
1 搭建场景
显示器能产生的光量是有限的。它可以从黑色变为全亮,这在 Shader 中对应于 RGB 值 0 和 1。这就是所谓的光的低动态范围(Low Dynamic Range,LDR)。全白像素的亮度因显示器而异,并且可以由用户调整,但它永远不会令人致盲。
现实生活并不局限于 LDR 光。没有最大亮度。光子同时到达得越多,物体看起来就越亮,直到看起来很痛苦甚至致盲。直视太阳会损害你的眼睛。
为了表示非常明亮的颜色,我们可以超越 LDR 进入高动态范围(High Dynamic Range,HDR)。这仅仅意味着我们不强制执行最大值 1。只要输入和输出格式可以存储大于 1 的值,Shader 就可以毫无问题地处理 HDR 颜色。但是,显示器无法超越其最大亮度,因此最终颜色仍会被限制在 LDR 内。
为了使 HDR 颜色可见,必须将它们映射到 LDR,这称为色调映射(Tonemapping)。这归结为非线性地使图像变暗,以便能够区分原始 HDR 颜色。这有点类似于我们的眼睛如何适应明亮的场景,尽管色调映射是恒定的。还有自动曝光(Auto-exposure)技术,它可以动态调整图像亮度。两者可以一起使用。但我们的眼睛并不总是能够充分适应。有些场景实在太亮了,这让我们很难看清。
我们如何在受限于 LDR 显示器的同时展示这种效果?
Bloom 是一种通过使像素颜色渗入相邻像素来弄乱图像的效果。这就像模糊图像,但是基于亮度。这样,我们可以通过模糊来传达过亮的颜色。这有点类似于光线如何在我们的眼睛内部扩散,这在高亮度的情况下会变得明显,但这主要是一种非现实的效果。
许多人不喜欢 Bloom,因为它弄乱了原本清晰的图像,并且让东西看起来不切实际地发光。这不是 Bloom 固有的错误,只是它经常被滥用。如果你追求真实感,请适度使用 Bloom,在有意义的时候使用。Bloom 也可以用于非现实效果的艺术创作。例如梦境序列,表示头晕,或用于创意场景转换。
1.1 Bloom 场景
我们将通过摄像机后期特效组件创建自己的 Bloom 效果,类似于我们在雾教程中创建延迟雾效果的方式。虽然你可以从新项目开始或继续该教程,但我使用了之前的高级渲染教程:表面置换作为该项目的基础。
创建一个具有默认照明的新场景。在里面放一堆明亮的物体,背景是黑色的。我使用了一个黑色平面和一堆大小不一的纯白色、黄色、绿色和红色立方体和球体。确保摄像机启用了 HDR。还将项目设置为使用线性色彩空间,以便我们可以最好地看到效果。
通常,你会将色调映射应用于具有线性和 HDR 渲染的场景。你可以先做自动曝光,然后应用 Bloom,最后执行最终的色调映射。但在本教程中,我们将专注于 Bloom,并且不会应用任何其他效果。这意味着所有最终超过 LDR 的颜色都将在最终图像中被截断。
1.2 Bloom 效果
创建一个新的 BloomEffect 组件。就像 DeferredFogEffect 一样,让它在编辑模式下执行并给它一个 OnRenderImage 方法。最初它不做任何额外的事情,只是从源纹理 Blit 到目标纹理。
1
2
3
4
5
6
7
8
9
10
using UnityEngine;
using System;
[ExecuteInEditMode]
public class BloomEffect : MonoBehaviour {
void OnRenderImage (RenderTexture source, RenderTexture destination) {
Graphics.Blit(source, destination);
}
}
让我们也将此效果应用于场景视图,以便更容易从不同的角度查看效果。这是通过向类添加 ImageEffectAllowedInSceneView 属性来完成的。
1
2
3
4
[ExecuteInEditMode, ImageEffectAllowedInSceneView]
public class BloomEffect : MonoBehaviour {
...
}
将此组件作为唯一的效果添加到摄像机对象。这就完成了我们的测试场景。
2 模糊
Bloom 效果是通过获取原始图像,以某种方式对其进行模糊处理,然后将结果与原始图像相结合来创建的。因此,要创建 Bloom,我们必须首先能够模糊图像。
2.1 渲染到另一个纹理
应用效果是通过从一个渲染纹理渲染到另一个渲染纹理来完成的。如果我们可以在一次 Pass 中完成所有工作,那么我们可以简单地使用适当的 Shader 从源 Blit 到目标。但是模糊需要做很多工作,所以让我们引入一个中间步骤。我们首先从源 Blit 到临时纹理,然后从该纹理 Blit 到最终目标。
获取临时渲染纹理最好通过调用 RenderTexture.GetTemporary 来完成。此方法负责为我们管理临时纹理,根据 Unity 的判断创建、缓存和销毁它们。至少,我们要指定纹理的尺寸。我们将从与源纹理相同的大小开始。
1
2
3
4
5
6
7
void OnRenderImage (RenderTexture source, RenderTexture destination) {
RenderTexture r = RenderTexture.GetTemporary(
source.width, source.height
);
Graphics.Blit(source, destination);
}
由于我们要模糊图像,我们不会对深度缓冲区做任何事情。为了表明这一点,使用 0 作为第三个参数。
1
2
3
RenderTexture r = RenderTexture.GetTemporary(
source.width, source.height, 0
);
因为我们使用的是 HDR,所以我们必须使用适当的纹理格式。由于摄像机应该启用 HDR,源纹理的格式将是正确的,所以我们可以使用它。它很可能是 ARGBHalf,但也可能使用其他格式。
1
2
3
RenderTexture r = RenderTexture.GetTemporary(
source.width, source.height, 0, source.format
);
现在不直接从源 Blit 到目标,而是先从源 Blit 到临时纹理,然后再从那里 Blit 到目标。
1
2
3
// Graphics.Blit(source, destination);
Graphics.Blit(source, r);
Graphics.Blit(r, destination);
之后,我们不再需要临时纹理。为了使其可供重用,请通过调用 RenderTexture.ReleaseTemporary 释放它。
1
2
3
Graphics.Blit(source, r);
Graphics.Blit(r, destination);
RenderTexture.ReleaseTemporary(r);
虽然结果看起来还是一样,但我们现在通过临时纹理移动它。
2.2 降采样
模糊图像是通过平均像素来完成的。对于每个像素,我们必须决定将一堆附近的像素组合起来。包括哪些像素定义了用于效果的滤波核(Filter Kernel)。少量的模糊可以通过仅平均几个像素来完成,这意味着一个小核。大量的模糊需要一个大核,组合许多像素。
核中的像素越多,我们必须采样输入纹理的次数就越多。由于这是逐像素进行的,大核可能需要大量的采样工作。所以让我们尽可能保持简单。
平均像素的最简单、最快的方法是利用 GPU 内置的双线性过滤(Bilinear Filtering)。如果我们将临时纹理的分辨率减半,那么每四个源像素我们就得到一个像素。低分辨率像素将在原始四个像素之间精确采样,因此我们最终得到它们的平均值。我们甚至不需要为此使用自定义 Shader。
1
2
3
4
5
int width = source.width / 2;
int height = source.height / 2;
RenderTextureFormat format = source.format;
RenderTexture r =
RenderTexture.GetTemporary(width, height, 0, format);
使用半尺寸的中间纹理意味着我们将源纹理降采样(Downsampling)到一半分辨率。在那一步之后,我们从临时纹理转到目标纹理,从而再次升采样(Upsampling)到原始分辨率。
这是一个两步模糊过程,每个像素与其周围的 4×4 像素块混合,有四种可能的配置。
结果是一个比原始图像更块状且稍微模糊的图像。
我们可以通过进一步减小中间步骤的大小来增加效果。
2.3 渐进式降采样
不幸的是,直接降采样到低分辨率会导致糟糕的结果。我们最终主要丢弃了像素,只保留了孤立的四个像素组的平均值。
更好的方法是多次降采样,每一步将分辨率减半,直到达到所需的级别。这样所有像素最终都会对最终结果做出贡献。
为了控制我们这样做的次数,添加一个公共 iterations 字段。使其成为一个范围为 1–16 的滑动条。这将允许我们将 65536×65536 的纹理一直降采样到单个像素,这应该足够了。
1
2
[Range(1, 16)]
public int iterations = 1;
为了使其工作,首先将 r 重命名为 currentDestination。在第一次 Blit 之后,添加一个显式的 currentSource 变量并将 currentDestination 分配给它,然后将其用于最终的 Blit 并释放它。
1
2
3
4
5
6
7
RenderTexture currentDestination =
RenderTexture.GetTemporary(width, height, 0, format);
Graphics.Blit(source, currentDestination);
RenderTexture currentSource = currentDestination;
Graphics.Blit(currentSource, destination);
RenderTexture.ReleaseTemporary(currentSource);
现在我们可以在当前源的声明和最终 Blit 之间放置一个循环。因为它在第一次降采样之后,其迭代器应该从 1 开始。每一步,首先再次将纹理大小减半。然后抓取一个新的临时纹理并将当前源 Blit 到它。然后释放当前源并使当前目标成为新源。
1
2
3
4
5
6
7
8
9
10
11
12
13
RenderTexture currentSource = currentDestination;
for (int i = 1; i < iterations; i++) {
width /= 2;
height /= 2;
currentDestination =
RenderTexture.GetTemporary(width, height, 0, format);
Graphics.Blit(currentSource, currentDestination);
RenderTexture.ReleaseTemporary(currentSource);
currentSource = currentDestination;
}
Graphics.Blit(currentSource, destination);
这工作正常,除非我们最终迭代次数过多,将大小减小到零。为了防止这种情况,在发生之前跳出循环。典型显示器的高度通常小于其宽度,所以你可以仅根据高度来判断。因为单像素线并没有真正增加多少,我在纹理高度降至 2 以下时就中止。
1
2
3
4
5
width /= 2;
height /= 2;
if (height < 2) {
break;
}
那纵向模式的手机和其他例外情况呢? 如果你想支持所有宽高比,只需同时检查宽度和高度。
2.4 渐进式升采样
虽然渐进式降采样是一种改进,但结果仍然太快变得太块状。让我们看看如果我们也渐进式升采样是否有帮助。
双向迭代意味着我们最终会将每个尺寸渲染两次,除了最小的那个。与其每次渲染都释放然后声明相同的纹理两次,不如将它们保存在数组中。我们可以简单地为此使用一个固定大小为 16 的数组字段,这应该绰绰有余。
1
RenderTexture[] textures = new RenderTexture[16];
每次我们抓取临时纹理时,也将其添加到数组中。
1
2
3
4
5
6
7
8
9
10
11
RenderTexture currentDestination = textures[0] =
RenderTexture.GetTemporary(width, height, 0, format);
...
for (int i = 1; i < iterations; i++) {
...
currentDestination = textures[i] =
RenderTexture.GetTemporary(width, height, 0, format);
...
}
然后在初始循环之后添加第二个循环。这个循环从最低级别的一步开始。我们可以将迭代器从第一个循环中提升出来,从中减去 2,并将其用作另一个循环的起点。第二个循环向后进行,将迭代器一直减小到 0。这是我们应该释放旧源纹理的地方,而不是在第一个循环中。此外,我们也在这里清理数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int i = 1;
for (; i < iterations; i++) {
...
Graphics.Blit(currentSource, currentDestination);
// RenderTexture.ReleaseTemporary(currentSource);
currentSource = currentDestination;
}
for (i -= 2; i >= 0; i--) {
currentDestination = textures[i];
textures[i] = null;
Graphics.Blit(currentSource, currentDestination);
RenderTexture.ReleaseTemporary(currentSource);
currentSource = currentDestination;
}
结果好多了,但还不够好。
2.5 自定义着色
为了改进我们的模糊,我们必须切换到比简单双线性过滤更高级的滤波核。这需要我们使用自定义 Shader,所以创建一个新的 Bloom Shader。就像 DeferredFog Shader 一样,从一个简单的 Shader 开始,它有一个 _MainTex 属性,没有剔除,并且不使用深度缓冲区。给它一个带有顶点和片元程序的 Pass。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Shader "Custom/Bloom" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
}
SubShader {
Cull Off
ZTest Always
ZWrite Off
Pass {
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
ENDCG
}
}
}
顶点程序甚至比雾效果的还要简单。它只需要将顶点位置转换到裁剪空间并通过全屏 Quad 的纹理坐标。因为我们最终会有多个 Pass,除了片元程序之外的所有东西都可以共享并在 CGINCLUDE 块中定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Properties {
_MainTex ("Texture", 2D) = "white" {}
}
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
struct VertexData {
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct Interpolators {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
Interpolators VertexProgram (VertexData v) {
Interpolators i;
i.pos = UnityObjectToClipPos(v.vertex);
i.uv = v.uv;
return i;
}
ENDCG
SubShader {
...
}
我们将在 Pass 本身中定义 FragmentProgram 函数。最初,只需采样源纹理并将其用作结果,使其变为红色以验证我们正在使用自定义 Shader。通常 HDR 颜色以半精度格式存储,所以让我们明确使用 half 而不是 float,即使这对于非移动平台没有区别。
1
2
3
4
5
6
7
8
9
10
Pass {
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
return tex2D(_MainTex, i.uv) * half4(1, 0, 0, 0);
}
ENDCG
}
向我们的效果添加一个公共字段来保存对此 Shader 的引用,并在 Inspector 中挂接它。
1
public Shader bloomShader;
添加一个字段来保存将使用此 Shader 的材质,这不需要序列化。在渲染之前,检查我们是否有此材质,如果没有则创建它。我们不需要在层次结构中看到它,也不需要保存它,所以相应地设置它的 hideFlags。
1
2
3
4
5
6
7
8
9
10
11
[NonSerialized]
Material bloom;
void OnRenderImage (RenderTexture source, RenderTexture destination) {
if (bloom == null) {
bloom = new Material(bloomShader);
bloom.hideFlags = HideFlags.HideAndDontSave;
}
...
}
每次我们 Blit 时,都应该使用此材质而不是默认材质。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void OnRenderImage (RenderTexture source, RenderTexture destination) {
...
Graphics.Blit(source, currentDestination, bloom);
...
Graphics.Blit(currentSource, currentDestination, bloom);
...
Graphics.Blit(currentSource, currentDestination, bloom);
...
Graphics.Blit(currentSource, destination, bloom);
...
}
2.6 盒式采样(Box Sampling)
我们将调整 Shader,使其使用不同于双线性过滤的采样方法。因为采样取决于像素大小,所以在 CGINCLUDE 块中添加神奇的 float4 _MainTex_TexelSize 变量。请记住,这对应于源纹理的纹素大小,而不是目标纹理。
1
2
sampler2D _MainTex;
float4 _MainTex_TexelSize;
由于我们总是采样主纹理并且只关心 RGB 通道,让我们创建一个方便的最小 Sample 函数。
1
2
3
half3 Sample (float2 uv) {
return tex2D(_MainTex, uv).rgb;
}
我们将使用简单的盒式滤波器核(Box Filter Kernel),而不是仅依赖双线性滤波器。它取四个样本而不是一个,对角定位,以便我们获得四个相邻 2×2 像素块的平均值。对这些样本求和并除以四,这样我们最终得到一个 4×4 像素块的平均值,使我们的核大小加倍。
1
2
3
4
5
6
7
half3 SampleBox (float2 uv) {
float4 o = _MainTex_TexelSize.xyxy * float2(-1, 1).xxyy;
half3 s =
Sample(uv + o.xy) + Sample(uv + o.zy) +
Sample(uv + o.xw) + Sample(uv + o.zw);
return s * 0.25f;
}
在我们的片元程序中使用此采样函数。
1
2
3
4
half4 FragmentProgram (Interpolators i) : SV_Target {
// return tex2D(_MainTex, i.uv) * half4(1, 0, 0, 0);
return half4(SampleBox(i.uv), 1);
}
2.7 不同的 Pass
结果更加平滑且质量更高,但也更加模糊。这主要是由于使用新的 4×4 盒式滤波器进行升采样。由于我们使用源纹素大小来定位采样点,我们最终覆盖了一个很大的区域,具有未聚焦的规则权重分布。
我们可以通过调整用于选择采样点的 UV 增量(Delta)来调整盒式滤波器。为了使这成为可能,将增量变成参数,而不是总是使用 1。
1
2
3
4
5
6
7
half3 SampleBox (float2 uv, float delta) {
float4 o = _MainTex_TexelSize.xyxy * float2(-delta, delta).xxyy;
half3 s =
Sample(uv + o.xy) + Sample(uv + o.zy) +
Sample(uv + o.xw) + Sample(uv + o.zw);
return s * 0.25f;
}
复制我们的 Shader Pass,这样我们最终得到两个。第一个—— Pass 0 ——将用于降采样,所以它应该使用原始增量 1。第二个 Pass 将用于升采样,我们将使用增量 0.5。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Pass { // 0
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
return half4(SampleBox(i.uv, 1), 1);
}
ENDCG
}
Pass { // 1
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
return half4(SampleBox(i.uv, 0.5), 1);
}
ENDCG
}
使用 0.5 的 UV 增量,我们最终用重叠的样本覆盖了一个 3×3 的盒子。所以一些像素对结果的贡献超过一次,增加了它们的权重。中间像素参与所有样本,对角像素仅使用一次,而其他像素出现两次。结果是一个更聚焦的升采样核。
接下来,我们必须指示 Blit 时应使用哪个 Pass。为了方便起见,向 BloomEffect 添加常量,以便我们可以使用名称而不是索引。
1
2
const int BoxDownPass = 0;
const int BoxUpPass = 1;
前两个 Blit 是 Down Pass,另外两个是 Up Pass。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void OnRenderImage (RenderTexture source, RenderTexture destination) {
...
Graphics.Blit(source, currentDestination, bloom, BoxDownPass);
...
Graphics.Blit(currentSource, currentDestination, bloom, BoxDownPass);
...
Graphics.Blit(currentSource, currentDestination, bloom, BoxUpPass);
...
Graphics.Blit(currentSource, destination, bloom, BoxUpPass);
...
}
此时,我们有一个相当简单但不错的模糊过程。我们可以使用许多不同的核来代替这些简单的滤波器核,每一个都有自己的优势——比如更好的时间稳定性——和成本。但是,对于本教程,我们将坚持使用这些。
3 创建 Bloom
模糊原始图像是创建 Bloom 效果的第一步。第二步是将模糊图像与原始图像相结合,使其变亮。但是,我们不会只使用最终的模糊结果,因为那会产生相当均匀的涂抹。相反,较低量的模糊应该比较高量的模糊对结果的贡献更大。我们可以通过累积中间结果,在升采样时添加到旧数据来做到这一点。
3.1 叠加混合
使用叠加混合(Additive Blending)可以添加到我们在某个中间级别已有的内容,而不是替换纹理的内容。我们要做的就是将升采样 Pass 的混合模式设置为 One One。
1
2
3
4
5
6
7
8
9
10
11
12
Pass { // 1
Blend One One
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
return half4(SampleBox(i.uv, 0.5), 1);
}
ENDCG
}
这种简单的方法对于所有中间 Pass 都可以正常工作,但在渲染到最终目标时会出错,因为我们还没有渲染到它。我们可能会最终每帧累积光照,使图像过曝,或者其他情况,具体取决于 Unity 如何重用纹理。为了解决这个问题,我们必须为最后一次升采样创建一个单独的 Pass,在那里我们将原始源纹理与最后一个中间纹理结合起来。所以我们需要一个源的 Shader 变量。
1
sampler2D _MainTex, _SourceTex;
添加第三个 Pass,它是第二个 Pass 的副本,只是它使用默认的混合模式,并将盒式样本添加到源纹理的样本中。
1
2
3
4
5
6
7
8
9
10
11
12
Pass { // 2
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
half4 c = tex2D(_SourceTex, i.uv);
c.rgb += SampleBox(i.uv, 0.5);
return c;
}
ENDCG
}
为此 Pass 定义一个常量,它将 Bloom 应用于原始图像。
1
2
3
const int BoxDownPass = 0;
const int BoxUpPass = 1;
const int ApplyBloomPass = 2;
最后一个 Blit 必须使用此 Pass,并使用正确的源纹理。
1
2
3
4
// Graphics.Blit(currentSource, destination, bloom, BoxUpPass);
bloom.SetTexture("_SourceTex", source);
Graphics.Blit(currentSource, destination, bloom, ApplyBloomPass);
RenderTexture.ReleaseTemporary(currentSource);
3.2 Bloom 阈值
现在我们仍在模糊整个图像。只是对于明亮的像素最明显。但 Bloom 的用途之一是仅将其应用于非常明亮的像素。为了实现这一点,我们必须引入亮度阈值(Threshold)。为此添加一个公共字段,滑动条范围从 0 到某个非常亮的值,如 10。让我们使用默认阈值 1,排除 LDR 像素。
1
2
[Range(0, 10)]
public float threshold = 1;
阈值决定了哪些像素对 Bloom 效果有贡献。如果它们不够亮,就不应该在降采样和升采样过程中包含它们。简单地将它们转换为黑色就可以做到这一点,这必须由 Shader 完成。所以在我们 Blit 之前设置材质的 _Threshold 变量。
1
2
3
4
5
if (bloom == null) {
...
}
bloom.SetFloat("_Threshold", threshold);
也将此变量添加到 Shader 的 CGINCLUDE 块中,再次使用 half 类型。
1
half _Threshold;
我们将使用阈值过滤掉我们不希望包含的像素。由于我们在模糊过程的开始执行此操作,因此称为预过滤步骤(Prefilter Step)。为此创建一个函数,它接受一种颜色并输出经过过滤的颜色。
1
2
3
half3 Prefilter (half3 c) {
return c;
}
我们将使用颜色的最大分量来确定其亮度 $b = c_r \vee c_g \vee c_b$,其中 $\vee$ 符号是我用来表示最大函数的运算符。
我们可以通过从亮度中减去阈值,然后除以亮度来确定颜色的贡献因子 $w = \frac{b-t}{b}$,其中 $t$ 是阈值。当 $t=0$ 时,结果始终为 1,这使颜色保持不变。随着 $t$ 增加,亮度曲线将向下弯曲,以便在 $b=t$ 处降至零。
由于曲线的形状,它被称为拐点(Knee)。由于我们不希望出现负因子,我们必须确保 $b-t$ 不会降至零以下,从而导致 $w = \frac{0 \vee (b-t)}{b}$。
为了避免 Shader 中除以零,确保除数至少有一个小值,如 0.00001。然后使用结果调制颜色。
1
2
3
4
5
6
half3 Prefilter (half3 c) {
half brightness = max(c.r, max(c.g, c.b));
half contribution = max(0, brightness - _Threshold);
contribution /= max(brightness, 0.00001);
return c * contribution;
}
过滤器仅应用于第一个 Pass。所以复制第一个 Pass,将其作为 Pass 0 放在顶部。将过滤器应用于盒式样本的结果。
1
2
3
4
5
6
7
8
9
10
Pass { // 0
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
return half4(Prefilter(SampleBox(i.uv, 1)), 1);
}
ENDCG
}
为这个新 Pass 添加一个常量,并将所有后续 Pass 的索引增加一。
1
2
3
4
const int BoxDownPrefilterPass = 0;
const int BoxDownPass = 1;
const int BoxUpPass = 2;
const int ApplyBloomPass = 3;
将新 Pass 用于第一次 Blit。
1
2
3
4
5
6
RenderTexture currentDestination = textures[0] =
RenderTexture.GetTemporary(width, height, 0, format);
Graphics.Blit(source, currentDestination, bloom, BoxDownPrefilterPass);
RenderTexture currentSource = currentDestination;
此时,将阈值设置为 1,你可能会看到没有或几乎没有 Bloom,假设使用的灯光和材质没有 HDR 值。为了让 Bloom 出现,你可以增加一些材质的光贡献。例如,我使黄色材质发光,这与反射光一起将黄色像素推入 HDR。
3.3 隔离 Bloom
为了更好地查看图像的哪些部分对 Bloom 有贡献,如果我们可以单独查看模糊效果,那就太方便了。所以让我们向我们的效果添加一个调试选项,通过公共布尔字段控制。
1
public bool debug;
我们将为调试目的创建一个单独的 Pass,所以在底部为其添加一个常量。
1
2
3
4
5
const int BoxDownPrefilterPass = 0;
const int BoxDownPass = 1;
const int BoxUpPass = 2;
const int ApplyBloomPass = 3;
const int DebugBloomPass = 4;
在调试模式下,直接将最后一个中间结果 Blit 到最终目标——使用调试 Pass ——而不是将其添加到源中。
1
2
3
4
5
6
7
8
if (debug) {
Graphics.Blit(currentSource, destination, bloom, DebugBloomPass);
}
else {
bloom.SetTexture("_SourceTex", currentSource);
Graphics.Blit(source, destination, bloom, ApplyBloomPass);
}
RenderTexture.ReleaseTemporary(currentSource);
新的调试 Pass 只是执行最后一次升采样,不与任何东西结合。
1
2
3
4
5
6
7
8
9
10
Pass { // 4
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
return half4(SampleBox(i.uv, 0.5), 1);
}
ENDCG
}
在调试模式下,我们可以清楚地看到黄色像素最终产生了 Bloom。除此之外,一些白色像素也被包括在内,但只有当它们最终反射大量定向光时才会如此。
为什么一些白色表面最终会变成 HDR? 默认的 Unity 场景非常明亮。除了定向光,还有环境光照和反射,它们都有助于最终的像素颜色。所有这些加在一起可能会导致亮度值大于 1。
3.4 软阈值(Soft Threshold)
我们用来调制颜色的拐点曲线以一定角度切过零,导致突然的截止点。这就是为什么它也被称为硬拐点(Hard Knee)。这意味着我们可能会在产生 Bloom 的区域和不产生 Bloom 的区域之间出现急剧过渡。这可以在上面截图中的大白色球体中看到。该球体有一个明确定义的部分被包括在内。这在某种程度上被模糊混淆了,但它仍然是一个严酷的过渡。
可以使这种过渡更平滑,从零混合到完全贡献。我们将通过滑动条控制它。在 0 处,我们得到当前的严酷过渡。在 1 处,我们得到一个软阈值,它从亮度 0 一直平滑地淡入 Bloom,直到它与硬拐点匹配。我们将使用 0.5 作为默认值。
1
2
[Range(0, 1)]
public float softThreshold = 0.5f;
这种淡入淡出也是由 Shader 完成的,所以将软阈值因子传递给材质。
1
2
bloom.SetFloat("_Threshold", threshold);
bloom.SetFloat("_SoftThreshold", softThreshold);
并向 Shader 添加一个变量。
1
half _Threshold, _SoftThreshold;
通过软化我们的硬拐点曲线,我们将其变成软拐点(Soft Knee)。我们要做的不是取 $b-t$ 和 0 的最大值,而是取 $b-t$ 和单独的软化曲线 $s$ 的最大值。所以我们得到 $w = \frac{s \vee (b-t)}{b}$。
软曲线定义为 $s = \frac{(b-t+k)^2}{4k}$,其中 $k = t \cdot ts$,$ts$ 是软阈值。
我们必须截断这条曲线,在它接触 0 的地方以及它遇到硬拐点的地方,这是通过使用 $s = \frac{(0 \vee (b-t+k) \wedge 2k)^2}{4k}$ 来完成的,其中 $\wedge$ 代表最小函数。调整预过滤函数以执行此计算,再次防止除以零。
1
2
3
4
5
6
7
8
9
10
half3 Prefilter (half3 c) {
half brightness = max(c.r, max(c.g, c.b));
half knee = _Threshold * _SoftThreshold;
half soft = brightness - _Threshold + knee;
soft = clamp(soft, 0, 2 * knee);
soft = soft * soft / (4 * knee + 0.00001);
half contribution = max(soft, brightness - _Threshold);
contribution /= max(brightness, 0.00001);
return c * contribution;
}
请注意,软拐点函数的某些部分可以隔离,以便它们仅依赖于配置值,这些值对于每个 Pass 都是常数。我们可以预先计算这些部分并将它们传递给向量中的 Shader,从而减少它必须做的工作量。我们可以将这些与阈值结合在一个单独的过滤器向量中。
1
2
3
4
5
6
7
8
9
10
// bloom.SetFloat("_Threshold", threshold);
// bloom.SetFloat("_SoftThreshold", softThreshold);
float knee = threshold * softThreshold;
Vector4 filter;
filter.x = threshold;
filter.y = filter.x - knee;
filter.z = 2f * knee;
filter.w = 0.25f / (knee + 0.00001f);
bloom.SetVector("_Filter", filter);
相应地调整 Shader。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// half _Threshold, _SoftTheshold;
half4 _Filter;
...
half3 Prefilter (half3 c) {
half brightness = max(c.r, max(c.g, c.b));
// half knee = _Threshold * _SoftThreshold;
half soft = brightness - _Filter.y;
soft = clamp(soft, 0, _Filter.z);
soft = soft * soft * _Filter.w;
half contribution = max(soft, brightness - _Filter.x);
contribution /= max(brightness, 0.00001);
return c * contribution;
}
3.5 Bloom 强度
最后,让我们能够调制 Bloom 效果的强度。这使得将其淡入淡出成为可能,也可以创造极其强烈的效果。为此添加一个滑动条,范围如 0–10。默认值应为 1。
1
2
[Range(0, 10)]
public float intensity = 1;
将此强度值作为材质属性传递给 Shader。由于通常使用 Gamma 空间中的因子设置 Bloom 的强度,因此将其从 Gamma 转换为线性空间。
1
2
bloom.SetVector("_Filter", filter);
bloom.SetFloat("_Intensity", Mathf.GammaToLinearSpace(intensity));
向 Shader 添加适当的变量。
1
half _Intensity;
将强度计入最后两个 Pass 的最终盒式样本中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Pass { // 3
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
half4 c = tex2D(_SourceTex, i.uv);
c.rgb += _Intensity * SampleBox(i.uv, 0.5);
return c;
}
ENDCG
}
Pass { // 4
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half4 FragmentProgram (Interpolators i) : SV_Target {
return half4(_Intensity * SampleBox(i.uv, 0.5), 1);
}
ENDCG
}
你现在有了一个基本的 Bloom 效果。它与 Unity 的 Post Processing Stack v2 的 Bloom 效果非常相似。可以通过添加色调、使采样增量可配置、使用不同的滤波器等来进一步扩展它。或者你可以继续景深Depth of Field































