遊戲數學:三角函數基礎 – 正切、三角形、與砲彈

原始檔案與未來教學更新資訊可於Patreon取得
您可於Twitter上追蹤我

本文屬於遊戲數學系列文

Here is the original English post.
本文之英文原文在此

前備教學

大綱

上個教學中,我們認識到了兩個基礎三角函數:正弦與餘弦。這次我們要來學習第三個基礎三角函數:正切(tangent)。這三者為三角函數的根基,能夠用來解決遊戲開發過程中會遇到的各種問題。

你將可透過本教學學會:

  • 正切函數的幾何意義
  • 正弦函數、餘弦函數、與正切函數之間的關係
  • 如何用正切函數做出圓滑的入場與退場效果
  • 三角形的邊與三角函數之間的關係
  • 給定初速與仰角,如何模擬砲彈路徑
  • 在發射砲彈前,如何預測砲彈路徑
  • 給定水平距離和仰角,如何定位砲彈的目標

正切函數的幾何意義

我們先來看看上個教學中的單位圓,圓上有一點P,X軸(+X方向)與連接P與原點的線段之間的夾角為\theta

從上個教學中已經得知P的座標(X, Y)= (\cos\theta, \sin\theta),我們現在來看看一個新的三角函數:正切(\tan\theta – 念作”tangent of theta”)。它是連接P與原點的線段的斜率

一條線的斜率是其垂直變動與水平變動之間的比率。舉例來說,我們來看看以下線段:

從點A移動到點B,往+X方向移動3單位,並往+Y方向移動2單位,所以此線段的斜率是\frac{2}{3}

至於如以下這個”走下坡”的線段:

其斜率則為\frac{-2}{3},因為垂直移動與水平移動的比率為負數,斜率便為負數。

現在,回到之前的單位圓圖:

我們看到從原點往P直線移動,會水平移動\cos\theta距離並且垂直移動\sin\theta距離,所以連接P與原點的線段斜率為\frac{\sin\theta}{\cos\theta}。於是\tan\theta = \frac{\sin\theta}{\cos\theta}.

但那只是個數學式子而已,我們來看看\tan\theta與單位圓的關係到底是如何。首先,於P畫個單位圓的切線,也就是一條通過P且與連接P與圓點的線段垂直的直線。

現在只看該切線介於P與X軸的部分,並且標記一些點:

\angle ABP\angle APD為直角,然後 \angle PAB = \angle PAD = \theta,並且令\overline{AB}表示連接兩點AB的線段長度。

接下來,將上圖拆成以下兩個直角三角形:

三角形的內角和為180^\circ,而且兩個三角形分別已經有兩個角為\theta90^\circ,沒有被標記的角(\angle APB\angle ADP)便是相同的: 180^\circ - \theta - 90^\circ

當兩個三角形各自的三個角度組合相同,他們便互相為相似三角形。意即若將其中一個三角形等比例縮放、旋轉、與翻轉,就有辦法變成跟另外一個三角形一模一樣。

兩個三角形互相為相似三角形時,其中一個三角形的任意兩邊長度比例將會等同於另外一個三角形的對應兩邊長度比例,於是:

     \begin{flalign*} \frac{\overline{BP}}{\overline{AB}} = \frac{\overline{DP}}{\overline{AP}} \end{flalign*}

已知P的座標為(\cos\theta, \sin\theta),於是\overline{AB} = \cos\theta\overline{BP} = \sin\theta。然後 \overline{AP}為單位圓的半徑,於是\overline{AP}=1,便可將上面的等式轉換成:

     \begin{flalign*} \frac{\sin\theta}{\cos\theta} = \frac{\overline{DP}}{1} \end{flalign*}

我們也知道\tan\theta = \frac{\sin\theta}{\cos\theta},於是等式最後會變成:

     \begin{flalign*} \tan\theta = \overline{DP} \end{flalign*}

我們找到\tan\theta的幾何意義了!

\tan\theta的絕對值等同於通過P、介於P與X軸之間的切線線段長度。注意到我使用絕對值這個字眼,因為\tan\theta的正負號是根據\sin\theta\cos\theta的正負號來決定。以下插圖將等同於tan\theta絕對值的切線線段以藍色標記:

正切曲線

