🦔
Jetpack Composeでパーティクルシステムを作る
はじめに
アプリを作っていてインタラクティブなフィードバッグが欲しい場面があり、パーティクルシステムを作ってみたので実装した方法について解説します。
改善点や間違いがあれば指摘していただけると幸いです。
技術概要
実装アーキテクチャ
- Particle データクラス: パーティクルの状態管理
- ParticleScope インターフェース: パーティクル機能の提供
- ParticleWrapper Composable: パーティクルシステムのメイン実装
- Modifier拡張: クリック時のパーティクル発生機能(ただし、Buttonコンポーザブルに対して適用してもパーティクルがでませんでした。)
実装詳細
1. Particleデータクラス
data class Particle(
var x: Float, // X座標
var y: Float, // Y座標
var vx: Float, // X方向の速度
var vy: Float, // Y方向の速度
var life: Float, // 生存時間(1.0〜0.0)
val decay: Float, // 減衰率
val size: Float, // サイズ
val color: Color // 色
) {
fun update() {
x += vx // 位置更新
y += vy
life -= decay // 生存時間減少
}
fun isDead() = life <= 0f
}
特徴:
- 物理ベースの移動(位置 + 速度)
- 時間経過による透明度変化
- ランダムな減衰率とサイズ
2. ParticleScopeインターフェース
interface ParticleScope {
@Composable
fun Modifier.emitParticlesOnClick(): Modifier
}
3. パーティクル生成部分
val newParticles = (1..10).map {
val side = Random.nextInt(4) // 0:上, 1:右, 2:下, 3:左
val (particleX, particleY, velocityX, velocityY) = when (side) {
0 -> { // 上辺から生成
val x = pos.x + Random.nextFloat() * bounds.width
val y = pos.y
val vx = Random.nextFloat() * 2 - 1 // 左右にランダム
val vy = -(Random.nextFloat() * 2 + 1) // 上方向
arrayOf(x, y, vx, vy)
}
// ... 他の辺の実装
}
Particle(
x = particleX,
y = particleY,
vx = velocityX,
vy = velocityY,
life = 1f,
decay = Random.nextFloat() * 0.02f + 0.01f,
size = Random.nextFloat() * 3 + 2,
color = Color.hsv(Random.nextFloat() * 60 + 180, 0.7f, 0.8f)
)
}
処理の内容としては、4辺からランダムに生成して初速をつけている形になります。
4. アニメーションループ
LaunchedEffect(isAnimating) {
while (isAnimating) {
particles = particles.map { particle ->
particle.apply { update() }
}.filter { !it.isDead() }
delay(64) // 約15FPS
}
}
ここまででパーティクルのデータを準備は終わっているので、あとはCanvasで表示するだけです。
5. 描画実装
Canvas(modifier = Modifier.fillMaxSize()) {
particles.forEach { particle ->
drawCircle(
color = particle.color.copy(alpha = particle.life),
radius = particle.size,
center = Offset(particle.x, particle.y)
)
}
}
lifeの値をalphaに指定することで自然な感じで消える動作になります。
コード全体
利用する側
@Composable
fun ParticleScope.TestScreen() {
Scaffold {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Particle System", fontSize = 32.sp)
HorizontalDivider()
// Buttonからは出ない(出したかったが、修正ができなかった)
Button(
onClick = {}, modifier = Modifier
.emitParticlesOnClick()
) {
Text("emit particle")
}
// こっちはパーティクルが出る
Box(
modifier = Modifier
.size(100.dp)
.emitParticlesOnClick()
.background(Color.Yellow)
)
}
}
パーティクルシステム側
val LocalParticle =
staticCompositionLocalOf<(LayoutCoordinates) -> Unit> { error("Particle action not provided") }
interface ParticleScope {
@Composable
fun Modifier.emitParticlesOnClick(): Modifier
}
val particleScopeImpl = object : ParticleScope {
@Composable
override fun Modifier.emitParticlesOnClick(): Modifier {
val particleAction = LocalParticle.current
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
return this
.onGloballyPositioned { coord ->
layoutCoordinates = coord
}
.clickable {
layoutCoordinates?.let { coord ->
particleAction(coord)
}
}
}
}
@Composable
fun ParticleWrapper(
modifier: Modifier = Modifier,
content: @Composable ParticleScope.() -> Unit
) {
var particles by remember { mutableStateOf(sequenceOf<Particle>()) }
var isAnimating by remember { mutableStateOf(false) }
Box(modifier = modifier) {
CompositionLocalProvider(LocalParticle provides ({ layoutCoordinates ->
val pos = layoutCoordinates.positionInRoot()
val bounds = layoutCoordinates.boundsInRoot()
val newParticles = (1..10).map {
// コンポーネントの周辺からランダムな位置で生成
val side = Random.nextInt(4) // 0: 上, 1: 右, 2: 下, 3: 左
val (particleX, particleY, velocityX, velocityY) = when (side) {
0 -> { // 上辺
val x = pos.x + Random.nextFloat() * bounds.width
val y = pos.y
val vx = Random.nextFloat() * 2 - 1 // 左右に少しランダム
val vy = -(Random.nextFloat() * 2 + 1) // 上方向
arrayOf(x, y, vx, vy)
}
1 -> { // 右辺
val x = pos.x + bounds.width
val y = pos.y + Random.nextFloat() * bounds.height
val vx = Random.nextFloat() * 2 + 1 // 右方向
val vy = Random.nextFloat() * 2 - 1 // 上下に少しランダム
arrayOf(x, y, vx, vy)
}
2 -> { // 下辺
val x = pos.x + Random.nextFloat() * bounds.width
val y = pos.y + bounds.height
val vx = Random.nextFloat() * 2 - 1 // 左右に少しランダム
val vy = Random.nextFloat() * 2 + 1 // 下方向
arrayOf(x, y, vx, vy)
}
else -> { // 左辺
val x = pos.x
val y = pos.y + Random.nextFloat() * bounds.height
val vx = -(Random.nextFloat() * 2 + 1) // 左方向
val vy = Random.nextFloat() * 2 - 1 // 上下に少しランダム
arrayOf(x, y, vx, vy)
}
}
Particle(
x = particleX,
y = particleY,
vx = velocityX,
vy = velocityY,
life = 1f,
decay = Random.nextFloat() * 0.02f + 0.01f,
size = Random.nextFloat() * 3 + 2,
color = Color.hsv(
Random.nextFloat() * 60 + 180,
0.7f,
0.8f
)
)
}
particles = particles + newParticles
isAnimating = true
})) {
particleScopeImpl.content()
}
LaunchedEffect(isAnimating) {
while (isAnimating) {
particles = particles.map { particle ->
particle.apply { update() }
}.filter { !it.isDead() }
delay(64) // 約15FPS
}
}
Canvas(
modifier = Modifier
.fillMaxSize()
) {
particles.forEach { particle ->
drawCircle(
color = particle.color.copy(alpha = particle.life),
radius = particle.size,
center = Offset(particle.x, particle.y)
)
}
}
}
}
data class Particle(
var x: Float,
var y: Float,
var vx: Float,
var vy: Float,
var life: Float,
val decay: Float,
val size: Float,
val color: Color
) {
fun update() {
x += vx
y += vy
life -= decay
}
fun isDead() = life <= 0f
}
コードのポイントとしてはParticleWrapperでparticlesを持つ際にsequenceを保持する際にデータの方をsequenceにしている事です。
listを使うとアニメーションがカクカクになり、目も当てられないような状態でした。
sequenceとlistの違いとしては、
Sequenceは要素に対して全ての操作を行ってから次の要素に対しての処理をする
listは全ての要素に対してある処理を行ってから次の処理を全ての要素に対して行う
カレーを複数の鍋で作る際に一つの鍋でカレーが完成すると次の鍋でカレーを作るのがsequenceなのに対して全ての鍋で利用するじゃがいもを同時に全て皮を剥くのがlistみたいなイメージです。
まとめ
今回パーティクルシステムを作っていて特に印象的だったのはlistを使うかsequenceを使うかの選択で大幅にパフォーマンスが異なるという事でした。
処理の順番の違いでlist ->
sequenceに置き換えるだけでパフォーマンスを改善できることがあるということを知識としては知っていましたが、実際に遭遇したのは初めてで少し感動しました。
Discussion