2019年12月10日火曜日

何故「事故マップ」というWebサービスを作ったか




何故「事故マップ」というWebサービスを作ったか


先日、事故マップというサービスをリリースしました。

基本的に仕事でも個人でもモバイルアプリをメインにしている私が何故Webサービスに手を出したのかの理由を紹介します。

その理由はシンプルにモバイルアプリだけでは継続的なユーザーの利用は見込めないと思ったからです。

個人開発のくくりで考えたときにスマートフォンの出始めは電卓アプリなどの単純なアプリだけで何十万も稼ぐことができました。その後トレンドはカジュアルゲームに移行し、アイディアと工夫で大金を稼ぐ事ができました。
しかし、時代は進みスマートフォンがより一般化する事で、ユーザーは物珍しさでアプリを利用することから本当に役に立つものを選んで利用するようになったと思います。

その中で、ユーザーに利用してもらえるアプリは2パターンに分類できると思います。


  • 特定の役に立つアプリ 例)Twitter GoogleMap  写真加工アプリ 
  • 今までにできなかった事ができるようになるアプリ 例)fontgenic ARアプリ

ARアプリとしてAR CakeDivider や AR Mini Sketchを作ってみましたが、あまりインパクトのあるものではなかったと評価しています。

UnityのAR Foundationを使って今後もARアプリの開発は続けて行きたいと考えていますが、将来の自分の幅を広げるためにも前者の領域にトライしてみたいと思うようになりました。

そして、役に立つアプリは自分の知らない情報を取得できる事が根底にあると考えました。しかし、Newsサイトなどを運営するほど自分には時間が無いためユーザーの情報を交換できるSNS的なサービスを作ろうと考えました。

奇しくも丁度1ヶ月半前に交通事故を起こしてしまい、相手方との自己負担割合を決める上で事故当時の情報(映像)がとても重要になるということを実感しました。時と場合によりますがその情報は当事者にとってはとても価値のあるものになるため、そのような情報を共有できるサービスがあれば皆の役に立つのでは無いかと思い「事故マップ」というサービスを作りました。

この記事を書いた一年後には皆様に使っていただけているサービスになっていることを願いながら、エンハンスや他の開発を頑張って行こうと思う今日この頃です。






2019年9月1日日曜日

Blenderを使って3Dデータを作成&表示させてみよう


Blenderを使って3Dデータを作成&表示させてみよう


これまではサンプルをベースに紹介をしてきましたが、実際にアプリを作成する際には独自の3Dデータを表示することになると思います。

3DモデリングはShadeやAutoCADなどの有償ソフトで行うことが多いですが、個人アプリ開発の場合では無償のBlenderでも十分こと足りると思います。

今回はBlenderを使って3Dデータを作成する方法と、ARKitで表示する方法を紹介します。


Blenderのインストール


この記事作成時のBlenderの最新バージョンは2.8.0になります。

こちらからBlenderをダウンロードしインストールを行います。

2.7.9では日本語化するには別途作業が必要でしたが、2.8.0ではデフォルト日本語に対応しています。


3Dデータの作成


Blenderを使って木の箱とボールを作ります。

Blenderを起動すると下記のようなデフォルトプロジェクトが表示されます。



まずは、カメラとライトは不要なので右のアウトライナーから右クリックで選択し「削除」を実行します。

木の箱はデフォルトのCubeを使うため、ボールを追加します。

オブジェクト画面のメニューより追加 > メッシュ > UV球を選択します。




こちらのカーソルの位置に球が作成されるので事前に配置したい位置にカーソルを移動しておきます。



デフォルでは「球」となっていますが、ARKit内で操作し易いように右のアウトライナーより名前を「Ball」に変更しておきましょう。


ひとまず、シーンの中に箱とボールを作ることができました。


3Dデータのエクスポートとプロジェクトへの追加


Blenderメニューのファイル > エクスポート > Collada (.dae) を選択してデータをエクスポートします。

ここでは3d_data.daeと名前を付けてエクスポートを行います。

作成したdaeファイルはXcodeのプロジェクトにドラッグ&ドロップで追加を行います。

もし、art.scnassetsのアセットカタログがプロジェクトにあるのであればそこに追加を行いましょう。



ない場合はプロジェクト直下に置いても構いませんが、プロジェクトの Copy Bundle Resourcesにdaeファイルが追加されていることを確認しましょう。
※art.scnassetsがCopy Bundle Resourcesに追加されている場合は不要です



scnファイルへの変換と位置の調整


このままdaeファイルの状態でも3Dデータを読み込むことはできるのですが、データ表示位置の調整やテクスチャの設定はscnファイルの方がやり易いのでdaeファイルをscnファイルへと変換を行います。

Xcodeの左のペインより、3d_data.daeを選択し、メニューの Editor > Convert to SceneKit scenn file formatを実行します。

これでファイルがscnファイルへと変換され下記のように表示がされます。



3Dカーソルがこのデータの原点になります。そのため、このままAKitでテープル上にデータを表示すると、テーブルにボールの半分がめり込んでしまいます。

そのため、面倒でも一度SCNNodeを親に設定し、表示位置を調整する必要があります。

左のScene graphより「+」を選択し、ノードを追加します。

この時の名前は対応付けがわかるようにCubuNodeとしておきます。

CubeNodeを作成したらScene graph上でCubeでCubuNode配下に移動させます。

そして、CubeNodeがを選択した時に3Dカーソルがデータの底に来るようにCubeの位置を変更します。

3Dカーソルの緑の矢印(Y軸)を選択して移動させます。

このとき視点が真横に来ないと調整が難しいですが、画面の左下にあるカメラマークでFrontを選択しておくと視点が真横に来ます。

調整が済むとこのような状態になります。



テクスチャ、色の設定


このままだと真っ白なデータが表示されてしまうためテクスチャの設定と色の設定を行います。


  • 箱 → 木目のテクスチャを設定
  • ボール → テクスチャを使わず、紫色の設定


まずは箱の設定にテクスチャを設定します。

daeファイルを読み込んだときと同様にテクスチャ画像をXcodeプロジェクトにドラッグ&ドロップします。

次に右のペインでマテリアルのタブを選択します。

このときMaterialsに何も表示されていなかったら「+」ボタンを押して新しくMaterialを追加してください。

この状態ではPreviewは味気のない球が表示されているはずです。

Properties > Diffuseを選択し先程追加した画像を選択します。



このとき画像と一緒に色も選択肢として表示されましたが、画像でなく色を選択すれば3Dデータに色をつけることができます。

ボールも箱と同様にDiffuseを設定します。

うまく設定ができれば最終的にはこのようになります。



プログラムへの読み込み


ここまで来たら最後はプログラム上で作成した3Dデータを読み込むだけです。

サンプルの飛行機を読み込んだときと同じようにファイルを指定して読み込むだけです。

具体的には下記のように書きます。

guard let dataScene = SCNScene(named: "art.scnassets/3d_data.scn") else { return }
if let node = dataScene.rootNode.childNode(withName: "cubeNode", recursively: true) {
    sceneView.scene.rootNode.addChildNode(node)
}

3d_data.scnでは二つの3Dデータを含んでいます。

読み出したいデータに合わせてchildeNode()で検索する名前を変更します。

なお、ここで気をつけて欲しいのは、検索する名前は3Dデータ自体ではなくその親ノードを指定することです。

直接3Dデータを読み込むことができますが、原点が底に来ていないため想定した位置とずれてしまうことになるので注意してください。

2019年8月22日木曜日

Sad Face Makerをリリースしました

最近モバイルアプリの個人開発が停止中だったのですが、WWDC2019の刺激を受けて久しぶりに新しいアプリを作りました。



Sad Face Makerという写真の編集アプリです。Twitterの投稿機能について学ぶことができました。こちらはQiitaの記事としてまとめています。

アプリを使うとこのようなGIFアニメーションを簡単に作ることができます。



2019年4月19日金曜日

AR Mini Sketchをリリースしました


ARMiniSketchというアプリをリリースしました。

画像の切り抜きやUIGestureのカスタマイズ方法などを学ぶことができました。
もしダウンロード数が増えて来たらAdMobの成果なども公開していきたいと思っています。

AppStoreには載せられなかったのですが、使い方を紹介したビデオです。


2019年3月21日木曜日

ARKitのサンプルに影を追加しよう



ARKitのサンプルに影を追加しよう


ここまでサンプルアプリをよりリアルにする方法として、ライトを追加する方法を紹介しましたが、何か物足りないと感じてはいなかったでしょうか?

現実世界では物体に光を当てると影ができますが、サンプルアプリの飛行機には影がありませんでした。

影を付けることでさらにリアルさを演出することができますので、今回はその方法をご紹介します。


影を付ける方法


影を付ける方法は次の二つがあります。

影の画像ファイルを用意して物体の下に表示する





物体にCast Shadowを有効にした光を物体に当てる




前者は方法としてはシンプルですが、それぞれの物体に合った影の画像を用意するのは面倒です。

さらに、対象の物体が移動した場合にはそれに合ったファイルを全て用意するのは現実的ではありません。

今回は後者の方法を紹介します。


ship.scnを修正する


1. ShipMeshの位置を修正する


この後にライトと影を写す平面を追加するのですが、shipの子ノードとして追加します。

全ての子ノードがshipと同じ位置にあった方が修正がやり易いので、飛行機本体のshipMeshを親ノードのshipと同じ位置まで移動させます。



ship.scnを開き、左のペインからshipMeshを選択し、Transformsのz positionを0に変更してください。

※このまま進めると、飛行機を回転させた時に翼が地面にめり込むのでy座標も0.2に設定してください


2. 影を表示するための平面を追加する


影を表示するための平面を追加します。

影は飛行機本体の下に表示するため、平面は必ずshipMeshと比べて低い位置に配置します。


右上のオブジェクトボタンを押し、Planeを選択し、飛行機の下あたりにドラッグ&ドロップします。



次に、planeを選択し、角度と位置を調整します。

追加した状態では平面は縦向きです。横にするため、Euerのxを-90にします。

位置はPositionのx, y, zをそれぞれ、0, -0.3, 0と入力します。


次に平面の大きさを調整します。

デフォルト状態では1m × 1mなので、2m × 2mにします。

Sizeのxとyに2を入力します。



最後に、平面に影を写すため、Write To Colorの全てのチェックを外しておきます。


3. Directional Lightを追加する


ライトを追加する方法でも紹介しましたが、ライトのタイプはいくつか存在します。

影を付けるためには投光器のような一方向の光を上から当てる必要があります。

投光器のような光のタイプはDirectionalといい、それを追加します。


先ほどと同じようにオブジェクト選択ボタンからDirectional lightを選択し、編集画面上にドラッグ&ドロップします。



ライトを配置したら角度と位置を調整します。

角度はxを-90に設定し、位置は飛行機の上に来るように1を設定します。



次にshadowの設定を行います。

