《Real-Time Rendering》变换

本文同时发布在我的个人博客https://dragon_boy.gitee.io

本章目录概览:

  • 基础变换
  • 特殊变换和操作
  • 四元数
  • 顶点混合
  • 变形
  • 几何缓存回放
  • 投影

变换是将例如点、向量或颜色等实体以某种方式进行转换的操作。对于图形从业者,掌握变换方法是很重要的,通过变换,我们可以对物体、灯光和摄像机进行位移、形状重构和做动画。我们可以确保所有的计算都发生在同一坐标系下,并通过一些不同的方式投射物体到一个平面上。上面这些只是变换可以做到的部分操作,但在图形学中,这些也差不多足够了。

线性变换:

f(x)+f(y)=f(x+y),(4.1)

kf(x)=f(kx),(4.2)

例如,f(x)=5x是一种让一个矢量的每个元素乘以5的变换。为了证明这一等式是线性的,上述两个等式条件要满足(4.1和4.2)。第一个条件成立,当任意两个矢量与5相乘然后相加,情况和两个矢量相加结果乘以5一样。第二个条件很明显成立。这一等式称为缩放变换,它改变了物体的大小。旋转变换是另一种线性变换,它旋转一个矢量。缩放和旋转变换,或者说所有针对三个矢量元素的线性变换,都可以写成3 \times3的矩阵形式。

然而,这种类型的矩阵的大小是不够的。一个针对三元素矢量x的函数,例如f(x)=x+(7,3,2)就不是线性的,在几个独立的矢量上执行这种变换的话需要加(7,3,2)多次来得到结果。将一个固定矢量与另一个矢量相加的操作称为平移,他将所有的位置移动了相同的值,这是一种非常有用的变换类型。我们还可以将不同的变换结合起来,例如,将一个物体缩小一半,然后将它移动到一个位置。不过目前这种将变换简单表示的方法不太容易将变换结合起来。

将线性变换和平移结合起来的话,我们可以使用仿射变换,典型地来说就是存储在一个4 \times4矩阵中。仿射变换是一种先执行线性变换再平移的变换。为了表示这个4元素矢量,我们使用齐次标记,用相同的方式表示点和方向。一个方向矢量可以表示为v=(v_x,v_y,v_z,0)^T,一个点可以表示为v = (v_x,v_y,v_z,1)^T

所有的平移、旋转、缩放、反射、剪切矩阵都是仿射变换。一个仿射矩阵的主要特点是它存储平行线,长度和角度都不需要。一个仿射变换也可以是多个仿射变换的序列。

变换时操作几何体的一种基本工具,大多数的图形应用编程平台提供给用户操作矩阵的能力,有时候是一个库,但明白矩阵操作的原理还是很有必要的。

基础变换

这一节讲述一些基本的变换,如平移、旋转、缩放、剪切、变换组合、刚体变换、法线变换和逆矩阵计算等。我们从平移开始。

平移

从一个位置到另一个位置的变换可以用一个平移矩阵T表示,这个矩阵通过一个矢量t=(t_x+t_y+t_z)来平移一个实体,T矩阵的表示如下:

T(t)=T(t_x+t_y+t_z)=\left(\begin{matrix} 1&0&0&t_x\\0&1&0&t_y\\0&0&1&t_z\\0&0&0&1\end{matrix}\right) (4.3)

平移矩阵的一个效果例子如下:


一个点p=(p_x,p_y,p_z,1)T(t)相乘后,平移变换得新的顶点p'=(p_x+t_x,p_y+t_y,p_z+t_z,1)。注意,一个形如v=(v_x,v_y,v_z,0)的矢量不会受平移矩阵T(t)的影响,这是因为方向矢量不会被平移。一个平移矩阵的逆矩阵为T^{-1}(t)=T(-t),即矢量t取反。

注意,矩阵的标记法有两种,这里我们使用列标记法,即矩阵是由多个列矢量排列成的,但注意,这与API有关,DirectX使用的是行标记法,OpenGL使用的是列标记法。

旋转

旋转变换通过绕某个轴旋转给定角度实现,和平移变换类似,它是一个刚体变换,即它会保持点变换之间的距离,轴保持右旋性(轴向不会反转)。这两种变换在确定位置和定向时非常有用。一个定向矩阵是一个绑定在摄像机或物体上的旋转矩阵,它定义了空间中它的朝向。

在二维空间,旋转矩阵很容易获得。假设我们有一个矢量,v=(v_x,v_y),我们用极坐标表示就是v=(rcos\theta, rsin\theta),我们将这个矢量逆时针旋转\phi弧度,我们就可以得到新矢量u=(rcos(\theta+\phi),rsin(\theta+\phi)),可以像下面这样表示:

u=\left(\begin{matrix}rcos(\theta+\phi)\\rsin(\theta+\phi)\end{matrix}\right)=\left(\begin{matrix}r(cos\theta cos\phi-sin\theta sin\phi)\\r(sin\theta cos\phi+cos\theta sin\phi)\end{matrix}\right)=\left(\begin{matrix}cos\phi&-sin\phi\\sin\phi&cos\phi\end{matrix}\right)\left(\begin{matrix}rcos\theta\\rsin\theta\end{matrix}\right)=R(\phi)v

这里我们将三角函数扩展开,得到一个旋转矩阵。在三维空间中,我们常使用旋转矩阵R_x(\phi),R_y(\phi),R_z(\phi),它们将实体沿x、y、z轴旋转\phi弧度,表示如下:

R_x(\phi)=\left(\begin{matrix}1&0&0&0\\0&cos\phi&-sin\phi&0\\0&sin\phi&cos\phi&0\\0&0&0&1\end{matrix}\right)

R_y(\phi)=\left(\begin{matrix}cos\phi&0&sin\phi&0\\0&1&0&0\\-sin\phi&0&cos\phi&0\\0&0&0&1\end{matrix}\right)

R_z(\phi)=\left(\begin{matrix}cos\phi&-sin\phi&0&0\\sin\phi&cos\phi&0&0\\0&0&1&0\\0&0&0&1\end{matrix}\right)

如果删除最后一列和最后一行,就可以得到一个3\times3矩阵,对每个3\times3旋转矩阵R,我们可以发现矩阵的迹(方阵对角线的元素的和)不变,如下:

tr(R)=1+2cos\theta

所有的旋转矩阵的行列式为1,也就是说它们都是正交矩阵,当结合多个变换后这一特性仍保持。获取逆矩阵的方式:R_i^{-1}(\phi)=R_i(-\phi),即在相反的方向上旋转。

一个例子:假设我们将一个物体绕z轴旋转\phi弧度,旋转中心为p点,图表显示如下:

绕一个点旋转的事实是这个点不会变化。我们先使用逆变换T(-p)将p点变换会原点,然后执行旋转R_z(\phi),最后我们在使用T(p)将其变换为原来的位置,结合的矩阵为:

X=T(p)R_z(\phi)T(-p)

注意上述矩阵相乘的顺序。

缩放

缩放矩阵,S(s)=S(s_x,s_y,s_z),使用s_x,s_y,s_z三个因数缩放x,y,z轴,这样缩放矩阵就可以放大或缩小一个物体。s_i越大,在该方向上的物体越大。缩放矩阵形式如下:

S(s)=\left(\begin{matrix}s_x&0&0&0\\0&s_y&0&0\\0&0&s_z&0\\0&0&0&1\end{matrix}\right)

如果s_x=s_y=s_z,那么这个缩放称为统一缩放,否则为非统一缩放。缩放矩阵的逆矩阵是S^{-1}(s)=S(1/s_x,1/s_y,1/s_z)

使用齐次坐标的话,就可以通过操作矩阵右下角的元素来获得统一缩放的另一种形式。例如,缩放因子为5,有下面两种方式设置缩放矩阵:

S=\left(\begin{matrix}5&0&0&0\\0&5&0&0\\0&0&5&0\\0&0&0&1\end{matrix}\right),S'=\left(\begin{matrix}1&0&0&0\\0&1&0&0\\0&0&1&0\\0&0&0&1/5\end{matrix}\right)

与使用S进行同一缩放相反,使用S'必须要设置齐次坐标,这种方法可能并不有效,在进行透视除法时会有问题(因为要确定齐次坐标是否为1)。

s的1个或3个元素如果取反的话,就可以得到反射矩阵,或镜像矩阵。如果是由两个缩放因数设为-1,那么相当于旋转了180°。注意,如果一个旋转矩阵和一个反射矩阵结合的话,它也是一个反射矩阵,因此,下面是一个反射矩阵:

\left(\begin{matrix}cos(\pi/2)&sin(\pi/2)\\-sin(\pi/2)&cos(\pi/2)\end{matrix}\right)\left(\begin{matrix}1&0\\0&-1\end{matrix}\right)=\left(\begin{matrix}0&-1\\-1&0\end{matrix}\right)

反射矩阵在检测时通常需要特殊对待。例如,一个沿逆时针绘制的三角形,在使用反射矩阵变换时会得到顺时针绘制的三角形,这种绘制顺序变化会导致不正确的光照和背面消隐问题。为了一个矩阵是否以某种方式反射,我们可以计算上方3\times 3的矩阵的行列式,如果值为负,那么它就是反射的,例如,上述的例子中,行列式为0\cdot0-(-1)\cdot(-1)=-1

