接下來要來看看Reynolds所設計的「流場循行(flow-field following)」轉向行為。
要設計vehicle的流場循行轉向行為,首先必須要知道什麼是「流場(flow field)」。簡單來說,如果把二維平面劃分出許多方格,而在每一個方格中,都存有一個指向特定方向的向量,那這些存有指示方向資料的方格所構成的平面,就是流場;所以在流場中的每個位置,都會有對應的方向。從這個關於流場的簡單說明,應該不難想到:流場循行,應該就是讓vehicle在流場上移動時,會依循流場位置所對應的方向,來調整他的運動方向。
知道什麼是流場之後,現在就來設計用來描述流場的類別:FlowField
。
要描述一個流場,FlowField
類別應該要有下列屬性:
w
:流場寬度;整數h
:流場高度;整數resolution
:解析度,也就是方格的邊長;整數cols
:直行數量,也就是w//resolution
;整數rows
:橫列數量,也就是h//resolution
;整數field
:大小為rows×cols
之二維List
,存放每個方格內之流場向量
有了這些屬性之後,我們就可以把流場向量給存放到field
中。例如,如果流場方格內的所有方向都是向右,程式可以這樣寫:
for i in range(rows):
for j in range(cols):
field[i][j] = pygame.Vector2(1, 0)
如果方向是隨機的,程式可以寫成
for i in range(rows):
for j in range(cols):
x, y = random.uniform(-1, 1), random.uniform(-1, 1)
field[i][j] = pygame.Vector2(x, y)
if field[i][j].length() != 0:
field[i][j].normalize_ip()
else:
field[i][j] = pygame.Vector2(0, 0)
這裡我們讓所有位置的流場向量都正規化,但並非一定要這麼做;流場向量的大小,可以依照實際需要而調整。另外,在if
條件判斷式中,當判斷向量為0
時,我們會看起來多此一舉地將流場向量設為0
向量,這是因為pygame
在向量長度小於某一個值時,就會將其視為0
向量;換句話說,這並不是真正的0
向量,而只是誤差範圍內的0
向量,所以有必要將其設為真正的0
向量,以避免後續使用上可能產生的問題。
當然,也可以使用Perlin noise來模擬自然界中流場的樣式:
xoff, yoff = 1, 101
for i in range(rows):
yoff += 0.05
for j in range(cols):
xoff += 0.05
# 將介於-1~1範圍的Perlin noise映射至0~360
angle_deg = 180*(noise.pnoise2(xoff, yoff)+1)
# 將角度相符的單位向量設為流場向量
field[i][j] = pygame.Vector2.from_polar((1, angle_deg))
在這裡,我們是將介於-1~1範圍的Perlin noise映射至0~360,然後將符合這個角度的單位向量作為流場向量。這裡的寫法並非絕對的,只要能夠形成想要的樣式即可。事實上,光是使用不同的Perlin noise參數,就足以產生許多不同樣式的流場出來了。
Exercise 5.6
for i in range(self.rows):
y = (0.5-i/self.rows)
for j in range(self.cols):
x = (0.5-j/self.cols)
self.field[i][j] = pygame.Vector2(x, y).rotate(-90)
流場向量存放在field
這個二維List
中,那我們怎麼知道在畫面上某個位置的流場向量方向呢?其實只要能知道那個位置是在哪個方格內,就能知道對應的流場向量了。要知道點(x, y)在哪個方格內,可以這樣計算:
column = int(x//resolution)
row = int(y//resolution)
所以,點(x, y)所在位置的流場向量,就是存放在field[row][column]
中之向量。這裡因為x
、y
有可能是浮點數,而List
的索引值只能是整數,所以在計算時,必須將算出來的結果轉為整數。
在實際應用時,因為vehicle有可能會跑出畫面而離開流場,導致在存取field
時,因為超出索引值的範圍而出現錯誤。所以,我們就加上判斷式,讓column
、row
不會超出範圍:
if column < 0:
column = 0
elif column > cols-1:
column = cols-1
if row < 0:
row = 0
elif row > rows-1:
row = rows-1
完整的FlowField
類別程式碼如下:
class FlowField:
def __init__(self, field_width, field_height, resolution):
self.screen = pygame.display.get_surface()
self.w = field_width
self.h = field_height
self.resolution = resolution
self.cols = self.w//self.resolution
self.rows = self.h//self.resolution
self.field = [[0]*self.cols for _ in range(self.rows)]
def init(self):
xoff, yoff = random.uniform(1, 100), random.uniform(101, 200)
for i in range(self.rows):
yoff += 0.05
for j in range(self.cols):
xoff += 0.05
# 將介於-1~1範圍的Perlin noise映射至0~360
angle_deg = 180*(noise.pnoise2(xoff, yoff)+1)
# 將角度相符的單位向量設為流場向量
self.field[i][j] = pygame.Vector2.from_polar((1, angle_deg))
def lookup(self, x, y):
column = int(x//self.resolution)
if column < 0:
column = 0
elif column > self.cols-1:
column = self.cols-1
row = int(y//self.resolution)
if row < 0:
row = 0
elif row > self.rows-1:
row = self.rows-1
return self.field[row][column]
def show(self):
for i in range(self.rows):
for j in range(self.cols):
v = self.field[i][j]
if v.length() > 0:
# 流場方格內之向量線段長度為v的長度之兩倍
v.scale_to_length(self.resolution/3)
# 流場方格中心點
pt = pygame.Vector2((j+0.5)*self.resolution, (i+0.5)*self.resolution)
# 畫流場方格內之向量線段
pygame.draw.line(self.screen, (0, 0, 0), pt-v, pt+v)
def show_vector(self):
for i in range(self.rows):
for j in range(self.cols):
v = self.field[i][j]
if v.length() > 0:
# 流場方格內之向量線段長度為v的長度之兩倍
v.scale_to_length(self.resolution/3)
# 流場方格中心點
pt = pygame.Vector2((j+0.5)*self.resolution, (i+0.5)*self.resolution)
# 畫流場方格內之向量線段
pygame.draw.line(self.screen, (0, 0, 0), pt-v, pt+v)
# 畫向量箭頭
s1 = -v.rotate(30)
if s1.length() > 0:
s1.scale_to_length(s1.length()/2)
s2 = -v.rotate(-30)
if s2.length() > 0:
s2.scale_to_length(s2.length()/2)
pygame.draw.line(self.screen, (0, 0, 0), pt+v, pt+v+s1)
pygame.draw.line(self.screen, (0, 0, 0), pt+v, pt+v+s2)
def show_grid(self):
# 畫橫線
for i in range(self.rows+1):
pt1 = (0, i*self.resolution)
pt2 = (self.cols*self.resolution, i*self.resolution)
pygame.draw.line(self.screen, (200, 200, 200), pt1, pt2, 3)
# 畫直線
for i in range(self.cols+1):
pt1 = (i*self.resolution, 0)
pt2 = (i*self.resolution, self.rows*self.resolution)
pygame.draw.line(self.screen, (200, 200, 200), pt1, pt2, 3)
設計好流場類別之後,接下來就來讓vehicle具有流場循行轉向行為的能力。
要讓vehicle具有流場循行的能力,只要把vdesired速度的方向設定成流場的方向即可。在Reynolds的原始設計中,vehicle會去預測他在未來某個時間點上會位於流場的哪個位置,然後把vdesired速度的方向,設定成和那個位置對應的流場方向一致。不過,原書在這裡略微簡化了一下,直接就讓vdesired速度的方向,和vehicle現在所在位置的流場方向一致。根據這樣的做法,可以在Vehicle
類別中,加入下面的方法,來讓vehicle具備流場循行轉向行為的能力:
def follow(self, flow_field):
x, y = self.position
desired = flow_field.lookup(x, y)
if desired.length() != 0:
desired.scale_to_length(self.max_speed)
steer = desired - self.velocity
if steer.length() > self.max_force:
steer.scale_to_length(self.max_force)
self.apply_force(steer)
在這個方法中,vehicle會利用FlowField
類別的lookup()
方法來取得所在位置的流場向量。原書特別提到,lookup()
方法也可以寫在Vehicle
類別中;不過,從OOP封裝的原則來看,把lookup()
方法寫在FlowField
類別中會是比較合理的做法,畢竟lookup()
方法所要擷取的資料,是屬於FlowField
類別的。
在下面這個例子中,vehicle會展現出流場循行的轉向行為。執行時,按滑鼠任一鍵,可以重置流場;按鍵盤空白鍵,可以切換是否畫出流場。
Example 5.4: Flow-Field Following
主程式如下:
# python version 3.10.9
import math
import random
import sys
import noise # version 1.2.2
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Example 5.4: Flow-Field Following")
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
screen_size = WIDTH, HEIGHT = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
flow_field = FlowField(WIDTH, HEIGHT, 20)
flow_field.init()
vehicles = []
for _ in range(120):
x = random.randint(0, WIDTH)
y = random.randint(0, HEIGHT)
vehicle = Vehicle(x, y)
vehicle.max_speed = random.uniform(2, 5)
vehicle.max_force = random.uniform(0.1, 0.5)
vehicles.append(vehicle)
show_field = True
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.MOUSEBUTTONDOWN:
flow_field.init()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
show_field = not show_field
screen.fill(WHITE)
if show_field:
#flow_field.show()
flow_field.show_vector()
# flow_field.show_grid()
for vehicle in vehicles:
vehicle.follow(flow_field)
vehicle.update()
vehicle.check_edges()
vehicle.show()
pygame.display.update()
frame_rate.tick(FPS)
Exercise 5.7
修改FlowField
類別的init()
方法:
def init(self):
xoff, yoff, zoff = 1, 101, time.localtime().tm_sec/10
for i in range(self.rows):
yoff += 0.05
for j in range(self.cols):
xoff += 0.05
# 將介於-1~1範圍的Perlin noise映射至0~360
angle_deg = 180*(noise.pnoise3(xoff, yoff, zoff)+1)
# 將角度相符的單位向量設為流場向量
self.field[i][j] = pygame.Vector2.from_polar((1, angle_deg))
主程式中,加入讓流場每隔固定時間就重置的功能:
# python version 3.10.9
import math
import random
import sys
import time
import noise # version 1.2.2
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Exercise 5.7")
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
screen_size = WIDTH, HEIGHT = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
flow_field = FlowField(WIDTH, HEIGHT, 20)
flow_field.init()
vehicles = []
for _ in range(120):
x = random.randint(0, WIDTH)
y = random.randint(0, HEIGHT)
vehicle = Vehicle(x, y)
vehicle.max_speed = random.uniform(2, 5)
vehicle.max_force = random.uniform(0.1, 0.5)
vehicles.append(vehicle)
show_field = True
t_start = time.localtime().tm_sec
# 重置流場之時間間隔,單位為「秒」
time_window = 2
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.MOUSEBUTTONDOWN:
flow_field.init()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
show_field = not show_field
screen.fill(WHITE)
# 若經過的時間大於設定的時間間隔,則重置流場
t_end = time.localtime().tm_sec
if abs(t_end - t_start) >= time_window:
flow_field.init()
t_start = t_end
if show_field:
flow_field.show()
# flow_field.show_vector()
# flow_field.show_grid()
for vehicle in vehicles:
vehicle.follow(flow_field)
vehicle.update()
vehicle.check_edges()
vehicle.show()
pygame.display.update()
frame_rate.tick(FPS)
Exercise 5.8
定義像素亮度的值為r+g+b
,並將流場向量方向設定為由暗到亮的方向。
def init(self, img):
for i in range(self.rows):
y0 = i*self.resolution
for j in range(self.cols):
x0 = j*self.resolution
# 計算方格內像素之亮度值、座標值
pixel_data = []
for y in range(y0, y0+self.resolution):
for x in range(x0, x0+self.resolution):
r, g, b = img.get_at((x, y))[:3]
pixel_data.append((r+g+b, pygame.Vector2(x, y)))
# 向量方向為從最暗點到最亮點的方向
pt_max = max(pixel_data, key=lambda x: x[0])[1]
pt_min = min(pixel_data, key=lambda x: x[0])[1]
self.field[i][j] = pt_max - pt_min