编者按
本文已向作者 @音速键盘猫 授权转载,原载于知乎,如需转载请务必联系原作者。开始之前
在之前的文章中有前辈提到大萌喵的写作风格的问题, 对此大萌喵感到抱歉和惭愧。 以后在谈技术的正文当中不会出现卖萌耍宝的废话啦 ~ 谢谢各位的批评指正, 大萌喵还是个大三学生, 非常热切地希望与前辈多交流。
上一次的文章中介绍的是相交高亮效果, 用到了DepthBuffer。 然后大萌喵就对DepthBuffer着迷的一发不可收拾 ... 以后几期更新的特效也都会和DepthBuffer有关 ~
过去的一周比较忙碌, 因此更新不及时啦。希望大家多多监督我哦 ~
废话.Stop();
垂直雾(Vertical Fog)是个啥
想必大家都知道雾特效, 一般来讲, 距离摄像机越远的点, 其受到雾特效的影响会越为严重。这是最为常见的雾特效。
但是还有一种雾, 在某一点的浓度与其和观察点间的距离关系似乎并不大, 而与其世界位置坐标有非常紧密的联系。
大萌喵不太懂物理, 姑且理解为雾霾因自身受到的重力而产生了沉积作用, 使得距离地面较近的区域雾浓度特别高, 但是一旦高过某个阈值, 浓度则开始急剧下降。
以上属于大萌喵的不懂装懂辣鸡解释。 如果某位大侠能够科学地解释一下, 大萌喵感激不尽。
知道了Vertical Fog是什么东西之后, 我们就需要知道这个特效有什么用。
在很多Top-Down类型的游戏(比如LOL, Dota, Space Marshall)中想要加入雾特效的话, 使用传统的基于距离和深度的雾特效会导致效果失真. 这是因为Top-Down的视角是比较广的, 简单粗暴地糊上一层雾会导致许多距离摄像机较远, 但又很重要的部分被渲染为白茫茫的雾。 与此相反, 我们只希望在放置GameObject的那一层产生比较集中的雾, 因此可以考虑基于每一个点的世界坐标决定其雾的浓度。
所以说我们要干啥
实现基于Image Effect的Vertical Fog效果。当然了思路是相通的, 如果想要局部地添加雾特效, 也可以将类似的着色器特效应用于模型上, 然后注意调整模型的Blend与ZTest就好了。
想看懂这篇文章, 我得知道啥
需要知道DepthBuffer或Unity的CameraDepthTexture. 对点元着色器与片元着色器有一定了解。
看完这篇文章, 我能知道啥
你将知道如何在片元着色器中重新构建每一个像素点的世界坐标。 (Reconstruct world position from Depth Buffer in Pixel Shader), 如何实现垂直雾特效。
首先, 假设我们什么都不知道
恩, 假设我们什么都不知道, 只有一个处理前的图和处理后的效果图:
通过上面的三张图, 我们能得到如下的结论:
- 某点的雾的浓度, 和该点的世界Y坐标有关系。
- 某点的雾的浓度, 和摄像机的位置, FOV, 角度等都没有任何关系。
- 雾的浓度符合某种数学公式, 使其沉积在了比较低的区域。
- 这个特效适合俯视被观察区域的情况. 如果身在雾中的话, 恐怕什么都看不清楚。
很明显, 只要能知道某一点的世界Y坐标, 那什么问题都解决了。
补课时间
如果你对透视投影的原理不太清楚 ... 请移步这里补课. 当然了, 大萌喵也要补课去 --- 好好学学怎么优雅地写数学公式和画图, 这样下一次遇到数学问题就不需要到处贴链接和引(dao)用, 同时还得让读者脑补了。
Naive思路(直白, 低效)
在片元着色器中逆推出每一个片元的View Space坐标, 然后乘以_InverseView矩阵将之转化回World Space。
首先我们将片元屏幕坐标重新映射到[-1, 1]的区间以回归到NDC Space, 随后将NDC Space转化为View Space, 再通过从C#脚本传入的摄像机的世界-摄像机变换矩阵的逆求出其世界坐标。
float depth = LinearEyeDepth ( SAMPLE_DEPTH_TEXTURE( _CameraDepthTexture, i.uv ) ); float2 p11_22 = float2 ( unity_CameraProjection._11, unity_CameraProjection._22 ); float3 vpos = float3( ( i.uv * 2 - 1) / p11_22, -1 ) * depth; float4 wpos = mul( _InverseView, float4( vpos, 1 ) ); return wpos.y / 10; //下文中会说到为什么写得这么奇怪。
这段程序中vpos的计算过程为了让代码看起来简单点而做了一点变化, 更加直观的方式是这样的:
float2 ndc = i.uv * 2 - 1; float3 vpos;vpos.x = ndc / p11_22.x * depth; vpos.y = ndc / p11_22.y * depth; vpos.z = -depth;
unity_CameraProjection中存储的是投影矩阵. (这个是OpenGL的版本)其形式如下:
点元着色器的坐标变化处理过程如下:
我们现在相当于在倒数第二个框框内, 唯一不同的是我们采用的是uv坐标, 范围是[0, 1]而并非[0, width]与[0, height]. 因此第二段程序所做的, 就是利用uv坐标来求出NDC Space坐标. 注意, 到此为止完全和Z没有任何关系. 所以我们只需要让x分量除以, 让y分量除以即可。
转化回NDC Space后, 由于我们本质上已经做过了标准化和剪裁, 因此倒数第四个与第五个框框跳过, 我们的逆推过程进入到了蓝色的大框框中。 而根据坐标变换规则, 我们有如下等式:
OpenGL中View Space的Z轴正方向背离View Frustum.。而通过CameraDepthTexture我们得到的值均为正, 因此需要特殊变换一下。
OK, 到此为止我们已经成功将Screen Space丢到了View Space中, 我们只需要在C#脚本中插入如下代码, 就可以将世界-摄像机变换矩阵的逆传入:
material.SetMatrix ( "_InverseView", GetComponent ().cameraToWorldMatrix );
然后, 乘以这个矩阵即可:
float4 wpos = mul ( _InverseView, vpos );
现在说明下为什么我们最后要查看的是wpos.y / 10. 其实这个10是我顺手敲上去的(卖个萌), 人眼对于暗色的分辨能力高于对明亮颜色的分辨能力, 因此这个过程非常类似于Gamma Correction. 但是为什么我这没有用乘方的形式进行校正呢? 这是因为严格意义上来讲我们输出的是"坐标", 而游戏场景中的坐标可能会比较大, 比如厨房的柜子顶端其y轴坐标就达到了2.5m。 因此不如简单粗暴地除以10, 这样也很容易查看我们最后的结果是否正常。
结果:
如图所示, 越高的地方越明亮.
但是, 我们不希望太Naive
那个谁教育过我们, 不要Too ***, Too ... 打住, 专栏要办下去的。
为什么上面介绍的方法不好? 我们都知道矩阵乘法是一个颇为耗资源的一个操作, 哪怕搬到GPU上也一样。而上面的做法是在片元着色器中做坐标转换, 屏幕分辨率是多少就做了多少次矩阵乘法 ...
不知道读者是不是和大萌喵一样有一种感觉: 一个特定的ViewPort Position和一个特定的深度值, 是能够唯一确定一个世界坐标的. 大萌喵不会画图 ... 诸位脑补一下哈, 透视投影的过程中, 处在同一条从摄像机射出的射线上的点, 最终会被绘制到同一个位置上。 (这也就是深度测试的意义之一 --- 只让最近的那个点被绘制出来)。但是如果我们又同时知道了射线上的某个点到摄像机的距离, 那么这个点就是唯一确定的。
那么最后我们要得到的世界坐标就是ray * depth + _WorldSpaceCameraPos。
恩, 如果能快速得到这条射线就好了。其实得到这条射线的方法简单的令人发指。
我们可以在C#脚本中计算出摄像机到其View Frustum的远剪裁面的四个角的世界坐标射线。
对于全屏幕的后期特效, 其实就是一个全屏幕的Quad, 四个顶点. 一个顶点对应上述的一个角。
点元着色器输出至片元着色器的过程中自带插值 ... 我们什么都不用做, 就这么华丽丽地得到了想要的射线。
求View Frustum四个角的世界坐标的C#程序:
Matrix4x4 frustumCorners = Matrix4x4.identity; float fovWHalf = camFov * 0.5f; Vector3 toRight = m_camTrans.right * camNear * Mathf.Tan (fovWHalf * Mathf.Deg2Rad) * camAspect; Vector3 toTop = m_camTrans.up * camNear * Mathf.Tan (fovWHalf * Mathf.Deg2Rad); Vector3 topLeft = (m_camTrans.forward * camNear - toRight + toTop); float camScale = topLeft.magnitude * camFar/camNear; topLeft.Normalize(); topLeft *= camScale; Vector3 topRight = (m_camTrans.forward * camNear + toRight + toTop); topRight.Normalize(); topRight *= camScale; Vector3 bottomRight = (m_camTrans.forward * camNear + toRight - toTop); bottomRight.Normalize(); bottomRight *= camScale; Vector3 bottomLeft = (m_camTrans.forward * camNear - toRight - toTop); bottomLeft.Normalize(); bottomLeft *= camScale; frustumCorners.SetRow (0, topLeft); frustumCorners.SetRow (1, topRight); frustumCorners.SetRow (2, bottomRight); frustumCorners.SetRow (3, bottomLeft); material.SetMatrix ("_FrustumCornersWS", frustumCorners);
现在我们的问题是如何让这个矩阵代表的四个角与Screen Quad的四个角一一对应。
通过观察, 我们得到了如下关系:
uv.x = 0, uv.y = 0 ------ index = 3;
uv.x = 1, uv.y = 0 ------ index = 2;
uv.x = 1, uv.x = 1 ------ index = 1;
uv.x = 0, uv.y = 1 ------ index = 0;
我们需要知道一个函数F(x, y) = index, 使其能符合上述关系.。否则我们在点元着色器中就要使用if来判断x与y的关系, 从而和z一一对应。 摆脱了矩阵乘法, 然后引入了一坨if ... 这波真亏。
容易推知如下关系: F(x, y) = abs (3 - x - 3 * y).
重获新生的点元着色器
v2f vert (appdata_img v) { v2f o; o.vertex = mul(UNITY_MATRIX_MVP, v.vertex); o.uv = v.texcoord.xy; int xx = (int)v.vertex.x; int yy = (int)v.vertex.y; int z = abs (3 - xx - 3 * yy); o.interpolatedRay = _FrustumCornersWS[ z ]; o.interpolatedRay.w = ( v.vertex.z ); return o; }
是时候计算雾的浓度了
首先我们获取每一个片元的世界坐标. 由于射线射向的是远剪裁面, 因此这里将DepthBuffer Linearize的时候不要转化成EyeSpace ... 应该是01Space.
float depth = Linear01Depth ( SAMPLE_DEPTH_TEXTURE ( _CameraDepthTexture, UnityStereoScreenSpaceUVAdjust ( i.uv, _CameraDepthTexture_ST ) ) ); float3 worldPos = ( depth * i.interpolatedRay ).xyz + _WorldSpaceCameraPos;
然后应用一个随着高度指数衰减的密度函数就可以了, 在这里大萌喵随便写了一个, 仅供参考啦:
return lerp (tex2D (_MainTex, i.uv), _FogColor, saturate(exp(-worldPos.y - _Start) * _Density));
最终效果
如何实现Fluffy效果
我们也可以不急着输出, 而是将计算好的Factor存储到一张单独的RT中, 然后对那张RT进行提取, 模糊的操作, 然后再对颜色进行插值。
后记
写这样一篇文章也算是有感而发 ... 之前在GameDev上有童鞋问怎么不在片元着色器中做矩阵乘法以重构世界坐标, 大萌喵解释了七八个回帖也没给人家弄明白- -那时候自己也是一知半解。 最近在看Siggraph实现别人的特效的时候, 发现作者居然也用了Naive方法 ...
FIN
大萌喵对DepthBuffer的热情怎么是区区两篇文章能挥洒得完的 ... 后续我将介绍体积光和力场效果的实现, 以及如何为屏幕上的一部分做后期特效, 而不是简单粗暴的一个Graphics.Blit。
大萌喵还只是一个学生党, 非常热切地希望能与各位前辈交流! 如果文章中有任何谬误, 烦请斧正, 大萌喵感激不尽!
完全看不懂,但是应该很厉害的样子。消灭0回复。
完全看不懂,但是应该很厉害的样子。消灭0回复。
完全看不懂,但是应该很厉害的样子。消灭0回复。
作为美术只单纯使用过unity的摄像机雾效和ue的高度雾……原理什么的……
完全看不懂,但是应该很厉害的样子。消灭0回复。
完全看不懂,但是应该很厉害的样子。突破4回复。
很不错