更新於 2024/12/02閱讀時間約 27 分鐘

The Nature of Code閱讀心得與Python實作:5.3 Flow Fields

接下來要來看看Reynolds所設計的「流場循行(flow-field following)」轉向行為。

raw-image

要設計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]中之向量。這裡因為xy有可能是浮點數,而List的索引值只能是整數,所以在計算時,必須將算出來的結果轉為整數。

在實際應用時,因為vehicle有可能會跑出畫面而離開流場,導致在存取field時,因為超出索引值的範圍而出現錯誤。所以,我們就加上判斷式,讓columnrow不會超出範圍:

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
分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.