這一節的標題是
4.9 Image Textures and Additive Blending
因為方格子標題字數限制,所以沒完整顯現
粒子系統可以用來製作視覺特效(visual effect, VFX),而粒子外觀的呈現方式,以及粒子具有怎樣的紋理(texture),都會影響特效所展現出來的效果。例如,在下圖中可以很清楚地看到,使用兩種不同的粒子紋理所呈現出來的特效效果,就有很大的不同。
要賦予粒子不同的紋理,可以直接用程式來寫,不過使用繪圖軟體製作成圖片,會比較節省力氣,畢竟現在的繪圖軟體功能強大,使用上又簡單。
在製作紋理圖片時,首選具備透明度功能的PNG格式。以前面那張圖右手邊,看起來有漸層效果紋理的粒子圖片來說,其製作方式,就是在全白的圖片上,以從中央擴散的方式,讓像素的透明度逐漸增加到完全透明為止。這樣子,當把圖片放到黑色背景上時,就會呈現出漸層的效果。如果圖片不支援透明度功能,就沒辦法呈現出這樣的效果。
製作好圖片之後,就可以利用粒子系統來製作模擬煙霧的特效;不過,在開始寫程式之前,得先搞清楚pygame
的混色模式(blend mode, blending mode)。
混色模式指的是,當把不同的顏色混在一起,要透過怎樣的計算方式,來決定最後出現的是什麼樣的顏色。在pygame
中,混色模式除了BLEND_PREMULTIPLIED
、BLEND_ALPHA_SDL2
這兩個比較特殊不在此討論的模式之外,可以由命名方式區分為兩大類,而其主要的差異在於混色時,包含或不包含透明度。
不包含透明度的混色模式
BLEND_ADD, BLEND_SUB, BLEND_MULT, BLEND_MIN, BLEND_MAX
BLEND_RGB_ADD, BLEND_RGB_SUB, BLEND_RGB_MULT, BLEND_RGB_MIN, BLEND_RGB_MAX.
這裡要注意的是,中間有或沒有RGB
三個字母,其實是一樣的模式。例如,BLEND_ADD
和BLEND_RGB_ADD
,指的是一樣的模式。
包含透明度的混色模式
BLEND_RGBA_ADD, BLEND_RGBA_SUB, BLEND_RGBA_MULT, BLEND_RGBA_MIN, BLEND_RGBA_MAX
這些模式會利用不同的計算式,來決定混色之後的顏色數值。假設src
是來源像素的顏色,而dest
是目標像素的顏色,且
0 <= src, dest <= 255
當把src
混色到dest
而得到顏色c
時,各個模式的RGBA
值計算式為:
ADD: c = (dest+src) if (dest+src)<=255 else 255
SUB: c = (dest-src) if (dest-src)>=0 else 0
MULT: c = (dest*src+255)//256
MIN: c = min(dest, src)
MAX: c = max(dest, src)
其中MULT
模式有兩個比較特別的性質,在接下來的例子中會用到。是怎樣特別的性質呢?從MULT
的計算式可以很明顯地看出來,當dest
或src
有一個是0
時,則混色之後的值,就會是0
;而當dest
或src
有一個是255
時,則混色之後的值,會是另外那個顏色的值。所以,當我們把一張具備透明度的圖片,以MULT
混色模式blit
到一張顏色為(255, 255, 255, A)
的圖片之後,所得到的圖片,顏色會跟原來的圖片一樣,就只有透明度會改變而已。
在pygame
的blit()
和fill()
方法中,有個special_flags
參數,可以用來選擇混色模式。如果不特別指定而使用預設的混色模式,則混色後的顏色,計算方式為:
if destA == 0:
c = src
A = srcA
else:
c = (src*srcA + dest*(255-srcA))//255
A = srcA + destA*(255-srcA)//255
這裡的srcA
、destA
分別指的是來源像素和目標像素的alpha
值,而A
則是混色後所得到的alpha
值。不過要注意的是,這兩個式子所算出的值,和用pygame
所算出的值,可能會有些小小的差距。造成這個小小差距的原因,在於pygame
為了加快計算速度,用了很多技巧來避免浮點數運算,但同時也犧牲了一些計算上的準確度。另外,從計算式可以看出來,當目標像素是完全透明時,混色之後得到的會是來源像素,完全無視目標像素的存在。
下面這個例子,就是利用前面那張圖右手邊,看起來有漸層效果紋理的粒子圖片,以混色的方式,來模擬煙霧被風吹動時的效果。
Example 4.8: An Image-Texture Particle System
粒子的圖片製作完成之後,可以使用pygame
的image.load()
來載入。假設圖片檔名是fuzzy-circle.png
,程式這樣寫:
img = pygame.image.load("fuzzy-circle.png")
如果要讓img
的格式是per-pixel alpha
,則載入時需使用convert_alpha()
,寫成
img = pygame.image.load("fuzzy-circle.png").convert_alpha()
為了避免在處理時破壞了原本的圖片,所以最好是複製一份副本,然後用這份副本來進行後續的處理工作。要複製圖片,程式這樣寫:
particle_image = img.copy()
另外,為了要讓粒子以更像煙霧的樣子噴出,在設定粒子的初始速度時,不再單純使用亂數來設定,而改用高斯分佈,讓大部分粒子的初始速度集中在給定的mean值附近。這部分的程式這樣寫:
vx = random.gauss(0, 0.3)
vy = random.gauss(-1, 0.3)
velocity = pygame.Vector2(vx, vy)
根據上述的方式,並考量圖片變數的傳遞,原本Particle
類別的__init__()
方法,要改成這樣:
def __init__(self, x, y, mass, particle_image):
# 取得顯示畫面
self.screen = pygame.display.get_surface()
# 讓傳遞進來的數值來決定particle的質量
self.mass = mass
# 複製圖片並取得圖片尺寸
self.particle_image = particle_image.copy()
self.size = particle_image.get_width()
# 粒子半徑
self.radius = self.size/2
# particle的壽命
self.lifespan = 100
# 讓傳遞進來的數值來決定particle的起始位置
self.position = pygame.Vector2(x, y)
# 讓particle的初始速度呈高斯分佈
vx = random.gauss(0, 0.3)
vy = random.gauss(-1, 0.3)
self.velocity = pygame.Vector2(vx, vy)
# particle的初始加速度
self.acceleration = pygame.Vector2(0, 0)
# 設定particle所在surface的格式為per-pixel alpha
self.surface = pygame.Surface((self.size, self.size), pygame.SRCALPHA)
在這裡,為了要傳遞圖片變數,所以在__init__()
方法中,新增了一個particle_image
參數。
除了Particle
類別的__init__()
方法之外,Emitter
類別的add_particle()
方法也需增加一個參數,用來傳遞圖片變數:
def add_particle(self, particle_image):
self.particles.append(Particle(self.origin.x, self.origin.y, self.mass, particle_image))
要讓噴出的粒子看起來就像煙霧一樣,在將畫有粒子的圖片blit
到最後要顯示的畫面上之前,要先把圖片以BLEND_RGBA_MULT
的混色方式,blit
到全白但有些透明的surface
上,藉此在不破壞漸層紋理效果的情況下,調整圖片的透明度。所以,Particle
類別的show()
方法,要改成這樣:
def show(self):
# 根據particle剩下的壽命長短,以MULT混色模式調整圖片透明度
self.surface.fill((255, 255, 255, self.lifespan))
self.surface.blit(self.particle_image, (0, 0), None, pygame.BLEND_RGBA_MULT)
# 把particle所在的surface貼到最後要顯示的畫面上
center = pygame.Vector2(self.radius, self.radius)
self.screen.blit(self.surface, self.position-center)
主程式以及繪製畫面上方風力指示向量的函數程式碼如下:
def draw_vector(screen, start_pt, end_pt, arrow_size=10):
# 繪製風力指示向量
# 箭頭方向
direction = 1 if end_pt[0] > start_pt[0] else -1
# 箭身
pygame.draw.line(screen, (255, 255, 255), start_pt, end_pt, 2)
# 箭頭上半部
pt_upper = (end_pt[0]-direction*arrow_size, start_y-arrow_size/2)
pygame.draw.line(screen, (255, 255, 255), end_pt, pt_upper, 2)
# 箭頭下半部
pt_lower = (pt_upper[0], start_y+arrow_size/2)
pygame.draw.line(screen, (255, 255, 255), end_pt, pt_lower, 2)
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Example 4.8: An Image-Texture Particle System")
BLACK = (0, 0, 0)
WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
try:
particle_image = pygame.image.load("fuzzy-circle.png").convert_alpha()
except:
print("無法載入圖片......")
pygame.quit()
sys.exit()
emitter = Emitter(WIDTH/2, HEIGHT-100, 0.5)
# 畫面上方風力指示向量起點
start_x = WIDTH//2
start_y = end_y = 100
start_pt = (start_x, start_y)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(BLACK)
x, y = pygame.mouse.get_pos()
# 風力大小為 -0.2 ~ 0.2
wind = pygame.Vector2(-0.2 + 0.4*(x/WIDTH), 0)
# 畫面上方風力指示向量終點
end_pt = (start_x+0.5*(x-start_x), end_y)
draw_vector(screen, start_pt, end_pt)
emitter.add_particle(particle_image)
emitter.apply_force(wind)
emitter.run()
pygame.display.update()
frame_rate.tick(FPS)
在製作紋理圖片時,除了紋理之外,也要注意圖片的解析度是否合適。在算繪(render)時,解析度越高的圖片會需要越多的計算能力;如果需要動態地改變圖片大小時,更是如此。所以,最好的情況是,使用的圖片大小,就剛好是要呈現在畫面上的大小,這樣在算繪時,就不需要額外再去調整圖片大小,可以加快執行速度。
那如果要呈現在畫面上的圖片大小需要動態地調整,製作圖片時,又該怎麼選擇圖片的大小呢?這時候,要呈現在畫面上的最大尺寸,就是製作紋理圖片時,圖片該有的尺寸。之所以這樣選擇,是為了避免放大圖片,因為放大圖片所需的計算量,會比縮小圖片所需的計算量大。製作紋理圖片時使用最大的尺寸,那在執行程式的過程中,即便需要動態調整圖片大小,也只會需要縮小圖片尺寸,可以避免進行需要較多計算量的圖片放大作業。
Exercise 4.11
使用的粒子圖片
效果
程式部分。調整粒子y方向初始速度的分佈範圍,讓火焰噴高一點:
def __init__(self, x, y, mass, particle_image):
:
:
vy = random.gauss(-2, 0.3)
:
:
讓粒子不再隨著壽命減短而淡出畫面,把show()
方法的第一行程式
self.surface.fill((255, 255, 255, self.lifespan))
改成
self.surface.fill((255, 255, 255, 255))
主程式部分,去除風力的作用,讓火焰向上燃燒。
Exercise 4.12
使用三種不同的粒子圖片
效果
主程式
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Exercise 4.12")
BLACK = (0, 0, 0)
WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
particle_images = []
load_image_failed = False
for i in range(1, 4):
try:
img = pygame.image.load(f"particle-image{i}.png").convert_alpha()
particle_images.append(img)
except:
print(f"無法載入圖片{i}......")
load_image_failed = True
if load_image_failed:
pygame.quit()
sys.exit()
emitter = Emitter(WIDTH/2, HEIGHT-100, 0.5)
# 畫面上方風力指示向量起點
start_x = WIDTH//2
start_y = end_y = 100
start_pt = (start_x, start_y)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(BLACK)
x, y = pygame.mouse.get_pos()
# 風力大小為 -0.2 ~ 0.2
wind = pygame.Vector2(-0.2 + 0.4*(x/WIDTH), 0)
# 畫面上方風力指示向量終點
end_pt = (start_x+0.5*(x-start_x), end_y)
draw_vector(screen, start_pt, end_pt)
# 增加新粒子時,隨機從list中選出一張粒子圖片
emitter.add_particle(particle_images[random.randint(0, 2)])
emitter.apply_force(wind)
emitter.run()
pygame.display.update()
frame_rate.tick(FPS)
最後要特別來看看由Robert Hodgin首創的混色模式:additive blending。這個混色模式的做法其實很簡單,就是在混色時,直接把兩個像素的顏色值相加,如果相加之後的值超過255,那就設定成255。所以,當一層一層畫面不斷混色累加時,顏色會越來越亮,呈現出非常太空時代的發光效果。
Example 4.9: Additive Blending
修改Particle
類別的show()
方法:
def show(self):
img = self.particle_image.copy()
# 改變圖片顏色並依據particle剩下的壽命長短來調整透明度
img.fill((255, 100, 255, self.lifespan), None, pygame.BLEND_RGBA_MULT)
# 利用預設的混色模式來讓圖片顏色變淡
self.surface.fill((0, 0, 0, 1))
self.surface.blit(img, (0, 0))
# 使用ADD混色模式把particle所在的surface貼到最後要顯示的畫面上
center = pygame.Vector2(self.radius, self.radius)
self.screen.blit(self.surface, self.position-center, None, pygame.BLEND_RGB_ADD)
主程式:
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Example 4.9: Additive Blending")
BLACK = (0, 0, 0)
WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
try:
particle_image = pygame.image.load("fuzzy-circle.png").convert_alpha()
except:
print("無法載入圖片......")
pygame.quit()
sys.exit()
emitter = Emitter(WIDTH/2, HEIGHT-100, 0.5)
# 畫面上方風力指示向量起點
start_x = WIDTH//2
start_y = end_y = 100
start_pt = (start_x, start_y)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(BLACK)
x, y = pygame.mouse.get_pos()
# 風力大小為 -0.2 ~ 0.2
wind = pygame.Vector2(-0.2 + 0.4*(x/WIDTH), 0)
# 畫面上方風力指示向量終點
end_pt = (start_x+0.5*(x-start_x), end_y)
draw_vector(screen, start_pt, end_pt)
# 一次增加三個粒子
for i in range(3):
emitter.add_particle(particle_image)
emitter.apply_force(wind)
emitter.run()
pygame.display.update()
frame_rate.tick(FPS)
Exercise 4.13
修改Emitter
類別的add_particle()
方法:
def add_particle(self, particle_image, amount=1):
for i in range(amount):
self.particles.append(Particle(self.origin.x, self.origin.y, self.mass, particle_image))
主程式中,一次增加三個粒子的寫法:
emitter.add_particle(particle_image, 3)
Exercise 4.14
修改Particle
類別的__init__()
方法:
# particle的壽命
self.lifespan = 200
# particle的初始速度
vx = random.randint(-2, 2)
vy = random.randint(-2, 2)
# 新增這一行
self.color = random.choice([(255, 0, 0), (0, 255, 0), (0, 0, 255)])
修改Particle
類別的show()
方法:
# 改變圖片顏色並依據particle剩下的壽命長短來調整透明度
img.fill(self.color+(self.lifespan,), None, pygame.BLEND_RGBA_MULT)
主程式
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Exercise 4.14")
BLACK = (0, 0, 0)
WIDTH, HEIGHT = screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
try:
particle_image = pygame.image.load("fuzzy-circle.png").convert_alpha()
except:
print("無法載入圖片......")
pygame.quit()
sys.exit()
emitter = Emitter(WIDTH//2, HEIGHT//2, 0.5)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(BLACK)
emitter.add_particle(particle_image, 3)
emitter.run()
pygame.display.update()
frame_rate.tick(FPS)