在Unity中实现体积光
在Unity中实现体积光
吐槽
最近项目进度停滞下来了,所以我就开始帮忙同事做一些效果。其中有一个效果就是体积光。我本着没做过就去做的精神,拦下了这个活。我一番资料查找下来并跟着网上的文章做了一下,我发现这个效果的实现复杂的一批,然后效果还不能满足策划。幸好策划识趣不要这个效果了,不然这简直就是折磨。不过我既然去学了,那必要写一篇文章记录一下这个效果。 ## 前言 体积光(Volumetric Lighting),又可以叫做GodRay,是一种模拟光线透过空气中的尘埃、雾气、烟尘等粒子形成的光柱效果。本文参考着网上的文章(Unity后处理——体积光)来实现的效果并加了一些自己的理解,如果你觉得我写的有问题可以去看一下我参考的文章。 ## 环境 Windows 10 Unity 2022.3.8f1c1 Universal RP 14.0.8 Core RP Library 14.0.8 ## 搭建场景 我们先搭建场景,差不多像下图这个样子就好了,摄像机也放到差不多的位置。
添加参数控制
因为我们走的是后处理,而URP中有一套后处理的框架,我们直接按照这个框架的来。我们创建一个名为PostVolumeLightSetting
的脚本,其内容如下。
1 | using UnityEngine; |
一般而言,创建的默认URP空场景中会自带一个Volume
组件的物体,我们只需要将这个脚本添加到这个组件下即可。我当前版本自带一个Volume
组件的物体名称为Global Volume
。之后我们将通过PostVolumeLightSetting
脚本来控制后处理体积光的效果。而PostVolumeLightSetting
脚本中的参数,我们后面来说明。
实现后处理体积光
在我参考的文章中的体积光实现是用一种叫做RayMarching的技术来实现的。
RayMarching的基本原理是从摄像机向屏幕上的每一个像素发射一条光线,光线按照一定步长前进,不断检测当前光线距离物体表面的距离,并根据这个距离调整光线的步长,直到抵达物体表面。在这个过程中,光线会被细分成更小的光线片段进行步进迭代,每次步进时计算相应的强度信息。最终,迭代结束后,将所有步进结果进行叠加计算,得到像素的颜色。————来自文心一言的整理。
RayMarching的实现
文章中判断是否到达物体表面是通过深度图来进行判断的,然后根据阴影图来判断当前位置光的强度。因为要在阴影图中进行采样,所以我们要将坐标转换到世界坐标系下。又因为我们是用后处理的方式来做效果,所以实际上我们是要将屏幕坐标转换到相机坐标系下,然后再转换到世界坐标系下。文章中其实有明确告诉你如何进行转换,这里我不再赘述。但是文章中的代码有些小问题,他并没有考虑到其他图像渲染API的情况。如果你Unity图像API是OpenGL,那么他的代码就不能用了。我也是刚好在测试其他平台的情况才发现的。其实我们也无需用他的代码,因为URP中已经帮我们实现了转换的函数名为ComputeWorldSpacePosition
。具体的实现代码如下:
1 | float4 ComputeClipSpacePosition(float2 positionNDC, float deviceDepth) |
我们只需要调用ComputeWorldSpacePosition
函数即可,这个函数会将屏幕uv坐标转换到世界坐标系下。函数中positionNDC
即我们的屏幕坐标,deviceDepth
即深度图中当前像素的深度值。invViewProjMatrix
我们填入UNITY_MATRIX_I_VP
。实际上上述函数也就是比文章中的函数多判断了UNITY_UV_STARTS_AT_TOP
,且简化了矩阵乘法。在具体实现中,我自己造着函数写了一份:
1 | float4 RebuildWorldPos(float2 uv,float depth) |
有了这个函数后,我们就可以在得到像素对应的深度值后将屏幕uv坐标转换到世界坐标系下了。在URP中获取深度信息也比较简单,我们将#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
引入后,使用SampleSceneDepth
函数就可以得到深度值。在这里我们同样要考虑到不同平台下深度值的问题。这里我直接给出一份URP自带Shader中的例子,该例子在PackageCache\com.unity.render-pipelines.universal@14.0.8\Shaders\PostProcessing\CameraMotionBlur.shader
下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23half2 GetCameraVelocity(float4 uv)
{
#if UNITY_REVERSED_Z
half depth = SampleSceneDepth(uv.xy).x;
#else
half depth = lerp(UNITY_NEAR_CLIP_VALUE, 1, SampleSceneDepth(uv.xy).x);
#endif
float4 worldPos = float4(ComputeWorldSpacePosition(uv.xy, depth, UNITY_MATRIX_I_VP), 1.0);
float4 prevClipPos = mul(_PrevViewProjM, worldPos);
float4 curClipPos = mul(_ViewProjM, worldPos);
half2 prevPosCS = prevClipPos.xy / prevClipPos.w;
half2 curPosCS = curClipPos.xy / curClipPos.w;
// Backwards motion vectors
half2 velocity = (prevPosCS - curPosCS);
#if UNITY_UV_STARTS_AT_TOP
velocity.y = -velocity.y;
#endif
return ClampVelocity(velocity, _Clamp);
}GetCameraPositionWS
函数,该函数会返回相机的世界坐标。具体函数在PackageCache/com.unity.render-pipelines.core@14.0.8/ShaderLibrary/Common.hlsl中,其实现如下:
1 | float3 GetCameraPositionWS() |
这时候我们就可以得到世界空间下相机到对应点的射线,接下来我们就可以开始进行RayMarching了。我们以相机的位置(世界空间下)为起点,沿着射线方向进行步进,每次步进的距离为stepSize
,步长的最大距离为maxDistance
,每次累加的光照强度为lightIntensity
,步长的最大次数为maxStep
。(其中stepSize
和maxStep
可以通过PostVolumeLightSetting
脚本来控制。而lightIntensity
则由阴影图和我们自身设定的lightIntensity
来确定,maxDistance
是由相机到物体表面的距离和我们的设定来决定。)最终我们到达步进的最大次数或是到达最大距离后停下来后便得到最终的颜色。我们为了沿着射线方向进行步进,我们要将之前的得到的射线进行归一化。因为我们要进行阴影图采样,所以我们必须要添加以下的变体
1 | #pragma multi_compile _ _MAIN_LIGHT_SHADOWS //接受阴影 |
如果不加上面的变体,最终我们采样出来的阴影图会不正确。接下来我们创建一个Shader文件,命名为VolumetricLight
,并将以下代码粘贴进去。
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136Shader "PostEffect/VolumetricLight"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color ("Color",Color) = (1,1,1,0.5)
_MaxDistance ("Max Distance", Float) = 40
_StepSize ("Step Size", Float) = 0.1
_LightIntensity ("Light Intensity", Float) = 0.05
_MaxStep ("Max Step", Int) = 1000
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline"}
ZWrite Off
ZTest Always
Cull Off
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
CBUFFER_START(UnityPerMaterial)
sampler2D _MainTex;
sampler2D _VolumetricLightRT;
float4 _MainTex_ST;
float4 _MainTex_TexelSize;
float _MaxDistance;
float _StepSize;
float _LightIntensity;
int _MaxStep;
half4 _Color;
CBUFFER_END
float4 RebuildWorldPos(float2 uv,float depth)
{
float4 pos = float4(uv,0,1);
pos = pos * 2 - 1;
pos.z = depth;
#if UNITY_UV_STARTS_AT_TOP
pos.y = -pos.y;
#endif
pos = mul(UNITY_MATRIX_I_VP, pos);
pos /= pos.w;
return pos;
}
half GetShadow(float3 pos)
{
float4 coord = TransformWorldToShadowCoord(pos);
return MainLightRealtimeShadow(coord);
}
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex.xyz);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
half3 VloumetricLight (v2f i) : SV_Target
{
//half4 col = tex2D(_MainTex, i.uv);
half2 screenUv = i.uv;
// 获取深度
float depth = SampleSceneDepth(screenUv);
#if !UNITY_REVERSED_Z
depth = lerp(UNITY_NEAR_CLIP_VALUE, 1, depth);
#endif
// 得到对应的世界坐标
float4 worldPos = RebuildWorldPos(screenUv,depth);
// 得到相机坐标(世界空间系下)并将其作为起点
float3 rayPos = GetCameraPositionWS();
// 得到光线方向
float3 dir = worldPos.xyz - rayPos;
// Ray-Marching
float addValue = 0;
// 计算最大距离
float maxDistance = min(length(dir),_MaxDistance);
// 归一化光线方向
dir = normalize(dir);
float addLightInstensity = 0;
// 如果步进次数到达上限,则退出循环
for(int j = 0;j < _MaxStep; ++j)
{
addValue += _StepSize;
// 如果超过最大距离,则退出循环
if(addValue > maxDistance) break;
// 按照光线方向移动_StepSize距离
rayPos += _StepSize * dir;
float intensity = _LightIntensity * GetShadow(rayPos);
// 累加当前的光照强度
addLightInstensity += intensity;
}
// 获取主光源信息,让体积光受主光源颜色的影响
Light mainLight = GetMainLight();
half3 col = _Color.rgb *_Color.a * mainLight.color * addLightInstensity;
return col;
}
ENDHLSL
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment VloumetricLight
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS //接受阴影
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE //产生阴影
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#pragma multi_compile _ _SHADOWS_SOFT //软阴影
ENDHLSL
}
}
}
添加RenderFeature
在URP中,我们使用RenderFeature
来添加新的渲染效果的。一般而言,我们需要点击右键->Create
->Rendering
->URP Renderer Feature
,但是实际上它就是一个脚本。我们也可以直接创建一个脚本。不过现在我们按照第一个方法来,因为这个它会帮我们写一部分代码。我们创建一个URP Renderer Feature
并将其命名为VolumetricLight
。模板中会自动帮我们定义好一个名为CustomRenderPass
的私有类。这里我们将其修改为如下的形式:
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60class CustomRenderPass : ScriptableRenderPass
{
private int loopNum = 1;
// 临时的贴图1
private int tmpSourceRtId;
// 临时的贴图2
private int tmpDesRtId;
// 要用的材质
public Material material { get; set; }
// 循环次数,并确保其不小于1
public int LoopNum { get { return loopNum;} set { loopNum = Mathf.Max(value, 1); } }
public CustomRenderPass()
{
// 找到我们定下来的shader
var shader = Shader.Find("PostEffect/VolumetricLight");
// 如果找到了,就创建材质
if(shader != null)
material = CoreUtils.CreateEngineMaterial(shader);
// 申请临时RT的ID
tmpSourceRtId = Shader.PropertyToID("_VolumetricLightRT");
tmpDesRtId = Shader.PropertyToID("_VolumetricDesLightRT");
}
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
// 申请临时RT
cmd.GetTemporaryRT(tmpSourceRtId, renderingData.cameraData.cameraTargetDescriptor);
cmd.GetTemporaryRT(tmpDesRtId, renderingData.cameraData.cameraTargetDescriptor);
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
// 如果材质存在,就执行
if(material != null)
{
var cmd = CommandBufferPool.Get(nameof(VolumetricLight));
cmd.Clear();
var renderer = renderingData.cameraData.renderer;
if (renderer.cameraColorTargetHandle.rt != null)
{
// 直接将结果赋值给相机
cmd.Blit(renderer.cameraColorTargetHandle, renderer.cameraColorTargetHandle, material, 0);
}
// 执行我们设定的命令
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
}
public override void OnCameraCleanup(CommandBuffer cmd)
{
// 释放临时RT
cmd.ReleaseTemporaryRT(tmpSourceRtId);
cmd.ReleaseTemporaryRT(tmpDesRtId);
}
}VolumetricLight
中的添加以下代码:
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
31
32
33
34
35
36
37
38
39
40
41
42CustomRenderPass m_ScriptablePass;
// 设定渲染时机
[ ]
private RenderPassEvent renderPassEvent;
// 初始设定
public override void Create()
{
m_ScriptablePass = new CustomRenderPass();
// Configures where the render pass should be injected.
m_ScriptablePass.renderPassEvent = renderPassEvent;
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
PostVolumeLightSetting setting = VolumeManager.instance.stack.GetComponent<PostVolumeLightSetting>();
if (setting != null && setting.IsActive() && m_ScriptablePass.material != null)
{
// 设定光的颜色并用透明度来控制强度
m_ScriptablePass.material.SetColor("_Color", setting.color.value);
// 设定光的最大距离
m_ScriptablePass.material.SetFloat("_MaxDistance", setting.maxDistance.value);
// 设定步长
m_ScriptablePass.material.SetFloat("_StepSize", setting.stepSize.value);
// 设定光的强度
m_ScriptablePass.material.SetFloat("_LightIntensity", setting.lightIntensity.value);
// 设定最大的步数
m_ScriptablePass.material.SetInt("_MaxStep", setting.maxStep.value);
// 设定卷积核大小
m_ScriptablePass.material.SetFloat("_KernelSize", setting.kernelSize.value);
// 设定空间方差
m_ScriptablePass.material.SetFloat("_SpaceSigma", setting.spaceSigma.value);
// 值方差
m_ScriptablePass.material.SetFloat("_ValueSigma", setting.valueSigma.value);
// 设定循环次数
m_ScriptablePass.LoopNum = setting.loopNum.value;
// 加入渲染
renderer.EnqueuePass(m_ScriptablePass);
}
}

