Unity Shader笔记(三) 在片段着色器中获取世界坐标

在片段着色器中获取像素对应的世界坐标,分为两种情况,第一种是在绘制物体时,第二种是在屏幕后处理阶段。前者比较简单,因此主要讨论后者,同时拿前者的绘制结果作为参照。

绘制物体时

顶点着色器向片段着色器额外传入世界坐标即可。在传递的数据中加上顶点的世界坐标worldPos

1
2
3
4
struct v2f {
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
};

顶点着色器中计算顶点的世界坐标并赋值。

1
2
3
4
5
6
v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld ,v.vertex);
return o;
}

之后在片段着色器中使用即可。

后处理阶段

这里会使用两种方法,都需要用到深度纹理。这两种方法分别是在片段着色器中使用矩阵变换反算世界坐标、和根据深度值在射线上插值获取。

使用矩阵变换坐标

主要步骤如下:

  • 保存相机VP的逆矩阵

  • 在片段着色器中取NDC的x和y

  • 在片段着色器中对深度纹理采样取z

  • 使用VP的逆矩阵变换到世界空间

在程序中需要保证相机开启深度纹理,并向材质传递VP的逆矩阵:

1
2
3
4
5
cam.depthTextureMode |= DepthTextureMode.Depth;

// ...

mat.SetMatrix("_VP_Inverse", (cam.projectionMatrix * cam.worldToCameraMatrix).inverse);

顶点着色器和片段着色器之间,需要传递深度纹理中的采样坐标:

1
2
3
4
5
struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
half2 uv_depth : TEXCOORD1;
};

顶点着色器比较常规,需要为深度纹理的坐标赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.uv = v.texcoord;
o.uv_depth = v.texcoord;

#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
o.uv_depth.y = 1 - o.uv_depth.y;
#endif

return o;
}

片段着色器中需要对深度纹理采样,获取到NDC坐标H,使用之前的PV逆矩阵转换到世界坐标得到worldPos

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fixed4 frag(v2f i) : SV_Target {

float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
#if UNITY_REVERSED_Z
d = 1.0f - d;
#endif
float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, d * 2 - 1, 1);
float4 D = mul(_VP_Inverse, H);
float4 worldPos = D / D.w;

fixed hasObj = step(d,0.999f);

return fixed4(worldPos.rgb, 1.0) * hasObj;
}

这种方法比较方便,且适用范围广(正交相机/透视相机)。但是需要在片段着色器中进行矩阵计算,效率较低。

根据深度值在射线上插值

屏幕上绘制的像素,对应的空间位置,位于从相机到远平面的连线上(对于正交相机,像素的空间位置位于与相机方向平行的位于远平面和近平面之间的直线上)。

fig

透视相机

像素位于连接相机到屏幕上一点的直线上:

fig

在程序中计算出由相机位置向远平面四个角的射线方向。并将此方向值保存在frustumCorners的四个列向量中。

1
2
3
4
5
6
7
8
9
10
cam.depthTextureMode |= DepthTextureMode.Depth;

// ...

frustumCorners.SetRow(0, bottomLeft);
frustumCorners.SetRow(1, bottomRight);
frustumCorners.SetRow(2, topRight);
frustumCorners.SetRow(3, topLeft);

mat.SetMatrix("_FrustumCornersRay", frustumCorners);

在后处理阶段,屏幕可以理解为一个Quad,在顶点着色器中,计算四个顶点对应的由相机指向该顶点的射线方向,保存在interpolatedRay中:

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
32
v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.uv = v.texcoord;
o.uv_depth = v.texcoord;

#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.texcoord.x < 0.5 && v.texcoord.y < 0.5) {
index = 0;
} else if (v.texcoord.x > 0.5 && v.texcoord.y < 0.5) {
index = 1;
} else if (v.texcoord.x > 0.5 && v.texcoord.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;
}

由顶点着色器传向片段着色器的数据结构中需要额外包含此射线方向:

1
2
3
4
5
6
struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
half2 uv_depth : TEXCOORD1;
float4 interpolatedRay : TEXCOORD2;
};

在片段着色器中,同样是采样深度纹理,得到深度值。然后根据相机的世界坐标、相机指向当前像素的方向、线性空间深度值来得出像素的空间坐标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fixed4 frag(v2f i) : SV_Target {

float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);

float linearDepth = LinearEyeDepth(d);

#if UNITY_REVERSED_Z
d = 1.0f - d;
#endif

float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;

fixed hasObj = step(d,0.999f);

return fixed4(worldPos.xyz,1.0) * hasObj;
}

正交相机

正交相机的计算方式稍有不同,但原理相似。像素点位于相机远近平面之间的与相机方向平行的直线上。所以需要得出这条直线,并使用深度值在这条线上求解出世界坐标。

fig

程序中需要计算远平面四个角的顶点坐标,以及远平面中心点的坐标(或相机的方向)。此处把远平面的中心点直接塞进frustumCorners的最后一列:

1
2
3
4
5
6
7
8
9
10
cam.depthTextureMode |= DepthTextureMode.Depth;

// ...

frustumCorners.SetRow(0, bottomLeft);
frustumCorners.SetRow(1, bottomRight);
frustumCorners.SetRow(2, topRight);
frustumCorners.SetRow(3, topLeft);

frustumCorners.SetColumn (3, farCenter);

顶点着色器与之前相似,但是需要额外向片段着色器传递相机的正方向camForward(长度为远平面的距离),这里的interpolatedRay实际上是像素点所属的直线相对于相机正方向的偏移量:

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
32
33
34
v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.uv = v.texcoord;
o.uv_depth = v.texcoord;

#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.texcoord.x < 0.5 && v.texcoord.y < 0.5) {
index = 0;
} else if (v.texcoord.x > 0.5 && v.texcoord.y < 0.5) {
index = 1;
} else if (v.texcoord.x > 0.5 && v.texcoord.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];

o.camForward = float3(_FrustumCornersRay[0][3],_FrustumCornersRay[1][3],_FrustumCornersRay[2][3]);

return o;
}

传递的数据结构如下:

1
2
3
4
5
6
7
struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
half2 uv_depth : TEXCOORD1;
float4 interpolatedRay : TEXCOORD2;
float3 camForward: TEXCOORD3;
};

最后在片段着色器中,先采样得到深度值。然后根据相机的世界坐标、相机正方向和线性空间深度值、像素所在的直线与相机正方向的偏移量计算出世界坐标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fixed4 frag(v2f i) : SV_Target {

float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);

float linearDepth = LinearEyeDepth(d);

#if UNITY_REVERSED_Z
d = 1.0f - d;
#endif

float3 worldPos = _WorldSpaceCameraPos + i.camForward * linearDepth + i.interpolatedRay.xyz;

fixed hasObj = step(d,0.999f);

return fixed4(worldPos.xyz,1.0) * hasObj;
}

使用以上各shader,在场景中添加一个Cube时的绘制结果:

直接绘制物体、屏幕后处理方法一、屏幕后处理方法二(透视相机)

fig

直接绘制物体、屏幕后处理方法一、屏幕后处理方法二(正交相机)

fig

涉及到的所有脚本和shader可以在这里找到。

REFERENCE

《Unity Shader入门精要》

https://www.khronos.org/opengl/wiki/GluProject_and_gluUnProject_code

https://docs.unity3d.com/Manual/SL-DepthTextures.html

https://www.derschmale.com/2014/03/19/reconstructing-positions-from-the-depth-buffer-pt-2-perspective-and-orthographic-general-case/