Casts shadowsにチェックを入れて、ModeをDeferredに設定します。

また、Castersは"Back face only"に設定をします。

この設置をしないと光が当たる上面がちらついてしまいます。



ここまで来れば影が表示されるようになりましたが、影の色が濃すぎるので透過の設定を行います。

先ほどのshadowのColorを選択しカラーパレットを表示させます。

黒を選択し、Opacityを50に変更します。


最後にライトと影をshipの子ノードに設定をしておきます。

このようにすることでshipをロードするとライトと影も一緒にロードされます。


アニメーションのコードをちょっと変更する


過去のアニメーションを追加時のコードは次のように書いていましたが、この状態だとライトと影も一緒に回転してしまいます。

if let ship = sceneView.scene.rootNode.childNode(withName: "ship", recursively: true) {
    ship.position = targetNode.position
                    
    let configuration = ARWorldTrackingConfiguration()
    sceneView.session.pause()
    sceneView.session.run(configuration)
                    
    let completion = {
         let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
         ship.runAction(rotationAction)
    }
                    
    SCNTransaction.begin()
    SCNTransaction.animationDuration = 1.5
    SCNTransaction.completionBlock = completion
    ship.opacity = 1
    SCNTransaction.commit()
                    
    targetNode.isHidden = true
}

そのため、ライトと影の位置は変えずに飛行機だけを回転させるために次のように変更します。

if let ship = sceneView.scene.rootNode.childNode(withName: "ship", recursively: true) {
    ship.position = targetNode.position
                    
    let configuration = ARWorldTrackingConfiguration()
    sceneView.session.pause()
    sceneView.session.run(configuration)
                    
    let completion = {
        let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
        if let shipMesh = ship.childNode(withName: "shipMesh", recursively: false) {
            shipMesh.runAction(rotationAction)
        }
    }
                    
    SCNTransaction.begin()
    SCNTransaction.animationDuration = 1.5
    SCNTransaction.completionBlock = completion
    ship.opacity = 1
    SCNTransaction.commit()
                    
    targetNode.isHidden = true
}


実際の動作




2019年3月20日水曜日

ARKitのサンプルにターゲットカーソルを追加しよう


ARKitのサンプルにターゲットカーソルを追加しよう


ここで取り上げるターゲットカーソルとは公式の計測アプリを起動した時に平面に表示されるカーソルのことです。


このようなカーソルを表示することでユーザーが意図した場所に3Dオブジェクトを置くことができ、ユーザーにとってより使い易いアプリとなります。


ターゲットカーソルの作成


計測アプリではターゲットカーソルを別途作成してアニメーションを付けていますが、今回は簡略化のためSNCPlaneの形を変えることでターゲットカーソルの代わりとします。

計測アプリのようなターゲットカーソルを作りたい場合はこちらの投稿を参考にしてください。

まず、ターゲットカーソルの可視部分となるジオメトリを定義します。

デフォルトの状態ではSCNPlaneは四角の平面となりますが、cornerRadiusに1を指定することで円形にすることができます。

また、色をつけるためにはdiffuseのcontensに任意のUIColorを設定します。

カーソルの下に何があるか見えた方が良いので半透明にしています。

余談ですが今回色を設定しましたが、画像情報を示すUIImageや2Dグラフィックス情報を示すSKSceneなどを指定することもできます。

let targetPlane = SCNPlane(width: 0.2, height: 0.2)
targetPlane.cornerRadius = 1
targetPlane.firstMaterial?.diffuse.contents = UIColor.blue.withAlphaComponent(0.5)
作成したジオメトリ情報をSCNNodeに指定することで飛行機と同じようにAR空間で扱うことができるようになります。

尚、targetNodeは別のViewControllerの別メソッド内でも利用したいためメンバ変数として事前に定義をしてあります。

また、ターゲットカーソルはARSessionが平面を検知したタイミングで表示させたいのでisHiddenはtrueを設定しておきます。

またポイントとしてはデフォルトのSCNPlaneは垂直方向に広がっているため、水平の平面状に表示させるため、x軸を基準に90度回転しておく必要があります。
targetNode = SCNNode(geometry: targetPlane)
targetNode.isHidden = true
targetNode.name = "target"
targetNode.eulerAngles.x =  -Float.pi / 2

画面の中央の座標を取得する


ターゲットカーソルはデバイス画面の中央に表示させたいので、画面の中央の座標を取得して値をscreenCenterというメンバ変数として保持しておきます。

var screenCenter: CGPoint {
    let bounds = sceneView.bounds
    return CGPoint(x: bounds.midX, y: bounds.midY)
}

Swiftではメンバ変数の初期値をクロージャを使って設定することができます。


画面の中央の座標で検知平面のhitTestを行う


過去のARKitのサンプルにタッチイベントを追加しようでも紹介をしましたが、hitTestは3Dオブジェクトだけではなく、ARKitの場合ARKitが検知した平面に対しても行うことができます。

毎フレームごとに画面中央座標で検知平面に対してhitTestを行い、得られた座標にターゲットカーソルを移動させることで、常に画面中央の検知平面上にターゲットカーソルが置かれることになります。

毎フレーム毎の処理はARKitサンプルにライトを追加しようで取り上げましたが、func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval)というメソッドの中で行います。

メソッドの中に次のようなコードを追加します。

