
说实话写OSG学习笔记的第四天我本来以为可以稍微松口气了。毕竟节点、场景树、相机这些概念在前三天已经搭了个框架感觉“三维的东西”不过如此。结果一进矩阵变换直接给我来了个下马威——这玩意儿不是“难”而是从思维底层就开始跟日常认知拧着来。你越是想当然它越是给你出幺蛾子。但反过来说一旦你把矩阵变换的“脾气”摸顺了后面再学模型加载、动画、交互、粒子系统都会顺很多。因为本质上OSG里所有东西的移动、旋转、缩放哪怕是相机在场景里飞背后都是矩阵在做运算。这篇笔记我打算按自己的学习路径来写先讲清楚矩阵变换到底在“变”什么再讲OSG里矩阵怎么用然后直接上一段能跑的代码把模型“摆”到指定位置最后把我这两天踩过的坑全都抖出来。你要是也在学OSG或者刚接触三维图形学这篇文章应该能帮你少走不少弯路。1. 矩阵变换到底在解决什么问题很多人一上来就背矩阵乘法的规则背完就忘就是因为没搞明白矩阵变换的“出发点”。我花了大半天才意识到矩阵变换不是数学题而是一个关于“坐标系”的问题。1.1 从“拍照”理解三个坐标系想象一下你手里有一台相机要拍一个放在桌子上的杯子。模型坐标系杯子自己所在的那个“原始空间”。不管杯子怎么摆这个坐标系都跟着杯子走。杯子中心是原点杯口朝上就是Y轴正方向。世界坐标系整个场景的“绝对坐标”。桌子在哪儿相机在哪儿都是相对于这个空间说的。杯子在模型坐标里是(0,0,0)但放到世界上它可能在(2, 1, -3)。视图坐标系相机位置开始算的坐标系。相机看到的“前方”就是Z轴负方向所有东西最终都要换算到这个坐标系里才能被投影到屏幕上。把一个杯子从“模型坐标”变到“世界坐标”中间那一大堆计算就是矩阵变换干的事。换句话说矩阵变换就是“从一个坐标系跳到另一个坐标系”的桥梁。1.2 为什么非要用4x4的矩阵早期我也困惑平移不就是加个偏移量吗为什么非要搞个矩阵出来3x3矩阵不是能处理缩放和旋转吗为什么最后要用4x4问题出在平移上。举个例子3x3矩阵乘法能把向量做线性变换比如旋转、缩放。但你没法用3x3矩阵直接表达“把点(1,2,3)移动到(4,5,6)”这种偏移。引入齐次坐标之后把一个三维点写成齐次坐标形式(x, y, z, 1)用4x4矩阵就能把平移也变成矩阵乘法。这样一来平移、旋转、缩放全部统一成同一种操作。更关键的是矩阵乘法可以“合并”。如果我现在要让一个模型先缩放、再旋转、再平移按普通思路就是三步计算。但把三个矩阵相乘得到一个“组合矩阵”一次性乘到顶点上就行。实际渲染里一个模型动辄几千上万个顶点如果你每个顶点都去做三次变换运算性能直接完蛋。有了组合矩阵每个顶点只需要做一次矩阵乘法这才是工程上可行的方案。1.3 常见变换的矩阵长什么样在OSG里平移、缩放、旋转都有现成的矩阵生成函数不需要你手写矩阵。但理解这些矩阵的“形状”对排查问题很有帮助。平移矩阵把点沿X轴移动dx| 1 0 0 dx | | 0 1 0 0 | | 0 0 1 0 | | 0 0 0 1 |缩放矩阵把点沿X轴缩放sx| sx 0 0 0 | | 0 sy 0 0 | | 0 0 sz 0 | | 0 0 0 1 |旋转矩阵绕Z轴旋转θ角| cosθ -sinθ 0 0 | | sinθ cosθ 0 0 | | 0 0 1 0 | | 0 0 0 1 |说实话这些矩阵我不是靠背的而是靠“转一转”验算出来的。比如绕Z轴旋转90度坐标(1,0,0)应该变成(0,1,0)代入公式算一遍就记住了。2. OSG里的矩阵到底怎么用原理看懂了接下来就是实操。OSG封装好了矩阵相关的类平时不需要手工创建矩阵直接调用类方法就行。但有几个细节非常容易踩坑我一个个说。2.1 核心类osg::Matrix 和 osg::Vec3OSG里最常用的两个类型osg::Vec3三维向量用来表示位置、方向、缩放等。osg::Matrix4x4矩阵核心成员是一个16个浮点数的数组按列优先存储。下面是最基础的初始化代码#include osg/Matrix #include osg/Vec3 // 创建一个单位矩阵 osg::Matrix m osg::Matrix::identity(); // 创建一个平移矩阵沿X轴移动5个单位 osg::Matrix trans osg::Matrix::translate(5.0, 0.0, 0.0); // 创建一个缩放矩阵三轴各缩放2倍 osg::Matrix scale osg::Matrix::scale(2.0, 2.0, 2.0); // 创建一个旋转矩阵绕Z轴旋转45度 osg::Matrix rot osg::Matrix::rotate(osg::DegreesToRadians(45.0), osg::Vec3(0.0, 0.0, 1.0));注意rotate函数的第一个参数是弧度不是角度。OSG 提供了一个转换工具osg::DegreesToRadians(45.0)但也可以直接写osg::PI_4。新手最容易在这里翻车角度值直接填进去结果旋转角度完全不是自己想要的。2.2 组合矩阵乘法顺序是最大的坑矩阵变换想要组合使用就要做乘法。但矩阵乘法不满足交换律顺序错了结果就完全变了。举个例子osg::Matrix trans osg::Matrix::translate(5.0, 0.0, 0.0); osg::Matrix scale osg::Matrix::scale(2.0, 2.0, 2.0); // 先缩放再平移 osg::Matrix m1 trans * scale; // 先平移再缩放 osg::Matrix m2 scale * trans;m1和m2的结果是完全不一样的。具体来说m1 trans * scale先对物体缩放然后整体平移。缩放是在模型自己的坐标系里做的平移量5个单位不受缩放影响是真正的5个单位。m2 scale * trans先对物体平移再整体缩放。平移量5个单位也会被缩放最后变成10个单位。这个道理我用特别笨的办法记住了代码里从右往左读。trans * scale表示“先应用右边的scale再应用左边的trans”。就像穿衣服先穿内衣再穿外套顺序反了就是内衣套外套。实际项目里常用的组合顺序是先缩放、再旋转、再平移。写成代码就是osg::Matrix m trans * rot * scale;这个顺序从右往左读就是“先缩放再旋转再平移”。这个顺序能保证缩放不会影响旋转轴的方向。旋转不会影响平移的方向。平移是在全局坐标里进行的不会跟着模型旋转偏转。2.3 把矩阵挂到场景里MatrixTransform节点矩阵本身只是“数据”要让它实际作用于模型就需要一个节点类型去承载它。OSG里最常用的就是osg::MatrixTransform。#include osg/MatrixTransform #include osg/ShapeDrawable #include osg/Geode // 创建一个模型一个立方体 osg::ref_ptrosg::Geode geode new osg::Geode; geode-addDrawable(new osg::ShapeDrawable(new osg::Box(osg::Vec3(0.0f, 0.0f, 0.0f), 1.0f))); // 创建矩阵变换节点 osg::ref_ptrosg::MatrixTransform mt new osg::MatrixTransform; // 设置矩阵先缩放2倍再绕Z轴旋转45度最后平移到(5,0,0) mt-setMatrix(osg::Matrix::translate(5.0, 0.0, 0.0) * osg::Matrix::rotate(osg::DegreesToRadians(45.0), osg::Vec3(0.0, 0.0, 1.0)) * osg::Matrix::scale(2.0, 2.0, 2.0)); // 把几何体挂到矩阵节点下面 mt-addChild(geode.get()); // 把矩阵节点挂到场景根节点下 root-addChild(mt.get());这里的关键是理解节点树的层级关系MatrixTransform节点下的所有子节点都会被它的矩阵“带着走”。如果你给一个Transform节点同时挂了几何体和另一个子节点那它们都会被同一个矩阵变换。2.4 preMult 和 postMult 的区别osg::Matrix类里还有两个方法也是新手最容易懵的preMult和postMult。preMult(other)相当于this this * other在“当前矩阵”的基础上后面再乘一个矩阵。postMult(other)相当于this other * this在“当前矩阵”基础上前面再乘一个矩阵。用一个例子来理解osg::Matrix m osg::Matrix::translate(5.0, 0.0, 0.0); // m 平移矩阵 m.preMult(osg::Matrix::scale(2.0, 2.0, 2.0)); // m 平移矩阵 * 缩放矩阵这时候 m 的效果是先缩放再平移。而osg::Matrix n osg::Matrix::translate(5.0, 0.0, 0.0); n.postMult(osg::Matrix::scale(2.0, 2.0, 2.0)); // n 缩放矩阵 * 平移矩阵这时候 n 的效果是先平移再缩放。我自己的记忆方法是pre就相当于在等式右边“后面”追加post就像在“前面”插队。但因为矩阵乘法本身方向跟直觉相反所以最靠谱的方式还是先画出变换流程再动手写代码宁可多画两分钟图也不要直接在代码里试错。3. 实操把一个模型摆到想要的位置理论说完了来一段完整的实操。目标是加载一个模型或自己建一个简单模型把它摆到场景中指定位置并设置旋转和缩放。3.1 搭建最小场景先搭一个最简单的场景一个立方体放在原点。#include osgViewer/Viewer #include osg/Group #include osg/Geode #include osg/ShapeDrawable #include osg/MatrixTransform #include osgDB/ReadFile int main(int argc, char** argv) { // 创建场景根节点 osg::ref_ptrosg::Group root new osg::Group; // 创建一个带颜色的几何体 osg::ref_ptrosg::Geode geode new osg::Geode; osg::ref_ptrosg::ShapeDrawable box new osg::ShapeDrawable(new osg::Box(osg::Vec3(0.0f, 0.0f, 0.0f), 1.0f)); box-setColor(osg::Vec4(1.0f, 0.0f, 0.0f, 1.0f)); // 红色 geode-addDrawable(box.get()); // 创建矩阵变换节点 osg::ref_ptrosg::MatrixTransform mt new osg::MatrixTransform; mt-addChild(geode.get()); // 设置变换矩阵 mt-setMatrix(osg::Matrix::translate(3.0, 2.0, -5.0)); // 挂到根节点 root-addChild(mt.get()); // 启动查看器 osgViewer::Viewer viewer; viewer.setSceneData(root.get()); return viewer.run(); }这段代码跑起来后你会看到一个红色立方体位置在(3, 2, -5)。如果看不着别急着怀疑代码先把相机往后退一退或者把平移量改小点比如(0, 0, -3)。因为默认相机位置在(0,0,0)附近你要是把物体平移到(3,2,-5)它可能跑到视线之外去了。3.2 加旋转和缩放接下来加上旋转和缩放。修改mt-setMatrix这一行mt-setMatrix(osg::Matrix::translate(3.0, 2.0, -5.0) * osg::Matrix::rotate(osg::DegreesToRadians(45.0), osg::Vec3(0.0, 1.0, 0.0)) * osg::Matrix::scale(2.0, 2.0, 2.0));这里做的事情是把立方体放大两倍然后绕Y轴旋转45度最后平移到(3,2,-5)。观察效果你会发现立方体是“原地旋转”不会边旋转边平移。这是因为我用了“先缩放再旋转再平移”的组合顺序。如果你把代码改成mt-setMatrix(osg::Matrix::scale(2.0, 2.0, 2.0) * osg::Matrix::rotate(osg::DegreesToRadians(45.0), osg::Vec3(0.0, 1.0, 0.0)) * osg::Matrix::translate(3.0, 2.0, -5.0));效果就不一样了先是整体平移到(3,2,-5)然后再对这个位置进行旋转和缩放。旋转会绕世界坐标原点转立方体会因为旋转“飞”到别的位置去而不是原地转。这就是我前面强调的“从右往左读”的实战体现。3.3 让模型动起来在回调里改矩阵如果只是静态摆放setMatrix就够了。但很多时候你希望模型持续动比如旋转、匀速平移。这就需要用回调。#include osg/NodeCallback class RotateCallback : public osg::NodeCallback { public: virtual void operator()(osg::Node* node, osg::NodeVisitor* nv) { osg::MatrixTransform* mt dynamic_castosg::MatrixTransform*(node); if (mt) { double time nv-getFrameStamp()-getSimulationTime(); double angle time * osg::DegreesToRadians(30.0); // 每秒转30度 mt-setMatrix(osg::Matrix::rotate(angle, osg::Vec3(0.0, 0.0, 1.0))); } traverse(node, nv); } };然后把回调挂上去mt-addUpdateCallback(new RotateCallback);这就能让立方体绕Z轴持续旋转。注意回调里每次更新矩阵都会打断之前设置的平移和缩放。如果你希望一边平移一边自转就得在回调里把平移、旋转、缩放组合起来而不是只写旋转。3.4 获取模型在世界空间的位置经常有人问“我怎么知道一个节点在世界空间里的坐标”这里有个很常用的函数osg::NodePath path; osg::Matrix worldMatrix osg::computeWorldToLocal(path);但更常用的是computeLocalToWorld#include osg/NodeVisitor #include osg/Matrix osg::Matrix computeLocalToWorld(osg::Node* node) { osg::NodePath path; // 从根节点到当前节点收集路径 // 这里省略了路径收集的过程实际开发中可以通过 NodeVisitor 实现 return osg::computeLocalToWorld(path); }核心思想从根节点开始把路径上每个节点的矩阵相乘得到的就是该节点的“世界矩阵”。这个矩阵可以把模型坐标系里的点变换到世界坐标系。这个小功能在后续做交互、碰撞检测、约束时特别有用。我当时在做一个“让模型沿指定轨迹移动”的Demo就是靠这个求出模型实时位置才把轨迹调试出来。4. 踩坑记录矩阵变换的常见问题矩阵变换的坑我这两天基本踩了个遍。我把它们整理成列表方便你对照。4.1 矩阵设置后物体不见了刚写完上面那段示例代码我运行之后发现场景里什么都没有。第一反应是“代码写错了”但检查了半天也没发现语法问题。后来怀疑是位置问题把平移量改成(0,0,-3)才看到物体。原因(3, 2, -5)这个位置已经超出了默认相机的视锥体范围。相机的默认位置在原点附近朝向Z轴负方向近裁剪面通常0.1远裁剪面100。但物体在(3,2,-5)已经偏离了相机正前方加上立方体尺寸只有1缩放前整体很小很容易在视野边缘或之外。排查思路先把矩阵设为单位矩阵确认物体能正常显示。再逐渐增加平移量观察物体什么时候消失就能大概判断视锥体的范围。也可以把相机初始位置调远点或者把物体的初始位置放在(0,0,-5)附近这样更方便观察。4.2 矩阵组合顺序反了效果完全不对这个坑我前面反复强调过但真的很容易犯。尤其是你想做“物体绕自身中心旋转”的时候。如果组合顺序写成“先平移再旋转”物体会绕世界原点旋转而不是绕自己中心转。我当时写一个“行星绕太阳公转”的Demo地球节点里套了个小的地球模型。我的本意是地球先自转再公转结果因为矩阵顺序写反了地球变成了“绕着太阳公转的同时一直保持同一面朝向太阳”。这其实就是旋转和平移顺序问题。如果你需要让物体绕自身中心旋转要先把它放在原点旋转再平移到目标位置osg::Matrix m trans * rot; // 先旋转再平移如果你想让它绕另一个点比如太阳旋转就得把那个点平移到原点旋转再平移回去osg::Matrix m translate(太阳位置) * rotate(角度) * translate(-太阳位置);4.3 缩放导致模型变形缩放看起来简单但有一个细节特别容易忽视三轴缩放值不一致时模型会变形。比如把一个圆形物体设置成scale(1,2,1)它就会被拉成椭圆。这在某些场景里是你要的效果但有时你只是想把模型整体放大结果手滑写成了scale(2,2,1)模型直接变扁。另外缩放负值会导致模型“翻转”也就是镜像效果。比如scale(-1,1,1)会让模型沿Y轴翻转。做镜像效果时可以这么用但如果只是因为把缩放值填成负数那你可能得花半天才能发现模型怎么“反”了。我一般会在程序启动时把缩放的三个值打日志确认无误再进行渲染。调模型时最常用的写法是统一缩放double s 2.0; osg::Matrix::scale(s, s, s);这样既直观又不容易手滑。4.4 鼠标点击模型点到位置不对这个坑让我印象很深做一个鼠标拾取功能时我用屏幕坐标计算射线结果总是点不中模型。后来发现问题出在我没有把射线从“世界坐标”转换到“模型坐标”。osgUtil::LineSegmentIntersector可以接受世界坐标系的射线但如果模型挂在MatrixTransform下面射线检测时要考虑模型的矩阵变换。最直接的做法是取到射线在世界坐标系里的起点和终点。用模型的逆矩阵把射线从世界坐标转换到模型坐标。再做碰撞检测。相当于“沿着矩阵的来路反向找回去”。这就是逆矩阵的经典应用场景。如果你忘了这一步碰撞检测的世界和模型世界不统一结果自然就是“点到空气”或者“点偏了”。4.5 回调里频繁创建矩阵导致性能问题矩阵对象本身很小频繁创建问题不大。但如果你在回调里每次更新都新建osg::Matrix然后调用setMatrix当场景里模型数量多起来时会影响效率。更好的做法是在回调成员变量里缓存矩阵每次更新只修改对应元素或者复用同一个矩阵对象。class RotateCallback : public osg::NodeCallback { private: double _angle 0.0; public: virtual void operator()(osg::Node* node, osg::NodeVisitor* nv) { osg::MatrixTransform* mt dynamic_castosg::MatrixTransform*(node); if (mt) { _angle osg::DegreesToRadians(1.0); // 每帧加1度 osg::Matrix m; m.makeRotate(_angle, osg::Vec3(0.0, 0.0, 1.0)); mt-setMatrix(m); } traverse(node, nv); } };像这种就比每次新建矩阵再调makeRotate强一点。虽然差异不大但工程习惯要从一开始就养好。4.6 单位矩阵的重要性最后提一下单位矩阵。初学者经常忽略它觉得“反正后面会设置矩阵”。但在有些场景中你需要“不设置任何变换”这时就显式设为单位矩阵mt-setMatrix(osg::Matrix::identity());单位矩阵的效果就是“什么都不变”。当你做矩阵累加或组合时尤其是自己实现链式变换时初始化为单位矩阵能避免很多脏数据。我在写处理骨骼动画相关的测试代码时如果不把节点矩阵先初始化为单位矩阵会出现第一帧模型位置乱跳的问题就是因为初始矩阵里残留了上一帧的数据。5. 几个实用的调试技巧这节说点调试技巧都是实际开发中能直接上手的。5.1 打印矩阵想知道当前矩阵到底是什么状态直接打印。osg::Matrix重载了流输出符号osg::Matrix m osg::Matrix::translate(1,2,3); std::cout m std::endl;输出会显示4x4矩阵的16个值。注意它是列优先存储的所以打印出来第一行是矩阵的第一列别搞混。5.2 用辅助几何体观察矩阵变换如果你想知道缩放、旋转到底对不对最简单的方法是放一个“参照物”。比如在原点放一个透明的大球或者放一根轴线。这样矩阵一变你就能立刻看出物体的位置、大小和朝向变化。我通常会放一个透明的包围球或坐标轴调矩阵时特别管用。5.3 用WorldView变换隔离问题有时候你会分不清“是模型矩阵错了”还是“相机矩阵错了”。这时候可以暂时固定相机把观察视角设为固定位置然后专心调试模型矩阵。viewer.getCamera()-setViewMatrixAsLookAt(osg::Vec3(5, 5, 5), osg::Vec3(0, 0, 0), osg::Vec3(0, 1, 0));这样至少能保证你看到的东西只受模型矩阵影响排查问题时就少了一个变量。5.4 矩阵求逆的常见用途矩阵求逆是个很有用的功能除了前面说的射线碰撞外还有一个常见场景你想让某个节点一直“面向”相机。实现方式是拿到相机的视图矩阵。求逆矩阵。用逆矩阵的旋转分量来设置节点的朝向。这其实就是广告牌Billboard技术的原理。写到这里我感受到矩阵变换确实串起了三维图形学里很多高级功能。虽然第四天学习笔记看起来只讲了“怎么放一个模型”但它背后连接着动画、相机控制、碰撞检测、场景管理等一系列内容。今天先说这么多。矩阵这块我还会继续深入学习后面如果踩到更深的坑再回来补笔记。