The Nature of Code閱讀心得與Python實作:2.5 Modeling a Force

閱讀時間約 16 分鐘

模擬世界是我們寫程式造出來的,我們就是模擬世界的主宰,所以各種作用力要長什麼樣子、要怎麼個作用法,都由我們決定。不過,如果希望這些作用力看起來像真實世界的作用力一樣,那在寫程式的時候,套用這些作用力在真實世界中的物理公式,會是比較省時省力的做法。

要套用真實世界作用力的物理公式,在模擬世界中呈現出這些作用力的效果,可以依循下列的步驟:

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

接下來就針對摩擦力、阻力、萬有引力這三種作用力,介紹如何利用上述步驟,在模擬世界中,創造出這些作用力。

摩擦力

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

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

raw-image

摩擦力的作用方式如上圖所示,其計算公式是:

f = −μN

其中,是速度的單位向量;μ是摩擦係數(coefficient of friction);N是正向力(normal force)的大小。

摩擦力的公式可以拆解成−及μN兩個部分;前者是摩擦力的方向,後者是摩擦力的大小。由上圖可以看出來,摩擦力的方向和物體的運動方向相反。因為物體的運動方向是,所以公式中的−,就是摩擦力的方向。至於摩擦力的大小方面,要先知道摩擦係數μ及正向力N的值,才有辦法計算。

先來看看在模擬時,摩擦係數怎麼設定。真實世界中,不同的材質表面,會有不同的摩擦係數,摩擦係數越大,摩擦力會越大;摩擦係數越小,則摩擦力會越小。在真實世界中,摩擦係數需經由實驗來測定;但在模擬時,畢竟是在虛擬世界中,只要模擬出來的效果達到要求,摩擦係數的大小,可以自訂。

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

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

知道怎麼計算摩擦力的方向和大小之後,將摩擦力的計算寫成函數,方便後續呼叫使用

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):
# 碰壁反彈時,喪失10%的速度
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

# python version 3.10.9
import sys

import pygame # version 2.3.0


pygame.init()

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

WHITE = (255, 255, 255)

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

FPS = 60
frame_rate = pygame.time.Clock()

# 摩擦係數
mu = 0.1

# 由左向右吹的風
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

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

# python version 3.10.9
import sys

import pygame # version 2.3.0


pygame.init()

pygame.display.set_caption("Exercise 2.6")

WHITE = (255, 255, 255)

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

FPS = 60
frame_rate = pygame.time.Clock()

# 摩擦係數
muA = 0.1
muB = 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(muA, moverA.velocity)
moverA.apply_force(friction)

