CoreAnimation
Swift

Swiftで花火を作った話

はじめに

2017-08-09 14_19_04.gif

LIVEで配信しながら何か作ろう・・・

そういって花火を作ることになりました。

完成品のリポジトリはこちらです
github.com/ha1f/fireworksClock

調査

適当に検索していると、shu223さんのすごい記事が引っかかりますので、これをSwift化するところから始めます。

UIKit上でパーティクルエフェクトを表示する

CAEmitterLayerを重ねて、そのレイヤ上で花火を表示しています。

import UIKit

class FireworksView: UIView {

    let emitterLayer = CAEmitterLayer()
    lazy var particleImage: UIImage = {
        let imageSize = CGSize(width: 50, height: 50)
        let margin: CGFloat = 0
        let circleSize = CGSize(width: imageSize.width - margin * 2, height: imageSize.height - margin * 2)
        UIGraphicsBeginImageContext(imageSize)
        if let context = UIGraphicsGetCurrentContext() {
            context.setFillColor(UIColor.red.cgColor)
            context.fillEllipse(in: CGRect(origin: CGPoint(x: margin, y: margin), size: circleSize))
        }
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsPopContext()
        return image!
    }()

    func setup() {
        emitterLayer.emitterPosition = CGPoint(x: self.bounds.midX, y: self.bounds.maxY)
        emitterLayer.emitterMode = kCAEmitterLayerAdditive

        let baseCell = CAEmitterCell()
        baseCell.emissionLongitude = -CGFloat.pi / 2
        baseCell.emissionLatitude = 0
        baseCell.emissionRange = CGFloat.pi / 5
        baseCell.lifetime = 2.0
        baseCell.birthRate = 0.5
        baseCell.velocity = 400
        baseCell.velocityRange = 50
        baseCell.yAcceleration = 300

        // 上昇中のパーティクルの発生源
        let risingCell = CAEmitterCell()
        risingCell.contents = particleImage.cgImage
        risingCell.emissionLongitude = (4 * CGFloat.pi) / 2
        risingCell.emissionRange = CGFloat.pi / 7
        risingCell.scale = 0.3
        risingCell.velocity = 100
        risingCell.birthRate = 50
        risingCell.lifetime = 1.5
        risingCell.yAcceleration = 350
        risingCell.alphaSpeed = -0.7
        risingCell.scaleSpeed = -0.1
        risingCell.scaleRange = 0.1
        risingCell.beginTime = 0.01
        risingCell.duration = 0.7

        // 破裂後に飛散するパーティクルの発生源
        let sparkCell = CAEmitterCell()
        sparkCell.contents = particleImage.cgImage
        // パーティクルを発生する角度の範囲
        sparkCell.emissionRange = 2 * CGFloat.pi
        // 1秒間に生成するパーティクルの数
        sparkCell.birthRate = 8000
        sparkCell.scale = 0.2
        sparkCell.velocity = 130
        // パーティクルが発生してから消えるまでの時間(s)
        sparkCell.lifetime = 2.0
        sparkCell.yAcceleration = 80
        sparkCell.beginTime = CFTimeInterval(risingCell.lifetime)
        sparkCell.duration = 0.1
        sparkCell.alphaSpeed = -0.1
        sparkCell.scaleSpeed = -0.1

        // baseCellからrisingCellとsparkCellを発生させる
        baseCell.emitterCells = [risingCell, sparkCell]

        // baseCellはemitterLayerから発生させる
        self.emitterLayer.emitterCells = [baseCell]
        self.layer.addSublayer(emitterLayer)
    }
}

class ViewController: UIViewController {

    @IBOutlet weak var fireworksView: FireworksView! {
        didSet {
            fireworksView.setup()
        }
    }
}

画像を用意するのが大変だったので、CoreGraphicsを使って丸い画像を生成しています。

lazy var particleImage: UIImage = {
    let imageSize = CGSize(width: 50, height: 50)
    let margin: CGFloat = 0
    let circleSize = CGSize(width: imageSize.width - margin * 2, height: imageSize.height - margin * 2)
    UIGraphicsBeginImageContext(imageSize)
    if let context = UIGraphicsGetCurrentContext() {
        context.setFillColor(UIColor.red.cgColor)
        context.fillEllipse(in: CGRect(origin: CGPoint(x: margin, y: margin), size: circleSize))
    }
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsPopContext()
    return image!
}()

仕組みとしてはパーティクルを全方向に放出するオブジェクトと、パーティクルを下向きに放出しながら進むオブジェクトを同時に飛ばし、それぞれのタイミングをずらすことで、軌跡と花火本体を実現しています。

baseCellが二つ共を打ち上げます。sparkCellが爆発する花火放出オブジェクトです。

