🦔

Jetpack Composeでパーティクルシステムを作る

に公開

はじめに

アプリを作っていてインタラクティブなフィードバッグが欲しい場面があり、パーティクルシステムを作ってみたので実装した方法について解説します。
改善点や間違いがあれば指摘していただけると幸いです。

技術概要

実装アーキテクチャ

  1. Particle データクラス: パーティクルの状態管理
  2. ParticleScope インターフェース: パーティクル機能の提供
  3. ParticleWrapper Composable: パーティクルシステムのメイン実装
  4. 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