Unity Shader 场景中可交互并支持深度的玻璃雾效实现记录(SRP)
本文为实现玻璃雾效及交互实现的过程记录,并非教程!!!
场景中指玻璃雾效同样是可放置在场景中的物体,在场景中会被其他物体遮挡。并非直接进行模糊后处理。
顶点着色器输出的POSITION在片元着色器中的xy值就已经是经过齐次除法,映射到[0, 1]再缩放到分辨率后的结果,即屏幕空间的坐标;
Unity Shader 各个空间坐标的获取方式及xyzw含义
透明物体Shader基本流程:
- 设置RenderQueue为Transparent;
- 设置混合模式;
- 关闭深度写入;
在SRP中,在Shader中设置的RenderQueue不起作用,须在材质中显式的指定RenderQueue。
'XXX' is missing the class attribute 'ExtensionOfNativeClass'!
报错:'XXX' is missing the class attribute 'ExtensionOfNativeClass'!
将未继承自MonoBehaviour的类附加到游戏对象上了,找一下对象中的'XXX'然后去掉即可。
State comes from an incompatible keyword space
Properties名称使用了关键字,例如_MainTex ("Texture", 2D) = "white" {},Texture为系统关键字。
Bilt
Unity中屏幕后处理可以通过Bilt实现。
Bilt函数的作用是将纹理A的结果复制到纹理B上(AB可为同一个纹理),但只支持颜色,如果想要复制深度和模板缓冲需要自己重新实现一个类似Bilt的函数。
If you want to use a depth or stencil buffer that is part of the source (Render)texture, or blit to a subregion of a texture, you have to manually write an equivalent of the Graphics.Blit function
Unity提供了两种Bilt实现,CommandBuffer.Blit和Graphics.Blit。两种方法在文档中的功能介绍和使用方法几乎没有任何区别,但CommandBuffer.Blit只接受Texture和Rendering.RenderTargetIdentifier类型的源纹理和Rendering.RenderTargetIdentifier类型的目标纹理。我依照官方文档使用Graphics.Blit在SRP中并没有效果,CommandBuffer.Blit可以正常使用。
获取屏幕可以用BuiltinRenderTextureType.CameraTarget或Camera.main.targetTexture,BuiltinRenderTextureType.CameraTarget返回的是默认渲染目标,即屏幕。而Camera.main.targetTexture可以在Unity中手动设置,如果不设置默认也是屏幕。
在用
Camera.main.targetTexture的时候发现Scene窗口的Camera.main.targetTexture只渲染了GUI的,说明Scene窗口渲染画面的相机其实不是主相机?在RenderDoc中看了一下,首先GUI绘制是和SRP无关的,在Scene中只有一个叫SceneCamera的相机,场景中唯一的MainCamera在Game中。
在Scene中,BuiltinRenderTextureType.CameraTarget为SceneCamera,而在Game中,BuiltinRenderTextureType.CameraTarget为MainCamera。
简单粗暴的设置buffer.Blit(src, src, material, 0)是不行的,输入输出纹理相同会导致_MainTex拿不到正确纹理。在URP/HDRP中,用OnRenderImage(RenderTexture source, RenderTexture destination)可以直接设置Blit。在SRP中不使用OnRenderImage实现的话,需要自己创建一个临时纹理存储相机渲染结果,再用buffer.Blit(TempMapID, BuiltinRenderTextureType.CameraTarget, material)把结果处理复制到屏幕上。
关于深度和模板缓冲
Unity中可以用CommandBuffer.SetRenderTarget设置颜色缓冲和深度/模板缓冲(只设置颜色缓冲时,会使用默认的深度/模板缓冲D32S8)。当需要深度/模板缓冲时,需要用CommandBuffer.GetTemporaryRT创建一个临时纹理,depthBuffer可以为0/16/24,为0时表示不使用深度缓冲,16仅使用深度缓冲,24同时使用深度和纹理缓冲。
D32S8:depth 32位,stencil 8位;
R16:Red 16位,即单通道16位;
Unity中还有一个函数RenderTexture.GetTemporary,和CommandBuffer.GetTemporaryRT的区别是前者要手动释放内存,而后者能自动释放(以及一个是返回纹理,一个是为已有nameID生成纹理),Any temporary textures that were not explicitly released will be removed after camera is done rendering, or after Graphics.ExecuteCommandBuffer is done. 我们用的是ScriptableRenderContext.ExecuteCommandBuffer,所以在相机渲染完成后才会释放。
但问题在于,Blit并不能传递模板缓冲和深度缓冲,不用模板缓冲就不方便将玻璃区域标识出来。
手动实现Blit
如果是OpenGL,直接传4个在屏幕空间的顶点坐标和uv进去即可。但Unity没法怎么做,所以咱得走标准流程。
Screen.width和Screen.height在Scene中获取的是窗口的大小,不是屏幕的大小(屏幕包含在窗口中,窗口还有部分区域用于绘制GUI),获取屏幕的大小需要用camera.pixelWidth和camera.pixelHeight,而在Game中没有这种问题,两类获取的结果一样。
所以用Screen.XX获取的大小创建深度缓冲后,再将该深度缓冲设置给屏幕的颜色缓冲时,会大小不一致导致报错。
基本实现了一个磨砂玻璃效果,但是uv映射有点问题,一个是尺度,一个是貌似翻转了。确定了,相机设置正交投影没成功。
设置正交投影代码
// 设置正交后设置VP矩阵,如果在context.Submit()前又设置回透视投影,最终提交的就会是透视投影的VP矩阵,所以正确的设置方法是。
cam.orthographic = true;
context.SetupCameraProperties(cam);
context.Submit()
// 绘制部分
cam.orthographic = false;
context.SetupCameraProperties(cam);

如果不设置回去会就变成下面这样。但对Game是没有影响的。

同样,要注意由于GPU和CPU异步执行,对一些需要GPU处理后在CPU中使用的纹理,需要先等待GPU指令全部提交执行完成。
纹理坐标和缓冲坐标
实现过程中遇到个问题,即出现了深度模板纹理和颜色纹理竖直方向不一致的问题,即本应该绘制在正方体下的玻璃雾效,出现在正方体上面。

这是因为D3D是以左上角为纹理UV原点,所以在Direct3D类平台上渲染到纹理时,Unity会在内部上下翻转渲染(渲染到BuiltinRenderTextureType.CameraTarget不会翻转)。这就导致了深度缓冲/模板缓冲生成的纹理是上下颠倒的。
捋一下思路哈,我们有三个纹理(屏幕颜色缓冲纹理,模糊处理的屏幕颜色缓冲纹理,深度/模板缓冲纹理),目的是在BuiltinRenderTextureType.CameraTarget(下称屏幕)上根据深度/模板缓冲纹理把模板缓冲中值为128的区域绘制高斯模糊后的屏幕结果,其他区域绘制原始屏幕结果。
如果是用Blit先将屏幕颜色缓冲纹理给到屏幕,再将屏幕做目标颜色缓冲,深度/模板缓冲纹理作为目标深度缓冲。像素模板检测通过则采样模糊处理的屏幕颜色缓冲纹理。
逐步分析:
- 用
Blit先将屏幕颜色缓冲纹理给到屏幕,屏幕颜色缓冲纹理是倒置的,但由于D3D11的纹理坐标规则,采样后结果是正确的。 - 屏幕做目标颜色缓冲,目标颜色缓冲是正确的。深度/模板缓冲纹理作为目标深度缓冲,导致目标深度缓冲是倒置的。
- 像素模板检测通过则采样模糊处理的屏幕颜色缓冲纹理。采样模糊处理的屏幕颜色缓冲纹理的结果是正确的,但模板缓冲是倒置的,最终导致结果异常。
要正确渲染出结果,得需要知道什么时候会进行上下翻转。为了探究Unity进行翻转渲染的阶段,设计了三种情况(左图为模板缓冲,右上为颜色缓冲,右下为深度缓冲):
- 倒置的屏幕颜色缓冲纹理,正置的颜色缓冲(目标为屏幕),倒置的深度缓冲。

- 倒置的屏幕颜色缓冲纹理,正置的颜色缓冲(目标为临时纹理),倒置的深度缓冲。

- 倒置的屏幕颜色缓冲纹理,倒的颜色缓冲(目标为临时纹理),倒置的深度缓冲。

根据结果,对Unity内部翻转实现推测有如下结论:
- 渲染翻转是在PS和OM阶段中间进行的,并不是对最终渲染输出进行翻转。
- 如果渲染目标是屏幕,而不是其他纹理,不会进行翻转。
其流程尝试画了一下:

所以为了渲染出正常效果,要想直接输出到屏幕上,需要翻转深度/模板缓冲(由于该纹理数据是D32S8,无法通过Shader修改,所以没能实现)。另一种办法,就是先输出到中间纹理(如情况3),然后把中间纹理直接输出到屏幕上。依照这种思路,可以渲染出正确结果。

优化模糊效果
模糊这块一开始参考的是用uv采样增加偏移的方法,但偏移过大效果就不太好(如上图很多晶格的感觉)。后参考【Unity Shader编程】之十五 屏幕高斯模糊(Gaussian Blur)后期特效的实现用先降采样,用标准高斯模糊后再升采样,由于每个采样点都是一片像素区域的平均(原先是该像素区域的一个点),效果好了很多。

只有高斯模糊,依然只是给人一种磨砂的感觉,需要加上一些噪声来模拟一下雾气不均匀的感觉,这里参考ShaderToy上大佬的作品1D, 2D & 3D Value Noise生成噪声纹理,再给我们的玻璃材质加上透明材质,透明度采样噪声纹理,稍微处理一下就能有比较满意的效果了。

