2024年2月29日 星期四

2.5 Modeling a Force

我們是虛擬世界的主宰,所以各種作用力要長什麼樣子、要怎麼個作用法,都由我們決定。不過,如果希望這些作用力像真的一樣,那就得研究一下真實世界的作用力是怎麼運作的。

在真實世界中有許多不同的力,如重力、摩擦力、電磁力、張力、彈力等。在這一章中,會藉由計算摩擦力、阻力、萬有引力,以個案研究的方式,來介紹下列處理作用力的步驟:

  • 瞭解作用力背後的觀念
  • 將描述作用力的數學式分解成兩個部分:
    • 怎麼計算作用力的方向?
    • 怎麼計算作用力的大小?
  • 將數學式寫成程式碼,以便產生使用Mover類別的apply_force()方法時,所需傳入的pygame.Vector2物件

Parsing Formulas

來看看摩擦力的公式$$\vec{f}=-\mu N \vec{v}$$

看到數學式時,有三個關鍵點需把握住,才能順利地把它寫成程式

  • 算出公式右邊的值,然後賦值給左邊。 以上述摩擦力的公式來說,等號左手邊是我們想要得到的東西,右手邊的那一大串,則是告訴我們該怎麼去計算。
  • 我們現在正在談論的,是向量還是純量? 這點非常重要,畢竟向量有大小和方向,而純量沒有。由上述的摩擦力公式可以看出來,摩擦力是向量,而等號右手邊的那一串中,只有代表單位速度向量的$\vec{v}$是向量。
  • 兩個符號相乘時,中間的乘號可以省略。 所以上述摩擦力的公式,完整的寫法應該是$$\vec{f}=-1\times\mu\times N\times \vec{v}$$

Friction

這一節就利用上一節提到的處理作用力的步驟來處理摩擦力。

摩擦力是當兩個物體接觸時,在接觸面所產生的,阻止彼此間相對運動的一種力。摩擦力是一種耗散力(dissipative force),也就是說,運動中的物體,會因為這種力的作用,而導致系統的總機械能降低。例如,開車的時候,當踩煞車時,煞車皮會利用和輪子間的摩擦力來降低車速。這時候,系統的動能會轉變成熱能。因為熱能並不屬於機械能,所以系統的機械能因為摩擦力而降低了。

摩擦力分為靜摩擦力(static friction)和動摩擦力(kinetic friction)。靜摩擦力是指,當力量作用在靜止的物體上,而物體仍然保持靜止狀態時的摩擦力;相對的,當物體在運動時的摩擦力,就是動摩擦力。在這裡,我們只會看動摩擦力的部分。

摩擦力的公式是:$$\vec{f} = -\mu N \hat{v}$$其中,$\hat{v}$是速度的單位向量;$\mu$是摩擦係數(coefficient of friction);$N$是正向力(normal force)。


這個公式可以拆成兩個部分來看,一個用來決定摩擦力的方向,另一個則是用來決定摩擦力的大小。

在摩擦力的方向方面,由上圖可以看出來,摩擦力的方向和物體的運動方向相反,這就是公式中的$-\hat{v}$。寫成程式,就是

friction = -velocity.normalize()

在摩擦力的大小方面,先來看看其中的摩擦係數$\mu$。

不同的材質表面,會有不同的摩擦係數。摩擦係數越大,摩擦力會越大;摩擦係數越小,則摩擦力會越小。例如,砂紙的表面遠比冰塊的表面粗糙,所以它的摩擦係數會遠大於冰塊。在模擬時,畢竟是在虛擬世界中,所以可以依據實際上的需要來設定摩擦係數的大小。例如

c = 0.01

接下來是正向力$N$。當物體在某個接觸面上運動時,因為重力的關係,會有力量施加在接觸面上。根據牛頓第三運動定律,這時會有一個垂直於接觸面的反作用力施加在物體上。這個垂直於接觸面的力就是正向力。重力越大,正向力會越大。因為重力跟質量有關,所以質量越大的物體,正向力也會越大,因而感受到的摩擦力也就越大。例如,汽車比腳踏車重很多,所以汽車感受到的摩擦力,也就比腳踏車大很多。

在計算正向力時,因為接觸面不一定是水平的,而有可能如上圖般,有個傾斜的角度。這時候,正向力的大小並不等於重力的大小。所以,要想知道正向力的大小,就必須知道傾斜的角度,然後利用三角函數來算出正確的值。不過,在這一章,我們先不管那麼多,反正我們主要的目標是要模擬出摩擦力的效果,先假設正向力的大小是1,就足以達成現階段的目標。更精緻的模擬效果,等下一章討論過三角函數之後,再回過頭來談。

知道怎麼計算摩擦力的方向和大小之後,計算摩擦力的程式可以寫成

c = 0.01
normal = 1
# pygame的0向量無法正規化
if velocity.length() != 0:
        v_hat = velocity.normalize()
    else:
        v_hat = pygame.Vector2(0, 0)

friction = -c*normal*v_hat

可以將摩擦力的計算寫成函數,方便後續呼叫使用

def friction_force(mu, velocity, N=1):
    if velocity.length() != 0:
        v_hat = velocity.normalize()
    else:
        v_hat = pygame.Vector2(0, 0)  

    return -mu*N*v_hat

使用friction_force()這個函數可以算出摩擦力,但在什麼時候使用呢?這個問題沒有標準答案,一切就看我們的需要而定。例如,假設mover是個半徑為radius的圓,而我們希望當它接觸畫面底部時,會受到摩擦力的作用,這時程式該怎麼寫呢?

要判斷圓形的mover有沒有接觸畫面底部,可以在Mover類別中加入下列方法:

def contact_edge(self):
    return self.position.y > self.height - self.radius - 1

這樣子,當mover跟畫面底部的距離在1個像素以內時,就會被判定是接觸到底部。

接下來,我們再加入非彈性碰撞(inelastic collision)的效果。

先前我們在模擬mover碰到畫面邊緣而往回彈時,都假設那是不會逸失動能的「理想化彈性碰撞」(idealized elastic collision),所以mover的速度大小不變。不過,在真實世界中,這種情況幾乎不會出現。在真實世界中的碰撞,絕大多數都是非彈性碰撞。當一個網球向下掉,碰到地面後反彈,每次反彈的高度會越來越低,就是非彈性碰撞的一個例子。

要讓mover碰到畫面底部或左右兩邊時,可以以非彈性碰撞的方式反彈,方法很簡單,就只要讓它在反彈之後,速度大小以一定的比例減少就可以了。可以在Mover類別中加入下列方法:

def bounce_edges(self):
    bounce = -0.9
    
    if self.position.x > self.width - self.radius: 
        self.position.x = self.width - self.radius
        self.velocity.x *= bounce
    elif self.position.x < self.radius:
        self.position.x = self.radius
        self.velocity.x *= bounce
        
    if self.position.y > self.height - self.radius:
        self.position.y = self.height - self.radius
        self.velocity.y *= bounce 

下面這個例子,就是在Example 2.3中,除了重力、風力之外,再加入摩擦力之後的效果。

Example 2.4: Including Friction

import pygame
import sys

pygame.init()

pygame.display.set_caption("Example 2.4: Including Friction")

FPS = 60
WHITE = (255, 255, 255)

width, height = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

c = 0.1  # friction coefficient

wind = pygame.Vector2(0.5, 0)

# 因為只有一個物體,沒有其他質量不同的物體對照下,
# 重力直接設定數值,不會影響模擬效果
gravity = pygame.Vector2(0, 1)

mover = Mover(width//2, 30, 3)

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    
    screen.fill(WHITE)    

    mover.apply_force(gravity)
    
    if pygame.mouse.get_pressed()[0]:
        mover.apply_force(wind)
        
    if mover.contact_edge():
        friction = friction_force(c, mover.velocity)
        mover.apply_force(friction)
    
    mover.bounce_edges()    
    mover.update()
    mover.show()
        
    pygame.display.update()
    frame_rate.tick(FPS)    

加入摩擦力之後,運動中的物體會因為摩擦力的作用而減速。因為摩擦力的作用方向總是和物體的運動方向相反,所以只要摩擦力持續作用,不管物體的運動方向如何改變,它的運動速度就會越來越慢。因此,當mover因為非彈性碰撞而不再彈跳就只在底部移動時,速度會越來越慢,最後停止。藉由調整摩擦係數及bounce_edges()裡頭的bounce數值大小,可以加快或減慢這個過程。

Exercise 2.6

有兩個物體時,重力部分的做法,就跟Example 2.3時的做法一樣,分別乘上物體各自的質量

gravityA = gravity*moverA.mass
gravityB = gravity*moverB.mass

摩擦力部分,在計算時,分別用物體各自的摩擦係數就可以了。

import pygame
import sys

pygame.init()

pygame.display.set_caption("Exercise 2.6")

FPS = 60
WHITE = (255, 255, 255)

width, height = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

# friction coefficients of movers relative to the bottom surface
cA = 0.1  
cB = 0.05

wind = pygame.Vector2(0.5, 0)
gravity = pygame.Vector2(0, 1)

moverA = Mover(200, 30, 5)
moverB = Mover(500, 30, 2)

gravityA = gravity*moverA.mass
gravityB = gravity*moverB.mass

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    
    screen.fill(WHITE)    

    moverA.apply_force(gravityA)
    moverB.apply_force(gravityB)
    
    if pygame.mouse.get_pressed()[0]:
        moverA.apply_force(wind)
        moverB.apply_force(wind)
        
    if moverA.contact_edge():
        friction = friction_force(cA, moverA.velocity)
        moverA.apply_force(friction)
        
    if moverB.contact_edge():
        friction = friction_force(cB, moverB.velocity)
        moverB.apply_force(friction)    
    
    moverA.bounce_edges()    
    moverA.update()
    moverA.show()
    
    moverB.bounce_edges()    
    moverB.update()
    moverB.show()
        
    pygame.display.update()
    frame_rate.tick(FPS)

把計算摩擦力的功能寫成Mover類別的方法,並沒有太大的意義。因為摩擦力是和其他物體有交互作用時才會產生的力,並不是mover物件本身的性質或單獨會有的行為。重力也是類似的情形。既然重力是計算好之後,再用apply_force()作用在mover上,並沒有把它寫成是Mover類別的方法,摩擦力也就比照辦理就可以了。

Exercise 2.7

拋擲只是一瞬間的作用力,並不會像風力一樣持續作用在物體上,所以改用MOUSEBUTTONDOWN來偵測滑鼠的動作。主程式如下:

import pygame
import sys

pygame.init()

pygame.display.set_caption("Exercise 2.7")

FPS = 60
WHITE = (255, 255, 255)

width, height = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

c = 0.1  # friction coefficient

# 向右上方拋擲物體的力
toss = pygame.Vector2(10, -25)

# 因為只有一個物體,沒有其他質量不同的物體對照下,
# 重力直接設定數值,不會影響模擬效果
gravity = pygame.Vector2(0, 1)

mover = Mover(width//2, 30, 3)

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
        elif event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 1:
                # 把速度歸零再施力,效果看起來比較好
                mover.velocity *= 0
                mover.apply_force(toss)
    
    screen.fill(WHITE)    

    mover.apply_force(gravity)
    
    if mover.contact_edge():
        friction = friction_force(c, mover.velocity)
        mover.apply_force(friction)
    
    mover.bounce_edges()    
    mover.update()
    mover.show()
        
    pygame.display.update()
    frame_rate.tick(FPS)

Air and Fluid Resistance

當物體在流體中運動時,也會感受到摩擦力的作用。不過,在流體中的摩擦力,跟上一節所提到的,因接觸固體表面而產生的摩擦力,在特性上有些不同。雖然兩者不同,但引起的效應是一樣的,就是都會讓物體慢下來。


在流體中的摩擦力有許多不同的名稱,例如viscous force、drag force、fluid resistance等。在這本書中使用的,主要是drag force這個名稱。中文翻譯的話,有譯成「阻力」的;也有譯成「拖曳力」的,不過在網路上看到的資料中,用「阻力」的比較多。

阻力的計算公式是

$$ F_d = -\frac{1}{2}\rho v^2 AC_d \hat{v} $$

接下來,就來逐一檢視公式中的各項,看看能不能藉由一些對結果不至於影響太大的假設,來讓公式在模擬時可以簡化一些。

  • $F_d$:阻力
  • $-1/2$:數字$1/2$在模擬時無關緊要,因為還有其他常數的值要設定。重點是在那個負號。負號代表的是,作用力的方向和速度的方向相反。
  • $\rho$:流體的密度,可以假設它是個不變的常數1。
  • $v$:物體相對於流體的運動速率。如果流體是靜止的,這一項就是物體的運動速率。
  • $A$:物體在流體中前進時,垂直於前進方向的截面積。在進行不那麼精準的初步模擬時,為了簡化起見,可以假設所有的物體都是球形,而且會忽略這一項。
  • $C_d$:阻力係數,是個常數。在模擬時,會根據我們需要的阻力強度來決定數值的大小。
  • $\hat{v}$:速度的單位向量。

透過上述的分析,可以把阻力的計算公式簡化為$$F_d = (v^2 C_d)\times(-\hat{v})$$根據這條公式,阻力的大小是$v^2C_d$;而方向則是$-\hat{v}$。

上述簡化後的公式,其實也可以看成是把原公式中的$1/2、\rho、A、C_d$這些常數合併後的結果。這也就是說,簡化後的公式本來應該寫成$$F_d = (v^2 \tilde{C_d})\times(-\hat{v})$$其中$$\tilde{C_d} = \frac{1}{2}\rho A C_d$$只不過為了偷懶,就把$\tilde{C_d}$上頭的那條蚯蚓省略掉,還是寫成$C_d$。所以,當模擬時,設定的阻力係數值,其實指的會是$\tilde{C_d}$的值,但這並不會影響模擬的結果。如果覺得設定的值效果不好,那就換另一個值就是了,反正模擬世界是寫程式的人創造出來的,各種東西的特性都是寫程式的人說了算。

根據簡化後的公式,計算阻力的程式可以這樣寫:

if velocity.length() != 0:
    v_hat = velocity.normalize()
    v2 = velocity.magnitude_squared()
    drag_force =  -v2*c*v_hat
else:
    drag_force = pygame.Vector2(0, 0)  

接下來,就來實作阻力的功能,讓模擬世界中的mover,在穿越特定區域的時候,會像跳進游泳池一樣,感受到一股阻力。

要讓mover在特定區域中會感受到阻力,首先需要把這特定區域給生出來。這個特定的區域,我們假設它是個長方形,這樣會比較好處理。所以,要建造這樣的區域,我們需要知道它的位置、長、寬、阻力係數,同時也要有個方法能把它顯示在畫面上。針對這樣子的需求,我們可以設計一個名叫Liquid的類別如下:

class Liquid:
    def __init__(self, x, y, w, h, c):
        self.screen = pygame.display.get_surface()
        self.region = pygame.Rect(x, y, w, h)
        self.x, self.y = x, y
        self.w, self.h = w, h
        self.c = c   # 阻力係數
        
    def show(self):
        pygame.draw.rect(self.screen, (175, 175, 175), self.region)

要產生一個Liquid物件,可以這樣寫:

liquid = Liquid(0, 320, 640, 180, 0.1)

這就會是一個讓mover在穿越時,能感受到阻力的區域。

有了Liquid這個類別之後,再來就是要讓mover在穿越Liquid物件時,能感受到來自Liquid物件的阻力。以物件導向的方式來寫的話,程式會長這樣:

if (liquid.contains(mover)):
    drag_force = liquid.calculate_drag(mover)
    mover.apply_force(drag_force)

也就是說,我們需要在Liquid類別中,再加入兩個方法,一個是contains()方法,用來判定mover是不是在liquid中;另一個是calculate_drag()方法,用來計算要施加在mover上的阻力。

先來看看contains()要怎麼寫。

既然liquid物件是由pygame的Rect物件所造出來的,我們可以使用Rect的collidepoint()方法來偵測mover是不是在liquid的長方形區域中。

def contains(self, mover):
    return liquid.region.collidepoint(mover.position.x, mover.position.y)

至於drag_force()方法,把先前寫過計算阻力的程式稍微修改一下就可以了:

def calculate_drag(self, mover):
    if mover.velocity.length() != 0:
        v_hat = mover.velocity.normalize()
        v2 = mover.velocity.magnitude_squared()
        return -v2*liquid.c*v_hat
    else:
        return = pygame.Vector2(0, 0)  

下面的例子,就是模擬大大小小的球,從空中掉進液體中的情形。

Example 2.5: Fluid Resistance



class Liquid:
    def __init__(self, x, y, w, h, c):
        self.screen = pygame.display.get_surface()
        self.region = pygame.Rect(x, y, w, h)
        self.x, self.y = x, y
        self.w, self.h = w, h
        self.c = c   # drag coefficient

    def show(self):
        pygame.draw.rect(self.screen, (175, 175, 175), self.region)
        
    def contains(self, mover):
        return liquid.region.collidepoint(mover.position.x, mover.position.y)

    def calculate_drag(self, mover):
        if mover.velocity.length() != 0:
            v_hat = mover.velocity.normalize()
            v2 = mover.velocity.magnitude_squared()
            return -v2*liquid.c*v_hat
        else:
            return pygame.Vector2(0, 0)


import pygame
import random
import sys

pygame.init()

pygame.display.set_caption("Example 2.5: Fluid Resistance")

FPS = 60
WHITE = (255, 255, 255)

WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

liquid = Liquid(0, HEIGHT/2, WIDTH, HEIGHT/2, 0.1)

movers = [Mover(40+i*70, 0, random.uniform(0.05, 2.5)) 
          for i in range(9)]
while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    
    screen.fill(WHITE)    

    liquid.show()
        
    for mover in movers:
        # 如果mover在liquid中,就會感受到來自liquid的阻力
        if liquid.contains(mover):
            drag_force = liquid.calculate_drag(mover)
            mover.apply_force(drag_force)
        
        gravity = pygame.Vector2(0, 0.1*mover.mass)
        mover.apply_force(gravity)
                
        mover.update()
        mover.show()
        mover.check_edges()

        
    pygame.display.update()
    frame_rate.tick(FPS)   

從模擬的結果可以看出來,越小的球掉得越慢。這是因為阻力的方向是向上,越小的球質量越小,向上的加速度越大,而導致向下掉的速度減少越多。最後的結果就是,越小的球,掉得越慢。

Exercise 2.8

液體的阻力最多只能讓物體的速度變成0,不可能會讓物體反彈。所以,在計算阻力時,應該把阻力限制在不會讓物體的運動方向180度改變的範圍內。因為Mover類別的update()方法中,計算速度的方式是

self.velocity += self.acceleration

所以,能夠讓速度變成0的加速度是

-self.velocity

而由牛頓第二運動定律,產生這個加速度所需的力量大小是

mover.mass*mover.velocity.length()

這就是不會讓物體反彈的液體阻力的最大值。

修改Liquid類別的calculate_drag()方法如下:

def calculate_drag(self, mover):
    if mover.velocity.length() != 0:
        drag_force_limit = mover.mass*mover.velocity.length()
        
        v_hat = mover.velocity.normalize()
        v2 = mover.velocity.magnitude_squared()
        drag_force = -v2*liquid.c*v_hat
        drag_force.clamp_magnitude_ip(drag_force_limit)

        return drag_force
    else:
        return pygame.Vector2(0, 0)

修改Example 2.5的程式中用來產生mover的部分,讓9個相同質量的mover從不同高度落下。

movers = [Mover(40+i*70, 15*i, 1.5) for i in range(9)]

從模擬的結果可以看出,從越高的地方落下,接觸水面時的速度會越快,因而受到來自水的阻力也就越大,速度也就減慢越多。所以,不管從哪個高度落下,在水中的速度其實都差不多。

Exercise 2.9

將阻力公式改為$$F_d = (v^2 \ell C_d)\times(-\hat{v})$$其中$\ell$是box的底部寬度。設定讓相同質量、底部寬度不同的數個box,從其底部距離水面相同的高度落下。在水中,底部寬度越小的box受到的阻力越小,所以下沈的速度越快。


程式如下:

class Box:
    def __init__(self, x, y, w, h):
        self.screen = pygame.display.get_surface()
        self.width, self.height = self.screen.get_size()
        
        self.x, self.y = x, y
        self.w, self.h = w, h
        
        self.mass = 0.1*self.w*self.h

        # 讓傳遞進來的數值來決定物體的起始位置
        self.position = pygame.Vector2(x, y)
        
        self.velocity = pygame.Vector2(0, 0)
        self.acceleration = pygame.Vector2(0, 0)
        
        # 設定box所在surface的格式為per-pixel alpha
        self.top_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)        
 
    def apply_force(self, force):
        self.acceleration += force/self.mass
        
    def update(self):
        self.velocity += self.acceleration
        self.position += self.velocity
        self.acceleration *= 0
        
    def show(self):
        # 使用具透明度的白色把box所在的surface清空
        self.top_surface.fill((255, 255, 255, 0))
        
        # 畫出具有透明度的box
        rect = pygame.Rect(self.position.x, self.position.y, self.w, self.h)
        pygame.draw.rect(self.top_surface, (75, 75, 75), rect)
        
        # 把box所在的surface貼到最後要顯示的畫面上
        self.screen.blit(self.top_surface, (0, 0))
        
    def check_edges(self):
        if self.position.y+self.h > self.height:
            self.position.y = self.height - self.h
            self.velocity.y = -self.velocity.y


class Liquid:
    def __init__(self, x, y, w, h, c):
        self.screen = pygame.display.get_surface()
        self.region = pygame.Rect(x, y, w, h)
        self.x, self.y = x, y
        self.w, self.h = w, h
        self.c = c   # coefficient of drag

    def show(self):
        pygame.draw.rect(self.screen, (175, 175, 175), self.region)
        
    def contains(self, box):
        return liquid.region.collidepoint(box.position.x, box.position.y+box.h)

    def calculate_drag(self, box):
        if box.velocity.length() != 0:
            drag_force_limit = box.mass*box.velocity.length()
            
            v_hat = box.velocity.normalize()
            v2 = box.velocity.magnitude_squared()
            # 計算阻力時,把box的底部寬度納入考量
            drag_force = -v2*box.w*liquid.c*v_hat
            drag_force.clamp_magnitude_ip(drag_force_limit)

            return drag_force
        else:
            return pygame.Vector2(0, 0)


import pygame
import sys

pygame.init()

pygame.display.set_caption("Exercise 2.9")

FPS = 60
WHITE = (255, 255, 255)

WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

liquid = Liquid(0, HEIGHT/4, WIDTH, HEIGHT/4*3, 0.1)

boxes = [Box(30+60*i, 50-400/(5+5*i), 10+5*i, 400/(5+5*i)) for i in range(10)]
while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    
    screen.fill(WHITE)    

    liquid.show()
        
    for box in boxes:
        # 如果box在liquid中,就會感受到來自liquid的阻力
        if liquid.contains(box):
            drag_force = liquid.calculate_drag(box)
            box.apply_force(drag_force)
           
        gravity = pygame.Vector2(0, 0.1*box.mass)
        box.apply_force(gravity)
                
        box.update()
        box.check_edges()
        box.show()
        
    pygame.display.update()
    frame_rate.tick(FPS)  

Exercise 2.10

升力(lift)的大小,設定成和阻力的大小一樣,但是方向則是向上。所以,計算方式改寫為

lift_force = pygame.Vector2(0, -v2*liquid.c)

其中,v2是速度向量大小的平方、liquid.c是液體的阻力係數。完整程式如下:

class Airplane:
    def __init__(self, x, y, w, h):
        self.screen = pygame.display.get_surface()
        self.width, self.height = self.screen.get_size()
        
        self.x, self.y = x, y
        self.w, self.h = w, h
        
        self.mass = 0.01*self.w*self.h

        # 讓傳遞進來的數值來決定物體的起始位置
        self.position = pygame.Vector2(x, y)
        
        self.velocity = pygame.Vector2(0, 0)
        self.acceleration = pygame.Vector2(0, 0)
        
        # 設定airplane所在surface的格式為per-pixel alpha
        self.top_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)        
 
    def apply_force(self, force):
        self.acceleration += force/self.mass
        
    def update(self):
        self.velocity += self.acceleration
        self.position += self.velocity
        self.acceleration *= 0
        
    def show(self):
        # 使用具透明度的白色把airplane所在的surface清空
        self.top_surface.fill((255, 255, 255, 0))
        
        # 畫出具有透明度的airplane
        rect = pygame.Rect(self.position.x, self.position.y, self.w, self.h)
        pygame.draw.arc(self.top_surface, (75, 75, 75), rect, math.radians(45), math.radians(160), 30)
        
        # 把airplane所在的surface貼到最後要顯示的畫面上
        self.screen.blit(self.top_surface, (0, 0))
        

class Liquid:
    def __init__(self, x, y, w, h, c):
        self.screen = pygame.display.get_surface()
        self.region = pygame.Rect(x, y, w, h)
        self.x, self.y = x, y
        self.w, self.h = w, h
        self.c = c   # coefficient of drag

    def show(self):
        pygame.draw.rect(self.screen, (175, 175, 175), self.region)

    def contains(self, airplane):
        return liquid.region.collidepoint(airplane.position.x, airplane.position.y)

    def calculate_lift(self, airplane):
        if airplane.velocity.length() != 0:
            v2 = airplane.velocity.magnitude_squared()
            # 升力大小和阻力一樣,但是方向向上
            lift_force = pygame.Vector2(0, -v2*liquid.c)

            return lift_force
        else:
            return pygame.Vector2(0, 0)
        

import pygame
import math
import sys

pygame.init()

pygame.display.set_caption("Exercise 2.10")

FPS = 60
WHITE = (255, 255, 255)

WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

liquid = Liquid(0, 0, 0.75*WIDTH, HEIGHT, 0.1)

airplane = Airplane(WIDTH, 250, 100, 50)
while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    
    screen.fill(WHITE)    

    liquid.show()
        
    # 如果airplane在liquid中,就會感受到來自liquid的阻力
    if liquid.contains(airplane):
        lift_force = liquid.calculate_lift(airplane)
        airplane.apply_force(lift_force)
       
    thrust = pygame.Vector2(-0.1*airplane.mass, 0)
    airplane.apply_force(thrust)
           
    airplane.update()
    airplane.check_edges()
    airplane.show()
        
    pygame.display.update()
    frame_rate.tick(FPS) 

Gravitational Attraction

萬有引力,指的是兩個物體之間,互相吸引的力量。重力也是萬有引力的一種,不過因為地球相對於一般的物體而言非常巨大,所以在我們的經驗中,都只覺得是地球的重力讓鳥屎掉下來,而不覺得鳥屎其實也在發揮它的吸引力,把地球吸向它。

兩個物體之間的萬有引力計算公式是$$F = \frac{Gm_1 m_2}{r^2}\hat{r}$$其中

  • $F$:要計算的萬有引力。
  • $G$:萬有引力常數,大小是$6.67428\times 10^{-11} m^3kg^{-1}s^{-2}$。既然是個常數,那就跟先前一些公式中的常數一樣,在模擬時,它的真正大小並不是那麼的重要,可以自己設定。如果沒有特別的考量,可以把它設定為1,方便處理。
  • $m_1$、$m_2$:物體1和物體2的質量,可以都設為1。如果想模擬出更豐富的效果,可以設定不同的數值,來讓質量比較大的物體,可以有比較大的引力。
  • $\hat{r}$:由物體1指向物體2,或由物體2指向物體1的單位向量。
  • $r$:物體1和物體2之間的距離。兩個物體之間的距離越大,引力越小;反之,距離越小,引力越大。

假設物體1和物體2的位置向量分別是position1和position2;而質量則分別是mass1和mass2。要計算物體1作用在物體2上的萬有引力,可以分別計算引力的方向以及大小。先來計算方向,也就是$\hat{r}$。

因為物體1作用在物體2上的引力,會把物體2拉向物體1,所以作用力的方向是從物體2指向物體1,計算$\hat{r}$的程式可以這樣寫:

d = position1 - position2
r_hat = d.normalize()

接下來計算引力的大小。

distance = d.magnitude()
strength = G*mass1*mass2/distance**2

所以物體1作用在物體2上的引力會是

force = strength*r_hat

計算萬有引力的程式有了,那要怎麼讓它在我們的模擬世界中發揮作用呢?假設在模擬世界中有兩個物體分別是

  • 先前建立的Mover類別所產生的物件。
  • 新的Attractor類別所產生的,具有固定位置的物件。

Mover物件會感受到把它拉向Attractor物件的引力。

因為Attractor物件就只是個固定不動的物體,所以Attractor類別相當簡單,就只需要有質量、位置,以及能夠在畫面上顯示出來的方法。程式碼如下:

class Attractor:
    def __init__(self, mass, x, y):
        self.screen = pygame.display.get_surface()
        self.width, self.height = self.screen.get_size()
        self.top_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)        
        
        self.mass = mass
        self.size = self.mass
        
        self.x, self.y = x, y
        self.position = pygame.Vector2(x, y)        
        
    def show(self):
        # 使用具透明度的白色把attractor所在的surface清空
        self.top_surface.fill((255, 255, 255, 0))
        
        # 畫出具有透明度的attractor
        pygame.draw.circle(self.top_surface, (0, 0, 0, 150), self.position, self.size)
        
        # 把attractor所在的surface貼到最後要顯示的畫面上
        self.screen.blit(self.top_surface, (0, 0))        

假設mover和attractor分別是由Mover類別和Attractor類別所產生的物件,那要怎麼讓他們能夠溝通而產生引力呢?現在就來看看有哪些方法可以達到這個目的。

第一種方法是使用能傳入attractor和mover的函數,例如

attraction(attractor, mover)

接下來的兩種方法,都是物件導向式的寫法。這兩種不同的寫法,主要的差異在於是站在attractor或mover的角度來看引力這件事。

「attractor把mover吸引過來」,這是站在attractor的角度來看引力。這種寫法會在Attractor類別中,寫一個能傳入mover來處理引力的方法。假設這個方法叫attract,那attractor吸引mover就可以這樣寫:

attractor.attract(mover)

如果是站在mover的角度來看引力,就會是「mover被吸引向attractor」。這種寫法會在Mover類別中,寫一個能傳入attractor來處理引力的方法。假設這個方法叫attracted_to(),那程式就寫成

mover.attracted_to(attractor)

這兩種物件導向式的寫法並沒有優劣之分,不過都要比第一種單純使用函數的寫法來得好。

除了上述三種寫法外,還有一種寫法,這種寫法是在Attractor類別中,寫一個能傳入mover來計算並傳回引力的方法,然後再把傳回的引力,透過Mover類別中的apply_force()方法,作用在mover上。這種方法寫出來的程式長這樣:

force = attractor.attract(mover)
mover.apply_force(force)

程式中的attract(),就是在Attractor類別中處理引力的方法。原書最後採用這種寫法來處理引力,因為先前在處理不同的作用力時,都是利用apply_force()來施加作用力於物體上,所以為了保持一貫性,就採用了這種寫法。

在開始寫attract()這個方法之前,要先來看一下計算引力時,可能會出現的極端狀況。

計算引力時,可能會有的極端狀況有兩個,一個是兩個物體距離非常遠;另一個是兩個物體距離非常近。

當兩個物體距離非常遠,也就是$r$非常大時,因為在公式中$r$位於分母,所以計算出來的引力值會非常小。這也就是說,這兩個物體間的引力,基本上等於是沒有任何作用。就好比地球和一萬光年以外的某顆行星,它們之間的引力,會趨近於0。

如果兩個物體距離非常近,也就是$r$非常小時,因為$r$位於分母,所以計算出來的引力值非常非常的大。在模擬時,這種情況會讓mover在接近attractor到一定程度時,因為引力很大很大而加速到以高速越過attractor。這時候,雖然attractor會把mover往回拉,但因為mover速度非常快,所以attractor一時之間也沒法讓mover慢下來,只能眼睜睜看著mover飛出畫面之外。

如果想要避免這些極端狀況,要怎麼做呢?其實很簡單,就只需要在計算引力時,把兩個物體之間距離的數值,強迫限制在某一個範圍內就可以了。例如,兩個物體之間的距離算出來是distance,如果想把這數值限制在5~25之間,程式可以這樣寫:

if distance < 5:
    distance = 5
elif distance > 25:
    distance = 25

這樣子,就不會有過於極端的情況發生。不過,到底要不要避免極端的情況發生,純粹是個選擇,沒有對或錯的問題,就看你想要的效果是什麼。

在下面這個範例中,會把萬有引力常數$G$納入Attractor類別中,並在attract()方法中,加入避免極端狀況的功能。因為Mover類別並未更動,程式不再列出。

Example 2.6: Attraction

class Attractor:
    def __init__(self, x, y, mass, G=1):
        self.screen = pygame.display.get_surface()
        self.width, self.height = self.screen.get_size()
        # 設定attractor所在surface的格式為per-pixel alpha
        self.top_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)        
        
        self.G = G
        
        self.mass = mass
        self.size = self.mass
        
        self.x, self.y = x, y
        self.position = pygame.Vector2(x, y)     
    
    def attract(self, mover):
        d = self.position - mover.position
        r_hat = d.normalize() if d.length() != 0 else pygame.Vector2(0, 0)
                
        # 限制距離數值的範圍,避免極端狀況發生
        distance = d.magnitude()
        if distance < 5:
            distance = 5
        elif distance > 25:
            distance = 25
            
        strength = self.G*self.mass*mover.mass/distance**2
        force =  strength*r_hat
        
        return force
        
    def show(self):
        # 使用具透明度的白色把attractor所在的surface清空
        self.top_surface.fill((255, 255, 255, 0))
        
        # 畫出具有透明度的attractor
        pygame.draw.circle(self.top_surface, (0, 0, 0, 150), self.position, self.size)
        
        # 把attractor所在的surface貼到最後要顯示的畫面上
        self.screen.blit(self.top_surface, (0, 0))        

    
import pygame
import sys

pygame.init()

pygame.display.set_caption("Example 2.6: Attraction")

FPS = 60
WHITE = (255, 255, 255)

WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

mover = Mover(400, 50, 0.5) 
# mover有不同的初始速度,會產生不同的效果
mover.velocity.x = 1

attractor = Attractor(WIDTH/2, HEIGHT/2, 20, 0.4)
while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    
    screen.fill(WHITE)    

    attractor.show()
    
    force = attractor.attract(mover)
    mover.apply_force(force)

    mover.update()
    mover.show()
        
    pygame.display.update()
    frame_rate.tick(FPS) 

在這個例子中,我們設定attractor和mover的半徑大小,是跟它們的質量大小呈正比的關係。這個設定並不怎麼精確,無法反映出真實世界中,物體大小和質量間的關係。因為attractor和mover都是圓,而半徑為$r$的圓,面積是$\pi r^2$,所以比較精確的做法,是設定attractor和mover的半徑大小,正比於它們質量的0.5次方,也就是$$r \propto m^{0.5}$$這樣子才能夠比較真實地呈現出,物體的大小和質量間的關係。

Exercise 2.11

分別修改Mover類別和Attractor類別的__init__()方法中,圓的半徑大小的計算方式

# mover大小
self.size = self.mass**0.5

# attractor大小
self.size = self.mass**0.5

接下來的這個例子,是像Example 2.5的做法一樣,讓畫面上同時有很多個mover,在attractor的引力作用下跑來跑去,主程式如下:

Example 2.7: Attraction with Many Movers

import pygame
import random
import sys

pygame.init()

pygame.display.set_caption("Example 2.7: Attraction with Many Movers")

FPS = 60
WHITE = (255, 255, 255)

WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

movers = [Mover(random.randint(0, WIDTH), 
                random.randint(0, HEIGHT), 
                random.uniform(0.1, 2)) 
          for i in range(10)]
# mover有不同的初始速度,會產生不同的效果
for mover in movers:
    mover.velocity = pygame.Vector2(1, 0)

attractor = Attractor(WIDTH/2, HEIGHT/2, 20, 0.4)
while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    
    screen.fill(WHITE)    

    attractor.show()
    
    for mover in movers:
        force = attractor.attract(mover)
        mover.apply_force(force)

        mover.update()
        mover.show()
        
    pygame.display.update()
    frame_rate.tick(FPS)  

Exercise 2.12

兩個mover加上兩個attractor,就可以畫出挺漂亮的圖案。下面是正在畫的當中先後擷取出來的兩張截圖。




主程式如下:

import pygame
import math
import sys

pygame.init()

pygame.display.set_caption("Exercise 2.12")

FPS = 500
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)

WIDTH, HEIGHT = screen_size = 640, 600
screen = pygame.display.set_mode(screen_size)

frame_rate = pygame.time.Clock()

movers = [Mover(450, 120, 0.065), Mover(450, 350, 0.065)]
attractors = [Attractor(100, 300, 20, 0.4), Attractor(500, 300, 20, 0.4)]

screen.fill(WHITE)
while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    
    for attractor in attractors:
        for mover in movers:
            force = attractor.attract(mover)
            mover.apply_force(force)
    
            mover.update()

            # 在mover所在的位置上畫點描繪出運動軌跡
            x, y = mover.position
            screen.set_at((int(x), int(y)), (0, 0, 0))
        
    pygame.display.update()
    frame_rate.tick(FPS)

Exercise 2.13

要想距離越大作用力越強;距離越小作用力越小,最簡單的做法,就是把引力公式中在分母的$r$,改放到分子就可以了。

要讓attractor吸引遠距離的mover,但卻排斥近距離的mover,最簡單的做法,就是當距離小於某個數值時,把作用力反向。要達到這個目的,可以在attract()方法中,於return force之前,加入判斷式,例如

if distance < 15:
    force = -force

這樣,當mover跟attractor之間的距離小於15時,作用在mover上的就會是斥力,而非引力。

沒有留言:

張貼留言

4.9 Image Textures and Additive Blending

粒子系統可以用來製作視覺特效(visual effect, VFX),而怎麼呈現粒子的外觀,以及粒子具有怎樣的紋理(texture),都會影響特效所展現出來的效果。例如在下圖中,可以很清楚地看到,使用兩種不同的粒子紋理所呈現出來的特效效果,就有很大的不同。 要賦予粒子不同的紋理...