Unity3D实现体积光

体积光是现实中常见的因丁达尔效应而产生的一种大气现象,文人墨客经常使用“慵懒的阳光泄下”描绘该现象带来的美感。笔者在一次旅游后见到了这种神奇的天然现象,遂决定在游戏中实现并使用这样的效果。git

青藏高原上雨后初晴的体积光github

体积光是由空气中的水蒸气和灰尘对光线的散射形成的,显然在实时渲染中咱们没法模拟庞杂的微小粒子,只能用近似的方法得到,咱们这里将更加着重于实现。web

咱们将使用Unity自带的Shadowmap实现体积光,众所周知,实时渲染中的阴影是经过将像素点的世界坐标(在fragment shader中得到或由像素深度反推得到)乘灯光的viewProjectionMatrix得到该像素点在shadowmap下的uv,并与shadowmap记录的像素点深度进行比较,若shadowmap记录的深度大于等于该点,则表示该点并无在阴影下,应该受到光照,反之则表示该点受到遮挡,不该该受到光照。Unity中目前支持的实时光照有Directional Light, Spot Light, Point Light,这三种光照类型的实现大同小异。算法

另外,但愿读者在阅读以前,确保本身对Unity渲染管线,官方提供的.cginc文件以及CommandBuffer, GL等API比较熟悉,接下来的分析将不会出现对此类基础的详解。cookie

效果的实现所有在本人的开源里,注意,本开源项目并不是彻底原创,在通过Slightly Mad大神的赞成后,对其开源原型进行魔改,增减,并上传:svg

源码地址:http://link.zhihu.com/?target=https%3A//github.com/MaxwellGengYF/Unity-Volumetric-Light函数

Directional Light:性能

Directional light经常使用来模拟太阳光,月光等天然光照,因为全部物体都会受到directional light的影响,所以咱们的能够直接使用后处理来实现。正如连接里的解释,raymarch的基本原理就是将一条线段按比例分红多段,而后将每一个顶点上的采样结果相加,因此首先须要得到射线的起点,咱们经过如下代码得到renderTexture上的4个角的近裁面位置,而后在后处理shader中经过fragment shader的线性插值便可得到每一个像素点在近裁面上的位置:优化

cam = GetComponent();orm

Matrix4x4 inverseViewProjectionMatrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, true);

inverseViewProjectionMatrix *= cam.worldToCameraMatrix;

inverseViewProjectionMatrix = inverseViewProjectionMatrix.inverse;

Vector3 leftBottom = inverseViewProjectionMatrix.MultiplyPoint(new Vector3(-1, -1, 1));

Vector3 rightBottom = inverseViewProjectionMatrix.MultiplyPoint(new Vector3(1, -1, 1));

Vector3 leftTop = inverseViewProjectionMatrix.MultiplyPoint(new Vector3(-1, 1, 1));

Vector3 rightTop = inverseViewProjectionMatrix.MultiplyPoint(new Vector3(1, 1, 1));

这样咱们就拥有了摄像机近裁面的四个角的世界坐标,同理能够经过这样的方法取得摄像机远裁面的四个角:

cam = GetComponent();

Matrix4x4 inverseViewProjectionMatrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, true);

inverseViewProjectionMatrix *= cam.worldToCameraMatrix;

inverseViewProjectionMatrix = inverseViewProjectionMatrix.inverse;

Vector3 leftBottom = inverseViewProjectionMatrix.MultiplyPoint(new Vector3(-1, -1, 0));

Vector3 rightBottom = inverseViewProjectionMatrix.MultiplyPoint(new Vector3(1, -1, 0));

Vector3 leftTop = inverseViewProjectionMatrix.MultiplyPoint(new Vector3(-1, 1, 0));

Vector3 rightTop = inverseViewProjectionMatrix.MultiplyPoint(new Vector3(1, 1, 0));

使用其余引擎或DirectX的朋友请注意,Unity3D是使用OpenGL标准的投影,因此咱们这里须要通过GL.GetGPUProjectionMatrix函数得到确实的投影矩阵,而且远裁面深度为0,近裁面深度为1。