在特定的方向上缩放。缩放矩阵S只能在特定的x、y、z轴上缩放。如果缩放需要在其它的方向上缩放,我们需要一个复合变换。假设缩放沿正交右旋的三个轴矢量f^x,f^y,f^z,首先,构建矩阵F

F=\left(\begin{matrix}f^x&f^y&f^z&0\\0&0&0&1\end{matrix}\right)

这个矩阵可以将原始空间坐标系转化到我们指定的空间坐标系。我们执行这种任意轴的缩放的方法是,先与F的转置相乘,或者说F的逆矩阵(F是正交的),然后,执行缩放,最后用F变换回去:

X=FS(s)F^T

剪切

另一种变换是剪切矩阵,它的作用是可以扭曲一个场景造成一种幻觉效果。有6种基本剪切矩阵,它们被标记为H_{xy}(s),H_{xz}(s),H_{yx}(s),H_{yz}(s),H_{zx}(s),H_{zy}(s),第一个轴代表剪切矩阵使用的轴,第二个轴代表执行剪切的轴。下面是剪切矩阵的一个例子:

剪切矩阵H_{xz}(s)表示如下:

H_{xz}(s)=\left(\begin{matrix}1&0&s&0\\0&1&0&0\\0&0&1&0\\0&0&0&1\end{matrix}\right)

写法很简单,因数s写在剪切要剪切的轴,即x轴,也就是第一行,然后位于执行剪切的列,即第三列,对应z

对一个点p执行这一变换的效果是(p_x+sp_z, p_y,p_z)^T。剪切矩阵的逆是H^{-1}_{ij}(s)=H_{ij}(-s),即在相反方向变换。

我们还可以使用不同的剪切矩阵:

H'_{xy}(s,t)=\left(\begin{matrix}1&0&s&0\\0&1&t&0\\0&0&1&0\\0&0&0&1\end{matrix}\right)

使用上述表示方式的话,下标的两个轴都会变化,同时没有写的那个轴执行变化s,t,相当于H'_{ij}(s,t)=H_{ik}(s)H_{jk}(t)。注意,所有剪切矩阵的行列式均为1,是一个体积守恒的变换,这在上方的图片例子中也体现了出来。

复合变换

由于矩阵乘法的不可交换性,矩阵的顺序是非常重要的,复合变换因此是依赖顺序的。

举个例子,考虑两个矩阵SRS(2,0.5,1)进行缩放,R_z(\pi/6)绕z轴逆时针旋转\pi/6弧度,这两个矩阵的结合有两种方式:


可以看到这两种方式的结果完全不同。

将多个矩阵结合为一个矩阵是出于效率的考虑,例如,想象一下你有一个游戏场景,场景中有几百万个顶点,现在场景种所有的物体都要缩放、旋转,最后平移。现在,我们不对每个顶点使用三个矩阵,而是将三个矩阵复合为一个矩阵使用。这个复合矩阵为C=TRS,注意这里的顺序,缩放矩阵必须首先使用,因此出现在符合矩阵种的最右侧。与一个顶点使用的方式是TRSp=(T(R(Sp))),同时这一顺序是大多数场景图表系统使用的。

值得注意的是,虽然矩阵符合是需要顺序的, 但矩阵本身可以按期望组合起来。例如。TRSp,我们可以首先计算刚体运动变换TR,然后可以这样组合(TR)(Sp),然后用中间结果替换。因此,矩阵复合是可以组合的。

刚体变换

当我们抓取一个物体,比如将一支笔从桌上拿起,然后将其移动到另一个位置,比如衬衫口袋,其中,只有物体的朝向和位置发生了改变,物体本身的形状没有变化。这样的变换,只结合了平移和旋转,被称为刚体变换,它有保留长度、角度和右旋性的特点。

所有的刚体矩阵X可以被写为一个平移矩阵T(t)和一个旋转矩阵R的复合,因此,X的等式如下:

X=T(t)R=\left(\begin{matrix}r_{00}&r_{01}&r_{02}&t_x\\r_{10}&r_{11}&r_{12}&t_y\\r_{20}&r_{21}&r_{22}&t_z\\0&0&0&1\end{matrix}\right)

X的逆矩阵为X^{-1}=(T(t)R)^{-1}=R^{-1}T(t)^{-1}=R^TT(-t)。另一种计算逆矩阵的方式是将RX按照下面的方式标记:

\overline{R}=(r_{,0},r_{,1},r_{,2})=\left(\begin{matrix}r_{0,}^T\\r_{1,}^T\\r_{2,}^T\end{matrix}\right),

X=\left(\begin{matrix}\overline{R}&t\\0^T&1\end{matrix}\right)

那么X的逆矩阵的表示可以如下:

X^{-1}=\left(\begin{matrix}r_{0,}&r_{1,}&r_{2,}&-\overline{R}^Tt\\0&0&0&1\end{matrix}\right)

例子:旋转一个摄像机。在图形学中一个常见的任务是定向一个摄像机,让它永远看向某一位置。我们这里用OpenGL公用库GLU的函数gluLookAt()函数。虽然这个方法如今已经不怎么实用,但定向摄像机的任务还是很重要的。假设摄像机在c,指向目标l,接着给定摄像机的上方向u',就像下图一样:


我们想要计算基础的三个矢量
{r,u,v}
。我们首先计算朝向
v=(c-l)/||c-l||
,注意这是标准化的矢量。指向右方的矢量
r=-(v\times u')/||v\times u'||
u'
并不是最终的指向上的方向,摄像机的上方向为
u=v\times r
,由于
v,r
标准化,
u
也是标准的,同时三个矢量相互正交。接着构建我们的摄像机变换矩阵
M
,想法是先平移所有物体,让摄像机位于原点
(0,0,0)
,然后修改三个基矢量,
r
对应
(1,0,0)
,x轴,
u
对应
(0,1,0)
,y轴,
v
对应
(0,0,1)
,z轴。表示如下:

M=\left(\begin{matrix}r_x&r_y&r_z&0\\u_x&u_y&u_z&0\\v_x&v_y&v_z&0\\0&0&0&1\end{matrix}\right)\left(\begin{matrix}1&0&0&-t_x\\0&1&0&-t_y\\0&0&1&-t_z\\ 0&0&0&1\end{matrix}\right)=\left(\begin{matrix}r_x&r_y&r_z&-t\cdot r\\u_x&u_y&u_z&-t\cdot u\\v_x&v_y&v_z& -t\cdot v\\0&0&0&1\end{matrix}\right)

注意我们将平移矩阵和基础变化结合在一起,平移矩阵要先使用。

法线变换

一个矩阵可以用来变换点、线、三角形和其它几何体,同样的矩阵也可以用来变换平行三角形表面的切线矢量,然而,这一矩阵并不能用来变换一个重要的几何体属性——表面法线(和点光照法线),下图显示了相关问题:



我们不再直接与这个矩阵相乘,最恰当的方法是使用该矩阵伴随矩阵的转置。在经过变换后,法线的长度可能不再是1,所以需要标准化。

变换法线的传统方法是使用矩阵逆的转置,这个方法通常是有效的。然而,完全的逆矩阵计算不是必须的,有时候可能不存在。逆矩阵本身是原矩阵的伴随矩阵除以原矩阵的行列式计算得来的,如果行列式为0的话,矩阵就是奇异的,逆矩阵就不会存在。

虽然只是计算4\times 4矩阵的伴随矩阵,但开销还是挺大的,通常也是不需要的。由于法线是一个方向矢量,平移对其无效,此外,大多数模型变换都是仿射的。这样的话,法线使用模型变换时并不会改变传入的齐次坐标,即不执行投影操作。在这些情况下,所需要计算的就是左上3\times 3的伴随矩阵。

通常甚至是上述这种伴随矩阵计算都不是必须的。假设我们知道变换矩阵完全由平移、旋转和统一缩放组成,平移不影响法线,同一缩放的因数仅仅改变法线的长度,剩下的旋转经常产出一些旋转。逆矩阵的转置可以变换法线,而旋转矩阵的定义是它的转置等于它的逆。作为法线变换的取代,两次转置或两次逆变换就得到原始矩阵。将他们结合起来,原始的矩阵变换就可以直接用来变换法线。

最后,对法线的完全重新标准哈也并不总是需要的。如果只有平移和旋转组合了起来,法线经过变换后就不会改变长度,那么重新标准化就不需要。如果统一变换也组合在一起,那么缩放因数可以用来直接标准化法线。例如,如果我们知道一系列应用的缩放可以让物体放大5.2倍,那么通过该矩阵变换的法线可以通过除以5.2来重新标准化。同样,我们可以创建一个可以得到标准化结果的法线变换矩阵,即原矩阵左上角3\times 3除以缩放因数。

注意,法线变换并不是一个问题。在变换后,表面法线会从三角形得到(三角形的边叉乘得到)。切线矢量和法线并不同,通常可以直接由原矩阵变换得到。

逆变换计算

逆变换在很多情况下都是需要的,例如,当在不同坐标系统中转换时。取决于一个变换的一些信息,下面三个方法可以用来计算一个矩阵的逆:

  • 如果矩阵是一个简单的变换或一系列简单变换的序列,通常只有一个参数,那么逆矩阵可以通过对参数和举着顺序取反得到,例如,如果M=T(t)R(\phi),那么M^{-1}=R(-\phi)T(-t)。这个方法很简单,并保留了变换的准确性,这在渲染大型世界时非常重要。
  • 如果矩阵是一个正交矩阵,那么M^{-1}=M^T,即转置等于逆。所有旋转的序列相当于一个旋转,这样它就是正交的。
  • 如果不知道其它信息,那么就是用伴随矩阵方法,克莱姆法则,三角分解法或高斯消元法。克莱姆法则和伴随矩阵方法通常最合适,因为要使用的计算分支更少。

使用逆矩阵的计算的目的可以被考虑在优化中,例如,如果一个逆矩阵被用来变换矢量,通常只需要左上角3\times 3矩阵进行逆计算。

特殊矩阵变换和操作

在这一节介绍一些实时渲染中重要的矩阵变换和操作,首先,我们介绍欧拉变换,它非常适合在主观上描述旋转。然后我们从单个矩阵介绍一系列基础的变换,最后,介绍一种绕任意轴旋转的方法。

欧拉变换

这一变换是构建一个矩阵来绕一个特定方向旋转自身或其它实体的一种主观方法。

首先,构建一些特定的默认视角方向,绕x,y,-z轴旋转的示例图如下:



欧拉变换是三个矩阵的乘积,形式上表示如下:

E(h,p,r)=R_z(r)R_x(p)R_z(h)

矩阵的顺序有24种不同的方式,但上述是最长使用的。由于E是旋转的复合,很显然它是正交的,因此它的逆可以表示为E^{-1}=E^T=(R_zR_xR_y)^T=R_y^TR_x^TR_x^T

欧拉角h,p,r分别表示了头角、俯仰角、横摇角绕它们轴旋转的程度。有时这些角度也可以称为环绕角,即头角为y环绕角,俯仰角为x环绕角等。同时,头角也有时被称为偏航角,这用在飞行器模拟中。

这一变换是主观的,因此让外行人也可以很快理解。例如,改变头角相当于让观察者摇头,改变俯仰角相当于观察者点头,改变横摇角相当于观察者左右晃头。我们不再按绕x、y、z轴讨论旋转,而是使用头角、俯仰角、横摇角来讨论。注意,这一变换并不只用于摄像机,也可用于其它实体,这些变换可以使用世界空间的全局坐标系或局部坐标系。

非常重要的一点是一些欧拉角的表示将z轴作为初始朝上的方向,区别只是标记的改变,虽然会让人有些疑惑。在计算机图形学中对于如何构建世界有两种方式:y轴朝上或z轴朝上。大多数制造业,包括3D打印业,世界空间中将z轴作为向上的方向,航空和潜水艇行业,将-z轴作为向上的方向。工业和GIS行业通常将z轴作为向上的方向,这是因为建筑规划或地图是二维的,x和y轴。媒体相关的建模系统通常在世界空间中将y轴作为向上的方向,这和我们在计算机图形学中形容摄像机屏幕上方向相匹配。这两种世界上方向矢量的区别就是一个90°旋转,但哪种会带来问题就不得而知了。在这里我们将y轴作为世界空间上方向。

我们也要指出摄像机在其视角空间的上方向和世界空间的上方向并没有必然的联系。左右摇头,摄像机就会左右摇晃,但对应的世界空间的轴向就不同了。另一个例子,假设世界空间使用y轴向上,我们的摄像机使用俯瞰视角,这也就意味着摄像的上方向在世界空间中对应(0,0,-1),在这一旋转中,摄像机没有y组件,作为代替考虑使用-z轴作为世界空间上方向,但在视角空间中y轴向上仍是正确的。

欧拉角对于小角度变换或视角旋转非常有用,但也有一些其它严格的限制,很难将两组欧拉角组合在一起。例如,在一组和另一组欧拉角之间进行插值并不是简单的对每个角度进行插值。实际上,两组不同的欧拉角可以得到相同的旋转,所以所有插值都不应该旋转物体,这也是使用一些替代的旋转表示法的原因,如四元数。使用欧拉角的时候,也会碰到万向轴锁死的情况。提取参数

从欧拉变换

在一些变换中,从一个正交矩阵中提取欧拉参数的流程非常有用,这一过程显示如下:

E(h,p,r)=\left(\begin{matrix}e_{00}&e_{01}&e_{02}\\e_{10}&e_{11}&e_{12}\\e_{20}&e_{21}&e_{22}\end{matrix}\right)=R_z(r)R_x(p)R_y(h)

这里我们只使用3\times 3矩阵,这对于旋转来说足够了,通常4\times 4矩阵的剩余行列均有0填充,右下角为1.

将三个旋转矩阵组合起来就是:

E=\left(\begin{matrix}cosrcosh-sinrsinpsinh&-sinrcosp&cosrsinh+sinrsinpcosh\\sinrcosh+cosrsinpsinh&cosrcosp&sinrsinh-cosrsinpcosh\\-cospsinh&sinp&cospcosh\end{matrix}\right)

通过上述等式可以知道sinp=e_{21},同时,e_{01}除以e_{11}e_{20}e_{22}相似,给出对头角和横摇角的参数:

\frac{e_{01} } {e_{11} }=\frac{-sinr} {cosr}=-tanr \quad and \quad \frac{ e_{20} } {e_{22} }=\frac{-sinh} {cosh} = -tanh

这样的话,h、p、r就可以从矩阵中提取出来:

h = atan2(-e_{20},e_{22})

p=arcsin(e_{21})

r=atan2(-e_{01},e_{11})

然而,我们还需要出一个特殊情况,如果cosp=0,就会遇到万向轴锁死的情况,这时候,旋转角r和h就会绕同一轴向旋转,这样就只需要一个角度。如果我们人为的将h设置为0,我们就可以得到:

E=\left(\begin{matrix}cosr&sinrcosp&sinrsinp\\sinr&cosrcosp&cosrsinp\\0&sinp&cosp\end{matrix}\right) \quad 4.26

由于p不影响第一列,当cosp=0时我们可以使用sinr/cosr=tanr=e_{10}/e_{00},这样r=atan2(e_{10},e_{00})

注意来意arcsin的定义,-\pi/2 \leq p \leq \pi/2,这也意味着如果E使用在这区间范围外的p值的话,就无法提取出参数。h、p、r并不唯一意味着多种欧拉参数可以得到相同的变换。

当使用欧拉变换时,万向轴锁死的问题可能会发生,即旋转的某一方向的自由度丢失。例如,假设旋转顺序是x/y/z,考虑绕y轴旋转\pi /2,第二个旋转执行,这样的话,局部坐标系的z轴就和原x轴重合,这样的话最后的绕z轴的旋转就是多余的。

数学上,等式4.26就表示了一种万向轴锁死,我们假设cosp=0,即p=±\pi / 2+2\pi kk是一个整数。使用这样的p值,我们就丢失了某一角度的自由度,因为矩阵将只取决于一个角度,r+hr-h

在建模系统冲,欧拉角常标识为x/y/z的顺序,其它的顺序也是可行的。例如,z/x/y用在动画中,z/x/z可用在动画和物理中。所有的方法都是合法的。最后的z/x/z顺序,对于某些应用是最高优先级的,这是因为绕x轴旋转\pi弧度会造成万向轴锁死。并不存在某一特定旋转顺序来避免万向轴锁死,尽管如此,欧拉角还是经常使用的,因为动画制作者通常更希望使用曲线编辑器来确定角度如何随时间变化。

例子:变换约束。想象一下你在用一个扳手拧螺丝,为了让螺丝拧到位,你需要将扳手绕x轴旋转。现在假设你的输入设备给了你一个旋转矩阵,即旋转扳手的旋转,问题在于直接将矩阵用于扳手可能会造成问题,扳手本应该只绕x轴旋转。为了限制输入变换P为一个绕x轴的旋转,我们简单的从中提取欧拉角h,p,r,然后创建一个旋转矩阵R_x(p),这是一种受欢迎的变换方式。

矩阵分解

到目前为止我们的工作都在我们知道原始和历史变换矩阵的假设下,这通常不是一个问题,例如,不外乎是一个复合矩阵可能与一些变换对象绑定,从一个复合矩阵获得不同变换的过程称为矩阵分解。

获取一系列变换由许多理由,包括:

  • 提取一个物体的缩放因数
  • 寻找一个特定系统所需的变换(例如,一些系统可能不允许使用手动的4\times 4矩阵)
  • 判断一个模型是否只经历了刚体变换
  • 在只有对象矩阵可获取的时候在动画帧之间插值。
  • 从一个旋转矩阵中移除剪切。
    之前的欧拉变换和剪切变换时演示过一些分解。

正如我们所看到的,检索得到平移矩阵是不需要的,因为我们只需要矩阵的右侧4个元素即可。我们同样可以通过判断一个矩阵的行列式是否为负来判断是否发生了反射。为了分理出旋转、缩放和剪切则需要更多的判断。

绕任意轴旋转

有时绕某一任意轴旋转一些角度在某些处理过程中是很方便的。假设旋转轴r是标准的,变换应该绕r旋转\alpha弧度。

为了做到这一点,我们首先变换到我们想要旋转轴的空间,这里的旋转轴是x轴。这通过一个旋转矩阵完成,称为M,然后真正的旋转执行后,我们使用M^{-1}变换回去,这一过程在下图中描述:


为了计算
M
,我们需要找到与
r
正交的两个轴。我们关注于找到第二个轴
s
,第三个轴
t
可以前两个轴叉乘得到,
t=r\times s
。一个数值稳定的方法是找到
r
的最小组件,并将其设置为0,交换剩余两个组件,然后将其中第一个组件取反,在数学上表示如下:

\overline{s}=\begin{cases}(0,-r_z,r_y), if\quad |r_x|\leq |r_y| \quad and \quad |r_x|\leq |r_z|\\(-r_z,0,r_x),if\quad |r_y|\leq |r_x| \quad and \quad |r_y|\leq |r_z|\\(-r_y,r_x,0),if\quad |r_z|\leq |r_x|\quad and \quad |r_z|\leq |r_y|\end{cases}

s=\overline{s}/||\overline{s}||

t=r\times s

上述式子保证了\overline{s}r正交,然后(r,s,t)是一组正交基。然后我们可以组件一个旋转矩阵M:

M=\left(\begin{matrix}r^T\\s^T\\t^T\end{matrix}\right)

这一矩阵将矢量r变换到x轴,s变换到y轴,t变换到z轴,那么最终绕r轴旋转\alpha的变换为:

X=M^TR_x(\alpha)M

换句话说,这意味着第一个变换是将r轴变换到x轴,使用M,然后绕x轴旋转\alpha弧度,使用R_x(\alpha),然后使用M的逆矩阵将旋转变换回去,因为M正交,M^T=M^{-1}

另一种绕某一任意轴旋转的方法如下:

R=\left(\begin{matrix}cos\phi + (1-cos\phi)r_x^2&(1-cos\phi)r_xr_y-r_zsin\phi&(1-cos\phi)r_xr_z+r_ysin\phi\\(1-cos\phi)r_xr_y+r_zsin\phi&cos\phi+(1-cos\phi)r_y^2&(1-cos\phi)r_yr_z-r_xsin\phi\\(1-cos\phi)r_xr_z-r_ysin\phi&(1-cos\phi)r_yr_z+r_xsin\phi&cos\phi+(1-cos\phi)r_z^2\end{matrix}\right)

四元数

虽然四元数1843年就由哈密顿发明出来作为复数的扩展,但直到1985年休梅克才介绍其在计算机图形学中的引用。四元数被用来代表旋转和朝向,它们比欧拉角和矩阵在某些方面要高级一些。任何一个三维旋转矩阵都可以被表示为一个绕某一特定轴的旋转,通过给定轴和角度,将其转换为四元数的表示要直接一些,同时如果使用欧拉角在每个轴向表示的话就会有些困难。四元数可以用于稳定且连续的旋转插值,这在有些时候不能使用欧拉角来完成。

一个复数有实部和虚部,类似的,四元数有四个部分。前三个值和三个旋转轴相关,旋转的角度影响所有的四个部分。每个四元数通过四个实数表示,每个元素与一个不同的部分相绑定。由于四元数有四个组件,我们选择将其表示为一个矢量,但为了与一般矢量相区分,我们使用\hat{q}来表示。我们首先介绍四元数的数学背景。

数学背景

我们首先定义四元数。

定义:一个四元数\hat{q}可以由以下方法定义,都是同等的:

\hat{q}=(q_v,q_w)=iq_x+jq_y+kq_z+q_w=q_v+q_w

q_v=iq_x+jq_y+kq_z=(q_x,q_y,q_z)

i^2=j^2=k^2=-1,jk=-kj=i,ki=-ik=j,ij=-ji=k

变量q_w被称为四元数的实部,q_v是虚部。

针对虚部q_v,我们可以使用正常的矢量操作,例如加,缩放,点乘,叉乘和其它。使用四元数的定义,两个四元数\hat{q},\hat{r}的相乘操作,显示如下:

\hat{q}\hat{r}=(iq_x+jq_y+kq_z+q_w)(ir_x+jr_y+kr_z+r_w)\\=i(q_yr_z-q_zr_y+r_wq_x+q_wr_x)\\+j(q_zr_x-q_xr_z+r_wq_y+q_wr_y)\\+k(q_xr_y-q_yr_x+r_wq_z+q_wr_z)\\+q_wr_w-q_xr_x-q_yr_y-q_zr_z\\=(q_v\times r_v+r_wq_v+q_wr_v,q_wr_w-q_v\cdot r_v)

在上述等式中可以发现,我们同时使用叉乘和点乘来计算两个四元数的乘积。

根据四元数的定义,相加,共轭,范数,单位复数的定义如下:

\hat{q}+\hat{r}=(q_v,q_w)+(r_v,r_w)=(q_v+r_v,q_w+r_w)

\hat{q}^{\star}=(q_v,q_w)^{\star}=(-q_v,q_w)

n(\hat{q})=\sqrt{\hat{q}\hat{q}^{\star} }=\sqrt{\hat{q}^{\star}\hat{q} }=\sqrt{q_v\cdot q_v + q_w^2}\\=\sqrt{q_x^2+q_y^2+q_z^2+q_w^2}

\hat{i}=(0,1)

n(\hat{q})=\sqrt{\hat{q}\hat{q}^{\star} }简化后,虚部被消掉,只有实部保留。范数有时可以被标记为||\hat{q}||=n\hat{q}。上述的一种结果是一个可相乘的逆,标记为\hat{q}^{-1},等式\hat{q}^{-1}\hat{q}=\hat{q}\hat{q}^{-1}=1对逆是必须成立的。我们可以从范数的定义得到一个方程:

n(\hat{q})^2=\hat{q}\hat{q}^{\star} \Leftrightarrow \frac{\hat{q} \hat{q}^{\star} } {n(\hat{q})^2} = 1

然后就可以得到逆:

\hat{q}^{-1}=\frac{1} {n(\hat{q})^2}\hat{q}^{\star}

逆的方程使用了标量乘法,这一操作从定义可以得到:s\hat{q}=(0,s)(q_v,q_w)=(sq_v,sq_w),且\hat{q}s=(q_v,q_w)(0,s)=(sq_v,sq_w),这也表明标量乘法是符合交换律的。

下面一些规则可以从定义中获得:

共轭规则:

(\hat{q}^{\star})^{star}=\hat{q}\\(\hat{q}+\hat{r})^{star}=\hat{q}^{\star}+\hat{r}^{star}\\(\hat{q}\hat{r})^{\star}=\hat{r}^{\star}\hat{q}^{\star}

范数规则:

n(\hat{q}^{\star})=n(\hat{q})\\n(\hat{q}\hat{r})=n(\hat{q})n(\hat{r})

乘法规则:
线性:

\hat{p}(s\hat{q}+t\hat{r})=s\hat{p}\hat{q}+t\hat{p}\hat{r}\\(s\hat{p}+t\hat{q})\hat{r}=s\hat{p}\hat{r}+t\hat{q}\hat{r}

交换律:

\hat{p}(\hat{q}\hat{r})=(\hat{p}\hat{q})\hat{r}

单位四元数,\hat{q}=(q_v,q_w),即n(\hat{q})=1,从这一点,\hat{q}可以被写成下面的形式:

\hat{q}=(sin\phi u_q,cos\phi)=sin\phi u_q + cos\phi

对于某一三维矢量u_q||u_q||=1,因为

n(\hat{q})=n(sin\phi u_q,cos\phi)=\sqrt{sin^2\phi (u_q\cdot u_q)+cos^2\phi}\\\sqrt{sin^2\phi + cos^2\phi}=1

只有u_q\cdot u_q=1=||u_q||^2,上式成立。在下一节就可以发现单位四元数对创建旋转非常合适。

对于复数,一个二维单位矢量可以被写为cos\phi + isin\phi=e^{i\phi}的形式,对四元数的等式如下:

\hat{q}=sin\phi u_q+cos\phi = e^{\phi u_q}

单位四元数的对数和幂运算:

log(\hat{q})=log(e^{\phi u_q})=\phi u_q

\hat{q}^t=(sin\phi u_q + cos\phi)^t=e^{\phi tu_q}=sin(\phi t)u_q+cos(\phi t)

四元数变换

接下来我们会学习四元数的一个子集,即单位四元数。单位四元数最重要的一点是它可以表示任何三维旋转,而且非常简洁和简单。

现在我们将描述是什么让单位四元数在表示旋转时非常有用。首先,将一个矢量或点的坐标p=(p_x\quad p_y\quad p_z\quad p_w)^T放入四元数\hat{p}的组件中,并假设我们有一个单位四元数\hat{q},可以得到以下变换:

\hat{q}\hat{p}\hat{q}^{-1}

\hat{p}绕轴u_q旋转2\phi,注意到由于\hat{q}是一个单位四元数,\hat{q}^{-1}=\hat{q}^{\star},这在下图中显示:


任何与
\hat{q}
的非零实部相乘都可以代表相同的变换,意味着
\hat{q}
-\hat{q}
代表相同旋转。也就是说,反转轴向
u_q
和实部
q_w
,可以创建一个和原来四元数旋转行为一致的四元数。这也意味着从一个矩阵提取一个四元数会返回
\hat{q}
-\hat{q}

给定两个单位四元数\hat{q}\hat{r},首先应用\hat{q}然后应用\hat{r}\hat{p}的复合如下:

\hat{r}(\hat{q}\hat{p}\hat{q}^{\star})\hat{r}^{\star}=(\hat{r}\hat{q})\hat{p}(\hat{r}\hat{q})^{\star}=\hat{c}\hat{p}\hat{c}^{\star}

矩阵转换

由于经常需要结合几个不同的变换,而且它们大多数都是矩阵形式,那么就需要一个将四元数转换为矩阵的方法。一个四元数\hat{q},转换为矩阵形式M^q的表达式如下:

M^q=\left(\begin{matrix}1-s(q_y^2+q_z^2)&s(q_xq_y-q_wq_z)&s(q_xq_z+q_wq_y)&0\\s(q_xq_y+q_wq_z)&1-s(q_x^2+q_z^2)&s(q_yq_z-q_wq_z)&0\\s(q_xq_z-q_wq_y)&s(q_yq_z+q_wq_x)&1-s(q_x^2+q_y^2)&0\\0&0&0&1\end{matrix}\right)

这里,s=2/(n(\hat{q}))^2,对于单位四元数,上述等式可以简化为:

M^q=\left(\begin{matrix}1-2(q_y^2+q_z^2)&2(q_xq_y-q_wq_z)&2(q_xq_z+q_wq_y)&0\\2(q_xq_y+q_wq_z)&1-2(q_x^2+q_z^2)&2(q_yq_z-q_wq_z)&0\\2(q_xq_z-q_wq_y)&2(q_yq_z+q_wq_x)&1-2(q_x^2+q_y^2)&0\\0&0&0&1\end{matrix}\right)

一旦四元数被构建,就不需要计算任何三角函数,在实际中这种转换过程非常有效率。

反向转换,即从一个正交矩阵M^q转换为一个单位四元数\hat{q}就有些复杂。关键点在于下面的对于上面矩阵的元素之间的差:

m_{21}^q-m_{12}^q=4q_wq_x\\ m_{02}^q-m_{20}^q=4q_wq_y\\ m_{10}^q-m_{01}^q=4q_wq_z \quad (4.47)

这些等式潜在的条件是q_w是已知的,v_q可以计算出来,这样\hat{q}可以得到,M^q的迹的计算如下:

tr(M^q)=4-2s(q_x^2+q_y^2+q_z^2)=4(1-\frac{q_x^2+q_y^2+q_z^2} {q_x^2+q_y^2+q_z^2+q_w^2})\\ =\frac{4q_w^2} {q_x^2+q_y^2+q_z^2+q_w^2}=\frac{4q_w^2} {(n(\hat{q}))^2}

上述结果可以得到下面针对一个单位四元数的转换:

q_w=\frac{1} {2}\sqrt{tr(M^q)},\quad q_x = \frac{m_{21}^q - m_{12}^q} {4q_w},\\ q_y=\frac{m_{02}^q - m_{20}^q} {4q_w}, \quad q_z = \frac{m_{10}^q - m_{01}^q} {4q_w}\quad (4.49)

为了保证数值稳定,对于小数字的除法要避免,因此,令t=q_w^2-q_x^2-q_y^2-q_z^2,这样的话:

m_{00}=t+2q_x^2\\ m_{11}=t+2q_y^2\\ m_{22}=t+2q_z^2\\ u=m_{00}+m_{11}+m_{22}=t+2q_w^2

上述式子也表明了m_{00},m_{11},m_{22},u中最大的那个元素决定q_x,q_y,q_z,q_w哪个最大,如果q_w最大,4.49式用来计算四元数,否则,使用下面的:

4q_x^2=+m_{00}-m_{11}-m_{22}+m_{33}\\ 4q_y^2=-m_{00}+m_{11}-m_{22}+m_{33}\\ 4q_z^2=-m_{00}-m_{11}+m_{22}+m_{33}\\ 4q_w^2=tr(M^q)

上述等式组用来计算q_x,q_y,q_z中的最大值,然后就可以使用4.47式计算剩余的组件。

球面线性插值

球面线性插值是这么一种操作:给定两个单位四元数\hat{q},\hat{r},和一个参数t\in [0,1],计算一个插值四元数,举例来说这对于为物体作动画来说很有用。这对于插值摄像机的旋转来说没那么有用,因为摄像机的上矢量会在插值期间扭曲变换,这通常是不想得到的效果。

这一操作的代数形式表示如下:

\hat{s}(\hat{q},\hat{r},t)=(\hat{r}\hat{q}^{-1})^t\hat{q}

然而,对于软件实现,下面的实现(slerp代表球面线性插值)更恰当:

\hat{s}(\hat{q},\hat{r},t)=\frac {sin(\phi (1-t))} {sin\phi}\hat{q}+\frac {sin(\phi t)} {sin\phi}\hat{r}

为了计算在等式中需要的\phi,可以使用下面的式子:cos\phi = q_xr_x+q_yr_y+q_zr_z+q_wr_w。对于t\in [0,1],球面线性插值函数计算从\hat{q}(t=0)\hat{r}(t=1)的在四维单位球面上的最短圆弧组成的四元数插值,这个圆弧位于\hat{q},\hat{r}和原点、四维单位球面的平面相交的圆上,这在下图中显示:


计算出来的旋转四元数绕一个固定轴以匀速旋转。像这样的曲线,拥有稳定的速度,即是0加速度的,这被称为测地曲线。大圆弧是一个经过原点的平面和球面相交得来的,这一圆的一部分称为一个大弧度。

球面线性插值方法对两个旋转进行插值非常有用,并且表现不错(固定轴,恒定速度),这在使用欧拉角进行插值时情况就不同了。实际中,直接结算一个球面线性插值是一种开销很大的操作,因为包括三角函数方法。Malushau讨论了将四元数插值应用到渲染管线的方法,他指出错误在一个三角形的朝向是4个角度的最大值,且为90度时发生,与其使用球面线性插值,不如直接在像素着色器中标准化四元数,这一错误率在光栅化一个三角形时是可以接受的。

当多余两个的旋转,假设为\hat{q}_0,\hat{q}_1,...,\hat{q}_{n-1},他们是可获取的话,我们希望从\hat{q}_0插值到\hat{q}_1\hat{q}_2一直到\hat{q}_{n-1},球面线性插值可以用一种更直接的方式使用。现在,当我们接近\hat{q}_i时,我们使用\hat{q}_i\hat{q}_{i+1}作为参数进行球面线性插值,这个会绕旋转朝向突然变换,这和点进行线性插值时发生的情况类似。

一种更好的插值方法是使用一些样条线。我们在\hat{q}_i\hat{q}_{i+1}之间引入四元数\hat{a}_i\hat{a}_{i+1},球面三次插值就可以通过上述4个四元数进行定义。可以发现,额外的四元数表示如下:

\hat{a}_i=\hat{q}_i exp\left[-\frac{log(\hat{q}_i^{-1}\hat{q}_{i-1})+log(\hat{q}_i^{-1}\hat{q}_{i+1})} {4}\right]

\hat{q}_i\hat{a}_i将会用来通过一个平滑的三次样条线进行球面插值:

squad(\hat{q}_i,\hat{q}_{i+1},\hat{a}_i,\hat{a}_{i+1},t)=\\ slerp(slerp(\hat{q}_i,\hat{q}_{i+1},t),slerp(\hat{a}_i,\hat{a}_{i+1},2t(1-t)))

从上式可以看出,squad方法使用slerp从重复的球面插值中构建,这一插值将会通过初始的旋转朝向\hat{q}_i,i\in[0,...,n-1],但不会通过\hat{a}_i——它们是用来索引初始旋转朝向的切线朝向。

从一个矢量旋转到另一个

一个普遍的操作是以最短的路径从一个方向s变换到另一个方向t。四元数极大的简化了这一流程。首先,标准化st,然后计算单位旋转轴,称为uu=(s\times t)/||s\times t||。接着,e=s\cdot t=cos(2\phi),且||s\times t||=sin(2\phi)2\phi是位于st之间的角。那么代表从st的旋转的四元数是\hat{q}=(sin\phi u, cos\phi)。实际上,可以使用半角相关和三角恒等式来简化\hat{q}=(\frac{sin\phi} {sin2\phi}(s\times t),cos\phi):

\hat{q}=(q_v,q_w)=\left(\frac{1} {\sqrt{2(1+e)}}(s\times t), \frac{2(1+e)} {2}\right)

使用方面的方式生成四元数避免了数值非稳定性(当st点几乎在同一方向上时)。当st点在对立方向上时,稳定性问题同时发生在两种方式上,即除以0的问题。当这种特殊情况被检测到时,任何和s垂直的旋转轴都可以用来旋转到t

有时,我们需要从s旋转到t的矩阵表示,在经过一些代数和三角函数简化后,旋转矩阵表示如下:

R(s,t)=\left(\begin{matrix}e+hv_x^2&hv_xv_y-v_z&hv_xv_z+v_y&0\\hv_xv_y+v_z&e+hv_y^2&hv_yv_z-v_z&0\\hv_xv_z-v_y&hv_yv_z+v_z&e+hv_z^2&0\\ 0&0&0&1\end{matrix}\right)

其中:

v=s\times t\\ e=cos(2\phi)=s\cdot t\\ h=\frac{1-cos(2\phi)} {sin^2(2\phi)} = \frac{1-e} {v\cdot v} = \frac{1} {1+e}

注意,我们必须考虑到当st平行或接近平行的情况,这时||s\times t||\approx0。如果\phi \approx 0,然后我们将得到一个单位矩阵。然而,如果2\phi \approx \pi,我们可以绕任意轴旋转\pi弧度,这个轴可以通过s和任意不平行s的矢量叉乘得到。

顶点混合

想象一个数字角色的手臂通过两部分来进行动画,前臂和上臂,在下方图例显示:


这个模型可以通过刚体变换来进行动画,然而,这两部分之间的关节可能不能重组为一个真正的肘关节,这是因为它们是两个独立的部分,因此,关节由两部分重叠的地方组成。显然,使用一个独立的物体是比较好的,然而,静态模型部分很难解决让关节变灵活的问题。

顶点混合是一个解决这一问题的最流行的方法,这一技术由其它的别名,如线性混合蒙皮,包络,骨骼子空间变形等。算法本身在这里并不详细介绍,定义骨骼和让蒙皮对改变做出反映是计算机动画的一个很老的概念了。用最简单的组成方式来说,前臂和上臂和前单独动画的方式不同,在关节处,这两部分通过可伸缩的皮肤连接。所以,这个可伸缩的部分有一系列顶点,它们通过前臂矩阵,另一系列顶点通过上臂矩阵进行变换。结果是三角形的顶点可能由不同的矩阵变换,与之对应地是每个三角形使用一个矩阵。

将这一步骤进一步进行说明,可以让一个顶点用过一些不同的矩阵进行变换,结果就是使用位置权重并混合在一起,这通过对要进行动画的物体进行绑骨进行,每个骨骼的变换可能影响每个顶点,通过一个用户自定义的权重。由于整体的手臂可能是可伸缩的,即所有的顶点可能被一个或多个矩阵影响,整个网格被称为蒙皮,在下图显示:


许多商业建模系统有相同的骨骼绑定建模特性。不管它们的名字,骨骼不需要是刚性的,例如,Mohr和Gleicher发表了额外的骨骼可以获得类似肌肉膨胀的效果,James和Twigg讨论了使用骨骼来伸缩挤压动画蒙皮的方法。

数学上,这一公式在下面显示,其中p是原始顶点,u(t)是变换顶点函数,它的位置取决于时间t

u(t)=\sum^{n-1}_{i=0} {\omega}_iB_i(t)M_i^{-1}p,\quad where \sum^{n-1}_{i=0} {\omega}_i=1,{\omega}_i\geq0,\quad (4.59)

n个骨骼影响p的位置,它的坐标显示在世界空间中。{\omega}_i是骨骼i对顶点p的权重,矩阵M_i将初始骨骼的坐标系转化到世界坐标系。典型地,一个骨骼有一个在其所在地初始坐标系的它控制地关节,例如,一个前臂骨骼可以使用一个绕肘关节动画旋转矩阵来从原始位置移动肘关节。B_i(t)矩阵是第i个骨骼的世界变换,它随时间对物体进行动画,它通常是几个矩阵的复合,比如层级上的前一个骨骼变换和局部动画矩阵。

一种保持和更新B_i(t)的矩阵动画方法有Woodland提出,每个骨骼将一个顶点从其局部位置变换到关于它的帧索引位置,最后的位置由一系列计算点插值得到。矩阵M_i在某些对于蒙皮的讨论中并没有明确提出,而是被考虑为B_i(t)的一部分。我们这里将其表示为一个有用的矩阵,它通常是一个复合矩阵过程的一部分。

在实际中,矩阵B_i(t)M_i^{-1}在每个骨骼的每一帧都进行复合,每个结果矩阵被用来变换顶点。顶点p通过不同的骨骼复合矩阵变换,然后使用权重{\omega}_i来混和,因此得名顶点混合。这些权重非负且和为1,所以发生的情况是顶点被变换到一些位置然后在它们之间进行插值。就本身而言,变换顶点uB_i(t)M_i^{-1}p的凸包中,对所有的i=0...n-1。法线同样可以使用4.59等式进行变换,取决于使用的变换(例如,一个骨骼是由是伸缩或挤压得),B_i(t)M_i^{-1}的逆的转置可能需要被替换。

顶点混合非常适合在GPU上使用,网格的一系列顶点可以被存储在一个静态缓冲中,然后一次性传给GPU并可以重复使用。在每一帧,只有骨骼矩阵改变,使用一个顶点着色器计算它们在存储网格上的效果。使用这种方法,从GPU转移和处理中的数据量出于最小化,允许GPU由效率地渲染网格。如果一个模型全部的骨骼矩阵可以同时使用,这种方式是最简单的,否则模型必须被分割为几部分,一些骨骼需要复制。作为替代,骨骼变换可以被存储在纹理中,顶点可以访问到,这避免了触及到寄存器存储限制的问题。每个变换可以被存储在两张纹理中,通过使用四元数来代表旋转。如果可以获取的话,未经排序的访问视图存储允许重复使用蒙皮结果。

如果权重集的范围位于[0,1]外或不能相加为1的情况是可以存在的,然而,这只有在一些其它的混合算法中成立,例如变形目标。

基本顶点混合的缺点是非期望折叠、扭转和自交会发生,这一情况在下图显示:


一个更好的方法是使用双重四元数,这一技术会使用蒙皮帮助保留原始变换的刚体性,这样就避免了躯干的“糖果包装纸”式的扭转。计算的消耗比线性蒙皮混合少1.5倍,结果很不错,表现出了对这一技术的快速适应性。然而,双重四元数蒙皮会导致突出效果,Le和Hodgins展示了使用旋转中心蒙皮作为一个更好的替代方法。它们依赖于一个假设,即局部变换应该式刚体的,且拥有相似权重的顶点应该拥有相似的变换。旋转中心对每个顶点进行预计算,同时正交约束用来阻止肘关节塌陷和糖果包装纸旋转问题。在运行时,这一算法和线性混合蒙皮类似,因为在GPU实现中会在旋转中心上执行线性混合蒙皮,接着一个四元数混合阶段。

变形

从一个三维模型变形到另一个模型在制作动画时很有用。想象以下,一个在时间t_0显示的模型,然后我们希望它可以变换到另一个在时间t_1的模型,对于所有在时间t_0t_1之间帧,一个连续混合的模型会计算得到,使用一些类型的插值。一个变形的例子显示在下图:

变形包含两个主要问题,顶点对应问题和插值问题。给定两个创建的模型,可能会使用不同的拓扑结构,不同数量的顶点和不同的网格连接性,这一点通常需要通过初始化顶点对应开始,这是一个很困难的问题,针对这一点这一领域存在很多相关研究,这里推荐Alexa的研究。

然而,如果两个模型已经是一对一的顶点对应,那么插值可以在逐顶点的基础上进行,也就是说,对第一个模型每个顶点,在第二个模型上必须对应存在一个顶点,反之亦然。这一点也让插值变得很简单,例如,线性插值可以在这些顶点上直接使用。为了计算一个针对时间t\in [t_0,t_1]的变形顶点,我们首先计算插值s=(t-t_0)/(t_1-t_0),然后进行线性顶点混合:

m=(1-s)p_0+sp_1

其中p_0p_1对应不同时间t_0t_1的相同顶点。

一个用户拥有更多控制权的变形变体是变形目标或混合形状(blend shapes),基本理念在下图显示:


我们从一个中间模型开始,在这个案例中是一张脸。我们将模型标记为N,额外地,我们还有一些不同的脸部表情。在这个例子中,只有一个笑脸。一般情况下,我们允许k\geq 1的不同表情,标记为P_i,i\in [1,...,k]。作为一个预处理,不同的脸被计算为:D_i=P_i-N,即中间模型被其它模型减去。

这时,我们有一个中间模型N,和一个不同的表情集D_i,一个变形模型M可以使用下面的公式获得:

M=N+\sum_{i=1}^k{\omega}_iD_i

对于中间模型,我们加上每个不同表情的特性,使用相应的权重{\omega}_i。在上面的图中,我们令{\omega}_i=1,这样可以得到一个完全的笑脸,令{\omega}_i=0.5可以得到一个半笑脸,以此类推,我们也可以使用负权重和大于1的权重。

对这个简单的脸部模型,我们可以加上另一个拥有悲伤表情的眉毛模型,使用一个眉毛的负权重可以创建一个快乐表情的眉毛,由于这些置换是可加性的,眉毛模型可以和一个拥有笑脸嘴巴的模型一起使用。

变形目标为动画工作者提供了很多的控制权,因为一个模型的不同的组件可以独立地变形到另一个模型。Lewis et al介绍了姿势空间变形技术,它将顶点混合和变形目标结合了起来。Senior使用了预计算的顶点纹理来存储和获取目标姿势间的置换。支持流输出和顶点ID的硬件允许更多的目标可以存储在单个模型中,以及效果可以单独地在GPU上计算。使用低分辨率网格然后通过细分曲面阶段和置换贴图生成高分辨率模型避免了在高分辨率模型上的蒙皮消耗。

一个使用蒙皮和变形的例子在下图显示(索尼的《声明狼藉:私生子》):


几何缓存回放

在剪辑场景中,通常期望使用极高质量的动画,例如,对无法表达的移动使用上述所有方法。一个最简单的方法是每帧存储所有的顶点,从硬盘读取它们然后更新网格,然而,对于一个简单的拥有30000个顶点的模型,用在一个简短动画中的话,读取速度可能至少需要50MB/s。Gneiting展示了一些减少内存消耗的方法,大概可以减少10%。

首先,使用量化操作,例如,位置和纹理坐标的每个坐标以16位整型的方式存储,这一步骤在广义上来说是失真的,即经过压缩后是不能恢复到原来的数据的。为了进一步减少数据,空间和暂时性预测被创建和区分编码。对于空间压缩,平行四边形预测可以被使用。对于一个连续三角形,下一个顶点预测位置可以简单地由当前三角形绕着当前三角形边地三角形平面镜像,构成一个平行四边形。和这一新位置地差被编码存储。通过使用良好的预测,大多数的值会接近0,这对于许多压缩模式来说是一个很理想的状况。和MPEG压缩类似,预测也在暂存维度中执行,即每个帧n,空间压缩被执行。在中间,预测在暂存维度被执行,例如,一个特定的顶点通过一个插值矢量从帧n-1到帧n,然后,通过一个相似的值移动到帧n+1。这些技术对减少存储空间来说足够了,这样就可以实时用在流数据中。

投影

在渲染场景前,所有场景中的相关物体必须被投影到某一类型的平面上或某一类型的简单的几何体中,在这之后,裁剪和渲染可以被执行。

在本章至今为止介绍的变换中都忽略了第四个坐标,即w组件,也就是说点和矢量在变换后保持着它们的类型。同样,4\times 4矩阵的最底下一行总是(0\quad 0\quad 0\quad 1)。透视投影矩阵对两种属性都由涉及:底部一行包含矢量和点的操作数,其次操作经常是需要的。也就是说,w经常不是1,所以除以w被需要用来获得非齐次点。正交投影,在这一章会先介绍的方式,是一种最简单的投影,也通常被使用,它不影响w组件。

在这一节,假设观察者沿摄像机负z轴观察,y轴朝上,x轴朝右,即右旋坐标系。一些资源和软件,例如Direct X,使用左手坐标系,观察者看向摄像机的+z轴。两个系统都是合法的,可以得到相同的效果。

正交投影

正交投影的一个特性是平行线在投影后保持平行。当透视投影用来观察一个场景时,不管距离远近物体保持相同的大小。矩阵P_O表示如下:

P_O=\left(\begin{matrix}1&0&0&0\\0&1&0&0\\0&0&0&0\\0&0&0&\end{matrix}\right)

上述矩阵是一个最简单的透视投影矩阵,它让x、y坐标保持不变,同时z坐标置为0,它正交投影到平面z=0上。

这一投影的效果在下图显示:


很明显,P_0时不可逆的,因为行列式|P_0|=0。换句话说,变换从三维塌缩到二维,就没有办法找到丢失的维度。使用这么一个正交投影来进行观察有一个问题,即他将正z和负z值的点都投影到投影平面上。通常会限制z轴到一个特定的间隔上,即从一个近平面到一个远平面。

一个更加通常使用的执行正交投影的矩阵由6个元素组成,(l,r,b,t,n,f),它们分别代表左、右、下、上、近平面和远平面。这一矩阵缩放和平移由这些平面构成与轴相接的碰撞盒(AABB)到一个与轴相接的绕原点的立方体。AABB的最小角为(l,b,n),最大角为(r,t,f)。需要注意到n>f,这是因为我们沿着-z轴观察。我们通常认为近平面小于远平面,所以我们可以这么使用后然后取反。

在OpenGL中,轴对齐立方体有一个最小角(-1,-1,-1)和一个最大角(1,1,1)。在DirectX中,范围是(-1,-1,0)(1,1,1)。这个立方体称为经典视锥体,在这个锥体中的坐标被称为标准齐次坐标。这一变换过程显示如下:

变换为经典视锥体的理由是在这里裁剪更加有效率。

在变换到经典视锥体后,几何体的顶点会通过这个立方体进行裁剪然后渲染,在这个立方体内的立方体会最终通过映射到保留单位四边形,即屏幕上,然后渲染。这一正交变换显示如下:

P_O=S(s)T(t)=\left(\begin{matrix}\frac {2} {r-l}&0&0&0\\0&\frac {2} {t-b}&0&0\\0&0&\frac {2} {f-n}&0\\&0&0&0&1\end{matrix}\right)\left(\begin{matrix}1&0&0&-\frac {l+r} {2}\\0&1&0&-\frac {t+b} {2}\\0&0&1&-\frac {f+n} {2}\\0&0&0&1\end{matrix}\right)\\ =\left(\begin{matrix}\frac {2} {r-l}&0&0&-\frac {r+l} {r-l}\\0&\frac {2} {t-b}&0&-\frac {t+b} {t-b}\\0&0&\frac {2} {f-n}&-\frac {f+n} {f-n}\\0&0&0&1\end{matrix}\right)

上述等式表明P_O可以被写为一个平移T(t)和一个缩放S(s)的复合,这个矩阵式可逆的,即P_O^{-1}=T(-t)S((r-l)/2,(t-b)/2,(f-n)/2)

在计算机图形学中,一个左手坐标系经常在投影后使用,即,对于视窗,x轴朝向右,y轴朝向上,z轴朝向视窗里面。因为在我们的AABB定义中远平面值小于近平面值,正交变换总是包含一个镜像变换。为了看到这个,假设原始AABB和目标经典视锥体大小一样,然后AABB的坐标(-1,-1,1)对应(l,b,n)(1,1,-1)对应(r,t,f),将其应用到上面的P_O

P_O=\left(\begin{matrix}1&0&0&0\\0&1&0&0\\0&0&-1&0\\0&0&0&1\end{matrix}\right)

这是一个镜像矩阵,他将右手坐标系转换到左手坐标系。
DirectX将深度缓冲映射到[0,1],而OpenGL映射到[-1,1],这可以在投影后通过应用一个简单的缩放和平移矩阵来完成:

M_{st}=\left(\begin{matrix}1&0&0&0\\0&1&0&0\\0&0&0.5&0.5\\0&0&0&1\end{matrix}\right)

那么在DirectX中使用的正交投影矩阵是:

P_{O[0,1]}=\left(\begin{matrix}\frac {2} {r-l}&0&0&-\frac {r+l} {r-l}\\0&\frac {2} {t-b}&0&-\frac {t+b} {t-b}\\0&0&\frac {1} {f-n}&-\frac {n} {f-n}\\0&0&0&1\end{matrix}\right)

上述矩阵在DirectX中通常使用转置形式使用,因为DirectX是行排列矩阵。

透视投影

一种比正交投影更复杂的投影是透视投影,这在大多数的图形学程序中广泛使用。这里,平行线经过透视投影后不再平行,而是相交于远处的一点。透视投影更符合我们现实世界的情况,即远处的物体看起来更小。

首先,我们介绍一个启发意义的透视矩阵的推导,它将物体投影到一个平面z=-d,d>0上。我们首先简化世界空间和视图空间的转换流程,接着使用更普遍的矩阵来讲解。

假设摄像机位于原点,然后我们想要投影一个点p到一个平面z=-d,d>0,得到一个新点q=(q_x,q_y,-d),这一过程在下图说明:

就如上图显示,通过相似三角形,下面针对q点的x组件的推导可以得到:

\frac{q_x} {p_x}=\frac{-d} {p_z}\quad \Leftrightarrow \quad q_x=-d\frac{p_x} {p_z}

针对q的其它组件的表达式是q_y=-dp_y/p_z,q_z=-d,结合上述的等式,这样就可以得到下面的投影矩阵P_p:

P_p=\left(\begin{matrix}1&0&0&0\\0&1&0&0\\0&0&1&0\\0&0&-1/d&0\end{matrix}\right)

这一投影矩阵的应用如下:

q=P_pp=\left(\begin{matrix}1&0&0&0\\0&1&0&0\\0&0&1&0\\0&0&-1/d&0\end{matrix}\right)\left(\begin{matrix}p_x\\p_y\\p_z\\1\end{matrix}\right)=\left(\begin{matrix}p_x\\p_y\\p_z\\-p_z/d\end{matrix}\right)\Rightarrow \left(\begin{matrix}-dp_x/p_z\\-dp_y/p_z\\-d\\1\end{matrix}\right)

最后一个步骤执行的是透视除法,为了让最后一个组件置为1。z坐标是-d,因为我们投影到这个平面上。

直观上,理解为什么投影要使用齐次坐标很简单,一个齐次过程的几何解释是它将点(p_x,p_y,p_z)投影到平面w=1上。

和正交投影一样,也有透视投影,不再是将物体直接投影到一个平面上,而是将视锥体投影到之前描述的经典视锥体上。这里视锥体被假设为从z=n开始到z=f结束,且{0}>n > f,在z=nc处的四边形拥有最小角(l,b,n)和最大角(r,t,n),这在下图显示:

参数(l,r,b,t,n,f)决定了摄像机的视锥体,视锥体的水平视场角右锥体的左右两个平面决定(lr),同理,垂直视场角右上下两个平面决定(tb)。视野月光,摄像机可看见的范围越大。不对称截锥体可以通过r \ne -lt \ne -b得到。不对称截锥体可以应用在立体景观和虚拟现实。

视野是提供给场景真实度的一个重要因素,相比于计算机屏幕,人眼有用一个物理视野,关系是:

\phi=2arctan(w/(2d))

其中\phi是视野,w是物体垂直于视线的角度,d是到物体的距离。例如,一个25英寸的显示器宽大概22英寸,在12英寸外,水平视场角为85度,在20英寸处,为52度,30英寸为40度。同样的公式可以应用来转换摄像机摄像头大小到视野范围上,例如,一个36mm摄像机的标准50mm镜头拥有\phi=2arctan(36/(2\cdot 50))=39.6度。

使用一个比初始更小的视野范围会减少透视效果,效果即观察者靠近场景观察。使用一个各大范围的视野会让物体出现扭曲效果,尤其是靠近屏幕边时,并且会夸大靠近物体的大小。然而,一个更广范围的视野会给观察者一种场景更大更有触动感,并且可以显示更多的邻近信息。

将视锥体变换到单位立方体的透视变换矩阵如下:

P_p=\left(\begin{matrix}\frac{2n} {r-l}&0&-\frac{r+l} {r-l}&0\\0&\frac{2n} {t-b}&-\frac{t+b} {t-b}&0\\0&0&\frac{f+n} {f-n}&-\frac{2fn} {f-n}\\0&0&1&0\end{matrix}\right)

将这一变换应用到一个点后,我们可以得到另一个点q=(q_x,q_y,q_z,q_w)^Tw组件q_w非零且不等于1,为了得到投影点p,我们需要除以q_w,即:

p=(q_x/q_w,q_y/q_w,q_z/q_w,1)

矩阵P_pz=f映射到+1z=n映射到-1

远平面外的物体会被剪切,并不会出现在场景中。透视投影可以将无限远作为远平面:

P_p=\left(\begin{matrix}\frac{2n} {r-l}&0&-\frac{r+l} {r-l}&0\\0&\frac{2n} {t-b}&-\frac{t+b} {t-b}&0\\0&0&1&-2n\\0&0&1&0\end{matrix}\right)

总结一下就是,透视变换,P_p被应用,然后剪切和坐标齐次化,这样会得到标准设备坐标。

为了得到能在OpenGL中使用的透视投影变换,首先样乘以S(1,1,-1,1),这将第三列取反,在这个镜像变换被应用后,远平面和近平面值置为正值,0<n'<{f'}。然而,它们仍表示沿世界-z轴的距离,这是观察方向,为了这一目的,这里是OpenGL的等式:

