LoginSignup
50
45

More than 5 years have passed since last update.

【Swift】SceneKitでクリスマスツリーを描いてみた

Last updated at Posted at 2015-12-15

12月25日、クリスマス。
家族が一堂に会し、ご馳走を食べたり、子供達はプレゼントをサンタさんから受け取ったりして、みんな笑顔に包まれる素敵なイベントですよね。
私は実家を離れており、家族と過ごせないため、家族と過ごすとされるクリスマス当日は必然的に一人で過ごすことになります。

クリスマスが近づくと、少しずつ、街中にクリスマスツリーが飾られていきますよね。素敵な装飾やイルミネーションで彩られていたりして、大きくなった今でもワクワクしてしまいます。
家で一人で過ごすとしても、少しはクリスマス気分を味わいたい・・。そんな思いから、以前から興味のあったSceneKitを使ってクリスマスツリーを描きたくなり、気が付けばつらつらとコードを書き進めていたのでした。

SceneKit

SceneKitはMac、iOSアプリケーションのフレームワークで、手軽に3Dオブジェクトの描画をしたり、物理演算を扱ったりすることができます。
この記事で作成するアプリケーションはiOS、言語はSwiftです。

SceneKitは手軽に使えるとは言うものの、3次元の物体の扱いはそれなりに複雑であり、かつ日本語の資料が十分にあるとは言えないため、なかなか手が出しづらい領域に感じました。この記事がSceneKitの練習の助けになれば幸いです(SceneKitについてあまり詳しく説明できてはいませんが・・)。

クリスマスツリーを描く

SwiftやXcodeの基本的操作にはあまり触れません。
完成したものは以下にあります。
https://github.com/tsugitta/XmasTree

プロジェクト作成

いつも通りプロジェクトを作成します。ただ、種類はGameを選択します。
スクリーンショット 2015-12-15 6.49.00.png

デフォルトで飛行機を描画するコードが記述してあるので、作られた方は是非一度シミュレータを立ち上げて動きを確認してみてください。
スワイプ・ピンチでカメラを操作することができるのですね。
なお、macでシミュレーションするとカクつきますし、トキメキに欠けます。是非実機でシミュレーションしてください。

すっきりさせる

とりあえず描画の処理はコントローラでなくビューに描きたいということで、SceneKitのビューであるSCNViewのサブクラスとしてXmasTreeViewを作成しました。(Command + N)

スクリーンショット 2015-12-15 7.32.06.png

スクリーンショット 2015-12-15 7.32.22.png

また、デフォルトでビューコントローラのクラス名がGameViewControllerになっているのですが、とりわけGame感のあるアプリを作るつもりは無いため、XmasTreeViewControllerに変更しました。また、中身も一掃してしまいました。

XmasTreeViewController
import UIKit

class XmasTreeViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
    }

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

}

ビューコントローラのクラス名を変えたので、StoryBoard上のビューコントローラのCustomClassも変更してあげました。

⇣こちらをクリックしてビューコントローラを選択してから
スクリーンショット 2015-12-15 7.41.22.png

⇣アイデンティティインスペクタから変更します。
スクリーンショット 2015-12-15 7.35.32.png

また、ViewControllerのviewプロパティのCustomClassをXmasTreeViewに変更しました。

⇣こちらをクリックしてビューを選択してから
スクリーンショット 2015-12-15 7.36.22.png

⇣アイデンティティインスペクタから変更します。
スクリーンショット 2015-12-15 7.42.33.png

すっきりしました!
以降はXmasTreeViewに描画処理を記述していきます。

ツリー以外を描く

残念ながら今シミュレータを立ち上げても、真っ暗闇です。とりあえず以下のようにして、世界と、光と、視界と、大地を与えました。

XmasTreeView.swift
import UIKit
import SceneKit

class XmasTreeView: SCNView {

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        scene = SCNScene()
        createSpotLight()
        createAmbientLight()
        createCameraNode()
        createGround()

