更新於 2024/10/25閱讀時間約 28 分鐘

The Nature of Code閱讀心得與Python實作:4.9 Image Textures and ...

這一節的標題是
4.9 Image Textures and Additive Blending
因為方格子標題字數限制,所以沒完整顯現

粒子系統可以用來製作視覺特效(visual effect, VFX),而粒子外觀的呈現方式,以及粒子具有怎樣的紋理(texture),都會影響特效所展現出來的效果。例如,在下圖中可以很清楚地看到,使用兩種不同的粒子紋理所呈現出來的特效效果,就有很大的不同。

raw-image

要賦予粒子不同的紋理,可以直接用程式來寫,不過使用繪圖軟體製作成圖片,會比較節省力氣,畢竟現在的繪圖軟體功能強大,使用上又簡單。

在製作紋理圖片時,首選具備透明度功能的PNG格式。以前面那張圖右手邊,看起來有漸層效果紋理的粒子圖片來說,其製作方式,就是在全白的圖片上,以從中央擴散的方式,讓像素的透明度逐漸增加到完全透明為止。這樣子,當把圖片放到黑色背景上時,就會呈現出漸層的效果。如果圖片不支援透明度功能,就沒辦法呈現出這樣的效果。

製作好圖片之後,就可以利用粒子系統來製作模擬煙霧的特效;不過,在開始寫程式之前,得先搞清楚pygame的混色模式(blend mode, blending mode)。

混色模式指的是,當把不同的顏色混在一起,要透過怎樣的計算方式,來決定最後出現的是什麼樣的顏色。在pygame中,混色模式除了BLEND_PREMULTIPLIEDBLEND_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_ADDBLEND_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的計算式可以很明顯地看出來,當destsrc有一個是0時,則混色之後的值,就會是0;而當destsrc有一個是255時,則混色之後的值,會是另外那個顏色的值。所以,當我們把一張具備透明度的圖片,以MULT混色模式blit到一張顏色為(255, 255, 255, A)的圖片之後,所得到的圖片,顏色會跟原來的圖片一樣,就只有透明度會改變而已。

pygameblit()fill()方法中,有個special_flags參數,可以用來選擇混色模式。如果不特別指定而使用預設的混色模式,則混色後的顏色,計算方式為:

if destA == 0:
c = src
A = srcA
else:
c = (src*srcA + dest*(255-srcA))//255
A = srcA + destA*(255-srcA)//255

這裡的srcAdestA分別指的是來源像素和目標像素的alpha值,而A則是混色後所得到的alpha值。不過要注意的是,這兩個式子所算出的值,和用pygame所算出的值,可能會有些小小的差距。造成這個小小差距的原因,在於pygame為了加快計算速度,用了很多技巧來避免浮點數運算,但同時也犧牲了一些計算上的準確度。另外,從計算式可以看出來,當目標像素是完全透明時,混色之後得到的會是來源像素,完全無視目標像素的存在。

下面這個例子,就是利用前面那張圖右手邊,看起來有漸層效果紋理的粒子圖片,以混色的方式,來模擬煙霧被風吹動時的效果。

Example 4.8: An Image-Texture Particle System

粒子的圖片製作完成之後,可以使用pygameimage.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)


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