在屏幕空间下制作3D UI

背景

有时候出于一些特殊需求,我们需要在平面的UI中制造出伪的3d感UI,让玩家或用户感受到UI的深度,也可以加深UI的交互性。本篇中,会主要利用插值和视差的方法来制作伪3d效果的UI。
接下来的内容将会涵盖
1.通过插值使得选择平面图像并重采样成为可能
2.通过视差让UI能看出深度

通过四边形对uv进行插值

在默认情况下,一个方形的面片,uv的分布如下图左侧所示(在UE中,v轴向下)。要想对图片进行像PS里面的自由变换那样的操作,即相当于将uv分布变成右侧的样子

寻找灭点

若想将平面内任意一点用向量AB, AC(x轴和y轴)表示出来,由于可能是非正交的坐标系,所以我们需要找寻一个“灭点”(严格来说,灭点不是2d平面里的概念,但是为了方便描述,在这里引入了这个术语)

直线的表达式

我们的第一个任务是寻找交点V,
对于直线表达式
$$F(x) = ax + by + c = 0$$
假设直线上两点为p0(x0,y0), p1(x1, y1),那么可以得到
$$
\begin{cases}
a = y_0 – y_1\\
b = x_1 – x_0\\
c = x_0.y_1 – x_1.y_0
\end{cases}
$$
更详细的解释可参考[1]
由于直线AB和CD的两个端点都已知,我们可以很方便的表示出AB和CD各自的a,b,c,这里我们可以将a,b,c压缩到一个vector3方便在不同函数中传递

1
2
3
4
5
6
float3 GetPointsLineParams(float2 A, flaot2 B) {
float a = A.y - B.y;
float b = B.x - A.x;
float c = A.x*B.y - B.x*A.y;
return float3(a,b,c);
}

两直线的交点

对于直线$p_0p_1$和$p_2p_3$的交点应该满足
$$a_0x + b_0y +c_0 = a_1x + b_1y + c_1 = 0$$
其中$a_0,b_0,c_0$为直线$p_0p_1$的参数,$a_1,b_1,c_1$为直线$p_2p_3$的参数
由此可推出
$$
\begin{cases}
D = a_0.b_1 – a_1.b_0 \qquad (D为0时,表示两直线平行)\\
x = (b_0.c_1 – b_1.c_0)/D\\
y = (a_1.c_0 – a_0.c_1)/D
\end{cases}
$$
有意思的是
这可以看做两个vector2的齐次坐标(a0, b0, c0)和(a1, b1, c1)的叉乘
$$
\begin{bmatrix}
i & j & k\\
a_{0} & b_{0} & c_{0}\\
a_{1} & b_{1} & c_{1}
\end{bmatrix}
$$

1
2
3
4
5
6
7
8
float3 GetLinesIntersectPoint(float2 A, float2 B, float2 C, float2 D) {
float3 a = GetPointsLineParams(A, B);
float3 b = GetPointsLineParams(C, D);
float x = (b.x*c.y – b.y*c.x)/D;
float y = (a.y*c.x – a.x*c.y)/D;
float D = a.x*b.y – a.y*b.x;
return float3(x,y, D);
}

坐标系映射

有了前面的准备工作,我们可以求出灭点V,接下来我们就是要算出任意一个采样点S对应的x,y轴上的分量
先以求y轴分量为例子,分为两种情况
1.AB和CD所在的直线相交,即存在一个灭点
2.AB和CD所在的直线平行,没有交点或者重合
这两者可以通过前面式子中的D判断出来

对于情况1,先利用两直线的交点求出灭点V的位置,对于平面上任意一个采样点s,求向量$\vec{VS}$和直线AC的交点,这样就知道了采样点在ABC坐标系下的y的值(AC假设为单位长度,则AP就是y的大小)

1
2
3
4
float3 P = GetLinesIntersectPoint(V, S, A, C);
flaot D = P.z;
float2 AP = P.xy - A;
float y_percent_1 = AP*AC.normalize()/AC.length();

对于情况2,直接将s沿着AB方向做延长的到向量和AC求交,同样可以求得y的坐标

1
2
3
4
5
6
7
8
9
10
float2 P = S + AB;
float2 AP = P.xy - A;
float y_percent_2 = AP*AC.normalize()/AC.length();
// We check if D is not 0
if(abs(D) > 0.0001)
// case 1
y_percent = y_percent_1;
else
// case 2
y_percent = y_percent_2;

用同样的思路,计算采样点在AB方向上的投影分量,就可以算出x坐标(u分量)
至此,我们就能对平面上任意一点计算出它在原uv平面上的坐标了

视差深度

旋转平面

在做视差之前,我们先解决旋转平面的问题。
在制作假的3d效果之前,我们先来回忆一下那些“真”的3d效果是如何做出来的。其实所谓真3d,也只不过是一个现实中3d的模拟。我们先得每个顶点赋予一个3d空间坐标,然后利用一些旋转矩阵来变换他们的位置。最后交给图形API或GPU来进行光栅化。由于光栅化的过程不是可编程管线,所以更多时候,我们只用考虑如何做位置变换即可。
我们现在的情况是,我们有一块屏幕,我们知道我们要绘制的每个像素在平面上的坐标,我们想知道它原来的位置,从而找到那个位置上的颜色并绘制出来。
可是这个像素对应的3d空间下的一点,先是结果了一个旋转位移变换,接着又做了一次投影变换(甚至还不一定是正交的投影矩阵),麻烦就麻烦在最后一步的投影,因为缺少深度信息,它阻止了我们直接应用一个逆变换矩阵。我们无法直接的从屏幕上的任意一点,追溯回它的原始位置