DispatchQueue.main.async {
    let results = self.sceneView.hitTest(self.screenCenter, types: [.existingPlaneUsingGeometry])

    if let existingPlaneUsingGeometryResult = results.first(where: { $0.type == .existingPlaneUsingGeometry }) {
        let result = existingPlaneUsingGeometryResult
        let transform = result.worldTransform
        let newPosition = transform.position

        self.targetNode.position = newPosition
    }
}

ここでは下記のような処理を行なっています。

  1. 画面中央の座標でhitTestを行う
  2. hitTestの結果が検知したものがAR空間上の平面であるかを確認
  3. そうであった場合、平面のhitした座標を取得する
  4. 座標を絶対座標に直す
  5. 絶対座標が示す場所へtargetNodeを移動させる

DispatchQueue.main.async {} と transform.position にはちょっとした理由がありますが、将来の投稿で説明をします。

この時点ではこのように書くと思っておいてください。


平面を検知した際の処理を変更する


これまでのサンプルアプリでは平面を検知したタイミングで飛行機のオブジェクトをARAnchorに追加をしていましたが、次のように変更をします。

変更前:
 検知した平面を表すARAnchorに飛行機を追加する

変更後:
 画面の初期化時にHiddenに設定したターゲットカーソルを表示する

単純に次のように変更をします。

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    targetNode.isHidden = false
}

ターゲットカーソルをタップした時に飛行機を表示させる


平面を検知した際にターゲットカーソルを表示するため、飛行機を表示させるためのロジックを追加する必要があります。

今回は見出しそのままでターゲットカーソルをユーザーがタップしたら飛行機を表示させるようにします。

前回の投稿でhitTestを行なった際に見つかった3Dオブジェクトを名前で判断を行なっていましたが、今回は下記のように"shipMesh"の他にターゲットカーソルを示す"target"という名前の条件判断を入れるようにします。

if let result = results.first {
    guard let hitNodeName = result.node.name else { return }

    if hitNodeName == "shipMesh" {
         if let ship = result.node.parent {
             let actMove = SCNAction.move(by: SCNVector3(0, 0, 0.1), duration: 0.2)
             ship.runAction(actMove)
         }
    } else if hitNodeName == "target" {
      .....
    }
}
そしてhitTestの結果targetであった場合、これまで行なっていたように飛行機を表示させます。

ただし、注意したいのが今回は飛行機をARAnchorの子ノードとして設定するのではなく、ターゲットカーソルが示す座標に配置するという点です。

イメージとしては次の絵のような感じです。



なので次の配置は次のように行います。

ship.position = targetNode.position

実際の動作




修正後のコード


import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate, UIGestureRecognizerDelegate {

    @IBOutlet var sceneView: ARSCNView!
    
    var targetNode : SCNNode!
    
    var screenCenter: CGPoint {
        let bounds = sceneView.bounds
        return CGPoint(x: bounds.midX, y: bounds.midY)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Set the view's delegate
        sceneView.delegate = self
        
        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        
        // Create a new scene
        let scene = SCNScene(named: "art.scnassets/ship.scn")!
        
        if let ship = scene.rootNode.childNode(withName: "ship", recursively: true) {
            if let particle = SCNParticleSystem(named: "BoostFire.scnp", inDirectory: "art.scnassets") {
                particle.particleSize = 0.3
                if let emitter = ship.childNode(withName: "emitter", recursively: true) {
                    emitter.addParticleSystem(particle)
                }
            }
            ship.opacity = 0
        }
        
        // Create a target
        let targetPlane = SCNPlane(width: 0.2, height: 0.2)
        targetPlane.cornerRadius = 1
        targetPlane.firstMaterial?.diffuse.contents = UIColor.blue.withAlphaComponent(0.5)
        targetNode = SCNNode(geometry: targetPlane)
        targetNode.isHidden = true
        targetNode.name = "target"
        targetNode.eulerAngles.x =  -Float.pi / 2
        scene.rootNode.addChildNode(targetNode)
        
        // Setup Omni Light
        let light = SCNLight()
        light.type = .omni

        let LightNode = SCNNode()
        LightNode.light = light
        LightNode.name = "light"
        LightNode.position = SCNVector3(-1,2,-1)
        scene.rootNode.addChildNode(LightNode)
        
        // Set the scene to the view
        sceneView.scene = scene
        
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tap(_:)))
        tapRecognizer.delegate = self
        sceneView.addGestureRecognizer(tapRecognizer)
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Create a session configuration
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        
        // Run the view's session
        sceneView.session.run(configuration)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        // Pause the view's session
        sceneView.session.pause()
    }

    @objc func tap(_ tapRecognizer: UITapGestureRecognizer) {
        let touchPoint = tapRecognizer.location(in: self.sceneView)
        
        let results = self.sceneView.hitTest(touchPoint, options: [SCNHitTestOption.searchMode : SCNHitTestSearchMode.all.rawValue])
        
        if let result = results.first {
            guard let hitNodeName = result.node.name else { return }
            
            if hitNodeName == "shipMesh" {
                if let ship = result.node.parent {
                    let actMove = SCNAction.move(by: SCNVector3(0, 0, 0.1), duration: 0.2)
                    ship.runAction(actMove)
                }
            } else if hitNodeName == "target" {
                if let ship = sceneView.scene.rootNode.childNode(withName: "ship", recursively: true) {
                    ship.position = targetNode.position
                    
                    let configuration = ARWorldTrackingConfiguration()
                    sceneView.session.pause()
                    sceneView.session.run(configuration)
                    
                    let completion = {
                        let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
                        ship.runAction(rotationAction)
                    }
                    
                    SCNTransaction.begin()
                    SCNTransaction.animationDuration = 1.5
                    SCNTransaction.completionBlock = completion
                    ship.opacity = 1
                    SCNTransaction.commit()
                    
                    targetNode.isHidden = true
                }
            }
        }
    }
    
    // MARK: - ARSCNViewDelegate
    