これで、赤い花火だけが上がる状態になります。

花火の終了間際をキラつかせる

花火、すべてのsparkが同時に消えるわけではなくて、最後はバラバラに消えてキラキラするので、

sparkCell.lifetime = 2.0
sparkCell.lifetimeRange = 0.4

にしてsparkの寿命に幅をもたせます

二色にする

全部単色だなーと思ったので、2色にしたい。
実際の花火玉とは違いますが、密度が半分の花火を2つ、同じ場所に打ち上げればよいのでは?
ということで、赤と黄で実装してみました。

名称未設定.png

分かるでしょうか?2色の重なりが必ず黄色が上になってしまう・・・
でもここはライブ。悩んでいると、もっと多くの花火を上げてかさねればよいのでは、と。

1/4密度の赤、1/2密度の黄、1/4密度の赤という3つの花火を打ち上げて重ねることで、よい重なりを実現できました!(コードではもっと雑な比率にしていますが)
同様の手法で、任意の配分で色を混ぜることができるようになりました

カラフルにする

ここまで作ったところで、shuさんのサンプルではもともとカラフルだったことを思い出します。

baseCellに対して色をカラフルにするようにします。更に、sparkCell自体に色があると重ねられてしまうので、sparkCellを白にします。これにより、cellにセットした色が反映されるようになりました。

// パーティクルの色
baseCell.color = UIColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0).cgColor
baseCell.redRange   = 0.9
baseCell.greenRange = 0.9
baseCell.blueRange  = 0.9

群衆を追加する

何かが足りない。そう、花火を上げてるのに見てる人がいない!

というわけで、群衆を追加します。

我らがいらすとやさんで「後ろ姿」を検索すると10枚程度でてきたので、これを追加することに決めます。

もちろん画像はランダムで選択します。

x座標は画面の横幅からランダムに決めます。ただし、frameのxはあくまで左上なので、開始位置を0.0にしてしまうと左端に人が来ません。
よって、x座標は arc4random_uniform(幅 + 画像幅) - 画像幅 として求めます。

y座標はすべて同じだとみんな同じ身長になってしまって違和感があるので、こちらも乱数である程度の幅をもたせます。

class CrowdView: UIView {
    private var personViews: [UIView] = []

    static let images = [
        #imageLiteral(resourceName: "stand2_back01_boy.png"), #imageLiteral(resourceName: "stand2_back02_girl.png"), #imageLiteral(resourceName: "stand2_back03_youngman.png"), #imageLiteral(resourceName: "stand2_back05_man.png"), #imageLiteral(resourceName: "stand2_back06_woman.png"), #imageLiteral(resourceName: "stand2_back07_ojisan.png"), #imageLiteral(resourceName: "stand2_back08_obasan.png"), #imageLiteral(resourceName: "stand2_back09_ojiisan.png"), #imageLiteral(resourceName: "stand2_back10_obaasan.png")
    ]

    static let imageSize = CGSize(width: 90, height: 150)
    // 足元の食い込み
    static let imageBottomOffset: CGFloat = 20
    static let imageBottomOffsetRange: CGFloat = 26

    private func appendPersonView(_ view: UIView) {
        personViews.append(view)
        self.addSubview(view)
    }

    private func resetPersons() {
        personViews.forEach { view in
            view.removeFromSuperview()
        }
        personViews = []
    }

    func setup(_ number: Int = 5) {
        let candidatesCount = CrowdView.images.count
        (0..<number)
            .map { _ in
                let image = CrowdView.images[Int(arc4random_uniform(UInt32(candidatesCount)))]
                let xPos = CGFloat(arc4random_uniform(UInt32(self.bounds.width + CrowdView.imageSize.width))) - CrowdView.imageSize.width
                let yOffset = CrowdView.imageBottomOffset + CGFloat(arc4random_uniform(UInt32(CrowdView.imageBottomOffsetRange))) - CrowdView.imageBottomOffsetRange / 2
                let imageView = UIImageView(frame: CGRect(origin: CGPoint(x: xPos, y: self.bounds.height - CrowdView.imageSize.height + yOffset), size: CrowdView.imageSize))
                imageView.image = image
                return imageView
            }
            .forEach { imageView in
                self.appendPersonView(imageView)
        }
    }
}

ちょっときれいにする

本当はFireworks構造体にbaseCell生成に必要なパメータまとめたかったけれど、時間の都合上ご容赦ください・・・

class FireworksView: UIView {

    static let particlesCount = 20000

    let emitterLayer = CAEmitterLayer()
    let sparkDelay: Float = 1.5
    var emitterCells: [CAEmitterCell] {
        return self.emitterLayer.emitterCells ?? []
    }

    /// returns circle image filled with specified color
    private static func generateCircleImage(with color: UIColor, size: CGSize = CGSize(width: 25, height: 25), inset: UIEdgeInsets = .zero) -> UIImage {
        let circleSize = CGSize(width: size.width - inset.left - inset.right, height: size.height - inset.top - inset.bottom)
        UIGraphicsBeginImageContext(size)
        if let context = UIGraphicsGetCurrentContext() {
            context.setFillColor(color.cgColor)
            context.fillEllipse(in: CGRect(origin: CGPoint(x: inset.left, y: inset.top), size: circleSize))
        }
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsPopContext()
        return image!
    }

    private func generateSparkCell(particle: UIImage, birthRate: Float) -> CAEmitterCell {
        let sparkCell = CAEmitterCell()
        sparkCell.contents = particle.cgImage
        sparkCell.emissionRange = CGFloat.pi * 2
        sparkCell.birthRate = birthRate
        sparkCell.scale = 0.2
        sparkCell.velocity = 130
        sparkCell.lifetime = 2.0
        sparkCell.lifetimeRange = 0.4
        sparkCell.yAcceleration = 80
        sparkCell.beginTime = CFTimeInterval(sparkDelay)
        sparkCell.duration = 0.1
        sparkCell.alphaSpeed = -0.1
        sparkCell.scaleSpeed = -0.1
        return sparkCell
    }

    private func generateSparkCell(color: UIColor, birthRate: Float) -> CAEmitterCell {
        return generateSparkCell(particle: FireworksView.generateCircleImage(with: color), birthRate: birthRate)
    }

    private func generateBaseCell(birthRate: Float, cells: [CAEmitterCell], isColorful: Bool = false) -> CAEmitterCell {
        let base = CAEmitterCell()
        base.emissionLongitude = -CGFloat.pi / 2
        base.emissionLatitude = 0
        base.emissionRange = CGFloat.pi / 5
        base.lifetime = 2.0
        base.birthRate = birthRate
        base.velocity = 400
        base.velocityRange = 50
        base.yAcceleration = 300
        base.emitterCells = cells
        if isColorful {
            base.color = UIColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0).cgColor
            base.redRange = 0.9
            base.greenRange = 0.9
            base.blueRange = 0.9
        }
        return base
    }

    private func generateBaseCell(birthRate: Float, risingCell: CAEmitterCell, colors: [UIColor], isColorful: Bool = false) -> CAEmitterCell {
        let sparkRate = Float(FireworksView.particlesCount / colors.count)
        let cells = colors.map { color in
            generateSparkCell(color: color, birthRate: sparkRate)
        }
        return generateBaseCell(birthRate: birthRate, cells: [risingCell] + cells, isColorful: isColorful)
    }

    private func generateRisingCell(particle: UIImage = FireworksView.generateCircleImage(with: UIColor.white)) -> CAEmitterCell {
        let risingCell = CAEmitterCell()
        risingCell.contents = particle.cgImage
        risingCell.emissionLongitude = (4 * CGFloat.pi) / 2
        risingCell.emissionRange = CGFloat.pi / 7
        risingCell.scale = 0.2
        risingCell.velocity = 100
        risingCell.birthRate = 50
        risingCell.lifetime = self.sparkDelay
        risingCell.yAcceleration = 200
        risingCell.alphaSpeed = -0.7
        risingCell.scaleSpeed = -0.1
        risingCell.scaleRange = 0.1
        risingCell.beginTime = 0.01
        risingCell.duration = 0.9
        return risingCell
    }

    func resetEmitPosition() {
        // 下過ぎないように
        emitterLayer.emitterPosition = CGPoint(x: self.bounds.midX, y: min(self.bounds.maxY, 450))
    }


    func setup() {
        emitterLayer.emitterMode = kCAEmitterLayerAdditive

        let risingCell = self.generateRisingCell(particle: FireworksView.generateCircleImage(with: UIColor.red))

        // 花火うちあげ元
        let blueWhiteBase = generateBaseCell(birthRate: 0.2, risingCell: risingCell, colors: [.blue, .blue, .white, .blue])
        let redYellowBase = generateBaseCell(birthRate: 0.7, risingCell: risingCell, colors: [.red, .yellow, .red, .yellow])
        let greenBase = generateBaseCell(birthRate: 0.3, risingCell: risingCell, colors: [.green])
        let colorfulBase = generateBaseCell(birthRate: 1.7, risingCell: generateRisingCell(), colors: [.white], isColorful: true)

        self.emitterLayer.emitterCells = [redYellowBase, blueWhiteBase, greenBase, colorfulBase]
        self.layer.addSublayer(emitterLayer)
        resetEmitPosition()
    }
}