所以我们要换个思路。我们虽然不能旋转所有的“点”,但是我们可以旋转平面的四个顶点,得到旋转后的一个四边形。光是通过这四边形,我们只能知道这四个顶点最后落到了屏幕上的什么位置。但是别忘了,我们可以利用前面提到的四边形插值,算出平面上任意一个像素对应的是原来的平面上的的坐标。这个坐标即是由四个顶点的属性加权构成。这样就能把本身难以直接解决的问题换了一个路径给解决了

计算3d空间中的点的旋转很简单,我这里就不赘述了,使用

1
float3 RotateVectorPivot(pos, pivot, lookAtVec, resetVec)

函数来表示。

1
2
3
4
5
6
7
8
// this is the relative pos to the pivot point(0,0)
float3 A = float3(0,0,layer_depth) - float3(0.5, 0.5, 0);
// float2(0,0) is the pivot point float3(0,0,1) means z up (which points to camera, or out of screen)
// camera is out of screen and look into the screen, we will manipulate camera pos to rotate plane
float3 A_rotated = RotateVectorPivot(A,float2(0,0), float3(0,0,1), camera_pos);
float A_dpeth = camera_height - A_rotated.z;
float2 A_scaled_xy = A_rotated.xy * camera_height / A_dpeth;
float3 A_persp = float3(A_scaled_xy.x, A_scaled_xy.y, A_dpeth);

先算出旋转4个点后的轮廓,白色区域即是原来(0,1)的uv象限。我们可以想象出来旋转的平面。

接下来我们就拿前面的uv映射去计算原本的uv位置

但是,好像不太对劲。感觉虽然有灭点的趋势,但是感觉形状扭曲了。
有趣的是,这里的直线并没有扭曲,但是因为它不符合我们真实世界中的透视,我们的眼睛和大脑会感到困惑。也就产生了扭曲感。我们之前做的插值,都做了一个假设,所有的顶点仍固定在原始的xy平面上,所以直接进行了均匀的插值。实际上,因为近大远小的原则,远处的点会聚在一起,而近处的点则会分散一些,所以我们接下来要将这个过程也修正了

进行perspective模拟

其实这一步就是模拟了GPU中的光栅化过程,我们先要在旋转顶点的时候,保存下来它的深度。接着用这个深度对顶点的属性插值(这里是它的x,y投影坐标)
我们可以知道顶点属性和顶点的深度会成一个正比的关系,一个通俗的理解就是:三角形上任意一条线段中上的一点P,对于其深度值z和色彩值c,有如下图所示的关系

这里的C可以是我们以前经常见到的颜色值,也同样可以是屏幕坐标(x,y)
这条插值连线上的一点Z的计算公式为
$$
\begin{array}{l}
Z &= Z_0 + t * (Z_1 - Z_0) = Z_0 + \dfrac{qZ_0(Z_1 - Z_0)}{qZ_0 +(1-q)Z_1},\\
&= \dfrac{qZ_0^2 + (1-q)Z_0Z_1 + qZ_0Z_1 - qZ_0^2}{qZ_0 +(1-q)Z_1},\\
&= \dfrac{Z_0Z_1}{qZ_0 +(1-q)Z_1},\\
&= \dfrac{1}{\dfrac{q}{Z_1} + \dfrac{(1-q)}{Z_0}},\\
&= \dfrac{1}{\dfrac{1}{Z_0} +q (\dfrac{1}{Z1} - \dfrac{1}{Z_0})}.\\
\end{array}
$$
结合图里的公式,可以得到任意属性的插值公式
$$
C= Z [\frac{C_{0}}{Z_{0}}(1-q) + \frac{C_{1}}{Z_{1}}q]
$$

所以,对于前面的

1
float2 UV_interplation(float3 A, float3 B, float3 C, float3 D, float2 uv)

在根据AC算y_percent的时候,还要再乘上深度的比例。因为这里的C0是0(C1是1),所以我们可以简化计算
$$
C= \frac{Z}{Z_{1}}q
$$

1
2
sample_depth = lerp(A_dpeth, C_depth, y_percent);
y_percent = y_percent * sample_depth / C_depth;

同理可以求出x_percent,这样,我们才算是得到了真正的uv坐标

加入深度

我们还想给不通过的UI面片放到不同的层中,实现不同的深度感。幸运的是,有了前面的框架之后,让面片有深度也很简单,只要设置旋转的四个顶点的初始深度即可

1
float3 A = float3(0,0,layer_depth) - float3(0.5, 0.5, 0);

比如这里的layer_depth只要设置成我们想要的深度即可
后面的计算都是一样,这样就可以得到标题的最终效果图了

如果你想让旋转变得剧烈,还可以降低摄像机的高度,从而让旋转轴的倾斜角加大

参考

[1] 算法之美——求两直线交点(三维叉积)——求四边形面积(二维叉积)
[2] Rasterization: a Practical Implementation