Musoucrow' BLOG


  • Home

  • Categories

  • Archives

  • Tags

  • Search

基于Playable API结合蓝图驱动的动画方案

Posted on 2022-10-16 | In Teach | | Visitors

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

前言

  转眼间到了十月,再不写点东西都不会写了。正好现在《拉维瓦纳》的新动画方案也算经过将近一年的验证,可以拿出来分享一二了。本文所称的动画皆属Unity下的AnimationClip,在阅读本文之前建议对Playable API与蓝图有一定的了解,那么事不宜迟这就开始!
  首先第一个问题,好好的Animator方案放着不用搁那造轮子是要干什么玩意?
  Animator本身的功能是基本完备的,需要的功能(动画播放、速度控制、分层混合、动画过渡)都有,但存在如下问题:

  • 所有动画信息、动画之间的过渡关系都需要集中配置,且继承功能羸弱,缺乏灵活性,极端情况下会呈现蜘蛛网:
  • 0
  • Animator的Update无法很舒服的控制,虽然提供了[Animator.Update(https://docs.unity.cn/cn/current/ScriptReference/Animator.Update.html)]方法,但想精准控制还需通过调控[Animator.speed](https://docs.unity.cn/cn/current/ScriptReference/Animator-speed.html)实现
  • 若想符合Animator预设动画之间的过渡效果,需让Animator自行驱动动画状态切换,而非手动调用Animator.Play,对于一些条件切换还需通过AnimatorControllerParameter进行,这使得Animator的状态机运作是独立于逻辑运行的,存在一定的不可控性:
  • 1

  基于上述原因,虽然捏着鼻子用也不是不行,但Unity也自知理亏,推出了可定制性更强的动画方案——Playable API

Playable API

  Playable API基于动画的用例可参考这篇文章,本质上它是一种混合器(根据多种数据输入源组织混合输出结果)的树,只是Unity官方实现了关于动画与音效的各项驱动器,从而使得我们可以实现更具可控性的动画方案:
2
  Playable API如其名般,就是一系列API提供的数据结构,通过调用它们进行数据的组织,最终输出结果:

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
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Animations;
// 某个MonoBehavior内
// 需要播放的动画
public AnimationClip clip;
// Playable运作的主体
private PlayableGraph graph;
public void Start() {
// 添加Animator组件,动画播放还是需要它,只是不会对它进行操作了
var animator = this.gameObject.AddComponent<Animator>();
// 创建对象
this.graph = PlayableGraph.Create();
// 设置Update模式为手动调用
this.graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
// 动画播放源,决定要播放什么动画
var clipPlayable = AnimationClipPlayable.Create(this.graph, this.clip);
// 创建Playable输出项,决定为动画
var output = AnimationPlayableOutput.Create(this.graph, this.gameObject.name, animator);
// 绑定动画播放源,输出成立!
output.SetSourcePlayable(clipPlayable);
// 播放Playable
this.graph.Play();
}
public void Update() {
// 每帧驱动Playable更新,实现动画运作
this.graph.Evaluate(Time.deltaTime);
}

  以上代码示例展示了最简单的Animation Playable用法——为当前GameObject播放一个AnimationClip动画,若安装了PlayableGraph Visualizer扩展包,还可通过菜单栏Window/Analysis/PlayableGraph Visualizer打开Playable预览窗口,在运行时点击GameObject即可看到具体的构造:
3
  如前图所示,动画相关的Playable类还有很多(AnimationMixerPlayable、AnimationLayerMixerPlayable),足以实现我们需要的基本功能:

  • 播放AnimationClip动画(利用AnimationClipPlayable)
  • 按权重混合两个动画结果,实现混合树以及动画过渡(利用AnimationMixerPlayable)
  • 设置AvatarMask,实现分部位与叠加动画(利用AnimationLayerMixerPlayable)

  下图便是一套实际项目运作的Playable图结构,表示两个部位(身体、部件)运作、且实现了动画过渡(所以分别有两个AnimationClip节点,表示新旧):
4
  由此可见Playable API在功能上是能够满足需要的,但直接这样吭哧吭哧的直接写肯定是不行的,所以要基于此构思出一套新的工作流,实现其对应的封装。

动画蓝图

  若是论对Playable API的动画封装方案,Animancer倒是一个不错的选择。该有的功能都有了,还有相关编辑器配套。但最终还是没有选用,原因系Animancer无法直接嵌入我设想的工作流中,且尚需加一些特色功能,这表示需要做一定的改动。最重要的一点在于:自己重新封装一套的成本并不高,那既然都要折腾,不妨大干一场了!
  封装方案的具体细节后续再说,先来看看成品的使用情况:
5
  可以看出这是一段基于xNode的蓝图方案构造的动画配置过程,讲述的是身体本体为播放名为Slash3的动画而做的一系列准备,包括动画帧事件、与Idle动画的切换过渡、速度曲线变化、绑定攻速属性等。从资源图标可以看出,这里的动画资源都是AnimationClip,而非什么名称索引,达到了要播什么就上什么的效果,无需像Animator集中配置。这样一段节点过程将会存成单独的蓝图文件,我称之为动画蓝图,以供所需之处调用。
  从动画蓝图的参数配置可以看出,它是支持多个播放实体与多个部件的,虽然上图只是展示了Body实体下的Base层。但由此可见肯定会有登记具体GameObject为实体的方式:
6
  如此便将小雪人的本体模型登记为名为Body的动画实体了,但由此可以看出还是不够的,必定还有个统一管辖动画的组件:
7
  通过挂载Anim Machine组件提供功能,然后在各处挂载Anim Agent作动画实体登记,再佐以动画蓝图驱动,这便是我的动画工作流!这套方案较之Animator有个很大的优势:它统一管辖了各个动画部件,将多个部件在一套管理模式下统一运作,在业务层封装成动画蓝图的形式,实现了想播什么就播什么的效果。当然动画蓝图针对的都是具体的AnimationClip,若是想实现同一套逻辑下不同单位播放不同动画的话,还可以在Anim Machine组件登记专属的动画蓝图,也就是这个部分:
8
  这时候便如同Animator那般通过名称做了一层映射,在实际调用动画蓝图时会填写名称查找登记,若登记存在对应动画蓝图则调用登记版本的,否则为节点参数的:
9
  新的动画工作流方案便这么点内容,我们再回顾一下上文提到的Animator缺点,看看解决情况如何:

  • 所有动画信息、动画之间的过渡关系都需要集中配置,且继承功能羸弱,缺乏灵活性,极端情况下会呈现蜘蛛网
    • 解决,动画信息无需集中配置,以动画蓝图的形式配置,想播谁调谁
    • 动画之间的过渡关系针对每个动画单独配置,而非蜘蛛网的形式(当然这点也许各有利弊)
    • 继承功能通过在Anim Machine组件登记,调用蓝图时通过名称映射实现替换
  • Animator的Update无法很舒服的控制,虽然提供了Animator.Update方法,但想精准控制还需通过调控Animator.speed实现
    • 解决,Playable Graph可手动Update,与主逻辑严丝合缝
  • 若想符合Animator预设动画之间的过渡效果,需让Animator自行驱动动画状态切换,而非手动调用Animator.Play,对于一些条件切换还需通过AnimatorControllerParameter进行,这使得Animator的状态机运作是独立于逻辑运行的,存在一定的不可控性
    • 解决,动画蓝图记录了针对其他动画的过渡方案,播了就有过渡,不存在独立运作的动画控制流程,按业务层严格控制
  • 除此之外,新方案还统一管辖了多个动画实体,让业务层无需过多操心,并根据业务需求做出诸多扩展,属于根据项目实际情况做出的实践

封装构成

  在聊具体的代码结构前我们先来看看Playable Graph的具体构成:
10
  可以看出这套结构下LayerPlayable节点是基层的管理单元,表示一个动画实体下某层部位的动画运作,基本可以对应为Animator的Layer层。它负责管理新旧两个AnimationClipPlayable以及它们的混合器AnimationMixerPlayable以实现动画过渡的功能,外加动画过程相关的速度曲线变化以及帧事件功能。
  在LayerPlayable之上便是AnimationPlayableOutput了,它代表着一个动画播放单元,对应着一个Anim Agent组件以及Animator组件。在一整套Playable Graph环境中可以有多个AnimationPlayableOutput,在一个Graph下统一运作。
  接下来便到了具体的代码结构,接下来将以一张描述了核心数据结构的UML图展开具体的组成结构,这不代表全部的代码结构,但足以说清楚核心部分:
11
  上图内容颇多,讲述了各个数据结构的基本成分与它们之间的关联。接下来将以具体的蓝图功能为引,牵引出相关种种。

设置动画

12
  上图为动画功能节点中的核心成分:设置动画(SetClip)。从前文可知这套动画方案是类似图形API的设置若干状态后执行的,所以这里只是为指定的动画实体(Part)下的部件(Layer)设置将要播放的AnimationClip资源。这将是个穿针引线的好机会,以下为关键代码:

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
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
private PlayableGraph graph;
private Dictionary<string, Part> partMap;
public void SetClip(string name, string layer, AnimationClip clip, bool isOnly=false) {
// 根据name判断当前动画实体是否存在
if (!this.partMap.ContainsKey(name)) {
return;
}
var layerMap = this.partMap[name].layerMap;
if (layerMap.ContainsKey(layer)) {
// isOnly参数判断不播放相同的动画
if (isOnly && layerMap[layer].behaviour.Clip == clip) {
return;
}
}
else {
// 若无当前Layer则创建
this.NewLayer(name, layer);
}
// 通过clip创建AnimationClipPlayable
var playable = AnimationClipPlayable.Create(this.graph, clip);
if (clip && !clip.isLooping) {
// 设置动画时长
playable.SetDuration(clip.length);
}
// 获取先前的动画过渡设置中是否有此动画的相关项
var cond = this.GetTransition(layerMap[layer].conds, layerMap[layer].appendConds, clip);
// 调用LayerPlayable,传入AnimationClipPlayable和过渡项
layerMap[layer].behaviour.Play(playable, cond);
layerMap[layer].playable.SetSpeed(1);
layerMap[layer].isOnly = isOnly;
}
}

  以上为AnimMachine层面的SetClip方法内容,大致便是做了Part与Layer的相关准备工作,创建了AnimationClipPlayable与获取动画过渡项,最后通过LayerPlayable驱动。那么接下来看看LayerPlayable.Play的内容:

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
// LayerPlayable.cs
public class LayerPlayable : PlayableBehaviour {
public FrameEvent[] events;
private AnimationMixerPlayable mixerPlayable;
private AnimationClipPlayable beforePlayable;
private AnimationClipPlayable currentPlayable;
private int eventIndex;
private Tween transitTween;
private Tween speedTween;
public void Play(in AnimationClipPlayable playable, TransitionCond cond) {
// 将先前的AnimationClipPlayable全部断连,可理解为重置
for (int i = 0; i < this.mixerPlayable.GetInputCount(); i++) {
this.mixerPlayable.DisconnectInput(i);
}
// Playable数据结构基本为值类型,通过此方法管理内存
if (this.beforePlayable.IsValid()) {
this.beforePlayable.Destroy();
}
// 标记前动画Playable与现在的
this.beforePlayable = this.currentPlayable;
this.currentPlayable = playable;
// 根据有效性计算AnimationMixerPlayable的输入数量
int count = 0;
count = this.currentPlayable.IsValid() ? count++ : count;
count = this.beforePlayable.IsValid() ? count++ : count;
this.mixerPlayable.SetInputCount(count);
// 部署新旧AnimationClipPlayable的权重,为动画过渡作准备
int n = 0;
if (this.currentPlayable.IsValid()) {
this.Connect(n, this.currentPlayable);
this.mixerPlayable.SetInputWeight(n, 0);
n++;
}
if (this.beforePlayable.IsValid()) {
this.Connect(n, this.beforePlayable);
this.mixerPlayable.SetInputWeight(n, 1);
}
// 重置帧事件相关
this.events = null;
this.eventIndex = 0;
// 准备过渡插值,关闭速度变化插值
this.EnterTransitTween(cond.time);
this.speedTween.Exit();
// 准备过渡初始化
this.mixerPlayable.SetTime(0);
this.mixerPlayable.SetSpeed(1);
}
}

  上述代码中的Tween类型是未曾介绍的,不过有过相关动效经验的人士相信一眼便看出来是什么了:它可通过指定起始、目标值,经历时间,然后根据特定的插值模式实现富有曲线变化的插值效果:
13
  简而言之,便是能让动画速度与过渡效果更富有节奏变化的中间件。当然实际上过渡这块并没有用上特殊的插值模式,因为实践下来无甚区别。接下来正好将动画过渡这块展开讲讲——

动画过渡

  在聊动画过渡之前,我们先来看看它到底是个啥:
14
  重点关注攻击结束到恢复站立的那段动画过程,实际上它是由一个动画(Attack)过渡到了另一个动画(Idle):
15
  由于骨骼动画的本质是在一个个姿势之间插值,所以跨动画姿势插值自然是可以做到的。但不似一段动画过程中的姿势插值,跨动画的姿势插值没有明确的插值时间,且各种动画之间的插值效果也是不确定的。所以我们需要为此做出一套管理机制,这便是此节点的意义:
16
  上图节点是在展示中的攻击动画蓝图里的,用途为当切换为Idle动画时,用0.15秒过渡过去,而切换到其他动画的话则是按默认过渡时间0.1秒进行,也可通过过渡项填空的方式指定默认过渡时间:
17
  当然除了这种从动画自身考虑切换到其他动画的情况,还有一种是主动设定切换到本动画时的过渡时间:
18
  如上图般设置,当播放此动画时便会直接完成切换,没有过渡。在诸如挨打动画这种讲究蒙太奇效果的场合很合适:
41
  如此可见,本套方案便是用这种离散式的节点设置替代了Animator的蜘蛛网,可谓有利有弊吧,但显然是更得我心的。接下来看看具体的代码实现:

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
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
[Serializable]
public struct TransitionCond {
public AnimationClip target;
public float time;
}
// 就是普通的设置
public void SetTransitionConds(string name, string layer, TransitionCond[] conds) {
if (!this.partMap.ContainsKey(name)) {
return;
}
var layerMap = this.partMap[name].layerMap;
if (!layerMap.ContainsKey(layer)) {
this.NewLayer(name, layer);
}
layerMap[layer].conds = conds;
}
public void SetTransit(string name, string layer, float time) {
if (!this.partMap.ContainsKey(name)) {
return;
}
var layerMap = this.partMap[name].layerMap;
if (!layerMap.ContainsKey(layer)) {
this.NewLayer(name, layer);
}
// 直接启动过渡插值,所以需要在SetClip之后调用
layerMap[layer].behaviour.EnterTransitTween(time);
}
// 根据clip寻找符合条件的过渡方案
private TransitionCond GetTransition(TransitionCond[] conds, AnimationClip clip) {
// 缺省情况
if (conds == null) {
return new TransitionCond() {time = 0.1f};
}
// 遍历搜索
foreach (var t in conds) {
if (t.target == clip) {
return t;
}
}
// 若最后为空则为默认设置
if (conds[conds.Length - 1].target == null) {
return conds[conds.Length - 1];
}
// 默认为0.1秒切换
return new TransitionCond() {time = 0.1f};
}
public void SetClip(string name, string layer, AnimationClip clip, bool isOnly=false) {
// ...
// 获取符合条件的过渡方案
var cond = this.GetTransition(layerMap[layer].conds, clip);
// LayerPlayable设置动画相关
layerMap[layer].behaviour.Play(playable, cond);
}
}

  可以看出AnimMachine部分并无什么重点,主要是根据AnimationClip搜索TransitionCond,还是看看关键的LayerPlayable吧:

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
// LayerPlayable.cs
public class LayerPlayable : PlayableBehaviour {
// 外部传入的设置AnimationLayerPlayable的回调
public Action<float> SetLayerWeight;
private Tween transitTween;
// 创建回调
public override void OnPlayableCreate(Playable playable) {
base.OnPlayableCreate(playable);
// 创建Tween对象,设置运行回调
this.transitTween = new Tween(this.OnTransitTween);
}
// Update函数
public override void PrepareFrame(Playable playable, FrameData info) {
base.PrepareFrame(playable, info);
this.transitTween.Update(info.deltaTime);
}
// 启动过渡插值
public void EnterTransitTween(float time) {
this.transitTween.Enter(0, 1, time);
}
// TransitionCond由AnimMachine通过GetTransition获得传入
public void Play(in AnimationClipPlayable playable, TransitionCond cond) {
// ...
// 启动过渡插值
this.EnterTransitTween(cond.time);
}
// 过渡插值运行时调用
private void OnTransitTween() {
// 获取当前插值的进度(0-1)
float rate = this.transitTween.Rate;
// 获取当前动画的Clip资源
AnimationClip clip = this.currentPlayable.GetAnimationClip();
// 当没有动画时,会逐渐将本Layer的权重降为0,不产生效果
if (clip == null) {
this.SetLayerWeight(1 - rate);
}
// 设置当前动画的权重,逐渐增大(0 -> 1),产生效果
this.mixerPlayable.SetInputWeight(0, rate);
// 若存在旧动画
if (this.mixerPlayable.GetInputCount() > 1) {
// 设置旧动画的权重,逐渐减少(1 -> 0),失去效果
this.mixerPlayable.SetInputWeight(1, 1 - rate);
}
}
}

  上述代码主要是通过transitTween驱动mixerPlayable对新旧动画输入源的权重调整实现过渡效果,其中的重点在于Layer无Clip时的处理:通过调用上层传入的SetLayerWeight回调从而控制AnimationLayerMixerPlayable对当前Layer输入源的权重从而实现逐渐失效:

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
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
public class Part {
public GameObject gameObject;
public AnimationPlayableOutput output;
public AnimationLayerMixerPlayable mixerPlayable;
public Dictionary<string, Layer> layerMap;
public List<Layer> layers;
public bool needSort;
}
public class Layer {
public AvatarMask mask;
public bool isAdditive;
public float weight;
public ScriptPlayable<LayerPlayable> playable;
public LayerPlayable behaviour;
public TransitionCond[] conds;
public bool isOnly;
}
private void NewLayer(string name, string layer) {
if (!this.partMap.ContainsKey(name)) {
return;
}
var layerMap = this.partMap[name].layerMap;
var layers = this.partMap[name].layers;
// 标记播放时将对layers进行排序
this.partMap[name].needSort = true;
// layerMap用于通过名称索引对象,layers用于确保Layer的输入源id
var obj = new Layer();
layerMap.Add(layer, obj);
layers.Add(obj);
// 创建LayerPlayable
obj.playable = ScriptPlayable<LayerPlayable>.Create(this.graph, 1);
obj.weight = 1;
obj.index = -1;
// 获取真正的PlayableBehaviour对象
obj.behaviour = obj.playable.GetBehaviour();
// 设置SetLayerWeight回调
obj.behaviour.SetLayerWeight = (rate) => {
// obj.index将在真正播放时分配
if (obj.index >= 0) {
// 设置AnimationLayerMixerPlayable的输入源权重
this.partMap[name].mixerPlayable.SetInputWeight(obj.index, obj.weight * rate);
}
}
}
}

  上述代码展示了Layer的创建过程,并引入了Part部分的mixerPlayable。可以理解为Layer层面下有个针对新旧动画之间的Mixer,Part层面下有个针对Layer之间的Mixer。而为了Layer是有序接入Part的Mixer的,还需为Layer设计两套容器存储(layerMap和layers)。既然已经引入到了Part,那么接下来关联性最大的节点为引展开讲讲吧——

