在本系列第一篇介绍过鼠标按键的功能,如下。
- 左键拖拽 - 旋转魔方
- 右键拖拽 - 变换视角
- 滚轮 - 缩放魔方
今天研究一下如何实现后面两个功能,用到的技术主要是Arcball,Arcball是实现Model-View-Camera的重要技术,这里的旋转基于Quaternion(四元数)来实现,当然也可以通过欧拉角来实现,但是欧拉角的旋转不够平滑。先看一下Model-View-Camera的效果,如下,这个gif效果图是用LICEcap录制的,帧率有些慢,略有卡顿现象,大家可以下载文末的可执行文件查看更加平滑的效果。
右键拖拽 - 变换视角
由上面的动画可以看到,通过用户按下并拖拽鼠标右键即可以旋转视角(表面上看是魔方在旋转,但实际上是camera在旋转,相对运动而已)。为了研究这个功能是如何实现的,我们可以将鼠标右键拖拽这个过程分解一下。
- 按下鼠标右键(此时鼠标的位置是P1)
- 拖拽右键(此时鼠标的位置是P2,注意P2是随拖拽实时变化的)
- 抬起鼠标右键(停止旋转)
为了实现上面的功能,我们在屏幕上虚拟出一个球体来,将P1和P2映射到这个球体,再从球心到P1和P2连线构成两个向量,有了这两个向量就可以求出旋转轴及旋转角度了,这个虚拟的球体,就是Arcball了,如下图。
在上图中P1和P2的夹角就是旋转角度,N则是旋转轴。旋转角度可以通过P1和P2的点积来实现,旋转轴可以通过P1和P2的叉积来实现,稍后详述,下面看看如何将屏幕上的点映射到球体上,这是实现Arcball的关键步骤。直观一点的想法,可以把屏幕看成一个矩形纹理,球体看做一个模型,所以将屏幕坐标映射到球体坐标的过程实际上相当于将这个矩形纹理贴图到球体上。需要注意的是,我们这里只用到半个球体(如果屏幕将球体一份为二的话)。
屏幕坐标到球坐标
看代码,顾名思义,这个函数完成屏幕坐标到球体坐标(单位向量)的转换,两个输入参数分别是鼠标按下时屏幕的X,Y坐标。
1 D3DXVECTOR3 ArcBall::ScreenToVector(int screen_x, int screen_y) 2 { 3 // Scale to screen 4 float x = -(screen_x - window_width_ / 2) / (radius_ * window_width_ / 2); 5 float y = (screen_y - window_height_ / 2) / (radius_ * window_height_ / 2); 6 7 float z = 0.0f; 8 float mag = x * x + y * y; 9 10 if(mag > 1.0f)11 {12 float scale = 1.0f / sqrtf(mag);13 x *= scale;14 y *= scale;15 }16 else17 z = sqrtf(1.0f - mag);18 19 return D3DXVECTOR3(x, y, z);20 }
代码解释:
4-5两行代码将屏幕坐标映射到球体坐标的范围,但此时还只是xy两个分量,所以后续的代码都是计算z坐标并单位化的。这里radius_是球体的半径,为了方便计算,通常设置为1。
10-15行,如果xy的平方和大于1,此时该点恰好位于半球球的边缘,所以令z=0
17行,如果xy平方和小于1,说明该点不位于半球边缘,计算z的值。
19行返回球体坐标对应的向量(已经单位化)。
关于这个函数更加详细的解释,看以看看我的另一篇随笔,。
旋转轴及旋转角度
这里我们用四元组来表示旋转,一个四元组包含四个分量x, y, z, w。假设一个旋转的旋转轴是axis,旋转角度是theta。那么对应的四元组q如下。
q.x = sin(theta / 2) * axis.x;q.y = sin(theta / 2) * axis.y;q.z = sin(theta / 2) * axis.z;q.w = cos(theta / 2);
有了上面的公式,我们就可以根据旋转轴和旋转角度来构造四元组了。下面的函数就是用来做这件事的,两个参数分别是旋转的起始向量和结束向量,这两个向量是由前面的ScreenToVector函数生成的。
1 D3DXQUATERNION ArcBall::QuatFromBallPoints(D3DXVECTOR3& start_point, D3DXVECTOR3& end_point) 2 { 3 // Calculate rotate angle 4 float angle = D3DXVec3Dot(&start_point, &end_point); 5 6 // Calculate rotate axis 7 D3DXVECTOR3 axis; 8 D3DXVec3Cross(&axis, &start_point, &end_point); 9 10 // Build and Normalize the Quaternion11 D3DXQUATERNION quat(axis.x, axis.y, axis.z, angle);12 D3DXQuaternionNormalize(&quat, &quat);13 14 return quat;15 }
代码解释:
第4行,计算量个向量的夹角余弦值,用的是点积公式,两个向量a和b,他们的点积a dot b = |a||b|cost(theta),如果a和b都是单位向量的话,那么a dot b = cost(theta),这里start_point和end_point已经是单位向量了,所以angle = cos(theta)。
第7,8两行代码计算旋转轴,用的是叉积公式,两个向量P1和P2的叉积生成第三个向量N,且N垂直于P1和P2。
第11,12行构造四元组,并单位化。需要注意的是旋转轴部分并没有严格按照上面的四元组公式,因为旋转轴是一个向量,而同一个方向可以有多种表示方法,比如(1,2,3)和(2,4,6)表示的是同一个方向向量。
Arcball的调用
Arcball可以在处理Windows消息的时候调用。
LRESULT Camera::HandleMessages(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam){ // update view arc ball if(uMsg == WM_RBUTTONDOWN) { SetCapture(hWnd) ; frame_need_update_ = true ; int mouse_x = (short)LOWORD(lParam) ; int mouse_y = (short)HIWORD(lParam) ; view_arcball_.OnBegin(mouse_x, mouse_y) ; } // mouse move if(uMsg == WM_MOUSEMOVE) { frame_need_update_ = true ; int mouse_x = (short)LOWORD(lParam); int mouse_y = (short)HIWORD(lParam); view_arcball_.OnMove(mouse_x, mouse_y) ; } // right button up, terminate view arc ball rotation if(uMsg == WM_RBUTTONUP) { frame_need_update_ = true ; view_arcball_.OnEnd(); ReleaseCapture() ; } return TRUE ;}
当鼠标右键按下时,设置frame_need_update_为true,这个向量表示鼠标移动时是否有拖拽发生,因为Windows并没有对应鼠标拖拽的消息,所以要通过两个方面来判断,一是鼠标按下了,二是鼠标移动了,同时满足这两个条件才表示拖拽发生了。调用ArcBall.OnBegin函数,这个函数会判断当前的鼠标位置是否位于窗口客户区内,如果在客户区外则不做相应。如果鼠标在窗口客户区内,还要记录当前鼠标的位置,并生成球体向量用于后续计算。
当鼠标移动时,调用ArcBall.OnMove(),这个函数首先求取鼠标当前位置,并生成球体向量,在根据上一次保存的球体向量计算出旋转增量对应的四元组。
当鼠标右键抬起时,设置frame_need_update_为false,结束旋转。
void ArcBall::OnBegin(int mouse_x, int mouse_y){ // enter drag state only if user click the window's client area if(mouse_x >= 0 && mouse_x <= window_width_ && mouse_y >= 0 && mouse_y < window_height_) { is_dragged_ = true ; // begin drag state previous_quaternion_ = current_quaternion_ ; previous_point_ = ScreenToVector(mouse_x, mouse_y) ; old_point_ = previous_point_ ; }}void ArcBall::OnMove(int mouse_x, int mouse_y){ if(is_dragged_) { current_point_ = ScreenToVector(mouse_x, mouse_y) ; rotation_increament_ = QuatFromBallPoints( old_point_, current_point_ ) ; current_quaternion_ = previous_quaternion_ * QuatFromBallPoints( previous_point_, current_point_ ) ; old_point_ = current_point_ ; }}void ArcBall::OnEnd(){ is_dragged_ = false ;}
鼠标滚轮 - 缩放
缩放使用鼠标滚轮来完成,在WM_MOUSEWHEEL消息,HIWORD里面存放的是鼠标滚轮的增量。获取这个增量,并
// Mouse wheel, zoom in/outif(uMsg == WM_MOUSEWHEEL) { frame_need_update_ = true ; mouse_wheel_delta_ += (short)HIWORD(wParam);}
在Camera类的OnFrameMove中判断是否有滚轮滚动,并做响应的处理,代码如下。
if(mouse_wheel_delta_){ radius_ -= mouse_wheel_delta_ * radius_ * 0.1f / 360.0f; // Make the radius in range of [min_radius_, max_radius_] // This can Prevent the cube became too big or too small radius_ = max(radius_, min_radius_) ; radius_ = min(radius_, max_radius_) ;}
这个if语句会根据滚轮的增量计算radius_,并将radius_限制在范围[min_radius_, max_radius_]内,防止模型过大或者过小。radius_变量稍后会用来计算眼睛到视点的距离,通过改变这个距离的值达到模型放大和缩小的效果,实际上模型并没有真正被缩放,只是观察的距离变了而已,这样就会产生近大远小的效果了。下面的代码用来计算眼睛的位置。
// Update the eye point based on a radius away from the lookAt positioneye_point_ = lookat_point_ - world_ahead_vector * radius_;
Camera
Camera类是Arcball的使用者,里面的OnFrameMove函数每一帧都会被调用,该函数负责缩放和旋转,并生成新的View Matrix。
1 void Camera::OnFrameMove() 2 { 3 // No need to handle if no drag since last frame move 4 if(!m_bDragSinceLastUpdate) 5 return ; 6 m_bDragSinceLastUpdate = false ; 7 8 if(m_nMouseWheelDelta) 9 {10 m_fRadius -= m_nMouseWheelDelta * m_fRadius * 0.1f / 120.0f;11 12 // Make the radius in range of [m_fMinRadius, m_fMaxRadius]13 m_fRadius = max(m_fRadius, m_fMinRadius) ;14 m_fRadius = min(m_fRadius, m_fMaxRadius) ;15 }16 17 // The mouse delta is retrieved IN every WM_MOUSE message and do not accumulate, so clear it after one frame18 m_nMouseWheelDelta = 0 ;19 20 // Get the inverse of the view Arcball's rotation matrix21 D3DXMATRIX mCameraRot ;22 D3DXMatrixInverse(&mCameraRot, NULL, m_ViewArcBall.GetRotationMatrix());23 24 // Transform vectors based on camera's rotation matrix25 D3DXVECTOR3 vWorldUp;26 D3DXVECTOR3 vLocalUp = D3DXVECTOR3(0, 1, 0);27 D3DXVec3TransformCoord(&vWorldUp, &vLocalUp, &mCameraRot);28 29 D3DXVECTOR3 vWorldAhead;30 D3DXVECTOR3 vLocalAhead = D3DXVECTOR3(0, 0, 1);31 D3DXVec3TransformCoord(&vWorldAhead, &vLocalAhead, &mCameraRot);32 33 // Update the eye point based on a radius away from the lookAt position34 m_vEyePt = m_vLookatPt - vWorldAhead * m_fRadius;35 36 // Update the view matrix37 D3DXMatrixLookAtLH( &m_matView, &m_vEyePt, &m_vLookatPt, &vWorldUp );38 }
代码解释:
第4行首先判断是否有拖拽,如果没有拖拽动作则不必更新视角,直接返回。
第6行将是否拖拽标志设置为false,因为能走到这一行表示有拖拽。
第8-15行处理鼠标滚轮动作,并确保camera的radius在控制范围内,这样魔方不至于太小或者太大。
第18行将滚轮的旋转增量清0,因为增量不累加,每个frame计算一次,下一个frame重新计算。
第21-22行求出旋转矩阵的逆矩阵,因为如果要达到同样的视角,模型和camera的旋转方向刚好相反。可以这样理解,如果想看魔方的背面,我们可以将魔方旋转180度,这相当于旋转模型,也可以固定魔方,走到魔方的背面去看,这就是旋转camera了。
源码
之前有几个网友提出公布源代码,当时由于代码比较混乱,所以没有公布,我花了几个星期的时间,将所有代码重新整理了一遍,现在基本上可以看了,但是还有很多细节需要打磨。昨晚上传到了github上,欢迎fork,如果不熟悉github,也可以在博客园本地下载。
编译源代码需要安装DirectX SDK,推荐大家使用Microsoft DirectX SDK (June 2010),这是最新的SDK,当然也是最后一个。大家可以自己编译试着玩玩,如有问题,欢迎留言讨论。
可执行程序
如果不想看代码,可以下载下面的可执行文件试玩,这个版本修复了之前几位网友发现的几个bug,还是那句话,欢迎大家继续找毛病。
To Be Continued
这个Demo刚刚上传到github,还有很多功能需要完善,由于个人精力有限,如果哪位网友有兴趣,可以和我一起完成,那就太好了,期待你的加入!稍后将这个Demo升级,编写DirectX10及DirectX11版本的RubikCube,也算是一个练手的过程吧,欢迎继续关注!