Pixel shader中的翻页动画

背景

有时候,我们在游戏中需要表现一个书本打开或则翻页的效果。如果很生硬的用一个2D形变动画或者一个淡入淡出,那肯定达不到想要的效果。所以有人想到了用3D UI来制作动画。W.Dana Nuon[1]有一篇文章介绍了一种将2D坐标映射到3D空间来完成一个基于圆锥的翻页顶点动画。由于是顶点动画,所以平面需要进行细分,顶点越多,自然效果也越好。可是有时候,这个需求只是做在UI中的,他们可能没有必要的3D美术或者制作3D UI的经验。他们只能制作2D的素材。这种时候该怎么办呢?
本文中将会提出一种基于片段着色器的方法,在屏幕空间下模拟翻页的动画。本质上其实就是一个ray-tracing的过程,只不过很多地方都进行了化简和hack

利用光线追踪

普通的渲染过程是光栅化,它将集合体上的每一个顶点对应到屏幕的对应位置,然后对其中的每个像素进行光栅化插值。其中发生的变化过程是将坐标从3D投影到2D上。这是一个简单但也高效的过程。早些时候,基本上所有的渲染都是利用光栅化的手段进行渲染的。
但对于我们这里的例子,我们正是因为没有办法求得3D坐标,所以无法展现这个动态过程,所以我们需要找寻另一种渲染的方式。也即ray-tracing的方法。
我们定义一个虚拟的摄像机坐标系,其中x正方向指向右侧,y正方向指向下侧(因为UE4中uv的v是自顶向下,所以我们这么定义方向,对于unity,我们也可以反过来),z轴垂直纸面向外。摄像机(或者说观察点)位于屏幕裁面(纸面)外,指向纸面(即z轴负方向)。对于一个quad,因为原始的uv的范围是[0,1],为了方便起见,我们把它划分为两块,如下图所示,再将虚拟”摄像机”安放在(0,0.5,5)的位置

我们假设摄像机的投影面即为xy范围[-1, 0]到[1, 1],z为0的平面。我们若想知道这个quad上某个像素应该着什么颜色,只用从摄像机向近裁面上该点射一道射线,和物体的交点的颜色值即为绘制的颜色。
按照上面的定义我们可以知道,初始状态下,每个像素点的xy坐标即为纹理采样中的uv坐标。如果我们将左右两张贴图分别放在了[-1,0]-[0,1]和[0,0]-[1,1]的范围内,则可能还要重新映射一下左侧的贴图。
至此,我们就有了基本的框架

平板翻页

在进行更复杂的翻页之前,我们先来实现一种最简单的翻页效果。想象我们的纸是一个刚性的片,翻页的过程中只有角度的变化。我们只用进行一个ray-plane intersection测试就知道,观察视线和quad的交点的3D坐标。
射线的表达式为:
$$E+t\vec{EP} = T$$
平面的表达式:
$$dot(\vec{N}, T) = 0$$
即$$\vec{N}_x T_x + \vec{N}_y T_y + \vec{N}_z * T_z = 0 $$
其中E是摄像机的位置,P是投影面上的采样点坐标,T是纸片上的交点坐标,$\vec{EP}$是观察方向,N是纸片法线方向(假设纸片绕着y轴正方向,按照右手坐标系旋转了$\theta$弧度,则$\vec{N} = (-sin\theta, 0, cos\theta)$)

E,D,P,N都已知,两式联立起来,将$E+t\vec{EP}$代入2式的T,就可以解出t,从而求出T

T的$T_x$和$T_y$是我们所关注的,但它们是投影坐标而不是uv坐标。

对于$T_v$,它等于$T_y$,因为我们并没有在y方向上进行移动。而对于$T_u$,因为已经知道了OT的长度,也知道了$T_y$,所以用勾股定律就可以求得
$$T_{u} =\sqrt{OT^{2} -(T_{v} )^{2}}$$

有了uv坐标,自然就能知道该像素该绘制的颜色。最终效果如下

增加一些弧度

如果用平板翻页,虽然视觉上是真实了,但是物理上还是失真。因为现实中纸张都比较柔软,在翻页的时候会褶皱或者弯曲。所以我们需要用另一种方法去模拟这个弯曲弧度。
这里用到的一个思路是,假想纸片包裹在一个过y轴且轴线平行于y轴的圆柱体。

射线与圆柱相交测试

第一步还是求交点T,这次是ray-cylinder。不过在这里因为y方向不重要,所以我们可以只考虑xz平面,将问题化简成ray-cycle相交的问题

