1 引言
在《Unity3D Shader系列之深度纹理》文章中,我们详细介绍了深度纹理有关的一些细节,包括以下内容:
- 深度纹理存储的是NDC坐标系中的深度值
- 为什么一定要存NDC坐标中的深度值
- OpenGl平台与DirectX平台的NDC坐标的深度值的一些差异
- 深度值精度导致的Z Fighting问题以及DirectX平台下为什么Reverse Z可以改善Z Fighting
- 前向渲染与延迟渲染两条渲染路径生成深度纹理的过程
- Unity中使用深度纹理的API说明,并实现了场景扫描效果
在《Unity3D Shader系列之护盾效果》中,我们使用深度纹理实现了护盾效果,里面核心的技术点在于需要在Shader中判断该物体是否与其他物体相交,其步骤为:在片元着色器中对深度纹理进行采样得到像素的深度值,然后再与当前渲染物体的深度值相减,如果差值在某一范围内(如0.1)就认为该物体在场景中与其他物体有相交。
这篇接着上两篇文章,来看看深度纹理的另外一个用法,即在屏幕后处理中使用深度纹理重建世界坐标。其实这个知识点网上到处都是,但是还是那句话,只有将一个知识点用最简单的语言让一个完全不懂的人都能理解,我们才是真正掌握了这个知识点。理解了知识点和能用语言完整表达出此知识点是完全不同的两个层次,所以我们这里还是不厌其烦地用自己的语言说出这个知识点。
本文实现的示例效果如下。
2 深度纹理重建世界坐标的原理
2.1 具体步骤
在某些情况下,我们需要屏幕后处理阶段得到像素点对应的世界坐标。如下图,我们在屏幕后处理阶段,想要知道屏幕空间中A1点对应的世界坐标A点。那么A点该怎么求呢?
有哪些条件是已知的呢?
- O点的世界坐标(即相机的世界坐标),在Shader中可以通过_WorldSpaceCameraPos获取到
- OD的长度(可以通过对深度纹理进行采样并计算从而得到A点在观察空间中的深度值)
- 透视相机的各项参数:
近、远裁剪平面的值
视口角度 Field of View
纵横比 Aspect
而A点的世界坐标 = O点的世界坐标 + O A → \overrightarrow {OA} OA。
所以问题的难点在于如何求 O A → \overrightarrow {OA} OA?
这里就直接说答案了,不知道这个方法真的很难想到。主要步骤如下:
-
当顶点为LT点(即屏幕左上角)时将 O L T → \overrightarrow {OLT} OLT向量的值放置在顶点着色器输出结构体中(eg:常用的符号v2f);
当顶点为LB点(即在屏幕左下角)时将 O L B → \overrightarrow {OLB} OLB放置在顶点着色器输出结构体中;
当顶点为RT点(即在屏幕右上角)时将 O R T → \overrightarrow {ORT} ORT放置在顶点着色器输出结构体中;
当顶点为RB点(即在屏幕右下角)时将 O R B → \overrightarrow {ORB} ORB放置在顶点着色器输出结构体中;
-
利用GPU硬件的插值(顶点着色器的输出结构体会在三角形遍历阶段进行重心坐标插值,然后将插值后的值传递给片元着色器使用),得到 O A 1 → \overrightarrow {OA1} OA1
-
利用三角形的相似关系,可以得到 O A → \overrightarrow {OA} OA = |OD| / 相机近裁剪平面值 * O A 1 → \overrightarrow {OA1} OA1
下面详细解释下三个步骤的原理。
2.2 步骤原理
2.2.1 步骤1原理
当顶点为LT点(即屏幕左上角)时将 O L T → \overrightarrow {OLT} OLT向量的值放置在顶点着色器输出结构体中(eg:常用的符号v2f);
当顶点为LB点(即在屏幕左下角)时将 O L B → \overrightarrow {OLB} OLB放置在顶点着色器输出结构体中;
当顶点为RT点(即在屏幕右上角)时将 O R T → \overrightarrow {ORT} ORT放置在顶点着色器输出结构体中;
当顶点为RB点(即在屏幕右下角)时将 O R B → \overrightarrow {ORB} ORB放置在顶点着色器输出结构体中;
那么问题来了,如何才能求到 O L T → \overrightarrow {OLT} OLT, O L B → \overrightarrow {OLB} OLB, O R T → \overrightarrow {ORT} ORT, O R B → \overrightarrow {ORB} ORB呢?
我们这里只讨论透视相机,透视相机弄清楚了,正交相机很简单。
示意图如下。
计算过程如下,就是几个简单的向量加减法,相信大家一看就明白了。
2.2.2 步骤2原理
利用GPU硬件的插值(顶点着色器的输出结构体会在三角形遍历阶段进行重心坐标插值,然后将插值后的值传递给片元着色器使用),得到 O A 1 → \overrightarrow {OA1} OA1
这一步是这种方法的核心。只有真正理解了这一步,才可以说是真正理解了这种方法。
我们先要明白什么是屏幕后处理。相机渲染完场景中所有物体后会得到一张渲染纹理,但是我们不直接把这张渲染纹理显示在屏幕上,而是额外对这张渲染纹理的每一个像素点进行处理一遍(这个过程就叫做屏幕后处理),然后将屏幕后处理的结果递到屏幕上。屏幕后处理一般是通过额外渲染一个与屏幕大小相同的矩形网格来实现的。该网格只有2个三角面,共4个顶点,如下图。对每个像素的额外处理则会放到片元着色器中,具体处理的是哪一个像素用uv坐标来得到。
我们知道,在渲染流水线中,GPU会在三角形设置阶段对顶点着色器输出结构体中的值进行重心坐标插值,然后再传递给片元着色器,就像下图这样。
也就是说,我们在步骤1中传递的 O L T → \overrightarrow {OLT} OLT、 O L B → \overrightarrow {OLB} OLB、 O R T → \overrightarrow {ORT} ORT、 O R B → \overrightarrow {ORB} ORB经过GPU硬件的插值后,在片元着色器中将会得到 O A 1 → \overrightarrow {OA1} OA1(方向和长度通过重心坐标插值都能得到)。
这一步根本不用写代码,GPU硬件已经实现了。
2.2.3 步骤3原理
利用三角形的相似关系,可以得到 O A → \overrightarrow {OA} OA = |OD| / 相机近裁剪平面值 * O A 1 → \overrightarrow {OA1} OA1
做以下辅助线方便理解。其中OD1是相机的近裁剪平面,OD是A点在观察空间的线性深度值。
3 场景定点扫描效果
原理理解了,写代码其实很简单啦,网上的代码拿过来用就行了。
PointScanCamera.cs
using UnityEngine;
[RequireComponent(typeof(Camera))]
public class PointScanCamera : MonoBehaviour
{
private Camera m_Camera;
[SerializeField]
private Material m_PointScanMaterial;
private void Awake()
{
m_Camera = GetComponent<Camera>();
}
private void OnEnable()
{
m_Camera.depthTextureMode |= DepthTextureMode.Depth;
var frustumCornersRay = CalcFrustumCornersRay();
m_PointScanMaterial.SetMatrix("_FrustumCornersRay", frustumCornersRay);
m_PointScanMaterial.SetFloat("_Near", m_Camera.nearClipPlane);
}
private void Update()
{
if (Input.GetMouseButton(0))
{
var ray = m_Camera.ScreenPointToRay(Input.mousePosition);
const float distance = 100f;
Debug.DrawRay(ray.origin, ray.direction * distance, Color.red, 1f);
if (Physics.Raycast(ray, out var hitInfo, distance))
{
Debug.Log($"{hitInfo.collider.gameObject.name} pos:{hitInfo.point}");
m_PointScanMaterial.SetVector("_IntersectPos", hitInfo.point);
}
}
}
private void OnDisable()
{
m_Camera.depthTextureMode &= ~DepthTextureMode.Depth;
}
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (m_PointScanMaterial == null)
{
Graphics.Blit(source, destination);
return;
}
Graphics.Blit(source, destination, m_PointScanMaterial);
}
private Matrix4x4 CalcFrustumCornersRay()
{
Matrix4x4 frustumCorners = Matrix4x4.identity;
float fov = m_Camera.fieldOfView;
float near = m_Camera.nearClipPlane;
float aspect = m_Camera.aspect;
float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
Vector3 cameraForward = transform.forward;
Vector3 toRight = halfHeight * aspect * transform.right;
Vector3 toTop = halfHeight * transform.up;
Vector3 topLeft = near * cameraForward + toTop - toRight;
float scale = 1.0f; // scale = 1.0f / near
topLeft *= scale;
Vector3 topRight = cameraForward * near + toRight + toTop;
topRight *= scale;
Vector3 bottomLeft = cameraForward * near - toTop - toRight;
bottomLeft *= scale;
Vector3 bottomRight = cameraForward * near + toRight - toTop;
bottomRight *= scale;
frustumCorners.SetRow(0, bottomLeft);
frustumCorners.SetRow(1, bottomRight);
frustumCorners.SetRow(2, topRight);
frustumCorners.SetRow(3, topLeft);
return frustumCorners;
}
}
PointScan.shader
Shader "LaoWang/PointScan"
{
Properties
{
_MainTex ("Main Tex", 2D) = "white"{
}
_ScanColor ("Scan Color", Color) = (1, 0, 0, 1)
_ScanRadius ("Scan Radius", float) = 4
_ScanWidth ("Scan Width", float) = 3
}
SubShader
{
Tags {
"RenderType"="Opaque" }
LOD 100
ZTest Off
Cull Off
ZWrite Off
Fog{
Mode Off }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float2 uv_depth : TEXCOORD1;
float4 interpolatedRay : TEXCOORD2;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_TexelSize;
sampler2D _CameraDepthTexture;
float4x4 _FrustumCornersRay;
float _Near;
float3 _IntersectPos;
fixed4 _ScanColor;
float _ScanRadius;
float _ScanWidth;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
o.uv_depth = v.uv;
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
{
o.uv_depth.y = 1 - o.uv_depth.y;
}
#endif
int index = 0;
if (v.uv.x < 0.5 && v.uv.y < 0.5)
{
index = 0;
}
else if (v.uv.x > 0.5 && v.uv.y < 0.5)
{
index = 1;
}
else if (v.uv.x > 0.5 && v.uv.y > 0.5)
{
index = 2;
} else
{
index = 3;
}
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
{
index = 3 - index;
}
#endif
o.interpolatedRay = _FrustumCornersRay[index];
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 color = tex2D(_MainTex, i.uv);
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
float eyeDepth = LinearEyeDepth(depth);
float3 worldPos = _WorldSpaceCameraPos + eyeDepth / _Near * i.interpolatedRay.xyz;
float dis = distance(worldPos, _IntersectPos);
float halfWidth = 0.5f * _ScanWidth;
float outter = _ScanRadius + halfWidth;
float inner = _ScanRadius - halfWidth;
float lerpValue = smoothstep(inner, outter, dis);
fixed4 ringColor = fixed4(1.0, 1.0, 1.0, 1.0);
if(lerpValue > 0.0 && lerpValue < 1.0)
{
ringColor = _ScanColor;
}
return color * ringColor;
}
ENDCG
}
}
}
当然上面的代码可以优化下,可以将Shader中片元着色器的除以_Near移动到C#这边来。
博主个人博客本文链接。