大家好,我是阿赵。
最近在使用Unity的PostProcess后处理效果的时候,发现了一个问题,下面记录一下这个问题的出现原因和解决办法。
一、出现问题
问题是这样出现的:
在场景里面添加某一个后处理效果后,当这个后处理的PostProcessVolume对象被删除时,Unity会疯狂报错
MissingReferenceException: The object of type ‘PostProcessVolume’ has
been destroyed but you are still trying to access it. Your script
should either check if it is null or you should not destroy the
object.
UnityEngine.Rendering.PostProcessing.PostProcessManager.IsVolumeRenderedByCamera
(UnityEngine.Rendering.PostProcessing.PostProcessVolume volume,
UnityEngine.Camera camera) (at
Library/PackageCache/com.unity.postprocessing@3.1.1/PostProcessing/Runtime/PostProcessManager.cs:455)
UnityEngine.Rendering.PostProcessing.PostProcessManager.UpdateSettings
(UnityEngine.Rendering.PostProcessing.PostProcessLayer
postProcessLayer, UnityEngine.Camera camera) (at
Library/PackageCache/com.unity.postprocessing@3.1.1/PostProcessing/Runtime/PostProcessManager.cs:335)
UnityEngine.Rendering.PostProcessing.PostProcessLayer.UpdateVolumeSystem
(UnityEngine.Camera cam, UnityEngine.Rendering.CommandBuffer cmd) (at
Library/PackageCache/com.unity.postprocessing@3.1.1/PostProcessing/Runtime/PostProcessLayer.cs:903)
UnityEngine.Rendering.PostProcessing.PostProcessLayer.BuildCommandBuffers
() (at
Library/PackageCache/com.unity.postprocessing@3.1.1/PostProcessing/Runtime/PostProcessLayer.cs:541)
UnityEngine.Rendering.PostProcessing.PostProcessLayer.OnPreCull () (at
Library/PackageCache/com.unity.postprocessing@3.1.1/PostProcessing/Runtime/PostProcessLayer.cs:466)
我做后处理的方式是,通过AssetBundle加载美术做好的已经添加了PostProcessVolume的物体的Object对象,然后实例化这个物体的同时修改它的layer为指定的层,给挂了PostProcessLayer的摄像机去渲染后处理效果。
根据分析之后,其实问题是出在实例化对象同时改变layer操作导致的,下面来具体分析一下。
二、PostProcess后处理的正常运作
在解决这个问题之前,我们要先知道PostProcess整一套过程实际上是怎样进行的。
1、几个基础概念
这套PostProcess后处理系统,它实际上有三个主要组成部分
1.PostProcessLayer
这是挂在摄像机上面的一个脚本,他的作用是可以指定摄像机只给指定layer的对象渲染时添加后处理效果。
2.PostProcessVolume
这个脚本并不是挂在摄像机上面的,而是应该建立一个空物体,然后挂在上面。它的作用是定义实际后处理效果的。比如它是全局的,还是范围的,它是属于哪个layer的,它包含了哪些后处理效果,参数如何。
我看到很多美术同事在使用这个后处理的时候都是用错的了。因为旧版本的Unity后处理脚本,是直接挂在摄像机上面的,所以对于这个新版本的后处理系统,他们也是把PostProcessLayer和PostProcessVolume同时挂在摄像机上。这种做法是绝对错误的,因为volume本身还包含了指定layer和触发范围的设置。如果把volume挂在摄像机上,那么会导致各种混乱的问题。
3.PostProcessManager
这个脚本是一个管理类,并不需要我们操作的。之前我们挂的PostProcessVolume和PostProcessLayer都是注册在PostProcessManager上,并且通过PostProcessManager来读取需要的数据渲染的。
2、渲染的过程
1.volume的注册和反注册
当带有PostProcessVolume的对象在场景创建或者激活时,会通过OnEnable生命周期,调动PostProcessManager的Register方法,把自己注册在管理类里面
当带有PostProcessVolume的对象被删除,或者不激活时,会通过OnDisable生命周期,调用PostProcessManager的Unregister方法,把自己从管理类里面移除
当带有PostProcessVolume的对象的layer被改变时,会先通过Unregister方法,从PostProcessManager里面删除,再通过Register方法重新添加
PostProcessManager里有2个很重要的变量要了解的
第一个是readonly List m_Volumes;
这个变量是存储场景里面当前存在并能渲染的所有PostProcessVolume对象
第二个是readonly Dictionary<int, List> m_SortedVolumes;
这个字典对象,是排序用的,通过layer作为key,后面是使用这个layer的所有PostProcessVolume对象的排序数组
2.PostProcessLayer的渲染
当场景里面存在带有PostProcessLayer的摄像机时,在OnPreCull生命周期时,会调用渲染方法。通过自身指定的layer,从PostProcessManager的SortedVolumes里面获取有没有当前layer的volume,如果有,则取出来,逐个渲染。
三、出现问题的原因分析
由于我的做法是在实例化的同时,改变了GameObject的layer,所以在运行的时候,实际的流程是这样的:
1、通过Instantiate实例化,调用了PostProcessVolume的OnEnable方法,然后调用到PostProcessManager的Register方法。这时候传进来的layer,是物体实例化时的layer。Register方法把volume添加到m_Volumes总列表。
由于排序用的m_SortedVolumes还没有当前layer作为key的数组,所以并不会直接添加到m_SortedVolumes,而只是SetLayerDirty,而由于SetLayerDirty其实也是基于m_SortedVolumes本身已经存在的layer的,这时候m_SortedVolumes是新的,还没有任何layer信息,所以实际上SetDirty是什么都没做。
2、在PostProcessLayer的OnPreCull方法里面,会刷新当前的层所用到的Volume。所以会拿着PostProcessLayer指定渲染的layer作为key去m_SortedVolumes获取当前layer里面的所有Volume。
由于之前的注册方法并没有真的添加到m_SortedVolumes这个排序的集合里面,所以到了PostProcessManager的GrabVolumes方法,会根据之前添加到m_Volumes总列表里面的所有volume做一次遍历,找到和当前PostProcessLayer相同层的volume,添加到排序列表SortedVolumes对应的layer。
值得注意的是,这时候获取的volume是它所在的GameObject的layer。由于当前是在OnPreCull方法里调用的,OnPreCull生命周期的执行,是在相机消隐之前,也就是说,他是比刚才的同步实例化和修改layer要晚的。刚才实例化volume对象的同一帧里面,我改变了layer,将会导致这里添加的是GameObject改变后的layer。
3、假如我们之前改变了volume的GameObject的layer,那么在PostProcessVolume的Update方法,也就是下一帧,它发现了物体的layer发生改变,会调用PostProcessManager.instance.UpdateVolumeLayer方法,去改变volume的注册。
具体的操作是,先反注册旧的layer,再添加新的layer注册
4、这里问题就来了
由于第2步添加的时候,是使用GameObject的实际layer的,所以旧layer没有添加到排序,所以也卸载不到,然后新layer其实已经在排序列表里面存在了,现在再次添加,PostProcessManager里面并没有判断是否已经存在,就变成了同一个volume重复添加了,这时候,排序列表里面,就有2个一样的volume对象。
假如现在我们把这个volume删除,按正常的流程,会通过PostProcessVolume的OnDisable方法去反注册volume,就会导致一个问题,因为重复添加了,所以列表里面有2个对象,但反注册的时候,只把一个删掉了,另外一个残留了。当我们再次添加同一个layer的volume时,排序的列表就变成了残留了一个已经被删掉的volume
当PostProcessManager逐个layer根据排序列表渲染的时候,就会发现有一个volume是空的,然后就报错了
从代码看,这个报错只会出现在unity2018.3以上版本的编辑器里面。不过很明显,出现这个错误的时候,管理类里面的数据已经出现了错乱了。
四、解决问题
知道了问题之后,要解决这个问题就变得很简单了。有很多个方案可以尝试:
1、修改源码
这是最直接的办法了,只需要把PostProcessManager里面添加到排序队列m_SortedVolumes时,判断一下是否已经存在,如果存在就不添加,就解决了。
不过由于PostProcess现在是以插件的形式从PackageManager下载的,如果要修改,最好是官方去修改。
2、设置原始资源的layer
如果在实例化volume的过程不需要修改layer,这个问题自然也不存在了。所以如果项目规划得比较详细,每个volume使用的场合都很明确,那么可以在美术制作的时候就把layer设置好。
但这样做有2个问题
1.对美术同事的要求比较高,因为美术同事对这些参数关注度很低,很可能会忘记设置或者设置错
2.如果同一个volume需要使用在不同的场合,需要不同的layer,就不能这么做了。
3、不要在同一帧实例化和改变layer
先实例化volume对象,然后延后一帧,再修改它的layer。这样就能避免刚才的问题了。不过这个方案要开计时器之类做延迟一帧处理。
4、设置layer前先隐藏
在要设置volume的layer之前,先把它SetActive为false,然后同一帧修改layer,再同一帧把它SetActive回true。这样,实际上PostProcessManager会在SetActive为false时先同步反注册一次,在修改layer的时候,由于不处于激活状态,所以Update不会走,也不会触发UpdateVolumeLayer方法,然后在SetActive回true的时候,OnEnable方法会把当前layer设置正确,并重新注册一次,这样就不会有问题了。