射线的表达式为:
$$E+t\vec{EP} = T$$
平面的表达式:
$$(T - C)^2 = r^2$$
这里所有向量都用2D向量表示。r为我们自定义的圆柱(圆)的半径,为已知量。
这里还有个细节,就是C的位置。C是圆心的坐标。在初始状态下,它的xz平面横坐标应该为$(0.5, -\sqrt{r^{2} -(0.5)^{2}})$
随着圆柱旋转起来,它的坐标还要再用一个2D的旋转矩阵来更新
求得C之后,将两式联立起来就是
$$(E+t\vec{EP} - C)^2 = r^2$$
$$\Longrightarrow (\vec{CE}+t\vec{EP})^2 = r^2$$
$$( CE_{x} +tEP_{x})^{2} +( CE_{z} +EP_{z})^{2} =r^{2}$$
$$t^{2}(EP_{x}^2 + EP_{z}^2) + 2t(CE_{x}.EP_{x} +CE_{z}.EP_{z}) +(CE_{x}^2 + CE_{z}^2) - r^2=0$$
于是利用一元二次方程$ax^2+bx+c=0$的求根公式求解t
$$
\begin{cases}
a = (EP_{x}^2 + EP_{z}^2)\\
b = 2( CE_{x} .EP_{x} +CE_{z} .EP_{z})\\
c = (CE_{x}^2 + CE_{z}^2) - r^2
\end{cases}
$$
t可能有1解,2解或者无解,分别对应相切,相交,相离三种情况
对于有2解的情况(即$\Delta = b^2 - 4ac > 0$),我们令
$$
\begin{cases}
t1 = \frac{-b-\sqrt{b^{2} -4ac}}{2a}\
t2 = \frac{-b+\sqrt{b^{2} -4ac}}{2a}
\end{cases}
$$
t1,t2分别对应近交点T1和远交点T2。当我们要决定如何绘制像素的颜色时,也是从这两个值中选择一个作为uv的依据

先考虑T为T1的情况,假设射线先交在T1处,于是我们想知道它对应的uv坐标。这次的v还是和前面flat时候的例子一样$T_v = T_y$
但对于$T_u$我们无法直接求得,要首先通过CO和CT的dot(因为$\angle OCT(\alpha)$的范围是[0,π])求出角$\alpha$,再利用$\angle \alpha$的弧长来得到$T_u$
$$dot(CO, CT) = r^2 * cos\alpha$$

代码如下:

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
float2 uv = ((in_uv.x - 0.5)*2, in_uv.y);
float3 P = float3(uv, 0);
float3 EP = normalize(P - E);

float3 chord_normal = float3(-sin(theta), 0, cos(theta));
float3 A = float3(cos(theta), 0, sin(theta))// The right edge projected point on xz plane

// Get intersection on cylinder
float3 E = float3(0, 0.5, 5); // eye position
float r = 1 / curvature; // r is AC
float AM = 0.5;
float MC = sqrt(r*r - AM * AM);
float3 C = (A + 0)*0.5 // M is the midpoint of AO
float3 CE = E - C;

float a = EP.x*EP.x + EP.z*EP.z;
float b = 2(CE.x*EP.x + EP.z*CE.z);
float c = CE.x*CE.x + CE.z*CE.z - r*r;
float delta = b*b - 4*a*c;

float t1 = (-b - delta) / (2*a);
float t2 = (-b + dleta) / (2*a);

float3 T1 = E + t1*EP;
float3 T2 = E + t2*EP;

这样我们就能正确的采样纹理了。这一步完成之后,我们可以测试不同曲率半径下的表现

旋转圆柱体

接下来,我们将圆柱体绕着y轴旋转$\theta$角度

效果如下

我们还可以debug交点T1,T2的深度变化情况


但这个真实的卷纸还有些差别,卷纸虽然依附于圆柱体上,但是只有部分是显示的,即我们要对圆柱进行裁剪。裁剪的依据是判断uv坐标是否落在了[0,1]的区间内。对u大于1或者小于0的部分,我们则不绘制。

不过有时候虽然u在[0,1]中,但是面却是背面,如下图所示。

所以只有当uv在[0,1]中且视线与圆柱的正面相交,才能说明当前采样点属于正面。
我们可以得到正面的部分的mask动画,即上图中斜向的网格线区域

接下来,如果剩余的黑色区域中与圆柱体有交点,则应该绘制背面

