下载 App

NanoVG 动态火焰效果 — 径向渐变叠加法

精华05/0268 浏览开发交流
在给主界面制作火炉效果的时候,发现初始ai给到的效果并不理想,而提示让生成火苗就是椭圆形的阴影在飘来飘去。最后让火粒子是几百颗小圆时,感觉也差那么点。
如下图:
而迭代后,ai产出的效果如下:
--------- 以下是方法:
## 完整实现
### 函数签名
```lua
--- 在指定区域绘制动态火焰
--- @param vg    NanoVG context
--- @param cx    火焰中心 x
--- @param cy    火焰中心 y
--- @param w     区域宽度(用于计算火焰尺寸比例)
--- @param h     区域高度
--- @param t     当前时间(秒,持续递增,驱动动画)
local function drawFireEffect(vg, cx, cy, w, h, t)
```
### 第一层:全局闪烁因子
火焰不是均匀燃烧的,需要一个全局的明暗波动。用 3 个不同频率的 sin 波叠加,避免单一频率的机械感:
```lua
local R = math.max(w, h) * 0.6  -- 基准半径
local f1 = math.sin(t * 3.2)
local f2 = math.sin(t * 1.7 + 0.5)
local f3 = math.sin(t * 5.1 + 1.2)
local flicker = 0.65 + 0.22 * f1 * f2 + 0.13 * f3
-- flicker 在 ~0.3 到 ~1.0 之间波动
```
这里的关键是 **f1 * f2 的乘积** —— 两个 sin 相乘会产生拍频效果,比简单相加更像真实的火焰闪烁。
### 第二层:环境热辐射光晕
火焰周围应该有柔和的暖色光晕,用一个大椭圆径向渐变实现:
```lua
local envP = nvgRadialGradient(vg,
    cx, cy, R * 0.05, R * 2.0,
    nvgRGBAf(1.0, 0.4, 0.08, 0.18 * flicker),  -- 中心:暖橙,半透明
    nvgRGBAf(0.6, 0.1, 0.0, 0.0)               -- 边缘:全透明
)
nvgBeginPath(vg)
nvgEllipse(vg, cx, cy, R * 2.0, R * 2.5)  -- 竖向略长
nvgFillPaint(vg, envP)
nvgFill(vg)
```
再加一个小范围的热芯光晕,让火焰根部更通透:
```lua
local coreGlowP = nvgRadialGradient(vg,
    cx, cy + R * 0.2, R * 0.02, R * 0.7,
    nvgRGBAf(1.0, 0.7, 0.2, 0.3 * flicker),
    nvgRGBAf(1.0, 0.3, 0.0, 0.0)
)
nvgBeginPath(vg)
nvgCircle(vg, cx, cy + R * 0.2, R * 0.7)
nvgFillPaint(vg, coreGlowP)
nvgFill(vg)
```
### 第三层:火焰主体(核心!)
这是最关键的部分 —— 140 个径向渐变圆的叠加。
#### 3.1 伪随机分布:黄金比例 Hash
不用 `math.random()`(每帧结果不同,火焰会抖),而是用**黄金比例 Hash** 为每个粒子生成固定的伪随机参数:
```lua
local h1 = (i * 0.6180339887) % 1.0          -- 第一组随机值
local h2 = ((i * 7 + 13) * 0.6180339887) % 1.0  -- 第二组(不同种子)
local h3 = ((i * 17 + 31) * 0.6180339887) % 1.0 -- 第三组
```
`0.6180339887` 是黄金比例的小数部分 `(sqrt(5)-1)/2`。乘以不同的整数系数再取模,就得到了多组均匀分布但互不相关的伪随机序列。
#### 3.2 生命周期动画
每个粒子有一个 `p` 值(0=底部刚生成,1=顶部消亡),通过时间驱动循环:
```lua
local speed  = 0.4 + h1 * 0.7    -- 每个粒子速度不同
local period = 2.0 + h2 * 2.0    -- 每个粒子周期不同
local p = ((t * speed + h3 * period) % period) / period
```
`speed` 和 `period` 都带随机变化,这样 140 个粒子的运动周期互不相同,避免了"齐步走"的机械感。
#### 3.3 火焰轮廓:底宽顶窄
火焰的外形用一个指数衰减的包络线控制:
```lua
-- y 位置:从底部线性上升
local py = baseY - p * FIRE_H
-- x 方向的允许范围随高度衰减(指数 1.2 → 略快于线性)
local envelopeW = FIRE_HALF_W * (1.0 - p) ^ 1.2
-- 在包络线内随机偏移 + 时间驱动的摆动
local xOff = (h1 - 0.5) * 2.0 * envelopeW
local sway  = math.sin(t * (2.5 + h2 * 2.5) + i * 0.7) * envelopeW * 0.4
local sway2 = math.sin(t * (4.0 + h1 * 3.0) + i * 1.3) * envelopeW * 0.15
local px = cx + xOff + sway + sway2
```
双层 sway(主摆动 + 高频微颤)让火焰的晃动更有层次。
#### 3.4 粒子大小
底部大(叠加后产生高亮核心),顶部小(火星飘散感):
```lua
local sizeBase = R * (0.22 + h2 * 0.16)  -- 基础大小带随机
local size = sizeBase * (1.0 - p * 0.65)  -- 随高度缩小
    * (0.85 + 0.15 * math.sin(t * 5.0 + i))  -- 微弱的大小脉动
```
#### 3.5 五段色带
火焰颜色随高度(`p` 值)分 5 段渐变:
```lua
if p < 0.12 then
    -- 核心:近白色      白(255,247,204)
    r, g, b = 1.0, 0.97, 0.8
    a = 0.35 * flicker
elseif p < 0.3 then
    -- 内焰:亮黄        黄(255,217,77)
    r, g, b = 1.0, 0.85, 0.3
    a = 0.30 * flicker
elseif p < 0.55 then
    -- 中焰:橙色        橙(255,140,15)
    r, g, b = 1.0, 0.55, 0.06
    a = 0.25 * flicker
elseif p < 0.78 then
    -- 外焰:暗橙→红     红橙(242,77,8)
    r, g, b = 0.95, 0.3, 0.03
    a = 0.18 * flicker
else
    -- 烟尾:暗红         暗红(179,31,3)
    r, g, b = 0.7, 0.12, 0.01
    a = 0.10 * (1.0 - (p - 0.78) / 0.22) * flicker
end
```
每段的 alpha 也不同 —— 核心最高(0.35),叠加后趋近不透明;烟尾最低(0.10),快速消失。
#### 3.6 绘制:径向渐变圆
这是整个效果的灵魂:
```lua
local paint = nvgRadialGradient(vg,
    px, py,           -- 圆心
    size * 0.05,      -- 内圆半径(几乎为 0,亮色集中在中心点)
    size,             -- 外圆半径
    nvgRGBAf(r, g, b, a),              -- 中心色:亮,半透明
    nvgRGBAf(r*0.5, g*0.25, 0.0, 0.0) -- 边缘色:暗,全透明
)
nvgBeginPath(vg)
nvgCircle(vg, px, py, size)
nvgFillPaint(vg, paint)
nvgFill(vg)
```
**为什么 `内圆半径 = size * 0.05` 而不是 0?** 如果内圆是 0,渐变会过于集中在一个像素点上。留一点点内圆面积,让中心有一个小的实色区域,叠加后核心更饱满。
**为什么边缘色不是纯黑透明 `(0,0,0,0)` 而是 `(r*0.5, g*0.25, 0)`?** 纯黑透明在半透明叠加时会产生灰色过渡带。用偏暖的暗色作为边缘色,过渡更自然。
### 第四层:飘散火星
火焰顶部应该有小火星飘散。40 个极小的渐变圆,速度快、alpha 衰减快:
```lua
local SPARKS = 40
for i = 1, SPARKS do
    -- ... 伪随机 + 生命周期(同上)
    -- 从火焰中上部射出,快速上升
    local sy = (baseY - FIRE_H * 0.35) - p * FIRE_H * 1.0
    -- 横向漂移随高度增大
    local drift = math.sin(t * (3.5 + h2 * 2.5) + i * 1.3) * R * 0.5 * p
    local sx = cx + (h1 - 0.5) * R * 0.4 + drift
    -- 很小的尺寸 + 快速消失的 alpha
    local sparkSize = R * (0.04 + h2 * 0.025) * (1.0 - p * 0.4)
    local sa = (0.7 + h1 * 0.3) * (1.0 - p)^2 * flicker
    -- 同样用径向渐变绘制
    local sp = nvgRadialGradient(vg, sx, sy, 0, sparkSize,
        nvgRGBAf(1.0, 0.75, 0.12, sa),
        nvgRGBAf(1.0, 0.2, 0, 0)
    )
    nvgBeginPath(vg)
    nvgCircle(vg, sx, sy, sparkSize)
    nvgFillPaint(vg, sp)
    nvgFill(vg)
end
```
火星的 alpha 用 `(1-p)^2` 衰减(平方),比线性衰减更快 —— 刚射出时亮,很快就暗下去消失。
---
## 调参指南
| 参数 | 作用 | 调大 | 调小 |
|------|------|------|------|
| `COUNT` | 粒子总数 | 火焰更浓密、核心更亮 | 火焰稀疏、若隐若现 |
| `FIRE_H` | 火焰高度 | 火柱更高 | 矮火 |
| `FIRE_HALF_W` | 底部半宽 | 火焰根部更宽(篝火感) | 窄火柱(蜡烛感) |
| `sizeBase` | 粒子基础半径 | 每个光球更大、更柔和 | 颗粒感更强 |
| 各段 `a` 值 | 透明度 | 更亮更实 | 更虚更透 |
| `speed` | 粒子上升速度 | 更猛烈的涌动 | 更缓慢的燃烧 |
| `sway` 幅度 | 横向摆动 | 更狂暴 | 更稳定 |
| 轮廓指数 `^1.2` | 收窄速度 | 更尖的火焰 | 更圆的火焰 |
| `SPARKS` | 火星数量 | 更多飘散余火 | 更干净的火焰 |
| `flicker` 基准 | 全局亮度下限 | 闪烁幅度更小 | 闪烁更剧烈 |
### 几种预设风格
**蜡烛火焰**(小而稳定):
```lua
COUNT = 40, FIRE_H = R * 1.5, FIRE_HALF_W = R * 0.25
sway 幅度 * 0.15, SPARKS = 5
```
**篝火**(宽而猛烈):
```lua
COUNT = 140, FIRE_H = R * 3.0, FIRE_HALF_W = R * 0.7
sway 幅度 * 0.4, SPARKS = 40
```
**地狱烈焰**(极端):
```lua
COUNT = 250, FIRE_H = R * 4.0, FIRE_HALF_W = R * 0.9
所有 a 值 * 1.5, speed 范围 0.6~1.5, SPARKS = 80
```
---
## 踩过的坑
### 1. 纯色填充 vs 径向渐变
```lua
-- 错误:纯色圆 → 像彩色弹珠
nvgFillColor(vg, nvgRGBAf(1, 0.5, 0, 0.3))
-- 正确:径向渐变圆 → 有光晕的发光体
nvgFillPaint(vg, nvgRadialGradient(vg, x, y, 0, r, bright, transparent))
```
这是最关键的一步。没有径向渐变,再多粒子也只是一堆色块。
### 2. math.random() vs 黄金比例 Hash
```lua
-- 错误:每帧随机 → 火焰像在抖
local x = cx + math.random() * w
-- 正确:确定性伪随机 → 每帧位置平滑变化
local h = (i * 0.6180339887) % 1.0
-- 动画由 sin(t * speed + phase) 驱动,而非随机
```
粒子的"随机性"应该是**空间分布的随机**,不是**时间上的随机**。黄金比例 Hash 给每个粒子一个固定身份,动画由 `sin(time)` 平滑驱动。
### 3. 单频闪烁 vs 多频叠加
```lua
-- 错误:单频 → 像呼吸灯
local flicker = 0.5 + 0.5 * math.sin(t * 2.0)
-- 正确:多频乘积 → 不规则的自然闪烁
local flicker = 0.65
    + 0.22 * math.sin(t * 3.2) * math.sin(t * 1.7 + 0.5)
    + 0.13 * math.sin(t * 5.1 + 1.2)
```
两个 sin 相乘产生拍频,加上第三个 sin 打破周期性。
### 4. 火苗形状粒子的失败
一开始试过用贝塞尔曲线画火苗形状的粒子(底宽顶尖),但效果很不自然 —— 每个火苗都有明确的轮廓边界,看起来像一堆叶子而不是火。
正确的思路是:**不要让单个粒子看起来像火苗,让大量模糊光球的叠加效果看起来像火焰。** 单个粒子应该是看不清楚的(径向渐变到透明),只有群体叠加才产生视觉。
---
## 性能
- 140 个火焰粒子 + 40 个火星 = 每帧 ~180 次 `nvgRadialGradient` + `nvgCircle` + `nvgFill`
- NanoVG 底层走 GPU,这个量级基本无压力
- 没有用到纹理、没有创建/销毁对象、没有内存分配,全是数学计算 + 绘图调用
---
猜你想搜
taptap 制造nanovg动态火焰
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
68 赞
21 回复
01:27
《大二暑假,我用AI做了一款属于自己的游戏》截图
《大二暑假,我用AI做了一款属于自己的游戏》
本人自制游戏现已加入taptap封闭测试(仅适配安卓机型)欢迎大家前来尝试(八月十号可以正式开始游玩) 如果有建议或者bug反馈可以加入qq群928501443 #发现好游戏 #游戏日常 #浅评一下 #今天游戏圈发生了啥
3 赞
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
37 赞
6 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
123 赞
44 回复
开发心得-界面布局心得截图
开发心得-界面布局心得
大家好,我正在用 TapTap 制造制作一款2D 治愈林间徒步旅行游戏《徒步荒野》。 我分享一下我的开发心得,希望能有用,下面是一些界面布局的心得。 第一步:定版式、操作 先确定游戏是横版还是竖版、需要哪些操作按钮,敲定整体玩法思路。 我的玩法灵感参考《边境之旅》,主打散步看风景、沿途触发随机事件。 原版是 3D,考虑到自己是新手,选择2D 横版街机式来做,制作更简单、好上手。 第二步:敲定全局美
6 赞
3 回复
00:26
塔啦啦科研:三渲二(风格1)截图
塔啦啦科研:三渲二(风格1)
最近在研究像素风格的三渲二,一直想做小丑牌那种风格,但是塔啦啦一直做不出来,发现是有一部分的功能做不了,只能实现简单的材质制作和描边,因此,我从零直接搭建了一整套引擎基础。目前进度可以看视频。 @嗒啦啦 什么时候出shader 评论区随意提问 #TapTap制造 #TapTap制造社区 #TapTap制造
4 赞
5 回复
「游戏上新」秦王的游戏截图
「游戏上新」秦王的游戏
你是秦王嬴政身边的廷臣。 方士徐福献上一匣【帝王卡牌】,四张凶牌封死命运: 屠戮|淫欲|奢华|征伐 七日死限,必须一一折断卡牌对应的欲望任务。 办不成? 秦王殿上长剑出鞘,君臣性命,顷刻倾覆。 ⚔游戏核心玩法 ▪回合卡牌策略,抉择牵动秦廷朝堂势力 ▪朝堂派系博弈:王权、法家、军功贵族、方士暗流角力 ▪多重结局:功成辅秦王一统,或是任务失败血染咸阳宫 ▪随机事件触发,每一局嬴政的猜忌程度截然
4 赞
2 回复
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
00:48
我做了一款哥特风像素射击游戏|《失落庭院》实机演示截图
我做了一款哥特风像素射击游戏|《失落庭院》实机演示
序列帧实在是太好丸辣! 一直想做哥特风的游戏哈哈,目前做了三把武器,三个怪物和两个房间,不知道有生之年能不能做完😭
11 赞
7 回复
关于制作出质量很高的横版战斗角色的方法截图
关于制作出质量很高的横版战斗角色的方法
其实全篇就一句话,角色为啥做起来这么好看,因为我一帧一帧K的。 在做角色之前,我们需要一个角色的设计稿,这个东西很简单,你把人设写好剩下的交给AI就行了,所有图片,序列帧都可以让AI绘制和处理,学会绘图是你要制作高质量游戏的第一课。 这里拿我在做的角色举例,他的参考图是这样: 当你拥有了一个角色以后,就要想办法让她动起来了。 那么如何让她动起来,答案是序列帧。 在此之前你需要知道,最容易出现一个误
40 赞
12 回复