Swiftでスプラトゥーン風インジケーターを試作してみた結果


どうもこんにちは。
いつまで経ってもゲームが上達しないエンジョイナワバリ勢のTakaboSoftです。

スプラトゥーンで遊んでいると、セーブ時など（カスタマイズ画面からハイカラシティへ戻るときなど）に画面左下にインジケーターが表示されるのですが、「iOSだったらどうやって実装するんだろう？」とふと疑問に思ったのでちょっとチャレンジしてみました。

※実験してみただけで、使う予定はありません。

## 結果
![03.gif](https://qiita-image-store.s3.amazonaws.com/0/31347/c8dc86d1-a72b-d0c1-b0fe-069c9c6f79d7.gif "03.gif")

ほとんど実物を見ないで作り始めたせいで、答え合わせしたらあんまり似てませんでした。
実物は同じ場所から定期的にインクが飛び出すようなものでした（今回はランダムで実装）。
縁付近が黒くなっているのも謎です。

## 製作過程

たぶん最大の難関は丸同士の距離が近いときに液体の表面張力のようなくっつき方をする所だと思います。
たとえばこんなもの↓です（こちらは3Dですが）。

![Metaballs.gif](https://qiita-image-store.s3.amazonaws.com/0/31347/3fafcb0b-71f5-f4c5-7dcc-1490b7b1a75d.gif "Metaballs.gif")
（画像の著作権者：T-tusさん、ライセンス：CC BY-SA 3.0、https://ja.wikipedia.org/wiki/メタボール ）

昔からそのようなくっついたり離れたりするもののことを「メタボール」と呼ぶことだけは知っていたので、そのキーワードでしばらくググっていると、iOSで2Dのメタボールを実装している方を発見。

Globular: Colourful Metaballs Controlled by 3D Touch
http://flexmonkey.blogspot.jp/2015/10/globular-colourful-metaballs-controlled.html

記事とソースを見たところ、SpriteKitで描画した結果にCoreImageでガウシアンブラー＋トーンカーブフィルタをリアルタイムで掛けているだけのようでした。

（3Dでは計算＆ポリゴンで実装するしかありませんが（？）、2Dの場合はこのような画像処理でメタボールを実装可能というのは面白いですね。）

### 2Dメタボールの考え方

まずブラーを掛けると、境界線付近の色（アルファ値）がなだらかになります。
丸同士のなだらかになった部分が重なるような箇所はより濃い色になります。
そしてその濃さを補正（例えば一定以上の濃さの所は色有り、それ未満は色無しといったような補正など）をしてやれば、結果的に丸同士の境界線付近がくっついたかのようなレンダリング結果になるというわけです。

### 今回の実装方針

これなら難しい計算もなく簡単に実装できそうだ・・・という事で、今回はその方法をモロに参考にして

1. SpriteKitを使って中心からランダムな方向にスプライトが飛ぶ（再び中心へ落下する）アニメーションを実装する
2. レンダリング結果にCoreImageでフィルタを掛けてメタボールっぽく見えるようにする

という方針で実装を試みました。

ただ、参考サイトのトーンカーブ方式ではうまくいきそうになかったので、カラーマトリクスフィルターを使ってアルファ値を増幅させるという方法で実装を行いました。

## ソース

* Xcode7.3.1 + Swift2.2 + iOS9

```swift:ViewController.swift
import UIKit
import SpriteKit

/// インジケータービュー
class SplaIndicatorView: SKView {
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        showsFPS = true
        allowsTransparency = true
        
        let scene = SKScene(size: bounds.size)
        scene.backgroundColor = UIColor.clearColor()
        presentScene(scene)
        
        circleTexeture = SKTexture(image: makeCircle(size: CGSize(width: 25, height: 25), color: UIColor.purpleColor()))
        
        // 真ん中のノードを作る
        let node = SKSpriteNode(texture: circleTexeture)
        node.position.x = bounds.midX
        node.position.y = bounds.midY
        scene.addChild(node)
        
        // フィルターの設定
        scene.filter = MetaBallFilter()
        scene.shouldEnableEffects = true
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private var timer: NSTimer?
    private var circleTexeture: SKTexture!
    
    override func didMoveToSuperview() {
        super.didMoveToSuperview()
        
        if superview != nil {
            if timer == nil {
                let timer = NSTimer(timeInterval: 0.05, target: self, selector: #selector(SplaIndicatorView.didTimer), userInfo: nil, repeats: true)
                NSRunLoop.currentRunLoop().addTimer(timer, forMode: NSRunLoopCommonModes)
                self.timer = timer
            }
        } else {
            timer?.invalidate()
            timer = nil
        }
    }
    
    @objc private func didTimer() {
        
        let size = CGFloat(arc4random_uniform(5) + 4)
        
        let node = SKSpriteNode(texture: circleTexeture, size: CGSize(width: size, height: size))
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        node.position = center
        scene?.addChild(node)
        
        let angle = CGFloat(rand()) / CGFloat(RAND_MAX) * CGFloat(2 * M_PI)
        let distance = size * 1.5 + 25
        let duration = 0.25
        let ac1 = SKAction.moveTo(CGPoint(x: center.x + cos(angle) * distance, y: center.y + sin(angle) * distance), duration: duration)
        ac1.timingMode = .EaseOut
        let ac2 = SKAction.moveTo(center, duration: duration)
        ac2.timingMode = .EaseIn
        node.runAction(SKAction.sequence([ac1, ac2])) {
            node.removeFromParent()
        }
    }
    
    private func makeCircle(size size: CGSize, color: UIColor) -> UIImage {
        UIGraphicsBeginImageContextWithOptions(size, false, 0)
        let c = UIGraphicsGetCurrentContext()
        CGContextAddEllipseInRect(c, CGRect(origin: CGPoint.zero, size: size))
        color.setFill()
        CGContextFillPath(c)
        let img = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        
        return img
    }
}

/// ブラー＋カラーマトリクスフィルター
class MetaBallFilter: CIFilter {
    
    var inputImage: CIImage?
    
    override var outputImage: CIImage! {
        
        guard let inputImage = inputImage else { return nil }
        
        let blur = CIFilter(name: "CIGaussianBlur")!
        blur.setDefaults()
        blur.setValue(5 * UIScreen.mainScreen().scale, forKey: kCIInputRadiusKey)
        blur.setValue(inputImage, forKey: kCIInputImageKey)
        
        let colorMatrixFilter = CIFilter(name: "CIColorMatrix")!
        colorMatrixFilter.setDefaults()
        colorMatrixFilter.setValue(blur.outputImage, forKey: kCIInputImageKey)
        colorMatrixFilter.setValue(CIVector(x:0, y:0, z:0, w: 20), forKey: "inputAVector")
        
        return colorMatrixFilter.outputImage
    }
}

/// ビューコントローラー
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = UIColor.whiteColor()
        view.addSubview(SplaIndicatorView(frame: CGRect(x: 100, y: 100, width: 120, height: 120)))
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}
```

## 終わりに

iOS8だと描画結果が違ったりして（フィルタの内容がクリアされない？）、挙動がやや怪しい感じを受けましたが、iOS9限定であればギリギリ成功と言えるのではないでしょうか。

ただ、第三世代のiPadでは20FPSぐらいしか出なく、重い印象でした。
（実験した端末にて、CoreImageがGPUとCPUのどちらで動いているか確認する方法が判りませんでした・・・、まあ第三世代は全体的にもっさりしているので気にしなくてもいいかもですが。）

たぶん一番重いのはガウシアンブラーだと思いますので、丸自体の画像に先にこのガウシアンブラーを掛けておいて、リアルタイムに掛けるフィルタはトーンマトリクスだけにすれば結構速くなりそうな予感です。
（その場合飛ばすインクサイズをある程度固定化するといった仕様変更が必要ですが。）

また、2Dメタボールの実装方法として、画像処理ではなく、パスを使ったやり方もあって、Swiftに移植されている方もいるようでした。

dabing1022/DBMetaballLoading
https://github.com/dabing1022/DBMetaballLoading

たぶんこういったパスを使った方が圧倒的に軽いと思いますので、また機会があればパス方式でもトライしてみたいと思います（難しそう）。

（あとNSTimerはあんまり良くなかった...）