        allowsCameraControl = true
        backgroundColor = UIColor.darkGrayColor()
    }

    func createSpotLight() {
        let lightNode = SCNNode() // ノードを作成
        lightNode.light = SCNLight() // ノードに光源を持たせる
        lightNode.light!.type = SCNLightTypeSpot // 光源をスポットライトタイプにする
        lightNode.position = SCNVector3(x: 0, y: 100, z: 0) // ノードの座標を設定(尚y軸が所謂垂直方向)
        lightNode.rotation = SCNVector4(1, 0, 0, -M_PI / 2.0) // ノードの方向をデフォルトの方向(y軸方向)から、(1, 0, 0)の方向に対し-π/2だけ回転させるようにする
        lightNode.name = "spotLight"
        scene!.rootNode.addChildNode(lightNode)
    }

    func createAmbientLight() {
        let ambientLightNode = SCNNode()
        ambientLightNode.light = SCNLight()
        ambientLightNode.light!.type = SCNLightTypeAmbient
        ambientLightNode.light!.color = UIColor.lightGrayColor()
        scene!.rootNode.addChildNode(ambientLightNode)
    }

    func createCameraNode() {
        let cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        cameraNode.position = SCNVector3(x: 0, y: 70, z: 40)
        cameraNode.rotation = SCNVector4(1, 0, 0, -0.8)
        scene!.rootNode.addChildNode(cameraNode)
    }

    func createGround() {
        let floorGround = SCNFloor() // 床のジオメトリの生成
        floorGround.firstMaterial?.diffuse.contents = UIColor(red: 0, green: 0.1, blue: 0, alpha: 1) // 床の色を緑に
        let floorNode = SCNNode() // 床のノードを生成
        floorNode.geometry = floorGround // 床のノードに床のジオメトリを紐付ける
        floorNode.position = SCNVector3Make(0, 0, 0) // 床のノードの座標をx,y,z座標系で指定(デフォルト値なので無くても同じ)
        scene!.rootNode.addChildNode(floorNode) // sceneのrootNodeに床ノードを追加し、画面に表示させる
    }

}

このビューで表示する世界を表すSCNSceneを生成して、ビューのsceneプロパティに埋めています。
sceneはrootNodeプロパティを持っています。ノードはモノに対応していて、表示させたいモノは、ノード(SCNNode)を生成して、sceneのrootNodeにaddChildNodeメソッドで追加していくことで画面上に表示させるのですね。
addChildNodeはSCNNodeのメソッドです。そのため、rootNodeでなくても任意のノードに対してノードを子に持たせることができます。それらにどういった違いが生まれるのかについては後述します。

また、ノードはそれ自体では目に見えるモノにはなっていません。ノードのgeometryプロパティが目に見えるモノに対応しているので、画面上に表示させるにはgeometryプロパティも埋めなくてはなりません。例えば上記のcreateGroundメソッドでは、SCNFloor()を代入していますが、これは文字通り床を表しています。

幹を描く

床が出来たので、次はツリーの幹を描くことに。
幹のノード・幹のジオメトリを作成して、それらを紐付け、ノードをsceneのrootNodeに追加すれば表示されるはずですね。

とりあえず、幹のノードのクラスとしてTreeNodeクラスをSCNNodeのサブクラスとして作成しました。

TreeNode.swift
import UIKit
import SceneKit

class TreeNode: SCNNode {

    let height: CGFloat = 45  // 幹の高さ
    let bottomRadius: CGFloat = 2  // 幹の底面の円の半径

    override init() {
        super.init()
        let halfHeight = Float(height / 2)
        geometry = SCNCone(topRadius: 0, bottomRadius: bottomRadius, height: height) // 円錐を適用する
        geometry!.firstMaterial?.diffuse.contents = UIColor(red: 50 / 255, green: 20 / 255, blue: 10 / 255, alpha: 1) // 色を変える
        pivot = SCNMatrix4MakeTranslation(0, -halfHeight, 0) // ピボットを円錐の中心から、円錐の底面の中心に移動させる
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}

SCNConeを使うことで、円錐・円柱を作ることができます。
addChildNodeが実行されると、親のノードの中心を原点として画面に追加されます(親の座標系が使われます)。これによって通常、親のノードの中心に子ノードの中心が重なるようにして画面に追加されます。今回、TreeNodeはsceneのrootNodeにaddChildNodeするのですが、その場合、(0, 0, 0)に円錐の中心が重なるように追加されてしまいます。
TreeNodeのpositionプロパティを調整して配置位置を変えても良いのですが、この中心点をpivot =..の記述により、円錐の底面の中心に移動させることで、この問題を回避しています。
こうすることで、円錐の底面の中心が、親ノードの中心すなわち(0, 0, 0)に重なるようになり、床の上に幹を置くことを実現しています。

XmasTreeViewのinit内部にでも以下の記述を追加すれば、幹が画面に表示されるようになります。

XmasTreeView.swift
let treeNode = TreeNode()
scene!.rootNode.addChildNode(treeNode)

スクリーンショット 2015-12-15 10.49.28.png

※夜仕様で暗くしています

これだけでも十分クリスマスツリー感はあるのですが、折角なので枝を描くことに。

枝を描く

枝のノード・枝のジオメトリを作成して、それらを紐付け、ノードを幹のノードに追加すれば良さそうです。
とりあえず、枝のノードのクラスとして、BranchNodeクラスをSCNNodeのサブクラスとして作成しました。

BranchNode.swift
import UIKit
import SceneKit

class BranchNode: SCNNode {