/*
    // Override to create and configure nodes for anchors added to the view's session.
    func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        let node = SCNNode()
     
        return node
    }
*/
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        targetNode.isHidden = false
    }
    
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        guard let lightEstimate = self.sceneView.session.currentFrame?.lightEstimate else { return }
        let ambientIntensity = lightEstimate.ambientIntensity
        let ambientColorTemperature = lightEstimate.ambientColorTemperature
        
        if let lightNode = self.sceneView.scene.rootNode.childNode(withName: "light", recursively: true) {
            guard let light = lightNode.light else { return }
            light.intensity = ambientIntensity
            light.temperature = ambientColorTemperature
        }
        
        DispatchQueue.main.async {
            let results = self.sceneView.hitTest(self.screenCenter, types: [.existingPlaneUsingGeometry])
            
            if let existingPlaneUsingGeometryResult = results.first(where: { $0.type == .existingPlaneUsingGeometry }) {
                let result = existingPlaneUsingGeometryResult
                let transform = result.worldTransform
                let newPosition = transform.position
                
                self.targetNode.position = newPosition
            }
        }
    }
    
    func session(_ session: ARSession, didFailWithError error: Error) {
        // Present an error message to the user
        
    }
    
    func sessionWasInterrupted(_ session: ARSession) {
        // Inform the user that the session has been interrupted, for example, by presenting an overlay
        
    }
    
    func sessionInterruptionEnded(_ session: ARSession) {
        // Reset tracking and/or remove existing anchors if consistent tracking is required
        
    }
}

extension matrix_float4x4 {
    var position : SCNVector3 {
        get {
            return SCNVector3(columns.3.x, columns.3.y, columns.3.z)
        }
    }
}

2019年3月18日月曜日

iOSのAdMob(Mobile Ads SDK)でGADInvalidInitializationException

appIDの設定方法が変わっていた


開発途中のアプリにAdMobの広告を入れようと思い、過去のアプリと同じ手順でバナー広告を入れたところ次のようなエラーが起きた。

*** Terminating app due to uncaught exception 'GADInvalidInitializationException', reason: 'The Google Mobile Ads SDK was initialized incorrectly. Google AdMob publishers should follow instructions here: https://googlemobileadssdk.page.link/admob-ios-update-plist to include the AppMeasurement framework, set the -ObjC linker flag, and set GADApplicationIdentifier with a valid App ID. Google Ad Manager publishers should follow instructions here: https://googlemobileadssdk.page.link/ad-manager-ios-update-plist'

公式のページ通りに設定をしているのになぜか上手くいかない。

どうやらSDKのバージョンが上がってappIDの設定方法が変わったらしい。

最新版のバージョンではAppDelegateの起動時にappIDの設定を行うのではなく、Info.plistに記述するとのこと。

※その代わりにAppDelegateの起動時にGADMobileAds.sharedInstance().start(completionHandler: nil)を実行する必要がある


公式ページ の日本語版はまだ説明が追いついていないようで、英語版だとInfo.plistに書くようにとの説明になっていた。

日本語版

英語版


2019年3月13日水曜日

ARKitサンプルにジェットのパーティクルを追加しよう



ARKitサンプルにジェットのパーティクルを追加しよう



ARKitのサンプル飛行機に下の絵のようなジェットのパーティクルを追加してみましょう。




パーティクルとは小さな画像を粒子に見立てて、加速度や出現頻度、範囲などの粒子運動を指定することで炎や煙、雪などをシミュレートするコンピューターグラフィックス技術です。

Web上でパーティクルを作成できるParticle2dxのようなサービスもありますが、Xcodeでも標準でパーティクルを作成することができます。


ジェットのパーティクルを作る


まず始めにXcodeの左のペインより適当な場所を選択し、右クリックで「New File...」を選択します。




作成するファイル形式の一覧が表示されますので、その中から「SceneKit Particle System File」を選択します。



Particle System Template の確認がありますがここはSmokeを選びましょう。

ファイル名はBoostFireという名前を指定します。

BoostFire.scnptというファイルが作成されるので、それを選択するとパーティクルの編集を行うことができます。

様々なパラメータがあって迷ってしまいますが、下記のような値を設定してください。


炎を演出するために重要なのは赤枠で囲った部分です。

  • Acceleration  → y の値に1をセットすることで炎の勢いを演出することができます
  • Color → 色を指定することで炎の赤を演出することができます
  • Custom Animation → 炎の形を作ることができます

Custom Animationの部分はパーティクルの広がり方を指定します。

デフォルトの状態では上に登るに従って発散する形なので右肩上がりの線になりますが、始まりから一度膨らんで萎む炎の形は下の絵のように山形になります。





パーティクルを飛行機に追加する


パーティクルが作れたので、早速飛行機に追加しましょう。

追加する場所は飛行機のお尻部分になります。

自作の3Dオブジェクトであれば追加したい場所に空のノードを作成しておく必要がありますが、サンプルの飛行機には既にemitterというノードが用意されています。




コード上の追加はとても簡単ですが、追加時にディレクトリを指定する必要があるので先に先程作成したBoostFire.scnpとsmoke.pngをart.scnassetsへ移動しておきましょう。




移動後はこのようなフォルダ構成になるはずです。

