工場裏のアーカイブス

素人によるiPhoneアプリ開発の学習記 あと機械学習とかM5Stackとか

SceneKitに触れてみる(2)

本記事はSceneKitに触れてみる(1)の続きとなります。こちらの記事で公開した拙作のSceneKitサンプルアプリについて、本記事ではソースコードの要点などをまとめてみます。

SceneKitのテンプレートプロジェクトについて

ソースコードの前に、まずはSpriteKitのテンプレート(Gameテンプレートで選択)で生成されるプロジェクトを眺めてみます。storyboardを見ると、あらかじめビューコントローラー(GameViewController)が用意されており、「SceneKit View」という種類のビューが貼り付けられています。

SceneKit Viewは下図のようにカスタムクラスが「SCNView」となっており、これがSceneKitのコンテンツを描画するための基本ビューとなります。SceneKitではこのSCNViewの上にシーン(SCNScene)を配置し、そこに立体モデル、カメラ、光源などの様々なノード(SCNNode)を配置することによって画面を作っていきます。この辺りの考え方はSpriteKitとほぼ同様です。
f:id:fleron:20150122223651p:plain

シーン(SCNScene)の作成

それでは順番に、ソースコードの要点についてまとめていきます。まずは冒頭の viewDidLoad メソッドの周辺です。

    weak var scnView : SCNView?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let scene = SCNScene()
        scnView = self.view as? SCNView
        scnView?.scene = scene
        scnView?.backgroundColor = UIColor(red: 0.2, green: 0.2, blue: 0.6, alpha: 1.0)
        scnView?.allowsCameraControl = true;
        
        setObject()
        setCamera()
        setLight()
    }

ここではscnViewというプロパティを用意してビューへの参照を取得し、空のシーン(SCNScene)を作成してビューに配置しています(sceneプロパティへの代入)。また背景色の設定もここで行っています。

そしてビュー(SCNView)には「allowsCameraControl」というプロパティがあり、これをtrueにするだけで、画面タッチ操作でカメラ(視点)を色々操作する機能が備わるようになります(以下は操作の例です。他にもあるかもしれません)。

  • 1本指ドラッグ:視点の3次元回転
  • 2本指ドラッグ:パン(視点の上下左右への移動)
  • ピンチ:ズームイン or ズームアウト
  • 回転:視点の軸回転
  • ダブルタップ:視点のリセット

細かい設定(特定の操作のみ無効にするなど)は残念ながら出来ないようですが、このような操作を自前実装するのは結構大変なので、かなり便利です。

そして、シーンへ各種ノードを配置する処理については、ソースを見やすくするために、それぞれ別々のメソッドにまとめています。以降で順番に触れていきます。

シーンへの立体図形の配置

シーンに初期の立体モデル(本アプリでは、半透明の箱および地面)を配置する処理は、setObjectメソッドで行っています。

func setObject(){
        let generator = SCNBox(width: 6.0, height: 6.0, length: 6.0, chamferRadius: 0.0)
        generator.firstMaterial?.diffuse.contents = UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.7)
        let generatorNode = SCNNode()
        generatorNode.geometry = generator
        generatorNode.position = SCNVector3(x: 0, y: 30, z: 0)
        generatorNode.name = "generator"
        self.scnView?.scene?.rootNode.addChildNode(generatorNode)
        
        let ground = SCNBox(width: 200.0, height: 2.0, length: 200.0, chamferRadius: 0.0)
        ground.firstMaterial?.diffuse.contents = UIColor(red: 0.8, green: 0.8, blue: 0.3, alpha: 1.0)
        let groundNode = SCNNode()
        groundNode.geometry = ground
        groundNode.position = SCNVector3(x: 0, y: 0, z: 0)
        groundNode.name = "ground"
        groundNode.physicsBody = SCNPhysicsBody.staticBody()
        groundNode.physicsBody?.physicsShape = SCNPhysicsShape(node: groundNode, options: nil)
        self.scnView?.scene?.rootNode.addChildNode(groundNode)
    }

SpriteKitではSKSpriteNode、SKLabelNode、SKShapeNodeなど、用途に応じて様々な種類のノードを使い分けていました。しかしSceneKitでは、まず空のノード(SCNNode)を生成し、そこに立体モデル、カメラ、光源など必要なオブジェクトを「取り付ける」ようなイメージでノードを扱うようです。

ここではまず、箱型の立体モデルのオブジェクトをSCNBoxクラスにより生成します。SceneKitにはこのSCNBoxのように、箱、球、円柱などの汎用的な立体モデルを生成するクラスが用意されています(SCNGeometryクラスより派生、他の立体も後で登場します)。そしてfirstMaterialプロパティで色(厳密には拡散光の色)を設定します。ここで色のアルファ値を1.0未満にすると、立体モデルを半透明化することが出来ます。

あとはオブジェクトを各ノードのgeometryプロパティに代入して取り付け、ノードの必要なプロパティ(位置、名前など)を設定して行きます。

ここで、地面ノード(groundNode)に対しては物理ボディを設定し、物理エンジンによる制御が働くようにしています。地面用なので、重力や他ボディの衝突影響などを受けないよう、staticBodyメソッドで物理ボディを生成します。そして、SCNPhysicsShapeで物理ボディの形状を規定します(ここではgroundNodeの形状をそのまま適用しています)。

そしてビューに配置したシーンに対して、addChildNodeメソッドでノードを配置します。この辺りはSpriteKitと同じ要領です。

シーンへのカメラの配置

シーンに視点となるカメラを配置する処理は、setCameraメソッドで行っています。

func setCamera(){
        let cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        self.scnView?.scene?.rootNode.addChildNode(cameraNode)
        cameraNode.position = SCNVector3(x: 0, y: 50, z: 50)
        cameraNode.rotation = SCNVector4(x: 1, y: 0, z: 0, w: -0.5)
    }

先程と同様まずは空のノードを生成します。そして今度はSCNCameraクラスによってカメラオブジェクトを生成し、ノードのcameraプロパティに代入して取り付けて、シーンに配置します。そしてカメラはデフォルトではZ軸のマイナス方向を向いているので、カメラノードの位置および向きを調節して、先ほど配置した立体モデルがアプリ起動時にうまく画面に収まるようにしています。

シーンへの光源の配置

シーンに光源を配置する処理は、setLightメソッドで行っています。

func setLight(){
        let ambientLightNode = SCNNode()
        ambientLightNode.light = SCNLight()
        ambientLightNode.light?.type = SCNLightTypeAmbient
        ambientLightNode.light?.color = UIColor.darkGrayColor()
        self.scnView?.scene?.rootNode.addChildNode(ambientLightNode)
        
        let lightNode = SCNNode()
        lightNode.light = SCNLight()
        lightNode.light?.type = SCNLightTypeSpot
        lightNode.light?.spotOuterAngle = 90
        lightNode.light?.color = UIColor.whiteColor()
        lightNode.light?.castsShadow = true
        lightNode.position = SCNVector3(x: 0, y: 50, z: 10)
        
        if let ground = self.scnView?.scene?.rootNode.childNodeWithName("ground", recursively: false){
            let cons = SCNLookAtConstraint(target: ground)
            cons.influenceFactor = 1.0
            lightNode.constraints = [cons]
        }
        
        self.scnView?.scene?.rootNode.addChildNode(lightNode)
    }

まず空のノードを生成し、光源オブジェクトを生成(SCNLightクラス)してノードに取り付け、シーンに配置する…という要領はこれまで同様ですが、ここでは種類(typeプロパティ)の異なる光源2つを用いています。

まず、typeプロパティにSCNLightTypeAmbientを設定した光源は、環境光として働きます。これは空間全体に一定の明るさを与えるような光であり、位置や方向に依存しないので、光の色(colorプロパティ)のみを設定します。

また、typeプロパティにSCNLightTypeSpotを設定した光源は、スポットライトとして働きます。これは特定の方向へ円錐状に広がる光であり、図にすると以下のようになります(非常に雑ですが)。
f:id:fleron:20150125002332p:plain
spotOuterAngleプロパティで円錐の広がる角度を設定します。また、castsShadowプロパティをtrueにすると、これだけでスポットライトにより生じる立体モデルの影が、他の立体モデルに投影されるようになります(個人的には、この機能には結構感激しました)。

ここで、スポットライトもデフォルトではマイナスZ軸方向を向いていますが、この向きを変えて、光源の位置(x: 0, y: 50, z: 10)から地面の中央を照らすようにすることを考えます。カメラのときと同様にrotationプロパティで向きを調節しても良いのですが、実はSceneKitには、あるノードがターゲットとなるノードの方向を常に向くよう束縛する仕組みが用意されています。ソース中にあるSCNLookAtConstraintがそれです。

ここではシーンに配置された地面ノードへの参照を取得し、それをターゲットとする方向束縛をスポットライトノードに設定しています(constraintプロパティへの代入)。これでスポットライトが常に地面の中央を照らすようになります。

なお、今回ターゲットとした地面は固定されており動きませんが、動き回るようなノードをターゲットにすることも出来ます。例えばカメラに方向束縛を設定し、動き回るキャラクターをカメラが常に追いかけるような状況設定も可能です。influenceFactorはそのような場合に影響するプロパティであり、値が1.0から小さくなるのに従って、束縛されたノードがターゲットの移動方向へ向き直るまでにタイムラグが生じるようになります。

画面タップ時の処理

本アプリでは、配置された半透明の箱がタップされると、ランダムな種類・色の立体モデルを新たに生成します。また、この新たな立体モデルはタップされると、フェードアウトして消滅します。これらの処理はtouchesBeganメソッドを用いて実装しています。

