最*有个功能, 要渲染从主相机视角看到的另一个相机的可视范围和不可见范围, 大概如下图 :
简单来说就是主相机视野和观察者相机视野重合的地方, 能标记出观察者相机的可见和不可见, 实现原理就跟 ShadowMap 一样, 就是有关深度图, 世界坐标转换之类的, 每次有此类的功能都会很悲催, 虽然它的逻辑很简单, 可是用Unity3D做起来很麻烦...
原理 : 在观察者相机中获取深度贴图, 储存在某张 RenderTexture 中, 然后在主相机中每个片元都获取对应的世界坐标位置, 将世界坐标转换到观察者相机viewPort中, 如果在观察者相机视野内, 就继续把视口坐标转换为UV坐标然后取出储存在 RenderTexture 中的对应深度, 把深度转换为实际深度值后做比较, 如果深度小于等于深度图的深度, 就是观察者可见 否则就是不可见了.
先来看怎样获取深度图吧, 不管哪种渲染方式, 给相机添加获取深度图标记即可:
_cam = GetComponent<Camera>();
_cam.depthTextureMode |= DepthTextureMode.Depth;
然后深度图要精度高点的, 单通道图即可:
renderTexture = RenderTexture.GetTemporary(Screen.width, Screen.height, 0, RenderTextureFormat.RFloat); renderTexture.hideFlags = HideFlags.DontSave;
之后就直接把相机的贴图经过后处理把深度图渲染出来即可 :
private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (_material && _cam) { Shader.SetGlobalFloat("_cameraNear", _cam.nearClipPlane); Shader.SetGlobalFloat("_cameraFar", _cam.farClipPlane); Graphics.Blit(source, renderTexture, _material); } Graphics.Blit(source, destination); }
材质就是一个简单的获取深度的shader :
sampler2D _CameraDepthTexture; uniform float _cameraFar; uniform float _cameraNear; float DistanceToLinearDepth(float d, float near, float far) { float z = (d - near) / (far - near); return z; } fixed4 frag(v2f i) : SV_Target { float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); depth = LinearEyeDepth(depth); depth = DistanceToLinearDepth(depth, _cameraNear, _cameraFar); return float4(depth, depth, depth, 1); }
这里有点奇怪吧, 为什么返回的不是深度贴图的深度, 也不是 Linear01Depth(depth) 的归一化深度, 而是自己计算出来的一个深度?
这是因为缺少官方文档........其实看API里面有直接提供 RenderBuffer 给相机的方法 :
_cam.SetTargetBuffers(renderTexture.colorBuffer, renderTexture.depthBuffer);
可是没有文档也没有例子啊, 鬼知道你渲染出来我怎么用啊, 还有 renderTexture.depthBuffer 到底怎样作为 Texture 传给 shader 啊... 我之前尝试过它们之间通过 IntPtr 作的转换操作, 都是失败...
于是就只能用最稳妥的方法, 通过 Shader 渲染一张深度图出来吧, 然后通过 SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); 获取到的深度值, 应该是 NDC 坐标, 深度值的范围应该是[0, 1]之间吧(非线性), 如果在其它相机过程中获取实际深度的话, 就需要自己实现 LinearEyeDepth(float z) 这个方法:
inline float LinearEyeDepth( float z ) { return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w); }
而在不同*台, _ZBufferParams 里面的值是不一样的, 所以如果实现它的话会很麻烦, 并且经过我的测试, 结果是不对的......
double x, y; OpenGL would be this: x = (1.0 - m_FarClip / m_NearClip) / 2.0; y = (1.0 + m_FarClip / m_NearClip) / 2.0; D3D is this: x = 1.0 - m_FarClip / m_NearClip; y = m_FarClip / m_NearClip; _ZBufferParams = float4(x, y, x/m_FarClip, y/m_FarClip);
PS : 使用观察者相机的 nearClipPlane, farClipPlane 计算出来的深度也是不正确的, 不知道为什么......
所以就有了我自己计算归一化深度的方法了:
float DistanceToLinearDepth(float d, float near, float far) { float z = (d - near) / (far - near); return z; }
简单好理解, 这样就获得了我自己计算的深度图, 只需要观察者相机的 far/near clip panel 的值即可. 所以可以看出, 由于缺少官方文档等原因, 做一个简单的功能往往会很花费时间, 说真的搜索了很多例子, 直接代码Shader复制过来都不能用, 简直了.
接下来是难点的主相机渲染, 主相机需要做这几件事 :
1. 通过主相机深度图, 获取当前片元的世界坐标位置
2. 通过把世界坐标位置转换到观察者相机视口空间, 检测是否在观察者相机视口之内.
3. 不在观察者相机视口之内, 正常显示.
4. 在观察者相机视口之内的部分, 将视口坐标转换成观察者相机的 UV 坐标, 然后对观察者相机渲染出来的深度图进行取值, 变换为实际深度值然后进行深度比较, 如果片元在观察者相机视口的坐标深度小于等于深度图深度, 则是可见部分, 其它是不可见部分.
主相机也设置打开深度图渲染 :
_cam = GetComponent<Camera>();
_cam.depthTextureMode |= DepthTextureMode.Depth;
那么怎样通过片元获取世界坐标位置呢? 最稳妥的是参考官方 Fog 相关 Shader, 因为使用的是后处理, 是无法在顶点阶段计算世界坐标位置后通过插值在片元阶段获得片元坐标位置的, 我们可以通过视锥的4个射线配合深度图来进行世界坐标还原. 下图表示了视锥体 :
后处理的顶点过程可以看成是渲染 ABCD 组成的*裁面的过程, 它只有4个顶点, 如果我们可以将OA, OB, OC, OD 四条射线存入顶点程序, 那么在片元阶段就能获得自动插值的相机到片元对应的*裁面的射线, 因为深度值 z 是一个非透视值( 也就是说透视投影矩阵虽然变换了z值, 可是z值直接保留到了w中, 其实是没有被变换的意思 ), 而 x, y 是经过透视变换的, 那么刚好四个射线方向通过GPU插值就符合逆透视变换了( Z在向量插值中不变 ).
假设 ABCD 面现在不是*裁面, 而是离 O 点距离为1的裁面, 那么 ABCD 的中心 M 点跟 O 点的射线 OM 的长度就是1, 那么 O 点片元代表的位置就可以简单计算出来了 :
float3 interpolatedRay = OM 向量(世界坐标系) float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, float2(0.5, 0.5))); // 0.5是屏幕中点 float3 wpos = _WorldSpaceCameraPos + linearDepth * interpolatedRay.xyz;
使用相机位置 + OM射线方向 * 深度 就可以算出来了. 那么其他片元怎么计算呢? 使用 OA, OB, OC, OD 向量让 GPU 自动插值计算即可, 其实 OM 向量就是它们的插值结果, 计算这四个向量也有几种方法, 我试了在顶点阶段通过判断顶点Index来计算, 不过似乎结果不正确, 就直接在 C# 中计算了(老版本的Fog Shader就是这样计算的) :
Matrix4x4 frustumCorners = Matrix4x4.identity; //先计算*裁剪*面的四个角对应的向量 float fov = _cam.fieldOfView; float near = _cam.nearClipPlane; float aspect = _cam.aspect; var cameraTransform = _cam.transform; float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad); Vector3 toRight = cameraTransform.right * halfHeight * aspect; Vector3 toTop = cameraTransform.up * halfHeight; Vector3 topLeft = cameraTransform.forward * near + toTop - toRight; float scale = topLeft.magnitude / near; topLeft.Normalize(); topLeft *= scale; Vector3 topRight = cameraTransform.forward * near + toRight + toTop; topRight.Normalize(); topRight *= scale; Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight; bottomLeft.Normalize(); bottomLeft *= scale; Vector3 bottomRight = cameraTransform.forward * near + toRight - toTop; bottomRight.Normalize(); bottomRight *= scale; //将4个向量存储在矩阵类型的frustumCorners 中 frustumCorners.SetRow(0, bottomLeft); frustumCorners.SetRow(1, bottomRight); frustumCorners.SetRow(2, topRight); frustumCorners.SetRow(3, topLeft); Shader.SetGlobalMatrix("_FrustumCornersRay", frustumCorners);
这样就把 OA, OB, OC, OD 传入 Shader 了, 只要在顶点阶段根据 UV 的象限取出对应的向量来就行了. 我们可以获取片元对应的世界坐标之后, 就要把世界坐标转换到观察者相机的视口之内去, 看看它是不是在观察者相机视野内 :
把观察者相机的矩阵以及参数传入Shader
Shader.SetGlobalFloat("_cameraNear", _cam.nearClipPlane); Shader.SetGlobalFloat("_cameraFar", _cam.farClipPlane); Shader.SetGlobalMatrix("_DepthCameraWorldToLocalMatrix", _cam.worldToCameraMatrix); Shader.SetGlobalMatrix("_DepthCameraProjectionMatrix", _cam.projectionMatrix);
转换坐标到观察者视口( wpos 就是前面计算出来的世界坐标 ) :
// 透视投影之后的坐标转换成NDC坐标, 再转换成视口坐标 float2 ProjPosToViewPortPos(float4 projPos) { float3 ndcPos = projPos.xyz / projPos.w; float2 viewPortPos = float2(0.5f * ndcPos.x + 0.5f, 0.5f * ndcPos.y + 0.5f); return viewPortPos; }
float3 viewPosInDepthCamera = mul(_DepthCameraWorldToLocalMatrix, float4(wpos, 1)).xyz; float4 projPosInDepthCamera = mul(_DepthCameraProjectionMatrix, float4(viewPosInDepthCamera, 1)); float2 viewPortPosInDepthCamera = ProjPosToViewPortPos(projPosInDepthCamera); float depthCameraViewZ = -viewPosInDepthCamera.z;
判断是否在观察者视野内, 不在视野内的直接返回原值 :
if (viewPortPosInDepthCamera.x < 0 || viewPortPosInDepthCamera.x > 1) { return col; } if (viewPortPosInDepthCamera.y < 0 || viewPortPosInDepthCamera.y > 1) { return col; } if (depthCameraViewZ< _cameraNear || depthCameraViewZ > _cameraFar) { return col; }
在视野内的, 就需要从观察者相机的深度图获取深度进行深度对比 :
Shader.SetGlobalTexture("_ObserverDepthTexture", getDepthTexture.renderTexture); // 把深度图传入主相机Shader
float2 depthCameraUV = viewPortPosInDepthCamera.xy; #if UNITY_UV_STARTS_AT_TOP if (_MainTex_TexelSize.y < 0) { depthCameraUV.y = 1 - depthCameraUV.y; } #endif float observer01Depth = tex2D(_ObserverDepthTexture, depthCameraUV).r; float observerEyeDepth = LinearDepthToDistance(observer01Depth, _cameraNear, _cameraFar); float4 finalCol = col * 0.8; float4 visibleColor = _visibleColor * 0.4f; float4 unVisibleColor = _unVisibleColor * 0.4f; // depth check if (depthCameraViewZ <= observerEyeDepth) { return finalCol + visibleColor; } else { // bias check if (abs(depthCameraViewZ - observerEyeDepth) <= _FieldBias) { return finalCol + visibleColor; } else { return finalCol + unVisibleColor; } }
深度贴图是我们自己计算的01深度值, 这里解压深度贴图也是使用观察者相机的 nearClipPlane, farClipPlane 直接计算得到的, 最基本的功能就完全实现了, 那些一大堆 if else 就由他去吧, 永远习惯不了着色语言的写法.
那么说让人头大的地方在哪呢? 上面过程中也出现过了, 包括 C# 那边提供的 API 没有文档不会用, 深度图解压函数自己实现会出错( 完全按照官方人员论坛回复来写的 ), 在顶点阶段自己计算视锥射线出错( 又难测试又难找资料 ), 还有上文中的宏 UNITY_UV_STARTS_AT_TOP 这些我哪知道什么时候要加什么时候不用加啊, 甚至 C# 中的 GL 函数还有 GL.GetGPUProjectionMatrix() 这样的功能.
其实最希望引擎能实现的是我告诉它我希望用那种坐标系或者用哪种标准来编程, 这样我就使用习惯的写法就行了啊, 比如屏幕0,0位置我习惯左上角, 你习惯左下角这样的. 就不需要每个人都对这些宏熟悉才能写个Shader. 我觉得都是比较习惯D3D标准的吧.
Shader.SetGlobalStandard("D3D"); // DX标准 Shader.SetGlobalStandard("OPENGL"); // OpenGL -- 提供全局设置和单独设置的功能的话
最后做出来的只是基本功能, 并没有对距离深度做样本估计, 所以会像硬阴影一样有锯齿边, 还有像没开各项异性的贴图一样有撕扯感, 解决这些问题的方法跟 ShadowMap 一样一样的.
ShadowMap 我在 Unity4.x 的时候就试着写过了, 用的是 VSM 做的软阴影, 现在写这个感觉还是跟那年一样, 没有便利性上的提升......
using System.Collections; using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(Camera))] public class GetDepthTexture : MonoBehaviour { public RenderTexture renderTexture; public Color visibleCol = Color.green; public Color unVisibleCol = Color.red; private Material _material; public Camera _cam { get; private set; } public LineRenderer lineRendererTemplate; void Start() { _cam = GetComponent<Camera>(); _cam.depthTextureMode |= DepthTextureMode.Depth; renderTexture = RenderTexture.GetTemporary(Screen.width, Screen.height, 0, RenderTextureFormat.RFloat); renderTexture.hideFlags = HideFlags.DontSave; _material = new Material(Shader.Find("Custom/GetDepthTexture")); _cam.SetTargetBuffers(renderTexture.colorBuffer, renderTexture.depthBuffer); if(lineRendererTemplate) { var dirs = VisualFieldRenderer.GetFrustumCorners(_cam); for(int i = 0; i < 4; i++) { Vector3 dir = dirs.GetRow(i); var tagPoint = _cam.transform.position + (dir * _cam.farClipPlane); var newLine = GameObject.Instantiate(lineRendererTemplate.gameObject).GetComponent<LineRenderer>(); newLine.useWorldSpace = false; newLine.transform.SetParent(_cam.transform, false); newLine.transform.localPosition = Vector3.zero; newLine.transform.localRotation = Quaternion.identity; newLine.SetPositions(new Vector3[2] { Vector3.zero, _cam.transform.worldToLocalMatrix.MultiplyPoint(tagPoint) }); } } } private void OnDestroy() { if(renderTexture) { RenderTexture.ReleaseTemporary(renderTexture); } if(_cam) { _cam.targetTexture = null; } } private void OnRenderImage(RenderTexture source, RenderTexture destination) { if(_material && _cam) { Shader.SetGlobalColor("_visibleColor", visibleCol); Shader.SetGlobalColor("_unVisibleColor", unVisibleCol); Shader.SetGlobalFloat("_cameraNear", _cam.nearClipPlane); Shader.SetGlobalFloat("_cameraFar", _cam.farClipPlane); Shader.SetGlobalMatrix("_DepthCameraWorldToLocalMatrix", _cam.worldToCameraMatrix); Shader.SetGlobalMatrix("_DepthCameraProjectionMatrix", _cam.projectionMatrix); Graphics.Blit(source, renderTexture, _material); } Graphics.Blit(source, destination); } }