播放

19
  播放节点作为动画蓝图设置完毕后的执行命令,类似渲染管线里的CommandBuffer.Submit()——将先前设置的状态打包执行。当然与Submit一样,播放节点也不会立即执行生效,而是在之后的逻辑时机统一执行,除非你勾选了force参数:

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
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
private HashSet<string> playSet;
protected void Awake() {
this.graph = PlayableGraph.Create(this.gameObject.name);
this.graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
}
protected void Start() {
this.graph.Play();
}
protected void Update() {
// 手动更新Playable Graph
if (!this.IsPaused) {
this.graph.Evaluate(Time.deltaTime);
}
}
protected void LateUpdate(float dt) {
if (this.playSet.Count > 0) {
// 遍历命令播放
foreach (var p in this.playSet) {
this.PlayAux(p);
}
this.playSet.Clear();
}
}
public void Play(string name, bool force=false) {
// force则直接执行
if (force) {
this.PlayAux(name);
this.playSet.Remove(name);
}
else {
this.playSet.Add(name);
}
}
// 真正的播放执行
private void PlayAux(string name) {
if (!this.partMap.ContainsKey(name)) {
return;
}
var part = this.partMap[name];
// 清空Layer输入源
for (int i = 0; i < part.mixerPlayable.GetInputCount(); i++) {
part.mixerPlayable.DisconnectInput(i);
}
// 对Layer们进行排序,若是有新Layer加入的话
if (part.needSort) {
part.layers.Sort(this.Sort);
}
// 设置输入源数量
part.mixerPlayable.SetInputCount(part.layers.Count);
int n = 0;
// 遍历Layer对象,将符合条件的LayerPlayable都加入到mixerPlayable中
for (int i = 0; i < part.layers.Count; i++) {
var layer = part.layers[i];
// 无权重、无动画者退出
if (layer.weight == 0 || !layer.behaviour.Clip) {
continue;
}
// 设置接入、序号、权重、是否为叠加效果
part.mixerPlayable.ConnectInput(n, layer.playable, 0);
part.mixerPlayable.SetInputWeight(n, layer.weight);
part.mixerPlayable.SetLayerAdditive((uint)n, layer.isAdditive);
layer.index = n;
// 若是有AvatarMask,则用于指定具体生效骨骼
if (layer.mask) {
part.mixerPlayable.SetLayerMaskFromAvatarMask((uint)n, layer.mask);
}
n++;
}
}
}

  可以看出,Play真正执行的内容便是构建part.mixerPlayable,Layer部分的Play内容已经在SetClip()时通过LayerPlayable.Play()完成了。将播放执行放在后续逻辑统一进行是为了业务的稳定性,也为特殊情况提供了force参数网开一面。
  说道这份上,顺带再看看动画实体Part的初始化过程,以补全最后一块拼图吧:

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
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
// 通过AnimAgent组件调用
public void SetPart(string name, GameObject gameObject) {
// 若存在相同Part则拒绝
if (this.partMap.ContainsKey(name) && this.partMap[name].gameObject == gameObject) {
return;
}
// 销毁已存在的Playable
if (this.partMap.ContainsKey(name)) {
this.partMap[name].mixerPlayable.Destroy();
}
// gameObject为空则表示卸载
if (!gameObject) {
this.partMap.Remove(name);
return;
}
var animator = gameObject.GetComponent<Animator>();
// Playable API动画需要Animator组件支持,若不存在则创建
if (!animator) {
animator = gameObject.AddComponent<Animator>();
}
// 正式创建Part对象
var part = new Part();
part.gameObject = gameObject;
part.layerMap = new Dictionary<string, Layer>();
part.layers = new List<Layer>();
part.mixerPlayable = AnimationLayerMixerPlayable.Create(this.graph);
part.output = AnimationPlayableOutput.Create(this.graph, gameObject.name, animator);
part.output.SetSourcePlayable(part.mixerPlayable);
this.partMap[name] = part;
}
}

专门功能

  通过设置动画、动画过渡、播放三块,我们将整个动画方案的核心构成都梳理清楚了。接下来便是介绍在此之上的诸多功能了,与先前相同,也是通过蓝图节点为引进行介绍:

帧事件

20
  本动画方案并没有选择Unity为AnimationClip准备的AnimationEvent,而是选择自定义了一套帧事件方案:通过设置事件蓝图填写每个时间点触发的对应事件名,在动画执行到对应时间点时将调用回调函数通知外界对应事件触发。从编辑交互的角度来说可谓原始得很了,被AnimationEvent完爆。这一块确实没那么讲究,凑合了。
  之所以不考虑AnimationEvent,有以下几点原因:

  • 在某些极端情况下会跳帧,事件未触发
  • 在动画过渡期间旧动画的事件依旧会执行
  • 与动画播放强耦合了,不利于纯逻辑运行
  • 不方便大规模编辑
  • 底层事件调用性能不佳

  于是自己造一套便势在必行了,当然对于使用Timeline制作流程业务的方案下就不需要了,因为Timeline本身便是帧事件触发。当然我并没有选择Timeline方案,所以帧事件也算是一种替代了。如上图所示般,那是一段攻击动画涉及的种种事件,之所以不是填写具体的动画帧数而是时间,首先是为能够让帧事件独立运作留个后门,其次是不同的AnimationClip的FPS是不一定一致的,从而会导致描述不稳定,且按帧作为时间刻度的话可能太大了(FPS较低)的话,这个问题在AnimationEvent很明显。
21
  上图便是播放攻击动画的状态蓝图,可见便是在动画蓝图中登记的对应事件业务。状态机这边通过注册回调以获得动画事件的触发,从而调用状态蓝图中对应的功能,实现动画驱动逻辑的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
[Serializable]
public struct FrameEvent {
public float time;
public string name;
}
// 平平无奇的设置数据
public void SetFrameEvents(string name, string layer, FrameEvent[] events) {
if (!this.partMap.ContainsKey(name)) {
return;
}
var layerMap = this.partMap[name].layerMap;
if (!layerMap.ContainsKey(layer)) {
this.NewLayer(name, layer);
}
layerMap[layer].behaviour.events = events;
}
}

  然后便到代码环节了,AnimMachine部分无甚出奇的内容,就是个对应设置事件的蓝图节点,还是看看重点部分吧:

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
// LayerPlayable.cs
public class LayerPlayable : PlayableBehaviour {
// 事件集,由AnimMachine.SetFrameEvents()传入
public FrameEvent[] events;
// 事件触发回调
public Action<string> OnEvent;
// 当前执行到的事件进度
private int eventIndex;
public void Play(in AnimationClipPlayable playable, TransitionCond cond) {
// ...
// 设置动画时会清空事件相关
this.events = null;
this.eventIndex = 0;
}
// Update
public override void PrepareFrame(Playable playable, FrameData info) {
// ...
// 无事件则结束
if (this.events == null) {
return;
}
// 获取当前动画的进度时间
var time = this.currentPlayable.GetTime();
// 从当前事件序号进度开始遍历,为防止事件触发后events清空,故加判定
// 这般遍历是为了确保哪怕跳帧也能不错漏触发事件
for (int i = this.eventIndex; this.events != null && i < this.events.Length; i++) {
var t = this.events[i].time;
if (time >= t) {
this.eventIndex = i + 1;
this.OnEvent(this.events[i].name);
}
}
// 处理循环动画的情况,恢复事件进度
if (this.Clip && this.Clip.isLooping && time >= this.Clip.length) {
var retain = time - this.Clip.length;
this.currentPlayable.SetTime(retain);
this.eventIndex = 0;
}
}
}

  可以看出帧事件的实现相当简单,主要注意动画速度过快时跳帧也不要漏过事件即可。由于《拉维瓦纳》是纯粹的单机游戏,没有划分纯逻辑层的动机,所以理应独立运行的帧事件也寄宿于Playable中了,阿弥陀佛。

速度控制

22 23
  对比上图,前者为纯匀速,后者为加入了速度变化的效果,以此增添更强的节奏感。当然其实纯匀速的效果也并不差,在多数游戏估计就这么行了,毕竟动画制作时关键帧之间的插值也不是线性的,本身就具有节奏感。但我选择开发了速度变化功能以此兜底,之所以不选择回到DCC重修动画,一来这样工作流比较折腾,其次实际演出是很综合性的结果,孤立的调整动画很难找到那种感觉,且不同的演出下哪怕是用了相同的动画,其对于节奏的要求也许也是不同的,故做了这个蓝图节点:
24
  含义非常简单,为某个动画设置起始速度、终点速度、变化时间、插值曲线,产生一段动画速度的插值变化,也可以From, To都填相同的数值实现简单的速度设置。其实这里算是偷懒了,存在一个硬伤:无法实现低-高-低的速度变化,只有从一个值到另一个值,按理来说为此应该使用AnimationCurve实现,但其缺点为难以拉出比算法强的曲线,但可开发配套工具利用算法生成,这个纳入日后的待办事项吧。
  除此之外速度相关的还有与具体属性绑定的蓝图节点,根据属性影响动画速度,与速度变化是相乘关系:
25
  如上图所示般,攻击动画绑定了攻速属性,将受攻速影响:
26
  当然要注意攻击特效的播放速度也得跟随动画速度,所以也会提供相关获取播放速度的API以供外界调用:

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
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
// 整个动画系统的总速度
public float speed = 1;
protected void Update() {
// 总speed生效
float dt = Time.deltaTime * this.speed;
if (!this.IsPaused) {
this.graph.Evaluate(dt);
}
}
// 设置LayerPlayable的播放速度,这里对应的是绑定属性的速度
public void SetSpeed(string name, string layer, float speed) {
if (!this.partMap.ContainsKey(name)) {
return;
}
var layerMap = this.partMap[name].layerMap;
if (!layerMap.ContainsKey(layer)) {
this.NewLayer(name, layer);
}
layerMap[layer].playable.SetSpeed(speed);
}
// 启动速度变化插值,这里调整的是Layer.mixerPlayable的播放速度
public void EnterSpeedTween(string name, string layer, float from, float to, float time, Tween.Easing easing, float clock=0) {
if (!this.partMap.ContainsKey(name)) {
return;
}
var layerMap = this.partMap[name].layerMap;
if (!layerMap.ContainsKey(layer)) {
this.NewLayer(name, layer);
}
layerMap[layer].behaviour.EnterSpeedTween(from, to, time, easing, clock);
}
// 获取动画实体总体的播放速度,根据各种情况计算
public float GetSpeed(string name, string layer) {
// 查无此人时返回1
if (!this.partMap.ContainsKey(name)) {
return 1;
}
var layerMap = this.partMap[name].layerMap;
// 查无此层时返回总速度
if (!layerMap.ContainsKey(layer)) {
return this.speed;
}
// 总速度 * 属性速度 * 变化速度
return this.speed * layerMap[layer].behaviour.Speed * (float)layerMap[layer].playable.GetSpeed();
}
}

  可以看出动画方案将速度分成了三层:总速度(影响全局)、属性速度(影响Layer)、变化速度(Layer之下),借助Playable Graph的特性,输入源的速度可影响下层的速度,且彼此的数值管理独立,分别将属性速度与变化速度存于不同的Playable中,满足功能需求。

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
// LayerPlayable.cs
public class LayerPlayable : PlayableBehaviour {
private AnimationMixerPlayable mixerPlayable;
private Tween speedTween;
public override void OnPlayableCreate(Playable playable) {
// ...
// 创建变化速度的Tween,绑定运行回调
this.speedTween = new Tween(this.OnSpeedTween);
}
public override void PrepareFrame(Playable playable, FrameData info) {
// ...
// 每帧驱动运行
this.speedTween.Update(info.deltaTime);
}
public void Play(in AnimationClipPlayable playable, TransitionCond cond) {
// ...
// 设置动画后关闭,需重新设置
this.speedTween.Exit();
}
// 启动速度变化
public void EnterSpeedTween(float from, float to, float time, Tween.Easing easing, float clock=0) {
this.speedTween.Enter(from, to, time, easing);
this.speedTween.Clock = clock;
}
// 速度变化Tween运行时调用
private void OnSpeedTween() {
// 获取当前速度值,赋值给mixerPlayable
this.mixerPlayable.SetSpeed(this.speedTween.Value);
}
}

  若是改为AnimationCurve的方案则不能用Tween了,得根据播放时间采样对应曲线上的值,当然那是后话了——

暂停控制

27 28
  如上两图所示,动画播放因为业务的原因(卡肉、硬直)会陷入一段时间的暂停。本来这块对外提供个暂停接口再实现相关业务即可,但想到若是要兼容多种暂停的情况势必得开发个暂停管理层,那干脆将相关业务直接写进AnimMachine好了:

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
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
// 提供单位是否暂停的组件
private Identity identity;
// 硬直计时器
public Timer lagTimer;
// 通用暂停开关
public bool isPaused;
public bool IsPaused {
get {
// 暂停条件
return (this.identity && this.identity.isPaused) || this.lagTimer.IsRunning || this.isPaused;
}
}
protected void Awake() {
// ...
this.lagTimer = new Timer();
this.identity = this.GetComponent<Identity>();
}
protected void Update() {
float dt = Time.deltaTime * this.speed
this.lagTimer.Update(dt);
// 暂停时则停止Play Graph运作
if (!this.IsPaused) {
this.graph.Evaluate(dt);
}
}
private void PlayAux(string name) {
// ...
// 播放时取消硬直计时器
this.lagTimer.Exit();
}
}

  由于卡肉属于单位全局性的暂停,所以交给其他组件负责,AnimMachine作配合即可。硬直方面则通过在外界启动lagTimer进行控制,再配合isPaused变量作一般功能性的暂停控制。

AvatarMask

29
  AvatarMask,Unity提供选择某些部位执行动画的方案,在Playable API下依旧存在。与Animator那样Layer绑定特定AvatarMask不同,在本方案下是根据蓝图节点自由设置的,可随时切换:
30 31 32
  在转转时移动播放此动画,通过AvatarMask使得只有腿部骨骼生效,即可实现上半身攻击下半身走路的效果。且设置权重值为0.8,产生与本体动画的混合感。

特色用法

  除了以上专门功能外,还有一些特色用法值得说说。这下真是最后一段了,拖太长了。

单帧设置

33
  如上图所示,其朝向鼠标指针的转向其实也是个动画:
34
  它本质上是个由0-360帧组成对应0-360度转身的功能性动画,这样的动画自然是不能直接播放的,反而需要设置单帧固定好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// AnimMachine.cs
public class AnimMachine : MonoBehaviour {
public void SetTime(string name, string layer, float time, bool paused=true) {
if (!this.partMap.ContainsKey(name)) {
return;
}
var layerMap = this.partMap[name].layerMap;
if (!layerMap.ContainsKey(layer)) {
this.NewLayer(name, layer);
}
// 设置CurrentPlayable的帧时间并暂停
layerMap[layer].behaviour.IsPaused = paused;
layerMap[layer].behaviour.Time = time;
}
}

  然后通过鼠标指针与单位的相对角度转换为帧时间设置,且为转向动画使用专门的AvatarMask,即可实现跟随鼠标转向的效果了。

部件动画协同

35
  这样看似简单的跑步动画实际上是由两个动画组成的,本体与部件:
36 37
  这么做是为了通用性,跑步动画是所有单位皆可使用的,所以为了在此基础上加料就得单独做个动画,最后组合起来播放:
38
  如上图所示,为了省事专门开发了部署部件的蓝图节点,在调用通用跑步的动画蓝图后,Part层对Body层的设置进行了继承,并播放了专门的部件Clip配合相关AvatarMask,并且部件动画之间也有自己的过渡关系:

1
2
3
4
5
6
7
8
9
10
// AnimSvc.cs
public static class AnimSvc {
public static void SetAvatarPart(AnimMachine animMachine, string name, string layer, string bodyLayer, AnimationClip clip, AvatarMask mask) {
// dstLayer继承srcLayer的各项属性
animMachine.Inherit(name, srcLayer, dstLayer);
animMachine.SetClip(name, layer, clip, isOnly);
animMachine.SetAvatarMask(name, layer, mask, isAdditive);
}
}

组合动画

39
  如上图所示,像这种由多个部件组合而成的流程动画控制也是交由本动画方案实现的,毕竟AnimationClip可不只是骨骼动画。为每个子GameObject标注为动画实体,通过动画蓝图统一调度:
40

后记

  总算是写完了,本篇文章断断续续写了好几天(属实是拉了),光是配图都配了41张。难免会有割裂之处,还请谅解。由于与业务深度捆绑,也不便作个开源版本,大伙便从这只言片语中看个思想,也算作为Unity动画方案的一种思路吧。

基于xNode构造的蓝图方案

Posted on 2022-05-05 | In Teach | | Visitors

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

前言

  经历两月的上海疫情风波,过完了一个毫无实感的五一假期后蓦然回首今年居然还没写博客。恰好现有的一套方案也刚好历时半年以上的验证了,也到了该分享的时候了,也作为Gameplay相关分享的回归开幕。
  有过Gameplay相关经验的朋友应该对于业务开发流程都有自己的一套,每个项目可能都不尽相同。相比的《DFQ》的纯代码硬磕、相关参数读取配置的方案,《拉维瓦纳》选择的是导出关键API、围绕蓝图组织逻辑与数据的方案。这样不仅存在让策划深度参与的可能性、也使得逻辑与数据的隔阂被打破——甚至可以说蓝图就是一种具有逻辑表达能力的数据。
  为此我们起先选择了FlowCanvas作为蓝图的解决方案,如果只是如此的话倒也没什么可说的。经过技术性Demo发布后,随着日益增长的需求,FlowCanvas凸显出其在性能方面的不足。主要体现在创建蓝图时的开销较大,且存在居高不下的GC Allow,再结合源码复杂度高难以修改、编辑器与运行时捆绑较强等问题,于是乎决定自己造了个轮子:xNodeGraph

0
1

  以上两图分别为FlowCanvas与xNodeGraph对同一业务的对比,可以看出后者除却支持中文、摆的好看了点,大体上是大差不差的。然而FlowCanvas作为商业销售的插件,在功能上是大而全的。而我们实际上用不到那么多功能,只保证满足核心需求即可:

  • 一款能够编辑节点、组织数据结构的编辑器
  • 一款简洁高效、提供蓝图资源即可运作的运行时
  • 提供便捷的API导出节点的方案

  如此,基于一款基于xNode作为节点编辑器、自行编写运行时及相关工具链的解决方案便呼之欲出了。

xNode & Odin

xNode

  入上图所示,xNode是一款纯粹的节点编辑器解决方案,并且你可以在不修改源码的前提下高自由度地定制界面形式。且定义了节点数据最基本的抽象(Graph、Node),并将它们组织了起来。在Unity的层面来说就是一个个ScriptableObject:

2

  说到界面定制,就不得不提xNode的好搭档Odin了。多数情况下无需编写IMGUI是它最大的优势,但这并非我要引入的重点。关键在于Odin本身集成一些成熟的界面形式且xNode本身与之结合良好:

3

  如上图的节点选择菜单便是使用Odin自带的GenericSelector实现的。当然使用Odin与否在某些团队是个路线之争,这个便因地制宜了。

数据流 or 代码流

  说完界面部分,便到了运行时的首要抉择:蓝图的最终运行形式是怎样的?关于这块的方案业界有不少实现:有类似虚拟机运行节点触发逻辑的、有将蓝图转换为Lua代码的、有直接生成C#代码的(致天国的Bolt2)、甚至还有UE那般虚拟机与代码生成都提供的。
  xNodeGraph最终选择了虚拟机方案,最核心的原因是蓝图的部分并非性能热点,用虚拟机也可以接受。且真正实现了一种具有逻辑表达的资源,若是代码生成还要多一层转换。
  在决定了虚拟机方案后,我决定在xNode的节点数据结构上直接填充逻辑,这虽然不太优雅(运行时与xNode绑定了),但我不想整太多抽象转换之类的事了,反正xNode本身足够轻量:

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
// LogNode.cs
[CreateNodeMenuAttribute("调试输出")]
public class LogNode : FlowNode {
public override string Title {
get {
return "调试输出";
}
}
public override string Note {
get {
return @"打印输出结果";
}
}
[Input(connectionType = ConnectionType.Override)]
public string value;
private BaseNode valueNode;
protected override void Init() {
base.Init();
this.valueNode = this.GetPortNode("value");
}
// 核心逻辑函数
public override object Run(Runtime runtime, int id) {
var value = this.GetValue<object>(this.value, this.valueNode, runtime);
Debug.Log(value.ToString());
return null;
}
}

  以上是最简单的调试输出节点的代码实现,如此也能看出这套虚拟机运行时的本质便是调用一个个节点对象的Run函数以实现逻辑驱动。

运行时

  从上文代码也能看出节点运行逻辑时有着名为runtime的参数,那便是驱动蓝图运行的对象,我们可以在需要运作蓝图的地方创建runtime对象,还可传递一些变量进去供蓝图使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// GraphBehaviour.cs
public class GraphBehaviour : MonoBehaviour {
public BaseGraph graph;
public BlackBoard blackBoard;
private Runtime runtime;
protected void Awake() {
this.runtime = new Runtime(this.graph, this.blackBoard);
this.runtime.SetVariable("gameObject", this.gameObject);
this.runtime.RunFunc("Awake");
}
}

  如代码所示,这便是在一个MonoBehavior的基础上通过外部提供的蓝图资源创建运行时,并将自身的gameObject作为变量传递到了蓝图,最终调用蓝图内定义的Awake函数,将gameObject输出:

4

  从上述例子也能大致看出运行时除了根据Func作为入口点按序调用节点之外,还包括变量的存储、以及避免提供数据的节点反复运算的缓存,对于需要重复进行新计算的结果的节点,选择复制多一份节点即可:

1
2
3
4
5
6
7
// Runtime.cs
public class Runtime {
private BaseGraph graph;
public Dictionary<BaseNode, object> cache;
public Dictionary<string, object> variable;
}

  从上文节点可看出节点分为供应节点与流程节点,流程节点具有In和Out两个插槽,而供应节点则必定有返回值插槽(否则就没有意义了)。流程节点也可以有返回值插槽,而且由于缓存机制的原因不会导致重复调用。

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
// Runtime.cs
public void RunNode(BaseNode node, int id) {
// 判断当前流程是否已执行完毕
if (this.exitIdSet.Contains(id) || this.IsExit) {
return;
}
// 调用节点的核心逻辑
node.Run(this, id);
// 获取Out插槽指向的In插槽节点
var next = node.NextNode;
if (next) {
this.RunNode(next, id);
}
}
// BaseNode.cs
public T GetValue<T>(T value, BaseNode node, Runtime runtime) {
if (node) {
// 判断缓存内是否有该节点的返回值
if (runtime.cache.ContainsKey(node)) {
return (T)runtime.cache[node];
}
// 调用核心逻辑获得数据,并在核心逻辑内将返回值数据缓存
return (T)node.Run(runtime, 0);
}
// 若是该值来源不通过节点则使用自身数据
return value;
}

  从上述代码还可看出有着名为id的参数,且它与判断流程是否执行完毕有关。在同步执行下这本来应该不是问题,毕竟整个流程的执行过程都是阻塞的,不过一口气执行到结尾罢了,根本没有判断的必要。这显然是为了异步的情况而生的:

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
// Runtime.cs
private HashSet<int> exitIdSet;
// 异步执行函数
public async Task RunFuncWaitting(string func, int id=0, BaseGraph graph=null) {
graph = graph == null ? this.graph : graph;
if (!graph.funcMap.ContainsKey(func)) {
return;
}
var f = graph.funcMap[func];
id = id > 0 ? id : func.GetHashCode(); // 若未提供id,则使用func名称的哈希码
if (id > 0 && this.exitIdSet.Contains(id)) {
this.exitIdSet.Remove(id);
}
// 根据该流程是否为异步决定执行
if (f.async) {
await this.RunNodeAsync(f.node, id);
}
else {
this.RunNode(f.node, id);
}
}
// RunNode的异步版
public async Task RunNodeAsync(BaseNode node, int id=0) {
if (this.exitIdSet.Contains(id) || this.IsExit) {
return;
}
// 调用节点的逻辑函数异步版
await node.RunAsync(this, id);
var next = node.NextNode;
if (next) {
await this.RunNodeAsync(next, id);
}
}
// 结束流程,将id登记到exitIdSet
public void ExitFunc(int id) {
if (id > 0) {
this.exitIdSet.Add(id);
}
}
// 当然也提供无id的版本
public void ExitFunc(string func) {
this.ExitFunc(func.GetHashCode());
}

  从上述代码可见,运行时使用的异步方案是C#的Async/Await,这套方案最大的缺点在于外部无法很方便的直接中断异步的执行,于是采用了通过id作为标识符的方式、逐节点检查的方式进行流程中断控制。
  由于异步传染的问题,每个节点都要实现对应的同步与异步两个版本函数(RunFunc/RunFuncAsync),每个Func节点会检查所属流程中是否含有异步节点(这件事会在编辑器端进行并保存为数据,也就是前文提到的f.async),理论上可以只保留异步版本也没关系,但实际测试下来异步的调用堆栈会比同步的要深以及略微的GC Allow,为了提升一点性能故选择了分离的做法。

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
// LogNode.cs
// Log节点的异步版本
public async override Task<object> RunAsync(Runtime runtime, int id) {
var value = await this.GetValueAsync<object>(this.value, this.valueNode, runtime);
Debug.Log(value.ToString());
return null;
}
// BaseNode.cs
// 获取数据的异步版
public async Task<T> GetValueAsync<T>(T value, BaseNode node, Runtime runtime) {
if (node) {
if (runtime.cache.ContainsKey(node)) {
return (T)runtime.cache[node];
}
var v = await node.RunAsync(runtime, 0);
return (T)v;
}
return value;
}

节点生成

  从上述LogNode可以看出复杂度还是有不少的,一些特殊节点倒也罢了,若是每个节点都如此手写那可真是太手工业了。于是乎节点代码生成势在必行:

1
2
3
4
5
6
7
8
// Math.cs
public static class Math {
[Node("数学-向量相加", "a + b", false)]
public static Vector3 AddVec(Vector3 a, Vector3 b) {
return a + b;
}
}

  节点生成需要对函数做标记,那么使用C#的Attribute特性便很自然了,通过提供函数的节点名称、说明、是否为流程节点自动生成类文件:

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
// Generated/Graph/Game/Lib/Math/AddVecNode.cs
namespace Generated.Graph.GGame.Lib.Math_ {
[CreateNodeMenuAttribute("数学-向量相加")]
public class AddVecNode : BaseNode {
public override string Title {
get {
return "数学-向量相加";
}
}
public override string Note {
get {
return "a + b";
}
}
public override bool Async {
get {
return false;
}
}
[Input(connectionType = ConnectionType.Override)]
public Game.Graph.Vec3 a;
private BaseNode aNode;
[Input(connectionType = ConnectionType.Override)]
public Game.Graph.Vec3 b;
private BaseNode bNode;
[Output]public Game.Graph.Vec3 ret;
protected override void Init() {
base.Init();
this.aNode = this.GetPortNode("a");
this.bNode = this.GetPortNode("b");
}
public override object Run(Runtime runtime, int id) {
var a = this.GetValue<Game.Graph.Vec3>(this.a, this.aNode, runtime);
var b = this.GetValue<Game.Graph.Vec3>(this.b, this.bNode, runtime);
this.ret.value = Game.Lib.Math.AddVec(a.value, b.value);
return this.ret;
}
public async override Task<object> RunAsync(Runtime runtime, int id) {
var a = await this.GetValueAsync<Game.Graph.Vec3>(this.a, this.aNode, runtime);
var b = await this.GetValueAsync<Game.Graph.Vec3>(this.b, this.bNode, runtime);
this.ret.value = Game.Lib.Math.AddVec(a.value, b.value);
await Task.CompletedTask;
return this.ret;
}
}
}

  这生成的还挺人模狗样的,原理倒也不复杂,便是通过在菜单点击代码生成的按钮后,遍历所有标记了NodeAttribute的函数,通过反射API获取函数的各项属性(名称、返回值、参数、是否异步等),基于代码模板生成文件即可:

5

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
// GenerateNodes.cs
public static class GenerateNodes {
private const string CODE = @"using System.Threading.Tasks;
using Game.Graph;
namespace Generated.Graph.{Namespace}_ {
[CreateNodeMenuAttribute('{Title}')]
public class {ClassName} : {ParentNode} {
public override string Title {
get {
return '{Title}';
}
}
public override string Note {
get {
return '{Note}';
}
}
public override bool Async {
get {
return {Async};
}
}
{Defines}
protected override void Init() {
base.Init();
{Init}
}
public override object Run(Runtime runtime, int id) {
{Call}
return {ReturnRun};
}
public async override Task<object> RunAsync(Runtime runtime, int id) {
{CallAsync}
return {ReturnRun};
}
}
}
";
// 类型转换映射
public static Dictionary<Type, Type> TypeMapping = new Dictionary<Type, Type>() {
{typeof(System.Single), typeof(Number)},
{typeof(System.Int32), typeof(Number)},
{typeof(System.Boolean), typeof(Bool)},
{typeof(UnityEngine.Vector3), typeof(Vec3)},
{typeof(UnityEngine.Quaternion), typeof(Quat)},
{typeof(System.Object), typeof(Obj)},
{typeof(UnityEngine.Color), typeof(Col)},
};
[MenuItem("Tools/Generate Nodes")]
public static void Menu() {
ClearFolder();
Generates();
EditorUtility.RequestScriptReload();
}
private static void Generates() {
var types = typeof(NodeAttribute).Assembly.GetExportedTypes();
foreach (var t in types) {
var methods = t.GetMethods(BindingFlags.Static | BindingFlags.Public);
foreach (var m in methods) {
var attr = m.GetCustomAttribute<NodeAttribute>();
if (attr != null) {
GenerateNode(t, m, attr);
}
}
}
}
}

  当然注意某些变量类型要转换为对应的形式,这个下文会详解原因。

变量与黑板

  从上文代码看得出,由于通用性问题,变量的传递需要通过转换为object类型进行传递。而值类型涉及与object的互转时会出现装箱拆箱成本,导致GC Allow,这个是需要避免的。所以专门为一系列用到的值类型实现了对应的包装:

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
// Variable.cs
public class Variables<T> {
public T value;
public override string ToString() {
return this.value.ToString();
}
}
[Serializable]
public class Obj : Variables<object> {}
[Serializable]
public class Number : Variables<float> {}
[Serializable]
public class Bool : Variables<bool> {}
[Serializable]
public class Vec3 : Variables<Vector3> {}
[Serializable]
public class Col : Variables<Color> {}
[Serializable]
public class Quat : Variables<Quaternion> {}

  这也是上文提到节点代码生成时要进行类型转换的原因,如此基本避免了运行时会产生GC Allow,相关数据都聚集在蓝图资源上,没有即时创建的情况(哪怕返回值也是提前创建好了)。唯一需要注意的是在外界传变量的时候要使用包装类型:

1
this.runtime.SetVariable("count", new Number() {value = 3});

  说完变量部分再来聊聊黑板(Blackboard),这个词源于FlowCanvas,意思是变量配置池:

6

  这玩意的实现非常粗暴,把诸类型堆砌一块就完事了:

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
// Blackboard.cs
[Serializable]
public class Blackboard {
[Serializable]
public class Unit {
public string type;
public string name;
public Number number;
public Bool boolean;
public string text;
public Vec3 vec3;
public Col col;
public Object obj;
public object GetValue() {
if (this.type == typeof(Number).ToString()) {
return this.number;
}
else if (this.type == typeof(Bool).ToString()) {
return this.boolean;
}
else if (this.type == typeof(string).ToString()) {
return this.text;
}
else if (this.type == typeof(Col).ToString()) {
return this.col;
}
else if (this.type == typeof(Vec3).ToString()) {
return this.vec3;
}
return this.obj;
}
}
public Unit[] values;
}

  黑板可在运行时创建时传参,这个在上文也有体现,在运行时将会把黑板的数据批量赋值为变量:

1
2
3
4
5
6
7
8
9
10
11
12
// Runtime.cs
public void SetBlackboard(Blackboard blackboard) {
if (blackboard == null || blackboard.values == null) {
return;
}
foreach (var unit in blackboard.values) {
var value = unit.GetValue();
this.SetVariable(unit.name, value);
}
}

  变量的用途可谓相当广泛,除了在蓝图层面的获取/设置之外,还可以在函数登记时标记哪些参数会自动调用对应变量,减少连线的复杂度:

1
2
3
4
[Node("移动-锁定转向", "锁定移动时的方向切换", true, "body")]
public static void LockTurn(Body body, bool enable) {
body.lockTurn = enable;
}

  如上代码的body参数便会自动获取名为body的变量,无需填写:

7

  除此之外还可以在函数直接与变量交互,实现通过变量名达到索引的效果,减少连接复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Node("工具-创建定时器", "创建定时器,使用名称登记,可选择填写时间结束后的功能名", true, "rt")]
public static void NewTimer(Runtime rt, string val, string func) {
var timer = new Timer();
rt.variable[val] = timer;
if (func != "") {
timer.Func = () => {
rt.RunFunc(func);
};
}
}
[Node("工具-启动定时器", "启动定时器,填写时间与是否循环", true, "rt")]
public static void EnterTimer(Runtime rt, string val, float time, bool isLoop) {
var timer = rt.variable[val] as Timer;
timer.Enter(time, null, isLoop);
}

后记

  目前这套方案对于子蓝图(有明确的输入输出)的支持还不算完善,只支持正常的跨蓝图函数调用,但对于现状而言也算够用了。这就是造非普世的轮子的局限性,只满足自我的需求便足矣。故而这份实现仅供参考,不推荐直接使用(除非你的需求也完美贴合)。另外也感谢秃头鼓励师、烟雨迷离半世殇提供的相关参考与支持。

2021年度总结

Posted on 2021-12-31 | In Talk | | Visitors

  2021年终于也领来了终结,又到了写年度总结的时候了。今年在文章产出这块挺拉的,也就写了3篇,其中一篇还是游戏发布。回首下来颇为汗颜——没想到这么少。
  但实际上也有心态与见识的转变,随着工作的深入,我愈发觉得很多方案是需要时间的沉淀的——在开发当初可能觉得没问题的设计,随着后续落地迭代可能会完全变了样。这块在Gameplay会更明显:没有一定程度的业务去喂,很多方案只能说是做了而已。事实上哪怕目前文章写的东西也会因为后续情况而有所更改。所以哪怕今年整了不少活,也不太好意思写了。当然文章贵精不在多,本质便是为了分享。将东西沉淀一下后再发出,也是对质量的负责。
  除此之外今年最大的收货便是《拉维瓦纳》第一版Demo的发布,该项目可以说是我进军3D游戏开发的钥匙,也是最大的文章素材(笑)。与美术朋友一同将3D游戏相关的点滴一步一步走了过来,也在年底迎来了新的伙伴。如今也开启了下一版Demo的计划,它将包含了该游戏想要表达的完整核心玩法与相关功能。希望明年能够如期完成吧。
  同时也因为这个Demo,以及对接下来游戏行业的趋势判断,让我选择逃离了科韵路。在经过了一番面试酣战,迎来了职业发展的十字路口:Gameplay or Graphics。在综合各项情况考虑之后,选择了Graphics,选择了TA的道路。核心原因在于:个人的职业发展方向还是更偏全面,致力于能够独立做出游戏来。所以以补全自身的短板为佳。毕竟Gameplay对我而言开发游戏还算够用,并且通过项目可以持续实践精进。
  实际做了TA之后,我便不再对图程与TA的区别有所疑惑了:两者的本质差别不在于具体做的事情,而是担当的职责。TA的职责在于作为技术与美术的桥梁、为美术提供技术支持、甚至是作为一名会技术的美术。图程的核心职责还是在于引擎与渲染管线的开发与维护及其相关工具链,两者在做的事情上可能会相同,但其出发点是不一样的。也因为做了TA后,让我彻底明白不会选择往图程的方向了。TA较之Gameplay,会有一种与项目隔层纱的感觉,而图程更是在此之外:有种不像是在做游戏,而是做引擎/管线了。说实话我并不太喜欢这种感觉,目前已是能接受的极限了。
  来到漕河泾之后见识到了不少新的事物,能很明显感受到原神造成的影响,手游进入了军备竞赛的阶段。大家都需要赶紧跟上,做一些从未做过的事。无论是技术还是美术都有了新的要求,要说压力是肯定有的,虽然不是加班的压力(笑)。但与此同时也迎来了充分的进步,只能说好好迎接这个时代,做个弄潮儿吧。
  这次说的较之前些年有些多了,确实是有不少感触。以上便是本人的2021年度总结了,且待明年的Blog吧。
  无双草泥马
  2021.12.31

基于模型索引图生成植被与渲染的方案

Posted on 2021-08-29 | In Teach | | Visitors

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

前言

0
  看着这光秃秃草地,这不整点草上去像样么?之前采用的放置若干草堆装饰物点缀,效果实在捉急。于是这波决定采取GPU Instance在草地上都填满草,这可以说是PS4时代下半场以来的标配了:
1
  若是按照传统工作流,也许就是什么刷草编辑器走起了。可惜我们并没有这个人力去做这种事情,且对于草的放置需求也很明确:在草地及其周边铺满,并加上一定的疏密变化即可。恰好我们的地形渲染也是自制的,对于草地的所在实际上是有索引贴图控制的:
2
  如上图所示,红色的部分即是草地,那么事情就变得明了了:只要能获取到贴图红色部分的坐标等信息,转换一番不就能给GPU Instance使用了?事不宜迟,这就开干!

索引图转换

  在正式开动之前还需要注意一个问题:不是给草地硬生生的填满了草就完事的,需要具有一定的疏密变化,且草地与其他地块之间的过渡处也要多少有点草,不然搞得跟防火带似得。由此可见,直接使用地形的索引贴图并非是个好的选择。于是我选择开发了转换工具:
3
  最后导出为黑白图:
4
  其功能大致为:

  • 指定转换通道(Channel)
  • 通道外扩(Expand)
  • 通道值随机(Opacity Rate)
  • 随机选择位置展开挖孔(Dissipate)

  如此较之地形索引图,便有了更多的变化了。

三角面填充

  有了转换后的索引图,接下来便是生成了。根据uv采样贴图,得到模型对应的位置在Shader里倒是相当简单。可在CPU端进行则需多费一番功夫了:我们知道模型是由多个三角面组成,每个三角面对应三个顶点,每个顶点有着自己的坐标、法线、UV等信息。但问题在于我们无法直接获得三角面内部的这些信息,而这在Shader里是经过光栅化后所以才能取得的。换言之,我们需要自己整个软光栅。
  这软光栅听着玄乎,实际上在这里只是用一个单位值,在三角形内部步进,得到一系列的点罢了,详细算法可以参考市面上的三角形填充算法,其原理并非本文内容,不作复述。
  在获得了三角面内部的一个个点后,我们还需要得到它们的坐标、法线、UV等信息。这需要获取该点相对于三角面的三个顶点的距离权重,将相关信息插值得出。如何取得这个距离权重,这便又引申出另一个算法:三角形线性插值。这其实便是实现软渲染的必备一环,这俩算法组合拳便能得出这个经典的RGB插值三角形了:
5

多线程生成

  有了三角面填充坐标点后,便可考虑生成的事了,首先要明确生成的策略:

  • 根据填充单位值对模型的三角面生成若干点,每个点都会记录相应信息(坐标、法线、UV、通道值),通道值根据UV采样贴图而得
  • 另外会根据点的法线值检查所在角度是否合法(不希望墙壁之类的地方也生成),对于角度不合法的点会作废处理
  • 根据合法点的通道值累加,除以一个可配置的系数,决定该模型能填充的草数量(草数量 = 总通道值 / 系数)
  • 在总数量的前提下,随机挑选合法点种草,最后将草数据导出即可

  按照这套逻辑直接开干自然是没毛病,但性能这块实在捉急(模型的三角面太多、三角形填充以及贴图采样都耗时),然而这其实是个很适合并行化的作业(以三角面为单位),以及最后的草随机生成。那么便可引入JobSystem搞事了,整个工作流将会变成:

  • 配置模型及其转换后的索引贴图,配置相关生成参数(填充单位值、系数等)
  • 收集点信息:创建CollectJob,将相关数据传入(三角面、顶点、UV集合等),以模型三角面数为作业量,对每个三角面填充点,筛选掉不合法的点,导出到统一容器中
  • 遍历容器成员,将每个作业的成果导入到外部容器中
  • 生成草信息:创建GenerateJob,将相关数据传入(点集合、模型矩阵、随机算子等),以草生成数为作业量,随机选择点进行草数据的生成
  • 将草信息容器转换为数组导出

  需要注意的是,JobSystem只支持非托管类型资源。也就是说我们不能直接将诸如Mesh、Texture2D之类的资源直接传入使用,得做点转换工作:

1
2
3
4
5
6
7
8
var job = new CollectJob();
job.vertices = new NativeArray<Vector3>(mesh.vertices, allocator);
job.uvs = new NativeArray<Vector2>(mesh.uv, allocator);
job.normals = new NativeArray<Vector3>(mesh.normals, allocator);
job.triangles = new NativeArray<int>(mesh.triangles, allocator);
job.maps = new NativeArray<Color>(texture.GetPixels(), allocator);
job.texelSize = new Vector2Int(texture.width, texture.height);

  是的,就连容器都得使用Unity专门开发的Native Container,不可以用诸如数组、List之流。
  另外对于CollectJob的点数据导出,经过我的一番实践,想要在多个作业同时导出到同一个容器,并且没有冲突的话,最佳方式是创建一个NativeArray对象,为每个作业开辟一定的空间,保证每个作业之间的写入区域是相互独立的:

1
2
3
4
5
var job = new CollectJob();
// length代表作业数,TRIANGLE_MAX表示每个三角形的最大可填充数量
job.points = new NativeArray<Point>(length * TRIANGLE_MAX, allocator, NativeArrayOptions.UninitializedMemory);
job.counts = new NativeArray<int>(length, allocator); // counts记录每个作业的填充数
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
// i, j, k表示三角面对应的三个顶点索引
var p1 = new Vector2(this.vertices[i].x, this.vertices[i].z);
var p2 = new Vector2(this.vertices[j].x, this.vertices[j].z);
var p3 = new Vector2(this.vertices[k].x, this.vertices[k].z);
// 填充三角面
NativeList<Vector2> vertices = Triangles.FillTriangle(p1, p2, p3, this.precision);
this.counts[index] = 0;
// 遍历顶点,构建点数据
for (int n = 0; n < vertices.Length; n++) {
// 超出上限则取消
if (this.counts[index] > TRIANGLE_MAX) {
break;
}
// 获取相对三个顶点的距离权重
Vector3 rates = Triangles.GetTriangleRates(vertices[n], p1, p2, p3);
// 根据距离权重,获取点各项数据
Vector3 position = this.vertices[i] * rates[0] + this.vertices[j] * rates[1] + this.vertices[k] * rates[2];
Vector2 uv = this.uvs[i] * rates.x + this.uvs[j] * rates.y + this.uvs[k] * rates.z;
Vector3 normal = this.normals[i] * rates.x + this.normals[j] * rates.y + this.normals[k] * rates.z;
float weight = this.SampleTexture(uv);
float angle = Vector3.Angle(Vector3.up, normal);
// 拥有通道值且角度合法的点方可加入
if (weight > 0 && math.abs(angle) < this.angleMax) {
// 保证各作业的写入位置是独立的
var point = new Point() {position = position, uv = uv, normal = normal, weight = weight + weight * weight};
this.points[index * TRIANGLE_MAX + this.counts[index]] = point;
this.counts[index]++;
}
}

  如此最后便可将容器内的数据导出到外部容器了:

1
2
3
4
5
6
7
8
9
10
11
12
private List<Point> PackPoints(in NativeArray<Point> points, in NativeArray<int> counts) {
var list = new List<Point>();
for (int i = 0; i < counts.Length; i++) {
for (int j = 0; j < counts[i]; j++) {
var point = points[i * TRIANGLE_MAX + j];
list.Add(point);
}
}
return list;
}

  生成草数据部分的GenerateJob则更为简单,由于它的生成数量是一开始便定好的,所以构造作业量长度的容器即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
float weight = 0;
// 取得总通道值
foreach (var point in points) {
weight += point.weight;
}
// 获取生成数量
int count = (int)(weight / group.opacity);
count = count > points.Count ? points.Count : count;
var job = new GenerateJob();
job.units = new NativeArray<Unit>(count, allocator);
job.points = points.ToNativeArray(allocator);
// 草坐标与模型矩阵有关
job.matrix = group.gameObject.transform.localToWorldMatrix;
// Job内使用的随机数需要来自Unity.Mathematics
job.random = new Random();
job.random.InitState();
var handle = job.Schedule(count, 1);
handle.Complete();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void Execute(int index) {
// 从points随机选择生成unit
int idx = this.random.NextInt(this.points.Length);
Point point = this.points[idx];
this.units[index] = this.NewUnit(point);
}
private Unit NewUnit(in Point point) {
// 构建矩阵,与模型矩阵结合,获得正确的坐标点
var matrix = new Matrix4x4();
matrix.SetTRS(point.position, Quaternion.identity, Vector3.one);
matrix = this.matrix * matrix;
// 为了节省内存,草的数据实际上只有坐标,其余部分在Shader随机生成
var unit = new Unit() {
position = new Vector3(matrix.m03, matrix.m13, matrix.m23)
};
return unit;
}

渲染支持

  有了数据之后,接下来便是将它们渲染出来了:我们会使用一个MonoBehavior,在LateUpdate时调用Graphics.DrawMeshInstancedIndirect进行草的批量绘制。为此需要两个关键的Compute Buffer:argsBuffer与unitBuffer,一者作为绘制API的参数,提供绘制模型的相关信息以及数量。另者即是草的数据集封装,传入材质属性供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
[ExecuteAlways]
public class GrassSolution : MonoBehaviour {
[Serializable]
public struct Unit {
public Vector3 position;
}
public Mesh mesh;
public Material material;
private ComputeBuffer argsBuffer;
private ComputeBuffer unitBuffer;
private void InitUnits() {
// ComputeBuffer的核心数据属于非托管资源,需要手动释放
if (this.unitBuffer != null) {
this.unitBuffer.Release();
}
// 将草数据集传入
this.unitBuffer = new ComputeBuffer(this.units.Length, sizeof(Unit));
this.unitBuffer.SetData(this.units);
}
private void InitArgs() {
// 构建所需参数
var args = new uint[] {
this.mesh.GetIndexCount(0),
(uint)this.units.Length,
this.mesh.GetIndexStart(0),
this.mesh.GetBaseVertex(0),
0
};
if (this.argsBuffer != null) {
this.argsBuffer.Release();
}
// 传入参数信息
this.argsBuffer = new ComputeBuffer(1, sizeof(uint) * 5, ComputeBufferType.IndirectArguments);
this.argsBuffer.SetData(args);
}
protected void Start() {
// 初始化ComputeBuffer
this.InitUnits();
this.InitArgs();
// 将unitBuffer数据传入Shader
this.material.SetBuffer("_Units", this.unitBuffer);
}
protected void LateUpdate() {
// 指定模型与材质,每帧绘制
Graphics.DrawMeshInstancedIndirect(this.mesh, 0, this.material, this.bounds, this.argsBuffer);
}
}

  到了Shader层面,还需要对接草的坐标信息,才能渲染到正确的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义草的数据结构
struct Unit
{
float3 position;
};
// ComputeBuffer容器,对应this.unitBuffer
StructuredBuffer<Unit> _Units;
Varyings Vert(Attributes input, uint instanceID : SV_InstanceID)
{
// 根据instanceID获取对应的草数据
Unit unit = _Units[instanceID];
// 赋予坐标
input.positionOS.xyz += unit.position;
// ...
}

视矩剔除

  如此一来草的渲染自然不是问题了:
6
  但可以看到,整个场景一眼望去,草的数量还是不少的。并且在视野内能看到的充其量就一个地块而已,为了保证性能,还需要引入视矩剔除。
  本方案采取的视矩剔除方案相当简单:在空间中根据草所在之处划分出一个个格子,每个格子记录所包含草的索引集以及格子的中心坐标,就像这样:
7
  格子的生成可在先前的草数据生成后再追加一步:遍历草坐标,将其划分到对应的格子对象中,将格子数据也一并导出即可。但仅此而已还不够完美:这样需要让格子对象存储所辖草的索引集,数据量较大。所以我们可以先将草数据分类到不同的格子容器中,最后将格子容器的草数据一一重写回草的容器中,以此确保每个格子所辖的草索引是连续的,如此只需要为每个格子记录索引起始值以及数量即可:

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
private List<Cell> GenerateCells(List<Unit> units) {
var unitsMap = new Dictionary<Vector3Int, List<Unit>>();
// 将每根草划分到对应的格子容器中
foreach (var unit in units) {
var pos = unit.position;
var cellPos = ToCellPos(pos);
if (!unitsMap.ContainsKey(cellPos)) {
unitsMap.Add(cellPos, new List<Unit>());
}
unitsMap[cellPos].Add(unit);
}
// 清空草容器
units.Clear();
// 根据格子容器将草重新写回草容器中
foreach (var iter in unitsMap) {
foreach (var unit in iter.Value) {
units.Add(unit);
}
}
// 构建格子数据集
var cells = new List<Cell>();
var count = 0;
// 构建格子数据,记录格子的草索引起始、数量、格子中心坐标
foreach (var iter in unitsMap) {
var cell = new Cell() {
center = ToCellCenter(iter.Key),
begin = count,
count = iter.Value.Count
};
cells.Add(cell);
count += iter.Value.Count;
}
return cells;
}

  有了数据之后,接下来便是剔除了:

  • 构建格子的ComputeBuffer(cellBuffer),以及构建一个存储草的可视索引集ComputeBuffer(visibleIdBuffer)
  • 编写ComputeShader,以格子数量为作业量,判断每个格子是否在摄像机视矩体内,若存在则将格子所辖的草索引传入visibleIdBuffer中
  • Compute Shader运行完毕后,将visibleIdBuffer的成员数通过ComputeBuffer.CopyCount传入argsBuffer的第二项(用于控制渲染数量)
  • 最后在Shader通过visibleIdBuffer[instanceID]获取草的索引值,由此间接取得草的数据
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
[Serializable]
public struct Cell {
public Vector3 center;
public int begin;
public int count;
}
// 构建cellBuffer
private void InitCells() {
if (this.cellBuffer != null) {
this.cellBuffer.Release();
}
this.cellBuffer = new ComputeBuffer(this.cells.Length, sizeof(Cell));
this.cellBuffer.SetData(this.cells);
}
// 构建visibleIdBuffer
private void InitVisbleId() {
if (this.visibleIdBuffer != null) {
this.visibleIdBuffer.Release();
}
// 它的最大长度为草数据的长度,ComputeBufferType.Append表明它是个可填充容器
this.visibleIdBuffer = new ComputeBuffer(this.units.Length, sizeof(int), ComputeBufferType.Append);
}
private void Cull() {
// Compute Shader一次并行32组
int patch = Mathf.CeilToInt(this.cells.Length / 32.0f);
// 获得当前视图下的投影矩阵
var camera = Camera.main;
var vp = camera.projectionMatrix * camera.worldToCameraMatrix;
// 清空容器
this.visibleIdBuffer.SetCounterValue(0);
// 传递所需数据,开始作业
this.shader.SetBuffer(id, "_VisibleIds", this.visibleIdBuffer);
this.shader.SetBuffer(id, "_Cells", this.cellBuffer);
this.shader.SetInt("_Count", this.cells.Length);
this.shader.SetMatrix("_VPMatrix", vp);
this.shader.Dispatch(id, patch, 1, 1);
// 将visibleIdBuffer的数量传递给argsBuffer,决定渲染数量
ComputeBuffer.CopyCount(this.visibleIdBuffer, this.argsBuffer, sizeof(uint));
}
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
// Compute Shader
#pragma kernel CSMain
struct Cell
{
float3 center;
int begin;
int count;
};
StructuredBuffer<Cell> _Cells;
AppendStructuredBuffer<int> _VisibleIds;
float4x4 _VPMatrix;
int _Count;
[numthreads(32, 1, 1)]
void CSMain (uint3 groupID : SV_GroupID, int index : SV_GroupIndex)
{
// 获取当前作业对应的格子id
int n = groupID.x * 32 + index;
// 确保处理的格子是在范围内的
if (n < _Count)
{
// 获取格子中心坐标
float3 posWS = _Cells[n].center;
// 将格子坐标转换到裁剪空间
float4 absPosCS = abs(mul(_VPMatrix, float4(posWS, 1.0)));
float range = absPosCS.w;
// 判断格子坐标是否在视矩体内
// 由于格子比较大,肯定会有一部分元素在视矩体之外,所以要扩大点范围
if (absPosCS.x <= range * 1.5 && absPosCS.y <= range * 1.6)
{
// 将格子所辖的草索引加入容器中
for (int i = 0; i < _Cells[n].count; i++) {
_VisibleIds.Append(_Cells[n].begin + i);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
// Shader
StructuredBuffer<Unit> _Units;
StructuredBuffer<int> _VisibleIds;
Varyings Vert(Attributes input, uint instanceID : SV_InstanceID)
{
// 间接获得草数据
int idx = _VisibleIds[instanceID];
Unit unit = _Units[idx];
}

  如此,将Cull函数放到适当的场合运行,草的剔除便算完成了:
8

渲染效果

  终于可以聊点开心的东西了,毕竟渲染效果这种东西是最直观的了,直接上效果:
9
  本方案使用的草属于面片草,它通过一个面片模型,选择若干草的图片,使用AlphaClip剔除透明度进行渲染:
10
  之所以使用AlphaClip而非正统的2D半透明AlphaBlend,是因为在不写入深度的前提下,Instance渲染的次序需要自己把控。然而在使用了剔除之后还要兼顾排序属实有点麻烦,并且哪怕如此也无法达到像素级别的排序:
11
  那么只好AlphaClip顶硬上了,这样的缺点是边缘会出现狗牙,连MSAA都救不了:
12
  当然狗牙也就狗牙了,游戏视角下看起来凑合的话就还好,在不考虑太过风骚的操作下,可通过提升贴图精度以及提高渲染分辨率缓解,就这样吧。
  渲染的本身部分首当其冲的便是随机的缩放与旋转,以及颜色了。为了节省内存,在CPU端提供的数据只有坐标,其余部分则在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
int _ColorMax; // 颜色上限
float2 _ScaleW; // 缩放宽度的范围
float2 _ScaleH; // 缩放高度的范围
float2 _AngleY; // 旋转Y轴的范围
float2 _AngleXZ; // 旋转XZ轴的范围
float4 _Colors[5]; // 颜色数组
// 构建矩阵
float4x4 MakeMatrix(Unit unit, uint instanceID) {
float3 pos = unit.position;
// 随机取值
float scaleW = lerp(_ScaleW.x, _ScaleW.y, Random(float2(pos.x, pos.y)));
float scaleH = lerp(_ScaleH.x, _ScaleH.y, Random(float2(pos.y, pos.z)));
float angleY = lerp(_AngleY.x, _AngleY.y, Random(float2(instanceID, pos.z)));
float angleXZ = lerp(_AngleXZ.x, _AngleXZ.y, Random(float2(pos.x, instanceID)));
// 确定缩放与旋转,生成矩阵
float3 scale = float3(scaleW, scaleH, scaleW);
float3 angle = float3(angleXZ, angleY, angleXZ);
float4x4 mat = SetTRS(pos, scale, radians(angle));
return mat;
}
// 随机颜色
float4 GetColor(Unit unit, uint instanceID) {
float3 pos = unit.position;
float rate = Random(float2(instanceID, pos.y));
int index = lerp(0, _ColorMax - 1, rate);
return _Colors[index];
}
Varyings Vert(Attributes input, uint instanceID : SV_InstanceID)
{
int idx = _VisibleIds[instanceID];
Unit unit = _Units[idx];
float4x4 mat = MakeMatrix(unit, idx);
// 实装草的坐标、缩放、旋转、颜色
input.positionOS.xyz = mul(mat, input.positionOS).xyz;
output.color = GetColor(unit, idx);
// ...
}

  贴图的随机化也是以此类推,贴图使用TextureArray,随机取得一个贴图索引后采样相应贴图。对于随机的算法选择参考各类噪声算法即可。
  在做完以上工作后也只有初步的样子而已,仍需进一步加料:
13
  目前的效果还是太平了,我们给它来加个渐变:

1
2
3
4
5
// 赋予矩阵前的y坐标
float py = input.positionOS.y;
float gradient = lerp(_Gradient.x, _Gradient.y, py);
output.color.rgb = output.color.rgb * gradient;

14
  这样多少有点意思了,但颜色给人总体到处都是差不多的,缺乏总体变化感,也许在其他做法里会考虑加入一张全局的颜色贴图,但我们做了全局光照,不如……?

1
2
3
4
5
6
7
float gradient = lerp(_Gradient.x, _Gradient.y, py);
// 采样GI底部颜色
float3 gi = GIBottom(vertexInput.positionWS);
// 控制GI颜色比例
output.color = float4(lerp(output.color.rgb, gi, 0.25) * gradient, 1);

15
  不错不错,而且出于草的位置关系以及性能考虑,我们只需要采样AmbientCube的底部方向的颜色即可。可谓一次有机的结合了。

后记

  本方案实际仍有不少细节,限于篇幅,只能写到这里了。这应是我有史以来写的最长的一篇文章了吧,但实在是不想拆成多篇来写了。本文限于篇幅,诸多细节未能列出,纯当外行看个热闹,内行看点门道吧。

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

Posted on 2021-07-17 | In Teach | | Visitors

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

前言

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

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

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

分析

  根据安柏霖一文大致可以看出,Irradiance Volume是一种将场景划分为多个区域,每个区域记录关键信息,最终应用于区域内的对象的一种全局光照方案。这听起来很像Light Probes,只不过Light Probes是逐对象的(整个模型着色),而Irradiance Volume能做到逐顶点/片元。
  经过一番研究,参考了论文、半条命2、AMD、COD等诸多资料后,得出一点:这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要高的(采样三张图),一般项目估计也用不上,仅供一乐。

2020年度总结

Posted on 2020-12-31 | In Talk | | Visitors

  又到了写年度总结的日子了,蓦然回首,发现这已经是写Blog的第四年了。较之去年,今年可谓相当高产了:12个月里产出11篇,四舍五入就是月刊了(笑。可见今年相当充实,每个月都整了新活。工作方面也已过一年,特作总结,以为归纳。
  今年内容输出上最大的变化就是吹响了进军3D的号角,以完成一款俯视角3D ARPG Demo为目标,对各项技术专题展开了研究。特别在图形效果方面,由于现实的需要,已经到了不得不掌握的时候。结果没想到学习起来相当顺利,以前一直觊觎而不得的知识在现实需求的推进下顺利掌握,果然还是得知行合一呀。
  当然能够如此顺利,个人想来也与前些年对这块一直觊觎的成果吧。毕竟概念的东西还是翻来覆去看过不少,只欠一次实践的机会罢了。现在想来也是颇为后悔,若是当年抽出点时间写写渲染器啥的那就更好了。不过也只是想想而已,没有明确的需求与觉悟下,只凭一时兴起是难以坚持下去的,只能把目光放在未来了。
  接触这块对我来说算是进入一个新的领域,也因此有幸结识了不少朋友。通过相互交流学到了不少,增长颇多见闻。也因此让我正视以技术美术作为职业的可能性,目前按个人标准看来还是不行的:效果实践的广度仍是不足,对于DCC软件的掌握、美术工作的流程与体验还不够深刻,对渲染管线、图形原理的认识与实践也不够。总体而言感觉只是初中级水平,还有很长的路要走。
  世人常言TA分程序向与美术向,尽管我并不是很认可:我认为懂美术的TA才是真的TA,而所谓程序向TA只是人才不足的权宜之计,或者是图形程序的过渡罢了。而我确实没有动力与天分去成为美术,可见若是要继续往这条路走下去的话,就得靠拢图形程序了吧。当然从现状而论,作为Gameplay程序兼职TA与策划也许是个不错的组合拳,这也算是独立制作出身的优势了吧(笑
  照目前来看的话,下一年Demo的重心将会回归Gameplay,文章产出应该会少很多吧(Gameplay的东西没有经过时间的验证就没有说服力)。当然TA这块也会继续走下去,还是挺有意思的,希望能有更多结合实际的机会吧。
  至于工作相关的内容限于篇幅与细节便在此略过不谈,以上便是本人的2020年度总结了,且待明年的Blog吧。
  无双草泥马
  2020.12.31

地形系统挣扎录——从Blender到Unity

Posted on 2020-12-14 | In Teach | | Visitors

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

前言

  之前由前篇决定场景的制作模式为Tile流,这种面片堆砌的流派对于方方正正的场景(如室内)很有效。但是对于如山地草原一般的场景就很难受了:
0
  可能粗看下去感觉还行,实际上是不禁细看的,地形是在方方正正的基础上揉捏而成,可以在斜边处可以到明显的采样变形,且草地边缘的石边是手动暴力贴上去的,制作起来非常的耗时且嗨奴。经此一役后美术决定引入业界更通用的场景制作方案——Terrain流:
1
  如上图所示,所谓Terrain流就是非常常见的那种在场景编辑器对模型揉揉捏捏塑形,涂涂抹抹上色的制作流派。对于制作地形复杂、贴图混杂的场景可谓相当受用。那么按理来说直接使用现有的地形编辑工具不就好了?——若是那么简单便没有本篇喽。
  首先经过一番实验后发现,由于美术风格、建模习惯等因素,决定采用传统建模方式,而非这些场景编辑器惯用的揉捏平面生成高度图。如此一来这些场景编辑器便都Pass,将目光放到了传统建模软件·Blender上……于是便有了本文的副标题:从Blender到Unity。Unity版本为2019.4(URP),Blender版本为2.9。

牛刀小试

  由于这波算是造轮子了,没得现有的套件可蹭,所以还是先整点最基本的实现吧。在抛开建模那块,Terrain流的贴图着色说白了就是由1张索引贴图+若干张地形贴图组成,通过工具绘制索引贴图,最终根据索引采样对应的地形贴图,实现贴图混杂的显示效果:
2
3
  大致Shader实现如下:

1
2
3
4
5
6
7
8
9
half4 Frag(Varyings input) : SV_Target
{
half4 mask = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, input.uv);
half4 color1 = SAMPLE_TEXTURE2D(_TerrainMap1, sampler_MaskMap, input.uv) * mask.r;
half4 color2 = SAMPLE_TEXTURE2D(_TerrainMap2, sampler_MaskMap, input.uv) * mask.g;
half4 color = color1 + color2;
return color;
}

  可见实现原理并不算复杂,将贴图的每个通道(RGBA)作为贴图的透明度值显示即可,但一切才刚刚开始……

图集 OR 纹理数组

  首先第一个问题便是贴图的管理方式:根据上文代码可以看出,目前的地形贴图是一张张独立的存在。那么就会变成有多少张地形纹理就要开多少个口了,不利于环保且哈批。业界相关流行的解决方法有图集(Atlas)与纹理数组(TextureArray)两种。
  所谓图集便是将各种贴图整合进一张大图里,按偏移采样,是很常见的做法:
4
  相关的采样方法可以参考冯乐乐的地形纹理合并,大致如此:

1
2
3
4
5
6
7
8
9
10
11
12
half4 SampleIndex(int index, half2 pos) {
half2 uv = frac(pos) * 0.484375 + 0.0078125;
int lines = floor(index / 2);
half2 uv2 = half2(index - lines * 2, lines);
uv2.y = 1 - uv2.y;
uv += uv2 / 2.0;
half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
return color;
}

  大致思路为根据要采样的贴图索引(index),结合世界坐标的小数点(pos),得到对应的uv坐标。其中这里的0.484375、0.0078125为采样收缩的魔法数字(0.0078125=1/128,128位图集的宽高,0.484375=0.5-0.0078125),这么做的理由与瓦片地图一致:由于贴图的密集性,线性采样两者的贴图边缘会产生混色现象,注意红色框选的部分:
5
  为了解决这个问题,于是选择人工收缩了采样范围。但相对而言,在镜头放大后还是能看得出贴图之间并非是严丝合缝的,毕竟采样已经不连续了,算是一个不大不小的缺陷吧。
  而纹理数组则是个PS4时代以来的新玩意,在Unity官方文档中明确了支持的平台,可见一般手机设备对此不见得能够支持。但我们做的是PC端便无所谓了,用起来用起来!
  纹理数组的原理很简单:一次性把多张贴图打包成新的数据,与一般贴图用法无异,只是采样的API有所不同,需要指定index。这么一来可就我可就不困了呀,拿先前的Shader改造下对比:

1
2
3
4
5
6
7
8
9
half4 Frag(Varyings input) : SV_Target
{
half4 mask = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, input.uv);
half4 color1 = SAMPLE_TEXTURE2D_ARRAY(_BaseMap, sampler_BaseMap, input.uv, 0) * mask.r;
half4 color2 = SAMPLE_TEXTURE2D_ARRAY(_BaseMap, sampler_BaseMap, input.uv, 1) * mask.g;
half4 color = color1 + color2;
return color;
}

  较之图集那花里胡哨的采样方式真是爽快多了,而且还没有合缝问题。美中不足在于Unity并没有提供直接的创建纹理数组的方法,需要自己撸一个,限于篇幅便不再列出,给个参考便可。

传统UV OR 世界坐标

  从图集的采样算法可以看出是不便使用模型本身的uv的,而是要用世界坐标作为驱动代替。但现在决定使用纹理数组,那么这便成了个问题。使用传统UV采样在多数情况下并没有什么问题,但是在这种情况下便暴露了:
6
  如图所示,山峰出现了很夸张的拉伸现象,这是UV划分精度不足导致的(整个山峰的面采样了一张贴图),对此可以通过划分UV解决,但规模一旦上去后,这会给美术带来不小的负担。那么来对比下世界坐标的情况:
7
  虽然在采样上有点小瑕疵,但的确是好多了。由于世界坐标是三维的,而纹理采样是二维的。若是只按某两个维度进行采样,在某些面必然会发生问题:
8
  于是我们可以根据法线判定面的朝向以决定使用世界坐标的哪两个轴,但在某些斜面下实际上需要用到两个平面维度的结果混合。于是干脆一步到底,根据法线三个轴的值决定三个平面维度(zy、xz、xy)的混合度,是为三向贴图(Tri-planar Mapping):

1
2
3
4
5
6
7
8
9
10
11
12
half4 Frag(Varyings input) : SV_Target
{
half3 weight = pow(abs(input.normal), _BlendSharpness); // _BlendSharpness可增大混合效果
weight = weight / (weight.x + weight.y + weight.z); // 质量守恒
half4 color = 0;
color += SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.position.zy) * weight.x;
color += SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.position.xz) * weight.y;
color += SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.position.xy) * weight.z;
return color;
}

  但由上图也看得出来,在某些情况下它并非是完美的(或者没必要)。且性能消耗大,采样次数翻了三倍。由此可见两者皆有其使用场合,故决定通过分支开关控制两者的切换。当然最理想的情况自然是让美术好好分UV(

界限突破

  讨论了以上两个问题后,还有一个很明显的问题:若是使用RGBA四个通道代表四种地形贴图的透明度,那么首先可使用的地形贴图数量就太少了,并且需要同时对这四张贴图进行采样,若是加上三向贴图更是达到了恐怖的12次采样!这绝对是不可接受的,仔细想来,实际上多数情况只会有两种贴图混合,加上Demo的像素风格更是不会发生太多的混合现象。那么便可以改成同一像素最多采样两种地形贴图的方案了:

1
2
3
4
5
6
7
8
9
half4 Frag(Varyings input) : SV_Target
{
half4 mask = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, input.uv);
half4 color1 = SAMPLE_TEXTURE2D_ARRAY(_BaseMap, sampler_BaseMap, input.uv, mask.r);
half4 color2 = SAMPLE_TEXTURE2D_ARRAY(_BaseMap, sampler_BaseMap, input.uv, mask.g);
half4 color = lerp(color1, color2, mask.b);
return color;
}

  如上所示,新方案下RG通道代表地形贴图的索引值,B通道作为两者颜色的混合度(255则完全显示color1,反之亦然),如此便可只用到三个通道的前提下支持多种地形贴图,同时只采样2次,哪怕加上三向贴图也算在可以接受的程度了。

在Blender的战斗

  以上Shader看着简单,到了Blender这边的Shader开发那麻烦可大了。由于可视化的因素,我们需要用特定颜色代表对应的索引值,且要选择通道明确的颜色,也就是:黑(RGB)、红(R)、绿(G)、蓝(B)、黄(RG)、紫(RB)、青(GB)。鉴于黑色的特性,将之作为地形的默认底色。其余颜色根据深浅与黑色进行混合。其中的关键便是如何识别单/双通道颜色的有效性,对此我选择双通道颜色值必须相差小于10才有效,反之则是值大的通道有效。
  这看着也不算很复杂,可恶的是Blender的新版渲染引擎Cycles并不支持代码形式的Shader(OGL),只支持连连看。而老板渲染引擎Eevee只支持在渲染模式下看到结果,这样便达不到实时修改预览的效果了。于是乎化身为毛线团战士:
9
10
11
  主要Blender内置的节点并不支持分支判断,于是只能老老实实把每种颜色的处理都弄上去加一块,使用比较节点取得0值来屏蔽不该显示的部分。除此之外便是贴图资源不能作为参数值存在,只能老老实实创建贴图节点在外头进行填写,可由于三向贴图的加入,每种贴图还得手动填三次,算上总和一共是21种了……
12
  不过所幸效果还是不错的,以后再有这样也许可以考虑看看源码了……
13

脚本转换

  在Blender绘制的贴图还无法直接用于游戏,毕竟游戏可顶不住这样的Shader写法。于是便需要一Python脚本将之转换为游戏Shader可直接食用的贴图。实现大致思路与Blender Shader无差,只是在CPU端便可暴力条件判断了,爽歪歪:

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
def convert_index(color):
a = get_max_channel(color)
b = get_mid_channel(color)
if a == 0:
return INDEX_MAP['black'], 0, None, None # Black
if a - b < 10:
v = a + b
alpha = (a + b) // 2
if v == color[0] + color[1]:
return INDEX_MAP['yellow'], alpha, 0, 1 # Yellow
elif v == color[0] + color[2]:
return INDEX_MAP['purple'], alpha, 0, 2 # Purple
elif v == color[1] + color[2]:
return INDEX_MAP['azure'], alpha, 1, 2 # Azure
else:
if a == color[0]:
return INDEX_MAP['red'], a, 0, None # Red
elif a == color[1]:
return INDEX_MAP['green'], a, 1, None # Green
elif a == color[2]:
return INDEX_MAP['blue'], a, 2, None # Blue
return INDEX_MAP['black'], 0, None, None # Black

  当然为了图片的可视化,索引不会按照原值输出,而是return math.floor(index * 255 / 6),自然游戏Shader那边也要做相应解码处理。
  除此之外,由于索引贴图的设计因素无法达到传统素材做法的线性采样效果,只能如二图那般马赛克:
14
15
  为了减少这种马赛克的感觉,便使用脚本判断像素周边有多少不相同索引的像素,以此按比例减少透明度:
16
  另外诸如设置图片输出宽高,指定颜色对应的索引值等功能限于篇幅在此便不展开了。

后记

  本篇的内容有点超乎我想象,也到了收获的时候了,最后来看看成果吧:
17
  在本次地形系统的调研中断断续续挣扎了一个半月,可谓把各种坑都踩了一遍。属实离谱,当然也与最近项目较忙有关。目前看来效果与先前并无太大差距,主要在创作模式多了新的道路,相关美术效果仍会持续优化,期待由此开端最终会演进到怎样的程度呢?

Visual Effect Graph魔改录

Posted on 2020-10-08 | In Teach | | Visitors

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

前言

  前文提到关于粒子想实现一些东西,本篇便来还愿了。Demo使用的粒子系统并非传统的Particle System,而是基于GPU的Visual Effect Graph,下文简称VEG。
  VEG的问题与近年来Unity新出的模块一样:有些功能就做了个壳没下文了,我需要的光照探针功能便是如此。尽管VEG在编辑器做了支持,但实际上功能是没实现的:
0
  所幸Unity近年来的模块有个好处:开源,通过Package Manager下载的模块其本身已经是开源可修改的,若是对其进行一番研究搞不好就能自己实现想要的功能,不必再苦等官方?答案是没错的,我如愿以偿为VEG增加了三个功能,并将之开源了。

研究

1
  根据上图可以发现,VEG特效的构成实际上就是Compute Shader + 一般Shader,它们是由VEG编辑器生成的,双击可查看生成后的代码。由此可见,VEG实际上与Shader Graph差不多,都是通过编辑器进行创作,最后生成相应的代码。
  由于先前的经验,这类源码的入手点自然是找到编辑器定义属性的地方。比如设置Shader效果的Output部分,顺藤摸瓜便很快找到了:
2
3
  以此类推,便找到了生成Shader的相关处:
4
5
6
  如此路线便打通了,以上得到了两个重要的信息:每个不同的Output类型(Quad、Cube、Mesh……)都会有对应的Shader模板,表示我们加料时也要考虑到多种类型的情况。其次是区分了Legacy与Universal两个文件夹,可见分别对应Built-in与URP管线,毕竟他们使用的Shader库并不相同。如此VEG能在老管线使用,并且在HDRP有新功能就能理解了。

接受阴影

  目前对于VEG最迫切需要的功能便是接受阴影了,无论实时阴影还是光照探针,VEG目前都是没有的。好在按照上文的路线通读一番后,发现追加接受阴影还是蛮容易的。
  顺着上文继续走下去,看到了主Pass下有个名为VFXApplyColor的插入片段在各类型的主Pass都有用到,可见是通用的着色过程。那么在这里加入阴影着色正好:
7
8
9
  通过搜索其他函数的出处找到了Shaders/RenderPipeline/Universal/VFXCommon.hlsl,到了这里便是熟悉的Shader编写环境了,Include的文件都是URP那套,写就完事了:

1
2
3
4
5
6
7
8
float4 VFXApplyShadow(float4 color, float3 posWS) {
float4 shadowCoord = TransformWorldToShadowCoord(posWS);
Light mainLight = GetMainLight(shadowCoord);
color.rgb *= mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation;
return color;
}

  当然这函数还不能直接用,根据观察其他函数还会在Shaders/VFXCommonOutput.hlsl针对VEG的环境做一层封装然后写到VFXApplyColor里即可:

1
2
3
4
5
6
7
8
9
10
11
12
float4 VFXApplyShadow(float4 color,VFX_VARYING_PS_INPUTS i)
{
#if USE_RECEIVE_SHADOWS
#if defined(VFX_VARYING_POSWS)
return VFXApplyShadow(color, i.VFX_VARYING_POSWS);
#else
return VFXApplyShadow(color, (float3)0); //Some pipeline (LWRP) doesn't require WorldPos
#endif
#else
return color;
#endif
}

10
  当然还要提供阴影相关的multi_compile,搜索代码得知加在VFXPassForwardAdditionalPragma片段,然后就可以看效果啦:
11
12

光照探针

  接受实时阴影算是完成了,但还要考虑到烘焙阴影的情况,于是对于光照探针的支持也要考虑到。VEG关于光照探针的外围支持已经完备(根据光照设置、探针设置开启相关KEYWORD),欠缺的只是Shader相关的部分。
  在不考虑GI,按照我在模型渲染一样的做法的前提下,只需要在VFXApplyShadow加点料即可:

1
2
3
4
5
6
7
8
9
10
11
12
float4 VFXApplyShadow(float4 color, float3 posWS) {
float4 shadowCoord = TransformWorldToShadowCoord(posWS);
Light mainLight = GetMainLight(shadowCoord);
#if defined(_MIXED_LIGHTING_SUBTRACTIVE)
mainLight.distanceAttenuation = lerp(GetMainLightShadowStrength(), 1, saturate(mainLight.distanceAttenuation));
#endif
color.rgb *= mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation;
return color;
}

  与之前的做法一样,distanceAttenuation在光照探针下会变成光照计算的着色值,将之锁定在阴影强度-1之间即可。然后烘焙阴影,设置特效组件开启光照探针即可看到效果了:
13
  比较遗憾的是,光照探针的计算是以GameObject为准的,而非以每个粒子为准,这也是没办法的事,只能尽量避免露馅了。
  关于阴影还剩最后一个点没有做:在编辑器的开关设置,这个模仿其他属性添加变量,并在VFXParticleOutput.cs的additionalDefines变量里添加相关KEYWORD,最后在Shader里做判定即可:
14
15

水面问题

  目前粒子特效在水面上显示会出现很明显的层次错误:
16
  火焰实际上并没有进入水里,但是看着却变蓝了。这是因为水面的渲染时机在所有对象之后,并使用CameraColorTexture进行显示。而此时火焰已在Texture里了,于是与水重叠的部分便被水渲染处理了。
  基于这个问题可以很迅速的想到解法:利用RenderFeature的RenderObjects可以新建渲染批次,并将粒子主Pass的LightMode改为新的批次即可:
17
18
  试了下效果,问题的确解决了,但是……
19
  但是水里的火焰消失了,这也是当然的,毕竟在水面渲染之前,火焰还没渲染呢。进一步思考后想到了个绝妙的方案:为粒子新增一个与主Pass一模一样的Pass,也就是目前的ParticlePost,保留原本的主Pass,将LightMode还原。当然只是如此的话会出现一个粒子渲染两次重叠起来的情况,而我们可以让ParticlePost只在与水面重叠时显示,这样便可解决重叠问题了。
  为此我们要用上模板测试,让水面写入特定的模板值,然后在ParticlePost做判定(假设水面写入值为2):
20
  限于篇幅,关于添加Pass的做法还请自行查阅源码。看看效果吧:
21
  很棒很棒,这下算是解决粒子与水面的问题了。尽管在水面时事实上是有重叠的,看着效果还行就凑合吧。由此延伸可以说是半透明对象与水面的一种解决方案了。
  最后是编辑器相关,只能写死数值显然是不好的,这里我使用了VEG提供的定义代码段功能,在VFXParticleOutput.cs的additionalReplacements变量添加,并在模板里调用即可:
22
23

后记

  VEG较之传统粒子最大的优势便是运算放在GPU以及开放源码可供修改了吧,可惜必须在支持Compute Shader的设备上才能运作。这一点注定它在手游里很难用得上了,只能期待老手机早日淘汰了……

在URP实现水面效果

Posted on 2020-09-06 | In Teach | | Visitors

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

前言

  Demo的场景也到了做水面的时候了,在涉及技术之前首先要确定的是美术表达:当然大体上也就是卡通水与写实水的抉择,最终决定是做出《伊苏:起源》
那样的写实水(注重扭曲、透视、无形变),并在此之上现代化。
0

上色

  首先我们先找个小池子作为试验场地——这样利于观测,那么很显然密室场景的熔岩就可以暂退了:
1
  水面的本质很简单,它就是个面片而已(不论海浪)。最直接的第一步自然是上色:
2
  上色之后自然是透视,把材质设置为Transparent,调整下透明度:
3
  很好,其实对于一些游戏的低画质,这个水面已经是成品了。当然这也太捞了,继续演进——

扭曲的准备

  对于水面效果的重点自然是扭曲了,处于水中的部分都会因为光的折射而变化。当然我们实际做起来并不会遵照这些大道理,看着是那么回事就得了(图形学第一定律)。最简单的做法自然是把对象渲染完毕后的画面截获,水面材质再选取合适的画面部分显示,并基于此加入扭曲——
  对于Built-In管线而言,想做到这点使用GrabPass即可,这方面的实现在《Unity Shader入门精要》已有详细做法。可是由于其设计不符合SRP的哲学,在URP已经被毙了,于是我们只能另寻他法了。
  当然实际上也没那么麻烦,思想已经有了,找到对应的实现方法即可:对象渲染完毕后的画面生成在URP可以通过管线设置文件勾选Opaque Texture实现,然后便可在Shader声明_CameraColorTexture调用。
4
  当然仅仅如此会有个问题:此图的生成时机是渲染所有非透明(Opaque)对象后,对于具有透明度的对象(Transparent)的渲染时机是在此之后的,这样水面里将会看不到Transparent对象了。对于此有两个解决方案:

  • 修改源码,将生成时机调到Transparent渲染之后。
  • 利用RenderFeature自行在合适的时机生成画面Texture。

  经过项目实际情况的考虑,我选择修改源码(具体修改在MyURP)。在Frame Debugger可以看到渲染时机已经变为Transparent之后了:
5
  做到这步只能算是准备好了子弹,接下来还要制造枪械:由于自带的Shader Pass的渲染时机并不在生成_CameraColorTexture之后,所以我们需要利用RenderFeature构建个渲染时机生成之后的环节。这里直接使用URP自带的Render Objects即可满足:
6
  如此只要Shader里Tag名为Grab的Pass,都将会在此RenderFeature进行渲染。接下来便是完成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
SubShader
{
Pass
{
Tags { "LightMode" = "Grab" }
Blend [_SrcBlend][_DstBlend]
ZWrite[_ZWrite]
Cull [_Cull]
HLSLPROGRAM
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
#pragma target 2.0
#pragma vertex Vert
#pragma fragment Frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
half4 _Color;
CBUFFER_END
TEXTURE2D(_CameraColorTexture); SAMPLER(sampler_CameraColorTexture);
struct Attributes
{
half4 positionOS : POSITION;
};
struct Varyings
{
half4 positionCS : SV_POSITION;
half4 screenPos : TEXCOORD0;
};
Varyings Vert(Attributes input)
{
Varyings output;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
output.positionCS = vertexInput.positionCS;
output.screenPos = ComputeScreenPos(output.positionCS);
return output;
}
half4 Frag(Varyings input) : SV_Target
{
half3 color = SAMPLE_TEXTURE2D(_CameraColorTexture, sampler_CameraColorTexture, input.screenPos.xy / input.screenPos.w);
return half4(_Color.rgb * color, _Color.a);
}
ENDHLSL
}
}

  Shader实现与一般形式十分相似,主要在于用上了_CameraColorTexture以及ComputeScreenPos函数,看看效果先:
7
  看得出效果还是有所不同的,毕竟现在水面显示的不再是一层半透明蓝色了,而是原有画面的基础上调色。现在万事俱备,只欠东风了——

扭曲的实现

  实现扭曲我们需要一张表达水面的法线贴图,或者噪声贴图也行。本质上是偏移UV,以产生扭曲的结果。我选择使用法线贴图,因为后续也有用到。
  水面法线贴图的生产我并不了解,目前是随便找张不规则图形的基础上使用Unity自带的Create from Grayscale生成的,效果居然还不错:
8
  应用起来也很简单,获取法线贴图的xy数据加到screenPos.xy即可。当然仅此而已的话水面是不会动的,所以我们还可以加个与时间挂钩的偏移值,以推动法线贴图的uv,便可产生动起来的效果:

1
2
3
4
5
6
7
8
9
10
11
half4 Frag(Varyings input) : SV_Target
{
half2 speed = _Speed * _Time.y * 0.01;
half3 bump = UnpackNormal(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv.zw + speed)).rgb;
half2 offset = bump.xy;
input.screenPos.xy += offset * input.screenPos.z;
half3 color = SAMPLE_TEXTURE2D(_CameraColorTexture, sampler_CameraColorTexture, input.screenPos.xy / input.screenPos.w);
return half4(_Color.rgb * color, _Color.a);
}

