用DirectX实现魔方(三)视角变换及缩放(附源码)

简介:

在本系列第一篇介绍过鼠标按键的功能,如下。

  • 左键拖拽 - 旋转魔方
  • 右键拖拽 - 变换视角
  • 滚轮 - 缩放魔方

今天研究一下如何实现后面两个功能,用到的技术主要是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,还是那句话,欢迎大家继续找毛病。

RubikCube

To Be Continued

这个Demo刚刚上传到github,还有很多功能需要完善,由于个人精力有限,如果哪位网友有兴趣,可以和我一起完成,那就太好了,期待你的加入!稍后将这个Demo升级,编写DirectX10及DirectX11版本的RubikCube,也算是一个练手的过程吧,欢迎继续关注!

 

本文转自zdd博客园博客,原文链接:http://www.cnblogs.com/graphics/p/3044238.html,如需转载请自行联系原作者


相关文章
|
安全 API 开发工具
oss加密的配置方法
阿里云OSS提供多种加密选项:SSE-OSS(默认或对象级AES-256加密)、SSE-KMS(使用KMS托管CMK)、临时密钥加密和客户端加密(CSE)。可通过控制台或API设置Bucket策略,使用HTTP头部指定加密方式。KMS和临时密钥可能涉及更复杂的密钥管理和权限配置。
1532 5
|
人工智能 自然语言处理 前端开发
基于RAG和LLM的水利知识大语言模型系统开发有感
在数字化时代,水利行业的智能化管理尤为重要。本文介绍了基于大语言模型(LLM)和检索增强生成(RAG)技术的水利知识问答系统的开发过程。该系统结合了前沿AI技术和水利专业知识,通过构建全面的水利知识库,优化用户体验,确保系统的灵活性和可扩展性。项目展示了AI技术在垂直领域的巨大潜力,为水利行业的智能化发展贡献力量。
|
监控 安全 生物认证
网络安全中的身份认证与访问控制技术详解
【6月更文挑战第30天】网络安全聚焦身份认证与访问控制,确保合法用户身份并限制资源访问。身份认证涉及生物和非生物特征,如密码、指纹。访问控制通过DAC、MAC、RBAC策略管理权限。最佳实践包括多因素认证、定期更新凭证、最小权限、职责分离和审计监控。这些措施旨在增强系统安全,防范未授权访问。
2374 2
Threejs中导入GLTF模型克隆后合并
这篇文章详细说明了在Three.js中如何导入GLTF模型,对其进行克隆,并将多个克隆模型合并成一个整体模型的过程。
736 2
Threejs中导入GLTF模型克隆后合并
|
存储 监控 安全
企业如何应对网络攻击的威胁
企业如何应对网络攻击的威胁【10月更文挑战第10天】
525 3
|
缓存 监控 固态存储
在Linux中,如何管理和优化文件系统的性能?
在Linux中,如何管理和优化文件系统的性能?
|
存储 搜索推荐 API
Electron-store本地存储功能
【10月更文挑战第18天】Electron-store 无疑为我们的 Electron 应用开发提供了强大的支持。它的本地存储功能不仅方便实用,而且性能优异,为我们打造高质量的应用提供了坚实的基础。
|
机器学习/深度学习 算法
R语言超参数调优:深入探索网格搜索与随机搜索
【9月更文挑战第2天】网格搜索和随机搜索是R语言中常用的超参数调优方法。网格搜索通过系统地遍历超参数空间来寻找最优解,适用于超参数空间较小的情况;而随机搜索则通过随机采样超参数空间来寻找接近最优的解,适用于超参数空间较大或计算资源有限的情况。在实际应用中,可以根据具体情况选择适合的方法,并结合交叉验证等技术来进一步提高模型性能。
1354 5
|
XML Java 数据库连接
JAVA框架技术之十八节springboot课件上手教程(一)
JAVA框架技术之十八节springboot课件上手教程
780 1
JAVA框架技术之十八节springboot课件上手教程(一)
|
内存技术 芯片
MDK st-link下载STM32程序出现Internal command error和Error:Flash download failed. Target DLL
MDK st-link下载STM32程序出现Internal command error和Error:Flash download failed. Target DLL   是因为目标板的芯片处于休眠的状态,在尝试连接目标板时候也会出现报错Internal command ...
4104 0