雾效的边缘是模板测试的结果,只有通过和不通过,所以显得特别硬。修改为用颜色纹理代替模板筛选雾效区域的功能(为什么一开始不改成这样?因为!想试试自定义的Blit,加之开始有这个想法的时候对这块还不是很熟悉。所以这篇文章不是一个教程什么的,而是记录,流程其实是边实现边优化之前不合理的地方),在最终绘制时同时采样这个屏幕缓冲得到的纹理,R通道加权到透明度上。

“软”边缘的玻璃雾效。
复习一下前面的内容,深度缓冲纹理是倒置的,绘制上图纹理时,PS阶段出来的图像是正的,因为渲染到非屏幕,所以会自动颠倒,就正好和深度缓冲对上了,后面采样该纹理也没问题。假设是用默认深度缓冲,因为深度缓冲是正的,结果就会有问题了😎。

但此时,雾效还是静态的,我们的最终目的是一个能交互的雾效😕。
交互功能
看了一下雪地交互的实现方法,最简单的是用一个正交相机从下往上拍记录深度值来偏移地形。而且雪地,沙漠类交互是和物体的交互,但雾效想用鼠标交互。好像不太通用?
这部分是思路是,创建一个Quad,模拟笔刷,Quad上配一个材质,采样笔刷贴图,然后这个Quad的材质单独绘制,且结果保存到一个游戏运行时能持久存在的纹理中(下称雾效区域纹理),这样我们在添加新区域时前面生成的雾效区域就能继续保留。

当按鼠标左键时,通过鼠标的屏幕空间坐标(xy,z自行设置一个合适的距离)获取鼠标在世界空间下的位置,并将Quad移动到该位置,面朝屏幕,设置Quad为活动状态(其他情况Quad需要为非活动状态,因为后面我们要做雾效自行消失的效果)。

雾效在鼠标点击的时候直接冒出来了,很突兀,我希望它是渐渐出来的效果。所以在雾效区域纹理上添加新区域时,返回新区域的透明度设置小一点,这样在和已有的雾效区域纹理混合时,一帧只会加深一点点区域。同时考虑到帧率不是固定的,为在不同帧率下获得基本接近的生成速度,透明度上还需要乘上一个俩帧之间的时间(俩帧间隔越短,每帧增加区域的程度更小)。

其雾效区域纹理如下。

为了模拟雾效会自行消失的效果,同过Blit在每一帧对雾效区域纹理去除一部分r通道的值,但发现如果这个值设置的太小,就会使得在r通道的值大于某个值时无变化,为了实现接近真实的消失效果,这个递减值是需要设置的非常小的。
上面提到的在r通道的值大于某个值时无变化是由于r通道精度导致的。当减数小于r通道的精度分辨率时,对其的修改就会没有效果。如果使用32位存储,把四个通道拼接起来,这样精度就可大幅提升。UnityCG.cginc中有相关函数:float 编码为 RGBA8。
点击查看代码
// Encoding/decoding [0..1) floats into 8 bit/channel RGBA. Note that 1.0 will not be encoded properly.
inline float4 EncodeFloatRGBA( float v )
{
float4 kEncodeMul = float4(1.0, 255.0, 65025.0, 16581375.0);
float kEncodeBit = 1.0/255.0;
float4 enc = kEncodeMul * v;
enc = frac (enc);
enc -= enc.yzww * kEncodeBit;
return enc;
}
inline float DecodeFloatRGBA( float4 enc )
{
float4 kDecodeDot = float4(1.0, 1/255.0, 1/65025.0, 1/16581375.0);
return dot( enc, kDecodeDot );
}
其原理并不复杂:使用纹理的RGBA通道存储float类型数值,由于Alpha通道需要用于混合,所以这里只使用RGB通道存储float值。这样在混合计算会有一些问题,因为Unity会单独计算三个通道的混合结果,但其对结果的影响非常小,完全可以接受。
顶点和片元着色器中的变量就直接在函数内定义局部变量,定义在全局的未被外部写入的变量会被优化掉!!!
再遇Bug,即使提升了精度发现还是有一样的问题?被这个问题弄了好几天😭,发现是Unity自带的颜色空间转换的问题,解决方法很简单,将ColorSpace改成gamme即可,设置Color Space为Linear时,临时颜色纹理的颜色空间为sRGB(片元着色器采样时会自动进行sRGB到线性的转换,而返回时也会自动转回sRGB),而设置为gamma后为线性空间,在PS中就不会自动进行空间转换。理论上应该都可以的,因为并不知道Unity这块的具体实现,推测可能是Unity在转换的时候到底做了些啥导致颜色空间转换过程中出现了一些精度丢失的问题😫。确保在线性空间下计算后,终于可以实现很小的变化率了。

哈气效果已经实现了,现在只需要再增加一个简单的抹除效果即可完成所有交互的功能嘞。

交互效果:

支持深度测试:

TODO:
加上一些噪点和划痕噪声;
抹除和增加雾气的功能添加变化;
整理思路,有空把代码整理放上来;



浙公网安备 33010602011771号