    let length: CGFloat // 枝の長さ
    let radius: CGFloat = 0.08 // 枝の半径

    init(length: CGFloat) {
        self.length = length
        super.init()
        geometry = SCNCone(topRadius: radius, bottomRadius: radius, height: length)
        geometry!.firstMaterial?.diffuse.contents = UIColor(red: 50 / 255, green: 20 / 255, blue: 10 / 255, alpha: 1)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}

枝は円柱で表現します。また、init時に長さを与えることとします。
では次に、枝を作るメソッドをTreeNodeに作ります。

枝は幹から無秩序に生えているってことはないですよね。
いくつか特徴があります。

  • 木のある特定の高さより上にしか生えない
  • 高い部分より低い部分の方が茂ってる感がある
  • 高い部分では幹の方向に、低い部分では枝や葉の重さの影響で水平方向に広がる

そこで以下のようにしました。

  • 木の高さに対する、幹に枝が生えている範囲の高さを定義する
  • それより上の範囲を、適当な数に等間隔で区切り、その区切りから枝を放射状に生やす(放射状に生やした枝の一塊を枝群と呼ぶことにする)
  • 一番低い所、一番高い所における枝群に含まれる枝の、長さ・本数・垂直方向からの角度をそれぞれ定義する
    • 枝の長さは高さが低い所の方が長い
    • 枝の本数は高さが低い所の方が多い(長さが長い分、そうしないと過疎る(枝分かれは考慮しない))
    • 枝の垂直方向からの角度は低い所の方が大きい
    • 一番低い所、一番高い所以外の枝群においては、その二つの値を適当に内分した値を採用する

以下のように実装してみました。

TreeNode.swift
import UIKit
import SceneKit

class TreeNode: SCNNode {

    let height: CGFloat = 45  // 幹の高さ
    let bottomRadius: CGFloat = 2  // 幹の底面の円の半径
    let ratioOfBranchZoneHeightToTreeHeight: Float = 3 / 4 // 木の高さに対する幹に枝が生えている範囲の高さ
    let numberOfBranchCircles = 12 // 放射状に広がる枝群の数
    let numberOfBranchesAtBottom = 35 // 最も低い位置にある枝群が含む枝の数
    let numberOfBranchesAtTop = 6 // 最も高い〃
    let branchLengthAtBottom: Float = 16 // 最も低い位置に生えている枝の長さ
    let branchLengthAtTop: Float = 5 // 最も高い〃
    let branchAngleAtBottom = Float(M_PI_2) // 最も低い位置に生えている枝のy軸からの角度
    let branchAngleAtTop = Float(M_PI / 8) // 最も高い〃

    override init() {
        super.init()
        let halfHeight = Float(height / 2)
        geometry = SCNCone(topRadius: 0, bottomRadius: bottomRadius, height: height) // 円錐を適用する
        geometry!.firstMaterial?.diffuse.contents = UIColor.branchBrownColor() // 色を変える
        pivot = SCNMatrix4MakeTranslation(0, -halfHeight, 0) // ピボットを円錐の中心から、円錐の底面の中心に移動させる

        createBranches()
    }

