3D数学是一门和计算几何相关的科学,研究怎样用数值的方法来解决几何问题。它广泛应用在图形和游戏开发中,掌握常用的数学方法和概念是程序开发的基础。
本章专门讲解关于 OSG 中的一些数学方法的应用。如果读者是资深的 3D开发者,可以跳过这一章;如果是初学者,要仔细阅读。建议读者都能够阅读这一章,因为其中的知识或许能帮助读者更好地使用OSG。
1. 坐标系统
坐标系是一个精确定位对象位置的框架,所有的图形变换都是基于一定的坐标系进行的。对于从事计算机图形学的研究者,掌握图形变换是不可或缺的,因此,理解坐标系非常重要。笔者一直认为,一个三维图形工作者可以认为自己站在一定的坐标系中,通过自己的想法来操控模拟的虚拟环境。
无论何时何地,只要确定原点和坐标轴的方向,都可以非常方便地建立坐标系。通常来说,任何一个三维坐标系都能表示空间中的所有点,因为坐标轴可以无限延伸。但为了方便处理各种问题,习惯上常定义多种坐标系。这些坐标系在本质上没有好坏之分,只是在不同的情况下处理问题的简化程度不同。
三维坐标系总体上可以分为两大类,即左手坐标系和右手坐标系,相信读者对这两种坐标系都有所了解,这里就不再介绍。常用的坐标系有世界坐标系、物体坐标系和摄像机坐标系,下面分别进行介绍。
- 世界坐标系
世界坐标系是一个特殊的坐标系,它建立了描述其他坐标系所需要的参考框架。从另一方面说,能够用世界坐标系来描述其他坐标系的位置,而不能用更大的、外部的坐标系来描述世界坐标系。世界坐标系也被广泛地称为全局坐标系或宇宙坐标系。
世界坐标系描述的是整个场景中的所有对象,可以理解为绝对坐标系,所有对象的位置都是绝对坐标。从整体上考虑,它为所有对象的位置提供一个绝对的参考标准,从而避免了物体之间由于各自独立的物体坐标系而导致的坐标系混乱。
世界坐标系通常描述的问题是一些对象的初始位置及场景中对象的变换过程,对象主要包括摄像机和绘制的物体。
2. 物体坐标系
物体坐标系是针对某一特定的物体而建立的独立坐标系。每一个物体都有自己的坐标系,当物体发生变换时,实际上是它本身的坐标系相对于世界坐标系发生变换的过程。
物体坐标系对于描述特定的物体非常方便,假设所有的物体都用一个世界坐标系来描述,则一个物体进行任何变换的计算量都非常大,包括顶点坐标变换等。定义物体坐标系之后,非常容易即可实现物体的变换,只需要对物体坐标系相对世界坐标系发生变换即可。如果用世界坐标系来描述一个人,很难确定人的各个器官的精确坐标,从而人体模型的建立就非常困难,这时可以充分体现物体标系描述特定物体的优势。
物体坐标系通常描述的问题是特定物体的内部对象,主要包括物体的顶点、物体的法向量和物体的方向。
3. 摄像机坐标系
摄像机坐标系是和观察者密切相关的坐标系。摄像机坐标系和屏幕坐标系相似,差别在于摄像机坐标系处于3D 空间中,而屏幕坐标系在2D 平面里。摄像机标系可以被看作是一种特殊的物体坐标系,该物体坐标系就定义在摄像机的屏幕可视区域。
摄像机坐标系描述的是物体是否绘制渲染,并且在屏幕上显示出来。可以这样认为,即使视野再宽广,也无法一眼看到整个世界,这样读者就会发现摄像机坐标系的意义所在。
摄像机坐标系描述的问题是哪些物体应该绘制渲染并且显示在屏幕上,主要包括物体是否在摄像机坐标系区域内、物体的渲染顺序和物体的遮挡绘制。
因此,摄像机坐标系描述的问题是非常复杂的,一个好的虚拟引擎在这方面的表现肯定会非常突出,如区域裁剪、物体的遮挡渲染等。
对于坐标系基本了解以后,读者可能会发现其中并没有涉及OSG,这是因为前面讲解的都是一些基本的概念,只有了解了这些基础知识后才能够轻松理解后面的内容。OSG对于上面所描述的3种坐标系都有比较好的应用,具体实现可参见源代码。OSG采用的世界坐标系是左手坐标系,这点与OpenGL 是保持一致的。值得注意的是它们之间的坐标轴方向不一样。
- OSG:X正方向向右,Y 正方向朝里,Z正方向朝上
- OpenGL:X正方向向右,Y正方向朝上,Z正方向朝外
OSG的坐标系统可以理解为OpenGL坐标系统绕X轴顺时针旋转90°。读者需牢记并理解坐标系,以后经常会遇到按一定的要求进行图形变换的情况
图2-1形象地描述了两个坐标系,其中,左侧的为OSG的坐标系,右侧的为 OpenGL 的坐标系。
图2-1 0SG的坐标系与OpenGL的标系
2.坐标系变换
坐标系变换是计算机图形处理的一个基础研究方向。三维实体对象需要经过一系列的坐标变换才能正确、真实地显示在屏幕上。在一个场景中,当读者对场景中的物体进行各种变换及相关操作时,坐标系变换是非常频繁的。坐标系变换通常包括世界坐标系-物体坐标系变换、物体坐标系-世界坐标系变换和世界坐标系-屏幕坐标系变换。屏幕坐标系是一个二维平面坐标系,即显示器平面,是非常标准的笛卡儿坐标系的第一象限区域。
使用OSG开发时,经常会用到坐标系变换。下面介绍在OSG中坐标系变换的具体实现,可能不能面面俱到,但具有普遍应用性。
2.1 世界坐标系-物体坐标系变换
世界坐标系-物体坐标系变换相对比较容易,它描述的问题主要是关于物体本身的。假设在世界坐标系中,一个人正准备走向一栋建筑,那么他就面临世界坐标系到物体坐标系的变换过程,变换过程中面临的问题是人相对建筑物的朝向、人相对建筑物的距离及人的移动方向等一系列的问题。
上面的假设读者或许会觉得非常熟悉,它基本等同于基本变换的过程。在OSG中,已有如下相关的类实现了基本变换过程:
- osg::PositionAttitudeTransform;// 位置变换类
- osg::MatrixTransform;// 矩阵变换类
这两个类实现的效果基本是相同的,只是数据的表达方式有区别,这样有利于处理各种数据的变换。后面的章节还会详细介绍,这里暂且留一个如何使用这两个类的疑问。或许学习这一章读者会有很多实现方面的疑问,不过没关系,通读全书后将不再疑惑。
通过上面的两个类可以很方便地实现世界坐标系-物体坐标系的变换过程应该是容易理解的。世界坐标系-物体坐标系变换的意义在于简化了世界坐标系下变换的运算,当面对非常大的场景时,这种变换在一定程度上可以减少数据的运算量和提高场景的渲染效率。
2.2 物体坐标系-世界坐标系变换
物体坐标系 - 世界坐标系变换不能简单地理解为世界坐标系-物体坐标系变换的逆过程。物体坐标系-世界坐标系变换描述的问题是处于世界坐标系中的物体。假设在物体坐标系中,一栋建筑物中有一个人,如何确定人在世界坐标系中的位置信息就是物体坐标系-世界坐标系变换所面对的问题。
物体坐标系-世界坐标系变换是有一定难度的。对于场景图形中某一个 OSG 节点,它和根节点之间可能存在一些变换节点,那么如何获取该节点在世界坐标系中的位置就显得非常困难。但是,在场景图形中,每一个节点都有自己的父节点且有自己的变换矩阵,这些变换矩阵包含了相对坐标数据。那么,计算某一特定节点在世界坐标系下的坐标,只需要将该节点的根节点和该节点之间的所有变换矩阵相乘即可。在 OSG 中有多种方式来实现物体坐标系-世界坐标系的变换,如回调、访问器等,用访问器实现是一种方便可操控的方式,相对而言,回调在一定程度上不具备可操控行,且会因为增加额外开销而影响渲染效率,但每帧都会自动计算矩阵变换。
访问器通过遍历的方式记录场景中节点的路径,并计算路径上矩阵变换的世界坐标,最终返回一个矩形式表示的世界坐标。下面具体实现该访问器,读者不必理解每一行代码,但需要理解实现的基本思路和原理,在以后的学习中,读者将逐渐熟悉代码,这些代码会经常用到。代码实现如程序清单 2-1 所示。
- /*
- 该访问器类用于返回某个节点的世界坐标
- 从起始节点开始向根节点通历,并将遍历的节点记录到nodePath 中
- 第一次到达根节点之后,记录起始点到根节点的节点路径
- 获取所有世界坐标矩阵之后,即获得节点的世界华标
- */
- class GetWorldCoordinateOfNodeVisitor : public osg::NodeVisitor
- {
- public:
- GetWorldCoordinateOfNodeVisitor() :osg::NodeVisitor(NodeVisitor::TRAVERSE_PARENTS), done(false)
- {
- wcMatrix = new osg::Matrixd();
- }
- virtual void apply(osg::Node &node)
- {
- if (!done)
- {
- // 到达根节点,此时节点路径也已记录完整
- if (0 == node.getNumParents())
- {
- wcMatrix->set(osg::computeLocalToWorld(this->getNodePath()));
- done = true;
- }
- // 继续遍历
- traverse(node);
- }
- }
- // 返回世界坐标矩阵
- osg::Matrixd *giveUpDaMat()
- {
- return wcMatrix;
- }
- private:
- bool done;
- osg::Matrix *wcMatrix;
- };
- /*
- 计算场景中某个节点的世界坐标,返回osg::Matrix格式的世界坐标
- 创建用于更新世界坐标矩阵的访问器之后,即获取该矩阵
- */
- osg::Matrixd *getWorldCoords(osg::Node *node)
- {
- GetWorldCoordinateOfNodeVisitor *ncv = new GetWorldCoordinateOfNodeVisitor();
- if (node &&ncv)
- {
- // 启用访问器
- node->accept(*ncv);
- return ncv->giveUpDaMat();// 遍历完成后,返回世界坐标矩阵
- }
- else
- {
- return NULL;
- }
- }
2.3 世界坐标系-屏幕坐标系变换
在场景中,所有的实体对象需要经过一系列的坐标变换才能正确显示在屏幕上,这些变换主要包括模型变换(将实体对象正确地放置在场景中)、投影变换(将场景中的实体对象投影到垂直于视线方向的二维成像平面上)和视口变换(投影变换之后得到的顶点需要经过视区变换才能得到最后的窗口坐标)。屏幕坐标是二维标,世界坐标是三维坐标,因此,正确的世界标系-屏幕坐标系变换就非常必要。
通过前面的介绍可知,世界坐标系-屏幕坐标系变换主要有3 个步骤,即模型变换、投影变换和视口变换,由模型变换和投影变换得到归一化的设备坐标,最后由视口变换得到屏幕窗口坐标。具体实现如程序清单2-2所示。
- // 输入点,进行矩阵变换
- void Transform_Point(double out[4], const double m[16], const double in[4])
- {
- #define M(row,col) m[col*4,+row]
- out[0] = M(0, 0) * in[0] + M(0, 1) * in[1] + M(0, 2) * in[2] + M(0, 3)*in[3];
- out[1] = M(1, 0) * in[0] + M(1, 1) * in[1] + M(1, 2) * in[2] + M(1, 3)*in[3];
- out[2] = M(2, 0) * in[0] + M(2, 1) * in[1] + M(2, 2) * in[2] + M(2, 3)*in[3];
- out[3] = M(3, 0) * in[0] + M(3, 1) * in[1] + M(3, 2) * in[2] + M(3, 3)*in[3];
- #undef M
- }
- // 返回三维点在二维屏幕上的投影点
- osg::Vec3d WorldToScreen(osgViewer::View *view, osg::Vec3 worldpoint)
- {
- double in[4], out[4];
- in[0] = worldpoint._v[0];
- in[1] = worldpoint._v[1];
- in[2] = worldpoint._v[2];
- in[3] = 1.0;
- // 获得当前的投影矩阵和模型视图矩阵
- osg::Matrix projectMatrix = view->getCamera()->getProjectionMatrix();// 投影矩阵
- osg::Matrix viewprojectMatrix = view->getCamera()->getViewMatrix();// 模型视图矩阵
- // 变换模型视图矩阵
- double modelViewMatrix[16];
- memcpy(modelViewMatrix, viewprojectMatrix.ptr(), sizeof(GLdouble) * 16);
- Transform_Point(out, modelViewMatrix, in);
- // 变换投影矩阵
- double myprojectMatrix[16];
- memcpy(myprojectMatrix, projectMatrix.ptr(), sizeof(GLdouble) * 16);
- Transform_Point(in, myprojectMatrix, out);
- // 变换视口变换矩阵
- if (in[3] == 0.0)
- {
- return osg::Vec3d(0.0, 0.0, 0.0);
- }
- in[0] /= in[3];
- in[1] /= in[3];
- in[2] /= in[3];
- int viewPort[4];
- osg::Viewport *myviewPort = view->getCamera()->getViewport();
- viewPort[0] = myviewPort->x();
- viewPort[1] = myviewPort->y();
- viewPort[2] = myviewPort->width();
- viewPort[3] = myviewPort->height();
- // 计算二维屏幕投影点
- osg::Vec3d sceenPoint;
- sceenPoint._v[0] = (int)(viewPort[0] + (1 + in[0]) * viewPort[2] / 2 + 0.5);
- sceenPoint._v[1] = (int)(viewPort[1] + (1 + in[1]) * viewPort[3] / 2 + 0.5);
- sceenPoint._v[2] = 0;
- return sceenPoint;
- }
3. 向量、矩阵及四元数
数据是计算机图形学研究的基础,常用的数据工具主要包括向量、矩阵及四元数等,这些数据适用于不同的情况,各有优缺点。因此,有必要学习这些常用的数据工具,在合适的地方用合适的数据工具会方便程序开发并提高运算速度和效率,可以达到优化程序的目的。
下面将分别介绍这些常用的数据工具,包括其数学定义和几何定义等。对于这些数据工具的常用运算可参见相关的线性代数书籍,本书不再详细介绍。介绍这些数据工具的目的是方便后面各种数据工具之间的转换,这些数据工具是研究开发的基础,读者一定要理解得非常深刻。
3.1 向量
向量作为2D、3D数学研究的标准工具,是一种很常用的数据工具,大部分的3D图形引擎都把向量作为基本的数据工具,可见向量在表达基本数据时有明显的优势。
向量存在两种不同但意义相关的定义,即数学定义和几何定义。从数学意义上来讲,向量是一个数据的集合,可以理解为程序开发中的数组。从几何意义上来讲,向量表示有大小和方向的有向线段。虽然两种定义有明显区别,但在数据表达方面存在共同的优点,可以很方便、简单地表示基本数据。
OSG定义了大量的类,用于保存向量数据,如顶点、法线、颜色和纹理坐标等。osg::Vec3 是一个三维浮点数组,可以用来保存顶点和法线数据;osg::Vec4用于保存颜色数据而osg::Vec2可用于保存2D纹理标。
基本的向量类如下:
- //二维向量
- osg::Vec2b
- osg::Vec2d
- osg::Vec2f
- osg::Vec2s
- //三维向量
- osg::Vec3b
- osg::Vec3dosg::Vec3f
- osg::Vec3s
- //四维向量
- osg::Vec4b
- osg::Vec4d
- osg::Vec4f
- osg::Vec4s
这些向量类除了可以进行简单的向量存储以外,还提供了长度计算和单位化函数,重载了向量加法、减法、乘法、除法及向量比较判断函数,例如:
- value_type length() const// 长度计算
- value_type length2(void) const// 长度平方计算
- value_type normalize()// 单位化
- const Vec2f operator+(const Vec2f &rhs) const//加法
- Vec2f& operator+=(const Vec2f &rhs)
- ....//减法、乘法、除法
- // 比较判断
- bool operator==(const Vec2f&v) const
- bool operator!=(const Vec2f &v) const
- bool operator<(const Vec2f &v) const
后面章节中的很多示例都会用到这些向量类,向量作为基本的数据工具,以后也会经常用到。
3.2 矩阵
矩阵是3D数学的重要基础。在前面已经提到,利用矩阵变换可以进行各种图形变换:同时,矩阵也可以描述坐标系之间的关系,实现坐标系之间的变换。矩阵已经广泛用于其他领域,而不限于计算机图形学,如信息统计和预测等。
矩阵与向量一样存在两种不同但意义相关的定义,即数学定义和几何定义。从数学意义上来讲,矩阵是一个以行和列形式组织数据的集合,可以理解为程序开发中的二维数组,也可以理解为多个向量叠加(注意不是相加)。从几何意义上来讲,它表示一组与坐标轴平行的向量,这里说的矩阵是3x3的方阵,表示基于坐标系坐标轴(X/Y/Z)上的位移。矩阵可以分解为多个沿不同坐标轴的单位向量的叠加,因此利用矩阵可以方便地进行各种图形变换以及坐标系之间的变换。或许读者还是不理解矩阵,确实,很难具体告诉读者矩阵像什么,只有真正去感受矩阵,才能明白矩阵。
3x3 的矩阵可以分解为沿X/Y/Z 轴的单位向量分别在标系上偏移的向量,即物体标系的偏移例如:
可以表示3个如下的向量:
X方向的单位向量(1,0,0)→(1,2,3)
Y 方向的单位向量(0,1,0) →(4,5,6)。
Z方向的单位向量(0,0,1) →(7,8,9)。
通过这个简单的举例,基本说明了图形变换时矩阵是如何工作的。
在OSG中定义了一些相关的类用来保存矩阵数据,如物体的方位、摄像机方位和位置等信息。
基本的矩阵类如下:
- osg::Matrix2
- osg::Matrix3
- osg::Matrixd
- osg::Matrixf
这些矩阵类除了可以进行简单的矩阵存储以外,还定义了矩阵的基本运算、矩阵与向量的乘法、矩阵的平移、旋转与缩放、逆矩阵,重载了矩阵的比较判断等函数,例如:
- void operator*=(const Matrixf &other)//乘法
- Matrixf operator*(const Matrixf &m) const
- bool invert(const Matrixf &rhs) //逆矩阵
- void makeScale(const Vec3f&)// 缩放
- ……
- void makeTranslate(const Vec3f &)//平移
- ……
- void makeRotate(const Vec3f &from, const Vec3f &to)//旋转
- ……
- bool operator<(const Matrixf &m) const//比较判断
- bool operator==(const Matrixf &m) const
- bool operator!=(const Matrixf &m) const
通过对矩阵的基本介绍,相信读者已经了解了什么是矩阵及矩阵的基本作用。但值得注意的是,在使用矩阵进行图形变换时,要先确定变换的过程,再确定矩阵变换,需要保证过程清晰。复杂的矩阵变换需要一定的数学技巧,同时也需要一定的经验。在进行矩阵变换时,要按部就班,以便实现正确的符合要求的矩阵变换。
3.3 四元数
四元数(Quatermions)是由威廉·卢云·哈密顿(William Rowan Hamilton,1805-1865)1843年在爱尔兰发现的数学概念,但真正应用于计算机图形学领域是在漫长的一个世纪之后。四元数是构造变换的有力工具,不过,它本身带有太多神秘的色彩,可能很多人都不了解它。一个四元数由一个标量和一个矢量组成,但这个矢量不是我们通常所讨论的三维空间,而是四维空间,可以表示为[w,v]或者[w,(x,y,z)],其中,w 是标量,v是矢量。四元数同样可以表示为:
其中,ax、ay、az 表示轴的矢量,θ
表示绕此轴的旋转角度。不过,需要注意的是,θ 是极坐标下的角度,简单的组合能够保证变换的稳定。当然,用轴角来描述也是可以的,只是上面的参数不能简单理解为坐标轴和角度之间的值。在使用四元数时,也可以写成轴角表示方式。
四元数存在于3D数学中有一个重要原因,它有一种slerp 插值运算。slerp 插值运算是一种空间的平滑的球面线性插值,没有其他方法可以提供如此的平滑插值。插值的基本思想是基于四维空间极坐标轴下弧线插值。四元数的平滑插值在场景交互过程中发挥着重要的作用,提供了平滑的渲染过程,如场景漫游等。
在OSG中封装了一个基本类 osg::Quat 来描述四元数,它不仅可以简单存储四元数,而且还定义了一系列的运算,如四元数的旋转、四元数的长度计算、四元数与向量之间的运算,重载了基本运算,还实现了slerp插值运算,例如:
- void makeRotate(value_type angle, value_type x, value_type y, value_type z)//旋转
- ............
- value_type length() const //长度计算
- value_type length2() const
- const Quat operator+ (const Quat &rhs) const//加法
- Quat & operator+=(const Quat &rhs)
- ............
- void slerp(value_type t, const Quat &from, const Quat &to) //插值
- Vec3f operator*(const Vec3f &v) const//与向量相乘
- Vec3d operator*(const Vec3d &v) const
平滑的插值只能用四元数来完成,如果想用其他的形式,需要先转换为四元数,待插值完毕再转换成原格式。由于四元数非常难以理解,所以是很难直接使用的。在后面的路径动画的章节中进行路径插值时就使用了slerp 插值运算形成平滑的路径。
3.4 矩阵与四元数之间的转换
通过前面的学习可知,矩阵与四元数在表达方位上各有优势。在不同的情况下,可以选择使用矩阵、四元数或两者的综合。矩阵能够在坐标系之间转换向量,而四元数则不能。如果四元数需要在坐标系之间转换,则四元数需转换为矩阵。四元数能够提供平滑的线性插值,而矩阵基础上进行插值,即使插值也是非常粗糙的。因此,适当的时候矩阵与四元数的转换是非常有必要的。
- 四元数转换为矩阵
为了将角位移从四元数表示方式转换为矩阵表示方式,有必要将四元数用轴角方式描述,以方便计算:
其中,ax 、ay、az表示轴的矢量,θ 表示绕此轴的旋转角度。事实证明,可以方便地利用四元数的4个分量表示矩阵的9个元素。下面直接给出推导出的公式不再逐一推导。推导需要很强的数学技巧。同时,它不是本书描述的重点,具体的推导过程可参见相关的线性代数书籍。般四元数转换为矩阵的公式如下(规范化):
根据上面的公式,具体实现代码如程序清单2-3 所示
void setRotate(const osg::Quat &q)
- 矩阵转换为四元数
为了从矩阵中提取四元数,同样需要根据公式进行反推。当然,反推需要很强的数学技巧,前面提到的四元数转换为矩阵的公式,构建方程组,即可求解出四元数的轴角表达式。根据前面所提到的四元数转换矩阵的公式进行反推,具体实现代码如程序清单 2-4 所示。
osg::Quat getRotate() const