次にViewController.swiftを開き、ViewDidLoad()内でshipをロードしている箇所に次のコードを追加します。

if let particle = SCNParticleSystem(named: "BoostFire.scnp", inDirectory: "art.scnassets") {
    particle.particleSize = 0.3
    if let emitter = ship.childNode(withName: "emitter", recursively: true) {
        emitter.addParticleSystem(particle)
    }
}

このようにすることでファイルからロードされたパーティクルがemitterの場所に配置されます。


実際の動作





修正後のコード

import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate, UIGestureRecognizerDelegate {

    @IBOutlet var sceneView: ARSCNView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Set the view's delegate
        sceneView.delegate = self
        
        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        
        // Create a new scene
        let scene = SCNScene(named: "art.scnassets/ship.scn")!
        
        if let ship = scene.rootNode.childNode(withName: "ship", recursively: true) {
            if let particle = SCNParticleSystem(named: "BoostFire.scnp", inDirectory: "art.scnassets") {
                particle.particleSize = 0.3
                if let emitter = ship.childNode(withName: "emitter", recursively: true) {
                    emitter.addParticleSystem(particle)
                }
            }
            ship.opacity = 0
        }
        
        // Setup Omni Light
        let light = SCNLight()
        light.type = .omni

        let LightNode = SCNNode()
        LightNode.light = light
        LightNode.name = "light"
        LightNode.position = SCNVector3(-1,2,-1)
        scene.rootNode.addChildNode(LightNode)
        
        // Set the scene to the view
        sceneView.scene = scene
        
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tap(_:)))
        tapRecognizer.delegate = self
        sceneView.addGestureRecognizer(tapRecognizer)
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Create a session configuration
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        
        // Run the view's session
        sceneView.session.run(configuration)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        // Pause the view's session
        sceneView.session.pause()
    }

    @objc func tap(_ tapRecognizer: UITapGestureRecognizer) {
        let touchPoint = tapRecognizer.location(in: self.sceneView)
        
        let results = self.sceneView.hitTest(touchPoint, options: [SCNHitTestOption.searchMode : SCNHitTestSearchMode.all.rawValue])
        
        if let result = results.first {
            guard let hitNodeName = result.node.name else { return }
            guard hitNodeName == "shipMesh" else { return }
            
            if let ship = result.node.parent {
                let actMove = SCNAction.move(by: SCNVector3(0, 0, 0.1), duration: 0.2)
                ship.runAction(actMove)
            }
        }
    }
    
    // MARK: - ARSCNViewDelegate
    
/*
    // Override to create and configure nodes for anchors added to the view's session.
    func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        let node = SCNNode()
     
        return node
    }
*/
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        
        if let ship = sceneView.scene.rootNode.childNode(withName: "ship", recursively: true) {
            ship.removeFromParentNode()
            node.addChildNode(ship)
            
            let configuration = ARWorldTrackingConfiguration()
            sceneView.session.pause()
            sceneView.session.run(configuration)
            
            let completion = {
                let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
                ship.runAction(rotationAction)
            }
            
            SCNTransaction.begin()
            SCNTransaction.animationDuration = 1.5
            SCNTransaction.completionBlock = completion
            ship.opacity = 1
            SCNTransaction.commit()
        }
    }
    
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        guard let lightEstimate = self.sceneView.session.currentFrame?.lightEstimate else { return }
        let ambientIntensity = lightEstimate.ambientIntensity
        let ambientColorTemperature = lightEstimate.ambientColorTemperature
        
        if let lightNode = self.sceneView.scene.rootNode.childNode(withName: "light", recursively: true) {
            guard let light = lightNode.light else { return }
            light.intensity = ambientIntensity
            light.temperature = ambientColorTemperature
        }
    }
    
    func session(_ session: ARSession, didFailWithError error: Error) {
        // Present an error message to the user
        
    }
    
    func sessionWasInterrupted(_ session: ARSession) {
        // Inform the user that the session has been interrupted, for example, by presenting an overlay
        
    }
    
    func sessionInterruptionEnded(_ session: ARSession) {
        // Reset tracking and/or remove existing anchors if consistent tracking is required
        
    }
}


2019年3月10日日曜日

ARKitのサンプルにタッチイベントを追加しよう


ARKitのサンプルにタッチイベントを追加しよう


インタラクティブなアプリケーションを作る場合にはユーザーの操作を処理する必要が出てきます。

通常のアプリではボタンなどのUI部品それぞれにイベントハンドラを仕込むことができるので、何がユーザーによって押されたかを調べる必要はありません。

しかし、ARKitの場合オブジェクトそれぞれにイベントハンドラを仕込むことはできないため、hitTestを使ってどのオブジェクトが押されたのかを調べる必要があります。


hitTestとは





hitTestとは、ある特定の座標から直線を伸ばしたときに、その延長線上にオブジェクトがあるかを確認する方法です。

ユーザーがタッチした座標でhitTestを行うことで何が選択されたのかを知ることができます。

そして、ARKitの場合hitTestで調べられるオブジェクトは主に2つあります。

  • 平面検知や画像認識によって置かれたARAnchor
  • AR空間上に置かれた3Dオブジェクト(例:サンプルアプリの飛行機)


今回は後者を扱いますが、次回以降で検知した平面をhitTestする方法を紹介したいと思います。


修正方針


今回は次のようにアプリを修正してみたいと思います。


  • 画面のタップイベントを拾う
  • タップ座標を取得する
  • 座標情報を元にhitTestを行う
  • 飛行機がタップされたのであれば10cm前進させる


