Objective-C
iOS
SceneKit
Swift

[Swift] iOS8から対応したSceneKitを触ってみたメモ

More than 3 years have passed since last update.

今回のWWDCで、iOS8からSceneKitに対応するというアナウンスがありました。

SceneKitは、一言で言えば「3Dゲームを手軽に作れるフレームワーク」でしょうか。

JavaScriptで言うところの「Three.js」に該当するようなイメージです。

(物理演算とかもカバーしているので、よりゲーム向けではあると思いますが)

Appleのドキュメントはこちら


シーンを作る

簡単なコード例は以下になります。

// create a new scene

let scene = SCNScene()

// create and add a camera t the scene
let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
scene.rootNode.addChildNode(cameraNode)

// place the camera
cameraNode.position = SCNVector3(x:0, y:0 x:5)

// create and add a light to the scene
let lightNode = SCNNode()
lightNode.light = SCNLight()
lightNode.light.type = SCNLightTypeOmni
lightNode.position = SCNVector3(x:0, y:10, z:10)
scene.rootNode.addChildNode(lightNode)

// create and add an ambient light to the scene
let ambientLightNode = SCNNode()
ambientLightNode.light = SCNLight()
ambientLightNode.light.type = SCNLightTypeAmbient
ambientLightNode.light.color = UIColor.darkGrayColor()
scene.rootNode.addChildNode(ambientLightNode)

// create and add a 3D box to the scene
let boxNode = SCNNode()
boxNode.geometry = SCNBox(width:1, height:1, length:1, chamferRadius:0.02)
scene.rootNode.addChildNode(boxNode)

// create and configure a material
let material = SCNMaterial()
material.diffuse.contents = UIImage(named:"texture")
material.specular.contents = UIColor.grayColor()
material.locksAmbientWithDiffuse = true

// set the material to the 3D object geometry
boxNode.geometry.firstMaterial = material

// animate the 3D object
let animation:CABasicAnimation = CABasicAnimation(keyPath:"rotation")
animation.toValue = NSValue(SCNVecto4:SCNVector4(x:1, y:1, z:0, w:Float(M_PI) * 2))
animation.duration = 5
animation.repeatCount = MAXFLOAT // repeat forever
boxNode.addAnimation(animation, forKey:nil)

// retrieve the SCNView
let scnView = self.view as SCNView

// set the scene to the view
scnView.scene = scene

// allows the user to manipulate the camera
scnView.allowsCameraControl = true

// show statistics such as fps and timing information
scnView.showStatistics = true

// configure the view
scnView.backgroundColor = UIColor.blackColor()

// add a tap gesture recognizer
let tapGesture = UITapGestureRecognizer(target:self, action:"handleTap:")
let gestureRecognizers = NSMutableArray()
gestureRecognizers.addObject(tapGesture)
gestureRecognizers.addObjectsFromArray(scnView.gestureRecognizers)
scnView.gestureRecognizers = gestureRecognizers

見てもらうと分かりますが、3D関連を扱うフレームワークやライブラリなどでよく見る形になってます。

基本単位はSCNNodeで、そのノードに役割を与え、それをシーンに追加していく、という感じです。



3Dオブジェクトを作る

基本的な形状のオブジェクトはSceneKit側ですでに用意されています。

用意されている基本的な形は以下のものがあります。


  • SCNBox

  • SCNCapsule

  • SCNCone

  • SCNCylinder

  • SCNFloor

  • SCNPlane

  • SCNPyramid

  • SCNShape

  • SCNSphere

  • SCNText

  • SCNTorus

  • SCNTube

ごく簡単な生成例は以下になります。


3d-object.swift

// SceneKit内のオブジェクトの単位は`SCNNode`

let aBoxNode = SCNNode()

// キューブ型の形状(Geometry)を生成
let aBoxGeometry = SCNBox(width: 0.1, height: 0.1, length: 0.1, chamferRadius: 0.02)

// 上記ノードの形状(Geometry)をキューブにする
aBoxNode.geometry = aBoxGeometry




テクスチャを貼る

テクスチャは非常に簡単に貼ることができます。


texture-sample.swift

// 

let material = SCNMaterial()
material.diffuse.contents = UIImage(named: "texture")
aBoxGeometry.firstMaterial = material

material.diffuse.contentsUIImageを指定するだけでテクスチャを貼ることが出来ます。

ちなみに、UIImageではなくUIColorを渡せば単色で塗ることもできるので非常に手軽です。



Skyboxを作る

Skyboxはいわゆる背景を描く方法です。

SceneKitはSkyboxも、とても手軽に作成できるようになっています。


skybox.swift

// sceneを作る

let scene = SCNScene()

// 背景(SCNMaterialPropety)に設定
scene.background.contents = [
UIImage(named: "right"),
UIImage(named: "left"),
UIImage(named: "top"),
UIImage(named: "bottom"),
UIImage(named: "front"),
UIImage(named: "back")
]


