基于IrradianceVolume魔改的全局光照方案

  欢迎参与讨论,转载请注明出处。

前言

  自《拉维瓦纳》技术性Demo演示公布后,得到不少反馈与总结。其中内部达成共识的一处便是目前的场景效果仍需提升,于是便围绕此展开了新的计划。其中明显的一点便是场景的着色表现太过平滑,近乎为Unlit。于是需要为此增添不少变化,其中一项措施便是全局光照
  而采用Unity自带的全局光照方案:Lightmap时则遇到不少问题与效果的不尽人意:

  • UV overlap问题
  • GPU烘焙时功能不齐全(如不支持TextureArray),而CPU烘焙则速度不佳
  • 对于动态物体需使用Light Probes方案,效果不够一体化
  • 对于场景内会动、会破坏的部分的支持度不佳
  • 烘焙器不开源,效果的可定制性不强

  基于以上原因,最终选择考虑其他的全局光照方案。以安柏霖的游戏中的Irradiance Volume为引,得出了一种自我魔改的全局光照方案,并不代表真正的Irradiance Volume方案,仅供参考,附上工程链接

分析

  根据安柏霖一文大致可以看出,Irradiance Volume是一种将场景划分为多个区域,每个区域记录关键信息,最终应用于区域内的对象的一种全局光照方案。这听起来很像Light Probes,只不过Light Probes是逐对象的(整个模型着色),而Irradiance Volume能做到逐顶点/片元。
  经过一番研究,参考了论文半条命2AMDCOD等诸多资料后,得出一点:这Irradiance Volume如同ECS一般,只有大致的概念,并无标准的实现。网上亦无太多相关开源实现,那么只好按照自己的理解去发挥了。
  其核心概念在上文也已说明,现落地为实际方案:

  • 按固定大小的格子划分场景
  • 使用ReflectionProbe拍摄每个格子下的CubeMap,提取6个面的代表色
  • 将每个面的代表色按位置存储到3D纹理,由于有6个面,所以需要6张
  • 具体模型着色时,根据顶点坐标找到所属格子,根据法线方向采样对应面的颜色进行混合,最终着色

构建格子

  首先是按固定大小的格子划分场景,为完成这一点,我们构造一个专门的MonoBehavior ProbeMgr,并构造格子的专属数据结构 ProbeData

1
2
3
4
5
6
[Serializable]
public class ProbeData {
public int index; // 格子在容器中的索引
public Vector3Int position; // 格子位置
public Color[] colors; // 格子内六个面的代表色
}

  通过在ProbeMgr定义格子在场景的数量(XYZ)、格子的大小、设置存储格子的容器,最后加上预览:

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
[ExecuteAlways]
public class ProbeMgr : MonoBehaviour {
// 六个面的方向向量
private static Vector3[] Directions = new Vector3[] {
new Vector3(-1, 0, 0),
new Vector3(1, 0, 0),
new Vector3(0, -1, 0),
new Vector3(0, 1, 0),
new Vector3(0, 0, -1),
new Vector3(0, 0, 1),
};
public Vector3Int size; // 格子在场景的数量
public float interval; // 格子的大小
public ProbeData[] datas; // 存储格子的容器
// 预览
protected void OnDrawGizmosSelected() {
Gizmos.color = Color.black;
var size = new Vector3(this.interval, this.interval, this.interval);
var position = this.transform.position;
// 显示格子
for (int x = -this.size.x; x <= this.size.x; x++) {
for (int y = -this.size.y; y <= this.size.y; y++) {
for (int z = -this.size.z; z <= this.size.z; z++) {
var pos = new Vector3(x, y, z) * this.interval;
Gizmos.DrawWireCube(position + pos, size);
}
}
}
// 显示六个面的代表色
foreach (var data in this.datas) {
var pos = this.GetProbePosition(data);
for (int i = 0; i < data.colors.Length; i++) {
Gizmos.color = data.colors[i];
Gizmos.DrawSphere(pos + Directions[i] * this.interval * 0.3f, this.interval * 0.1f);
}
}
}
}

0
  上图已是烘焙好的结果,仅供参考,如此格子的构建便完成了。

提取代表色

  所谓提取格子六个面的代表色,这种做法其实有个专属名词:Ambient Cube。其核心思想就是简化某个区域内的光照信息,这很显然是非常物理不正确且粗暴的,但有道是图形学第一理论:看起来对了,那就是对了。类似替代方案还可以采用二阶球谐,两者在效果上较为接近,这并非本文重点,不再展开。
  在上文也提到对此的具体方案:使用ReflectionProbe拍摄每个格子下的CubeMap,最后采样Cubemap的每个面的颜色求平均值即可。看起来这是个可并行化的任务:为每个格子都创建ReflectionProbe对象进行拍摄,然后使用Compute Shader对Cubemap进行采样提取颜色。可惜事与愿违,在Unity内部实现中,ReflectionProbe的拍摄同一时刻只有一个,而类似的Camera拍摄Cubemap更是非异步的,可见拍摄这一块想达到真正的并行化是做不到了。所幸Compute Shader那一块还行,也懒得改成非并行写法了(万一有天支持了),附上相关代码:

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
[ExecuteAlways]
public class ProbeMgr : MonoBehaviour {
// Cubemap大小
private const int SIZE = 128;
public ComputeShader shader; // 提取代表色的Compute Shader
public Texture3D[] textures; // 六个面对应的3D纹理
private int progress; // 拍摄进度
public bool IsBaking {
get;
private set;
}
// 编辑器模式下专属,拍摄时需时刻保持刷新
#if UNITY_EDITOR
protected void Update() {
if (this.IsBaking) {
EditorUtility.SetDirty(this);
}
}
#endif
// 拍摄总函数入口,注意它是协程形式的
public async void Bake() {
// 部署拍摄,让相关Shader切换为拍摄模式
this.IsBaking = true;
Shader.EnableKeyword("_BAKING");
this.progress = 0;
this.FlushProbe(); // 构建格子
// 构建贴图资源
this.textures = new Texture3D[6];
for (int i = 0; i < this.textures.Length; i++) {
var texture = new Texture3D(this.size.x * 2 + 1, this.size.y * 2 + 1, this.size.z * 2 + 1, DefaultFormat.HDR, 0);
texture.wrapMode = TextureWrapMode.Clamp;
this.textures[i] = texture;
}
// 全格子发起拍摄
for (int i = 0; i < this.datas.Length; i++) {
this.CaptureProbe(this.datas[i]);
}
// progress代表拍摄进度,在未全部拍摄完成前进行刷新并等待
while (this.progress < this.datas.Length) {
EditorUtility.SetDirty(this);
await Task.Yield();
}
// 拍摄完毕了,贴图应用
foreach (var texture in this.textures) {
texture.Apply();
}
// 设置相关数据到Shader,关闭拍摄模式
this.SetValue();
Shader.DisableKeyword("_BAKING");
this.IsBaking = false;
}
// 格子拍摄,注意它是协程形式的
private async void CaptureProbe(ProbeData data) {
// 为格子构建专属ReflectionProbe对象
var go = new GameObject("Reflect");
var reflect = go.AddComponent<ReflectionProbe>();
reflect.nearClipPlane = 0.001f;
reflect.farClipPlane = 100;
reflect.hdr = true;
reflect.backgroundColor = Color.white;
reflect.clearFlags = ReflectionProbeClearFlags.SolidColor;
reflect.resolution = 128;
// 设置ReflectionProbe的位置到格子中心
var position = this.GetProbePosition(data);
go.transform.SetParent(this.transform);
go.transform.position = position;
// 构建Cubemap贴图
var rt = RenderTexture.GetTemporary(SIZE, SIZE, 32, RenderTextureFormat.ARGBFloat);
rt.dimension = TextureDimension.Cube;
// 进行拍摄
var id = reflect.RenderProbe(rt);
// 等待拍摄完成,在此期间保持刷新
while (!reflect.IsFinishedRendering(id)) {
EditorUtility.SetDirty(this);
await Task.Yield();
}
// 构建颜色数据,它对应着colors[6]
var colorBuffer = new ComputeBuffer(6, sizeof(float) * 4);
// 设置相关属性到Compute Shader并启动
int kernel = this.shader.FindKernel("CSMain");
this.shader.SetTexture(kernel, "_CubeMap", rt);
this.shader.SetBuffer(kernel, "_Colors", colorBuffer);
this.shader.SetFloat("_Size", SIZE);
this.shader.Dispatch(kernel, 6, 1, 1);
// 执行完毕,将提取后的代表色存放到格子数据中
colorBuffer.GetData(data.colors);
// 设置代表色到对应位置的3D纹理中
var pos = data.position;
for (int i = 0; i < data.colors.Length; i++) {
var color = data.colors[i];
this.textures[i].SetPixel(pos.x, pos.y, pos.z, color);
}
// 清理资源,进度+1
colorBuffer.Release();
RenderTexture.ReleaseTemporary(rt);
DestroyImmediate(go);
this.progress++;
}
}
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
#pragma kernel CSMain
TextureCube<float4> _CubeMap;
SamplerState _LinearClamp;
RWStructuredBuffer<float4> _Colors;
float _Size; // CubeMap贴图大小
// 只开了6条线程,实现每个面取色的并行化
[numthreads(6, 1, 1)]
void CSMain (uint3 id : SV_GroupID)
{
float rate = 1.0 / _Size;
float3 color = float3(0.0, 0.0, 0.0);
for (int i = 0; i < _Size; i++) {
for (int j = 0; j < _Size; j++) {
// 通过位置获取对应uv
float2 uv = float2(j, i) * rate;
uv = 2.0 * uv - 1.0; // 0~1 -> -1~1
float3 coord = float3(0.0, 0.0, 0.0);
// 获取每个面对应的Cubemap纹理坐标
if (id.x == 0) { // +X
coord = float3(1.0, uv);
}
else if (id.x == 1) { // -X
coord = float3(-1.0, uv);
}
else if (id.x == 2) { // +Y
coord = float3(uv.x, 1.0, uv.y);
}
else if (id.x == 3) { // -Y
coord = float3(uv.x, -1.0, uv.y);
}
else if (id.x == 4) { // +Z
coord = float3(uv, 1.0);
}
else if (id.x == 5) { // -Z
coord = float3(uv, -1.0);
}
// 将每个点的颜色采样加起来
color += _CubeMap.SampleLevel(_LinearClamp, coord, 0).rgb;
}
}
// 求颜色平均值,得到代表色
float maxn = _Size * _Size;
_Colors[id.x] = float4(color / maxn, 1.0);
}

