资讯动态

Python爱心粒子特效:从粒子系统到贝塞尔曲线的完整实现

发布时间:2026/10/23 6:55:56 来源:尧图企业网站定制
简介一份可直接运行的Python爱心粒子代码面向Python初学者、图形动画爱好者以及需要趣味编程素材的开发者。资源基于pygame库实现通过定义HeartParticle粒子类来管理粒子的位置、速度、加速度和生命周期利用三次贝塞尔曲线结合直线段绘制出爱心轮廓并加入透明度随寿命递减的动画配合循环判断让粒子呈现起伏呼吸的灵动效果。代码中提供了完整的HeartParticle类、bezier_curve函数及运行示例同时细致讲解了粒子系统、贝塞尔曲线、动画渲染等核心知识点读者可轻松调整粒子数量、速度、颜色等参数打造个性化的爱心特效。包内共1个docx文档39KB文档将可运行代码与图文解析整合一体离线即可对照阅读与复现。该资源已有7700余人学习下载适合作为编程练习、节日表白或Python图形动画教学的实用参考。1. 爱心粒子代码一套能跑的粒子系统比表白更重要的是这三块知识点python 的软件生态里爱心可视化一直是被搜得最多的资源之一网上各种版本我也见过不少大多是静态图形或者单纯循环动画。这份爱心粒子代码不太一样它把粒子系统、贝塞尔曲线和动画循环三件事塞进了同一个 Demo 里——每个小红点都有自己的速度、加速度和寿命爱心轮廓用曲线控制点逐帧扫描出来整体跑起来是动态的粒子流。它适合两类人一是刚学完 Python 基础、想练 pygame 和面向对象写法的同学二是想找一份能直接改参数、改颜色、改粒子的可运行素材的开发者。接下来我按“结构 → 曲线原理 → 调参 → 避坑 → 二次封装”的顺序把这个项目拆透。2. 环境与代码骨架先把依赖和主循环理清再谈效果2.1 依赖准备Python 版本、pygame 安装与运行入口这份代码用到了 pygame 和 random、math 标准库实际依赖只有一个第三方库。Python 版本方面项目本身没有用到 3.10 的新语法常见做法是装一个 Python 3.8 或 3.10 的环境我这边两个版本都跑过没有兼容性问题。安装依赖和执行入口如下pip install pygame python heart_particle.py依赖安装完成后运行脚本会弹出一个 800×600 的窗口标题是 “Python Love”窗口里会有红色粒子从心形区域向外飞散同时心形轮廓会持续显示。注意一点pygame 在部分 Linux 环境下需要额外装 SDL 相关系统库如果pip install pygame之后 import 报错先检查系统包管理器里的 SDL2 依赖Windows 和 macOS 下一般不会遇到这个问题。2.2 粒子类 HeartParticle位置、速度、加速度与寿命粒子是这个项目最核心的抽象代码里用了一个类来管理粒子的全部状态class HeartParticle(): def __init__(self, pos): self.pos [pos[0], pos[1]] self.vel [float(random.randint(-35, 35)) / 10, float(random.randint(-35, 35)) / 10] self.acc [float(random.randint(-10, 10)) / 200, float(random.randint(-10, 10)) / 200] self.lifespan random.randint(10, 50) def move(self): self.vel[0] self.acc[0] self.vel[1] self.acc[1] self.pos[0] self.vel[0] self.pos[1] self.vel[1] self.lifespan - 1 def draw(self, window): if not self.lifespan: return False alpha int(float(self.lifespan) / 50 * 255) pygame.draw.circle(window, (255, 0, 0, alpha), [int(self.pos[0]), int(self.pos[1])], 6) return True逻辑说明粒子每一帧执行一次 move速度先累加加速度位置再累加速度这是一个典型的欧拉积分模型。lifespan每帧减 1减到 0 后 draw 返回 False上层会把它从列表中移除。alpha的设计意图是让粒子随寿命增加而变淡但这里有个坑后面避坑章节会专门讲 pygame.draw 根本不处理 RGBA 的 alpha 分量。参数含义vel的随机范围是 -3.5 到 3.5除以 10 是把随机整数变成小数acc的随机范围是 -0.05 到 0.05单位是“每帧每帧的速度变化量”。也就是说加速度非常小粒子整体轨迹偏平滑不会出现突然转向。lifespan在 10 到 50 帧之间随机40fps 下大约存活 0.25 到 1.25 秒。2.3 主循环与绘制顺序fill、draw、flip 的顺序不能搞反主循环是 pygame 的标准结构但这份原始代码里的绘制顺序有一个严重问题我先把修正后的版本贴出来screen pygame.display.set_mode((800, 600)) pygame.font.init() pygame.display.set_caption(Python Love) clock pygame.time.Clock() done False particles [] while not done: for event in pygame.event.get(): if event.type pygame.QUIT: done True screen.fill((255, 255, 255)) draw_heart(400, 325, 3) particles draw_particles(screen, particles) append_particles(particles, [400, 300], 1) pygame.display.flip() clock.tick(40) pygame.quit()逻辑说明每一帧的标准流程是“清屏 → 画静态背景 → 更新并绘制粒子 → 追加新粒子 → 翻转缓冲”。screen.fill必须放在所有绘制之前否则上一帧的内容会残留在画面上并且先画的图形会被 fill 覆盖掉。原始代码把draw_particles放在 fill 前面等于粒子全部画在了即将被清空的画布上窗口里根本看不到粒子。clock.tick(40)控制帧率上限为 40fps这个值决定了粒子的运动速度观感。调高到 60 会让粒子动作更细腻但粒子寿命是按帧数计的同样帧数下存活时间反而变短调低到 30 会显得粒子运动一顿一顿。这个参数我一般不建议低于 30。3. 贝塞尔曲线画爱心控制点、扫描线与坐标换算详解3.1 de Casteljau 递归多控制点求曲线点爱心轮廓没有用画图软件导出的位图而是用贝塞尔曲线实时计算出来的。核心函数看名字很唬人其实只有十几行def bezier_curve(points, time): if len(points) 1: return points new_points [] for i in range(len(points) - 1): x (1 - time) * points[i][0] time * points[i 1][0] y (1 - time) * points[i][1] time * points[i 1][1] new_points.append([x, y]) return bezier_curve(new_points, time)逻辑说明这是 de Casteljau 算法的递归写法每一次递归都对相邻控制点做一次线性插值控制点数量减一一直递归到只剩一个点时这个点就是参数 time 对应的曲线上的点。time 从 0 连续变化到 1所有点连起来就是一条完整贝塞尔曲线。参数说明points是控制点列表数量可以是 3 个也可以是 5 个time是曲线参数范围 0 到 10 取起点1 取终点。需要注意严格来说 3 个控制点生成的是二次贝塞尔曲线5 个控制点生成的是四次贝塞尔曲线原文统称“三次贝塞尔曲线”并不严谨但递归算法本身对任意阶数都成立不影响运行。3.2 六组控制点与扫描线填充技巧draw_heart里定义了六组控制点它们两两配对在同一个 t 值下取两个曲线上的点并连线points1 [(0.5, 0.2), (0.43, -0.05), (0, -0.05)] points2 [(0, -0.05), (-0.43, -0.05), (-0.5, 0.2)] points3 [(-0.5, 0.2), (-0.5 * 0.04, 0.55), (0, 0.78), (0.5 * 0.04, 0.55), (0.5, 0.2)] points4 [(0.5, 0.2), (0.5 * 0.04, 0.55), (0, 0.78), (-0.5 * 0.04, 0.55), (-0.5, 0.2)] points5 [(0, -0.05), (0, 0.34), (-0.083, 0.55)] points6 [(0, -0.05), (0, 0.34), (0.083, 0.55)]这六组点的作用可以分成三对配对控制点数量覆盖区域作用points1 points23 3心形上半部左右两侧画出顶部到中心凹陷的轮廓points3 points45 5心形下半部画出两个半圆弧和底部尖角points5 points63 3心形中间凹陷补齐 V 形缺口附近的轮廓实际的绘制代码对每组配对各取贝塞尔曲线上同一 t 处的点用直线连接for i in range(100): t float(i) / 100 p1 bezier_curve(points1, t)[0] p2 bezier_curve(points2, t)[0] p3 bezier_curve(points3, t)[0] p4 bezier_curve(points4, t)[0] p5 bezier_curve(points5, t)[0] p6 bezier_curve(points6, t)[0] pygame.draw.line(window, (255, 255, 255), [x 64 * size * p1[0], y 48 * size * p1[1]], [x 64 * size * p2[0], y 48 * size * p2[1]], 3) pygame.draw.line(window, (255, 255, 255), [x 64 * size * p3[0], y 48 * size * p3[1]], [x 64 * size * p4[0], y 48 * size * p4[1]], 3) pygame.draw.line(window, (255, 255, 255), [x 64 * size * p5[0], y 48 * size * p5[1]], [x 64 * size * p6[0], y 48 * size * p6[1]], 3)逻辑说明这种画法叫同参数连线扫描。t 从 0 到 1 步进 100 次每次把两个对称控制组上相同参数的点连起来得到的是一簇扫描线。比如 points1 和 points2 的连线会从心形顶部一条一条往下扫扫过整个上半部points3 和 points4 的连线则从两侧往底部扫。100 条线叠在一起视觉上就呈现出一个有厚度的实心爱心轮廓而不是断开的点。线宽设为 3是为了让相邻线之间几乎没有间隙。3.3 归一化坐标与屏幕映射64 和 48 这两个缩放因子控制点坐标都是小数范围在 -0.5 到 0.78 之间直接画到窗口上只能得到几个像素的点。所以draw_heart里做了一步映射x cx - 32 * size y cy - 24 * size这里的cx、cy是爱心中心在屏幕上的像素坐标size是整体缩放倍数。控制点坐标 p[0] 映射到屏幕时乘 64 再乘 sizep[1] 乘 48 再乘 size。也就是说控制点 x 方向每 1.0 对应 64 个像素y 方向每 1.0 对应 48 个像素。为什么要两个方向用不同缩放因为爱心图形的原始宽高比大约是 64:48即 4:3两个方向分开缩放才能保持形状不变形。左上角的偏移量cx - 32 * size和cy - 24 * size也由这个比例得来图形总宽 64 个归一化单位中心点 cx 要在正中所以左边界是 cx 减一半宽度y 方向同理总高 48 个单位上边界是 cy 减去 24 个单位。这个换算关系在做多爱心、做缩放动画时非常关键后面第 6 章的呼吸效果就是靠动态改 size 实现的。4. 粒子动力学与动画节奏从能跑到好看的调参过程4.1 粒子生成策略正态分布半径与随机角度粒子不是从同一个点喷出来的而是在爱心中心附近按圆形区域随机扩散生成。原始代码的生成函数是这样的def append_particles(particles, center, size): for i in range(15): r int(random.normalvariate(0, 10) * size) angle random.uniform(0, 2 * 3.14) x center[0] r * math.sin(angle) y center[1] r * math.cos(angle) particles.append(HeartParticle([x, y]))逻辑说明每一帧追加 15 个新粒子生成位置用极坐标描述——先随机半径 r再随机角度 angle然后换算成屏幕坐标。radius 用的是正态分布而不是均匀分布这意味着大多数粒子会落在中心附近少数粒子离中心较远视觉上更加自然。random.uniform(0, 2 * 3.14)覆盖完整圆周粒子会从心形中心向 360 度方向散开。参数说明normalvariate(0, 10)的均值是 0标准差是 10乘 size 后半径分布范围基本在 ±30 像素以内。15 是每帧粒子数这个值直接决定屏幕上同时存在的粒子总量60 帧之后会达到一个动态平衡数量大约在几百个的量级。如果机器配置低可以把 15 改成 8粒子密度下降但流畅度明显提升。需要注意原始代码里粒子生成中心是[400, 300]而爱心中心画在[400, 325]y 方向差了 25 像素。实际上粒子是从爱心顶部上方一点的位置冒出来的如果你希望粒子看起来是从心形内部涌出把两处坐标统一即可。4.2 生命周期与透明度渐隐粒子的消失机制是寿命耗尽这段逻辑在 HeartParticle 的 draw 方法中def draw(self, window): if not self.lifespan: return False alpha int(float(self.lifespan) / 50 * 255) pygame.draw.circle(window, (255, 0, 0, alpha), [int(self.pos[0]), int(self.pos[1])], 6) return Truealpha的计算逻辑是寿命从 50 减到 0比例从 1 降到 0乘 255 得到透明度值。设计意图是粒子越老越透明最后淡出消失。这里有两个实际经验要提第一pygame.draw 在普通 Surface 上绘制时会忽略颜色元组里的 alpha 分量所以这段代码里的 alpha 实际上不会生效粒子直到寿命结束前都是纯红色结束瞬间直接消失没有渐隐效果第二如果你想真正实现渐隐需要用带 SRCALPHA 的 Surface 或者用 set_alpha 处理这个细节放到避坑章节展开。粒子的绘制大小固定在半径 6 像素这个值决定了单个粒子的视觉重量。调大到 8 会让爱心看起来更饱满但粒子之间会快速重叠成一大片红色调小到 3 会更细腻接近星光效果适合追求精致感的场景。4.3 帧率、粒子数量和心跳效果动画节奏由三个参数共同控制帧率、每帧粒子数、粒子速度范围。下面是我实际调过的一组对照参数默认值调高效果调低效果clock.tick 帧率40动作细腻CPU 占用升高粒子运动肉眼可见卡顿每帧粒子数15粒子密度大画面更满粒子稀疏心形区域空旷vel 随机范围±35 / 10粒子飞散速度快轨迹长粒子几乎原地蠕动lifespan10~50粒子存活时间长拖尾长粒子一闪即逝心跳效果是在不改变粒子系统的前提下让心形轮廓的 size 随时间周期性缩放。实现方式很简单用时间戳驱动一个正弦波import time while not done: beat abs(math.sin(time.time() * 4)) * 0.15 0.85 screen.fill((255, 255, 255)) draw_heart(400, 325, 3 * beat) particles draw_particles(screen, particles) append_particles(particles, [400, 300], 1) pygame.display.flip() clock.tick(40)逻辑说明math.sin(time.time() * 4)产生的值在 -1 到 1 之间波动取绝对值让波形不会出现负值再乘 0.15 缩放、加 0.85 抬升使 size 在 0.85 到 1.0 之间周期变化看起来就像爱心在呼吸。乘 4 是心跳频率系数数值越大心跳越快我实测 4 到 5 之间观感比较自然超过 8 会显得像抽搐。5. 避坑指南这份代码直接跑会遇到的五个实际问题5.1 NameError: name math is not defined直接把原始代码保存运行会在执行 append_particles 时抛出 NameError因为代码里用了 math.sin 和 math.cos文件头却只 import 了 pygame 和 random。原因项目引用了标准库 math但没有导入。这类问题经常出现在从网页复制代码的场景依赖导入行被漏掉。解决在文件头部补上import math。如果不想引入新依赖也可以把 append_particles 里的math.sin(angle)换成random.uniform配合正弦近似但完全没必要math 是 Python 内置标准库导入无成本。补上之后函数里的2 * 3.14建议顺手改成2 * math.pi精度更好。5.2 粒子画出来了但窗口里看不到运行后窗口只显示白色爱心轮廓红色粒子完全看不到。原因主循环的绘制顺序错误。原始代码先执行draw_particles再执行screen.fill((255, 255, 255))粒子画完之后被白色背景整个覆盖等于白画。同时draw_heart画轮廓用的颜色也是(255, 255, 255)白色在白底上同样不可见。解决把绘制顺序调整为fill → 画爱心 → 画粒子 → 追加粒子 → flip爱心轮廓颜色改成非白色比如(255, 105, 180)粉色或(60, 60, 60)深灰。这个坑很隐蔽因为它不报错只是显示效果完全不对新手排错时最容易忽略绘制顺序问题。5.3 粒子在画面上一帧被绘制两次轨迹变粗原版 draw_particles 函数同时承担了两件事绘制粒子和过滤死亡粒子。实现上把 draw 调用写了两遍def draw_particles(screen, particles): for p in particles: p.move() p.draw(screen) return [p for p in particles if p.draw(screen)]原因循环里画了一遍列表推导式里又画了一遍。存活粒子的圆会被绘制两次同一位置叠加后颜色分量被重复写入边缘发虚粒子轨迹看起来比预期粗。解决把两件事合并成一次遍历def draw_particles(screen, particles): alive [] for p in particles: p.move() if p.draw(screen): alive.append(p) return alive逻辑说明每一帧只 move 一次、只 draw 一次draw 返回 True 才保留到下一帧列表。这样既减少了重复绘制又避免了粒子生命周期还没结束就被误删的情况。原始写法里move 之后的粒子需要再次调用 draw 来判断是否存活逻辑上是通的但副作用是重绘。5.4 粒子的 alpha 渐隐不生效最后瞬间消失预期效果是粒子慢慢变淡再消失实际运行中粒子始终保持深红色寿命结束直接消失。原因pygame.draw.circle 接受的 color 元组虽然可以是 RGBA 四元组但在普通 RGB Surface 上绘制时 alpha 分量会被直接忽略透明效果不生效。这是 pygame 绘制 API 的常见限制不是代码逻辑的问题。解决如果临时想看到渐隐效果最简单的方式是不用透明度改用颜色深浅变化——把红色分量从 255 随寿命按比例调低例如(255, 0, 0)变到(180, 0, 0)视觉上有一部分“变暗消失”的感觉。要真正实现半透明粒子正确做法是创建一个带 SRCALPHA 标志的 Surfaceparticle_surface pygame.Surface((12, 12), pygame.SRCALPHA) particle_surface.set_alpha(alpha) pygame.draw.circle(particle_surface, (255, 0, 0), (6, 6), 6) window.blit(particle_surface, particle_pos)这种方式把每个粒子画到独立透明表面上再用 set_alpha 控制整体不透明度最后贴到主窗口。代价是每个粒子多一次 blit 操作粒子数量多的时候帧率会下降。5.5 运行几十秒后程序明显变卡粒子系统跑了 30 到 60 秒后画面帧率下降CPU 占用升高。原因每帧追加 15 个粒子如果 draw_particles 的过滤逻辑没修好粒子只绘制不清理列表无限增长。原版逻辑里 draw 返回 False 的粒子会被列表推导式过滤掉但前面那层重复 draw 会让部分粒子多活一帧加上生成速率较快实际存活粒子数比预期高。另一个潜在因素是粒子列表里的死亡粒子如果未被正确移除pygame.draw 每帧都要遍历大量不可见对象CPU 开销线性上涨。解决先按 5.3 的方式修正过滤逻辑确保死亡粒子当帧就被丢弃。更稳妥的做法是限制粒子总数def append_particles(particles, center, size): if len(particles) 500: return for i in range(15): # 原有生成逻辑逻辑说明粒子数超过 500 时直接跳过追加配合每帧过滤粒子数量会稳定在一个上界附近。500 这个值在 40fps 下基本感觉不到密度变化如果追求更满的效果可以调到 800但 CPU 占用会明显上升。这个限制对长期运行的展示型程序几乎是必需的。6. 把爱心代码改造成可复用组件参数化、多爱心与自定义配色实际上跑通这份代码之后最有价值的不是表白本身而是把它变成能复用的特效函数。我的做法是把整个动画封装成一个带配置参数的类或函数所有关键值都暴露为参数def heart_demo(center(400, 325), heart_size3, particle_color(255, 0, 0), particle_per_frame15, heart_beatFalse): screen pygame.display.set_mode((800, 600)) pygame.display.set_caption(Python Love) clock pygame.time.Clock() particles [] done False while not done: for event in pygame.event.get(): if event.type pygame.QUIT: done True size heart_size if heart_beat: size * abs(math.sin(pygame.time.get_ticks() / 300)) * 0.15 0.85 screen.fill((255, 255, 255)) draw_heart(center[0], center[1], size, colorparticle_color) particles draw_particles(screen, particles) append_particles(particles, center, 1, particle_per_frame) pygame.display.flip() clock.tick(40)逻辑说明这个封装把爱心中心、心形缩放、粒子颜色、每帧粒子数、是否启用心跳全部参数化。调用时只需要一行heart_demo(heart_beatTrue)就能获得带呼吸效果的心形粒子动画不再需要改主循环。如果想同时显示多个爱心可以直接建多个粒子列表每一帧分别更新particles_a [] particles_b [] while not done: screen.fill((255, 255, 255)) draw_heart(250, 250, 2) draw_heart(550, 450, 2) particles_a draw_particles(screen, particles_a) append_particles(particles_a, (250, 250), 1, 10) particles_b draw_particles(screen, particles_b) append_particles(particles_b, (550, 450), 1, 10) pygame.display.flip()两个爱心各自维护独立的粒子列表互不干扰粒子颜色也可以分别指定。这里要注意粒子列表不能共用否则过滤逻辑会把另一个爱心的粒子一并清除。配色方面粒子颜色和爱心轮廓颜色建议独立控制。原代码里轮廓和粒子都是红色改颜色时最好把 draw_heart 里的 line 颜色也接收一个参数否则粒子换成金色、轮廓还是红色会显得突兀。我一般会把轮廓设为比粒子略深的同色系比如粒子用(255, 182, 193)浅粉轮廓就用(255, 105, 180)粉红。最后说一个我自己的习惯从那次被绘制顺序坑过一次之后我每写一个 pygame 动画都会先固定检查三件事——fill 是否在所有绘制之前、粒子过滤是否在单次遍历中完成、draw 类 API 是否被重复调用。这三条过一遍动画类代码 90% 的显示问题都能被前置拦截。这份爱心粒子代码是一个很好的练手样本跑通之后建议你亲手把参数全改一遍改完你就明白粒子系统调度的大致套路了希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