前段时间刚玩《使命召唤11》的时候发现里面新增了一种很高科技的扫描手榴弹,可以产生一圈类似全息效果的扫描圈,并显示出墙后的敌人,类似这样:
![](http://www.lsngo.net/wp-content/uploads/2017/10/led_0.jpg)
最近打算实现一个用在第三人称中类似的效果,如下:
![](http://www.lsngo.net/wp-content/uploads/2017/10/scan.gif)
实现方案:
1.根据_CameraDepthTexture计算屏幕空间像素点的世界坐标
2.将扫描发起位置的世界坐标传入shader
3.计算屏幕空间世界坐标到扫描发起位置世界坐标的距离
4.根据相关参数渲染出扫描区域
1._CameraDepthTexture中记录了投影空间的深度信息,有两种方式可以得到屏幕空间的世界坐标:
第一种方式:直接计算投影坐标并通过传入的投影空间到世界的矩阵还原世界坐标。
1 2 3 4 | fixed depth = tex2D(_CameraDepthTexture, i.uv).r; fixed4 projPos = fixed4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, -depth * 2 + 1, 1); worldPos = mul(internalCameraToWorld, worldPos); worldPos /= worldPos.w; |
这一步的原理是先采样深度图,得到投影空间深度,通过屏幕uv可以得到该点的投影坐标projPos,然后通过投影转世界矩阵将其还原为世界坐标。
但这样计算需要在pixel shader下进行逐像素的投影坐标计算,实际上在shader中可以通过worldPos = _WorldSpaceCameraPos.xyz + linearDepth*ray的方式得到,其中_WorldSpaceCameraPos是世界空间的相机坐标,unity已经为我们提供了,linearDepth为线性深度,只需通过Linear01Depth即可计算得到,所以最终归结为如何计算ray向量。
我们可以通过在顶点函数中计算相机到屏幕的四个角落的向量,通过光栅化阶段的插值,即可在像素着色器中得到每个像素位置的ray向量。
首先我们需要在cpu中计算指向屏幕的四个角落的向量,并存入一个矩阵:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | Matrix4x4 frustumCorners = Matrix4x4.identity; float fovWHalf = camera.fieldOfView * 0.5f; Vector3 toRight = camera.transform.right * camera.nearClipPlane * Mathf.Tan(fovWHalf * Mathf.Deg2Rad) * camera.aspect; Vector3 toTop = camera.transform.up * camera.nearClipPlane * Mathf.Tan(fovWHalf * Mathf.Deg2Rad); Vector3 topLeft = (camera.transform.forward * camera.nearClipPlane - toRight + toTop); float camScale = topLeft.magnitude * camera.farClipPlane / camera.nearClipPlane; topLeft.Normalize(); topLeft *= camScale; Vector3 topRight = (camera.transform.forward * camera.nearClipPlane + toRight + toTop); topRight.Normalize(); topRight *= camScale; Vector3 bottomRight = (camera.transform.forward * camera.nearClipPlane + toRight - toTop); bottomRight.Normalize(); bottomRight *= camScale; Vector3 bottomLeft = (camera.transform.forward * camera.nearClipPlane - toRight - toTop); bottomLeft.Normalize(); bottomLeft *= camScale; frustumCorners.SetRow(0, topLeft); frustumCorners.SetRow(1, topRight); frustumCorners.SetRow(2, bottomRight); frustumCorners.SetRow(3, bottomLeft); |
之后将frustumCorners传入shader,这里有一个问题,由于传入的是四阶矩阵,刚好对应屏幕四个角点,如何在shader中判断哪个角点使用矩阵的哪一阶,这里《unity shader入门精要》中使用的方式是直接在shader中通过屏幕uv判断角点位置,而unity官方屏幕特效包中的体积雾则使用了一种更有意思的做法,实现了一个自定义GraphicsBlit,这个方法相当于直接为屏幕特效所使用的四个角点的z值设置为索引,则在shader中可以直接通过该索引访问矩阵:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | private static void CustomGraphicsBlit(RenderTexture source, RenderTexture dest, Material fxMaterial) { //Graphics.Blit(source, dest, fxMaterial); //return; RenderTexture.active = dest; fxMaterial.SetTexture( "_MainTex" , source); GL.PushMatrix(); GL.LoadOrtho(); fxMaterial.SetPass(0); GL.Begin(GL.QUADS); //注意GL.Vertex3的z值 GL.MultiTexCoord2(0, 0.0f, 0.0f); GL.Vertex3(0.0f, 0.0f, 3.0f); // BL GL.MultiTexCoord2(0, 1.0f, 0.0f); GL.Vertex3(1.0f, 0.0f, 2.0f); // BR GL.MultiTexCoord2(0, 1.0f, 1.0f); GL.Vertex3(1.0f, 1.0f, 1.0f); // TR GL.MultiTexCoord2(0, 0.0f, 1.0f); GL.Vertex3(0.0f, 1.0f, 0.0f); // TL GL.End(); GL.PopMatrix(); } |
shader代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | v2f vert (appdata v) { v2f o; half index = v.vertex.z; //取得索引 v.vertex.z = 0.1; //将z值还原,否则会渲染错误 o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv.xy; o.uv_depth = v.uv.xy; #if UNITY_UV_STARTS_AT_TOP if (_MainTex_TexelSize.y < 0) o.uv.y = 1 - o.uv.y; #endif o.interpolatedRay = _FrustumCorners[( int )index].xyz; //根据在程序中计算好的顶点z值作为索引 return o; } fixed4 frag (v2f i) : SV_Target { fixed4 c = tex2D(_MainTex, UnityStereoTransformScreenSpaceTex(i.uv)); fixed depth = Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, UnityStereoTransformScreenSpaceTex(i.uv_depth))); fixed4 worldPos = fixed4(depth*i.interpolatedRay, 1); worldPos.xyz += _WorldSpaceCameraPos; ... } |
总结两种方法:第一种方法比较容易理解,即强行使用投影矩阵的逆矩阵将投影坐标还原,优点是同时支持透视和正交投影,缺点是逐像素进行矩阵计算,第二种方法只需在顶点阶段确定指向世界坐标的射线方向,片段着色器中计算较少,缺点是只支持透视投影
2.计算传入的初始位置和屏幕空间世界坐标距离:
1 2 3 4 | fixed dis = length(internalCentPos.xyz - worldPos.xyz); fixed a = 1 - saturate(( abs (dis - internalArg.x) - internalArg.y) / internalArg.z); a = a * internalFade.x + c * internalFade.y; |
最终可以得到如下效果:
![](http://www.lsngo.net/wp-content/uploads/2017/10/scan_2.gif)
3.保存上一步的渲染结果,使用CommandBuffer,将需要标记为持续显示的目标(例如敌人)也渲染到该纹理,注意需要判断目标是否在摄像机内,效果如下:
![](http://www.lsngo.net/wp-content/uploads/2017/10/scan_6.gif)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | public static void CallRender(Vector3 worldPosition, Renderer[] renderer) { if (!IsInitialized()) return ; if (instance.m_IsShowingEffect) { if (renderer == null ) return ; Vector3 pjpos = instance.m_Camera.worldToCameraMatrix.MultiplyPoint(worldPosition); pjpos = instance.m_Camera.projectionMatrix.MultiplyPoint(pjpos); if (pjpos.x < -1 || pjpos.x > 1 || pjpos.y < -1 || pjpos.y > 1 || pjpos.z < -1 || pjpos.z > 1) return ; for ( int i = 0; i < renderer.Length; i++) { instance.m_CommandBuffer.DrawRenderer(renderer[i], instance.m_ReplaceMaterial); } } } |
4.根据屏幕uv信息将屏幕uv栅格化,并计算每个格子中采样到的颜色值,可以得到如下结果:
![](http://www.lsngo.net/wp-content/uploads/2017/10/scan_3.gif)
1 2 3 4 5 6 | float2 fl = floor (i.uv * _EffectScale); float dp = tex2D(_PreTex, (fl + float2(0.5, 0.5)) / _EffectScale); float4 led = tex2D(_EffectTex, i.uv * _EffectScale - fl); col.rgb += led.rgb*dp; |
5.同样根据刚刚栅格的结果,可以计算出每一小格的uv,根据该uv来采样用于作为全息扫描效果的纹理,得到如下结果:
![](http://www.lsngo.net/wp-content/uploads/2017/10/scan_4.gif)
6.叠加最终结果:
![](http://www.lsngo.net/wp-content/uploads/2017/10/scan_5.gif)