【日常篇】009_基于质点动力学的二维烟花模拟

在遥远的过去,城市建设还没有这么发达的时候,烟花爆竹是可以随意燃放的,那时每年春节听鞭炮、礼花弹声都会从除夕的清晨一直听到元宵的夜晚,可以说是十分地震撼。但随着城市的发展、空气的恶化以及环保意识的提升等众多原因,如今在全国大多数的城区都不能再像以前那样自由”玩耍”了,这使得近几年的春节都显得异常地”冷清”
今年的春节,依旧是一个不能在所在城市燃放烟花爆竹的春节。但和前几年不同的是,现在手上有了足够便利的图形库以及足够方便的语言,这使得”将现实生活中的热闹转移到程序中”的想法得以实现
虽然在现实生活中观赏不到漫天烟花这一盛景了,但在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.⎩⎪⎪⎨⎪⎪⎧​​d t d x ​=v m d t d v ​=m g ​−c v v ​
其中− c v v ⃗ -cv\vec v −c v v即为阻力项,c c c为阻力相关参数,记c m = γ \frac{c}{m}=\gamma m c ​=γ,则运动方程最终写为:
{ 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.⎩⎪⎪⎨⎪⎪⎧​​d t d x ​=v d t d v ​=g ​−γv v ​
因为在这里考虑的是二维情形,所以将方向分解到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.⎩⎪⎪⎪⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎪⎪⎪⎧​​d t d x ​=v x ​d t d v x ​​=−γv v x ​d t d x ​=v y ​d t d v y ​​=−g −γv v y ​​
给定初始条件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}v x ​∣t =0 ​,v y ∣ t = 0 v_y|{t=0}v y ​∣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.⎩⎪⎪⎪⎪⎨⎪⎪⎪⎪⎧​​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 ​
其中v i = v x , i 2 + v y , i 2 v_i=\sqrt{v_{x,\ i}^2+v_{y,\ i}^2}v i ​=v x ,i 2 ​+v y ,i 2 ​​

如此以来,就可以通过给定的初始条件,求出任意一个质点的运动轨迹。本次模拟的烟花,就是基于这个模型运动的

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)

    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)

    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)):

            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修一下,也加到程序中作为背景。这样一来,简易二维烟花模拟的程序便完成了

明年,将会考虑三维的情形。在三维的情形下,自由度应该会更高,也许能获得更好看的效果

效果展示

【日常篇】009_基于质点动力学的二维烟花模拟
PS:服了……就这个烟花视频,审核都不给通过,我也是醉了……

; 代码链接

https://github.com/VtaSkywalker/firework_2022

Original: https://blog.csdn.net/qq_41959720/article/details/122867356
Author: 十万行出头天
Title: 【日常篇】009_基于质点动力学的二维烟花模拟

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/781489/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球