相关代码如下:

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
// Sample on texture from 3d position: T
float4 GetUv(float3 T, float3 C, float3 E, out float2 uv_1st) {
float2 CT = float2(T.x, T.z) - float2(C.x, C.z);
float2 TE = float2(E.x, E.z) - float2(T.x, T.z);
float face = sign(dot(CT, TE));

float2 CO = - float2(C.x, C.z);
float cos_alpha = dot(CT, CO)/(r*r);
float sample_u = arccos(cos_alpha);

// Check if u is on the positive side or not
float is_positive = sign(CT.x*CO.y - CT.y*CO.x); // 2D cross product
sample_u = sample_u * is_positive; // now u is mapped to [-1,0] and [0,1]
float2 origin_uv = float2(sample_u, T.y);
float2 stage1_uv = origin_uv * float2(sign(dir), 1);
float face_dir = sign(face * dir);
float2 stage2_uv = face_dir > 0 ? stage1_uv: float2(1 - stage1_uv.x, stage1_uv.y);
float IsUvValid = (stage1_uv.x > 0 && stage1_uv.x < u_limit && stage1_uv.y > 0 && stage1_uv.y < 1) ? 1:-1;

return float3(stage2_uv, IsUvValid, face);
}

float2 T1_1st_uv, T2_1st_uv;
float4 T1_uv = GetUv(T1, C, E, T1_1st_uv);
float4 T2_uv = GetUv(T2, C, E, T2_1st_uv);
float2 overlap_uv = T1_uv.z > 0 ? T1_uv.xy : T2_uv.xy;
// Get stage1 uv that centered at mid so that we can apply x_limit later correctly
float2 overlap_1st_uv = T1_uv.z > 0 ? T1_1st_uv.xy : T2_1st_uv.xy;
float overlap_face = T1_uv.z > 0 ? T1_uv.w : T2_uv.w;
float T_z = T1_uv.z > 0 ? T1.z : T2.z;

完整的动画如下

处理AB面

所谓AB面,即单数页(A)和偶数页(B),分布在摊开书的左侧和右侧。我们将一页纸从右侧翻到左边的时候,需要用另一个面来绘制。这里有个需要注意的地方,如果右侧的书的边界是贴着左侧的,那翻过来之后就是贴着右侧了,但是u的方向不能简单镜像,因为那样会让字都反过来。
因为书页既可能从右翻到左,也可能从左翻到右。由由于翻的方向不同,其实我们需要虚拟出的圆柱体的方向也有所不同,如果是从右向左,圆柱体应该是轴线在初始状态(θ为0)的时候是在z轴负半轴。反之,如果从左向右,则在z轴正半轴。

所以我们总共需要考虑4种情况。
我们校正uv可以分为两步:
1.根据指外翻还是内翻(即从右向左还是从左向右),将当前可见页的u重新映射到[0,1]的区间里
2.如果是B面(即观察方向和面的方向相反),将u做one minus取补。(例如[0,1]->[1,0])
为方便比较,直接画出下面的表格

1
2
3
4
5
6
7
8
9
10
11
// Switch left page or right page
float IsLeft = sign(overlap_face * dir);

// Sampled texure color
float4 sampled_color = IsLeft? tex2D(leftTex, overlap_uv):tex2D(rightTex, overlap_uv);
// Check if intersect uv is on the left(positive) side and in [0,1]
float current_page_mask = (
overlap_1st_uv.x > 0 && overlap_1st_uv.x < x_limit
&& overlap_1st_uv.y > 0 && overlap_1st_uv.y < 1) ? 1:0;
sampled_color = sampled_color * current_page_mask; // Show only the current page
sampled_color = float4(lerp(base_color.rgb, sampled_color.rgb, IsLeft), sampled_color.a);

完成这一步之后,我们就能将正确的uv绘制出来了。

自动曲率

我们在纸翻到一般,立于纸面的时候,希望曲率半径小一些(曲率大一些),而在靠近纸面处曲率半径大一些(曲率小一些)。我们可以绘制以下类似函数,从而实现动态自动调整弧度

我使用的是上边红色的函数曲线,主要是多了一点小变化。如果要简化,用下面的函数也没问题。

添加活页扣

为了让书本更有趣,有时候会想加上一个活页的结构。因为活页扣比纸面高,所以它的处理需要额外处理一下。不过因为我们前面已经取得了大部分的坐标信息。所以到这一步的时候,我们只用比价一下深度就能知道绘制次序了。

1
2
3
4
5
6
7
8
// Ring-binder
float occlude_mask = saturate(sign(ring_height - T_z));
float4 ring_color = tex2D(RingTex, ring_uv);

float opacity = max(base_color.a, sampled_color.a);
float4 final_color = float4(
lerp(sampled_color.rgb, ring_color.rgb, ring_color.a * occlude_mask),
opacity);

参考

[1] Implementing iBooks page curling using a conical deformation algorithm
[2] Turning Pages of 3D Electronic Books [Lichan Hong et al. 2003]
[3] Intersection of a ray and a cone