先别急着写代码,这个效果看起来花哨,但拆开无非三件事:接住鼠标点击的坐标、在这一坐标“引爆”一批粒子、再用定时器把粒子运动状态一帧帧画出来。用Qt做这个尤其顺手,因为它把“接收输入、驱动刷新、绘制窗口”这三件事的工具都备齐了。适合刚学完Qt基础教程、想用一个小项目把QPainter、事件处理、定时器串起来的人,也适合给自己的工具软件加个不那么死板的交互反馈——比如点击按钮时放一小簇烟花,成本很低,但观感提升非常明显。
我做这个项目之前先列了几个目标,避免写着写着就跑偏:第一,不能引入任何第三方库,纯Qt Widgets搞定;第二,代码量控制在几百行内,核心逻辑尽量不绕弯;第三,性能要稳,快速连点不能卡顿更不能崩溃。最终实现的代码确实不复杂,但里面有几个坑很值得拿出来说说。
1. 整体设计:烟花本质是个“粒子系统”
1.1 为什么说烟花效果就是粒子系统的简化版
一个单击事件发生后,观众看到的不是一颗炮弹,而是一团光点从中心炸开,然后边飞边变暗、边飞边落下。这段话翻译成程序语言,就是:在某个坐标生成 N 个粒子,每个粒子带有位置、速度、颜色、寿命四个属性,之后每帧执行“位置 += 速度 * 时间差”的积分运算,同时让寿命衰减、让颜色透明度随寿命变化,寿命归零就把粒子从容器里移除。这就是粒子系统,只不过烟花只需用到一个很轻量的二维简化版本,连碰撞检测都用不上。
有一点想特意说一下:这个效果完全用 QWidget + QPainter 就能做,没必要上 QGraphicsView,更没必要为了粒子数量去开多线程。很多人一听说粒子就想到生产者消费者模型、线程池,恨不得把每个粒子丢到单独线程里去算物理,这在烟花场景里纯属给自己挖坑。几百个粒子的坐标积分,单线程一帧内算完连 1 毫秒都用不到,而 Qt 的绘图无论如何都得回到 GUI 线程,跨线程更新界面反而要处理各种同步问题,得不偿失。
1.2 方案选型:重写事件函数还是装事件过滤器
鼠标点击的获取有两种常见姿势,我在这两个方案之间犹豫过。方案一是直接继承 QWidget,重写mousePressEvent,这是最直观的做法,所有逻辑都收在自定义控件内部;方案二是给现有控件挂eventFilter,在过滤器里拦截鼠标事件,好处是无需改变原有类结构,想给整个主窗口加全局烟花特效时特别方便。
实际项目里我用的是方案一,因为我的需求很纯粹:点击窗口任意位置放烟花,整个窗口就是烟花画布,继承重写是最清晰的做法。但如果你是要把烟花效果“寄生”到别人的复杂界面上,比如给某个按钮区域加点击特效,那就应该用事件过滤器。两种方案的取舍我整理成了一张表:
| 方式 | 侵入性 | 适用场景 | 代码组织 |
|---|---|---|---|
| 重写 mousePressEvent | 高,必须继承目标控件 | 自定义窗口/控件内放烟花 | 逻辑集中在控件类内部,好维护 |
| 事件过滤器 eventFilter | 低,不需要改原类 | 给已有界面动态加特效 | 需要额外管理过滤器生命周期 |
还有一个隐藏细节:Qt 的QMouseEvent::pos()返回的是相对于接收事件的控件的坐标,这点在嵌套布局时要特别留意。假如你给一个父窗口装了事件过滤器,拿到的坐标就是父窗口坐标系下的,直接拿来当全局坐标用,画出来的烟花位置会偏移。反过来,如果重写了子控件的鼠标事件,那坐标天然就是子控件局部坐标,直接用就行。我早期用过滤器做全窗口特效时就在这个坐标转换上栽过跟头,后来老老实实转了mapFromGlobal。
2. 核心细节:粒子结构、物理模型与颜色设计
2.1 先定义粒子结构体:别小看这个简单类
烟花粒子的数据成员并不复杂,但设计是否合理,直接决定后面的代码好不好写。我用一个最朴素的Particle结构体:
QPointF pos:当前位置,用浮点坐标,避免整数坐标导致粒子运动有“卡顿感”QPointF vel:当前速度向量,单位是像素/秒QColor color:粒子颜色,后续透明度会随寿命变化float life:剩余寿命,单位是秒float maxLife:总寿命,用于计算透明度比例float size:绘制半径bool alive:存活标记,用于回收
为什么位置和速度都用浮点?因为粒子每帧的位移可能只有零点几像素,如果存在整数坐标里,大量粒子的运动会变成离散的跳变,看上去就不流畅了。而颜色直接存 QColor 是为了后面画图方便,QPainter 拿 QColor 就能设置画笔和画刷,不用临时做格式转换。
写结构体时最好给成员初始化,比如写成bool alive = true;,而不是只声明不赋初值。因为如果某个分支漏了初始化,读到一个随机的alive值,粒子要么显示不出来,要么显示一个死粒子占着内存。这类问题调试时非常恶心,有时候表现得不稳定,偶尔崩一次,多是结构体字段没初始化导致的。
2.2 物理模型:重力、阻力与初速度的计算逻辑
烟花粒子炸开后,视觉上要有“爆炸扩散、随后下落”的层次感。实现这个效果只需要给每个粒子施加一个向下的重力加速度,再给一个阻尼系数让粒子的速度随时间慢慢衰减。参数我试出来一组比较舒服的默认值,可以直接抄:
| 参数 | 取值 | 作用 |
|---|---|---|
| 初始速度范围 | 120 ~ 420 像素/秒 | 决定爆炸扩散半径 |
| 重力加速度 | 200 像素/秒² | 控制粒子下落快慢 |
| 阻尼系数 | 0.98/帧(等效 0.98^60 ≈ 0.30/秒) | 模拟空气阻力,让粒子轨迹有“减速”感 |
| 粒子寿命 | 1.2 ~ 2.0 秒随机 | 控制烟花存续时间 |
初速度的随机分布有讲究。最简单的是在 0~360 度上均匀取角度,速度大小在固定范围内均匀随机,这样炸出来是一个均匀的圆形。但为了更像真实烟花,我加了一点扰动:速度大小偏向中间值而不是纯均匀分布,这样边缘少一点中心多一点的粒子分布更自然。实现上可以用正态分布近似,或者干脆在均匀随机的基础上做两次采样的平均值,效果差不多。
阻尼的计算需要注意帧率差异。如果直接把速度乘以一个固定系数,比如vel *= 0.98f,那么看似每帧衰减一点,其实 60 帧每秒和 30 帧每秒的衰减速度差了整整一倍。正确做法是用帧间隔dt换算成时间无关的衰减,比如vel *= pow(0.98f, dt * 60.0f),这样无论机器跑 30 帧还是 144 帧,粒子的视觉轨迹都保持一致。初次做粒子效果的人最容易忽略这一点,在低帧率下看不出问题,但换到高刷屏上就会发现烟花“膨胀”了,原因就在这里。
2.3 颜色设计:从 HSV 取暖色,避免脏色
颜色是烟花观感的核心之一。直接用QColor::fromRgb随机生成 RGB,很容易混出灰褐色、暗绿色这类看起来“脏脏”的颜色,效果像低配版电脑屏保。更好的办法是固定一个色相区间,只在饱和度和明度上做变化。
我用的配色思路是以金色和暖色为主:色相取[40, 60]区间(金色系),同时偶尔加入[0, 20]的红色系做点缀,饱和度固定取 180~255,明度取 255。这样整场烟花是暖色基调,散射到深色背景上时对比鲜明。如果你想要彩色烟花,可以把色相区间放宽到整个色轮,但每次爆炸只用其中一段相邻区间,比如这一炮是蓝紫色系,下一炮是粉红色系,而不是让一炮里面七色俱全——真实烟花一炮的颜色往往是一致的,这样反而更耐看。
透明度的衰减公式也值得记一下:粒子绘制时透明度用alpha = 255 * (life / maxLife)线性衰减。但真实烟花在炸开的一瞬间最亮,然后快速衰减,所以我加了点非线性处理:在寿命前 10% 保持全亮,之后按二次曲线加速变暗,观感上更接近“噗”地一亮然后散去的效果。这个细节不调也问题不大,但调完后整场烟花的高级感高了一个档次。
3. 实操实现:事件、绘制与定时器三件套
3.1 核心类骨架与事件处理
整个效果我只需要两个类:粒子结构体Particle和主控件类FireworksWidget。控件类继承QWidget,重写三个函数:paintEvent、mousePressEvent,外加一个自己实现的step更新粒子状态。构造函数里启动一个QTimer,每个 16 毫秒触发一次,但具体步长用QElapsedTimer测量实际流逝时间,避免定时器漂移。
鼠标点击事件处理很短:
void FireworksWidget::mousePressEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { launchExplosion(event->pos()); } QWidget::mousePressEvent(event); }为什么会调用基类的mousePressEvent?因为我的窗口没有其他需要处理的鼠标逻辑,调用一下是为了遵守事件处理的基本规范,避免某些情况下事件被吞掉。如果你用的是事件过滤器,就不存在这个问题。
顺便说一个热词里经常搜到的事情:Qt 怎么模拟鼠标点击事件。如果你想把烟花做成自动演示,不一定要真的模拟鼠标点击,直接在程序里调用launchExplosion(QPointF(x, y))和动手点击走的是同一条路径,效果完全一样。这比用QApplication::sendEvent去伪造一个QMouseEvent简单得多,也少踩很多坑,因为伪造的事件还有坐标系、全局坐标、接受状态等一堆细节要处理。模拟事件要慎重,能直接调业务函数就别先造假事件。
3.2 粒子发射与更新逻辑
发射逻辑要分两批:一批是爆炸大粒子,从点击点向四周炸开;另一批是延迟生成的小火花粒子,用于模拟烟花炸开后二次产生的拖尾和闪烁。大粒子在点击瞬间入队,小火花粒子我设计了一个延迟队列,记录它们的出生点和剩余延迟时间,帧更新时判断延迟时间是否归零,归零后再生成一批小粒子。这个“二次爆炸”效果让烟花看起来更丰富,是纯扩散粒子版本没有的层次。
void FireworksWidget::step(float dt) { // 更新大粒子 for (auto& p : m_particles) { p.vel.y += GRAVITY * dt; p.vel *= std::pow(DRAG_PER_FRAME, dt * 60.0f); p.pos += p.vel * dt; p.life -= dt; if (p.life <= 0.0f) p.alive = false; } // 延迟生成火花粒子 for (auto& item : m_pendingSparks) { item.delay -= dt; if (item.delay <= 0.0f) { spawnSparks(item.center); item.alive = false; } } // 清理死亡粒子 m_particles.erase( std::remove_if(m_particles.begin(), m_particles.end(), [](const Particle& p) { return !p.alive; }), m_particles.end()); m_pendingSparks.erase( std::remove_if(m_pendingSparks.begin(), m_pendingSparks.end(), [](const PendingSpark& p) { return !p.alive; }), m_pendingSparks.end()); }清理容器这里我特意用了std::remove_if配合erase,而不是遍历时erase单个元素。原因很简单:遍历时删除元素会让迭代器失效,轻则漏删粒子,重则直接崩溃。用两段式的 remove-erase 是一劳永逸的写法,也顺便避开了“越过终点删除导致野指针”这类经典事故。
QTimer 的驱动方式也试过坑。最开始我用timer.setInterval(16),然后在timeout里直接调step(0.016),看起来逻辑没问题,但实际上定时器的回调并不是严格 16 毫秒一次,窗口拖动、系统负载升高时会产生漂移。粒子运动用固定步长算,会导致画面时快时慢。正确做法是用QElapsedTimer在每次回调时测量真实间隔,再用这个真实间隔作为dt传给step,这样闪烁和顿挫都消失了,物理表现和真实时间挂钩。
3.3 paintEvent 的绘制技巧:半透明背景与混合模式
绘制代码是烟花效果的门面,里面有三个值得一提的技巧:半透明背景形成拖尾、混合模式让粒子互相叠光、抗锯齿让粒子边缘平滑。
void FireworksWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); // 半透明填充实现拖尾效果 painter.fillRect(rect(), QColor(0, 0, 0, 60)); // 粒子叠加发光 painter.setCompositionMode(QPainter::CompositionMode_Plus); for (const auto& p : m_particles) { QColor c = p.color; c.setAlphaF(std::clamp(p.life / p.maxLife, 0.0f, 1.0f)); painter.setPen(Qt::NoPen); painter.setBrush(c); painter.drawEllipse(p.pos, p.size, p.size); } for (const auto& p : m_sparks) { // 小火花绘制成短线条,比圆点更有流星感 painter.setPen(QPen(p.color.withAlpha(200), 1.0)); painter.drawLine(p.pos, p.pos - p.vel * 0.03); } }第一行fillRect(rect(), QColor(0, 0, 0, 60))是拖尾效果的关键:每次重绘前不是把画布完全清成黑色,而是叠上一层透明度为 60 的半透明黑,之前的画面会残留在背景里逐渐变暗。这就像相机长曝光,粒子的旧轨迹形成快速衰减的余晖。如果你改成完全不透明的 fillRect,粒子会画一帧清一帧,看起来干净但少了一层“光晕”,拖尾的效果完全消失。
混合模式CompositionMode_Plus值得展开说一下。这个模式的含义是当前绘制的像素值和背景像素值相加,在粒子的交叉区域会产生变亮的效果。粒子数量多时,中心区域会出现一个自然的光晕亮点,非常接近真实烟花炸开时的过曝感。如果不设置这个模式,默认的 SourceOver 模式下粒子只是互相覆盖,颜色混在一起容易发灰。注意:设置了混合模式之后,drawEllipse绘制出的颜色会变亮甚至偏白,这是正常现象,不要当成 bug 去调整透明度调半天。
抗锯齿必须打开。不打开的话,圆点粒子边缘全是锯齿,尤其粒子小的时候看起来像一堆马赛克。QPainter::Antialiasing对drawEllipse和drawLine都有效,几乎不消耗什么性能,属于必开项。
关于paintEvent还有一个细节:内部不要做粒子状态更新。我第一次写的时候图省事,把step逻辑塞进了paintEvent,结果窗口只要一触发重绘,粒子就会跳一帧,又因为step里面可能调用了update(),形成了“重绘-更新-再重绘”的循环,窗口一直处于高占用状态。职责分离是这里的铁律:paintEvent只做绘制,粒子状态更新放到定时器回调里。
3.4 快速连点、窗口缩放与高DPI的适配
快速连点时,粒子容器会在短时间内新增大量粒子,这个过程有两个风险:一是容器频繁扩容导致的性能抖动,二是粒子数量超出合理范围。我在构造函数里给m_particles.reserve(2000),预分配空间避免插入时反复malloc;同时在发射函数里判断粒子总数,超过上限(比如 3000)就丢弃新粒子的生成,保证极端情况下的流畅度。
窗口缩放和高 DPI 是另一个隐形的坑。默认情况下 Qt Widgets 程序在高 DPI 屏幕上会进行坐标缩放,但QPainter内部也会应用缩放,如果你没有正确设置devicePixelRatio,画出来的烟花可能出现位置偏移、粒子大小异常的情况。在支持高 DPI 的配置下,建议在paintEvent开头调用painter.setDevicePixelRatio(devicePixelRatioF()),再结合 Qt6 或 Qt5.14 以上版本默认开启的高 DPI 缩放,粒子的大小和位置才能在各种屏幕上都保持一致。如果你用的是传统写法,没有显式处理 DPI,在某些设备上会看到烟花位置比鼠标点偏了半个身位,就是这个原因。
4. 常见问题与避坑实录:从崩溃到优化
4.1 粒子卡死或闪烁:多半是重绘策略不对
我用update()触发重绘,而不是repaint()。这两个函数的区别是初学者最容易搞混的:update()会把绘制请求合并到下一次事件循环处理,连续调用多次也只会重绘一次;repaint()是立即强制执行绘制,如果粒子数量较多,一次step后马上调repaint()会让绘制和状态更新耦合在一个紧密循环里,界面其他事件根本插不进去,表现为窗口拖动时卡顿、粒子闪烁不均。所有主动重绘需求都用update(),这是能显著提升流畅度的一个小改动。
闪烁还有一个原因,是窗口默认没有开启WA_OpaquePaintEvent。这个属性的意思是告诉 Qt 你的paintEvent会覆盖整个窗口的所有像素,不需要先做填充背景的准备工作。我的paintEvent第一行就用半透明黑色填充了整个rect(),相当于自己管理了背景,所以可以安全地开启这个属性,减少一次底层背景清除,闪烁问题也能缓解。
4.2 窗口关闭后定时器还在跑:崩溃的经典来源
第一次做完这个效果,我遇到过一个印象深刻的崩溃:程序正常使用没问题,但关闭窗口的一瞬间偶发崩溃。排查后发现是QTimer的锅。我在控件析构函数里没有主动停止定时器,而定时器的timeout信号连接到step槽函数,窗口销毁后信号仍可能触发一次,访问到已经销毁的粒子容器,于是读到野指针崩溃。
解决办法其实很简单:在析构函数里加m_timer.stop();,甚至更稳妥地disconnect掉信号。更保险的做法是给QTimer设置this为 parent,让它在对象树销毁时自动停掉,但手动 stop 仍然是必要的,因为它能保证在析构顺序的早期就把回调给断掉。这个坑在 Qt 里太典型了,尤其是这种“控件跑动画”的场景,十个类似项目里至少有三四个会踩到。
4.3 粒子数量爆炸导致内存上升:排查思路参考
快速连点加延迟火花,粒子数量是爆发式增长的。如果回收机制有 bug,比如alive标记没及时置为 false,粒子的增涨速度会远比清理速度快,表现在任务管理器里就是内存在几十秒内肉眼可见地上升。遇到这种情况,最快的排查方式是在step函数末尾打印粒子总数:
qDebug() << "particles:" << m_particles.size() << "sparks:" << m_sparks.size();如果总数持续上涨不回落,说明有某个分支没有正确标记死亡并从容器移除。我在做延迟火花的时候犯过这个错:PendingSpark的delay被减成负数之后,我判断条件写成了<= 0.0f时才生成火花,但生成火花后忘记了把alive置为 false,导致这一项在清理时被保留,下一帧又生成一批火花,粒子数量直接翻倍增长。调试信息定位到这个问题只花了几分钟,但如果没有打印日志,纯靠肉眼盯画面很难发现。
4.4 性能优化:数量级与实测数据参考
我实测了一下性能:单次点击生成 80 个大粒子和 120 个小火花粒子,总粒子数约 200,运行在普通办公本上,step加paintEvent的总耗时在 2~3 毫秒左右,完全无压力。即使同时叠加 10 次点击,粒子总数 2000,依然能稳定在 30 帧以上。如果目标是极致的粒子数量,比如上万粒子,那 QPainter 的逐个drawEllipse就成了瓶颈。替代方案是预先渲染一个粒子图片,然后drawPixmap绘制,减少复杂路径计算的开销;更进一步可以用QOpenGLWidget配合批量绘制,但那个复杂度就不是这个项目能涵盖的了。
说实话,对于普通交互反馈来说,几百个粒子带来的收益上限已经很高了,再多的粒子数量观众也分辨不出来。从工程角度讲,与其追求上万粒子,不如把精力花在粒子的颜色、二次火花、拖尾这些视觉层次上,投入产出比更高。
4.5 扩展方向:文字烟花与全局点击特效
做完基本效果后,我还做了两个扩展,可以给大家参考。一个是文字烟花:用QPainterPath加载文字轮廓,对路径采样出一圈坐标点,每个点作为火花粒子的初始位置,就能输出“点击鼠标炸出一行字”的效果。这个扩展的难点在于路径采样要均匀,否则笔画的密度不均,字会出现一段密一段疏的问题。另一个是全局点击特效:把FireworksWidget做成透明无边框的全屏覆盖窗口,用事件过滤器监听桌面鼠标点击,然后向对应坐标发烟花。透明窗口要设置Qt::FramelessWindowHint和Qt::WA_TranslucentBackground,并处理穿透点击的事件传递,复杂度会略高一些,但做出来是一个很抢眼的桌面小工具。
弹窗动画是几个经典坑最集中的地方:粒子回收、重绘策略、定时器生命周期、高 DPI 坐标,一个不少。建议想动手的朋友先做“单帧绘制版”,即点击后不启动定时器、只生成一批粒子并手动调一次update(),确认画面正常后再接入定时器动画,这样能把绘制问题和动画问题分开排查,定位 bug 的效率会高很多。这个“先静态后动态”的习惯,我后来在做所有动画类界面时都保持着,非常省事。
最后再分享一个个人心得:做这种视觉效果的项目,尽量用真实参数去调而不要靠肉眼硬猜。比如观察粒子速度漂移时,在step里打印一个代表性粒子的速度和坐标,跟手算的理论值对一下,能快速发现阻尼系数或者坐标更新公式的错误。很多看起来“有点不对但说不上来哪不对”的动画问题,根源都是数值计算上的细微偏差,而数值问题用日志对比一眼就能定位。对这种图形特效项目来讲,最不值钱的是写代码的时间,最值钱的是定位问题的思路,思路清晰了,效果自然就出来了。