12月25日、クリスマス。
家族が一堂に会し、ご馳走を食べたり、子供達はプレゼントをサンタさんから受け取ったりして、みんな笑顔に包まれる素敵なイベントですよね。
私は実家を離れており、家族と過ごせないため、家族と過ごすとされるクリスマス当日は必然的に一人で過ごすことになります。
クリスマスが近づくと、少しずつ、街中にクリスマスツリーが飾られていきますよね。素敵な装飾やイルミネーションで彩られていたりして、大きくなった今でもワクワクしてしまいます。
家で一人で過ごすとしても、少しはクリスマス気分を味わいたい・・。そんな思いから、以前から興味のあったSceneKitを使ってクリスマスツリーを描きたくなり、気が付けばつらつらとコードを書き進めていたのでした。
SceneKit
SceneKitはMac、iOSアプリケーションのフレームワークで、手軽に3Dオブジェクトの描画をしたり、物理演算を扱ったりすることができます。
この記事で作成するアプリケーションはiOS、言語はSwiftです。
SceneKitは手軽に使えるとは言うものの、3次元の物体の扱いはそれなりに複雑であり、かつ日本語の資料が十分にあるとは言えないため、なかなか手が出しづらい領域に感じました。この記事がSceneKitの練習の助けになれば幸いです(SceneKitについてあまり詳しく説明できてはいませんが・・)。
クリスマスツリーを描く
SwiftやXcodeの基本的操作にはあまり触れません。
完成したものは以下にあります。
https://github.com/tsugitta/XmasTree
プロジェクト作成
いつも通りプロジェクトを作成します。ただ、種類はGameを選択します。
デフォルトで飛行機を描画するコードが記述してあるので、作られた方は是非一度シミュレータを立ち上げて動きを確認してみてください。
スワイプ・ピンチでカメラを操作することができるのですね。
なお、macでシミュレーションするとカクつきますし、トキメキに欠けます。是非実機でシミュレーションしてください。
すっきりさせる
とりあえず描画の処理はコントローラでなくビューに描きたいということで、SceneKitのビューであるSCNViewのサブクラスとしてXmasTreeViewを作成しました。(Command + N)
また、デフォルトでビューコントローラのクラス名がGameViewControllerになっているのですが、とりわけGame感のあるアプリを作るつもりは無いため、XmasTreeViewControllerに変更しました。また、中身も一掃してしまいました。
import UIKit
class XmasTreeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
}
ビューコントローラのクラス名を変えたので、StoryBoard上のビューコントローラのCustomClassも変更してあげました。
また、ViewControllerのviewプロパティのCustomClassをXmasTreeViewに変更しました。
すっきりしました!
以降はXmasTreeViewに描画処理を記述していきます。
ツリー以外を描く
残念ながら今シミュレータを立ち上げても、真っ暗闇です。とりあえず以下のようにして、世界と、光と、視界と、大地を与えました。
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のサブクラスとして作成しました。
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内部にでも以下の記述を追加すれば、幹が画面に表示されるようになります。
let treeNode = TreeNode()
scene!.rootNode.addChildNode(treeNode)
※夜仕様で暗くしています
これだけでも十分クリスマスツリー感はあるのですが、折角なので枝を描くことに。
枝を描く
枝のノード・枝のジオメトリを作成して、それらを紐付け、ノードを幹のノードに追加すれば良さそうです。
とりあえず、枝のノードのクラスとして、BranchNodeクラスをSCNNodeのサブクラスとして作成しました。
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に作ります。
枝は幹から無秩序に生えているってことはないですよね。
いくつか特徴があります。
- 木のある特定の高さより上にしか生えない
- 高い部分より低い部分の方が茂ってる感がある
- 高い部分では幹の方向に、低い部分では枝や葉の重さの影響で水平方向に広がる
そこで以下のようにしました。
- 木の高さに対する、幹に枝が生えている範囲の高さを定義する
- それより上の範囲を、適当な数に等間隔で区切り、その区切りから枝を放射状に生やす(放射状に生やした枝の一塊を枝群と呼ぶことにする)
- 一番低い所、一番高い所における枝群に含まれる枝の、長さ・本数・垂直方向からの角度をそれぞれ定義する
- 枝の長さは高さが低い所の方が長い
- 枝の本数は高さが低い所の方が多い(長さが長い分、そうしないと過疎る(枝分かれは考慮しない))
- 枝の垂直方向からの角度は低い所の方が大きい
- 一番低い所、一番高い所以外の枝群においては、その二つの値を適当に内分した値を採用する
以下のように実装してみました。
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")
}
}
extension Int {
static func getInteriorDivision(start start: Int, end: Int, position: Int, maxPosition: Int) -> Int {
return start + (position - 1) * (end - start) / (maxPosition - 1)
}
}
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))
}
}
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))
}
}
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)
}
}
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)
}
}
先述の条件だけで描画すると、規則的であまりにも完全な枝ができてしまいます。
不完全さこそ自然的であり、美しさの象徴ですよね。
枝を水平方向に折り曲げる際に各パラメータに±10%のランダムさを与えることで、十分では無いかもしれませんが、自然さを表現してみました。
葉を描く
同様にして、LeafNodeクラスをSCNNodeのサブクラスとして作りました。
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")
}
}
あとは枝に葉を生やすだけです。
葉もランダムに生えているものではありませんよね。
しかし、枝のように高さに依存した性質があるようにも思えません。
- 全ての枝に生える
- 枝に等間隔で生える
- 枝の方向から少しだけ曲がった方向に放射状に生える
ということで以下のように実装してみました。
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平面方向とは枝に垂直な向きを表しています。そのため、上記のコードにより、自然な向きの葉が記述できます。
低い場所に生えている枝に対しては(あるいは長い枝に対しては)枝分かれさせないと変かな、と思っていましたが、こうして見ると枝の数を多くしておけば特に違和感は無さそうです。
おわりに
「ちょっとしたアプリ」の域を超えるとなると、なかなか難しいかもしれませんが、そうでない限りにおいては簡単にそれっぽいものが作れて楽しいですね。
本当は
- イルミネーションで彩る
- 枝・葉をゆらゆらさせる
- ボール的な何かをぶつけて壊す
といったことにも取り組んでみたかったのですが、時間切れのため、いつか気が向いた時にでも挑戦してみようと思います。
それでは素敵なクリスマスを!