Axiom3D:Ogre射线与点,线,面相交,鼠标操作3维空间.
在第一篇网络分解成点,线,面.第二篇分别点以球形,线以圆柱,面分别以MergerBatch整合批次显示.因为整合批次显示后,相应的点,线,面不能以Ogre本身的射线来选取,因为整合后,以点举例,多个点显示虽然不在一起,但是是一个Mesh.Ogre本身的检测只能检测到这里,在我们这不满足要求,相应的点,线,面检测都需要自己来计算.
在讲解本文之前,先看下射线的相关生成代码,只有先明白射线如何生成,生成最后是相对什么空间.
[OgreVersion( 1, 7, 2790, "Slightly different" )] public void GetCameraToViewportRay( float screenX, float screenY, out Ray ray ) { var inverseVP = ( _projectionMatrix*_viewMatrix ).Inverse(); #if !AXIOM_NO_VIEWPORT_ORIENTATIONMODE // We need to convert screen point to our oriented viewport (temp solution) Real tX = screenX; Real a = (int)OrientationMode*System.Math.PI*0.5f; screenX = System.Math.Cos( a )*( tX - 0.5f ) + System.Math.Sin( a )*( screenY - 0.5f ) + 0.5f; screenY = System.Math.Sin( a )*( tX - 0.5f ) + System.Math.Cos( a )*( screenY - 0.5f ) + 0.5f; if ( ( ( (int)OrientationMode ) & 1 ) == 1 ) { screenY = 1.0f - screenY; } #endif Real nx = ( 2.0f*screenX ) - 1.0f; Real ny = 1.0f - ( 2.0f*screenY ); var nearPoint = new Vector3( nx, ny, -1.0f ); // Use midPoint rather than far point to avoid issues with infinite projection var midPoint = new Vector3( nx, ny, 0.0f ); // Get ray origin and ray target on near plane in world space var rayOrigin = inverseVP*nearPoint; var rayTarget = inverseVP*midPoint; var rayDirection = rayTarget - rayOrigin; rayDirection.Normalize(); ray = new Ray( rayOrigin, rayDirection ); }
这个方法在摄像机类,主要因为摄像机内整合了视图矩阵与透视矩阵,再说下视图矩阵与透视矩阵的作用,视图矩阵相当于把世界坐标变换成摄像机坐标系,我们平常用Camear.Lookat来完成,一个参数是当前位置,一个是向上向量,一个是指向目标向量.这三个参数分别用来指定视图坐标系的圆点,指向目标向量是Z轴,向上向量是Y轴,根据YZ向量的叉积生成X轴,然后X轴与Z轴的叉积会重新生成Y轴,为什么Y轴后面会重新生成,因为我们目标向量可能一直在变,本来不可能一直垂直Y轴,而在我们视图坐标系中,三轴垂直,Y轴更多用来辅助生成X轴.比如我们不设置Camear.Lookat,那么默认视图坐标系与世界坐标对应关系如下,目标在原点,而Z轴是Vector3.zero-Vector3.Z,就是负Z轴,Y轴还是Vector3.Y,根据z与y生成x轴对应的是-Vector3.X,然后生成Y轴,和原来一样,总的来说,就是一个和世界坐标系同原点,但是x,z是负方向,y的原来一样的坐标系.
而透视矩阵是在视图矩阵后,把视图里视椎体(一般四个参数,近截而距离,远截面距离,上下视角,左右比例)映射成一个长宽高各为(-1,1)的立方体,在Ogre与opengl都是如此,在dx中是(0,1)的立方体,这个关系不大.这里不说具体如何生成矩阵,只讲这个矩阵的含义.在经过透视矩阵后生成的x,y,z三个方向各为(-1,1)的立方体中,x方向的(-1,1)对应的屏幕的宽度,而y方向对应是屏幕的高度,z对应的是深度,主要用来判断那个物体在前,那个物体在后,根据深度可以添加很多特殊效果.
物体一般的变换大致如下,如果物体本身有个局部坐标系,需要先从局部坐标系换成世界坐标系,再从世界坐标系换成屏幕坐标系,最后经过透视矩阵转换成屏幕上的点.
那么我们用鼠标点击一下,就是屏幕上的点,如何换算成3D里面的坐标系了,Ogre里的GetCameraToViewportRay就给我们做了一个很好的演视,或者说是把上面的过程反推回去,首先根据点在视图中的位置x/width,y/hight生成0-1的浮点值,在前面我们知道,透视矩阵后的长宽都是-1到1的,所以先要把这里的0,1的值映射成-1,1,很简单2*x-1.需要注意的是,在屏幕里,Y是从上向下,而我们世界坐标系Y是从下向上,所以首先把y倒过来取负.x与y确定后,z值不确定.这很正常,二维变成三维,本来就少一维的信息,在这里我们生成射线就好说了,分别是近点z=-1,和远点z=1生成射线就行,这边没取z=1,而是z=0,代码上有解释,中间的点比最远的的那点好,避免无限远投影的问题.那么在这我们就生成了原经过透视矩阵后的坐标,如果把这坐标换成世界坐标了,很简单来,原来是世界坐标经过视图与透视矩阵后成透视坐标,我们只要把对应透视坐标剩以视图与透视矩阵的逆矩阵就成了世界坐标系中的位置.我们知道矩阵剩以他自己的逆矩阵等于1.根据这两点最后生成射线表示法的一种.P(t) = P0 + t*D.P0是原点,D是方向,t是沿方向D前进的长度.
经过上面我们知道,通过GetCameraToViewportRay生成的射线是世界坐标系下的,相应的运算在同一坐标系下算才有意义.下面是点选择的效果.
如图上,八个点是一个Mesh,可以分别设置大小,颜色等,具体设计请看上一篇.点的选择比较简单,把点当然一个球形,用Ogre自带的射线与球相交检查就可.
public void HitTest(Ray ray) { foreach (var p in this) { Sphere sphere = new Sphere(this.Parent.PointNode.FullTransform * p.VertexInfo.Position, this.Scale); var result = Utility.Intersects(ray, sphere); if (result.Hit) { this.SelectPoint = p; break; } } }
这个算是比较简单的,只需要注意一点的,原来点是局部坐标系下的,需要剩以本节点下的局部坐标下,转换成世界坐标再与我们的Ray比较.
下面是线的选择,同样虽然是多条线,可以不同的颜色,但是只有一个Batch.先看下效果图.
具体算法见如下代码:
/// <summary> /// 3D数学基础:图形与游戏开发 里的算法,二射线相交检测 /// </summary> /// <param name="ray1"></param> /// <param name="ray2"></param> /// <param name="deviation">ray1与ray2最近距离误差范围</param> /// <returns></returns> public static Tuple<bool, Real, Real, Vector3, Vector3> RayIntersectsRay(this Ray ray1, Ray ray2, float deviation) { var OfO = ray2.Origin - ray1.Origin; var DxD = ray1.Direction.Cross(ray2.Direction); //平行 if (DxD == Vector3.Zero) { //1重合,2不重合,重合也相当于hit. float limit = 0.00001f; var t = ray2.Origin.x - ray1.Origin.x; var v = ray1.Origin + ray1.Direction * t; //查看ray2.Origin是否在ray1.Origin射线中 if (v.DifferenceLessThan(ray2.Origin, limit)) { return Tuple.Create(true, (Real)t, (Real)0, v, ray2.Origin); } return Tuple.Create(false, (Real)0, (Real)0, Vector3.Invalid, Vector3.Invalid); } else { var t1 = OfO.Cross(ray2.Direction).Dot(DxD) / DxD.Dot(DxD); var t2 = OfO.Cross(ray1.Direction).Dot(DxD) / DxD.Dot(DxD); var p1 = ray1.Origin + ray1.Direction * t1; var p2 = ray2.Origin + ray2.Direction * t2; bool bHit = (p1 - p2).Length < deviation; return Tuple.Create(bHit, t1, t2, p1, p2); } }
这个算法是参照3D数学基础:图形与游戏开发里的算法,二射线相交检测.前面平行(分二种,一种重合,一种不重合)的情况没碰到,也就没测,我们主要看不平行的情况,也有二种,一种是在同一平面,一种是不在同一平面.在同一平面的情况不多,更多的是不在同一平面的情况,这种的话并不是就说相交不到,因为我们本来选择的是线段,在这以圆柱画出的,只要检测二射线相离最近的二点在一个范围内就算相交,这个具体的推导算法就不解释了,大家有兴趣可以去看下3D数学基础:图形与游戏开发,里面有完整的推导过程.不过上面明显还不完善,还要加上如下代码.
public void HitTest(Ray ray) { foreach (var line in this) { var p1 = this.Parent.LineNode.FullTransform * this.Parent[line.Index[0]]; var p2 = this.Parent.LineNode.FullTransform * this.Parent[line.Index[1]]; //3D数学基础:图形与游戏开发 里的算法,二射线相交检测 //s1 ray s2 (p1 + (p2-p1)*t) Ray ray0 = new Ray(p1, (p2 - p1).ToNormalized()); var hitResult = ray0.RayIntersectsRay(ray, this.Scale); if (hitResult.Item1) { var t = hitResult.Item2; if (t >= 0 && t <= (p2 - p1).Length) { this.SelectLine = line; break; } } } }
因为我们选择的是线段,所以不仅要检测相交,还要检测交点是否在有效范围内.最后同上面,对应的点应该转化到世界坐标系下.
最后是面,面就不发图了,直接放代码,流程和上面差不多.
public void HitTest(Ray ray) { foreach (var surface in this) { foreach (var tri in surface.Triangles) { var matrix = this.Parent.LineNode.FullTransform; var p1 = matrix * this.Parent[tri.sharedVertIndex[0]]; var p2 = matrix * this.Parent[tri.sharedVertIndex[1]]; var p3 = matrix * this.Parent[tri.sharedVertIndex[2]]; var result = ray.RayIntersectsTriangle(p1, p2, p3); if (result.Item1) { this.SelectSurface = surface; break; } } } } public static System.Tuple<bool, Vector3> RayIntersectsTriangle(this Ray ray, Vector3 a, Vector3 b, Vector3 c) { // Place the end beyond any conceivable triangle, 1000 meters away var start = ray.Origin; var end = start + ray.Direction * 1000000f; var pq = end - start; var pa = a - start; var pb = b - start; var pc = c - start; // Test if pq is inside the edges bc, ca and ab. Done by testing // that the signed tetrahedral volumes, computed using scalar triple // products, are all positive var result = Tuple.Create(false, Vector3.Invalid); float u = pq.Cross(pc).Dot(pb); if (u < 0.0f) { return result; } float v = pq.Cross(pa).Dot(pc); if (v < 0.0f) { return result; } float w = pq.Cross(pb).Dot(pa); if (w < 0.0f) { return result; } var denom = 1.0f / (u + v + w); // Finally fill in the intersection point var where = (u * a + v * b + w * c) * denom; return Tuple.Create(true, where); }
RayIntersectsTriangle算法是Axiom3D论坛里有人提供的,大家可以搜索下.
经过上面点,线,面选择后,我们来完善前面没有完善的地方,在上面截图控件上方,手掌一样的东东用来移动模型,最开始直接用鼠标移动来对应物体的x,y移动,当然效果烂的要命,网上搜了一下,都没昨讲.没有办法,只有自己动手了,前面我们看到Ray的生成过程,鼠标点击后,这点映射成三维里的,x,y好确定,唯一没想通如何确定z值.后面在脑海里把视截体想了下,如果能确定我们要移动的目标在视截体距离近截面和远截面的位置(后面发现只要用到与近截面的距离,具体后面再来说),不就可以根据这个比例映射在ray上的长度,就是前面P(t) = P0 + t*D.这里面的t值.
下面是相关代码,鼠标左键点下后,我们确定与近截面的距离,viewD.
if (EngineCore.Instance.ActionType != ActionType.None) { EngineCore.Instance.Select = true; //hitD = this.Camera; this.viewZ = this.Camera.FrustumPlanes[0].GetDistance(node.DerivedPosition); }
鼠标移动的代码:
public override void MouseMove(MouseEventArgs e) { int offsetX = e.X - prePoint.X; int offsetY = e.Y - prePoint.Y; if (offsetX == 0 && offsetY == 0) return; if (Control.MouseButtons == MouseButtons.Left) { if (EngineCore.Instance.Select) { var node = EngineCore.Instance.ViewNode; if (EngineCore.Instance.ActionType == ActionType.Translate) { var ray = this.CreateViewportRay(e.X, e.Y); var pos = ray.Origin + ray.Direction * this.viewZ; node.DerivedPosition = pos; } else if (EngineCore.Instance.ActionType == ActionType.Rotate) { node.Yaw((Real)(new Degree((Real)(offsetX * 0.15f)))); node.Pitch((Real)(new Degree((Real)(offsetY * 0.15f)))); } else if (EngineCore.Instance.ActionType == ActionType.Scale) { } } else { Real dist = (this.camera.Position - EngineCore.Instance.ViewNode.DerivedPosition).Length; this.camera.Position = EngineCore.Instance.ViewNode.DerivedPosition; this.camera.Yaw((Real)(new Degree((Real)(-offsetX * 0.25f)))); this.camera.Pitch((Real)(new Degree((Real)(-offsetY * 0.25f)))); this.camera.MoveRelative(new Vector3(0, 0, dist)); EngineCore.Instance.AxisNode.Yaw((Real)(new Degree((Real)(-offsetX * 0.25f)))); EngineCore.Instance.AxisNode.Pitch((Real)(new Degree((Real)(-offsetY * 0.25f)))); } } this.renderWindow.Update(); prePoint = e.Location; base.MouseMove(e); }
结合前面讲解Ray的生成过程,我们知道,ray.Origin是鼠标点击的近截面上的点,而Direction是方向,因为已经归一化,也就是说这个是单位向量,那么我们只需要知道移动的目标与近截面的距离,就能得到我们点击z值了.
效果就是鼠标左键按下是确定与近截面的距离,然后移动的时候,在视截体里,x,y是变化的,z固定的.测试了一下,效果不错,很满意,如我视点移到Y轴上,与x,z面垂直,就能保证移动物体在x,z面,根据不同的视角移动,现实感还是很强,也没有感觉不合理的位置.
旋转就比较简单了,直接把鼠标的二维坐标映射过去就成,效果也还行.代码也在上面,不单独说了,缩放感觉放鼠标滚轮处理比较好,暂时不管了,就个很容易.最后是没选择物体是,摄像机会围绕模型旋转,具体实现大家看下代码就明白了.
新的一年第一天,确定今年主要自学内容,C++ 11与Ogre,在这个项目完成后,主要重新啃C++,最好能用Ogre实现一些功能.