用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坐标。
D3DXVECTOR3 ArcBall::ScreenToVector(int screen_x, int screen_y)
{
// Scale to screen
float x = -(screen_x - window_width_ / ) / (radius_ * window_width_ / );
float y = (screen_y - window_height_ / ) / (radius_ * window_height_ / ); float z = 0.0f;
float mag = x * x + y * y; if(mag > 1.0f)
{
float scale = 1.0f / sqrtf(mag);
x *= scale;
y *= scale;
}
else
z = sqrtf(1.0f - mag); return D3DXVECTOR3(x, y, z);
}
代码解释:
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 / ) * axis.x;
q.y = sin(theta / ) * axis.y;
q.z = sin(theta / ) * axis.z;
q.w = cos(theta / );
有了上面的公式,我们就可以根据旋转轴和旋转角度来构造四元组了。下面的函数就是用来做这件事的,两个参数分别是旋转的起始向量和结束向量,这两个向量是由前面的ScreenToVector函数生成的。
D3DXQUATERNION ArcBall::QuatFromBallPoints(D3DXVECTOR3& start_point, D3DXVECTOR3& end_point)
{
// Calculate rotate angle
float angle = D3DXVec3Dot(&start_point, &end_point); // Calculate rotate axis
D3DXVECTOR3 axis;
D3DXVec3Cross(&axis, &start_point, &end_point); // Build and Normalize the Quaternion
D3DXQUATERNION quat(axis.x, axis.y, axis.z, angle);
D3DXQuaternionNormalize(&quat, &quat); return quat;
}
代码解释:
第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 >= && mouse_x <= window_width_
&& mouse_y >= && 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。
void Camera::OnFrameMove()
{
// No need to handle if no drag since last frame move
if(!m_bDragSinceLastUpdate)
return ;
m_bDragSinceLastUpdate = false ; if(m_nMouseWheelDelta)
{
m_fRadius -= m_nMouseWheelDelta * m_fRadius * 0.1f / 120.0f; // Make the radius in range of [m_fMinRadius, m_fMaxRadius]
m_fRadius = max(m_fRadius, m_fMinRadius) ;
m_fRadius = min(m_fRadius, m_fMaxRadius) ;
} // The mouse delta is retrieved IN every WM_MOUSE message and do not accumulate, so clear it after one frame
m_nMouseWheelDelta = ; // Get the inverse of the view Arcball's rotation matrix
D3DXMATRIX mCameraRot ;
D3DXMatrixInverse(&mCameraRot, NULL, m_ViewArcBall.GetRotationMatrix()); // Transform vectors based on camera's rotation matrix
D3DXVECTOR3 vWorldUp;
D3DXVECTOR3 vLocalUp = D3DXVECTOR3(, , );
D3DXVec3TransformCoord(&vWorldUp, &vLocalUp, &mCameraRot); D3DXVECTOR3 vWorldAhead;
D3DXVECTOR3 vLocalAhead = D3DXVECTOR3(, , );
D3DXVec3TransformCoord(&vWorldAhead, &vLocalAhead, &mCameraRot); // Update the eye point based on a radius away from the lookAt position
m_vEyePt = m_vLookatPt - vWorldAhead * m_fRadius; // Update the view matrix
D3DXMatrixLookAtLH( &m_matView, &m_vEyePt, &m_vLookatPt, &vWorldUp );
}
代码解释:
第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,也算是一个练手的过程吧,欢迎继续关注!
用DirectX实现魔方(三)视角变换及缩放(附源码)的更多相关文章
- C#/ASP.NET MVC微信公众号接口开发之从零开发(三)回复消息 (附源码)
		
C#/ASP.NET MVC微信接口开发文章目录: 1.C#/ASP.NET MVC微信公众号接口开发之从零开发(一) 接入微信公众平台 2.C#/ASP.NET MVC微信公众号接口开发之从零开发( ...
 - Spring AOP实现方式三之自动扫描注入【附源码】
		
注解AOP实现 这里唯一不同的就是application 里面 不需要配置每个bean都需要配置了,直接自动扫描 注册,主要知识点是怎么通过配置文件得到bean, 注意类前面的@注解. 源码结构: ...
 - leaflet-webpack 入门开发系列三地图分屏对比(附源码下载)
		
前言 leaflet-webpack 入门开发系列环境知识点了解: node 安装包下载webpack 打包管理工具需要依赖 node 环境,所以 node 安装包必须安装,上面链接是官网下载地址 w ...
 - C#进阶系列——一步一步封装自己的HtmlHelper组件:BootstrapHelper(三:附源码)
		