画面のタップイベントを拾う


画面のタップイベントを拾うためにはメインのViewControllerがGestureRecognizerのdelegateになる必要があります。

クラス定義に下記のDelegateを追加しましょう。

UIGestureRecognizerDelegate

また、画面(sceneView)がタップされたことを検知したいので、TapGestureRecognizerを生成し、イベント処理者をViewControllerに設定し、sceneViewに登録をしておきます。

let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tap(_:)))
tapRecognizer.delegate = self
sceneView.addGestureRecognizer(tapRecognizer)
UITapGestureRecognizerのtarget引数がイベント処理者を指定し、actionが呼ばれるメソッドを指定します。

そのため、呼ばれるメソッドをViewControllerにtapという名前で用意しておきます。

@objc func tap(_ tapRecognizer: UITapGestureRecognizer) {
}

タップ座標を取得する


画面をタップした時にtap()が呼ばれるようになったので、tap()の中に次のようにタップした座標を取得するメソッドを追加します。

let touchPoint = tapRecognizer.location(in: self.sceneView)

座標情報を基にhitTestを行う


次のようにhitTestを呼び出し、touchPointを渡してやります。

let results = self.sceneView.hitTest(touchPoint, options: [SCNHitTestOption.searchMode : SCNHitTestSearchMode.all.rawValue])

option引数にパラメータを仕込むことで、細かな制御ができますが、今回はシンプルにhitTestの延長線上の全てのオブジェクトを検知対象とするようにします。



飛行機がタップされたのであれば10cm前進させる


飛行機がタップされたことは検知ノードの名前を調べることで判断することができます。

また、このときに注意して欲しいのがhitTestの当たり判定は延長線上にある全てのSCNNodeを引っ張ってくるため、必然的に当たり判定は3Dデータのメッシュを表すノードが判定され易くなります。

サンプルの飛行機データはshipというノード配下の子ノードにshipMeshというデータがあり、これがメッシュデータを表すノードになります。


shipノードの当たり判定がない訳ではありませんが、非常に小さいためユーザーにタップされたことを検知するには子ノードのshipMeshで判定した方が、ユーザーの期待値に沿うことになります。

if let result = results.first {
   guard let hitNodeName = result.node.name else { return }
   guard hitNodeName == "shipMesh" else { return }
            
   if let ship = result.node.parent {
      let actMove = SCNAction.move(by: SCNVector3(0, 0, 0.1), duration: 0.2)
      ship.runAction(actMove)
   }
}


実際の動作





修正後のソースコード


import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate, UIGestureRecognizerDelegate {

    @IBOutlet var sceneView: ARSCNView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Set the view's delegate
        sceneView.delegate = self
        
        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        
        // Create a new scene
        let scene = SCNScene(named: "art.scnassets/ship.scn")!
        
        if let ship = scene.rootNode.childNode(withName: "ship", recursively: true) {
            ship.opacity = 0
        }
        
        // Setup Omni Light
        let light = SCNLight()
        light.type = .omni

        let LightNode = SCNNode()
        LightNode.light = light
        LightNode.name = "light"
        LightNode.position = SCNVector3(-1,2,-1)
        scene.rootNode.addChildNode(LightNode)
        
        // Set the scene to the view
        sceneView.scene = scene
        
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tap(_:)))
        tapRecognizer.delegate = self
        sceneView.addGestureRecognizer(tapRecognizer)
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Create a session configuration
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        
        // Run the view's session
        sceneView.session.run(configuration)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        // Pause the view's session
        sceneView.session.pause()
    }

    @objc func tap(_ tapRecognizer: UITapGestureRecognizer) {
        let touchPoint = tapRecognizer.location(in: self.sceneView)
        
        let results = self.sceneView.hitTest(touchPoint, options: [SCNHitTestOption.searchMode : SCNHitTestSearchMode.all.rawValue])
        
        if let result = results.first {
            guard let hitNodeName = result.node.name else { return }
            guard hitNodeName == "shipMesh" else { return }
            
            if let ship = result.node.parent {
                let actMove = SCNAction.move(by: SCNVector3(0, 0, 0.1), duration: 0.2)
                ship.runAction(actMove)
            }
        }
    }
    
    // MARK: - ARSCNViewDelegate
    
/*
    // Override to create and configure nodes for anchors added to the view's session.
    func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        let node = SCNNode()
     
        return node
    }
*/
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        
        if let ship = sceneView.scene.rootNode.childNode(withName: "ship", recursively: true) {
            ship.removeFromParentNode()
            node.addChildNode(ship)
            
            let configuration = ARWorldTrackingConfiguration()
            sceneView.session.pause()
            sceneView.session.run(configuration)
            
            let completion = {
                let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
                ship.runAction(rotationAction)
            }
            
            SCNTransaction.begin()
            SCNTransaction.animationDuration = 1.5
            SCNTransaction.completionBlock = completion
            ship.opacity = 1
            SCNTransaction.commit()
        }
    }
    
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        guard let lightEstimate = self.sceneView.session.currentFrame?.lightEstimate else { return }
        let ambientIntensity = lightEstimate.ambientIntensity
        let ambientColorTemperature = lightEstimate.ambientColorTemperature
        
        if let lightNode = self.sceneView.scene.rootNode.childNode(withName: "light", recursively: true) {
            guard let light = lightNode.light else { return }
            light.intensity = ambientIntensity
            light.temperature = ambientColorTemperature
        }
    }
    
    func session(_ session: ARSession, didFailWithError error: Error) {
        // Present an error message to the user
        
    }
    
    func sessionWasInterrupted(_ session: ARSession) {
        // Inform the user that the session has been interrupted, for example, by presenting an overlay
        
    }
    
    func sessionInterruptionEnded(_ session: ARSession) {
        // Reset tracking and/or remove existing anchors if consistent tracking is required
        
    }
}