以近裁面坐标做为线段起点,以近裁面坐标到远裁面坐标为线段方向,咱们还须要肯定线段的终点,显然,直接取远裁面是很是愚蠢的,假设摄像机会渲染1000米,而咱们的显卡性能中等偏良好,大概能够承受64次采样的性能消耗,那每一步采样之间将会相隔1000 / 64 = 15.625米,也就是说在15米左右以内的物体变化都不会体积光产生任何影响,并且还会致使采样点出如今着色像素以后,产生严重的bug。

所以,咱们须要经过深度图,得到屏幕像素的深度并以此为线段的终点,同时限制线段最大长度,将线段长度设置到一个精度与视觉效果的相对平衡,肯定终点的示例代码大体以下:

// _InverseViewProjectionMatrix UNITY_MATRIX_VP矩阵的逆矩阵

// _SampleCount 采样率

// _VolumetricIntensity 体积光强度

// _MaxLength 最远采样距离

float depth = tex2D(_CameraDepthTexture, i.uv).r; //像素深度

float worldPos = mul(_InverseViewProjectionMatrix, float3(i.uv * 2 - 1, depth)); //经过NDC坐标反推世界坐标

float3 startPos = … //已经取得的世界空间近裁面坐标

float3 direction = normalize(worldPos - startPos); //线段归一化方向

float m_length = min(_MaxLength, length(worldPos - startPos)); //获取线段的目标长度

float perNodeLength = m_length / _SampleCount; //每两个采样点之间的距离

float3 currentPoint = startPos; //记录当前采样点的位置

float intensity = 0;

for (int i = 0; i < _SampleCount; ++i){ //进行固定次数的采样

currentPoint += direction * perNodeLength; //更新当前采样点

intensity += GetShadow(currentPoint); //得到当前坐标的阴影遮挡信息

}

intensity *= _VolumetricIntensity * m_Length; //对体积光的强度进行控制

return intensity;

经过相似实现,能够得到一个简易的体积光效果,如何得到阴影采样的实现,在UnityCG.cginc和UnityShadowLibrary.cginc中有详细的实现,文件目录在(Unity Folder)/Editor/Data/CGIncludes/,其中平行光的阴影采样涉及到cascadeShadowMap的计算,须要对采样进行由近及远分红4层,有兴趣的朋友能够自行研究一下,这里再也不赘述。

2. Point Light

不一样光源之间其实惟一的不一样就是计算ray march线段的起点与终点的方法不一样,point light的照射范围实际上是一个球形,因此咱们能够简单的直接在灯光的位置放一个球,而后经过直线与球交点的计算方法,而后其余诸如深度比较等操做,与Directional Light并没有不一样,示例代码大体以下:

// _Center 球心坐标

// _Radius 球半径

// _InverseViewProjectionMatrix UNITY_MATRIX_VP矩阵的逆矩阵

// _SampleCount 采样率

// _VolumetricIntensity 体积光强度

float depth = tex2D(_CameraDepthTexture, i.uv).r; //像素深度

float pixelWorldPos= mul(_InverseViewProjectionMatrix, float3(i.uv * 2 - 1, depth)); //经过NDC坐标反推世界坐标

float3 wpos = … //球表面位置

float3 wNormal = … //归一化的球表面法线

float3 startPos = wpos; //肯定线段起始点

float3 camToSurface = wpos - _WorldSpaceCameraPos;//从摄像机到表面的方向,即ray march的迭代方向

float camToSurfaceLength = length(camToSurface);//摄像机到球表面的距离

float3 surfaceToCenter = -wNormal * _Radius; //球表面到球心

float3 direction = normalize(camToSurface); //线段方向

float3 desiredEndPoint = startPos + dot(normalize(camToSurface), surfaceToCenter) * 2 * camToSurface;

//这里经过简单的立体几何知识,求得射线与球的先后两个相交点

float m_length = length(startPos - pixelWorldPos); //从起点到屏幕像素点的距离

m_length = min(m_length, length(startPos - desiredEndPoint));//得到最小线段距离

float intensity = 0;

float3 currentPoint = startPos;

float perNodeLength = m_length / _SampleCount; //每两个采样点之间的距离

for(int i = 0; i < _SampleCount; ++i){

currentPoint += direction * perNodeLength; //更新当前采样点

intensity += GetShadow(currentPoint - _Center);

//注意,point light使用cubemap做为shadowmap,所以咱们须要传入球心到采样点的方向

}

intensity *= _VolumetricIntensity * m_Length; //对体积光的强度进行控制