这里你可以明显看到一些奇怪的断层,如果这时候你直接进行和原效果叠加。你会发现这个效果很假,你甚至都不需要找一个特殊的视角。效果如下:

这样看起来就像是光断层了。这样效果的原因有两个。第一个是我设定的光的步长太长了,所以有些像素采样光少了。第二个原因是当我们使用软阴影的时候,他所生成的阴影图本身就有些奇怪的。你可以将上面的Shader返回改为return GetShadow(worldPos.xyz);
。你就可以得到下图的效果:

如果我们单纯去调小步长或是修改阴影图生成的参数,我个人操作下来感觉效果还是不太理想。而在参考的文章中,作者给出了一个解决方案,他直接对体积光生成的图像进行模糊处理。这样我们可以在步长较大的情况下仍然可以得到一个还算可以的效果。而且这样的操作也会将阴影图所带来的影响进一步降低。
优化
文章中其实介绍了两种模糊方法:高斯模糊和双边滤波。高斯模糊算是一种经典的模糊算法了,但是它会将我们光的范围进一步扩大。虽然是解决了上述的问题,但是效果就有些假了。这里我不放出图片了,大家可以去参考文章中看一下效果。我这里直接给出双边滤波的实现,这也是参考文章最后使用的模糊方法。关于双边滤波,我这里还是建议直接去看参考文章,再加上去网上找一下资料。因为我自己其实并没有理解双边滤波的原理。所以我这里直接给出完整的实现。
RendererFeature脚本: 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121using PostEffect;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class VolumetricLight : ScriptableRendererFeature
{
class CustomRenderPass : ScriptableRenderPass
{
private int loopNum = 1;
// 临时的贴图1
private int tmpSourceRtId;
// 临时的贴图2
private int tmpDesRtId;
// 要用的材质
public Material material { get; set; }
// 循环次数,并确保其不小于1
public int LoopNum { get { return loopNum;} set { loopNum = Mathf.Max(value, 1); } }
public CustomRenderPass()
{
// 找到我们定下来的shader
var shader = Shader.Find("PostEffect/VolumetricLight");
// 如果找到了,就创建材质
if(shader != null)
material = CoreUtils.CreateEngineMaterial(shader);
// 申请临时RT的ID
tmpSourceRtId = Shader.PropertyToID("_VolumetricLightRT");
tmpDesRtId = Shader.PropertyToID("_VolumetricDesLightRT");
}
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
// 申请临时RT
cmd.GetTemporaryRT(tmpSourceRtId, renderingData.cameraData.cameraTargetDescriptor);
cmd.GetTemporaryRT(tmpDesRtId, renderingData.cameraData.cameraTargetDescriptor);
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
// 如果材质存在,就执行
if(material != null)
{
var cmd = CommandBufferPool.Get(nameof(VolumetricLight));
cmd.Clear();
var renderer = renderingData.cameraData.renderer;
if (renderer.cameraColorTargetHandle.rt != null)
{
// 给到临时贴图中,方便后面我们进行双边滤波的操作
cmd.Blit(renderer.cameraColorTargetHandle, tmpSourceRtId, material, 0);
for (int i = 0; i < loopNum; i++)
{
// 进行双边滤波
cmd.Blit(tmpSourceRtId, tmpDesRtId, material, 1);
cmd.Blit(tmpDesRtId, tmpSourceRtId);
}
// 设定贴图
cmd.SetGlobalTexture("_VolumetricLightRT", tmpSourceRtId);
// 直接将结果赋值给相机并进行叠加操作
cmd.Blit(renderer.cameraColorTargetHandle, renderer.cameraColorTargetHandle, material, 2);
}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
}
public override void OnCameraCleanup(CommandBuffer cmd)
{
// 释放临时RT
cmd.ReleaseTemporaryRT(tmpSourceRtId);
cmd.ReleaseTemporaryRT(tmpDesRtId);
}
}
CustomRenderPass m_ScriptablePass;
// 设定渲染时机
[ ]
private RenderPassEvent renderPassEvent;
// 初始设定
public override void Create()
{
m_ScriptablePass = new CustomRenderPass();
// Configures where the render pass should be injected.
m_ScriptablePass.renderPassEvent = renderPassEvent;
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
// 获取后处理的设置
PostVolumeLightSetting setting = VolumeManager.instance.stack.GetComponent<PostVolumeLightSetting>();
if (setting != null && setting.IsActive() && m_ScriptablePass.material != null)
{
// 设定光的颜色并用透明度来控制强度
m_ScriptablePass.material.SetColor("_Color", setting.color.value);
// 设定光的最大距离
m_ScriptablePass.material.SetFloat("_MaxDistance", setting.maxDistance.value);
// 设定步长
m_ScriptablePass.material.SetFloat("_StepSize", setting.stepSize.value);
// 设定光的强度
m_ScriptablePass.material.SetFloat("_LightIntensity", setting.lightIntensity.value);
// 设定最大的步数
m_ScriptablePass.material.SetInt("_MaxStep", setting.maxStep.value);
// 设定卷积核大小
m_ScriptablePass.material.SetFloat("_KernelSize", setting.kernelSize.value);
// 设定空间方差
m_ScriptablePass.material.SetFloat("_SpaceSigma", setting.spaceSigma.value);
// 值方差
m_ScriptablePass.material.SetFloat("_ValueSigma", setting.valueSigma.value);
// 设定循环次数
m_ScriptablePass.LoopNum = setting.loopNum.value;
// 加入渲染
renderer.EnqueuePass(m_ScriptablePass);
}
}
}
1 | Shader "PostEffect/VolumetricLight" |
存在的问题
在我自己实现过程中,我发现下面这样的表现:

有一说一,这看起来确实奇怪。但是原因并不难猜。这些完全发白的地方,都是光线步进累加的结果。这些地方在深度图中也没有对应的深度,所以光线步进可以一直累加。且其本来就暴露在光下面,所以光线步进最终得到的结果就是这样强烈的白光。或许你觉得加一个深度限制就好了。比如你把第一个Pass的代码改为:
1
half3 col = _Color.rgb *_Color.a * mainLight.color * saturate(addLightInstensity) * step(depth,0.8);

对此我并没有什么很好的方法去解决这个问题。
参考文章
- Unity后处理——体积光:https://www.bilibili.com/read/cv27062642/
- RayMarching:https://zhuanlan.zhihu.com/p/366477891
闲言碎语
说来惭愧,我其实并没有搞懂这个效果的原理。我只是按照文章中的代码来实现了一下。即使我去搜索了RayMarching和双边滤波的相关资料,我还是不知道为什么它们会有这样的效果。所以我觉得我写的有些粗糙,希望有大佬能指点我一下。