2019年3月7日木曜日

ARKitのサンプルにアニメーションを追加しよう



ARKitのサンプルにアニメーションを追加しよう


今回扱うのはアニメーションです。

ただAR空間の物体に動きが入るとよりリアルさが出て楽しくなります。


アニメーションの設定方法


アニメーションの設定方法は大きく3つあります。

  • SCNAction
  • SCNTransaction
  • CoreAnimation


SCNAction


直進や回転、拡大縮小、フェードイン/フェードアウトをそれぞれ対応したActionを使ってAR上の物体にアニメーションを適用することができます。


SCNTransaction


AR空間の物体は60FPSのループで常に描画されていますが、その間隔を伸ばすことによりアニメーションを設定することができます。

イメージとしては物体を1m右に動かすことを1秒かけて行うことで、その間の動きが描画される感じです。



CoreAnimation


通常AR空間に表示する3DオブジェクトはBlenderなどの3Dモデリングツールを使って作られますが、3Dモデリングツールでは形を作るだけでなくオブジェクトにアニメーションを設定することができます。

そのような、外部ツールで設定されたアニメーションを呼び出すことでアニメーションを描画することができます。

キャラクターが歩くなどの複雑なアニメーションの場合は大抵CoreAnimationを使います。




修正方針


今回は次の2つのアニメーションをそれぞれ別の方法で実装してみます。

  1. 平面検知された時に表示される飛行機をフェードインさせる
  2. 飛行機をきりもみ回転させる


飛行機のフェードイン


SCNTransactionと、物体の透過を設定するopacityというプロパティを使って実装します。

まずARKitサンプルの飛行機を平面に置いてみようでshipの初期状態をHiddenにしましたが、これをopacityに変更しておきます。

ship.opacity = 0
このopacityは0〜1が設定でき、0が完全に透明な状態を示します。

次に、func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor)で飛行機を置いていますが、この処理の最後に次のSCNTransaction処理を追加します。
SCNTransaction.begin()
SCNTransaction.animationDuration = 1.5

ship.opacity = 1
SCNTransaction.commit()


飛行機のきりもみ回転


単純に飛行機を回転させる場合は次の2行で実装できます。

let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
ship.runAction(rotationAction)

しかし、これを単純にshipに追加してしまうと、フェードインしている状態で回転をしてしまいます。

フェードインが完了してから回転をしたい場合は次のように実装をします。

let completion = {
    let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
    ship.runAction(rotationAction)
}

SCNTransaction.begin()
SCNTransaction.animationDuration = 1.5
SCNTransaction.completionBlock = completion
ship.opacity = 1

SCNTransaction.commit()

実際に動作している様子




実際のコード

import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate {

    @IBOutlet var sceneView: ARSCNView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Set the view's delegate
        sceneView.delegate = self
        
        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        
        // Create a new scene
        let scene = SCNScene(named: "art.scnassets/ship.scn")!
        
        if let ship = scene.rootNode.childNode(withName: "ship", recursively: true) {
            ship.opacity = 0
        }
        
        // Setup Omni Light
        let light = SCNLight()
        light.type = .omni

        let LightNode = SCNNode()
        LightNode.light = light
        LightNode.name = "light"
        LightNode.position = SCNVector3(-1,2,-1)
        scene.rootNode.addChildNode(LightNode)
        
        // Set the scene to the view
        sceneView.scene = scene
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Create a session configuration
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        
        // Run the view's session
        sceneView.session.run(configuration)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        // Pause the view's session
        sceneView.session.pause()
    }

    // MARK: - ARSCNViewDelegate
    
/*
    // Override to create and configure nodes for anchors added to the view's session.
    func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        let node = SCNNode()
     
        return node
    }
*/
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        
        if let ship = sceneView.scene.rootNode.childNode(withName: "ship", recursively: true) {
            ship.removeFromParentNode()
            node.addChildNode(ship)
            
            let configuration = ARWorldTrackingConfiguration()
            sceneView.session.pause()
            sceneView.session.run(configuration)
            
            let completion = {
                let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
                ship.runAction(rotationAction)
            }
            
            SCNTransaction.begin()
            SCNTransaction.animationDuration = 1.5
            SCNTransaction.completionBlock = completion
            ship.opacity = 1
            SCNTransaction.commit()
        }
    }
    
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        guard let lightEstimate = self.sceneView.session.currentFrame?.lightEstimate else { return }
        let ambientIntensity = lightEstimate.ambientIntensity
        let ambientColorTemperature = lightEstimate.ambientColorTemperature
        
        if let lightNode = self.sceneView.scene.rootNode.childNode(withName: "light", recursively: true) {
            guard let light = lightNode.light else { return }
            light.intensity = ambientIntensity
            light.temperature = ambientColorTemperature
        }
    }
    
    func session(_ session: ARSession, didFailWithError error: Error) {
        // Present an error message to the user
        
    }
    
    func sessionWasInterrupted(_ session: ARSession) {
        // Inform the user that the session has been interrupted, for example, by presenting an overlay
        
    }
    
    func sessionInterruptionEnded(_ session: ARSession) {
        // Reset tracking and/or remove existing anchors if consistent tracking is required
        
    }
}