# 接觸畫面底部時會感受到摩擦力的作用
if moverB.contact_edge():
friction = friction_force(muB, 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

# python version 3.10.9
import sys

import pygame # version 2.3.0


pygame.init()

pygame.display.set_caption("Exercise 2.7")

WHITE = (255, 255, 255)

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

FPS = 60
frame_rate = pygame.time.Clock()

# 摩擦係數
mu = 0.1

# 向右上方拋擲物體的力
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(mu, mover.velocity)
mover.apply_force(friction)

mover.bounce_edges()
mover.update()
mover.show()

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

阻力

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

raw-image

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

阻力,記為Fd,其計算公式是

Fd = − 0.5 ρ v² A Cd

公式中的負號代表作用力的方向和速度的方向相反,其餘各項所代表的,分別是:

  • ρ:流體的密度。
  • v:物體相對於流體的運動速率。如果流體是靜止的,這一項就是物體的運動速率。
  • A:frontal surface area,物體在流體中前進時,垂直於前進方向的截面積。
  • Cd:阻力係數,是個常數。
  • :速度的單位向量。

這條公式可以拆解成−及0.5 ρ v² A Cd兩個部分;前者是阻力的方向,後者是阻力的大小。

瞭解公式中各項所代表的含意之後,可以利用一些小技巧來簡化公式,而不至於影響模擬的效果。例如,公式中的0.5 ρ Cd可以合併成一項來看待,不需要分別設定ρ、Cd的值;也就是說,可以令

d = 0.5 ρ Cd

然後直接設定C̃d的值來進行模擬。這是因為,最後會影響阻力大小的是C̃d的值,而不是ρ、Cd個別的值;例如ρ=0.5、Cd=1和ρ=1、Cd=0.5這兩種設定,得到的C̃d值是一樣的,既然如此,那直接設定C̃d就可以了。

接下來,來看看該怎麼設定A的值。一般來說,除非是要呈現不同的A值如何影響阻力的大小,不然可以把A值設定為1。若需要調整阻力大小,改變C̃d的值就可以了。

經過上述的簡化後,阻力公式會變成

Fd = − v²C̃d

後續在不至於引起混淆的情況下,為了簡化起見,會將C̃d上頭的那條蚯蚓省略,還是寫成Cd,而且還是把它叫做「阻力係數」。

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

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

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

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

class Liquid:
def __init__(self, x, y, w, h, Cd):
# 顯示畫面
self.screen = pygame.display.get_surface()

# 液體分布範圍
self.region = pygame.Rect(x, y, w, h)

# 液體阻力係數
self.Cd = Cd

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物件是由pygameRect物件所造出來的,我們可以使用Rectcollidepoint()方法來偵測mover是不是在liquid的長方形區域中。

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

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

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

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

Example 2.5: Fluid Resistance

raw-image
class Mover:
def __init__(self, x, y, mass):
# 取得顯示畫面的大小
self.screen = pygame.display.get_surface()
self.width, self.height = self.screen.get_size()

# 讓傳遞進來的數值來決定物體的質量
self.mass = mass

# 物體的質量越大,尺寸就會越大
self.size = 16*self.mass
self.radius = self.size/2

# 讓傳遞進來的數值來決定物體的起始位置
self.position = pygame.Vector2(x, y)

# 物件的初始速度、初始加速度
self.velocity = pygame.Vector2(0, 0)
self.acceleration = pygame.Vector2(0, 0)

# 設定mover所在surface的格式為per-pixel alpha
self.surface = pygame.Surface((self.size, self.size), 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):
# 使用具透明度的白色把mover所在的surface清空
self.surface.fill((255, 255, 255, 0))

# 畫出具有透明度的mover
center = pygame.Vector2(self.radius, self.radius)
pygame.draw.circle(self.surface, (0, 0, 0, 50), center, self.radius)

# 把mover所在的surface貼到最後要顯示的畫面上
self.screen.blit(self.surface, self.position-center)

def check_edges(self):
if self.position.x > self.width - self.radius:
self.position.x = self.width - self.radius
self.velocity.x = -self.velocity.x
elif self.position.x < self.radius:
self.position.x = self.radius
self.velocity.x = -self.velocity.x

if self.position.y > self.height - self.radius:
self.position.y = self.height - self.radius
self.velocity.y = -self.velocity.y
elif self.position.y < self.radius:
self.position.y = self.radius
self.velocity.y = -self.velocity.y


class Liquid:
def __init__(self, x, y, w, h, Cd):
# 顯示畫面
self.screen = pygame.display.get_surface()

# 液體分布範圍
self.region = pygame.Rect(x, y, w, h)

# 液體阻力係數
self.Cd = Cd

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.Cd*v_hat
else:
return pygame.Vector2(0, 0)


# python version 3.10.9
import random
import sys

import pygame # version 2.3.0

pygame.init()


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

WHITE = (255, 255, 255)

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

FPS = 60
frame_rate = pygame.time.Clock()

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

movers = [Mover(40+i*70, 0, random.uniform(0.1, 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)

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

在模擬時,有時當球接觸到液體時,會向上反彈,而不是落入液體中;這種情形通常會發生在阻力係數比較大、球的質量比較小,或球向下掉的速度比較快時。之所以會發生這種在真實世界不存在的現象,是因為模擬時使用的時間步長太大的關係。在真實世界中,當球一接觸液體時,就會開始受到阻力的作用,而讓速度持續減慢;這是一個連續的過程,隨著球的速度不斷變化,阻力的大小也跟著不斷變化,當球速變成0時,阻力也會變成0,所以球不會突然改變運動的方向而向上彈。但在模擬時,在同一個時間步長內,球所感受到的阻力是一個固定的值,所以有可能在進入下一個時間步長時,因為阻力所產生的反向加速度比較大,而改變運動的方向。

那要怎麼做才能讓球不會反彈呢?縮短時間步長是個可行的辦法,不過畢竟有其極限。接下來的練習,就是要在不調整時間步長的前提下,想辦法解決這個問題。

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.Cd*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

這個練習主要是要呈現不同A值對阻力大小的影響,所以A值不能一律設為1,而應以box的底部寬度來取代。因此,阻力公式應改為

Fd = − v² ℓ Cd

其中ℓ是box的底部寬度。

設定讓相同質量、底部寬度不同的數個box,從其底部距離液面相同的高度落下。在液體中,底部寬度越小的box受到的阻力越小,所以下沈的速度越快。

raw-image

程式如下:

class Box:
def __init__(self, x, y, w, h):
# 取得顯示畫面的大小
self.screen = pygame.display.get_surface()
self.width, self.height = self.screen.get_size()

# 物件大小
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.surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
self.surface = pygame.Surface((self.w, self.h), 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.surface.fill((255, 255, 255, 0))

# 畫出具有透明度的box
rect = pygame.Rect(0, 0, self.w, self.h)
pygame.draw.rect(self.surface, (75, 75, 75), rect)

# 把box所在的surface貼到最後要顯示的畫面上
# self.screen.blit(self.surface, (self.position.x, self.position.y))
self.screen.blit(self.surface, (self.position.x, self.position.y))

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, Cd):
# 顯示畫面
self.screen = pygame.display.get_surface()

# 液體分布範圍
self.region = pygame.Rect(x, y, w, h)

# 液體阻力係數
self.Cd = Cd

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.Cd*v_hat
drag_force.clamp_magnitude_ip(drag_force_limit)

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


# python version 3.10.9
import sys

import pygame # version 2.3.0


pygame.init()

pygame.display.set_caption("Exercise 2.9")

WHITE = (255, 255, 255)

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

FPS = 60
frame_rate = pygame.time.Clock()

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

# 各個box底部到液面的距離相同
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.Cd)

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

class Airplane:
def __init__(self, x, y, w, h):
# 取得顯示畫面的大小
self.screen = pygame.display.get_surface()
self.width, self.height = self.screen.get_size()

# 物件大小
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.surface = pygame.Surface((self.w, self.h), 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.surface.fill((255, 255, 255, 0))

# 畫出具有透明度的airplane
rect = pygame.Rect(0, 0, self.w, self.h)
pygame.draw.arc(self.surface, (75, 75, 75), rect,
math.radians(45), math.radians(160), 30)

# 把airplane所在的surface貼到最後要顯示的畫面上
self.screen.blit(self.surface, (self.position.x, self.position.y))


class Liquid:
def __init__(self, x, y, w, h, Cd):
# 顯示畫面
self.screen = pygame.display.get_surface()

# 液體分布範圍
self.region = pygame.Rect(x, y, w, h)

# 液體阻力係數
self.Cd = Cd

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_lift(self, airplane):
if airplane.velocity.length() > 0:
v2 = airplane.velocity.magnitude_squared()
# 升力大小和阻力一樣,但是方向向上
lift_force = pygame.Vector2(0, -v2*liquid.Cd)

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


# python version 3.10.9
import math
import sys


import pygame # version 2.3.0

pygame.init()

pygame.display.set_caption("Exercise 2.10")

WHITE = (255, 255, 255)

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

FPS = 60
frame_rate = pygame.time.Clock()

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

airplane = Airplane(WIDTH, 250, 100, 50)
thrust = pygame.Vector2(-0.1*airplane.mass, 0) # 飛機推力

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)

airplane.apply_force(thrust)

airplane.update()
airplane.show()

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

萬有引力

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

萬有引力的計算公式是

Fg = G m1 m2 r̂ / r2

其中,Fg為萬有引力;G為萬有引力常數;m1、m2分別為物體1與物體2的質量;r為物體1與物體2之間的距離;r̂為由物體1指向物體2,或由物體2指向物體1的單位向量。

在真實世界中,萬有引力常數的大小是6.67428×10-11 m3kg-1s-2。既然G是個常數,那就跟先前一些公式中的常數一樣,在模擬時,它的真正大小並不是那麼的重要,可以自己設定,只要能得到想要的效果就可以。如果沒有特別的考量,可以把它設定為1,方便處理。

要計算物體1和物體2兩個物體間的萬有引力,需要計算其方向和大小。在寫程式時,比較好的做法是,先計算引力的方向,也就是公式中的r̂,然後再計算引力的大小。要注意的是,物體1作用在物體2上的萬有引力,和物體2作用在物體1上的萬有引力,這兩者的大小相同,而方向剛好相反。接下來就來看看程式要怎麼寫。

假設物體1和物體2的位置向量分別是position1position2;而質量則分別是mass1mass2。假設我們要計算物體1作用在物體2上的萬有引力,因為這樣子的引力,會把物體2拉向物體1,所以作用力的方向是從物體2指向物體1。要計算r̂,程式可以這樣寫:

d = position1 - position2
r_hat = d.normalize() if d.length() > 0 else pygame.Vector2(0, 0)

至於計算引力大小的程式,可以這麼寫:

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

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

force = strength*r_hat

計算萬有引力的程式有了,接下來就來設計一個叫做Attractor的類別,讓透過這個類別所製造出來的物件,能夠具備吸引其他物件靠近的能力。

我們把Attractor類別所製作出來的物件叫做attractor,而且為了不要搞得太複雜,我們設定attractor不會到處跑,只會待在固定的地方。

因為attractor是個固定不動具有引力的物體,所以會有質量、位置、大小、等特性(attribute),以及能夠在畫面上將其顯示出來的方法;這部分可以參考Mover類別的寫法來設計。除此之外,為了讓attractor能夠吸引其他物體,還必須納入萬有引力常數這個特性,以及設計出能產生引力吸引其他物體的方法。

假設mover是由Mover類別產生的物件,那要怎麼讓moverattractor能夠溝通而產生引力,使得moverattractor吸引過去呢?現在就來看看有哪些方法可以達到這個目的。

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

attraction(attractor, mover)

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

attractormover吸引過來」,這是站在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

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

完成後的Attractor類別長這樣:

class Attractor:
def __init__(self, x, y, mass, G=1):
# 取得顯示畫面的大小
self.screen = pygame.display.get_surface()
self.width, self.height = self.screen.get_size()

self.G = G

self.mass = mass

# 物體的質量越大,尺寸就會越大
self.size = self.mass
self.radius = self.size/2

# attractor的位置
self.x, self.y = x, y
self.position = pygame.Vector2(x, y)

# 設定attractor所在surface的格式為per-pixel alpha
self.surface = pygame.Surface((self.size, self.size), pygame.SRCALPHA)

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.surface.fill((255, 255, 255, 0))

# 畫出具有透明度的attractor
center = pygame.Vector2(self.radius, self.radius)
pygame.draw.circle(self.surface, (0, 0, 0, 150), center, self.radius)

# 把attractor所在的surface貼到最後要顯示的畫面上
self.screen.blit(self.surface, self.position-center)

下面這個例子,可以看出模擬的效果。因為Mover類別並未更動,所以程式未列出。

Example 2.6: Attraction

# python version 3.10.9
import sys

import pygame # version 2.3.0


pygame.init()

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

WHITE = (255, 255, 255)

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

FPS = 60
frame_rate = pygame.time.Clock()

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

attractor = Attractor(WIDTH/2, HEIGHT/2, 40, 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)

在這個例子中,我們設定attractormover的半徑大小,是跟它們的質量大小呈正比的關係。這個設定並不怎麼精確,無法反映出真實世界中,物體大小和質量間的關係。因為attractormover都是圓,而半徑為r的圓,面積是π r²,所以比較精確的做法,是設定attractormover的半徑大小,正比於它們質量的0.5次方,也就是

r ∝ m0.5

這樣子才能夠比較真實地呈現出,物體的大小和質量間的關係。

Exercise 2.11

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

# mover大小
self.size = 2*self.mass**0.5
self.radius = self.size/2

# attractor大小
self.size = 2*self.mass**0.5
self.radius = self.size/2

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

Example 2.7: Attraction with Many Movers

# python version 3.10.9
import random
import sys

import pygame # version 2.3.0


pygame.init()

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

WHITE = (255, 255, 255)

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

FPS = 60
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,就可以畫出挺漂亮的圖案。下面是正在畫的當中先後擷取出來的兩張截圖。

raw-image
raw-image

主程式如下:

# python version 3.10.9
import math
import sys

import pygame # version 2.3.0


pygame.init()

pygame.display.set_caption("Exercise 2.12")

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

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

FPS = 500
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

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


15會員
129內容數
寫點東西自娛娛人
留言0
查看全部
發表第一個留言支持創作者!
ysf的沙龍 的其他內容
在真實世界中有各式各樣的作用力影響著我們,那在模擬世界中呢?要怎麼在本來無一物的模擬世界中,製造出作用力呢?
到目前為止,為了簡化問題,我們都假設物體的質量是1。接下來,我們將移除這個假設,然後將完全符合牛頓第二運動定律的apply_force()方法,整合到Mover這個類別中。
這一節要來看看,有許多個力同時作用時,該怎麼處理。
這一節談的是牛頓的三大運動定律,以及力對於物體運動狀態的影響。
這一章介紹的是力(force),以及力與加速度間的關係。
介紹以物件導向的方式,以向量來實作物體運動的模擬程式。
在真實世界中有各式各樣的作用力影響著我們,那在模擬世界中呢?要怎麼在本來無一物的模擬世界中,製造出作用力呢?
到目前為止,為了簡化問題,我們都假設物體的質量是1。接下來,我們將移除這個假設,然後將完全符合牛頓第二運動定律的apply_force()方法,整合到Mover這個類別中。
這一節要來看看,有許多個力同時作用時,該怎麼處理。
這一節談的是牛頓的三大運動定律,以及力對於物體運動狀態的影響。
這一章介紹的是力(force),以及力與加速度間的關係。
介紹以物件導向的方式,以向量來實作物體運動的模擬程式。
你可能也想看
Google News 追蹤
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
美國總統大選只剩下三天, 我們觀察一整週民調與金融市場的變化(包含賭局), 到本週五下午3:00前為止, 誰是美國總統幾乎大概可以猜到60-70%的機率, 本篇文章就是以大選結局為主軸來討論近期甚至到未來四年美股可能的改變
Thumbnail
Faker昨天真的太扯了,中國主播王多多點評的話更是精妙,分享給各位 王多多的點評 「Faker是我們的處境,他是LPL永遠繞不開的一個人和話題,所以我們特別渴望在決賽跟他相遇,去直面我們的處境。 我們曾經稱他為最高的山,最長的河,以為山海就是盡頭,可是Faker用他28歲的年齡...
在真實世界中有各式各樣的作用力影響著我們,那在模擬世界中呢?要怎麼在本來無一物的模擬世界中,製造出作用力呢?
到目前為止,為了簡化問題,我們都假設物體的質量是1。接下來,我們將移除這個假設,然後將完全符合牛頓第二運動定律的apply_force()方法,整合到Mover這個類別中。
Thumbnail
這篇內容,將會講解什麼是變數,以及與變數相關的知識。包括變數、資料型態、變數賦值、變數的命名規則、變數的作用區域、變數的可重複性、內建變數。
這一節要來看看,有許多個力同時作用時,該怎麼處理。
這一節談的是牛頓的三大運動定律,以及力對於物體運動狀態的影響。
介紹以物件導向的方式,以向量來實作物體運動的模擬程式。
介紹如何在模擬物體運動時,引入加速度這個物理量。
Thumbnail
這一節談的是向量的定義,以及如何運用向量來建立模擬物體運動時,關於位置和速度間的關係式。
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
美國總統大選只剩下三天, 我們觀察一整週民調與金融市場的變化(包含賭局), 到本週五下午3:00前為止, 誰是美國總統幾乎大概可以猜到60-70%的機率, 本篇文章就是以大選結局為主軸來討論近期甚至到未來四年美股可能的改變
Thumbnail
Faker昨天真的太扯了,中國主播王多多點評的話更是精妙,分享給各位 王多多的點評 「Faker是我們的處境,他是LPL永遠繞不開的一個人和話題,所以我們特別渴望在決賽跟他相遇,去直面我們的處境。 我們曾經稱他為最高的山,最長的河,以為山海就是盡頭,可是Faker用他28歲的年齡...
在真實世界中有各式各樣的作用力影響著我們,那在模擬世界中呢?要怎麼在本來無一物的模擬世界中,製造出作用力呢?
到目前為止,為了簡化問題,我們都假設物體的質量是1。接下來,我們將移除這個假設,然後將完全符合牛頓第二運動定律的apply_force()方法,整合到Mover這個類別中。
Thumbnail
這篇內容,將會講解什麼是變數,以及與變數相關的知識。包括變數、資料型態、變數賦值、變數的命名規則、變數的作用區域、變數的可重複性、內建變數。
這一節要來看看,有許多個力同時作用時,該怎麼處理。
這一節談的是牛頓的三大運動定律,以及力對於物體運動狀態的影響。
介紹以物件導向的方式,以向量來實作物體運動的模擬程式。
介紹如何在模擬物體運動時,引入加速度這個物理量。
Thumbnail
這一節談的是向量的定義,以及如何運用向量來建立模擬物體運動時,關於位置和速度間的關係式。