更新於 2024/09/16閱讀時間約 1 分鐘

The Nature of Code閱讀心得與Python實作:3.9 Spring Forces

在Exercise 3.7中,我們曾經利用sin函數來模擬彈簧吊錘(bob)的運動,雖然這樣子的做法程式很容易寫,但是卻沒辦法模擬彈簧吊錘受到如風力、重力等環境中其他作用力的影響下,在空間中的運動狀況。要克服這樣子的問題,就不能再倚靠sin函數,而必須改用能夠用來計算彈簧彈力的虎克定律(Hooke's law)。

raw-image

根據虎克定律

彈簧的彈力正比於彈簧的形變量

這裡的形變量,指的是彈簧被拉長或擠短的量;所以,把彈簧拉得越長或擠得越短,彈簧回復原狀的力量就會越大。

虎克定律的數學式是

Fspring = -kx

其中,k是彈簧常數(spring constant);x是彈簧的形變量,計算方式是將彈簧當前的長度減去在平衡狀態時的長度。彈簧在平衡狀態時的長度,也稱為靜止長度(rest length)。

因為力是向量,在計算時,必須算出它的大小和方向,所以在寫程式時,我們會利用下列向量變數與純量變數來存放彈簧的資料:

anchor  # 向量,存放錨釘的位置
bob_position # 向量,存放吊錘的位置
rest_length # 純量,存放彈簧的靜止長度

利用虎克定律來計算彈簧的彈力時,需要知道彈簧常數k和彈簧的形變量x。k就只是個常數,所以設定個適合的常數就可以了:

k = 0.1

要計算x,必須知道彈簧當前的長度和靜止長度。彈簧的靜止長度是原本就知道的數字,假設是放在rest_length裡頭。彈簧當前的長度,其實就是錨釘和吊錘之間的距離,也就是由錨釘到吊錘的向量長度。所以

current_length = (bob_position - anchor).length()
x = current_length - rest_length

從x的計算方式可以知道,當x>0時,代表彈簧被拉長;而當x<0時,則代表彈簧被擠短。

有了k和x之後,就可以算出彈簧彈力的大小。那彈簧彈力的方向呢?如前面的圖所顯示的,當彈簧被拉長時,會有一股力量讓它朝著錨釘的方向縮回去;而當彈簧被擠短時,則會有一股力量,讓它朝著遠離錨釘的方向伸長,虎克定律式子中的-1,就是在描述這個方向反轉的現象。所以,想要知道彈簧彈力的方向,就要先知道在拉長彈簧時,施加在彈簧上的拉力的方向。要找出這個拉力的方向其實很簡單,既然彈簧的一端是錨釘,而另一端是吊錘,當我們拉著吊錘讓彈簧變長時,拉力的方向,很顯然的,就是從錨釘到吊錘的方向,也就是向量

bob_position - anchor

的方向。

根據上述的分析,利用虎克定律,我們就可以算出彈簧彈力。計算彈簧彈力的程式,可以這樣寫:

k = 0.1
force = bob_position - anchor
current_length = force.length()
x = current_length - rest_length
force = -k*x*force.normalize()

有了可以計算彈簧彈力的方法之後,接下來就要來看看怎麼用物件導向的方式來寫模擬的程式。不過,在實際動手寫之前,要先考慮一下整個程式的架構要長怎樣,畢竟寫法不只一種。

在模擬彈簧的運動時,整個系統是由彈簧和吊錘所組成,彈簧提供彈力,而吊錘受力之後,就在畫面上跑來跑去。既然吊錘會在畫面上跑來跑去,而我們先前所建立的Mover類別產生的物件,也會在畫面上跑來跑去,那順理成章的,我們可以就把吊錘看成是類似於Mover類別所建立的物件,然後用處理Mover類別的方式來處理。略微修改一下Mover類別,另外再加入阻尼的作用,讓吊錘像真實世界看到的情況那樣,運動速度會慢慢地降下來,最後回到靜止狀態,這樣就可以得到如下用來模擬彈簧吊錘的Bob類別:

class Bob:
def __init__(self, x, y, mass):
# 取得顯示畫面
self.screen = pygame.display.get_surface()

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

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

# 加入阻尼,讓振盪速度越來越小
self.damping = 0.98

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

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

def apply_force(self, force):
self.acceleration += force/self.mass

def update(self):
self.velocity += self.acceleration
self.velocity *= self.damping
self.position += self.velocity

self.acceleration *= 0

def show(self):
pygame.draw.circle(self.screen, (0, 0, 0), self.position, self.radius)

設計好Bob類別之後,接下來就來處理彈簧的部分。我們可以直接把計算彈簧彈力的程式碼全都直接寫主程式中,然後用Bob類別的apply_force()方法,讓彈力作用在吊錘上,像這樣:

spring_force = 算出來的彈簧彈力
bob.apply_force(spring_force)

但是,考慮到同一條彈簧有可能會掛上不只一個吊錘,或者會有好幾條彈簧串在一起的情況,另外設計一個Spring類別來處理彈簧的部分,會是比較好的做法。

Spring類別中,需要有個方法,用來將彈簧彈力作用在吊錘上。在2.6節設計Body類別時,我們設計了attract()這個方法,用來計算引力,並將引力作用在物體上。這樣子的寫法也可以運用到Spring類別上,用來計算彈簧彈力,並將彈力作用在吊錘上。當我們把吊錘吊掛在彈簧上,跟彈簧連接在一起時,彈簧的彈力才會作用在吊錘上;所以,就把這個讓吊錘連上彈簧的方法命名為connect()。有了connect()方法後,當要讓一個spring物件把彈力作用在一個bob物件時,程式就可以寫成

spring.connect(bob)

完整的Spring類別程式碼如下:

class Spring:
def __init__(self, x, y, spring_constant, rest_length):
# 取得顯示畫面
self.screen = pygame.display.get_surface()

# 彈簧常數
self.k = spring_constant

# 錨釘的位置
self.anchor = pygame.Vector2(x, y)

# 彈簧靜止長度
self.rest_length = rest_length

def connect(self, bob):
force = bob.position - self.anchor
current_length = force.length()
x = current_length - self.rest_length # 彈簧形變量
force = -self.k*x*force.normalize()

bob.apply_force(force)

def show(self):
# 畫錨釘
pygame.draw.circle(self.screen, (0, 0, 0), self.anchor, 5)

def show_line(self, bob):
# 畫彈簧線
pygame.draw.line(self.screen, (0, 0, 0, 50), self.anchor, bob.position, 3)

原書在設計Spring類別時,是把彈簧常數直接寫死。不過,考慮到不同的彈簧,可能會有不同的彈簧常數,所以這裡不把彈簧常數寫死,改為在產生Spring物件時設定。這樣子的設計方式,在使用上應該會比較有彈性一點。

Example 3.10: A Spring Connection

# python version 3.10.9
import math
import sys

import pygame # version 2.3.0

pygame.init()

pygame.display.set_caption("Example 3.10: A Spring Connection")

WHITE = (255, 255, 255)

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

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

x, y = width//3, 150
mass = 24
bob = Bob(x, y, mass)

x, y = width//2, 10
spring_constant = 0.2
rest_length = 100
spring = Spring(x, y, spring_constant, rest_length)

gravity = pygame.Vector2(0, 2)

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

screen.fill(WHITE)

bob.apply_force(gravity)

spring.connect(bob)

bob.update()
bob.show()

spring.show()
spring.show_line(bob)

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

Exercise 3.13

當彈簧拉長或擠短到超過限定長度時,因為不能再進一步拉長或擠短,而造成吊錘停止運動;所以,這時除了需將彈簧長度調整成限定的長度外,也必須將吊錘的速度歸零。

def constrain_length(self, bob, min_length, max_length):
direction = bob.position - self.anchor
current_length = direction.length()
if current_length <= min_length:
bob.velocity = pygame.Vector2(0, 0)
direction.scale_to_length(min_length)
bob.position = self.anchor + direction
elif current_length >= max_length:
bob.velocity = pygame.Vector2(0, 0)
direction.scale_to_length(max_length)
bob.position = self.anchor + direction

Exercise 3.14

修改Spring類別。除了刪除錨釘相關的程式碼,也要修改connect()方法,讓彈簧兩端都能掛上吊錘。要注意的是,當彈簧兩端連接的都是吊錘時,作用在兩個吊錘上的彈力,大小相等但方向會相反。另外,constrain_length()也需修改,彈簧長度的計算,原先是計算錨釘到吊錘間的距離,現在則是改為計算兩端兩個吊錘間的距離。修改後的Spring類別及主程式如下:

class Spring:
def __init__(self, spring_constant, rest_length):
# 取得顯示畫面
self.screen = pygame.display.get_surface()

# 彈簧常數
self.k = spring_constant

# 彈簧靜止長度
self.rest_length = rest_length

def connect(self, bob1, bob2):
# 計算作用在bob2的作用力,方向是從bob1到bob2
force = bob2.position - bob1.position
current_length = force.length()
x = current_length - self.rest_length # 彈簧形變量
force = -self.k*x*force.normalize()

# 當彈簧兩端連接的都是吊錘時,作用在兩個吊錘的彈力,大小相等但方向相反。
bob2.apply_force(force)
bob1.apply_force(-force)

def constrain_length(self, bob1, bob2, min_length, max_length):
direction = bob2.position - bob1.position
current_length = direction.length()
if current_length <= min_length:
bob1.velocity = pygame.Vector2(0, 0)
bob2.velocity = pygame.Vector2(0, 0)
direction.scale_to_length(min_length)
bob2.position = bob1.position + direction
elif current_length >= max_length:
bob1.velocity = pygame.Vector2(0, 0)
bob2.velocity = pygame.Vector2(0, 0)
direction.scale_to_length(max_length)
bob2.position = bob1.position + direction

def show_line(self, bob1, bob2):
pygame.draw.line(self.screen, (0, 0, 0), bob1.position, bob2.position, 3)


# python version 3.10.9
import math
import random
import sys


import pygame # version 2.3.0

pygame.init()

pygame.display.set_caption("Exercise 3.15")

WHITE = (255, 255, 255)

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

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

# 吊錘及彈簧的數量
n = 5

x, y = width//2, height//2

mass = 12
bobs = [Bob(x+i*15*random.randint(-n, n), y+i*15*random.randint(-n, n), mass) for i in range(n)]

spring_constant = 0.05
rest_length = 100
springs = [Spring(spring_constant, rest_length) for i in range(n)]

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

screen.fill(WHITE)

for spring in springs:
for i in range(n):
bob1 = bobs[i]
bob2 = bobs[(i+1) % n]
spring.connect(bob1, bob2)

for bob in bobs:
bob.update()

for spring in springs:
for i in range(n):
bob1 = bobs[i]
bob2 = bobs[(i+1) % n]
spring.constrain_length(bob1, bob2, 30, 300)
spring.show_line(bob1, bob2)

for bob in bobs:
bob.show()

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

在模擬時,先將所有的吊錘連接上彈簧,讓彈力作用在吊錘上,然後再更新吊錘的狀態。等吊錘狀態更新後,再來檢查及調整彈簧的長度,並畫出彈簧,最後才畫出吊錘。這樣子的處理順序,主要是在避免類似2.6節所討論過的,因為以不同的順序來處理物件間的引力以及更新物件狀態時,所造成的模擬上的差異。

分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.