sceneインスタンスのbackgroundプロパティはSCNMaterialPropertyで、readonlyです。

そのcontentsプロパティに、配列で6面のテクスチャ用画像を渡すと、あとは自動的にSkyboxとしてレンダリングしてくれます。

面の順番は、AppleのDocumentから引用すると以下のようになります。

skybox.png



Colladaファイルから3Dモデルを読み込む

Colladaファイルを読み込むにはいくつかの方法があるようです。

一番シンプルなのはこちらの記事で紹介されているものでしょうか。


モデルを読み込む

daeファイルにはシーンが含まれます。

つまり、カメラやライト、そして3Dモデルです。

さらにはアニメーションも個別に格納されています。

(※daeファイル自体はただのXMLファイルなので、テキストエディタとかで見ると構造が分かります)

ここが大事です→ 「3Dモデルデータとアニメーションは個別に取り出す必要があります。」

3Dモデルを取り出すコード例を示すと以下のようになります。


dae-sample.swift

// daeファイルのURLを取得

var url: NSURL = NSBundle.mainBundle().URLForResource("Hoge", withExtension: "dae")

// SceneSourceを生成
var sceneSource: SCNSceneSource = SCNSceneSource(URL: url, options: nil)

// IDを指定してモデルデータを取得(※1)
var node: SCNNode = sceneSource.entryWithIdentifier("aModelIdentifier", withClass: SCNNode.self) as SCNNode
scene.rootNode.addChildNode(node)


※1 ... dae内のモデルのエントリーする名前を指定する。(ここを間違うとエラーで落ちる)



Xcode上で確認する

daeファイルはXcode上で確認、編集することもできます。

sceneSource.identifiersOfEntriesWithClassで一覧を出力することが出来ますが、エディタ上でもそうした名前やオブジェクトの構造などを確認することができるようになっています。

さらに、簡易的なものであればモデルデータの編集も可能になっています。


check-entry.swift

// IDの一覧を出力

println(sceneSource.identifiersOfEntriesWithClass(SCNNode.self))



アニメーションを読み込む

さて、上記で「 モデルとアニメーションは別々に保存されている 」と書きました。

なので、モデルデータを読み込んだだけでは当然、アニメーションしません。

アニメーションデータを取り出し、さらにモデルにそれをアタッチすることで初めてアニメーションが可能になります。

コード例は以下です。(sceneSourceはモデルを取り出したものと同じです)


dae-animation-sample.swift

// Scene sourceからアニメーションデータを取り出す

let animation: CAAnimation = sceneSource.entryWithIdentifier("anAnimationIdentifier", withClass: CAAnimation.self) as CAAnimation

// 取り出したアニメーションデータを、モデルデータにアタッチ
aModel.addAnimation(animation, forKey: "someKeyName")




物理エンジンを使う

SceneKitには物理演算を行ってくれる機能も実装されています。

が、おそらくバグだと思いますが現時点では利用に問題があるようです。

Xcode6 beta3にてこの問題は解消されたようです。

scene.physicsWorldプロパティにアクセスすると「未定義」と言われてビルドエラーになります。

TwitterでつぶやいたらStack overflowの記事を教えてもらいました。

どうやらたんなるバグのようです。

なので、Objective-Cでブリッジ用のコードを書いて、そこ経由で設定するとうまくいくようです。(詳細は該当記事のAnswerを見てください)

ちなみに単純な形状に物理演算をさせるには以下のようにするだけで手軽に実現できました。


physics.swift

// まずノードを生成します

let aBoxNode = SCNNode()
let aBoxGeo = SCNBox(width: 0.1, height: 0.1, length: 0.1, chamferRadius: 0.02)
aBoxNode.geometry = aBoxGeo
scene.rootNode.addChildNode(aBoxNode)

// 物理演算世界での形状を定義
let aBoxShape = SCNPhysicsShape(geometry: aBoxGeo, options: nil)
aBoxNode.physicsBody = SCNPhysicsBody(type: SCNPhysicsBodyType.Dynamic, shape: aBoxShape)


レンダリング用のBoxオブジェクトを生成する部分は共通です。

加えて、同様の形状を流用して物理演算世界の形状を定義し、それをノードに接続する、というイメージです。

aBoxNode.physicsBody = ...の部分ですね。

これに設定をするだけで、あとは勝手に画面下に向かって落ちていきます。

だいぶ手軽ですね。



モデルデータから物理演算用シェイプを作る

上記の例はコード上でGeometryを生成してそれを流用していました。

しかし、実際のゲームではモデリングツールから出力されたモデルデータを利用するケースがほとんどでしょう。

その場合にも手軽に利用することが出来ます。

その場合はコードを以下のように書き換えます。


model-sample.swift

let aModelShape = SCNPhysicsShape(node: aModelNode, options: nil)


また、モデルデータのサイズなどを調整して表示するケースもあると思います。

しかし表示上のサイズを変更しても、物理演算対象のサイズは一緒に変化しません。

