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函數就行了。下面我們將詳細說明如何實現的。

三維世界少不了三元組這個數據結構,我們先在這里定義出來,省得下文啰嗦。

TriTuple
 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軸的投影就行了。

GoFront
 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軸上的投影長度算出來。

GoLeft
 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向上移動

GoUp
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

那么代碼也就出來了。

Turn
 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坐標平移回去就好了。

夠麻煩的,因為平移了一次,又按新坐標算了一次。 

Stagger
 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和中點投影模型

至於左右旋轉和前后翻轉,本來就是組合着處理的,無需再做什么了。

完整工程源碼在此:bitzhuwei.FeedingFrenzy.firstPersonPerspective.zip


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM