Unity SRP世界空间重建

        世界空间重建解决的是:当我们在不透明物体渲染完成,想要知道深度缓存中保存当前渲染的物体的世界空间位置。这个功能还是比较常用的,一些后处理效果比如雾效,ScreenSpace效果都基于此。

        重建世界空间有两种情况,一种是基于后处理的,我们已经在屏幕空间了,不再是渲染场景中的物体而是直接处理屏幕输出。还有一种是基于场景已有物体渲染的,比如透明物体渲染。

非后处理重建

        首先从简单的非后处理的情况入手,这种方法适合在渲染透明物体时使用,常用来判断边界情况。参考下图,我们当前在渲染的物体是P,深度缓存中记录的物体坐标是P',现在的目标就是根据P点的渲染数据和深度缓存求出P'的世界位置。

         假设世界空间原点为O,相机坐标为C,根据相似性易知

\frac{\overrightarrow{CP'}}{\overrightarrow{CP}} = \frac{Dbuffer}{D_{p}}

        从而得到P'点的坐标

\overrightarrow{OP'} = \overrightarrow{OC} + \overrightarrow{CP'} = \overrightarrow{OP} * t+(1-t)*\overrightarrow{OC}, t=\frac{Dbuffer}{D_{p}} \newline

        所以我们只需要当前像素点的世界坐标OP,相机坐标OC,以及当前像素点深度Dp和深度缓存Dbuffer就够了。这里Dp不用计算,用w就可以。

        在Unity中实现代码如下,这里是按照Unity Shader 深度值重建世界坐标 - 简书这篇文章给的验证正确性的方法写的渲染透明物体的shader,保证在不透明物体之后渲染。最后将透明物体放在不透明物体之前,比较还原出的结果值是否相同。

            struct appdata
            {
                float3 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };
 
            struct v2f
            {
                float3 worldPos : TEXCOORD0;
                float4 positionCS : SV_POSITION;
            };
            
            v2f vert (appdata v)
            {
                v2f o;
                float4 worldPos = mul(unity_ObjectToWorld, float4(v.positionOS, 1.0));
                o.worldPos = worldPos.xyz;
                o.positionCS = mul(unity_MatrixVP, worldPos);
                return o;
            }

            bool IsOrthographicCamera()
            {
                return unity_OrthoParams.w;
            }

            float OrthographicDepthBufferToLinear(float rawDepth)
            {
            #if UNITY_REVERSED_Z
		        rawDepth = 1.0 - rawDepth;
            #endif
                return (_ProjectionParams.z - _ProjectionParams.y) * rawDepth + _ProjectionParams.y;
            }

            float4 frag(v2f i) : SV_TARGET
            {
                float4 positionSS = i.positionCS;
                float depth = IsOrthographicCamera() ? OrthographicDepthBufferToLinear(positionSS.z) : positionSS.w;
                float bufferDepth = SAMPLE_DEPTH_TEXTURE_LOD(_CameraDepthTexture, sampler_point_clamp, screenUV, 0);
                bufferDepth = IsOrthographicCamera() ?
		            OrthographicDepthBufferToLinear(bufferDepth) :
		            LinearEyeDepth(bufferDepth, _ZBufferParams);

                float3 depthWorldPos =  lerp(_WorldSpaceCameraPos, i.worldPos, bufferDepth / depth);
                return float4(depthWorldPos, 1);
            }

结果如下,用的Quad覆盖在显示世界坐标的不透明物体前面,两者完全重合,证明是没问题的。


后处理重建-基于逆矩阵

        后处理时,观察矩阵V和投影矩阵P仍然是保持不变的(其实整个渲染流程都不会改变)。而根据当前屏幕坐标可以计算出NDC空间的x和y,加上深度缓存可以得到z,从而得到NDC空间的坐标,再乘以w就得到Clip Space空间坐标。后面就简单了,根据VP矩阵的逆就可以算出World Space坐标。

        这里需要的clip space转world space的矩阵是:

P_{w} = (M_{View}^{-1}(M_{Proj}^{-1}*P_{clip})) =(M_{Proj}*M_{View})^{-1}

        在URP中,Unity提供内置变量提供了UNITY_MATRIX_I_VP这个矩阵可以直接用,但是在SRP中只能手动计算了,在C#中计算好矩阵值传入shader。

Matrix4x4 VP = camera.projectionMatrix * camera.worldToCameraMatrix;
Matrix4x4 VP_I = VP.inverse;

         参考Resolved - Reconstructing world space position from depth texture - Unity Forum还需要调用GL.GetGPUProjectionMatrix处理一下projectionMatrix,但我测试这样结果有差异,结果camera.projectionMatrix反而是正确的,这是结果的比较,不清楚GetGPUProjectionMatrix做了什么处理,有可能这个接口并不适用SRP。

camera.projectionMatrix
0.89428	0.00000	0.00000	0.00000
0.00000	1.73205	0.00000	0.00000
0.00000	0.00000	-1.00060 -0.60018
0.00000	0.00000	-1.00000 0.00000

GL.GetGPUProjectionMatrix(camera.projectionMatrix, false))
0.89428	0.00000	0.00000	0.00000
0.00000	1.73205	0.00000	0.00000
0.00000	0.00000	0.00030	0.30009
0.00000	0.00000	-1.00000 0.00000

        我们知道,深度缓存中存储的深度值为(在没有ReverseZ的情况下)

Z_{buffer} = Z_{ndc}*0.5 + 0.5

        同样有UV_{screen} = XY_{ndc} * 0.5 + 0.5,于是NDC空间坐标计算如下

P_{ndc} = \frac{float3(UV,Z_{buffer}) + 1}{2}

        然后就是应用逆矩阵,推导过程摘抄一下Unity Shader 深度值重建世界坐标 - 简书推导流程,给出公式如下:

P = \frac{M_{IVP}*P_{ndc}}{(M_{IVP}*P_{ndc}).w}

 贴一下代码,效果跟之前一样就不列了。

float4x4 _UNITY_MATRIX_I_VP;

float4 ReconstructWorldFragment(Varyings input) : SV_TARGET
{
    float4 positionSS = input.positionCS;
    float2 screenUV = positionSS.xy / _ScreenParams.xy;
    float bufferDepth = SAMPLE_DEPTH_TEXTURE_LOD(_CameraDepthTexture, sampler_point_clamp, screenUV, 0);
#if UNITY_REVERSED_Z
		bufferDepth = 1.0 - bufferDepth;
#endif
    
    float4 ndc = float4(screenUV.x * 2 - 1, screenUV.y * 2 - 1, bufferDepth * 2 - 1, 1);
    float4 worldPos = mul(_UNITY_MATRIX_I_VP, ndc);
    worldPos /= worldPos.w;
    return worldPos;
}

后处理重建-基于向量插值

        逆矩阵还原的方法实现简单,但是矩阵求逆开销还是比较大的,其实我们还可以参考最初的非后处理方法,虽然我们不绘制场景物体,但是后处理Blit绘制的mesh是全屏的Quad,在view space中mesh的深度介于near跟far之间,我们可以任意选一个位置,比如选择远平面,那么深度就已经是常数了,只需要求出当前像素点的世界空间坐标,我们还可以证明,这个坐标只需要在顶点着色器中计算,然后自动插值:

        这里借用一下这个图,不妨假设现在从相机右侧方向观察,所以BC对应的是屏幕高度。假设当前屏幕空间点的坐标是UV,G是根据深度缓存重构出的世界空间位置,A点位置是已知的,目标就是求出G点的坐标。

        根据上图的推导,问题转化为求出AF向量。我们要知道的一点是,这里K、G、F其实最终映射到屏幕上的相同的UV坐标,投影矩阵就是基于此计算出来的(可参考OpenGL Projection Matrix)。所以屏幕上的Y轴坐标可以这样计算(这里假设UV以屏幕左下角为起点):

UV.y = \frac{JK}{JD}=\frac{IF}{IB}

        众所周知当我们对向量进行线性插值时,得到的结果总在两个向量终点之间,例如当我们对AI向量和AB向量线性插值时,结果总会在直线IB上,这很好理解:

\vec{AF} = \vec{AI} * (1-t) + \vec{AB} * t = \vec{AI} + t*(\vec{AB} - \vec{AI}) = \vec{AI} + t* \vec{IB}, 0<t<1

        我们可以求出这个t代表什么,然后带入上式

t = \frac{ \vec{AF} - \vec{AI}}{(\vec{AB} - \vec{AI})}=\frac{IF}{IB} = UV.y

\vec{AF} = \vec{AI} + UV.y*(\vec{AB} - \vec{AI})

        上式中,UV.y是已知的,AI向量和AI向量在每次渲染时是个常向量,我们可以把它存储起来,这样我们就可以在片段着色器中做上面的计算。但还有更好的方法,把AI向量和AB向量放在顶点着色器中对应位置,分别作为上下两个顶点的值,这样会自动做插值,得到正确的向量方向。

        假设从相机顶部往下观察,可以得到一样的结果,只是这时是关于UV.x的。不过想要从二维推广到三维,就复杂一点了,如下图:

        思路是一样的,首先在水平方向上根据UV.x插值BL和BR得到A点坐标,插值TL和TR得到A点坐标,然后A点和B点坐标根据UV.y插值。这里就不写详细公式了,最终结果就是AF等于四个顶点坐标根据UV做双线性插值的结果,刚好等于顶点着色器中的数据插值到片段着色器的方法。所以四个顶点处分别放向量坐标,就可以得到AF向量了。

        注意这里根据我们选择近平面还是远平面,四个顶点填的向量是不一样的。对应之前的这个图,如果我们四个顶点都填的是远平面向量,那么最后得到的是AF向量,否则就是AK向量了。

        要怎么计算这四个向量呢?这里给出计算使用远平面时左上角TL位置的公式:

\vec{TL_{far}} = \vec{Forward} * far+\vec{Up} * HalfHeight - \vec{Right} * HalfWidth

HalfHeight =far * tan(\frac{fov}{2}),HalfWidth =HalfHeight * aspect

\vec{\Rightarrow TL_{far}} = far * (\vec{Forward}+\vec{Up}*tan(\frac{fov}{2})-\vec{Right} * tan(\frac{fov}{2}) * aspect)

        其中Forward,Up,Right三个向量是相机的z,y,x轴方向。对于其他位置,只需要改变Up和Right项的正负号,如果是近平面,只需要将far替换成near即可。

        最后,根据我们选择的是近平面还是远平面选择下面两个公式计算就可以了。

\vec{AG} =\vec{AF}* \frac{depth}{far} = \vec{AF}* Linear01Depth

\vec{AG} =\vec{AK}* \frac{depth}{near}

        本质上没什么区别,但是Unity有提供计算Linear01Depth方法,远平面时可以直接使用这个值,注意这个介于0~1的值和深度缓存中的完全不是一个含义,前者是线性深度,近平面对应的值是near/far而不是0。

        接下来是实现部分,这里使用CommandBuffer.Blit来实现后处理,因为不确定顶点着色器中四个顶点的顺序,将序号(通过SV_VertexID获取)打印出来

         所以左下角序号是0,左上是1,右上是2,右下是3,可以通过这个序号来决定使用哪个数值。贴一下关键代码:

float fov = camera.fieldOfView;
float far = camera.farClipPlane;
float aspect = camera.aspect;
float tan = Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
Vector3 halfHeightFar = camera.transform.up * tan;
Vector3 halfWidthFar = camera.transform.right * tan * aspect;
Vector3 TopRight = far * (camera.transform.forward + halfHeightFar + halfWidthFar);
Vector3 TopLeft = far * (camera.transform.forward + halfHeightFar - halfWidthFar);
Vector3 ButtomRight = far * (camera.transform.forward - halfHeightFar + halfWidthFar);
Vector3 ButtomLeft = far * (camera.transform.forward - halfHeightFar - halfWidthFar);
cameraPosVector[0] = ButtomLeft;
cameraPosVector[1] = TopLeft;
cameraPosVector[2] = TopRight;
cameraPosVector[3] = ButtomRight;
buffer.SetGlobalVectorArray(cameraDepthPosVectorId, cameraPosVector);

以及shader部分:

VaryingsNew QuadPassVertex(Attributes input, uint vid : SV_VertexID)
{
    VaryingsNew output;
    // todo: direct fill four clip position
    output.positionCS = TransformObjectToHClip(input.positionOS);
    output.screenUV = input.baseUV;
    output.camera2DepthVector = _CameraDepthPosVector[vid];
    return output;
}

float4 ReconstructWorldFragment(VaryingsNew input) : SV_TARGET
{
    float4 positionSS = input.positionCS;
    float2 screenUV = positionSS.xy / _ScreenParams.xy;
    float bufferDepth = SAMPLE_DEPTH_TEXTURE_LOD(_CameraDepthTexture, sampler_point_clamp, screenUV, 0);
    float bufferLinear01Depth = Linear01Depth(bufferDepth, _ZBufferParams);
    float3 camera2Depth = input.camera2DepthVector * bufferLinear01Depth;
    float4 worldPos = float4(_WorldSpaceCameraPos + camera2Depth, 1.0);
    return worldPos;
}

后处理重建-结合方法

        除了以上两种方法,还有把两者结合起来的。在向量插值方法的顶点着色器中,我们需要计算近平面或者远平面上四个顶点的向量,除了在C#中手动计算传递到着色器其实着色器中也可以计算,也很简单就是将这四个顶点根据逆矩阵求出计算世界位置,再减去相机的世界空间就可以。

        注意,这篇文章末尾给的思路是有问题的Unity Shader 深度值重建世界坐标 - 简书,转到观察空间是不够的,因为我们要的是世界空间下的位置向量。这种情况仅当观察矩阵没有旋转才可以使用,但实际不太可能存在这种情况。举个简单的例子,假设观察空间沿Z轴旋转90度,那么此时x轴对应的是原来的y轴,y轴对应的是原来的x轴,对应关系都变了肯定不能混用。

        这里求四个顶点的世界坐标的方法和逆矩阵方法中是一样的,这里直接给出公式:

P_{world} = M_{proj*view}^{-1}*P_{clip} = M_{proj*view}^{-1}*P_{ndc}*w = \frac{M_{proj*view}^{-1}*P_{ndc}}{(M_{proj*view}^{-1}*P_{ndc}).w}

        这个逆矩阵之前求过,只需要求出NDC坐标,远平面NDC坐标的z是1,近平面则是-1,xy只要根据UV乘2减一就可以得到,NDC坐标已经是除以w之后的值了,所以w肯定是1,从而得到:

P_{world} = \frac{M_{proj*view}^{-1}*float4(UV*2-1,\pm 1,1)}{w}

        这个UV坐标与使用远近平面无关,实际上就是每个顶点的UV,在顶点着色器直接赋值。

        后面的方法就跟向量插值方法一样了,根据远近平面选择对应的公式即可。

        贴一下汇总的shader,这里同时支持上面提到的三种后处理方法。

float4x4 _UNITY_MATRIX_I_VP;
float4 _CameraDepthPosVector[4];

struct Attributes
{
    float3 positionOS : POSITION;
    float2 baseUV : TEXCOORD0;
};

struct VaryingsNew
{
    float4 positionCS : SV_POSITION;
    float2 screenUV : VAR_SCREEN_UV;
#if defined(_ReconstructUseInterpolation) || defined(_ReconstructUseCombine)
    float3 camera2DepthVector : TEXCOORD1;
#endif
};

VaryingsNew BlitPassVertex(Attributes input, uint vid : SV_VertexID)
{
    VaryingsNew output;
    // todo: use direct position result to replace
    output.positionCS = TransformObjectToHClip(input.positionOS);
    output.screenUV = input.baseUV;
    
#if defined(_ReconstructUseInterpolation)
    output.camera2DepthVector = _CameraDepthPosVector[vid];
#elif defined(_ReconstructUseCombine)
    float4 ndcFarPos = float4(output.screenUV * 2 - 1, 1, 1);
    float4 worldPos = mul(_UNITY_MATRIX_I_VP, ndcFarPos);
    output.camera2DepthVector = worldPos.xyz / worldPos.w - _WorldSpaceCameraPos;
#endif
    return output;
}

float4 ReconstructWorldFragment(VaryingsNew input) : SV_TARGET
{
    float4 positionSS = input.positionCS;
    float2 screenUV = positionSS.xy / _ScreenParams.xy;
    float bufferDepth = SAMPLE_DEPTH_TEXTURE_LOD(_CameraDepthTexture, sampler_point_clamp, screenUV, 0);
#if defined(_ReconstructUseInvVP)
    #if UNITY_REVERSED_Z
	bufferDepth = 1.0 - bufferDepth;
    #endif
    float4 ndc = float4(screenUV.x * 2 - 1, screenUV.y * 2 - 1, bufferDepth * 2 - 1, 1);
    float4 worldPos = mul(_UNITY_MATRIX_I_VP, ndc);
    worldPos /= worldPos.w;
#elif defined(_ReconstructUseInterpolation) || defined(_ReconstructUseCombine)
    float bufferLinear01Depth = IsOrthographicCamera() ?
		OrthographicDepthBufferToLinear(bufferDepth) / _ProjectionParams.z :
		Linear01Depth(bufferDepth, _ZBufferParams);
    float3 camera2Depth = input.camera2DepthVector * bufferLinear01Depth;
    float4 worldPos = float4(_WorldSpaceCameraPos + camera2Depth, 1.0);
#else
    worldPos = 1;
#endif
    return worldPos;
}

        最后,如果使用DrawProcudal方法做的后处理,并且Mesh不是Quad而是三角形,比如Post Processing这种情况,那么用来插值顶点的数值要变化。

         比如上面的情况,外面大三角形的顶点值应该是E=A,F=2*B-A,G=2*D-A。

猜你喜欢

转载自blog.csdn.net/paserity/article/details/129578140