その場合はオプションでサイズの指定をしてやることで解決できます。


model-physics-sample.swift

let aModelShape = SCNPhysicsShape(node: aModelNode, options: [

SCNPhysicsShapeScaleKey: NSValue(SCNVector3: SCNVector3(x: 0.1, y: 0.1, z: 0.1))
])

SCNPhysicsShapeScaleKeyをキーに、NSValueを渡してやることでスケールを調整することができます。



物理演算後の位置を知る

SCNNodeにはpositionプロパティがありますが、ゲーム実行後、物理演算の影響を受けて移動しても、このpositionプロパティは書き換わりません。

物理演算後の位置を知るには、SCNNode.presentationNode()メソッドを使って、実際に表示されているノードへアクセスし、その位置を取得する必要があります。


presentationNode.swift

// presentationNodeを通して、表示されている実際の位置を得る

println(aNode.presentationNode().position.y)



Game Loopを実装する

CADisplayLinkクラスを利用するのがよさそうです。以下の記事を参考にしました。

Building a Game Loop in iPhone and iPad Game Development



サンプルコード

CADisplayLinkは、いわゆるリフレッシュレートに応じて呼び出されます。

まず、画面更新用のupdateメソッドを実装します。


update.swift

func update(displayLink: CADisplayLink) {

// Game loop logic here.
}

そして、更新用のメソッドをループに追加します。


game-loop.swift

var displayLink: CADisplayLink = CADisplayLink(target: self, selector: "update:")

displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)


その他、SCNSceneRendererDelegateを使う

ゲームループの実装として他に、SCNSceneRendererDelegateを使うの手もありそうです。

が、こちらはレンダリングされている間に呼び出されるものなので、例えば画面内のオブジェクトがupdateする必要がなくなったら呼ばれなくなる、という問題があります。

AppleのDocument(SCNSceneRendererDelegate Protocol Reference)を見ると、いくつかのdelegate methodがあり、さらにそれらがどういうふうに呼ばれるかが図解されています。



定義されているデリゲートメソッド

定義されているデリゲートメソッドは以下のようです。


  • renderer:updateAtTime:

  • renderer:didApplyAnimationsAtTime:

  • renderer:didSimulatePhysicsAtTime:

  • renderer:didRenderScene:atTime:

  • renderer:willRenderScene:atTime:

図を引用させてもらうと、

SCNSceneRendererDelegate_2x.png



音を再生する

こちらの記事を参考にさせてもらいました。

まず最初に、Swiftで書いたコードを載せておきます。


sound.swift

// AudioToolboxを利用するのでimportしておきます

import AudioToolbox

func playSound() {
var soundID: SystemSoundID = 0
var soundURL: NSURL = NSBundle.mainBundle().URLForResource("aSound", withExtention:"wav")
AudioServicesCreateSystemSoundID(soundURL as CFURLRef, &soundID)
AudioServicesPlaySystemSound(soundID)
}


※ちなみに、上記サンプルは&soundIDとして参照渡しをしていますが、ポインタ周り用の型も用意されているようです。(例えば今回の場合はvar soundID: CMutablePointer<SystemSoundID>と宣言できる)

型についてはAppleのDocumentに一覧が載っているのでそちらを参照ください。



その他、ハマったメモ


SCNPhysicsShapeのオプションを指定するとBAD_ACCESS_ERROR

SCNPhysicsShapeは物理演算対象の当たり判定(形:Shape)を定義するクラスです。

SCNNodeSCNGeometryを渡して初期化します。

第二引数にいくつかのオプションが指定できるようになっていますが、ここにオプションを指定すると、Simulator上ではなぜかBAD_ACCESS_ERRORが出てランタイムエラーになりました。

が、 実機では問題なく動きました。


タップの反応が遅い

今回、SceneKitを学ぶために簡単なゲームを作ってみました。

そのゲームの性質上、タップには敏感に反応してもらいたかったのですが、連続でタップすると各タップの間に検知できない瞬間があり、そのせいでゲーム性が損なわれていました。

これは実は、冒頭のサンプルでも書かれているscnView.allowsCameraControl = trueが原因でした。

allowsCameraControlを有効にすると、なにもしなくても、画面をドラッグしたりダブルタップで原点にカメラを戻したり、といったことができるようになります。

とても重宝する機能だと思いますが、ダブルタップを検知したりするせいで、タップに対する処理が少しだけ遅くなります。

allowsCameraControlはあくまでデバッグ用に使い、実際の場合はオフにしておいたほうがいいでしょう。



LobiRec SDK

ちなみに自分のところで作っているLobi Rec SDK(ゲームのプレイ動画を録画できるSDK)を入れてみたんですが、SceneKit on Swiftでも無事に動きました。

ちょっとだけ導入方法が変わりますが、上記で書いた通り、SceneKitにはレンダリングする前とレンダリングした後のタイミングをDelegateメソッドで取得できるので、それに合わせてキャプチャすることで対応が可能でした。(ステマ)