return intensity;

3. Spot Light

原理与点光源其实大体类似,只不过咱们这里为了性(tou)能(lan),就不在shader中判断角度,而是直接使用CommandBuffer渲染两次锥形mesh,而后经过两次渲染的深度的差异,计算线段的起点和终点,示例代码大体以下(注意,这段代码在背面深度已渲染到指定的RenderTarget以后,被渲染的像素应该在Mesh的正面):

// _InverseViewProjectionMatrix UNITY_MATRIX_VP矩阵的逆矩阵

// _SampleCount 采样率

// _VolumetricIntensity 体积光强度

// _BackDepthTexture 光源mesh的背面深度

float backDepth = tex2D(_BackDepthTexture, i.uv).r; //背面深度

float backWorldPos= mul(_InverseViewProjectionMatrix, float3(i.uv * 2 - 1, backDepth )); //经过NDC坐标反推世界坐标

float depth = tex2D(_CameraDepthTexture, i.uv).r; //深度图像素深度

float pixelWorldPos= mul(_InverseViewProjectionMatrix, float3(i.uv * 2 - 1, depth )); //经过NDC坐标反推世界坐标

float3 direction = normalize(backWorldPos - _WorldSpaceCameraPos);

float3 startPos = wpos; // 起点位置即为Mesh的正面像素位置

float m_Length = min(length(backWorldPos - startPos), length(pixelWorldPos - startPos)); //线段长度

float intensity = 0;

float3 currentPoint = startPos;

float perNodeLength = m_length / _SampleCount; //每两个采样点之间的距离

for(int i = 0; i < _SampleCount; ++i){

currentPoint += direction * perNodeLength; //更新当前采样点

intensity += GetShadow(currentPoint);

}

intensity *= _VolumetricIntensity * m_Length; //对体积光的强度进行控制

return intensity;

直接渲染两次mesh这个方法确实比较丑陋,不过咱们二者相权取其轻,多一个drawcall通常来说比在shader中进行复杂的运算更为节省性能,并且还要考虑到lighting cookie的使用,在许屡次时代渲染中,场景地编经常使用cookie改变灯光形状,增长灯光真实度,所以咱们必须保证GetShadow传入的值确实的存在于shadowmap中。

4. 优化性能:

因为屡次迭代,体积光的消耗实际很是高昂,即便是在性能强大的PC平台也是如此,所以咱们能够考虑许多优化方法大幅度下降性能消耗,而降采样就是其中很是好的一种方法。咱们将目标贴图的分辨率设置为摄像机Render Target的分辨率的一半,也就是直接将计算量缩小到了以前的1/4,可是降采样后的图像马赛克化严重,咱们可使用高斯模糊,将其经过正态分布的模糊采样放回到原分辨率的贴图中,降采样不只不会下降画质,反而会给体积光带来一种朦胧感与意境。

降采样模糊后的太阳体积光效果

5. 进一步提升质量:

细心观察的朋友已经注意到,这个体积光虽然已经实现,可是极其生硬,单调,仿佛蒙了一层布同样,实在谈不上美感,缘由很简单,空气中的雾气,尘埃并非简单的发光,而是折射太阳光,所以咱们须要考虑透光时光线强弱的变化。

首先,既然是透射,那么当视线与太阳射入角度越小时,阳光应该愈加强劲,这种算法称之为Mie Scattering,即经过视线与光源夹角计算强度值,有时会在次表面透射材质中使用到。加上Mie Scattering后效果为:

加上Mie Scattering后已经初步拥有了真实感

大气的状况复杂多变,有时雾并非静止不动而是在风的影响下有缓慢的飘动,可是如果使用3D贴图进行二次raymarch,将会致使性能消耗很是高昂,这是不管如何也没法接受的,所以咱们这里使用一个比较Trick的方法,使用一张2D贴图在屏幕空间进行采样,影响体积光的强度。(Demo中这么使用确实没什么问题,可是有时场景复杂可能会穿帮)。

水雾的密度大于空气,所以会缓慢下沉,因为咱们的ray march全程使用统一的世界坐标,所以想要基于采样点的高度进行强度累计很是容易,这里就不赘述了,直接给出最终实现效果:

  大连那家医院看男科 http://www.lnbohaink.com/

  大连男科医院哪一个好 http://www.0411nk.cn/