在本系列第一篇介紹過鼠標按鍵的功能,如下。
- 左鍵拖拽 - 旋轉魔方
- 右鍵拖拽 - 變換視角
- 滾輪 - 縮放魔方
今天研究一下如何實現后面兩個功能,用到的技術主要是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 else 17 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行返回球體坐標對應的向量(已經單位化)。
關於這個函數更加詳細的解釋,看以看看我的另一篇隨筆,ScreenToVector詳解。
旋轉軸及旋轉角度
這里我們用四元組來表示旋轉,一個四元組包含四個分量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 Quaternion 11 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/out if(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 position eye_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 frame 18 m_nMouseWheelDelta = 0 ; 19 20 // Get the inverse of the view Arcball's rotation matrix 21 D3DXMATRIX mCameraRot ; 22 D3DXMatrixInverse(&mCameraRot, NULL, m_ViewArcBall.GetRotationMatrix()); 23 24 // Transform vectors based on camera's rotation matrix 25 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 position 34 m_vEyePt = m_vLookatPt - vWorldAhead * m_fRadius; 35 36 // Update the view matrix 37 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,也算是一個練手的過程吧,歡迎繼續關注!
