HarmonyOS游戏开发:火焰粒子效果与实时渲染
HarmonyOS游戏开发:火焰粒子效果与实时渲染
📌 核心要点:基于粒子系统模拟火焰效果,掌握火焰颜色渐变、温度映射、性能优化及篝火/爆炸等实战场景。
一、背景与动机
火焰,大概是游戏特效中最"迷人"的存在了。从RPG游戏里篝火旁的温暖光晕,到动作游戏中华丽的爆炸火光,再到策略游戏中燃烧的城池——火焰效果无处不在,却又极难做好。
为什么难?因为火焰不是"一个东西",它是成百上千个微小粒子的集体行为。每个粒子有自己的位置、速度、颜色、大小、生命周期,而且这些属性还在不断变化。更关键的是,火焰的"真实感"来自于这些粒子的随机性和协调性——太规律了像假火,太随机了像噪点。
在HarmonyOS游戏开发中,火焰效果的实现主要依赖Canvas自定义绘制和粒子系统。没有现成的"火焰组件"给你拖拽使用,一切都要从粒子物理开始搭建。但别担心,一旦你理解了火焰的粒子模型,实现起来其实并不复杂——甚至可以说,火焰效果是粒子系统最经典的入门案例。
今天咱们就从零开始,一步步搭建一个完整的火焰粒子系统,最终实现篝火和爆炸两种实战效果。
二、核心原理
2.1 火焰的粒子模型
火焰的本质是什么?是燃烧产生的高温气体和微小颗粒。从粒子系统的角度看,火焰可以建模为:
- 发射器:在火焰底部持续产生新粒子
- 粒子运动:粒子向上运动,受随机扰动影响左右飘摆
- 温度衰减:粒子越往上温度越低
- 颜色映射:温度高→白/黄色,温度低→橙色→红色→暗红→透明
- 大小变化:粒子先膨胀后收缩
- 生命周期:粒子到达一定高度后消亡
graph TD
A[火焰发射器]:::primary --> B[生成新粒子]:::info
B --> C[设置初始属性]:::info
C --> D{每帧更新}:::warning
D --> E[更新位置:向上+随机扰动]:::success
D --> F[更新温度:随时间衰减]:::success
D --> G[更新颜色:温度→颜色映射]:::success
D --> H[更新大小:先膨胀后收缩]:::success
D --> I[更新透明度:随生命衰减]:::success
E --> J{生命结束?}:::warning
F --> J
G --> J
H --> J
I --> J
J -->|否| D
J -->|是| K[移除粒子]:::error
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
classDef success fill:#9C27B0,stroke:#7B1FA2,color:#fff
classDef error fill:#F44336,stroke:#D32F2F,color:#fff
2.2 火焰颜色与温度映射
火焰的颜色由温度决定,这是物理学的基本事实。在粒子系统中,我们用**温度值(0~1)**来控制颜色渐变:
| 温度范围 | 颜色 | RGB近似值 | 视觉效果 |
|---|---|---|---|
| 1.0 ~ 0.8 | 白/亮黄 | (255, 255, 200) | 火焰核心,最亮 |
| 0.8 ~ 0.6 | 黄色 | (255, 220, 50) | 明亮火焰 |
| 0.6 ~ 0.4 | 橙色 | (255, 150, 20) | 火焰主体 |
| 0.4 ~ 0.2 | 红色 | (220, 50, 10) | 火焰边缘 |
| 0.2 ~ 0.0 | 暗红/透明 | (100, 20, 5) | 烟雾余烬 |
2.3 粒子属性结构
// 火焰粒子属性
interface FireParticle {
x: number // X坐标
y: number // Y坐标
vx: number // X方向速度
vy: number // Y方向速度(向上为负)
temperature: number // 温度 0~1
size: number // 粒子大小
life: number // 剩余生命 0~1
maxLife: number // 初始生命值
turbulence: number // 扰动强度
}
三、代码实战
3.1 基础用法——简单火焰粒子
先从最简单的火焰开始:一个持续燃烧的小火苗。
// 简单火焰粒子效果
@Component
struct SimpleFireDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private particles: FireParticle[] = []
private animationId: number = -1
// 粒子接口
interface FireParticle {
x: number
y: number
vx: number
vy: number
temperature: number
size: number
life: number
maxLife: number
turbulence: number
}
// 生成新粒子
private emitParticle(centerX: number, centerY: number): void {
const particle: FireParticle = {
x: centerX + (Math.random() - 0.5) * 20, // 随机偏移
y: centerY,
vx: (Math.random() - 0.5) * 2, // 随机水平速度
vy: -(Math.random() * 3 + 2), // 向上速度
temperature: 1.0, // 初始温度最高
size: Math.random() * 8 + 4, // 随机大小
life: 1.0, // 初始生命满
maxLife: Math.random() * 0.5 + 0.5, // 随机生命长度
turbulence: Math.random() * 2 + 1 // 随机扰动强度
}
this.particles.push(particle)
}
// 温度到颜色映射
private temperatureToColor(temp: number, alpha: number): string {
let r: number, g: number, b: number
if (temp > 0.8) {
// 白/亮黄
r = 255
g = 255 - Math.round((1 - temp) * 5 * 55)
b = 200 - Math.round((1 - temp) * 5 * 180)
} else if (temp > 0.6) {
// 黄色
r = 255
g = 220 - Math.round((0.8 - temp) * 5 * 70)
b = 50 - Math.round((0.8 - temp) * 5 * 40)
} else if (temp > 0.4) {
// 橙色
r = 255
g = 150 - Math.round((0.6 - temp) * 5 * 100)
b = 20 - Math.round((0.6 - temp) * 5 * 15)
} else if (temp > 0.2) {
// 红色
r = 220 - Math.round((0.4 - temp) * 5 * 120)
g = 50 - Math.round((0.4 - temp) * 5 * 30)
b = 10
} else {
// 暗红
r = 100 - Math.round((0.2 - temp) * 5 * 80)
g = 20
b = 5
}
return `rgba(${r},${g},${b},${alpha})`
}
// 更新粒子状态
private updateParticles(): void {
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i]
// 更新位置
p.x += p.vx + (Math.random() - 0.5) * p.turbulence
p.y += p.vy
// 温度随生命衰减
p.life -= 0.02
p.temperature = Math.max(0, p.life / p.maxLife)
// 大小先膨胀后收缩
const lifeRatio = 1 - p.life / p.maxLife
p.size = p.size * (lifeRatio < 0.3 ? 1.02 : 0.98)
// 移除死亡粒子
if (p.life <= 0) {
this.particles.splice(i, 1)
}
}
}
// 渲染粒子
private renderParticles(): void {
this.context.clearRect(0, 0, 400, 600)
// 使用混合模式让火焰更亮
this.context.globalCompositeOperation = 'lighter'
for (const p of this.particles) {
const alpha = Math.min(1, p.life * 1.5)
const color = this.temperatureToColor(p.temperature, alpha)
this.context.beginPath()
this.context.arc(p.x, p.y, p.size, 0, Math.PI * 2)
this.context.fillStyle = color
this.context.fill()
}
this.context.globalCompositeOperation = 'source-over'
}
// 动画循环
private startAnimation(): void {
const animate = () => {
// 每帧发射3-5个新粒子
const emitCount = Math.floor(Math.random() * 3) + 3
for (let i = 0; i < emitCount; i++) {
this.emitParticle(200, 450)
}
this.updateParticles()
this.renderParticles()
this.animationId = requestAnimationFrame(animate)
}
animate()
}
build() {
Column() {
Canvas(this.context)
.width(400)
.height(600)
.backgroundColor('#1a1a2e')
.onReady(() => {
this.startAnimation()
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#0a0a1a')
}
aboutToDisappear() {
if (this.animationId !== -1) {
cancelAnimationFrame(this.animationId)
}
}
}
3.2 进阶用法——火焰颜色渐变与温度映射优化
上面的简单火焰有个问题:颜色过渡不够平滑,看起来像"一层一层的"。我们用渐变径向填充来优化。
// 进阶火焰:使用径向渐变让颜色过渡更自然
@Component
struct AdvancedFireDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private particles: FireParticle[] = []
private animationId: number = -1
// 使用径向渐变绘制单个粒子
private drawParticleWithGradient(p: FireParticle): void {
const alpha = Math.min(1, p.life * 1.5)
const radius = p.size
// 创建径向渐变:中心亮,边缘暗
const gradient = this.context.createRadialGradient(
p.x, p.y, 0, // 内圆中心
p.x, p.y, radius // 外圆半径
)
// 根据温度设置渐变色
const coreColor = this.temperatureToColor(p.temperature, alpha)
const edgeColor = this.temperatureToColor(p.temperature * 0.3, alpha * 0.3)
gradient.addColorStop(0, coreColor)
gradient.addColorStop(0.4, this.temperatureToColor(p.temperature * 0.7, alpha * 0.8))
gradient.addColorStop(1, edgeColor)
this.context.beginPath()
this.context.arc(p.x, p.y, radius, 0, Math.PI * 2)
this.context.fillStyle = gradient
this.context.fill()
}
// 温度到颜色映射(同基础用法,此处省略)
private temperatureToColor(temp: number, alpha: number): string {
// ... 同上
return `rgba(255,${Math.round(150 * temp + 50)},${Math.round(20 * temp)},${alpha})`
}
// 渲染所有粒子(使用渐变)
private renderParticles(): void {
this.context.clearRect(0, 0, 400, 600)
this.context.globalCompositeOperation = 'lighter'
// 按温度从低到高排序,低温粒子先绘制
const sorted = [...this.particles].sort((a, b) => a.temperature - b.temperature)
for (const p of sorted) {
this.drawParticleWithGradient(p)
}
this.context.globalCompositeOperation = 'source-over'
}
build() {
Column() {
Canvas(this.context)
.width(400)
.height(600)
.backgroundColor('#1a1a2e')
.onReady(() => {
// 启动动画循环(同基础用法)
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
3.3 完整示例——篝火与爆炸效果
将火焰粒子系统扩展为两种实战效果:持续燃烧的篝火和瞬间爆发的爆炸。
// 完整实战:篝火 + 爆炸效果
interface FireParticle {
x: number
y: number
vx: number
vy: number
temperature: number
size: number
life: number
maxLife: number
turbulence: number
type: 'campfire' | 'explosion' // 粒子类型
}
@Component
struct FireEffectDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private particles: FireParticle[] = []
private animationId: number = -1
@State modeText: string = '篝火模式'
// 篝火粒子发射
private emitCampfireParticles(): void {
const centerX = 200
const centerY = 400
const count = Math.floor(Math.random() * 4) + 3
for (let i = 0; i < count; i++) {
this.particles.push({
x: centerX + (Math.random() - 0.5) * 30,
y: centerY + (Math.random() - 0.5) * 10,
vx: (Math.random() - 0.5) * 1.5,
vy: -(Math.random() * 2.5 + 1.5),
temperature: 0.8 + Math.random() * 0.2,
size: Math.random() * 10 + 5,
life: 1.0,
maxLife: Math.random() * 0.6 + 0.4,
turbulence: Math.random() * 1.5 + 0.5,
type: 'campfire'
})
}
}
// 爆炸粒子发射
private emitExplosionParticles(centerX: number, centerY: number): void {
const count = 80 // 爆炸一次产生大量粒子
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2
const speed = Math.random() * 6 + 2
this.particles.push({
x: centerX,
y: centerY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
temperature: 1.0, // 爆炸初始温度最高
size: Math.random() * 12 + 3,
life: 1.0,
maxLife: Math.random() * 0.4 + 0.2, // 爆炸粒子生命更短
turbulence: Math.random() * 3 + 1,
type: 'explosion'
})
}
}
// 更新粒子
private updateParticles(): void {
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i]
// 位置更新
p.x += p.vx + (Math.random() - 0.5) * p.turbulence
p.y += p.vy
if (p.type === 'campfire') {
// 篝火粒子:向上减速
p.vy *= 0.99
p.vx *= 0.98
} else {
// 爆炸粒子:受重力影响向下
p.vy += 0.1
p.vx *= 0.97
p.vy *= 0.97
}
// 生命和温度衰减
p.life -= 0.02
p.temperature = Math.max(0, p.life / p.maxLife)
// 大小变化
const lifeRatio = 1 - p.life / p.maxLife
if (p.type === 'explosion') {
p.size *= (lifeRatio < 0.2 ? 1.05 : 0.96) // 爆炸先膨胀后收缩
} else {
p.size *= (lifeRatio < 0.3 ? 1.01 : 0.99)
}
// 移除死亡粒子
if (p.life <= 0) {
this.particles.splice(i, 1)
}
}
}
// 温度到颜色映射
private temperatureToColor(temp: number, alpha: number): string {
let r: number, g: number, b: number
if (temp > 0.8) {
r = 255; g = 255 - Math.round((1 - temp) * 5 * 55); b = 200
} else if (temp > 0.6) {
r = 255; g = 220 - Math.round((0.8 - temp) * 5 * 70); b = 50
} else if (temp > 0.4) {
r = 255; g = 150 - Math.round((0.6 - temp) * 5 * 100); b = 20
} else if (temp > 0.2) {
r = 220 - Math.round((0.4 - temp) * 5 * 120); g = 50; b = 10
} else {
r = 100 - Math.round((0.2 - temp) * 5 * 80); g = 20; b = 5
}
return `rgba(${r},${g},${b},${alpha})`
}
// 渲染
private render(): void {
this.context.clearRect(0, 0, 400, 600)
this.context.globalCompositeOperation = 'lighter'
// 绘制环境光晕(篝火模式下)
if (this.modeText === '篝火模式') {
const glowGradient = this.context.createRadialGradient(200, 400, 10, 200, 400, 150)
glowGradient.addColorStop(0, 'rgba(255,150,50,0.15)')
glowGradient.addColorStop(0.5, 'rgba(255,100,20,0.05)')
glowGradient.addColorStop(1, 'rgba(255,50,0,0)')
this.context.fillStyle = glowGradient
this.context.fillRect(0, 0, 400, 600)
}
// 绘制粒子
const sorted = [...this.particles].sort((a, b) => a.temperature - b.temperature)
for (const p of sorted) {
const alpha = Math.min(1, p.life * 1.5)
const gradient = this.context.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size)
gradient.addColorStop(0, this.temperatureToColor(p.temperature, alpha))
gradient.addColorStop(0.5, this.temperatureToColor(p.temperature * 0.6, alpha * 0.6))
gradient.addColorStop(1, this.temperatureToColor(p.temperature * 0.2, 0))
this.context.beginPath()
this.context.arc(p.x, p.y, p.size, 0, Math.PI * 2)
this.context.fillStyle = gradient
this.context.fill()
}
this.context.globalCompositeOperation = 'source-over'
// 绘制篝火木柴(装饰)
if (this.modeText === '篝火模式') {
this.context.fillStyle = '#5D4037'
this.context.save()
this.context.translate(200, 420)
this.context.rotate(-0.3)
this.context.fillRect(-40, -5, 80, 10)
this.context.restore()
this.context.save()
this.context.translate(200, 420)
this.context.rotate(0.3)
this.context.fillRect(-40, -5, 80, 10)
this.context.restore()
}
}
// 动画循环
private startAnimation(): void {
const animate = () => {
if (this.modeText === '篝火模式') {
this.emitCampfireParticles()
}
this.updateParticles()
this.render()
this.animationId = requestAnimationFrame(animate)
}
animate()
}
build() {
Column() {
// 模式切换
Row({ space: 16 }) {
Button('篝火模式')
.onClick(() => {
this.modeText = '篝火模式'
this.particles = []
})
.backgroundColor(this.modeText === '篝火模式' ? '#FF5722' : '#666666')
Button('爆炸模式')
.onClick(() => {
this.modeText = '爆炸模式'
this.particles = []
})
.backgroundColor(this.modeText === '爆炸模式' ? '#FF5722' : '#666666')
}
.margin({ bottom: 16 })
Canvas(this.context)
.width(400)
.height(500)
.backgroundColor('#0a0a1a')
.borderRadius(12)
.onReady(() => {
this.startAnimation()
})
.onClick((event) => {
// 爆炸模式下点击触发爆炸
if (this.modeText === '爆炸模式') {
this.emitExplosionParticles(event.x, event.y)
}
})
Text(this.modeText === '篝火模式' ? '持续燃烧的篝火' : '点击画布触发爆炸')
.fontSize(14)
.fontColor('#999999')
.margin({ top: 8 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#1a1a2e')
}
aboutToDisappear() {
if (this.animationId !== -1) {
cancelAnimationFrame(this.animationId)
}
}
}
四、踩坑与注意事项
坑点1:Canvas的globalCompositeOperation性能陷阱
lighter混合模式能让火焰看起来更亮更真实,但它的性能开销是source-over的3-5倍。当粒子数量超过200时,帧率可能骤降。
优化方案:限制粒子总数在150以内;或者只对高温粒子使用lighter,低温粒子使用source-over。
坑点2:径向渐变创建过多导致卡顿
每个粒子都创建一个createRadialGradient,当粒子数量多时,渐变对象的创建和销毁会带来显著的GC压力。
优化方案:预创建一组渐变模板(比如10个温度等级),粒子根据温度选择最近的模板,而不是每帧都创建新渐变。
坑点3:粒子数组的splice操作性能差
this.particles.splice(i, 1)在数组中间删除元素,时间复杂度O(n)。当粒子数量多时,频繁的splice会导致帧率波动。
优化方案:使用双缓冲策略——维护两个数组,每帧将存活粒子复制到新数组,避免splice操作。
// 双缓冲策略
private updateParticles(): void {
const alive: FireParticle[] = []
for (const p of this.particles) {
// 更新粒子...
if (p.life > 0) {
alive.push(p)
}
// 不再splice,直接丢弃死亡粒子
}
this.particles = alive
}
坑点4:requestAnimationFrame不自动取消
组件销毁时如果不取消requestAnimationFrame,回调会继续执行,访问已销毁的组件上下文导致崩溃。
必须在aboutToDisappear中取消动画。
坑点5:Canvas的onReady只触发一次
Canvas.onReady回调只在Canvas首次准备好时触发一次。如果你在onReady中启动了动画循环,切换页面再回来时动画不会自动重启。
解决:在onPageShow或onVisibleAreaChange中重新启动动画。
坑点6:火焰效果在深色和浅色背景下的表现差异
火焰效果在深色背景下看起来很好,但在浅色背景下几乎看不到——因为lighter混合模式在浅色背景上效果很弱。
解决:根据背景色动态调整粒子的alpha值和混合模式。浅色背景下使用source-over并增加alpha。
坑点7:爆炸效果的一次性粒子太多
爆炸瞬间产生80个粒子,加上渐变绘制,首帧可能需要30-40ms的渲染时间,导致明显的卡顿。
优化方案:分帧发射——首帧发射30个核心粒子,后续2-3帧各发射15-20个外围粒子,让爆炸"展开"而不是"炸开"。
五、HarmonyOS 6适配说明
API差异
| API | HarmonyOS 5.0 | HarmonyOS 6.0 | 迁移建议 |
|---|---|---|---|
| Canvas绘制 | CanvasRenderingContext2D |
CanvasRenderingContext2D + GPUContext |
可选GPU加速上下文 |
| requestAnimationFrame | 返回number | 返回number + 支持优先级 | 可指定动画帧优先级 |
| 粒子系统 | 无内置 | ParticleEmitter组件 |
简单场景可用内置组件 |
| Canvas分辨率 | 默认物理分辨率 | canvas.resolutionScale |
可控制绘制分辨率 |
| 离屏Canvas | OffscreenCanvas |
OffscreenCanvas + WebWorker |
支持Worker中绘制 |
行为变更
- Canvas GPU加速默认开启:HarmonyOS 6中Canvas默认使用GPU渲染,不再需要手动设置
RenderingContextSettings(true) - 粒子数量限制:单个Canvas同时绘制的粒子数量建议不超过300,超过后系统可能自动降帧
- 渐变对象缓存:
createRadialGradient创建的渐变对象会被自动缓存复用,相同参数不会重复创建 - requestAnimationFrame精度:回调时间戳精度从ms级提升到μs级,便于更精确的物理计算
适配代码
// HarmonyOS 6适配:使用内置ParticleEmitter和GPU加速
@Component
struct Hmos6FireDemo {
build() {
Stack() {
// 方式1:使用内置ParticleEmitter(简单场景)
ParticleEmitter({
// 火焰发射器配置
emitter: {
emitRate: 30, // 每秒发射30个粒子
lifetime: 1500, // 粒子生命1.5秒
position: { x: 200, y: 400 },
shape: 'circle',
radius: 15
},
particle: {
color: {
from: '#FFFFC8', // 初始颜色:亮黄
to: '#FF3300' // 结束颜色:红色
},
scale: {
from: 1.0,
to: 0.2
},
opacity: {
from: 1.0,
to: 0.0
},
velocity: {
x: { from: -20, to: 20 },
y: { from: -80, to: -30 }
},
acceleration: {
y: -10 // 向上加速
}
}
})
.width(400)
.height(500)
// 方式2:Canvas + GPU加速(复杂场景)
// Canvas(this.context)
// .width(400)
// .height(500)
// .resolutionScale(0.75) // HarmonyOS 6: 降低绘制分辨率提升性能
}
}
}
六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐⭐ |
| 使用频率 | ⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐ |
火焰效果是粒子系统的"Hello World"——它涵盖了粒子发射、属性更新、颜色映射、生命周期管理、混合模式等所有核心概念。掌握了火焰,其他粒子效果(烟雾、水流、星尘)都是换汤不换药。
核心要点回顾:
- 火焰的本质是粒子的集体行为——发射器产生粒子,粒子向上运动并衰减
- 温度映射是火焰的灵魂——从白/黄到橙/红再到暗红/透明,颜色渐变决定真实感
- 径向渐变让粒子更柔和——中心亮边缘暗,比纯色填充真实10倍
- lighter混合模式是火焰的"光"——让粒子叠加产生明亮的火焰核心
- 性能优化要关注粒子数量和渐变创建——双缓冲、预创建模板、分帧发射
- HarmonyOS 6的ParticleEmitter让简单场景的实现更简单,复杂场景仍需Canvas自定义
火焰效果就像烹饪中的"火候"——同样的食材(粒子),火候不同(参数配置),出来的效果天差地别。多调多试,找到那个"刚刚好"的参数组合,你的火焰就能"烧"出真实感。
- 点赞
- 收藏
- 关注作者
评论(0)