前言:之前的两篇封装了一些基础的表单组件,这篇继续来封装几个基于bootstrap的其他组件.和上篇不同的是,这篇的有几个组件需要某些js文件的支持. 本文原创地址:http://www.cnblog ...
 - spring学习笔记2---MVC处理器映射(handlerMapping)三种方式(附源码)
		
一.根据Beanname访问controller: 在springmmvc-servlet.xml的配置handlermapping中加入beanname,通过该beanname找到对应的contro ...
 - 深入理解NIO(三)—— NIO原理及部分源码的解析
		
深入理解NIO(三)—— NIO原理及部分源码的解析 欢迎回到淦™的源码看爆系列 在看完前面两个系列之后,相信大家对NIO也有了一定的理解,接下来我们就来深入源码去解读它,我这里的是OpenJDK-8 ...
 - Android 自定义View及其在布局文件中的使用示例(三):结合Android 4.4.2_r1源码分析onMeasure过程
		
转载请注明出处 http://www.cnblogs.com/crashmaker/p/3549365.html From crash_coder linguowu linguowu0622@gami ...
 - Managed DirectX中的DirectShow应用(简单Demo及源码)
		
阅读目录 介绍 准备工作 环境搭建 简单Demo 显示效果 其他 Demo下载 介绍 DirectX是Microsoft开发的基于Windows平台的一组API,它是为高速的实时动画渲染.交互式音乐和 ...
 - 微信公众账号开发教程(三) 实例入门:机器人(附源码) ——转自http://www.cnblogs.com/yank/p/3409308.html
		
一.功能介绍 通过微信公众平台实现在线客服机器人功能.主要的功能包括:简单对话.查询天气等服务. 这里只是提供比较简单的功能,重在通过此实例来说明公众平台的具体研发过程.只是一个简单DEMO,如果需要 ...
 
随机推荐
- React阶段开发总结
			
这次独立编写了React页面主要是数据切换.点击不同的按钮,Ajax请求不同的后台数据.数据驱动表格内容的显示.使用React组件开发. 开发中获得下面的心得: 1.后台给的地址早一点添加路由(写好数 ...
 - GCC4.8.2升级安装
			
一.查看本机GCC版本: 使用gcc -v 查看本机版本信息,我的gcc版本为: gcc 版本 4.4.6 20120305 (Red Hat 4.4.6-4) (GCC) 二.升级或安装编译器: 1 ...
 - 怎么计算Oracle的表一条记录占用空间的大小
			
如何计算Oracle的表一条记录占用空间的大小? 如何计算Oracle的表记录占用空间的大小? 是把所有字段的大小都加起来吗?varchar(256),char,number算几个字节? ------ ...
 - UI基础
			
知识点一:OC不允许直接修改对象的结构体属性的成员,允许直接修改对象的结构体属性 示例代码: CGRect tempFrame = self.frame; tempFrame.origin.x += ...
 - SQLServer数据库中创建临时表
			
IF object_id('tempdb..#jimmy') is not NULL BEGIN DROP TABLE #jimmy; END IF object_id('tempdb..#jimmy ...
 - Samba网络配置
			
Samba网络配置 操作环境 ubuntu14.04 1. 更新Linux源列表 sudo apt-get update 2. 安装Samba服务 sudo apt-get install samba ...
 - WinForm程序全局捕捉异常处理办法
			
如何全局捕捉Winform程序异常呢,当然是从程序启动入口的Program类下的Main()方法定义了,下面看下这个类怎么写的吧 static class Program { static strin ...
 - office中通过宏添加快捷键
			
把“Microsoft 公式 3.0”作为一个按钮放在 2013中的快速访问工具栏的方法 在使用office办公软件的过程中,因为有的人还在使用office2003版本,所以在使用高版本的office ...
 - 【DP】组合数字
			
Password Attacker 题意就是给 M 个关键字,组合成 N 字符长度的结果,每一个关键字都必须在 N 位的字符中出现,有多少种可能结果. 范围 1 ≤ M ≤ N ≤ 100. 举例假设 ...
 - (01背包 排序+特判)饭卡(hdu 2546)
			
http://acm.hdu.edu.cn/showproblem.php?pid=2546 Problem Description 电子科大本部食堂的饭卡有一种很诡异的设计,即在购买之前判断余额 ...