我們已經在上個教學中看過\sin\theta\cos\theta針對\theta的作圖,兩者重疊在一起看起來如下:

現在再把\tan\theta重疊上去:

請留意\tan\theta不像\sin\theta\cos\theta,其值沒有受限於[-1, 1]範圍內。因為\tan = \frac{\sin\theta}{\cos\theta},當\cos\theta接近0的時候,\tan\theta的絕對值則會趨近於無限大。同時也請留意\tan\theta的週期為\pi,不同於\sin\theta\cos\theta的週期2\pi

另一值得關注的一點,是此三個函數之間正負號的相互關係。因為\tan\theta = \frac{\sin\theta}{\cos\theta},當\sin\theta\cos\theta正負號相同的時候\tan\theta為正值,反之則為負值。

現在,來試試看把物件的X座標設成正切函數值:

float tan = Mathf.Tan(Rate * Time.time);
obj.transform.position = Vector3(tan, 0.0f, 0.0f);

物件從-X方向高速入場、減速、然後又加速往+X方向退場。

我們可以利用這個特性來製作如下的流星效果:

float tan = Mathf.Tan(Rate * Time.time);
obj.transform.position = center + moveDirection * tan;

加減速的效果似乎沒有很明顯,那來試著使用\tan\theta的三次方將加減速效果放大:

float tan = Mathf.Tan(Rate * Time.time);
float tan3 = tan * tan * tan;
obj.transform.position = center + moveDirection * tan3;

三角函數、角度、與三角形

瞭解了三個基礎三角函數與單位圓的關係之後,,讓我們來探討它們與直角三角形的關係。畢竟這些函數被稱作三角函數,與三角形是關係密切的。

首先,先介紹一些術語,以下是個一角大小為\theta的直角三角形:

兩端為角\theta與直角的邊稱作鄰邊(adjacent side),因為緊鄰\theta。另一與直角相鄰的邊稱作對邊(opposite side),因為位於\theta對面。剩下最長且位於直角對面的一邊則為斜邊(hypotenuse)。

以下為三個基礎三角函數與這些邊長的關係:

一時無法記住這些關係的話,可以參考這個視覺記憶法。將\sin\theta\cos\theta、和\tan\theta三者個首字母用草寫如此書寫在三角形的周圍(請原諒我難看的手寫):

書寫一個字母的時候,其對應的函數即為”第一個擦過的邊分之第二個擦過的邊“:

再來看一次算式:

不管此直角三角形的大小為何,這些等式恆成立,因為三角形兩邊之比例與各邊的絕對長度是無關的。

若將直角三角形等比例縮放,大小調整為斜邊長度剛好是1,便可把這個直角三角形放入單位圓中,並且與圓周上的一點P=(X, Y)一起比較:

可以發現上述三角函數等式與P的座標(X, Y)=(\cos\theta, \sin\theta)是相容的:

     \begin{alignat*} \ \sin\theta &= \frac{Y}{1} &&= Y \:\:\:\: \ \cos\theta &= \frac{X}{1} &&= X \:\:\:\: \ \tan\theta &= \frac{Y}{X} &&= \frac{\sin\theta}{\cos\theta} \end{alignat*}

知道了三角函數與直角三角形邊長之間的關係之後,若碰到一角大小為\theta的直角三角形,且知道其中一邊的邊長,便可用三角函數表達另外兩邊的長度。

讓我們用J代表鄰邊(adjacent side)長度,S代表對邊(opposite side)長度,H代表斜邊(hypotenuse)長度:

若已知斜邊長度H,則鄰邊長度J = H \cos\theta,對邊長度S = H \sin\theta

若已知鄰邊長度J,則對邊長度S = J \tan\theta,斜邊長度H = \frac{J}{\cos\theta}

若已知對邊長度S,則鄰邊長度J = \frac{S}{\tan\theta},對邊長度H = \frac{S}{\sin\theta}:

模擬與預測砲彈路徑

終於到實戰範例的時候了!讓我們來看看,當給定砲彈發射初始速率、發射水平角、與發射仰角,要如何模擬砲彈的行徑,甚至是如何在發射前就預測好完整路徑。

在這之前,先快速地把動力學的專有名詞複習一下。物件在空間中的定位稱為位置(position),物件的位置隨時間之改變率稱為速度(velocity – 通常表達為每秒之位置變化),物件的速度向量長度為速率(speed),物件的速度隨時間之改變率稱為加速度(acceleration – 通常表達為每秒之速度變化)。

尤拉方法(Euler Method)是個可以用來模擬物件移動的簡易演算法:針對每個可移動的物件,隨其位置同時保存速度資料。每一次更新(update) – 又稱時間推進(time step),對物件的速度加上加速度乘以delta time (兩次更新之間的時間差)之變化量,在對物件的位置加上速度乘以delta time之變化量:

velocity += acceleration * deltaTime;
position += velocity * deltaTime;

欲模擬大約位於地面高度且人體尺寸附近的重力,我們使用一個長度固定且方向往下的向量。以下這個範例使用尤拉方法,模擬一個初始速度向右上方的2D物件行進路線:

若我們在一禎(frame)當中進行多次時間推進,而每過幾次推進便繪製一個小點,就可以畫出物件路徑的預測圖:

velocity = initialVelocity;
position = initialPosition;
for (int i = 0; i < NumIterations; ++i)
{
  velocity += acceleration * deltaTime;
  position += velocity * deltaTime;
  
  if (i % IterationsPerDot != 0)
    continue;
  
  DrawDot(position);
}

現在,令砲彈初始速率(初始速度向量的長度)為K,發射水平角大小為\theta,發射仰角為\phi(phi)。令+Z方向為大砲的前方,+X方向為大砲的右方(Unityg使用左手座標系)。

欲計算初始速度,我們需要先計算出相同方向的單位向量(長度為1之向量),並將其長度乘以K

下圖顯示+X方向的單位向量(紅色)、+Y方向的單位向量(綠色)、+Z方向的單位向量(藍色)、初始速度方向的單位向量(黑色並標記為V_i)、初始速度水平分量同向的單位向量(灰色並標記為V_h)、砲彈發射水平角\theta(介於+ZV_h之間)、與發射仰角\phi(介於V_hV_i之間):

目標是要找出V_i並且將其乘上K,我們可以先把上圖拆成兩個各自含有單位圓的圖。

首先是個水平單位圓圖,含有+X++ZV_h、和\theta

再來是個垂直單位圓圖,含有+YV_hV_i、和\phi

調整觀看第一個水平單位原圖的角度,找出能夠看到這個令人熟悉的單位圓圖:

我們之前已經看過這個算法了,V_h+Z平行的分量大小為\cos\theta,與+X平行的分量大小則為\sin\theta,於是我們便能算出V_h = (\sin\theta, 0, \cos\theta)

用同樣的視角觀看第二個垂直的單位圓圖:

同樣的算法,V_iV_h平行的分量大小為\cos\phi,與+Y平行的分量大小則為\sin\phi,於是我們可以算出V_i

     \begin{flalign*} V_i &= \cos\phi \cdot V_h + \sin\phi \cdot (0, 1, 0) \\ &= (\cos\phi \sin\theta, \: \sin\phi, \: \cos\phi \cos\theta) \end{flalign*}

V_i乘上K便是我們所要找的初始速度向量:

     \begin{flalign*} V_i &= K \cdot (\cos\phi \sin\theta, \: \sin\phi, \: \cos\phi \cos\theta) \\ &= (K \cos\phi \sin\theta, \: K \sin\phi, \: K \cos\phi \cos\theta) \end{flalign*}

其對應的程式碼如下:

Vector3 ComputeInitialVelocity()
{
  float sinTheta = Mathf.Sin(HorizontalAngle);
  float cosTheta = Mathf.Cos(HorizontalAngle);
  float sinPhi = Mathf.Sin(ElevationAngle);
  float cosPhi = Mathf.Cos(ElevationAngle);

  return
    InitialSpeed
    * new Vector3
      (
        cosPhi * sinTheta, 
        sinPhi, 
        cosPhi * cosTheta
      );
}

給定初始速率、水平角、和仰角,能夠算出砲彈發射的初始速度向量,我們便能模擬與預測砲彈路徑:

void FireCannon()
{
  velocity = ComputeInitialVelocity();
  obj.transform.position = InitialPosition;
}

void Update()
{
  float dt = Time.deltaTime;
  velocity += acceleration * dt;
  obj.transform.position += velocity * dt;
}

void DrawTrajectory()
{
  float dt = Time.fixedDeltaTime;
  Vector3 velocity = ComputeInitialVelocity();
  Vector3 position = InitialPosition;
  for (int i = 0; i < NumIterations; ++i)
  {
    velocity += acceleration * dt;
    position += velocity * dt;
    
    if (i % IterationsPerDot != 0)
      continue;
    
    DrawDot(position);
}

放置砲彈目標

我們能夠發射砲彈了,現在來放置一些目標吧。給定相對於大砲的水平距離與仰角,要如何在正確的位置放置目標?下圖是欲達到的結果,每個目標與大砲的水平(XZ平面上)距離相同,並且水平間隔相等,也就是相對於大砲的水平角間距相等。

我們已經知道水平角為\theta時,可以求得水平單位向量(\cos\theta, 0, \sin\theta),將其與給定的水平距離(標記為D)相乘,便可得到目標對於大砲的相對水平位移(D \cos\theta, 0, D \sin\theta)。將\theta等值相隔,便可得到各目標的XZ座標(下圖中的紅點):

最後一步是求得各目標的Y座標,也就是離地距離。先前已經看過,一個角大小為\theta的直角三角形,若其鄰邊長度為J,則其對邊長度為J \tan\theta

J替換為水平距離D\theta替換為仰角\phi,則可求得各目標的Y座標值為D \tan\phi

我們終於能將目標放置在正確的位置了:

float theta = -0.5f * AngleInterval * (NumTargets - 1);
float elevationTan = Mathf.Tan(ElevationAngle);

foreach (var target in targetArray)
{
  Vector3 horizontalVec = 
    HorizontalDistance 
    * new Vector3
      (
        Mathf.Sin(theta), 
        0.0f, 
        Mathf.Cos(theta)
      );

  theta += AngleInterval;

  Vector3 verticalVec = 
    HorizontalDistance 
    * elevationTan 
    * Vector3.up;

  target.transform.position = 
    Cannon.position 
    + horizontalVec 
    + verticalVec;
}

我們還沒有討論要如何偵測砲彈是否與目標碰撞,所以砲彈目前會穿過目標:

碰撞偵測(collision detection)非本教學的主題,所以我就很快地帶過球與球之間地碰撞偵測方法。要偵測兩球是否碰撞,就檢查兩球心連線長度是否小於兩球半徑和。若砲彈與目標碰撞,我們便將兩者摧毀。

Vector3 cannonballToTargetVec = 
  target.transform.position 
  - cannonball.transform.position;

float cannonballToTargetDist = cannonballToTargetVec.magnitude;

if (cannonballToTargetDist < cannonballRadius + targetRadius)
{
  DestroyCannonball();
  DestroyTarget();
}

用同樣的方法,我們可以偵測砲彈預測路徑與目標碰撞,提早中止路徑繪製。

然而,這個方法是離散(discrete)碰撞偵測,意即當砲彈行進速率夠大時,還是會穿過目標。我們可以用連續(continuous)碰撞偵測技巧來解決此問題,但這同樣也不是本教學的範疇,我會在未來的教學中再對其詳加介紹。

總結

我們在上個教學中認識了兩個基礎三角函數:正弦與餘弦。於本教學中,我們看到了第三個基礎三角函數 – 正切 – 的幾何意義。我們也學會了正弦、餘弦、與正切對單位圓與直角三角形的關係。

接著,我們將正切函數對角度的作圖與正弦和餘弦的作圖重疊。現在也有能力用正切函數製作圓滑的物件入場與退場效果。

最後,我們學會了當給定初始速率、水平角、和仰角,如何用三個基礎三角函數模擬與預測發射砲彈的路徑。我們也學會了給定相對於大砲的水平距離和仰角,如何放置砲彈目標。

現在我們習得了解決日常遊戲開發問題所必備的三個基礎三角函數。於未來的教學中,我將介紹更多以這些三角函數為基底而衍伸的數學工具,並且會展示其能套用到的實務範例。

若您喜歡這篇教學,請考慮到Patreon支持我。感謝!

About Allen Chou

Physics / Graphics / Procedural Animation / Visuals
This entry was posted in C#, Gamedev, Math, Programming, Unity. Bookmark the permalink.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.