1
  上图是拍摄现场,可惜拍摄这块无法达成并行化,显得有点捞,只能将就了。

着色

  我们所需的数据都已构建完成,接下来便是着色了。首先需要将相关数据传到Shader,作为全局变量使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[ExecuteAlways]
public class ProbeMgr : MonoBehaviour {
private void SetValue() {
if (this.datas == null) {
return;
}
// 将六个面的3D纹理传递到Shader
for (int i = 0; i < this.textures.Length; i++) {
Shader.SetGlobalTexture("_VolumeTex" + i, this.textures[i]);
}
Shader.SetGlobalVector("_VolumeSize", (Vector3)this.size); // 格子数量(XYZ)
Shader.SetGlobalVector("_VolumePosition", this.position); // 格子矩阵的原点坐标(基于transform.position减去宽高而来)
Shader.SetGlobalFloat("_VolumeInterval", this.interval); // 格子大小
}
}

  然后便是核心Shader代码:

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
sampler3D _VolumeTex0;
sampler3D _VolumeTex1;
sampler3D _VolumeTex2;
sampler3D _VolumeTex3;
sampler3D _VolumeTex4;
sampler3D _VolumeTex5;
CBUFFER_START(IrradianceVolume)
float3 _VolumeSize;
float3 _VolumePosition;
float _VolumeInterval;
CBUFFER_END
// position: 顶点世界坐标
// normal: 顶点世界法线
// 获取该顶点下对应的颜色
float3 GetIrradiance(float3 position, float3 normal) {
float3 pos = position - _VolumePosition; // 获取顶点坐标在格子矩阵下的相对位置
float3 size = (_VolumeSize * 2 + 1) * _VolumeInterval; // 获取格子矩阵的总大小
float4 coord = float4(pos / size, 0); // 获取顶点坐标在格子矩阵下的uv
float3 direction = reflect(-_MainLightPosition.xyz, normal); // 这里是个魔改措施,为了让颜色的反映更加风骚
float3 color = GetAmbientColor(direction, coord); // 获取AmbientCube下的颜色
return color;
}
// normal: 顶点世界法线
// coord: 3d纹理uv
// 根据法线,获取AmbientCube下的颜色
float3 GetAmbientColor(float3 normal, float4 coord) {
// 无负数的权重值
float3 nSquared = normal * normal;
// 根据法线方向判断对应的三个面的纹理进行采样
// 实测这里的三目运算符并不会产生分支
float3 colorX = normal.x >= 0.0 ? tex3Dlod(_VolumeTex0, coord).rgb : tex3Dlod(_VolumeTex1, coord).rgb;
float3 colorY = normal.y >= 0.0 ? tex3Dlod(_VolumeTex2, coord).rgb : tex3Dlod(_VolumeTex3, coord).rgb;
float3 colorZ = normal.z >= 0.0 ? tex3Dlod(_VolumeTex4, coord).rgb : tex3Dlod(_VolumeTex5, coord).rgb;
// 将三个方向对应的颜色乘以权重值,得出最终色
float3 color = nSquared.x * colorX + nSquared.y * colorY + nSquared.z * colorZ;
return color;
}

  如此,通过调用GetIrradiance函数,传入顶点世界坐标与法线便可获取相关颜色,然后根据个人喜好进行着色即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// URP SimpleLitForwardPass.hlsl
half4 color = UniversalFragmentBlinnPhong(inputData, diffuse, specular, smoothness, emission, alpha);
// 处于拍摄模式下将屏蔽这段代码
#ifndef _BAKING
#ifdef _VOLUME_GI_ON
half3 ambientColor = GetIrradiance(inputData.positionWS, inputData.normalWS);
#else
half3 ambientColor = 1.0;
#endif
color.rgb *= ambientColor;
color.rgb = MixFog(color.rgb, inputData.fogCoord);
#endif
color.a = OutputAlpha(color.a, _Surface);

成果展示

  以下是成果展示:
2
3
4

  相较于传统GI方案来说,这样的效果未免过于浓郁了,这是我故意加了魔改代码后的结果:要的就是这种效果。毕竟GI对于我的目的而言并非为了什么物理正确,只是想让场景增添更多的颜色变化而已罢了。

后记

  这套GI方案的好处便是可控性强,有着做出更具风味效果的可能性。当然就性能消耗而言实际上是较传统Lightmap要高的(采样三张图),一般项目估计也用不上,仅供一乐。