    private func createBranches() {
        let floatTreeHeight = Float(height) // メソッドの都合上Floatが欲しかった
        let branchCircleHeightAtBottom = floatTreeHeight * (1 - ratioOfBranchZoneHeightToTreeHeight) // 枝群の最も低い場所のy座標

        for i in 1...numberOfBranchCircles { // 各高さで放射状に枝を描く
            // 以下の4つは高さに合わせて、最高面と最低面の値を内分した値を取得している
            let branchCircleHeight = Float.getInteriorDivision(start: floatTreeHeight, end: branchCircleHeightAtBottom, position: i, maxPosition: numberOfBranchCircles)
            let numberOfBranches = Int.getInteriorDivision(start: numberOfBranchesAtTop, end: numberOfBranchesAtBottom, position: i, maxPosition: numberOfBranchCircles)
            let length = Float.getInteriorDivision(start: branchLengthAtTop, end: branchLengthAtBottom, position: i, maxPosition: numberOfBranchCircles)
            let angle = Float.getInteriorDivision(start: branchAngleAtTop, end: branchAngleAtBottom, position: i, maxPosition: numberOfBranchCircles)
            for j in 1...numberOfBranches { // 放射状に枝を描く
                // 枝のノードを作成
                let branchNode = BranchNode(length: CGFloat(length)) 

                // 枝を生やすべき方向に対応するZX平面上の偏角
                let angleZX = Double(j) * 2 * M_PI / Double(numberOfBranches)

                // 枝のピボットを枝の先端に移し、幹にくっつくようにする                
                branchNode.pivot = SCNMatrix4MakeTranslation(0, -length / 2, 0)

                // positionは親の座標系が使われるため(親のノード中心が原点)、幹の半分の高さをひくことで都合良くしている
                branchNode.position = SCNVector3(0, branchCircleHeight - floatTreeHeight / 2, 0)

                // 枝を生やすべき方向に対応するZX平面上のベクトルへ向けて、angleだけ回転させる
                // なお、3つの値を10%の範囲で揺らす(後述)
                branchNode.rotation = SCNVector4.makeRoughly(sin(angleZX), 0, cos(angleZX), Double(angle))


                addChildNode(branchNode)
            }
        }
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}
Int+Extension.swift
extension Int {

    static func getInteriorDivision(start start: Int, end: Int, position: Int, maxPosition: Int) -> Int {
        return start + (position - 1) * (end - start) / (maxPosition - 1)
    }

}

Double+Extension.swift
import UIKit

extension Double {

    static func getRandom(Min _Min : Double, Max _Max : Double) -> Double {
        return ( Double(arc4random_uniform(UINT32_MAX)) / Double(UINT32_MAX) ) * (_Max - _Min) + _Min
    }

    func shake(shakeRate shakeRate: Double) -> Double {
        return Double.getRandom(Min: self * (1 - shakeRate), Max: self * (1 + shakeRate))
    }

}
Float+Extension.swift
import UIKit

extension Float {

    static func getInteriorDivision(start start: Float, end: Float, position: Int, maxPosition: Int) -> Float {
        let floatPosition = Float(position)
        let floatMaxPosition = Float(maxPosition)
        return start + (floatPosition - 1) * (end - start) / (floatMaxPosition - 1)
    }

    static func getRandom(Min _Min : Float, Max _Max : Float) -> Float {
        return ( Float(arc4random_uniform(UINT32_MAX)) / Float(UINT32_MAX) ) * (_Max - _Min) + _Min
    }

    func shake(shakeRate shakeRate: Float) -> Float {
        return Float.getRandom(Min: self * (1 - shakeRate), Max: self * (1 + shakeRate))
    }

}
SCNVector4+Extension.swift
import UIKit
import SceneKit

extension SCNVector4 {

    static let shakeRate: Double = 0.1

    static func makeRoughly(x: Double, _  y: Double, _ z: Double, _ w: Double) -> SCNVector4 {
        let newX = x.shake(shakeRate: shakeRate)
        let newY = y.shake(shakeRate: shakeRate)
        let newZ = z.shake(shakeRate: shakeRate)
        let newW = w.shake(shakeRate: shakeRate)
        return SCNVector4(newX, newY, newZ, newW)
    }

}
UIColor+Extension.swift
import UIKit

extension UIColor {

    class func branchBrownColor() -> UIColor {
        return UIColor(red: 50 / 255, green: 20 / 255, blue: 10 / 255, alpha: 1)
    }