override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
        let p = touches.anyObject()!.locationInView(self.scnView)
        if let hitResults = self.scnView?.hitTest(p, options: nil) {
            if hitResults.count > 0 {
                let result = hitResults.first?.node
                
                if result?.name? == "generator"{
                    let geom = generateRandomGeometry()
                    geom.firstMaterial?.diffuse.contents =
                        UIColor(red: colorValue(), green: colorValue(), blue: colorValue(), alpha: 1.0)
                    let geomNode = SCNNode(geometry: geom)
                    geomNode.position = SCNVector3(x: 0, y: 30, z: 0)
                    geomNode.name = "geometry"
                    geomNode.physicsBody = SCNPhysicsBody.dynamicBody()
                    geomNode.physicsBody?.physicsShape = SCNPhysicsShape(node: geomNode, options: nil)
                    self.scnView?.scene?.rootNode.addChildNode(geomNode)
                }else if result?.name? == "geometry"{
                    result!.runAction(SCNAction.sequence([
                        SCNAction.fadeOutWithDuration(0.5),
                        SCNAction.removeFromParentNode()
                        ]))
                }
            }
        }
    }
    
    func generateRandomGeometry() -> SCNGeometry{
        var kind = arc4random() % 6
        
        switch kind{
            case 0:
                let pyramid = SCNPyramid(width: 3.0, height: 3.0, length: 3.0)
                return pyramid
            case 1:
                let cylinder = SCNCylinder(radius: 1.5, height: 3.0)
                return cylinder
            case 2:
                let sphere = SCNSphere(radius: 1.5)
                return sphere
            case 3:
                let torous = SCNTorus(ringRadius: 1.5, pipeRadius: 0.5)
                return torous
            case 4:
                let chamferBox = SCNBox(width: 3.0, height: 3.0, length: 3.0, chamferRadius: 0.8)
                return chamferBox
            default:
                let box = SCNBox(width: 3.0, height: 3.0, length: 3.0, chamferRadius: 0.0)
                return box
        }
    }
    
    func colorValue() -> CGFloat{
        let rand = CGFloat(arc4random() % 10000)
        return rand / 20000.0 + 0.5
    }


立体モデルへのタップ検出

iPhone画面(すなわちビュー)へのタッチイベントが発生し、touchesBeganメソッドが呼び出されたら、まずはビュー上のタップ位置(座標)を取得します。そして、このタップ位置をビューのhitTestメソッドに引数として与えると、タップ位置と重なった全ての立体モデル(SCNGeometryの派生クラスのオブジェクト)のノード一覧を、配列として取得することが出来ます。

hitTestメソッドの動作は、与えられた位置から画面内の3D空間へ垂直に光線を飛ばし、その光線が貫く立体モデルを順次検出するようなイメージです。一覧配列には、検出順にノードが格納される(すなわち、視点に一番近い立体モデルノードが先頭要素になる)ようです。また、実際は一覧配列の型はSCNNodeそのものではなく、SCNHitTestResultという他の様々な情報も含んだクラスであり、このnodeプロパティからノードへの参照を取得します。

そして一覧配列の要素数が0ではなく、タップ位置に立体モデルが存在する場合は、一覧配列の先頭のノード(前述のように、視点に一番近い立体モデル)をresult変数に取得しています。あとはこのノードの名前(nameプロパティ)によって、処理の場合分けを行います。

半透明の箱タップ時の処理(新規立体モデルの追加生成)

半透明の箱(名前:”generator”)へのタップが検出されたら、generateRandomGeometryという自作メソッドを呼び出して、SCNGeometry系列の様々な汎用立体モデルオブジェクト(ピラミッド、円柱、球、トーラス、箱)をランダムに選んで生成しています。なお箱(SCNBox)については、chamferRadius引数の値を大きくすると、それに応じて角を丸めることが可能です。また、立体モデルの色(拡散光の色)は、0.5〜1.0の実数値をランダムに返すcolorValueという自作メソッドを用いて設定しています。

そして、この立体モデルをノードに取り付けて必要なプロパティを設定します。初期位置は半透明の箱と同じ位置とし、名前は”geometry”とします。そして物理ボディを設定するのですが、ここでは重力や衝突の影響を受けるようdynamicBodyメソッドを用います。

新規立体モデルタップ時の処理(フェードアウト消滅するアクション)

生成された新規立体モデル(名前:”geometry”)へのタップが検出されたら、そのノードにアクション(SCNActionクラス)をrunActionメソッドで与えて実行させます。

ここではfadeOutwithDuration、removeFromParentNodeアクションをシーケンス化して「ノードを指定秒数でフェードアウトさせた後に、親(この場合はシーン)から除去する」というアクションを与えています。これによりタップされた新規立体モデルは消滅します。

SceneKitにおけるアクションは、ほとんどSpriteKitと同じような感覚で扱うことが出来るようです。