P_{OpenGL}=\left(\begin{matrix}\frac{2n'} {r-l}&0&-\frac{r+l} {r-l}&0\\ 0&\frac{2n'} {t-b}&-\frac{t+b} {t-b}&0\\ 0&0&\frac{f'+n'} {f'-n'}&-\frac{2f'n'} {f'-n'}\\ 0&0&1&0\end{matrix}\right),\quad 4.74

一个更简单的设置是提供垂直视场角\phi,屏幕纵横比a=w/hn'f',结果是:

P_{OpenGL}=\left(\begin{matrix}c/a&0&0&0\\ 0&c&0&0\\ 0&0&\frac{f'+n'} {f'-n'}&-\frac{2f'n'} {f'-n'}\\ 0&0&1&0\end{matrix}\right)

其中c=1.0/tan(\phi /2)

一些API将近平面映射到z=0,远平面到z=1,额外地,DirectX使用左手坐标系来定义投影矩阵,这意味着DirectX沿+z轴观察,近平面和远平面值为正值,这里是DirectX的投影矩阵:

P_{p[0,1]}=\left(\begin{matrix}\frac{2n'} {r-l}&0&-frac{r+l} {r-l}&0\\ 0&\frac{2n'} {t-b}&-\frac{t+b} {t-b}&0\\ 0&0&\frac{f'} {f'-n'}&-\frac{f'n'} {f'-n'}\\ 0&0&1&0\end{matrix}\right)

DirectX使用横排列矩阵,所以需要使用矩阵的转置。

使用透视投影变换的一个效果是计算的深度值并不是与输入值p_z线性排列的。使用透视投影矩阵和点p相乘后,结果如下:

v=Pp=\left(\begin{matrix}...\\...\\dp_z+e\\±p_z\end{matrix}\right)

其中v_x,v_yd额细节被省略,常量df取决于选择的矩阵。如果我们使用4.74等式,那么d=-(f'+n')/(f'-n'),e=-2f'n'/(f'-n'),且v_x=-p_z。为了获得标准设备坐标的深度值,我们需要除以w组件:

z_{NDC}=\frac {dp_z+e} {-p_z}=d-\frac {e} {p_z}

其中针对于OpenGL投影,z_{NDC}\in [-1,+1]。可以法线,输出深度z_{NDC}和输入深度p_z相反对应。

例如,如果n'=10f'=110,当p_z为沿-z轴的60单位,标准设备坐标的深度值为0.833,不是0。下图显示了不同近平面的效果:


近平面和远平面的放置影响深度缓冲的精度。

有一些方法可以提高深度精度。一个通常的方法,我们称为翻转z,存储的是1.0-z_{NDC}值,可以是浮点值或整型值。二者区别显示在下图:

Reed展示了使用一个翻转z的浮点缓冲的模拟,这提供了最高的精度,这也是对整型深度缓冲的最合适的方法。对于标准映射(非翻转z),变换中独立的投影矩阵会减少错误率,正如Upchurch和Desburn所阐述的。例如,使用P(M_p)要好于使用T_p,其中T=PM。同样,在[0.5,1.0]的范围,fp32和int24在精度上非常相似,因为fp32有23位小数部分。将z_{NDC}对应于1/p_z的理由是它让硬件更简洁,深度压缩更容易成功。

Lloyd建议使用对数深度值来提高对阴影贴图的精度。Lauritzen等人使用前一帧的深度值来决定最大近平面和最小远平面。对于屏幕空间深度,Kemen建议使用下面的公式来逐顶点重映射:

z=w(log_2(max(10^{-6},1+w))f_c-1),\quad OpenGL

z=wlog_2(max(10^{-6},1+w))f_c/2,\quad DirectX

其中w是经过透视投影矩阵变换的w值,z是顶点着色器输出的z值。当只有这个变换在顶点着色器中使用是,深度仍会被GPU在顶点的非线性变换深度值间沿三角形进行线性插值。由于对数是单调函数,只要分段线性插值和准确的非线性变换深度值的差别小,遮蔽消隐硬件和深度压缩技术仍起作用。这对于使用足够几何体细分的大多数情况都适用。然而,也可能逐片段应用变换。这通过输出逐顶点值e=1+w完成,这个值之后会由GPU沿三角形插值。像素着色器然后修改片段深度值为log_2(e_i)f_c/2,其中e_ie的插值。当GPU中没有浮点深度值和渲染中需要大距离深度时这一方法是很好的替代。

Cozzi建议使用多重截锥体,可以按照任何期望来提高精度。视锥体在深度方向上被切分为几个不重叠的子截锥体,它们的组合是一个完整的视锥体。子截锥体按从后往前顺序渲染,首先,颜色和深度缓冲被清除,所有要渲染的物体按顺序排序。对每个子截锥体,它的投影矩阵被设置,深度缓冲被清除,然后子截锥体重叠的物体被渲染。

提供的阅读材料

  • http://immersivemath.com/ila/index.html
    这一网站上你可以交互式的学习线性代数。
  • 《The Geometry Toolbox》,这本书可以帮助建立关于矩阵的概念。
  • 《Mathemetics for 3D Game Programming and Computer Graphics》,这本书介绍了图形学相关数学。
  • Hearn,Baker,Marschner和Shirley这几个人的相关文章从一个不同的视角阐述了图形学相关矩阵基础。
  • Ochiai等人的课程介绍了在计算机图形学中使用的矩阵基础,矩阵的势和对数。
  • 《Graphics Gems》系列介绍了许多变换相关算法。
  • Golub和Van Loan的《Matrix Computations》这本书可以让读者进一步研究矩阵技术。
  • 更多关于骨骼子空间变形/顶点混合和形状插值的内容可以在Lewis等人的SIGGRAPH文章上获得。
  • Hart等人和Hanson提供了可视化四元数。
  • Pletinckx和Schlag在四元数集间进行平滑插值的一些不同方法。
  • Vlachos和Isidoro提供了四元数的C^2插值。
  • Dougan提供了一些对于与四元数插值相关的沿一个曲线计算相应的坐标系的问题的看法。
  • Alexa,Lazarus和Verroust展示了对于许多不同变形技术的研究。
  • Parent的书对于研究计算机动画的技术来说是很棒的资源。
  • 个人在这里还推荐3Blue1Brown的线性代数本质的相关视频:https://www.bilibili.com/video/BV1ys411472E
  • 最后,更多的资源可以在官网找到:realtimerendering.com
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349