    class func leafGreenColor() -> UIColor {
        return UIColor(red: 0, green: 0.1, blue: 0, alpha: 1)
    }

}

先述の条件だけで描画すると、規則的であまりにも完全な枝ができてしまいます。

スクリーンショット 2015-12-15 10.06.14.png

不完全さこそ自然的であり、美しさの象徴ですよね。

枝を水平方向に折り曲げる際に各パラメータに±10%のランダムさを与えることで、十分では無いかもしれませんが、自然さを表現してみました。

スクリーンショット 2015-12-15 10.04.52.png

葉を描く


同様にして、LeafNodeクラスをSCNNodeのサブクラスとして作りました。


LeafNode.swift
import UIKit
import SceneKit

class LeafNode: SCNNode {

    let length: CGFloat
    let radius: CGFloat = 0.1

    init(length: CGFloat) {
        self.length = length
        super.init()
        geometry = SCNCone(topRadius: radius, bottomRadius: radius, height: length)
        geometry!.firstMaterial?.diffuse.contents = UIColor.leafGreenColor()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}


あとは枝に葉を生やすだけです。
葉もランダムに生えているものではありませんよね。
しかし、枝のように高さに依存した性質があるようにも思えません。

  • 全ての枝に生える
  • 枝に等間隔で生える
  • 枝の方向から少しだけ曲がった方向に放射状に生える

ということで以下のように実装してみました。

BranchNode.swift
import UIKit
import SceneKit

class BranchNode: SCNNode {

    let length: CGFloat // 枝の長さ
    let radius: CGFloat = 0.08 // 枝の半径
    let numberOfLeavesInLeafCircle = 8 // 円状に広がる葉群の数
    let intervalLeafCircle: Float = 2 // 葉群の間隔
    let leafLength: Float = 3
    let leafAngle = Float(M_PI_4) // 葉の枝方向からの角度

    init(length: CGFloat) {
        self.length = length
        super.init()
        geometry = SCNCone(topRadius: radius, bottomRadius: radius, height: length)
        geometry!.firstMaterial?.diffuse.contents = UIColor.branchBrownColor()

        createLeaves()
    }

    private func createLeaves() {
        let floatBranchLength = Float(length)
        let numberOfLeafCircles = Int(floatBranchLength / intervalLeafCircle)

        for i in 1...numberOfLeafCircles {
            let leafCircleHeight = floatBranchLength - Float(i - 1) * intervalLeafCircle

            for j in 1...numberOfLeavesInLeafCircle {
                let leafNode = LeafNode(length: CGFloat(leafLength))
                let angleZX = Double(j) * 2 * M_PI / Double(numberOfLeavesInLeafCircle)
                leafNode.pivot = SCNMatrix4MakeTranslation(0, -leafLength / 2, 0)
                leafNode.position = SCNVector3(0, leafCircleHeight - floatBranchLength / 2, 0)
                leafNode.rotation = SCNVector4.makeRoughly(sin(angleZX), 0, cos(angleZX), Double(leafAngle))

                addChildNode(leafNode)
            }
        }
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}

枝の記述とほぼ同様ですね。
一つ注意しなくてはならないのは、葉ノードのy軸方向はデフォルトでは枝が伸びている方向になっていることです。
枝を追加する時に、y軸方向を向いていた枝に対して、rotationプロパティを操作して水平方向へ向かせたわけですが、この操作は座標系の向きを変えることを意味していたということですね。先述の通り、子ノードの座標系は親ノードの座標系と一致します。上のコードでは、生成した葉ノードを、ZX平面方向にπ/4回転させていますが、このZX平面方向とは枝に垂直な向きを表しています。そのため、上記のコードにより、自然な向きの葉が記述できます。

スクリーンショット 2015-12-15 10.13.03.png

低い場所に生えている枝に対しては(あるいは長い枝に対しては)枝分かれさせないと変かな、と思っていましたが、こうして見ると枝の数を多くしておけば特に違和感は無さそうです。

おわりに

「ちょっとしたアプリ」の域を超えるとなると、なかなか難しいかもしれませんが、そうでない限りにおいては簡単にそれっぽいものが作れて楽しいですね。

本当は

  • イルミネーションで彩る
  • 枝・葉をゆらゆらさせる
  • ボール的な何かをぶつけて壊す

といったことにも取り組んでみたかったのですが、時間切れのため、いつか気が向いた時にでも挑戦してみようと思います。

それでは素敵なクリスマスを!

50
45
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
50
45