OpenGL入门暨用C#做个3D吞食鱼(一)第一人称视角的实现
OpenGL入门暨用C#做个3D吞食鱼(一)第一人称视角的实现
废话少说先上图:
图表 1第一人称视角效果图
源代码在文末。
为了学OpenGL,尝试各种代码示例是不错的选择。但是我就经常因为视角不合适又不能动而看不到画出来的东西!那么做一个类似CS里面那样第一人称视角的走动功能(前后左右走,上下走,左右旋转和上下旋转)就是大势所趋啊。
1. 开发环境
我喜欢C#的智能提示、控件和各种自动生成的代码,最适合学OpenGL、编译原理之类的新东西用了。于是学OpenGL也找了C#版的。在codeproject上有这个SharpGL的介绍(在这里),超好用的OpenGL封装,看一眼就知道是我的菜,我想要的都有了。用法太简单了,不再具体介绍。
2. 目标
具体的说,我们要实现下面的功能:按键盘的WSAD或上下左右键,可以实现前后左右移动;按Q键,上升(CS里爬楼梯上楼);按E键,下降;按住鼠标左键左右移动,镜头跟着左右旋转;按住鼠标左键上下移动,镜头跟着上下翻转。这是基本功能,具体到工程实现时还会陆续添加一些必要的辅助功能,到时候再说。
3. 前提条件
问:我们的线索是什么呢?
答:OpenGL有一个gluLookAt函数,是用来设定眼睛、目标和"上方向"的。简单来说,它的作用就是告诉OpenGL,我们的眼睛在哪儿,眼睛往哪个点看,我们站立的时候从脚到头是哪个方向。想象一下,你站立在一个地方,眼睛注意看某处一个点,这时,整个映入眼帘的画面就是OpenGL要呈现给你的画面了。(额,貌似不需要想象,不是瞎子就行了)
在SharpGL这个类库里,当然有与之对应的OpenGL.LookAt函数。所以我们只需要弄清楚在移动、旋转的时候如何计算眼睛、目标和上方向的三维坐标就行了。
好消息是,上方向(用向量表示)永远设定为(0,1,0)就行了,不需要改变。在SharpGL里我们默认Y轴为上方,X轴和Z轴组成了地面。在这个吞食鱼游戏里,游戏地图是从Y轴正半轴向上的一小块地方。这就是世界环境的设定了。
如下图表 2SharpGL.LookAt模型所示,红色(向右的)箭头为X轴,绿色(向上的)箭头为Y轴,蓝色(斜向下的)为Z轴。黑色箭头的头部表示看向的目标(center),箭尾为眼睛所在的位置。为方便起见,我们在后文把黑色箭头都称为"视线"。
图表 2SharpGL.LookAt模型
PS:虽然视线不是无穷的指向远处的,但这不妨看到碍箭头后面的部分。设定看到的范围的函数是gluPerspective(SharpGL里对应OpenGL.Perspective方法),这不是本文重点,请自行上百度Google之。
毫无疑问,第一人称的功能,无非就是设定eye和center的值,然后调用一下OpenGL.LookAt函数就行了。下面我们将详细说明如何实现的。
三维世界少不了三元组这个数据结构,我们先在这里定义出来,省得下文啰嗦。
1 public struct TriTuple 2 { 3 public double X; 4 public double Y; 5 public double Z; 6 public TriTuple(double x, double y, double z) 7 { 8 this.X = x; 9 this.Y = y; 10 this.Z = z; 11 } 12 13 public override string ToString() 14 { 15 return string.Format("{0:f2},{1:f2},{2:f2}", X, Y, Z); 16 } 17 18 public void Add(TriTuple diff) 19 { 20 this.X = this.X + diff.X; 21 this.Y = this.Y + diff.Y; 22 this.Z = this.Z + diff.Z; 23 } 24 25 /// <summary> 26 /// make this trituple's length to 1. 27 /// </summary> 28 public void Normalize() 29 { 30 var length = Math.Sqrt(this.X * this.X + this.Y * this.Y + this.Z * this.Z); 31 this.X = this.X / length; 32 this.Y = this.Y / length; 33 this.Z = this.Z / length; 34 } 35 }
下面我们按照从易到难的顺序依次解决第一人称视角移动和旋转的问题。
4. 前后移动
如下图表 3向前移动所示,从黑色的视线移动到橙色的视线就是向前移动。移动的长度(Step)是我们自行设定的,想让它走多块就走多块。
图表 3向前移动
可见新的eye和center其Y轴坐标是不变的。我们只需把Step在X、Z轴的投影分别加给center和eye在X、Z轴的投影就行了。
1 public void GoFront(double step) 2 { 3 if (openGLControl == null) return; 4 5 var diff = new TriTuple(this.center.X - this.eye.X, 6 0, 7 this.center.Z - this.eye.Z); 8 var length2 = diff.X * diff.X + 0 + diff.Z * diff.Z; 9 var radio = Math.Sqrt(step * step / length2); 10 var stepDiff = new TriTuple(diff.X * radio, 0, diff.Z * radio); 11 12 this.eye.Add(stepDiff); 13 this.center.Add(stepDiff); 14 }
向后移动与之雷同,就不多说了。
5. 左右移动
如下图表 4向左移动所示,从黑色的视线移动到橙色的视线就是向左移动。两个视线的在XZ平面上的投影是一个长方形。移动的距离(Step)是我们可以自行指定的。
图表 4向左移动
可见新视线的center和eye在Y轴上的坐标也是不变的。这里的关键依然是把Step在X、Z轴上的投影长度算出来。
1 public void GoLeft(double step) 2 { 3 if (openGLControl == null) return; 4 5 var diff = new TriTuple(this.center.X - this.eye.X, 6 0, 7 this.center.Z - this.eye.Z); 8 var length2 = diff.X * diff.X + 0 + diff.Z * diff.Z; 9 var radio = Math.Sqrt(step * step / length2); 10 var stepDiff = new TriTuple(diff.Z * radio, 0, -diff.X * radio); 11 12 this.eye.Add(stepDiff); 13 this.center.Add(stepDiff); 14 }
向右移动与之雷同,不说了。
6. 上下移动
如下图表 5向上移动所示,从黑色的视线移动到橙色的视线就是向上移动。这次最简单,只要把center和eye的Y轴上的坐标增加Step就行了。
图表 5向上移动
1 public void GoUp(double step) 2 { 3 if (openGLControl == null) return; 4 5 this.eye.Y = this.eye.Y + step; 6 this.center.Y = this.center.Y + step; 7 }
简单吧,向下移动就不说啦。
7. 左右旋转
如下图表 6向左旋转所示,从黑色视线变换到橙色视线就是向左旋转。一般我们面对的情况是,知道旋转多少度(图中θ值),求旋转后的视线。
图表 6向左旋转
首先,变换前后视线的center和eye在Y轴上的坐标依旧是不变的。然后,eye的坐标也是不变的。那么就剩下center在X轴和Z轴的坐标了。你会发现,新的center的X、Z轴的坐标值与他们在XZ平面上投影的坐标值相同(因为是投影的嘛……)。所以实际上我们的问题就变为了在平面上的问题。
那么在平面上如何求一个向量转过角度之后的向量呢?这是初等数学三角函数求角度之和的三角函数问题啦。
图表 7三角函数求和
这里我们先以eye为原点看问题,这个问题解决了,到时候再把eye的坐标加回去就行了。
我们知道三角函数有公式:
Sin(θ+φ) = Sin(θ) * Cos(φ) + Cos(θ) * Sin(φ)
Cos(θ+φ) = Cos(θ) * Cos(φ) - Sin(θ) * Sin(φ)
这是单位向量,对于上图图表 6三角函数求和所示的黑色视线转换到橙色视线(红色为X轴,蓝色为Z轴,还记得上文这个设定吧?),就是
orangeCenter.Z = Sin(θ) * blackCenter.X + Cos(θ) * blackCenter.Z
orangeCenter.X = Cos(θ) * blackCenter.X - Sin(θ) * blackCenter.Z
好了,现在把eye的坐标加回去,就是
orangeCenter.Z = Sin(θ) * (blackCenter.X - eye.X) + Cos(θ) * (blackCenter.Z - eye.Z) + eye.Z
orangeCenter.X = Cos(θ) * (blackCenter.X - eye.X) - Sin(θ) * (blackCenter.Z - eye.Z) + eye.Z
那么代码也就出来了。
1 /// <summary> 2 /// 正数向右转,负数向左转 3 /// </summary> 4 /// <param name="turnAngle"></param> 5 public void Turn(double turnAngle) 6 { 7 if (openGLControl == null) return; 8 9 var diff = new TriTuple(this.center.X - this.eye.X, 10 0, 11 this.center.Z - this.eye.Z); 12 var cos = Math.Cos(turnAngle); 13 var sin = Math.Sin(turnAngle); 14 var centerDiff = new TriTuple(diff.X * cos - diff.Z * sin, 15 0, 16 diff.X * sin + diff.Z * cos); 17 this.center.X = this.eye.X + centerDiff.X; 18 this.center.Z = this.eye.Z + centerDiff.Z; 19 }
因为三角函数在-∞到+∞的角度范围内都成立,所以左转右转只不过是角度的正负不同,其算法是一模一样的。
8. 前后翻转
前后翻转,其实就是前仰后合。这可以说是最难解的问题了。但是在本数学奇葩的努力下,顺利解决了。
如下图表 8向后翻转所示,从黑色视线到橙色视线就是向后翻转了(前仰后合的后合)。首先,eye前后的坐标不发生变化,而center的三个坐标值都要重新计算了。
图表 8向后翻转
我们看,黑色视线和橙色视线构成的平面与Y轴平行。根据上面计算左右旋转的经验,平移一些东西是不会影响最终得到计算结果的。那么我们可以保持eye的Y轴上的坐标不变,把eye平移到Y轴上去,成为图表 9在Y轴上向上翻转所示的样子。
图表 9在Y轴上向上翻转
这时,我们把图中紫色直线视作新的坐标轴W,它与Y轴一起组成新的坐标系。我们像上面计算左右旋转那样,利用三角函数计算出center在这个坐标系里的坐标CW。这个CW的Y轴上的值就是原来的center的Y轴上的值,而其W轴上的值CW则是沿W轴方向的向量,其在X轴和Z轴的分量就是X轴和Z轴的坐标。最后,把X、Z坐标平移回去就好了。
够麻烦的,因为平移了一次,又按新坐标算了一次。
1 private void Stagger(double staggerAngle) 2 { 3 if (openGLControl == null) return; 4 5 var ceX = this.center.X - this.eye.X; 6 var ceZ = this.center.Z - this.eye.Z; 7 var distanceCE = Math.Sqrt(ceX * ceX + ceZ * ceZ); 8 var diff = new TriTuple(distanceCE, this.center.Y - this.eye.Y, 0); 9 var cos = Math.Cos(staggerAngle); 10 var sin = Math.Sin(staggerAngle); 11 var centerDiff = new TriTuple(diff.X * cos - diff.Y * sin, 12 diff.X * sin + diff.Y * cos, 13 0); 14 this.center.Y = this.eye.Y + centerDiff.Y; 15 var percent = centerDiff.X / distanceCE; 16 this.center.X = this.eye.X + percent * ceX; 17 this.center.Z = this.eye.Z + percent * ceZ; 18 }
9. 高级目标
基本的前后左右上下移动和左右旋转前后翻转都实现了。虽然直接用键盘的KeyDown事件和鼠标的MouseDown、MouseMove、MouseUp事件就可以实现第一人称视角的移动了,但是还有不方便的地方:按住了前进键,再按向左移动键就不管用。就是说不能向左前方移动。
所以我们的高级目标就是要解决"组合移动"的问题。
先说前后左右上下移动。设计思路是:按下哪个方向键,就增加这个方向的标记,弹起哪个方向键,就去掉这个方向的标记;新增一个线程,每隔一个时间间隔(游戏世界里的Tick即可,一般游戏程序里都要有一个Tick的时间间隔开控制游戏速度)就根据当前标记的所有方向来计算新的视线,并Invoke窗口线程调用OpenGL.LookAt更新图像。
如下图表 10效果图中的center和中点投影所示,橙色矩形围起来的那个小箱子,就是center的位置,黑色矩形围起来的那个稍微大一点的箱子,是center和eye的中点在平面C上的投影(C是这么定义的:与Y轴垂直,且经过center点,如图表 11center和中点投影模型所示)。
PS:这个两个箱子大小其实是一样的,这里我们可以看到透视实现了近大远小的作用。
图表 10效果图中的center和中点投影
图表 11center和中点投影模型
至于左右旋转和前后翻转,本来就是组合着处理的,无需再做什么了。
微信扫码,自愿捐赠。天涯同道,共谱新篇。
微信捐赠不显示捐赠者个人信息,如需要,请注明联系方式。 |