我們是虛擬世界的主宰,所以各種作用力要長什麼樣子、要怎麼個作用法,都由我們決定。不過,如果希望這些作用力像真的一樣,那就得研究一下真實世界的作用力是怎麼運作的。
在真實世界中有許多不同的力,如重力、摩擦力、電磁力、張力、彈力等。在這一章中,會藉由計算摩擦力、阻力、萬有引力,以個案研究的方式,來介紹下列處理作用力的步驟:
- 瞭解作用力背後的觀念
- 將描述作用力的數學式分解成兩個部分:
- 怎麼計算作用力的方向?
- 怎麼計算作用力的大小?
- 將數學式寫成程式碼,以便產生使用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上的就會是斥力,而非引力。
沒有留言:
張貼留言