SpotLight是Unity里面常用的灯光类型,我们渲染它的时候按照生活常识来说需要渲染两个部分,一个是照亮东西的效果,如下图:
![84ba64273baa2c537b6d80d1271b54d6.png](https://i-blog.csdnimg.cn/blog_migrate/1599a473acb7045cb25177724384f99e.jpeg)
另外一个当然就是舞台上经常看到的光柱效果,学名叫体积光,如下图所示:
![e5f9eacada62813a5baf241f79c665f8.png](https://i-blog.csdnimg.cn/blog_migrate/f90595d22e0619fc540c831ffc36a7db.jpeg)
这篇文章就主要针对如何高质量并且高效率的实现体积光的渲染做出分析。
体积光分析
首先我们要分析所渲染的对象是个什么东西,体积光这个东西其实是光线在介质中传播的时候遇到介质中的杂质被反射到其他方向从而被观察者看到的一束发亮的光辉,这个其实是光的散射。
由于在项目实际需求中,一般是用到探照灯、舞台射灯、大路灯,房间台灯下这样的灯光效果中,实现这种效果渲染其实有两种方案,一种是直接按照物理规律,于屏幕空间发射射线不断采样,探查每个位置的光照散射亮度,然后累加起来,这样的好处是效果真实可信,对于被遮挡而呈现出明暗变化的光线有很好的还原效果。但是这样的计算极度依赖采样数量,每个像素至少64次采样或者在有TAA和抖动的情况下可以少一些。对于现在的移动平台来说计算量太大,距离实时运行起来还太遥远。当然另外一种做法就是直接贴片,透明面片,一巴掌扇在摄像机上,各种假,各种穿帮,当然也有优秀的插件能够做质量还算可以接受的效果。比如VolumetricLightBeam插件提供的效果:
![c3478f0a68ea8a25a16318002b5fd05e.png](https://i-blog.csdnimg.cn/blog_migrate/2775206b459f9fd119fbccdd7364dbcf.jpeg)
如果大家不想自己看本文实现一套体积光效果,可以直接使用这个插件,里面整合了很多功能。
当然我希望的是百尺竿头更进一步,能不能把效果做得更好?
VolumetricLightBeam插件提供的效果有一定的局限性,在有些角度下会有穿帮的问题,比如直射摄像机的时候,中心点莫名其妙的黑点和模型的棱处会稍微更亮一些。当视线移动的时候看上去很明显的圆锥模型。
![f659babc381dc8d00c15273d615fd756.png](https://i-blog.csdnimg.cn/blog_migrate/e7d79a61a9620073890e3e066095a561.jpeg)
那么我们该怎么让一个模型做得体积光没有这种模型感出现呢?我就在想模型能不能只提供一个渲染的入口,仅仅是用来划定体积光的渲染范围,我只需要视线和射灯的属性数据直接计算当前视线穿过射灯光柱应该有的效果?
有一篇知乎上的文章讲了如何计算视线穿过点光源一段距离点光源在视线上的光照强度的累计值:https://zhuanlan.zhihu.com/p/21425792 这篇文章里面讲了如何通过光线追踪累加出在匀质物理内部每个采样点的亮度值,然后推导出了如果没有阴影的话,直接通过起始位置、视线方向、距离、光源位置积分计算出整段长度的亮度值。
![dc18b750bd3a2c07ab21d3cb1d2de79c.png](https://i-blog.csdnimg.cn/blog_migrate/a92e1dff50649290e8d5083ea81472d8.jpeg)
有了这个东西,我们做效果比较真实的体积光就有着落了。那么现在的问题就是如何求得起始位置、视线方向、视线透过射灯光柱的距离?
视线圆锥求交
我们将计算模型简化成求视线和圆锥的两个交点,分析图如下:
![ddd5b164da1b725738f20587c056e857.png](https://i-blog.csdnimg.cn/blog_migrate/f1739ad2ad3a42e5d88edaf85cda1bbc.jpeg)
摄像机的视线(设视线和圆锥相交于A’ B’两点)和圆锥的锥顶O可以组成一个平面OA’B’,在图中是红色面,圆锥底部是蓝色面,平面OA’B’和圆锥相交的形状是三角形,相关知识:https://baike.baidu.com/item/%E5%9C%86%E9%94%A5%E6%9B%B2%E7%BA%BF/6691222?fr=aladdin
设平面OA’B’和圆锥底面圆相交于A点和B点,那么该平面也可以称为平面OAB。我们现在第一步就是要分析出射线OA和OB的朝向。我们现在知道O的位置、C的位置、圆锥张角的一半α,设AB中点为P,由于全等和对称,OP垂直于AB。
首先我们可以通过模型点、相机位置、圆锥顶点O三点求出平面OAB,设平面的法向为Ncut,由图可知,OP的朝向其实是OC在平面OAB上的投影。由于OC朝向就是射灯朝向是已知的,那么OP朝向通过向量在平面投影的计算公式可得:
dirOP = normalize(dirOC - dot(Ncut,dirOC)*Ncut)
现在有了OP我们就进一步分析:
![b38d8fb1b6cce1a1f24953106346204b.png](https://i-blog.csdnimg.cn/blog_migrate/31cf86b15e51c708d6d8a12e343af4f4.jpeg)
我们把圆锥单独拧出来看,红色就是截面在不同方向看上去的样子。∠AOP为γ,∠POC为θ,∠COA和∠COB都是α。Cosθ就等于dot(dirOP,dirOC),cosα已知,那么我们怎么通过θ、α求得γ角呢?
由上图可以得到如下关系
|CP| = |OC| * tanθ; 右视图
|OP| = |OC| / cosθ; 右视图
|AC| = |OC| * tanα; 底面视图
|AC|²-|OP|² = |AP|²; 底面视图和勾股定理
那么由以上等式我们可以得到tanγ = |AP|/|OP| = sqrt((tanα)²- (sinθ / cosθ)²) * cosθ;
那么我们通过tanγ如何求得dirOA和dirOB呢?
首先我们需要求得PA和PB的朝向,很简单,dirPA = normalize(cross(Ncut,OC));dirPB就是反方向即-dirPA。
然后OA = OP + PA , OB = OP + PB,这个时候我们不用关心具体P点在哪里,就当P点在OP直线上O+dirOP的位置,然后|PA| = tanγ*|OP|,由于OP的长度为1(dirOP的长度),那么就简化为|PA| = tanγ。那么OA = OP + tanγ*dirPA = O + dirOP + tanγ * dirPA;
所以dirOA = normalize(O+tanγ* dirPA + dirOP);
dirOB = normalize(O+tanγ* dirPB + dirOP);
好我们写个脚本在OnDrawGizmos里面看看我们计算出来的朝向对不对吧:
![397bc75b8443652ad1289da90563793c.png](https://i-blog.csdnimg.cn/blog_migrate/0644c72425f0e84d05bf792f7289e38f.jpeg)
现在两个交线方向都有了,最后一步就是求交点了A’B’了。同一平面内的三条直线求交点。
求直线和平面的交点的方法我找到了https://www.cnblogs.com/qiu-hua/p/8001177.html。
所以我直接将问题转化成求OA、OB与任意和OAB平面相交于A’B’直线的平面相交点的问题,那么我们能够找到的平面是哪个呢?当然就是垂直于Ncut和视线方向的向量normalize(cross(viewDir,Ncut))了,这个向量在Ncut的法平面上也就是OAB平面上而且因为和视线方向垂直,所以这个向量很适合做目标平面的法向量,然后我们有知道视线看到的位置为meshWorldPos,这样我们有这个平面的法向量和这个平面上的一个点,就可以求得这个平面了,我们管这个平面为viewPlane。利用上面链接提到的方法,计算出两个交点:
![6722f707fe5fe0a0fa14ddf2af85059a.png](https://i-blog.csdnimg.cn/blog_migrate/baa6d25889015634cf072fe2148fba50.jpeg)
然后我们在OnDrawGizmos里面验证一下对不对,在insectStart处画绿球,insectEnd画黄球:
![f1883bbdaf9574f70f0a3dc7550c22b0.png](https://i-blog.csdnimg.cn/blog_migrate/241f04ae44304d8db643ea55a0371b1e.jpeg)
可以看到结果是正确的,交点计算出来了。
现在计算出好看漂亮的体积光的基础已经打好了,但是有了交点,还没完,因为还有几种特殊情况:
一、交点在OA或者OB的反方向上,出现这个情况的原因是摄像机朝向和圆锥表面其实只有一个交点。
二、摄像机在椎体内部。
如果交点都在OA或者OB的正方向上,vptA和vptB的符号会是相同的并且都是负数,交点在OA或OB反方向上那么我们计算出来的vptA或者vptB的符号就会相反,通过这个我们可以判断出是第一个交点反了还是第二个交点反了,由于我们可以使用C#代码来测试我们的计算结果,所以我们可以发现当摄像机视线和椎体表面只有一个交点并且位于椎体下方在椎体笼罩范围内时,vptA > 0,这时我们的起点应该是摄像机近裁剪面上的视点,终点是insectEnd。当摄像机视线和椎体表面只有一个交点并且位于椎体上方不在锥体笼罩范围内时vptB > 0,这个时候我们的起点应该是insectStart,终点位于视线的无穷远处,当然我们直接用9999远的点insectStart + viewDir * 9999代替即可,情况如下图:
![c6bc336ab823add6a2cf4cc75ae5223a.png](https://i-blog.csdnimg.cn/blog_migrate/166d3a9ef84568f5e830835bf512a575.jpeg)
![78d232485d08f27c7d0d6bd74ba632ab.png](https://i-blog.csdnimg.cn/blog_migrate/f1b887affd5a9faaf8a951cd2cf15ea3.jpeg)
当然还有另外一种情况,那就是我能找到2个都在正向的交点,但是因为我的视点在圆锥体笼罩范围内,导致我实际应该从摄像机近裁剪面上的视点计算起,到insectEnd。这种情况如下图:
![6e96e9499eeb59c7235f7f116fe9d5b8.png](https://i-blog.csdnimg.cn/blog_migrate/1e5e39cef6c1eebe96619ef745aa59c3.jpeg)
这样我们的交点的情况就讨论完了,实际计算的时候我们还需要考虑光锥和地面模型或者其他模型穿插的问题,我们需要根据场景深度图计算出被遮挡的部分有多远,因为我们计算的交点距离其实就是视线方向的距离,所以我们直接用相机近裁剪面上的视点和相机最近的交点的距离乘以视线和相机前向夹角的cos值,我们定义为A值,就可以得到视线处光锥最近的深度,然后读取深度图中的深度值两者比较,用深度图的深度减去光锥处的深度就得到光锥被遮挡后的余下部分的深度,然后我们再除以A值就反算成距离。
![90000f1c4114a0cd86f78053b09fffd6.png](https://i-blog.csdnimg.cn/blog_migrate/9108fa94bb6b8ff0918039e1c268c106.png)
![d31f3b27f8c8e161a694dc55e8cf7694.png](https://i-blog.csdnimg.cn/blog_migrate/9ed2a4f26affe0bea69f8181688dcd40.png)
下面就是将视线方向、起点、光源位置、视线穿过光源的距离带入之前的累计光照强度积分公式里面计算了,由于我们的计算是认为光能照射到无穷远,并且是点光源的发光模式,所以我们为了实际需求可以加上距离衰减,按照起点和终点哪个距离光源近做距离衰减,这样就可以得到一个比较适当的体积光外观啦。
![a899818fdca15c508767bfc06d14a9e9.png](https://i-blog.csdnimg.cn/blog_migrate/49b955fb9a45011191f8b9163dd48f77.jpeg)
当然光有这个是不够的,我们还需要边缘衰减,当然可以称为边缘柔化,由于我们之前计算了cosθ,我们就可以通过这个值和cosα来做边缘衰减了。我用的方法是:
1 - saturate((1 - cosθ) / (1 - cosα) - _InnerRate)
_InnerRate是边缘衰减程度,等于1代表不怎么衰减,就会出现和上图一样的效果,等于0就会有最柔和的边缘,如下图:
![40811ac7e15908683d2f678361967a97.png](https://i-blog.csdnimg.cn/blog_migrate/42748f00064078752057ac4ac7b61cfe.jpeg)
平头椎体灯光
现在我们的体积光基本算法就成型了。当然做灯光嘛,这种完全锥体的灯光是很少见的,我们经常遇到的是平头锥体,而不是尖头,是个圆锥台的样子,下面就是根据圆锥台进行的一些算法调整。
首先我们需要另外一个参数值nearClipRadius。这个参数是为了匹配圆形底座的尺寸,直译过来就是近裁剪圆半径,我将平头圆锥的近裁剪圆的圆心设定到对象的0点,然后通过这个值,反过来根据张角计算出圆锥的顶点的实际位置_Pos,以及圆锥顶点到近裁剪圆中心点的距离_NearClip。然后range这个原来控制距离衰减的参数就变成了range + _NearClip。用来保证调整nearClipRadius不会影响到光柱的长度位置。
如果我们就这么直接套上参数计算的话,我们nearClipRadius越大,整个光柱就越暗淡,这个很好理解,就相当于我在裁一个光柱的一段显示,nearClipRadius越大,那么裁下来的位置距离圆锥顶点即光源处就越远,如下图:
![86b71587f70e7fc39b2aad7fdc92c562.png](https://i-blog.csdnimg.cn/blog_migrate/564f825ea8db5990ca8b674d1a3bf87b.jpeg)
这个时候我想到了一个办法,那就是将计算InScatter的时候将原来的光源位置调整到近裁剪圆面的中心去,这样我们就能看到一个比较舒服的射灯了:
![1694209bb0f9b41b0c5301c50e02e7cb.png](https://i-blog.csdnimg.cn/blog_migrate/78380d1def8af23a7a219c7f8d968aa9.jpeg)
效果截图:
![04295825a961d4e3c1678d27ebdeaead.png](https://i-blog.csdnimg.cn/blog_migrate/a330e0415419985ac3f2ac1727c83fa8.jpeg)
![8a2eb2a5605461869fe17ce80b4ab88b.png](https://i-blog.csdnimg.cn/blog_migrate/c4edee34e00abd72f1f86f2fbaf955a8.jpeg)
后记
算法有很多可以修改的地方,比如InScatter只是一种计算光强的方法,可以选择用其他的函数替代,也许能够得到更好的效果,_InnerRate的计算在灯光正面照射的时候其实是不正确的,这段时间一直没有找到别的更准确的计算方法,希望有兴趣的人可以研究一下。
整个光锥我是在OnPostRender里面使用CommandBuffer单独计算的,为了降低OverDraw渲染目标可以在全尺寸、1/4尺寸、1/9尺寸RenderTexture里面选择,性能表现尚可,当然还加了一个摄像机的裁剪,当射灯不会照射到摄像机范围内的时候是不会被渲染的。如果是大量柔和的射灯可以考虑使用低分辨率RenderTexture。我只渲染模型的背面。渲染好了以后Blend One One到原始的颜色图上。
这篇文章完成之前,我已经加入了对噪声的支持:
![b368eec303931cc50536cd4c8785fd81.png](https://i-blog.csdnimg.cn/blog_migrate/b580759dade5c8e5d2d3504f00280e56.jpeg)