在遥远的过去,城市建设还没有这么发达的时候,烟花爆竹是可以随意燃放的,那时每年春节听鞭炮、礼花弹声都会从除夕的清晨一直听到元宵的夜晚,可以说是十分地震撼。但随着城市的发展、空气的恶化以及环保意识的提升等众多原因,如今在全国大多数的城区都不能再像以前那样自由“玩耍”了,这使得近几年的春节都显得异常地“冷清”
今年的春节,依旧是一个不能在所在城市燃放烟花爆竹的春节。但和前几年不同的是,现在手上有了足够便利的图形库以及足够方便的语言,这使得“将现实生活中的热闹转移到程序中”的想法得以实现
虽然在现实生活中观赏不到漫天烟花这一盛景了,但在pygame中,却有着无限的可能
物理模型:空气阻力与重力
礼花弹中每一束礼花的生命周期,都可以简单地描述为:
(1)一个白点从箱子中向上喷出
(2)白点在经历了一定的时间后发生爆炸
(3)各种颜色的点从白点爆炸的地方向四周喷出
上述提到的所有运动都可以简化为质点运动,因此在物理模型的选用上只需考虑质点动力学即可
对于近地上抛的物体而言,不可忽略的力除了重力之外,还有一个与运动方向相反的空气阻力。这样的质点运动方程为:
{
d
x
⃗
d
t
=
v
⃗
m
d
v
⃗
d
t
=
m
g
⃗
−
c
v
v
⃗
\left\{\begin{aligned} &\frac{d\vec x}{dt}=\vec v\\ &m\frac{d\vec v}{dt}=m\vec g-cv\vec v \end{aligned}\right.
⎩⎪⎪⎨⎪⎪⎧dtdx=vmdtdv=mg−cvv
其中
−
c
v
v
⃗
-cv\vec v
−cvv即为阻力项,
c
c
c为阻力相关参数,记
c
m
=
γ
\frac{c}{m}=\gamma
mc=γ,则运动方程最终写为:
{
d
x
⃗
d
t
=
v
⃗
d
v
⃗
d
t
=
g
⃗
−
γ
v
v
⃗
\left\{\begin{aligned} &\frac{d\vec x}{dt}=\vec v\\ &\frac{d\vec v}{dt}=\vec g-\gamma v\vec v \end{aligned}\right.
⎩⎪⎪⎨⎪⎪⎧dtdx=vdtdv=g−γvv
因为在这里考虑的是二维情形,所以将方向分解到
x
x
x和
y
y
y这两个分量上:
{
d
x
d
t
=
v
x
d
v
x
d
t
=
−
γ
v
v
x
d
x
d
t
=
v
y
d
v
y
d
t
=
−
g
−
γ
v
v
y
\left\{\begin{aligned} &\frac{dx}{dt}=v_x\\ &\frac{dv_x}{dt}=-\gamma vv_x\\ &\frac{dx}{dt}=v_y\\ &\frac{dv_y}{dt}=-g-\gamma vv_y \end{aligned}\right.
⎩⎪⎪⎪⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎪⎪⎪⎧dtdx=vxdtdvx=−γvvxdtdx=vydtdvy=−g−γvvy
给定初始条件
x
∣
t
=
0
x|_{t=0}
x∣t=0,
y
∣
t
=
0
y|_{t=0}
y∣t=0,
v
x
∣
t
=
0
v_x|_{t=0}
vx∣t=0,
v
y
∣
t
=
0
v_y|_{t=0}
vy∣t=0,从
t
=
0
t=0
t=0开始,使用最基本的欧拉方法进行数值求解:
{
x
i
+
1
=
x
i
+
v
x
,
i
Δ
t
v
x
,
i
+
1
=
v
x
,
i
−
γ
v
i
v
x
,
i
Δ
t
y
i
+
1
=
x
i
+
v
y
,
i
Δ
t
v
y
,
i
+
1
=
v
y
,
i
−
(
g
+
γ
v
i
v
y
,
i
)
Δ
t
\left\{\begin{aligned} &x_{i+1}=x_i+v_{x,\ i}\Delta t\\ &v_{x,\ i+1}=v_{x,\ i}-\gamma v_i v_{x,\ i}\Delta t\\ &y_{i+1}=x_i+v_{y,\ i}\Delta t\\ &v_{y,\ i+1}=v_{y,\ i}-(g + \gamma v_i v_{y,\ i})\Delta t\\ \end{aligned}\right.
⎩⎪⎪⎪⎪⎨⎪⎪⎪⎪⎧xi+1=xi+vx, iΔtvx, i+1=vx, i−γvivx, iΔtyi+1=xi+vy, iΔtvy, i+1=vy, i−(g+γvivy, i)Δt
其中
v
i
=
v
x
,
i
2
+
v
y
,
i
2
v_i=\sqrt{v_{x,\ i}^2+v_{y,\ i}^2}
vi=vx, i2+vy, i2
如此以来,就可以通过给定的初始条件,求出任意一个质点的运动轨迹。本次模拟的烟花,就是基于这个模型运动的
def motionUpdate(self, eachTrack):
"""
运动状态的更新
"""
# 考虑空气阻力、重力
eachTrack.pos[0] = eachTrack.pos[0] + eachTrack.velocity[0] * self.dt
eachTrack.pos[1] = eachTrack.pos[1] + eachTrack.velocity[1] * self.dt
v = (eachTrack.velocity[0]**2 + eachTrack.velocity[1]**2)**0.5
vx = eachTrack.velocity[0]
vy = eachTrack.velocity[1]
eachTrack.velocity[0] = vx - eachTrack.gamma * v * vx * self.dt
eachTrack.velocity[1] = vy + (self.g - eachTrack.gamma * v * vy) * self.dt
烟花模型
在喷出烟花的时候,同一方向同一速度上可以连续喷出数个质点,相邻两个质点的间隔时间为数值计算的时间间隔 Δ t \Delta t Δt,这样一来很多个质点连在一起,就可以拖出一道轨迹
烟花在爆炸后,亮度自然是会随着冷却而下降的,在图像上表现为不透明度的降低,直至最后完全消失。在这里为了方便,使用的是线性降低
一个质点可以用一个结点来描述,而当很多个质点连在一起时,就可以使用链表来描述这串质点形成的轨迹
由此定义链表(轨迹)以及链表结点(质点):
class Track:
"""
烟花的轨迹,由链表的形式存储
"""
def __init__(self, pos, velocity, explodeThreshold, alphaDelayRate, color, gamma=0):
self.pos = pos
self.velocity = velocity
self.head = TrackNode(pos, 255, color)
self.head.next = self.head
self.head.last = self.head
self.nodeNumber = 1
self.maxNodeNumber = 10
self.lifeTime = 0
self.isExplode = False
self.explodeThreshold = explodeThreshold
self.alphaDelayRate = alphaDelayRate
self.gamma = gamma # 空气阻力系数
class TrackNode:
"""
烟花结点
"""
def __init__(self, pos, alpha, color):
self.pos = pos
self.alpha = alpha
self.color = color
self.next = None
self.last = None
其中,alpha为不透明度,color为rgb三元组,在pygame中的最大值均为255。maxNodeNumber为链表最大的结点数,可以用来控制轨迹的长度。lifeTime用于记录这个轨迹被释放出来的时间长度,通过和explodeThreshold相比较可以判断是否达到爆炸的时刻。isExplode用于表示是否已经爆炸。alphaDelayRate用于表示不透明度值的衰减速度,单位为每秒。gamma为上述微分方程中的空气阻力系数,通过调整这个参数可以看到不同的运动效果,在这里考虑不同的烟花下落速度不一样,是因为空气阻力常数不同导致的
燃放烟花的场景
场景布置
前面提到的是“物理引擎”和烟花的各种参数,现在将其结合起来,真正地放到一个场景中,去模拟烟花的燃放:
class Stage:
"""
场景逻辑类
"""
def __init__(self):
self.tracks = []
self.width = 1080
self.height = 540
self.g = 98 # 重力加速度参数
self.dt = 0.05 # 时间间隔
作为一个用于燃放烟花的场景,自然需要知道当前在场上有哪些烟花的轨迹(tracks),也需要知道这个场景有多大(width, height),重力加速度常量对于场景中的所有物体都是一样的,整个场景只能有一个重力加速度参数(g),数值模拟的时间间隔也直接决定了模拟的精度和轨迹的密度( Δ t \Delta t Δt)
生成新的烟花
烟花刚生成的时候就是一个没有爆炸的火药:
def addFireWork(self):
"""
生成新的烟花
"""
pos = [random.random() * self.width, self.height + 20]
velocity = [40 - 80 * random.random(), -200 - 120 * random.random()]
explodeThreshold = 2.2 + 0.2 * random.random()
newFireWork = Track(pos=pos, velocity=velocity, explodeThreshold=explodeThreshold, alphaDelayRate=100, color=[255, 255, 255])
self.tracks.append(newFireWork)
首先随机地初始化烟花的位置和速度,这样就有了初始条件。因为是上升一段时间就要爆炸的火药,所以适当地将爆炸前的时间设定在2.0-2.4秒之间。火药在这里设定为白色,所以颜色选为白色。生成了这个新的轨迹后,就将其添加到场景中
烟花状态更新
def stateUpdate(self):
"""
烟花状态更新
"""
newTrackList = []
deleteTrackList = []
for eachTrack in self.tracks:
# 未爆炸情形
if(eachTrack.isExplode == False):
self.unExplosionTrackUpdate(eachTrack)
# 爆炸情形:对于已经爆炸了的火药,自然是不能留了,现在所能看到的是视觉残留
else:
self.explosionTrackUpdate(eachTrack)
# 更新爆炸状态
self.explosionStateUpdate(eachTrack, newTrackList)
# 增加/删除track
self.trackListUpdate(newTrackList, deleteTrackList)
在这个场景中,烟花分为已爆炸的和未爆炸的。对于未爆炸的烟花:
def unExplosionTrackUpdate(self, eachTrack):
"""
未爆炸track的更新
"""
p = eachTrack.head.last
# 结点数未达上限时,新增到链表末尾
if(eachTrack.nodeNumber < eachTrack.maxNodeNumber):
eachTrack.addNode(p.pos, 255, p.color)
# 头结点位置、速度更新
self.motionUpdate(eachTrack)
# 各个结点的位置、alpha更新
while(p != eachTrack.head):
p.pos = p.last.pos
self.alphaUpdate(eachTrack, p)
p = p.last
# 头结点:
p.pos = [eachTrack.pos[0], eachTrack.pos[1]]
self.alphaUpdate(eachTrack, p)
由于初始化的轨迹只有一个结点,因此当结点数少于上限时,需要在尾部增加一个新的结点。而当结点数达到上限后,则只需更新每个结点的运动状态即可。因为相邻结点在时间上都是相邻的,所以在更新运动状态时,只需要“从后往前”更新即可
而对于已经爆炸的轨迹,它的使命已经完成了,因此在这里就让它的结点从尾部到头部开始删除(实际上每次删的都是头结点,但因为每个结点都有了位置更新,所以看起来删的是尾结点):
def explosionTrackUpdate(self, eachTrack):
"""
爆炸track的更新
"""
# 所有结点往头结点方向移动一位
p = eachTrack.head.last
while(p != eachTrack.head):
p.pos = p.last.pos
p.alpha = p.last.alpha
p = p.last
eachTrack.delNode(p)
# 如果已经删的只剩头了,就可以消失了
if(eachTrack.nodeNumber == 1):
eachTrack.nodeNumber = 0
对于已经删光了结点以及离开画面太远的轨迹,就可以将其从场景中移除了,以免越看越卡:
def trackListUpdate(self, newTrackList, deleteTrackList):
"""
增加/删除track
"""
# 增加新的轨迹
for eachNewTrackList in newTrackList:
self.tracks.append(eachNewTrackList)
# 移除结点全部删光了的链表(以及跑出屏幕一定距离的)
for eachTrack in self.tracks:
if(eachTrack.nodeNumber == 0):
deleteTrackList.append(eachTrack)
if(((eachTrack.pos[0] - self.width / 2)**2 + (eachTrack.pos[1] - self.height / 2)**2)**0.5 >= 604):
deleteTrackList.append(eachTrack)
for eachDeleteTrackList in deleteTrackList:
self.tracks.remove(eachDeleteTrackList)
烟花爆炸的三种形式
在这里构思出了三种不同类型的烟花,第一种是常见的“柳树”型:
def explosionWillow(self, eachTrack, newTrackList):
"""
炸出柳树烟花
"""
color = [255 * random.random(), 255 * random.random(), 255 * random.random()]
alphaDelayRate = 100
velocityAmp = 60 + 40 * random.random()
# 爆炸,放出烟花轨迹
for theta in np.linspace(0, 2*np.pi, int(4 + 6 * random.random())+1, endpoint=False):
theta += 15 / 180 * np.pi - 30 / 180 * np.pi * random.random()
pos = [eachTrack.pos[0], eachTrack.pos[1]]
velocity = np.array([velocityAmp * np.cos(theta), velocityAmp * np.sin(theta)]) + np.array(eachTrack.velocity)
newTrack = Track(pos=pos, velocity=velocity, explodeThreshold=np.inf, alphaDelayRate=alphaDelayRate, color=color, gamma=1e-2)
newTrackList.append(newTrack)
在这里会随机炸出5——10条轨迹,这些轨迹将会在空中持续变暗,在2.55秒的时间后完全消失
第二种是很多个点的烟花(在现实生活中会不停地闪烁,这里不会闪起来。以及“孔雀开屏”这个词是乱写的,实在想不出该起什么名字了……):
def explosionPeacock(self, eachTrack, newTrackList):
"""
孔雀开屏
"""
alphaDelayRate = 155
color = [255 * random.random(), 255 * random.random(), 255 * random.random()]
for theta in np.linspace(0, 2*np.pi, 30, endpoint=False):
velocityAmp = 40 + 80 * random.random()
pos = [eachTrack.pos[0], eachTrack.pos[1]]
velocity = np.array([velocityAmp * np.cos(theta), velocityAmp * np.sin(theta)]) + np.array(eachTrack.velocity)
newTrack = Track(pos=pos, velocity=velocity, explodeThreshold=np.inf, alphaDelayRate=alphaDelayRate, color=color, gamma=1e-2)
newTrack.maxNodeNumber = 2
newTrackList.append(newTrack)
第三种则是因为想不出该设计什么类型了,而随意设计的,二级爆炸型。二级爆炸的情形下,会继续炸出6——8个火药,这些被炸出的火药还会再次爆炸,以炸出更多的烟花:
def doubleExplosion(self, eachTrack, newTrackList):
"""
二级爆炸
"""
color = [255, 255, 255]
alphaDelayRate = 200
velocityAmp = 160 + 20 * random.random()
# 爆炸,放出烟花轨迹
for theta in np.linspace(0, 2*np.pi, int(5 + 2 * random.random())+1, endpoint=False):
theta += 15 / 180 * np.pi - 30 / 180 * np.pi * random.random()
pos = [eachTrack.pos[0], eachTrack.pos[1]]
velocity = np.array([velocityAmp * np.cos(theta), velocityAmp * np.sin(theta)]) + np.array(eachTrack.velocity)
newTrack = Track(pos=pos, velocity=velocity, explodeThreshold=1, alphaDelayRate=alphaDelayRate, color=color, gamma=1e-2)
newTrackList.append(newTrack)
在一个火药爆炸的时候,就会随机爆炸为上述三种烟花中的一种:
def explosionStateUpdate(self, eachTrack, newTrackList):
"""
更新爆炸状态
"""
eachTrack.lifeTime += self.dt
if (eachTrack.lifeTime - self.dt < eachTrack.explodeThreshold <= eachTrack.lifeTime):
eachTrack.isExplode = True
fireWorkType = int(3 * random.random())
if(fireWorkType == 0):
self.explosionWillow(eachTrack, newTrackList)
elif(fireWorkType == 1):
self.explosionPeacock(eachTrack, newTrackList)
elif(fireWorkType == 2):
if(len(self.tracks) <= 255):
self.doubleExplosion(eachTrack, newTrackList)
else:
self.explosionWillow(eachTrack, newTrackList)
因为二级爆炸很容易引起更进一步的三级爆炸、四级爆炸……这样下去很容易导致程序卡死,因此设定一个阈值(这里是255),当场景内的轨迹数量超过这个阈值时,就会强制在随机抽中二级烟花的时候,将其替换为柳树烟花。这样就避免了程序可能出现的卡死
画面显示
首先是按照场景设定的大小,初始化图形界面:
class Display:
"""
画面显示
"""
def __init__(self):
self.stage = Stage()
pygame.init()
self.size = (self.stage.width, self.stage.height)
self.screen = pygame.display.set_mode(self.size)
self.bgimg = pygame.image.load("bgimg.png")
在图形化界面的主循环中计时,到了一定的时间间隔就增加一个时间步长 Δ t \Delta t Δt或放出一个新的烟花:
def mainLoop(self):
FRAME_INTERV = 17
FIREWORK_INTERV = 0.33
TIME_SCALE = 2
tick = 0
fireWorkTick = 0
while True:
for event in pygame.event.get():
if (event.type == pygame.QUIT):
sys.exit()
pygame.time.delay(FRAME_INTERV)
tick += 1
fireWorkTick += 1
if(tick == round(self.stage.dt / (FRAME_INTERV / 1e3) / TIME_SCALE)):
# print(self.stage.tracks)
self.stage.stateUpdate()
tick = 0
if(fireWorkTick == round(FIREWORK_INTERV / (FRAME_INTERV / 1e3) / TIME_SCALE)):
self.stage.addFireWork()
fireWorkTick = 0
self.draw()
pygame.display.update()
同时,在每一次更新画面的时候,都要将整个场景的当前景象画出来:
def draw(self):
"""
绘制烟花
"""
self.screen.fill((0,0,0))
self.screen.blit(self.bgimg, (0,0))
for eachTrack in self.stage.tracks:
p = eachTrack.head
while(p.next != eachTrack.head):
surface = pygame.Surface((5,5), pygame.SRCALPHA)
color = [p.color[0], p.color[1], p.color[2], p.alpha]
pygame.draw.circle(surface, color=color, center=(2.5,2.5), radius=2.5, width=0)
self.screen.blit(surface, (p.pos[0], p.pos[1]))
p = p.next
从网上挑一张适合用作背景的图片下来,用GIMP修一下,也加到程序中作为背景。这样一来,简易二维烟花模拟的程序便完成了
明年,将会考虑三维的情形。在三维的情形下,自由度应该会更高,也许能获得更好看的效果
效果展示
PS:服了……就这个烟花视频,审核都不给通过,我也是醉了……