9

  不错不错,对于某些游戏而言,到了这步也算完成了。但还不够——

着色

  目前有一个很明显的不足:虽然有了扭曲,但水面还是平平的一片蓝色,显然是缺乏明暗的体现。此时先前的法线贴图便可再次派上用场了:结合法线来做漫反射(Diffuse)效果。当然我们还不能直接使用取得的法线,还得将其转换至世界空间才行。

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
Varyings Vert(Attributes input)
{
...
VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);
half3 viewDirWS = GetCameraPositionWS() - vertexInput.positionWS;
output.TtoW0 = half4(normalInput.tangentWS.x, normalInput.bitangentWS.x, normalInput.normalWS.x, viewDirWS.x);
output.TtoW1 = half4(normalInput.tangentWS.y, normalInput.bitangentWS.y, normalInput.normalWS.y, viewDirWS.y);
output.TtoW2 = half4(normalInput.tangentWS.z, normalInput.bitangentWS.z, normalInput.normalWS.z, viewDirWS.z);
}
half4 Frag(Varyings input) : SV_Target
{
...
// 书接上文的bump
bump = normalize(half3(dot(input.TtoW0.xyz, bump), dot(input.TtoW1.xyz, bump), dot(input.TtoW2.xyz, bump)));
Light light = GetMainLight();
...
color = Diffuse(color, light.direction, bump);
}
half3 Diffuse(half3 color, half3 lightDir, half3 normal)
{
half NDotL = dot(normal, lightDir) + 0.5 * 0.5;
return color * NDotL;
}

  这里漫反射用的是半兰伯特(Half-Lambert),这是为了保证水面的亮度足够,看看效果:
10
  嗯,有点味道了。再加个高光看看吧:

1
2
3
4
5
6
7
8
9
10
11
half3 Specular(half3 color, half3 lightDir, half3 viewDir, half3 normal)
{
half3 halfVec = normalize(lightDir + viewDir);
half NdotH = dot(normal, halfVec);
NdotH = saturate(NdotH);
half v = pow(NdotH, _Smoothness);
v *= _SpecularStrength;
return color + color * v;
}

11
  越来越有味了,不过感觉这种高光不够突出光点,加个Step试试:

1
2
3
half v = pow(NdotH, _Smoothness);
v = step(_SpecularRamp, v);
v *= _SpecularStrength;

12
  不错不错,就这样吧,到实际场合看看。

反射

13
  目前的效果如上,总的来说算是OK了,但感觉还是差了点什么……没错,就是反射。起初我很自然而然的脑补认为要让周边的岩石草木投射在水面,为此我尝试了各种方案(反射探针、反射摄像机、平面反射……)都不满意,最终发现这纯属脑补了。实际由于视角原因是达不到那样的效果的,能够反射的内容基本会与折射重叠。醒悟之后发现最合适的反射内容只有纵身跳入的人物以及天空罢了:前者的出现场合太少了,对于后者与其用各种反射手段,还不如直接弄张天空贴图完事。

1
2
3
4
5
6
7
8
9
10
11
12
half4 Frag(Varyings input) : SV_Target
{
...
half3 texColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv.xy + speed);
half3 color = lerp(1, texColor, _Fresnel);
color = Diffuse(color, light.direction, bump);
color *= SAMPLE_TEXTURE2D(_CameraColorTexture, sampler_CameraColorTexture, input.screenPos.xy / input.screenPos.w);
color = Specular(color, light.direction, viewDir, bump);
return half4(_Color.rgb * color, _Color.a);
}

  弄了张天空贴图,结合扭曲所用的偏移值进行uv移动,使用_Fresnel控制反射与折射的比例。注意这里的_Fresnel仅仅是个0-1的参数,并非是真正的菲涅耳系数(由于视角关系根本用不到)。来对比下吧:
14
15
  这样的假反射在美术上的意义主要是能让水的颜色没那么单调,并且由于贴图是移动的,也带来了更多的动感。

后记

  最后加上点互动特效,有那么点意思了:
16
  在加这波粒子特效时也遇到了不少问题,也多了一些想要实现的东西。限于篇幅只能留待日后了。

在Demo实装光照烘焙与探针

Posted on 2020-08-02 | In Teach | | Visitors

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

前言

  Demo目前的实时光影虽已完成,但考虑到不同的配置设备,还是得做出不同档次的光影方案。那么烘焙光照(Lightmapping)与光照探针(Light Probes)就免不了了。本文将结合项目实际需求,讲述遇到的问题及解决方案。项目引擎版本为2019.4,渲染管线为URP。

初步的烘焙

  首先要做的自然是对Shader增加光照烘焙与探针的支持,照抄URP的SimpleLit Shader即可。大致要点如下:

  • Shader添加multi_compile:LIGHTMAP_ON与_MIXED_LIGHTING_SUBTRACTIVE,这表示Shader会参与光照贴图与混合光照
  • 顶点着色器参数添加half2 lightmapUV : TEXCOORD1;,这是光照贴图的UV
  • 片元着色器参数添加DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 1);,这是URP自带的宏,根据LIGHTMAP_ON决定配置光照烘焙或探针的参数(lightmapUV or vertexSH),最后的参数1决定是第几个TEXCOORD
  • 在顶点着色器调用OUTPUT_LIGHTMAP_UV(lightmapUV, lightmapScaleOffset, OUT)与OUTPUT_SH(normalWS, OUT)宏,它们将根据情况配置lightmapUV与vertexSH
  • 在片元着色器对光照贴图或探针进行取色(SAMPLE_GI(lmName, shName, normalWSName)),最后将之加入到着色环节即可
  • 若是想要烘焙模式下也能接受实时阴影,记得调用MixRealtimeAndBakedGI函数

  总的来说都封装好了,照着拼凑而已。那么事不宜迟,直接按照默认的烘焙配置整个看看,记得要将GameObject的Static里的Contribute GI勾选方可参与烘焙:
0
  看着似乎还不错,那么对比下实时看看吧:
1
  这么一看还是有不少差距的,必须要让烘焙与实时的效果高度接近才行呐——

ShadowMask

  经过与烘焙设置一番斗智斗勇后,我发现我要的仅仅是让阴影烘焙,以节省阴影的运算罢了。什么全局光照、烘焙自带的着色等等都是不需要的。为此我尝试过不少骚操作:生成光照贴图后进行二值化处理、直接在Shader对烘焙色进行处理等……可惜这些方案都只是治标不治本,要么在流程上繁琐,要么性能不佳,要么无法应对所有情况。最终我把目光放在了烘焙三模式之一的ShadowMask,它将单独生成阴影贴图,那么若是我只用它,抛弃光照贴图,便可达到目的了。
  不幸的是,URP并没有支持ShadowMask,官网显示仍处于In research状态。幸好网上有其他人做了实现ShadowMask的教程,顺便也学习了一波可编程渲染管线(SRP)的基础知识。经过研究发现,ShadowMask的添加并不复杂,甚至可以说是URP主动将之关闭了(严重怀疑是故意拖到后面做,显得有活干)。当然这么干了之后就表示需要维护自己的URP版本了,顺便将之开源了。
  SRP本质上是开放了一个可供用户定制的表层,多数核心功能还是封装好的。ShadowMask也不例外,其生成附属于烘焙模块。我们要做的只是添加一些设置,以及相应的Shader支持罢了:

1
2
3
4
5
6
7
8
// 添加Shadowmask字段以让界面开启选项
mixedLightingModes = SupportedRenderingFeatures.LightmapMixedBakeModes.Subtractive | SupportedRenderingFeatures.LightmapMixedBakeModes.IndirectOnly | SupportedRenderingFeatures.LightmapMixedBakeModes.Shadowmask
// 添加Shader关键字,以决定是否启用功能
public static readonly string MixedLightingShadowmask = "SHADOWS_SHADOWMASK";
// 根据条件设置相应的Shader关键字
CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MixedLightingShadowmask, renderingData.lightData.supportsMixedLighting &&m_MixedLightingSetup == MixedLightingSetup.ShadowMask);

  Shader方面要做的调整也不多,URP本身自带ShadowMask的贴图变量TEXTURE2D(unity_ShadowMask);,其UV与光照贴图一致,复用即可。记得在Shader添加multi_compile SHADOWS_SHADOWMASK以判别是否处于ShadowMask模式下。

1
2
3
4
5
6
7
8
9
10
#if defined(SHADOWS_SHADOWMASK) && defined(LIGHTMAP_ON)
// ShadowMask贴图只有R通道
half shadowMask = SAMPLE_TEXTURE2D(unity_ShadowMask, samplerunity_ShadowMask, lightmapUV).r;
// 不超过光照Strength值
shadowMask = LerpWhiteTo(shadowMask, GetMainLightShadowStrength());
// 加入到阴影着色中
light.shadowAttenuation = min(light.shadowAttenuation, shadowMask);
#endif

  大致要做的事情就这么多,烘焙设置除阴影方面外,能怎么快就怎么设置(反正也用不上光照贴图了),一般来说需要注意的有Bounces要设为1,不然阴影会不完整。Flitering将对阴影贴图做边缘柔和处理,Lightmap Resolution与Lightmap Size决定阴影质量,参考如下:
2
  另外需要注意的是,阴影精度很大程度上取决于模型的大小,因为一个模型只能有一张光照/阴影贴图,在贴图大小定死上限的前提下,模型越大贴图的解析度自然越低。那么来看看效果吧,图一为实时,图二为烘焙:
3
4
  效果可以说是高度接近了,干掉了光照贴图后着色变得完全一致,阴影贴图在合理的设置下也达到了高度接近实时的效果。坡肥!

光照探针

  现在虽然实现了高度接近实时的阴影烘焙,但显而易见,当人物走向阴影处便会是这样的结果:
5
  在某些游戏也许不太理会这种现象,但这也太捞了,光照探针便是为了解决这个问题而生的。通过在场景布置探针,将会根据动态对象附近的探针取色决定明暗度:
6
  光照探针如果要手动布置那实在是太麻烦了,于是我使用了这个插件,通过简单的设置暴力的去平铺一波:
12
  根据官方文档说法,探针数量与性能成反比(但越多越精确)。但此插件平铺并不会把探针置于模型内部,以及对比了下《使命召唤手游》的光照探针,感觉还行:
13
  Shader方面没什么要改的,在URP获取光照函数GetMainLight()本身自带了对光照探针的着色处理(附加在light.distanceAttenuation中),由于不需要用到全局光照,之前的OUTPUT_SH之类的都可以删了。当然有个现象需要注意下:
7
  可以看到在暗处时实在是太黑了(也许是放弃了全局光照导致),于是我们加个约束,将暗值约束在光照Strength到1:

1
2
3
#if !defined(LIGHTMAP_ON) && defined(_MIXED_LIGHTING_SUBTRACTIVE)
mainLight.distanceAttenuation = lerp(GetMainLightShadowStrength(), 1, saturate(mainLight.distanceAttenuation));
#endif

8
  很好,这下可以说是大功告成了!

后记

  最后演示下不同光影品质下的差别吧,分别为低、中、高:
9
10
11
  话虽如此,可我发现目前直接把高品质光影扔到iPhone8下居然稳定59帧,太强了……
14

12…6
Musoucrow

Musoucrow

53 posts
7 categories
37 tags
RSS
Coding Github
© 2022 Musoucrow
Powered by Hexo
Theme - NexT.Mist