原帖地址:http://ogldev.atspace.co.uk/www/tutorial15/tutorial15.html
本篇教程中,我们将实现鼠标控制摄像机的方位。摄像机可以有多个自由度(所谓自由度,是指沿着某个方向的移动或旋转等操作,通常一个自由度用一个参数表示),不同的应用所需要的摄像机模式不同,所需要的自由度也不同,比如第一人称射击游戏和普通RPG游戏摄像机模式就不同。在第一人称射击游戏摄像机中,我们需要摄像机在原地转360度(沿y轴旋转360度,模拟人头的转动),另外还需要沿着观察方向,拉近,远移摄像机,或者上下倾斜摄像机,以便扩大观察视野 。我们将不会实现在上下方向旋转一圈(绕x轴或z轴旋转),这是飞行游戏中可能的摄像机模式,本篇教程中,我们只实现一个第一人称射击游戏的摄像机模式。 完成本篇教程后,我们将有一个摄像机类,该类中实现键盘和鼠标控制摄像机的操作。
下图是世界战争2中的防空机枪,本篇教程中我们将实现类似该机枪操作的摄像机。
机枪有2个控制轴:
- 它能沿着向量 (0,1,0)旋转360度,角度称作水平角,向量称作垂直轴。
- 它能够沿着平行地面的向量上下倾斜,但倾斜的角度是有限的,不可能转一圈,这个角度称作垂直角,这个向量称作水平轴。注意:垂直轴是向量 (0,1,0),而水平轴则是个变化的向量,它总是垂直于机枪瞄准的方向向量同时平行于地面(xz平面)。我们要正确理解这些概念,以便得到正确的数学公式。
我们的实现是通过鼠标左右移动来改变水平角,上下移动改变垂直角,得到这两个角度后,我们再来计算观察向量和up向量。
通过水平角转动观察向量是很直观的,此时,观察向量的z分量是水平向量的sine值(如下图所示),x分量是水平向量的cosin值,此时y分量可以看作0。
通过垂直角来转动观察向量比较复杂,因为水平轴会随着摄像机转动而变化。当沿着水平角转动时,我们可以通过垂直轴观察向量的叉积计算得到水平轴。但当机枪上下移动时候,要得到水平轴,需要一定技巧。幸运的,我们可以通过一个方便的数学工具四元素,来实现这个功能。
四元素由爱尔兰的数学家Willilam Rowan Hamilto在1843年首先提出,他的想法基于严密的数学逻辑。四元素Q定义如下:
这儿i,j,k是复数,且有下面的等式成立:
我们常用4维向量 (x, y, z, w)来表示一个四元素,四元素 Q 的共轭四元素为:
归一化四元素和归一化向量的方法是一样的,下面我们将通过四元素来学习一个向量如何绕一个任意向量旋转。我们仅给出公式,关于原理可参考相关数学教程或到网上搜索。
下面的公式描述了一个向量V绕四元素Q旋转角度a:
旋转四元素Q定义如下:
旋转后的向量W可以简单表示为 (W.x,W.y,W.z),W计算中,我们首先用Q乘以V,这是四元素乘以向量,结果是四元素,接下来用这个四元素再乘以四元素Q的共轭四元素,结果是一个向量,这两种乘法是不一样的,在math_3d.cpp中实现了这些不同类型的乘法。
当用户在屏幕上移动鼠标时候,我们需要实时更新水平角和垂直角。我们通过观察向量来初始化垂直角和水平角,上面的圆以及坐标系图是XZ平面图:
假定观察向量为 (x,z),水平角alpha, (y分量只和垂直角有关),圆的半径是1,则可以看出alpha角的sine值等于z,通过反sine函数就可以求出alpha角,由于z范围是[-1,1],所以alpha角的值范围是-90度到90度,但水平角范围是360度,且四元素按顺时针旋转,这意味着我们通过四元素旋转90度,当z轴是-1时候,再转alpha角会从-90跳到90,这就会出现一些歧义。最简单的解决办法,就是我们只计算z范围[0,1],这时候得到正的alpha角0-90度,然后绑定结果在向量位于的1/4圆,例如当观察向量是(0,1)的时候,asin得到90度,360度减90度等于270度,则该向量的水平角是270度。
垂直角的计算更加简单,我们首先限定它的范围是-90(看的直上)度到+90度(看的直下),这意味着我们只需要计算观察向量y分量的asin值,然后取反,例如Y=1,则为90度,取反-90度,此时看向上,Y=-1,为-90度,取反,90度,此时看向下。
主要代码:
camera.cpp
Camera::Camera(int WindowWidth, int WindowHeight, const Vector3f& Pos, const Vector3f& Target, const Vector3f& Up)
{
m_windowWidth = WindowWidth;
m_windowHeight = WindowHeight;
m_pos = Pos;
m_target = Target;
m_target.Normalize();
m_up = Up;
m_up.Normalize();
Init();
}
摄像机类的构造函数首先会得到窗口(或屏幕,这时候为游戏全屏模式,但我的笔记本设置该模式失败,但台式机成功)的大小,为了移动鼠标到屏幕中心,我们需要这两个参数,另外,Init()函数设定摄像机的一些内部属性。
void Camera::Init()
{
Vector3f HTarget(m_target.x, 0.0, m_target.z);
HTarget.Normalize();
if (HTarget.z >= 0.0f)
{
if (HTarget.x >= 0.0f)
{
m_AngleH = 360.0f - ToDegree(asin(HTarget.z));
}
else
{
m_AngleH = 180.0f + ToDegree(asin(HTarget.z));
}
}
else
{
if (HTarget.x >= 0.0f)
{
m_AngleH = ToDegree(asin(-HTarget.z));
}
else
{
m_AngleH = 90.0f + ToDegree(asin(-HTarget.z));
}
}
m_AngleV = -ToDegree(asin(m_target.y));
m_OnUpperEdge = false;
m_OnLowerEdge = false;
m_OnLeftEdge = false;
m_OnRightEdge = false;
m_mousePos.x = m_windowWidth / 2;
m_mousePos.y = m_windowHeight / 2;
glutWarpPointer(m_mousePos.x, m_mousePos.y);
}
在Init函数中,我们会计算水平角,另外我们还会创建一个targe向量称作HTarget(水平target向量),该向量是原始target向量在XZ平面上的投影,对该向量,我们会做归一化操作,接下来,我们会检测target向量在哪一个1/4圆里,并用它的z分量计算得到水平角。接下来,我们计算垂直角,这更简单,只需要一行代码。
摄像机还有四个参数用来表示鼠标是否位于屏幕(或窗口)范围内,当超出屏幕范围时,我们会自动翻转方向,这将允许我们360度旋转。我们把这四个参数初始化为false,是因为鼠标初始位置在屏幕中心,新的glut函数glutWarpPointer用来移动鼠标。
void Camera::OnMouse(int x, int y)
{
const int DeltaX = x - m_mousePos.x;
const int DeltaY = y - m_mousePos.y;
m_mousePos.x = x;
m_mousePos.y = y;
m_AngleH += (float)DeltaX / 20.0f;
m_AngleV += (float)DeltaY / 20.0f;
if (DeltaX == 0) {
if (x <= MARGIN) {
m_OnLeftEdge = true;
}
else if (x >= (m_windowWidth - MARGIN)) {
m_OnRightEdge = true;
}
}
else {
m_OnLeftEdge = false;
m_OnRightEdge = false;
}
if (DeltaY == 0) {
if (y <= MARGIN) {
m_OnUpperEdge = true;
}
else if (y >= (m_windowHeight - MARGIN)) {
m_OnLowerEdge = true;
}
}
else {
m_OnUpperEdge = false;
m_OnLowerEdge = false;
}
Update();
}
这个函数用来通知摄像机鼠标已经移动,传入的参数x,y是鼠标在屏幕上的位置,delta是当前鼠标位置和上次鼠标位置的差,我们分别计算x和y方向的delta,计算完后,我们会把当前的鼠标位置保存在m_mousePos变量中,以便下次调用。接下来,我们用delta值更新当前摄像机的水平角和垂直角, 注意我们增加了一个缩放系数,可能在不同的计算机中,需要不同的系数,可以自己调整这个值。在后面的教程中,我们将会用帧率做为缩放系数,这样就能保证在不同计算机上应用程序执行效果一致。
接下来通过鼠标位置更新 m_On*Edge变量,这儿还有一个margin变量,缺省值是10,表示鼠标靠近边10个像素时,触发靠近边界的事件。最后,我们调用Update函数基于新的水平角和垂直角重新计算target向量和up向量.
void Camera::OnRender()
{
bool ShouldUpdate = false;
if (m_OnLeftEdge) {
m_AngleH -= 0.1f;
ShouldUpdate = true;
}
else if (m_OnRightEdge) {
m_AngleH += 0.1f;
ShouldUpdate = true;
}
if (m_OnUpperEdge) {
if (m_AngleV > -90.0f) {
m_AngleV -= 0.1f;
ShouldUpdate = true;
}
}
else if (m_OnLowerEdge) {
if (m_AngleV < 90.0f) {
m_AngleV += 0.1f;
ShouldUpdate = true;
}
}
if (ShouldUpdate) {
Update();
}
}
这个摄像机渲染函数在main函数渲染循环中调用。当鼠标移动到窗口边界时,这时候将不会有鼠标事件,但我们仍需要调用这个函数进行检测,直到鼠标离开窗口边界。鼠标停留窗口边界时候,Render函数会根据相应的边界条件来更新相应的水平角和垂直角。当鼠标离开窗口范围时候,我们会检测到相应事件,并清除这些边界条件标志变量。
(camera.cpp)
void Camera::Update()
{
const Vector3f Vaxis(0.0f, 1.0f, 0.0f);
// Rotate the view vector by the horizontal angle around the vertical axis
Vector3f View(1.0f, 0.0f, 0.0f);
View.Rotate(m_AngleH, Vaxis);
View.Normalize();
// Rotate the view vector by the vertical angle around the horizontal axis
Vector3f Haxis = Vaxis.Cross(View);
Haxis.Normalize();
View.Rotate(m_AngleV, Haxis);
View.Normalize();
m_target = View;
m_target.Normalize();
m_up = m_target.Cross(Haxis);
m_up.Normalize();
}
这个函数根据水平角和垂直角更新target和up向量。view向量初始化时候,平行于地面,这意味着垂直角为0,看向右边(x正轴方向),这意味着水平角为0,我们同时设定垂直轴指向上方,通过水平角来旋转view向量,得到结果总是指向要观察的物体,但摄像机的高度不一定正确,比如位于xz平面上,等于贴到了地面。我们用垂直轴和view向量做一个叉积,得到一个位于XZ平面上的向量,且该向量垂直于view向量和垂直轴位于的平面,这是我们新的水平轴, 接着我们会用这个新的水平轴旋转垂直角,结果就是最终的target向量。最后我们还要修正up向量,就是通过target向量和新的水平轴叉积得到新的up向量并归一化,这个修正是必要的,当摄像机抬头时候,up向量必须倾斜,就像我们抬头看天空时候,我们的头必须倾斜,此时up向量并不是y轴的方向,而是和垂直于我们的头。当摄像机上下倾斜时候,up向量也会不断变化。
(tutorial15.cpp)
//设定分辨率,颜色格式以及屏幕刷新率
glutGameModeString("1920x1200:32@75");
//进入游戏模式,即使glutGameModeString的设置生效
glutEnterGameMode();
这两个函数使我们的摄像机运行在高分率的游戏模式,也就是全屏模式,这使得360度旋转变得更容易观察。注意:分辨率和像素格式都是通过这个字符串定义的,32表示是32位颜色格式,即每个像素占32位,但这个全屏设置,不一定生效,有可能会失败,此时我们可以通过函数glutGameModeGet(GLenum mode)进行查询。
tutorial15.cpp
pGameCamera = new Camera(WINDOW_WIDTH, WINDOW_HEIGHT);
动态创建摄像机,不能用静态变量,因为摄像机类初始化时候调用了glutWarpPointer,但如果glut没有初始化,则该函数会失败。
glutPassiveMotionFunc(PassiveMouseCB);
glutKeyboardFunc(KeyboardCB);
这2个新的glut回调函数,一个用来处理鼠标事件,一个用来处理普通键盘事件(相对于特殊键),Passive移动意味着鼠标只是移动,并没有任何键(左右中)按下。
static void KeyboardCB(unsigned char Key, int x, int y)
{
switch (Key) {
case 'q':
exit(0);
}
}
static void PassiveMouseCB(int x, int y)
{
pGameCamera->OnMouse(x, y);
}
q键退出游戏,鼠标回调函数仅仅简单把鼠标参数传给摄像机。
static void RenderSceneCB()
{
pGameCamera->OnRender();
在渲染函数中调用摄像机渲染函数,使得没有鼠标移动时候或者鼠标在屏幕边缘时候,也可以移动摄像机。
在我的笔记本上,进入游戏模式失败,所以是一个1920*1200的窗口模式,但点击窗口用alt+prt截屏,却截的下面的界面: