工場裏のアーカイブス

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

Core MLを用いた、iPhone での機械学習あれこれ(1)

※(2020/09/21追記)本記事執筆時点ではPyTorchのモデルを直接Core MLモデルに変換する方法がありませんでしたが、現在はCore ML Toolsのバージョン4.0以降を用いることで可能となっています。それに関する記事も作成しましたので、併せてご覧ください。chemicalfactory.hatenablog.com


最近はPython + PyTorchによる機械学習(特にディープラーニング)に興味を持っており、参考書を片手に色々と遊びながら勉強しています。色々と面白いことが出来そうなので、将来的にはこれを応用したiPhoneアプリを作成してみたいと思ったのですが、PyTorchで実装したモデルをiPhoneアプリに組み込む方法があるのかが気掛かりでした。

調べてみたところ、iPhoneではiOS 11以降で利用可能な「Core ML」という機械学習用のライブラリや、「Vision」という画像認識用のライブラリが公開されているようです。2020年1月現在では、PyTorchのモデルを直接Core MLのモデルに変換する方法は無さそうですが、一度ONNXモデル (Open Neural Network Exchange、Microsoft社とFacebook社による標準フォーマット)を噛ませることで変換可能となるようです。

また、Core ML用のオープンソースのモデルがApple社によって公開されており、更に「Create ML」というCore ML用の画像分類モデルなどを容易に作成可能なツールも用意されているようです(Xcode 11であれば、標準で付属しています)。これらを用いれば自力で機械学習のコードをほとんど書くことなしに、例えばiPhoneのカメラに写った動物や物体の種類が何であるのか認識するようなアプリを作ることも出来てしまうようです。

そこで本記事のシリーズでは、PyTorchモデルのCore MLモデルへの変換や、Create MLの利用など、Core ML周りについて色々調べて試して分かったことを、雑多にですがメモして行きます(まだまだ勉強不足で、分かっていないことも多いですが)。
 

PyTorchモデルを、Core MLモデルに変換して利用

PyTorchモデルの準備(手書き数字分類を例として)

まずはベタな題材として、PyTorchでMNISTの手書き数字を分類するためのモデルを用意しました。こちらをCore MLモデルに変換して、iPhoneアプリで利用出来るようにすることが目標です。

import torch
import torchvision
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torchvision.transforms as transforms

batch_size = 10

trainset = torchvision.datasets.MNIST(root='./data', train=True,
                                        download=True, transform=transforms.ToTensor())
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size,
                                            shuffle=True, num_workers=0)

testset = torchvision.datasets.MNIST(root='./data', train=False, 
                                        download=True, transform=transforms.ToTensor())
testloader = torch.utils.data.DataLoader(testset, batch_size=batch_size,
                                            shuffle=False, num_workers=0)

class MNIST_Conv_MN(nn.Module):
    def __init__(self):
        super(MNIST_Conv_MN, self).__init__()
        self.conv1 = nn.Conv2d(1, 8, 3) 
        self.pooling = nn.MaxPool2d(2, 2) 
        self.fc1 = nn.Linear(13 * 13 * 8, 10)

    def forward(self, x):
        x = self.conv1(x)
        x = F.relu(x)
        x = self.pooling(x)
        x = x.view(-1, 13 * 13 * 8)
        x = self.fc1(x)
        return x
    
model=MNIST_Conv_MN()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters())

num_epochs = 10

for epoch in range(num_epochs):
    
    train_loss = 0
    train_acc = 0
    val_loss = 0
    val_acc = 0
    
    model.train()
    for i, (inputs, labels) in enumerate(trainloader):
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        
        train_loss += loss.item()
        train_acc += (outputs.max(1)[1] == labels).sum().item()
        loss.backward()
        optimizer.step()
    avg_train_loss = train_loss / len(trainloader.dataset)
    avg_train_acc = train_acc / len(trainloader.dataset)

    model.eval()
    with torch.no_grad():
        for inputs, labels in testloader:
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            val_loss += loss.item()
            val_acc += (outputs.max(1)[1] == labels).sum().item()
        avg_val_loss = val_loss / len(testloader.dataset)
        avg_val_acc = val_acc / len(testloader.dataset)
        
        print("Epoch [{}/{}], Loss: {loss:.4f}, val_loss: {val_loss:.4f}, val_acc: {val_acc:.4f}"\
              .format(epoch+1, num_epochs, i+1, loss=avg_train_loss, val_loss=avg_val_loss, val_acc=avg_val_acc))
        
torch.save(model.state_dict(), "MNIST.pth")

※モデルの構成は「Chainerで作るコンテンツ自動生成AIプログラミング入門」という書籍のサンプルを参考にしています。また学習ループの書き方は「PyTorchニューラルネットワーク実装ハンドブック」という書籍を参考にしています。

※MNISTのデータセットはtorchvision.datasetsで作成しています。MNISTのピクセル値は本来0〜255ですが、transforms.ToTensor()でテンソルに変換すると0.0〜1.0に正規化される仕様のようです。

こちらの実行結果の例を示します。比較的シンプルなモデルですが、概ね97〜98%程度の認識精度は出せるようです。ここではモデルを”MNIST.pth”という名前で保存しておきます。

Epoch [1/10], Loss: 0.0241, val_loss: 0.0119, val_acc: 0.9669
Epoch [2/10], Loss: 0.0108, val_loss: 0.0093, val_acc: 0.9708
Epoch [3/10], Loss: 0.0081, val_loss: 0.0082, val_acc: 0.9739
Epoch [4/10], Loss: 0.0067, val_loss: 0.0068, val_acc: 0.9781
Epoch [5/10], Loss: 0.0060, val_loss: 0.0069, val_acc: 0.9781
Epoch [6/10], Loss: 0.0054, val_loss: 0.0063, val_acc: 0.9792
Epoch [7/10], Loss: 0.0049, val_loss: 0.0069, val_acc: 0.9792
Epoch [8/10], Loss: 0.0045, val_loss: 0.0066, val_acc: 0.9801
Epoch [9/10], Loss: 0.0043, val_loss: 0.0062, val_acc: 0.9819
Epoch [10/10], Loss: 0.0039, val_loss: 0.0065, val_acc: 0.9810

 

ONNXを噛ませた、PyTorch→Core MLのモデル変換

ここから先の内容は、以下の外部サイトの内容やコードを大いに参考にさせて頂いています。まずはこちらを参考にONNX、onnx-coremlのインストールを済ませておきます。
qiita.com

そして先ほど保存した”MNIST.pth”を用意して、以下のコードを実行すれば、PyTorchの手書き数字認識用モデルがONNXモデルを経由してCore MLモデルに変換されます(ここでは”MNIST.mlmodel”という名前で保存)。

import torch
import torchvision
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torchvision.transforms as transforms

import onnx
from onnx_coreml import convert

class MNIST_Conv_MN(nn.Module):
    def __init__(self):
        super(MNIST_Conv_MN, self).__init__()
        self.conv1 = nn.Conv2d(1, 8, 3) 
        self.pooling = nn.MaxPool2d(2, 2) 
        self.fc1 = nn.Linear(13 * 13 * 8, 10)

    def forward(self, x):
        x = self.conv1(x)
        x = F.relu(x)
        x = self.pooling(x)
        x = x.view(-1, 13 * 13 * 8)
        x = self.fc1(x)
        return x
    
model = MNIST_Conv_MN()
model.load_state_dict(torch.load('MNIST.pth'))

dummy_input = torch.FloatTensor(1, 1, 28, 28)
torch.onnx.export(model, dummy_input, 'MNIST.proto', verbose=True)

model_onnx = onnx.load('MNIST.proto')
onnx.checker.check_model(model_onnx)

coreml_model = convert(
    model_onnx,
    mode='classifier',
    image_input_names=['input'],
    image_output_names=['output'],
    class_labels=[i for i in range(10)]
)

coreml_model.save('MNIST.mlmodel')

 
コードの構成は参考サイトのものを、ほとんどそのまま使用させて頂いていますが、ONNXモデル変換時のダミー入力(dummy_input)は、本モデルの入力に合わせてtorch.FloatTensor(1, 1, 28, 28)としました。また、本モデルの出力数は10(数字0〜9の分類結果)となりますので、Core MLモデル変換時のclass_labelsはそのまま同様に数字で埋めています。

変換が上手く行けば、以下のようにCore MLモデルのファイルが生成されます。
f:id:fleron:20200119232545p:plain
 

Core MLモデルの内容確認

Xcode(本記事執筆時点:Version 11.3)でSingle View appのプロジェクトを新規作成して、先ほど作成したCore MLモデル(”MNIST.mlmodel”)をドラッグ&ドロップでプロジェクトに追加してみます。
f:id:fleron:20200119233409p:plain
 
Xcode上で追加したモデルを選択すると、以下のようにモデルの詳細が表示されます。
f:id:fleron:20200119233421p:plain

この中でも「Prediction」の項が重要であり、こちらの「Name」が後ほどコード上でモデルに入力を渡したり、出力を受け取ったりするための変数名となります。そして入力の型を見ると「MultiArray(Float32 1 x 28 x 28)」となっています。これは、モデルへの入力データを「MLMultiArray」という形式で与える必要があることを意味するようです。こちらについては後述します。
 

Core MLモデルを使ってみる。

またまたベタな題材かもしれませんが、作成したCore MLモデルを用いて、下図のように黒色のキャンバス内(280 x 280のUIImageView)に白色で数字を手書きして「予測」ボタンを押すと、書かれた数字が何か予測するアプリを作成したいとします。

f:id:fleron:20200126193731p:plain

このような場合、最初にキャンバス内の画像(UIImage)を28 x 28にリサイズするまでは良いのですが、先述のようにモデルの入力データはMLMultiArrayなので、そのまま入力することは出来ず形式変換する必要があります。

そもそもMLMultiArrayとは何なのかという話ですが、要は特定の型(Floatなど)を有する多次元配列のようです…と言っても実際のメモリ上では一次元配列で表現されるようです。例えばRGBの3チャンネルを有する画像の場合は、以下の模式図のように各チャンネルを順番に一次元配列へと変換するような形でMLMultiArrayに格納すれば良いようです。

f:id:fleron:20200126204156p:plain

…と言うだけなら簡単ですが、MLMultiArrayでは現在のところ、numpyにおける配列操作やPyTorchにおけるTensor操作のように便利な機構はなく、UIImageをピクセルデータの配列に変換して、MLMultiArrayに適切なデータの並びで格納するような処理なども自前で用意する必要がありそうです(以下の外部サイトを参考にさせて頂きました)。中々に敷居が高いです。

KerasとCoreMLでMNIST手書き文字認識
MLMultiArrayとポインタ
MLMultiArrayを見据えたUnsafeMutableRawPointerの取り回し
 
 
ここではCore MLモデル(”MNIST.mlmodel”)を追加したプロジェクトからスタートして、最初の参考サイトにおけるCore ML周りのコードをほとんどそのまま使わせて頂き、手書き数字を予測するアプリを作成してみました。コードを以下に示します。
(ただし、UIImageViewに手書きで数字を書けるようにする処理は主眼ではなく、長くなってしまいますので省略しました。以下の外部サイトなどが参考になると思います)
iOS標準機能の良いお絵描きアプリを目指して・・・

import UIKit
import CoreML

class ViewController: UIViewController {

    //"canvas"は数字を手書きするための、280 x 280のUIImageView。
    //"predictLabel"は予測結果を表示するためのラベル。
    //いずれもstoryboard上で準備済みとする。
    @IBOutlet weak var canvas: UIImageView!
    @IBOutlet weak var predictLabel: UILabel!
    
    //-------------------------------------------------------------------- 
    // 本来はここに、"canvas"に手書きで数字を書けるようにするための
    // コードが存在するが、長くなり過ぎるため省略
    //--------------------------------------------------------------------  
    
    //“predictButton"は「予測」ボタンであり、storyboardで用意済みであるとする。
    @IBAction func predictButtonPushed(_ sender: Any) {
        if let inputImage = self.canvas.image{
            coreMLRequest(image: inputImage)
        }
    }
    
    func coreMLRequest(image: UIImage){
        let imgSize: Int = 28
        let imageShape: CGSize = CGSize(width: imgSize, height: imgSize)
    
        //(280, 280)の画像サイズを(28, 28)にリサイズして、更にpixelBufferに変換。
        let imagePixel = image.resize(to: imageShape).getPixelBuffer()
        
        //(1, 28, 28)のMLMultiArrayを生成し、pixelBufferに変換した画像を格納する。
        let mlarray = try! MLMultiArray(shape: [1, NSNumber(value: imgSize), NSNumber(value: imgSize)], dataType: MLMultiArrayDataType.float32 )
        for i in 0..<imgSize*imgSize {
            mlarray[i] = imagePixel[i] as NSNumber
        }
        
        //プロジェクトに追加したCore MLのモデル("MNIST.mlmodel")をロード
        let MNISTModel = MNIST()
        
        //画像データを格納したMLMultiArrayをモデルに入力し、書かれている数字を予測する。
        //入力用の変数名(input_1)、出力用の変数名(_10)は"MNIST.mlmodel"の詳細で確認した通り。
        if let prediction = try? MNISTModel.prediction(input_1: mlarray) {
            if let first = (prediction._10.sorted{ $0.value > $1.value}).first{
                self.predictLabel.text = "予測:\(Int(first.key))"
            }
        }
    }
}

extension UIImage {
    //画像を指定のサイズにリサイズする(参考サイトのコードそのまま)。
    func resize(to newSize: CGSize) -> UIImage {
        UIGraphicsBeginImageContextWithOptions(CGSize(width: newSize.width, height: newSize.height), true, 1.0)
        self.draw(in: CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height))
        let resizedImage = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext()
        return resizedImage
    }

    //画像をpixelBufferに変換。ただし参考サイトのコードを一部変更して、
    //ピクセル値を二値化はせずに0.0〜1.0の値として扱う(元々のモデルに合わせる)
    func getPixelBuffer() -> [Float]
    {
        guard let cgImage = self.cgImage else {
            return []
        }
        let bytesPerRow = cgImage.bytesPerRow
        let width = cgImage.width
        let height = cgImage.height
        let bytesPerPixel = 4
        let pixelData = cgImage.dataProvider!.data! as Data
        var buf : [Float] = []

        for j in 0..<height {
            for i in 0..<width {
                let pixelInfo = bytesPerRow * j + i * bytesPerPixel
                let r = CGFloat(pixelData[pixelInfo])
                let g = CGFloat(pixelData[pixelInfo+1])
                let b = CGFloat(pixelData[pixelInfo+2])

                let v: Float = floor(Float(r + g + b)/3.0)/255.0

                buf.append(v)
            }
        }
        return buf
    }
}

 
“MNIST.mlmodel”のモデル詳細を確認した際に、「Prediction」項で確認した入出力用の変数をそのまま用います(変数名を変えられたら良いのですが、現在のところ方法が分かっていません)。

一例として「5」の数字を描いた場合における、本モデルの出力(prediction._10)の中身を表示すると以下のようになります。画像が0〜9の各数字である確率がdictionaryとして出力されますので、確率が最大となる数字が予測結果となります。今回はきちんと「5」が確率最大となっております(実のところ、本アプリの精度は体感的に高いとは言えず、特に「6」「8」「9」などの数字は誤って予測されやすいですが…)。

[6: -7.831572532653809, 3: 0.5545886158943176, 9: -3.484532594680786, 5: 4.532480239868164, 0: -12.719040870666504, 1: -9.382288932800293, 8: -7.790628433227539, 4: -15.417695999145508, 2: -5.6677656173706055, 7: -6.926422595977783]

 
というわけで、当初の目的であった「PyTorchで実装したモデルをiPhoneアプリに組み込む」をひとまず実現することが出来ました。しかし、MLMultiArrayの扱いは敷居が高く、出来ればこれを介さずに、画像などを入力としてモデルに与える方法が欲しい所です。また記事の冒頭で述べた通り、Apple社が公開しているオープンソースのCore MLモデルや、Create MLという魅力的なツールもありますので、これらも使ってみたい所です。

次回以降の記事では、これらについてもメモして行きたいと思います。

Core MLを用いた、iPhone での機械学習あれこれ(2)に続きます。

SwiftとSpriteKitによる、ゲームアプリの簡単な骨組み的なモノ

以下の過去記事では、SpriteKitを用いたゲームアプリの簡単な骨組みの作り方についてメモしました。しかし当時はXcode 5の時代で、まだコードもObjective-Cで書いていた頃であり、今になってSwiftでゲームアプリを作成する際には参考にしづらいと感じてしまいました。
chemicalfactory.hatenablog.com


そこで本記事ではSwift + SpriteKitで、SpriteKit Sceneファイル(以下SKSと表記)も活用して、過去記事同様のアプリ骨組みを作成する方法を改めてまとめたいと思います。
 

SpriteKitテンプレートの準備

  • まずXcode(本記事執筆時点:Version 11.3)でSpriteKitのテンプレートを作成し、「GameScene.sks」「Actions.sks」「GameScene.swift」のファイル3つは不要なので削除します。ここまでの手順は以下の記事と同様です。

chemicalfactory.hatenablog.com

  • テンプレートに「TitleScene」「InfoScene」「GameScene」「GameOverScene」という名前で、4つのSKSおよびクラスファイルを作成し、インスペクタで紐付けもしておきます。これも手順としては先程の記事とほとんど同様です。

f:id:fleron:20200112144247p:plain
 

各シーンのレイアウト作成

TitleSceneのレイアウト

  • まずは「TitleScene」から作成します。SKSを表示して、Xcode右上の「+」ボタンからオブジェクト選択ウインドウを開き、ラベルノード(Label)をドラッグ&ドロップでシーンの適当な位置に配置します。そしてラベルのインスペクタでテキスト内容を「タイトル」に変更し、文字色やフォントサイズなどを好きに設定します(シーンの背景色も変えています)。更に、後にコードからインスタンスを呼び出す際の識別用に「Name」に適当な名前を与えておきます(ここでは「titeLabel」としました)。

f:id:fleron:20200112144259p:plain
f:id:fleron:20200112171122p:plain
 

  • なおSpriteKitは本来、左下が座標系の原点(0, 0)であり過去記事でもそれを前提としたコードとしていますが、SKSファイルでは中心が原点(0, 0)となっています。これはSKSではデフォルトで、シーンのアンカーポイント(Anchor Point)がX=0.5, Y=0.5に設定されているからです。本記事ではこのままの設定としますが、もしアンカーポイントをX=0.0, Y=0.0に変更すれば、従来通りに左下原点となります。以下の参考図もご覧ください。

f:id:fleron:20200112150643p:plain
f:id:fleron:20200112153628p:plain
 

  • 続いて、他画面に遷移するためのボタンとして用いるラベルも同様に配置します。これらも同様に識別用のNameを与えておきます。ここでは「スタート」のラベルを「startButton」、「情報」のラベルを「infoButton」としました。

f:id:fleron:20200112155436p:plain
 

InfoSceneのレイアウト

  • 続いて「InfoScene」のSKSに同様の手順でラベルを配置します。ボタンとして用いる「戻る」のラベルには「backButton」のNameを与えています(「情報」のラベルは、特にコード上で操作しないのでNameは不要)。

f:id:fleron:20200112163105p:plain

GameSceneのレイアウト

  • 「GameScene」のSKSにも同様の手順でLabelを配置します。ここではカウントダウンを表示するためのラベル(赤字の「0」)が必要であり、「timerLabel」のNameを与えています(「ゲーム画面」のラベルは、特にコード上で操作しないのでNameは不要)。

f:id:fleron:20200112164041p:plain

GameOverSceneのレイアウト

  • 最後に「GameOverScene」のSKSにも同様の手順でラベルを配置します。「リトライ」のラベルには「retryButton」、「戻る」のラベルには「backButton」のNameを与えています(「ゲームオーバー」のラベルには、特にコード上で操作しないのでNameは不要)。

f:id:fleron:20200112164417p:plain
 

各シーンの実装

TitleSceneの実装

SKSによる各シーンのレイアウト作成が完了したら、あとはコードを書いて実装して行きます。最初に「TitleScene.swift」を以下のコードに書き換えます。

import SpriteKit

class TitleScene: SKScene {
    
    override func didMove(to view: SKView) {
        
        //「タイトル」のLabelに拡大縮小のアクションを設定。
        //特に必要性は無いけれど、アクション設定方法の例として。
        if let titleLabel = self.childNode(withName: "//titleLabel") as? SKLabelNode{
            let action1 = SKAction.scale(to: 1.5, duration:2.0)
            let action2 = SKAction.scale(to: 1.0, duration:2.0)
            let sequence = SKAction.sequence([action1, action2])
            titleLabel.run(SKAction.repeatForever(sequence))
        }
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        
        //タップ位置にLabelが存在するか、存在する場合には名前は何か、で場合分け
        for t in touches {
            let point = t.location(in: self)
            
            if let startButton = self.childNode(withName: "//startButton") as? SKLabelNode{
                if startButton.contains(point){
                    //SKSで定義したシーンを行き先に設定して、画面遷移を行う。
                    if let scene =  SKScene(fileNamed: "GameScene"){
                        let transition = SKTransition.crossFade(withDuration: 1.0)
                        scene.scaleMode = .aspectFill
                        self.view?.presentScene(scene, transition: transition)
                    }
                }
            }
            
            if let infoButton = self.childNode(withName: "//infoButton") as? SKLabelNode{
                if infoButton.contains(point){
                    if let scene =  SKScene(fileNamed: "InfoScene"){
                        let transition = SKTransition.crossFade(withDuration: 1.0)
                        scene.scaleMode = .aspectFill
                        self.view?.presentScene(scene, transition: transition)
                    }
                }
            }
        }
    }
}


やっていることの内容は過去記事と同じです。ただし、過去記事ではラベルも全てコード上で生成していましたが、今回は既にSKSで生成済みなのでその必要はありません。必要に応じて、シーンのchildNode関数でラベルのインスタンスを呼び出せばOKです。

このときwithName:引数にはSKSで設定したNameを与えますが、その冒頭に”//“とスラッシュ2つを付与しています。こちらはSpriteKitのテンプレートにおけるchildNode関数の使用法に習っています…が、その意味合いについても把握したかったので調べてみたところ、Appleの公式ドキュメントに解説がありました。

どうやらchildNode関数などで子ノードを検索する際に、現在のノードの子ノードだけを検索するか、それともノードツリーの根元から全体を検索するかの違いが生じるようです。本アプリのように、シーンが子ノードとして各ラベルを持つだけの単純な構成であれば、”//“を付けても付けなくても見た目には変わらず動作するようですが、もっと複雑なノードの階層構造を有するようなアプリでは使い分けを意識する必要がありそうです。

ラベルのインスタンス変数が得られたら、あとは過去記事と同様にアクションを設定したり、ボタン代わりにして画面遷移をしたりすることが可能になります。ただし画面遷移を行う際には、行き先となるシーンはfileNamed:引数を用いてSKSからロードします。

なおchildNode関数にしてもシーンのロードにしても、引数に誤った名前を与えるとnilが返されますので、Optional型の変数を用いてif-let構文でアンラップしていますが、if文のネストが重なって若干読みづらくなってしまっているように感じます。もう少しスマートに記述したいのですが、ここは今後の課題です(以下のようにguard-let構文を用いれば、if文のネストが1つ減って若干読みやすくなる?)

guard let startButton = self.childNode(withName: "//startButton") as? SKLabelNode else{
    return
}

 

InfoSceneの実装

「InfoScene.swift」を以下のコードに書き換えます。内容は相変わらず、TitleSceneとほとんど同じです。

import SpriteKit

class InfoScene: SKScene {
        
    override func didMove(to view: SKView) {
        //特に必要な処理はないので空欄
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        for t in touches {
            let point = t.location(in: self)
            
            if let backButton = self.childNode(withName: "//backButton") as? SKLabelNode{
                if backButton.contains(point){
                    if let scene =  SKScene(fileNamed: "TitleScene"){
                        let transition = SKTransition.crossFade(withDuration: 1.0)
                        scene.scaleMode = .aspectFill
                        self.view?.presentScene(scene, transition: transition)
                    }
                }
            }
        }
    }
}

 

GameSceneの実装

「GameScene.swift」を以下のコードに書き換えます。

import SpriteKit

class GameScene: SKScene {
    
    let gameInterval = 10.0
    var isLastUpdateTimeInitialized : Bool = false
    var lastUpdateTime : TimeInterval = 0.0
    var gameTime : TimeInterval = 0.0
    
    override func didMove(to view: SKView) {
        //特に必要な処理はないので空欄
    }
    
    override func update(_ currentTime: TimeInterval) {
        if !isLastUpdateTimeInitialized{
            isLastUpdateTimeInitialized = true
            lastUpdateTime = currentTime
        }
        
        let timeSinceLast = currentTime - lastUpdateTime
        lastUpdateTime = currentTime
        
        gameTime += timeSinceLast
        let timeRemaining = gameInterval - gameTime
        if let timerLabel = self.childNode(withName: "//timerLabel") as? SKLabelNode{
            timerLabel.text = "\(Int(ceil(timeRemaining)))"
        }
        
        if(timeRemaining <= 0.0){
            if let scene =  SKScene(fileNamed: "GameOverScene"){
                let transition = SKTransition.crossFade(withDuration: 1.0)
                scene.scaleMode = .aspectFill
                self.view?.presentScene(scene, transition: transition)
            }
        }
    }
}


こちらもやっていることの内容は過去記事と同じであり、update関数が呼び出される度に、前回と今回の呼び出し時刻の差分を取って、正確な経過時間をカウントしています。そして一定時間(ここでは10秒)が経過したら、強制的にゲームオーバー画面(GameOverScene)に遷移します。

なお過去記事では「isTransition」という名前のBool変数をフラグとして、画面遷移の処理が1度だけ行われるようにしていました(当時はこのようにしないと、画面遷移が上手く機能しなかったため)。しかし、現在ではこのような細工をしなくても問題なく画面遷移が行われるようです。
 

GameOverSceneの実装

「GameOverScene.swift」を以下のコードに書き換えます。こちらも内容は相変わらず、TitleSceneとほとんど同じです。

import SpriteKit

class GameOverScene: SKScene {
    
    override func didMove(to view: SKView) {
        //特に必要な処理はないので空欄
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        for t in touches {
            let point = t.location(in: self)
            
            if let retryButton = self.childNode(withName: "//retryButton") as? SKLabelNode{
                if retryButton.contains(point){
                    if let scene =  SKScene(fileNamed: "GameScene"){
                        let transition = SKTransition.crossFade(withDuration: 1.0)
                        scene.scaleMode = .aspectFill
                        self.view?.presentScene(scene, transition: transition)
                    }
                }
            }
            
            if let backButton = self.childNode(withName: "//backButton") as? SKLabelNode{
                if backButton.contains(point){
                    if let scene =  SKScene(fileNamed: "TitleScene"){
                        let transition = SKTransition.crossFade(withDuration: 1.0)
                        scene.scaleMode = .aspectFill
                        self.view?.presentScene(scene, transition: transition)
                    }
                }
            }
        }
    }
}

 

GameViewControllerの書き換え

最後に「GameViewController.swift」を開いて、最初に表示されるシーンを”TitleScene”に書き換えます。これでSwiftでも、SKSを用いたゲームアプリの骨組みを完成させることが出来ました。

//シーンを表示する部分のコードのみ。前後は省略

if let view = self.view as! SKView? {

    if let scene = SKScene(fileNamed: "TitleScene") {
        // Set the scale mode to scale to fit the window
        scene.scaleMode = .aspectFill
        
        // Present the scene
        view.presentScene(scene)
    }
    
    view.ignoresSiblingOrder = true
    
    //view.showsFPS = true
    //view.showsNodeCount = true
}

SpriteKit Sceneとスケールモード

現在のXcode(本記事執筆時点:Version 11.3)では、昔作成した記事の時代と比べてSpriteKitのテンプレートの中身が大きく変わっており、SpriteKit Sceneファイル(以下SKSと表記)を用いて、まるでStoryboardのように様々なノードをシーン上にマウスで配置出来るようになったようです。

もちろん昔のように、SKSを用いずに全てコードだけでSpriteKitを扱うことも出来るようですが(こちらのサイトが非常に参考になります)、SKSは便利そうなので今後は使い方を学習し、またSpriteKitを活用したアプリを作成してみたいと考えています。

しかしその上で、現状ではiPhoneだけでも8から11 Pro Maxまで様々な機種が現役であり、もちろんiPadもありますので、サイズどころかアスペクト比まで異なるそれぞれの機種の画面において、SpriteKitのシーンの見え方を制御するために、改めてスケールモードの設定について確認しておきたいと思いました。

そこで本記事では、まずはSpriteKitのテンプレートに自前のSKSを追加してみます。そのSKSではシーンのサイズを1242 x 2208(iPhone 8 Plusのピクセルと同じ)として、同じサイズの背景画像をスプライトノードとして配置します。そしてスケールモードの設定によって、シーンの見え方が下表の機種でどのように変わってくるのかをシミュレーター上で実験してみます。

機種名 ポイント ピクセル アスペクト比
iPhone 8 Plus 414 x 736 1242 x 2208 9 : 16
iPhone 11 Pro Max 414 x 896 1242 x 2688 9 : 19.5
iPad Pro 12.9 1024 x 1366 2048 x 2732 3 : 4

 

SpriteKitのテンプレートに自前のSKSを追加

こちらは単なる手順のメモとなりますので、サクッとまとめます。

  • まずはXcodeの新規プロジェクト作成で「Game」から、SpriteKitのテンプレートプロジェクトを作成します。

f:id:fleron:20200104013736p:plain
f:id:fleron:20200104013740p:plain
 

  • プロジェクトが開いたら、デフォルトで存在する「GameScene.sks」「Actions.sks」「GameScene.swift」のファイル3つは不要なので削除します。

f:id:fleron:20200104013539p:plain
 

  • プロジェクトに自前のSKSを追加します。ファイルの追加画面で「SpriteKit Scene」を選択します。ファイル名は何でも良いですが、ここではデフォルトの「MyScene.sks」とします。

f:id:fleron:20200104014304p:plain
 

  • 続いてプロジェクトに、SKSceneのサブクラスとしてクラスファイルを追加します。こちらをSKSと紐付けますので、同じ名前にするのが良いと思います(ここでは「MyScene.swift」)。

f:id:fleron:20200104014619p:plain
 

  • クラスファイルが追加出来たら、中身を以下のように書き換えます。今回は後にSKS上でシーン上にスプライトノードを配置して、それを表示するだけですので、特にここでは何の処理も行いません。
import UIKit
import SpriteKit

class MyScene: SKScene {
    override func didMove(to view: SKView) {

    }
}

 

  • 続いて「MyScene.sks」のインスペクタで、以下のようにクラスファイル名を入力して紐付けをします。

f:id:fleron:20200104015821p:plain
 

  • ついでに「Color」でシーンの背景色も変えてみます(ここでは青色)。これは特に必要というわけではありませんが、後にシミュレーターを起動してテストをしてみた際に、自前のSKSからシーンが表示されていることを分かりやすくするためです。

f:id:fleron:20200104020535p:plain
 

  • 最後に、デフォルトで存在する「GameViewController.swift」を開いて、以下のようにシーンを生成する部分のファイル名を書き換えます。
if let scene = SKScene(fileNamed: "MyScene") {     //シーンのファイル名を“GameScene”から書き換え
        // Set the scale mode to scale to fit the window
        scene.scaleMode = .aspectFill
                
        // Present the scene
        view.presentScene(scene)
}

 

  • あとはプロジェクトをビルドして、どれかの機種のシミュレーターを起動すれば、先ほど設定したシーンの背景色(青色)一色の画面が表示されます。間違いなく自前のSKSからシーンが表示されていることが確認出来ました。

f:id:fleron:20200104022122p:plain
 
 

スケールモードによる、機種別のシーンの見え方(準備)

  • ここからが本題です。まずは「MyScene.sks」に戻り、インスペクタでシーンのサイズをiPhone 8 Plusのピクセル数と同じ1242 x 2208に変更します。

f:id:fleron:20200104135526p:plain
 

  • そしてシーンに配置するための、1242 x 2208サイズの背景画像を用意します。今回は以下のように1〜5の数字を上下左右および中央に書いた画像を用いて、スケールモードの設定によってシーンが拡大縮小される場合は、その様子が分かりやすくなるようにしました(画像は掲載用に縮小してありますが、実際は1242 x 2208サイズの物を用いています)。この画像をプロジェクトに追加しておきます(ここではファイル名を「background.png」とします)。

f:id:fleron:20200104140119p:plain
 

  • 「MyScene.sks」を表示した状態で、Xcodeの「+」ボタンをクリックすると、シーン上に配置するノードなどのオブジェクトを選択するウインドウが開きます。「Color Sprite」を選択して、ドラッグ&ドロップでシーンの中央に配置します。

f:id:fleron:20200104141324p:plain
 

  • 先ほどの背景画像「background.png」をプロジェクトに追加済みであれば、Spriteのインスペクタで「Texture」を操作すると名前が表示されますので、選択すればシーン上に表示されます。

f:id:fleron:20200104141815p:plain
 

  • あとは「GameViewController.swift」でスケールモードを設定している部分を色々書き換えて、それぞれの機種のシミュレーター上でシーンの見え方がどう変わるかを実験して行きます。デフォルトでは「aspectFill」の設定となっていますが、他に「aspectFit」「fill」「resizeFill」が設定出来ます。
if let scene = SKScene(fileNamed: "MyScene") {     
        // Set the scale mode to scale to fit the window
        scene.scaleMode = .aspectFill   //スケールモードの設定。ここの部分を書き換える。
                
        // Present the scene
        view.presentScene(scene)
}

 
 

スケールモードによる、機種別のシーンの見え方(結果)

まずは実験対象とする機種の一覧表を再掲します。

機種名 ポイント ピクセル アスペクト比
iPhone 8 Plus 414 x 736 1242 x 2208 9 : 16
iPhone 11 Pro Max 414 x 896 1242 x 2688 9 : 19.5
iPad Pro 12.9 1024 x 1366 2048 x 2732 3 : 4

早速それぞれの結果(シミュレータの画面キャプチャ)を見て行きたいと思います。
 

aspectFillの場合

iPhone 8 Plus iPhone 11 Pro Max iPad Pro 12.9
f:id:fleron:20200104143855p:plain f:id:fleron:20200104144027p:plain f:id:fleron:20200104144044p:plain

まずはデフォルトのaspectFillの場合です。aspectFillではシーンのアスペクト比は保たれたまま、画面全体を隙間なく埋めるように表示されます。そのためシーンが一方向に拡大縮小されて見た目が変わってしまうことは無いのですが、画面に収まらず見切れてしまう部分が生じる場合があります。

今回はiPhone 8 Plusのピクセルに合わせた画像ですので、この機種ではもちろん画像通りの表示が得られますが、11 Pro MaxおよびiPadでは端の部分が見切れてしまっています(更に実際の11 Pro Maxでは、1の数字は一部がノッチに隠れてしまいます)。
 

aspectFitの場合

iPhone 8 Plus iPhone 11 Pro Max iPad Pro 12.9
f:id:fleron:20200104143706p:plain f:id:fleron:20200104143717p:plain f:id:fleron:20200104143835p:plain

続いてaspectFitの場合です。aspectFitではシーンのアスペクト比は保たれたまま、シーン全体が画面内に収まるように表示されます。その代わり何も表示されない余り部分が画面に生じる場合があります。

実際に11 Pro MaxおよびiPadでは、シーンからはみ出した画面の余り部分が真っ黒に表示されています(本ブログの背景が黒いので分かりづらいかもしれませんが)。
 

fillの場合

iPhone 8 Plus iPhone 11 Pro Max iPad Pro 12.9
f:id:fleron:20200104151208p:plain f:id:fleron:20200104151222p:plain f:id:fleron:20200104151254p:plain

続いてfillの場合です。fillではシーン全体が画面にピッタリ合うよう、縦横方向に拡大縮小されて表示されます(アスペクト比は保たれません)。画面の見切れや余り部分などは生じませんが、特にキャラクターなどを描いたスプライトを表示する場合は、見た目が大きく変わってしまうかもしれません(また、こちらでも11 Pro Maxでは、1の数字は一部がノッチに隠れてしまいます)。
 

resizeFillの場合

iPhone 8 Plus iPhone 11 Pro Max iPad Pro 12.9
f:id:fleron:20200104152432p:plain f:id:fleron:20200104152452p:plain f:id:fleron:20200104152517p:plain

最後にresizeFillの場合ですが、これまでと異なり8 Plusでも画像通りの表示とならず、中央の5の数字周辺部分だけが大きく表示されています。どうやらresizeFillではシーンのスケーリングは行われず、ビューのサイズ(通常は機種一覧表の「ポイント」と合致)に切り抜かれて表示されるようです。

例えば8 Plusの場合1242 x 2208サイズのシーン全体のうち、その中央の414 x 736サイズの部分だけが切り抜かれるので、このような表示となります。他の機種についても同様です。

ここで、試しに「MyScene.sks」でシーンのサイズは1242 x 2208のまま、背景画像のSpriteのサイズだけを400 x 700(8 Plusのビューよりわずかに小さい値)に変更して、再度resizeFillで機種別の表示を見てみます。
f:id:fleron:20200104170424p:plain
 
結果は以下の通りです。

iPhone 8 Plus iPhone 11 Pro Max iPad Pro 12.9
f:id:fleron:20200104171435p:plain f:id:fleron:20200104171447p:plain f:id:fleron:20200104171458p:plain

今度はSpriteのサイズがいずれの機種のビューよりも小さいですので、Spriteは400 x 700のまま表示され、余った部分はシーンの背景色(ここでは青色)で表示されます。
 
以上、スケールモードの設定によって、同じシーンの見え方が機種別にどのように異なってくるのかを確認しました。これらのスケールモードは、毎回これを設定すればOKという正解があるわけではなく、作成するアプリの内容や、対応する機種(iPhoneのみか、iPadもカバーするか)などによって適宜使い分ける必要がありそうです。「GameViewController.swift」内で機種や画面サイズの情報をチェックし、それによってどのスケールモードを設定するか条件分岐させる方法もあるようですので、今後は実際のアプリ開発を通じて更に色々と調べて行きたいと思います。

M5Stackで日本語文章をスクロール(2)

※本記事は前回の続きとなります。前回はフォントおよび文字をまとめたデータファイルを作成し、それをmicro SDカードから読み込ませることで、M5Stackで日本語文章を表示可能とする方法についてメモしました。

f:id:fleron:20191214235145j:plain
 
本記事では、M5Stackのライブラリの一つであるTFT_eSPIのスプライト機能を用いて、文章を画面上でスクロールする方法についてメモしたいと思います(こちらの公式サンプルを大いに参考にしております)。
 

スプライトとは何か?

そもそもスプライトとは何であるのか、端的な説明は中々難しく、私自身もスプライトについて正しく理解出来ているのかは正直自信がありません。不正確な点もあるかもしれませんが、私の理解に基づいて例え話で説明してみます。

例えばM5Stackの画面に眼と口の絵を表示して、画面を顔のように見せるプログラムを作りたいとします。このときM5.Lcd系の描画関数(drawLineとかdrawRectとか)を用いる標準的な方法は、大きな紙(画面)に直接マジックで目と口の絵を描き込むようなものです。一方スプライト機能は、あらかじめ眼だけ、口だけの絵を描いた小さな紙を用意しておき、それらを大きな紙の上に配置して顔にするようなものです。この小さな紙にあたるのがスプライトです。

f:id:fleron:20191216232325p:plain

ここでは小さな紙と例えましたが、実際はスプライトの大きさは自由に設定可能であり、画面よりサイズの大きなスプライトを用意することも可能です。スプライトにもM5.Lcd系と同様の描画用関数が存在し、同じように図形や文字列などを描き込むことが出来ます。

ここで、この顔の右眼だけをウインクさせたいとします。大きな紙に直接マジックで書き込むM5.Lcd系の方法では、新しく大きな紙を用意してまた両眼と口を全て描き直さないといけません。左眼と口には何の変化も無いにもかかわらずです。一方でスプライト機能では、右眼の小さな紙だけを新しくすれば良いので効率的です。

さらに、大きな紙とは画面そのものであるので、そこに直接描き込む過程も外から丸見えとなってしまいます。単純な顔の表示だけならまだしも、より複雑な絵をアニメーションさせたいような場合は、絵を描く→紙を新しくする(画面をクリアする)→新しい絵を描く、という過程の繰り返しが画面のちらつきとなって表れてしまいます。
一方スプライトは、それに対して色々を描き込んでも、画面に配置するまでは外に見えることはありません。いわば裏方で描画を行うことが可能であり、画面には裏で描画完了済みのスプライトのみを表示することで、アニメーションのちらつきを抑制することが可能です。

拙い説明でしたが、スプライト機能とはこのような物となります。
 

スプライト機能を用いた文字列のスクロール

TFT_eSPIのスプライト機能には、setScrollRect関数・Scroll関数という簡単にスクロールを実現可能な、非常に便利な仕組みが備わっています。

setScrollRect関数ではスプライト上に、指定したサイズ・色でスクロール用の短形を描画することが出来ます。そして短形上に描画した図形や文字列は、Scroll関数を呼び出すだけで、x軸・y軸方向に指定した量だけスクロールさせることが出来ます。ベルトコンベアの上に載せた物が運ばれるようなイメージです。

これらを用いて、日本語の文字列を一方向にスクロールさせる簡単なサンプルスケッチを作成してみました(※前回記事で作成したフォントのデータファイルを使用します)。

#include <M5Stack.h>
#include <M5Stack.h>

//スプライトの初期化。
TFT_eSprite esp = TFT_eSprite(&M5.Lcd);

int tcount = 0;

void setup() {
  M5.begin();
  M5.Lcd.fillScreen(TFT_WHITE);

  //スプライトで使用する色のビット数を設定(1 or 8 or 16)
  esp.setColorDepth(8);

  //スプライトの実体を生成。ただしこの段階では、まだ画面上には
  //表示されない。後にpushSprite関数で画面上に配置する。
  esp.createSprite(280, 200);

  //M5.Lcd系と同名、同機能の描画関数が多く揃っており(例外もあり)、
  //スプライト上に様々な描画を行うことが可能。前回記事で作成した
  //フォントのデータも同様に読み込んで表示可能。
  esp.fillSprite(TFT_DARKGREY);
  String fileName = "MotoyaLMaru-20”; //ファイル名は適宜書き換え
  esp.loadFont(fileName, SD);
  esp.setTextColor(TFT_WHITE, TFT_BLACK);
  
  //ここでの座標は画面全体ではなく、スプライト内部のローカル座標を表す
  //スプライトの左上から(5, 5)の位置に文字を描画
  esp.setCursor(5, 5);
  esp.print("スプライト");

  //スクロール用の短形を作成。この短形上に描画した図形や文字列は
  //scroll関数によってスクロールさせることが可能。
  esp.setScrollRect(20, 90, 240, 20, TFT_BLACK);
}

void loop() {
  //画面上にスプライトが配置されて、表示されるようになる。
  //指定座標がスプライト左上の頂点位置となる。
  esp.pushSprite(20, 20);

  delay(10);

  //スクロール用短形上に描画した図形や文字列を、(x, y)方向に
  //指定した量だけスクロールさせる。
  esp.scroll(2, 0);

  //スクロール用短形の上に載るように、文字列を描画する。
  //スクロールした文字列は、やがて短形の端に消えて行くので
  //一定周期で新たな文字列を描画する。
  tcount--;
  if(tcount <= 0){
    tcount = 50;
    esp.setTextColor(TFT_YELLOW, TFT_BLACK);
    esp.setCursor(20, 90);
    esp.print("スクロール");
  }
}

こちらを実行すると、以下のようにスプライトが灰色の領域として表示され、その中央には黒色の領域(ScrollRect)が表示されます。そしてその上を「スクロール」という文字列が左から右へ次々と流れて行きます。

f:id:fleron:20191220223226p:plain
f:id:fleron:20191220223159p:plain
 

ところで本スケッチでは、ScrollRectの上に文字列がきちんと乗るように調整しています。しかし以下のように、文字列がScrollRectからはみ出したら何が起きるでしょうか?

    //カーソル位置を書き換えて、文字がScrollRectからはみ出すようにする
    esp.setCursor(20, 80);  //(20, 90)から書き換え
    esp.print("スクロール");

実際にやってみると、以下のようにScrollRectに乗った部分のみがスクロールして、はみ出した部分は動かずに取り残されます。

f:id:fleron:20191220225307p:plain

以上のようにスプライト機能を用いることで、M5Stackで日本語文章をスクロールさせるという目的を、比較的簡単に達成することが出来ました。スプライト機能については、まだまだ色々と面白い使い方が出来そうですので、今後も色々調べて勉強してみたいと思います。

M5Stackで日本語文章をスクロール(1)

早速M5Stackの記事を書いてみます。

M5Stackの液晶画面に、日本語文章をスクロールで流せるようにしたい!と思い立ったのですが、そもそもM5Stackは標準では日本語表示に対応していないことが判明しました。英文だけならM5.Lcd.print()などで簡単に表示出来るのですが、それをスクロールさせたりするだけでも簡単ではなく、方法を探して色々と彷徨いました。

あれこれ調べて、ようやく比較的簡単に(と思われる)両方を実現する方法に辿り着きましたので、ここにメモします。

フォントデータを用いて日本語表示(※micro SDが必要)

M5Stackで日本語を表示する方法については、以下のサイトで非常に参考となる情報が見つかりました。
M5Stackで好きなフォントを使う

こちらの「ライブラリ用フォントの作成手順」に従えば、パソコンにインストールされたフォントを選び、そのフォントで表示したい文字をまとめた「.vlw」形式のデータファイルを作成出来ます。かな文字や漢字を含めたデータファイルをmicro SDカードに保存し、M5Stackに差し込んで読み込ませることで、日本語混じりの文章を表示可能になるという寸法です。やや下準備に手間は掛かりますが、一度ファイルを用意してしまえば簡単にキレイな文字を表示出来ますので、この方法を使って行きたいと思います。

※ただしフォント(特に商用のもの)によっては、フォントデータを加工したり、フォントデータを他ファイルに埋め込んで利用したりすることを、利用規約で明確に禁止している場合があります。データファイルの作成は、自由な改変が許可されたオープンソースのフォントを探して利用した方が無難です。

ここでは、Google Fontsが提供するオープンソースApache License 2.0)の日本語フォント「小杉丸フォント」をインストールして使用してみます。なおGoogle Fonts提供の日本語フォント一覧や、ダウンロード方法は、こちらのサイトが参考になります。
Google Fontsで日本語フォントが正式サポート開始!使い方やダウンロード方法など


ダウンロードしたファイルを解凍すると、中にTTFファイル(拡張子「.ttf」)が入っています。Macの場合はダブルクリックすると以下のようにフォントのプレビュー画面が開きますので「フォントをインストール」をクリックすればOKです(小杉丸フォントは「MotoyaLMaru」という名前でインストールされます)。
f:id:fleron:20191205220145p:plain

無事にインストールが完了したら、先述の 参考サイト の手順通りに、Processingで「Create Font」スケッチのコードを実行して、インストール済みのフォント一覧を表示し、小杉丸フォント(MotoyaLMaru)のインデックスを確認します。
f:id:fleron:20191205221637p:plain

続いて手順通りに、コードの ”fontNumber” 変数に確認したインデックスを設定するよう書き換えます。なおM5Stack側では、この方法で表示するフォントはsetTextSize関数によるサイズ変更などは出来ないようであり、 コードの “fontSize" 変数で設定したサイズが、そのまま表示サイズとなるようです。M5Stack側でサイズを色々切り替えたい場合は、あらかじめ様々な “fontSize” を設定したデータファイルをmicro SDに複数入れておく必要がありそうです。

そしてかな文字や漢字をデータファイルに含めるために、手順通りにUnicodeのコードポイントを指定します。かな文字だけなら ”unicodeBlock” の中に、”Hiragana〜” “Katakana〜” のコードポイントがコメントアウトされた部分がありますので、以下のように解除するだけでもOKです。
f:id:fleron:20191205224103p:plain

ただし漢字は膨大な種類の文字があるので、何もかも含めようとするとデータファイルのサイズも膨大となり、micro SDからの読み込みも時間が掛かるようになってしまいます。ここでは常用漢字2136文字のみのコードポイント一覧をあらかじめテキストファイルとして用意しておき、それを “specificUnicodes” の中に貼り付けてみました。
f:id:fleron:20191205225754p:plain

後は再度コードを実行すれば、文字の一覧が表示されたウインドウとともに、「.vlw」ファイルが生成・格納されたフォルダが自動的に開きます。なおファイル名はProcessingでのフォント一覧確認時に表示されたものとなりますが、末尾にはフォントサイズの数字が自動的に付与されます(以下の例では20)。
f:id:fleron:20191205230642p:plain

この「.vlw」ファイルをmicroSDのルートディレクトリに格納して(ファイル名がやや長いので「MotoyaLMaru-20.vlw」と短縮しました)。M5Stackに指し込んだら、日本語文章を表示するための準備は完了です。簡単なスケッチで試してみます。

#include<M5Stack.h>

void setup() {
  // put your setup code here, to run once:
  M5.begin();

  M5.Lcd.fillScreen(TFT_NAVY);

  //データファイル名に応じて適宜書き換え。拡張子.vlwは記述不要
  String fileName = "MotoyaLMaru-20"; 
  M5.Lcd.loadFont(fileName, SD);

  //setTextColorに引数を2つ与えると、第1引数が文字色、
  //第2引数が背景色となる。print関数で表示すると文字の輪郭が背景色となる。
  //背景色を設定した方がキレイに表示出来る。
  M5.Lcd.setTextColor(TFT_WHITE, TFT_BLACK);
  M5.Lcd.setCursor(10, 10);
  M5.Lcd.print("0123456789");

  M5.Lcd.setTextColor(TFT_PINK, TFT_BLACK);
  M5.Lcd.setCursor(10, 40);
  M5.Lcd.print("ABCDEFGHIJKLMN");

  M5.Lcd.setTextColor(TFT_GREEN, TFT_BLACK);
  M5.Lcd.setCursor(10, 70);
  M5.Lcd.print("OPQRSTUVWXYZ");

  M5.Lcd.setTextColor(TFT_YELLOW, TFT_BLACK);
  M5.Lcd.setCursor(10, 100);
  M5.Lcd.print("常用漢字も表示出来る");

  M5.Lcd.unloadFont();
}

void loop() {
  // put your main code here, to run repeatedly:
}

loadFont関数でデータファイルを読み込むだけで、print関数などで日本語文章も小杉丸フォントで表示することが出来ます。文字が潰れたりすることもなく、くっきりと明快でキレイな表示が得られます。

f:id:fleron:20191206000925j:plain

本記事はここまでで一区切りとします。次回記事では、表示可能となった日本語文書をスクロールさせる方法についてメモしたいと思います。

M5Stackで日本語の文章をスクロール(2)に続く

再起動

本業の忙しさなどもあり、長らくプログラミング関係のことを色々放置してしまっていましたが、最近また情熱が再燃しましたので、本ブログもゆるりと更新再開して行きたいと思います。

iPhoneアプリ開発も再開して行きたいと思いますが、しばらく触れていなかった間にiOSではOpenGL ESがdeprecatedとなり、Swfitはバージョンが5まで上がり、Swift UIなる新技術が登場するなど、かなり環境が大きく変わっているようです。まさに浦島太郎状態ですが少しずつキャッチアップして行きたい。

また、このような環境変化に伴い昔作成したアプリ(特にOpenGLを用いたもの)がApp Storeから切られてしまいましたので、作り直しもしたいと思います。

アプリ開発以外のあれこれ

また最近は機械学習にも興味を持ち、Python + PyTorchの勉強も始めています。今後はこれらについての記事も(ネタが出来たら)書いて行きたいと思います。更に、会社の後輩にM5Stackなるものの存在を教えてもらい、ためしに手を出してみたらハマってしまいました。現在は画面に色々と表示するようなコードを書いて遊んだりもしています。M5Stackに関する記事も書いていけたらと思います。

あれこれ手を広げ過ぎかもしれませんが(笑)、今後も当ブログをよろしくお願い致します。

SceneKitにおける画面遷移のテスト

以前にこちらの記事で触れましたように、SpriteKitでは SKTransition を用いて、あるシーンから別のシーンへと、アニメーション付きで移動する処理を容易に実装出来ます。このような画面遷移をSceneKitでも実装したいと考えていたのですが、以前のSceneKitにはこのような仕組みは用意されておらず、もどかしい思いをしていました。

しかし、iOS 9以降ではSceneKitのビュー(SCNView)でもpresentSceneメソッドが利用可能となり、SKTransition を用いた画面遷移が利用可能となったようです。
これでSceneKitでも、タイトル画面などを備えたゲームが作りやすくなる!と思い、早速テストしてみたのですが、どうやらSceneKitのシーン(SCNScene)はSpriteKitのシーン(SKScene)と仕様に色々な違いがあり、SpriteKitと同じ感覚で…というわけには行きませんでした。

それでもいろいろ調べて、何とかSceneKitで SKTransition を用いた画面遷移を(あまりスマートではない方法ですが)実装することが出来ましたので、本記事ではその手順などについてメモします。
 

SCNSceneとSKSceneの違い

例えば、あるシーンに表示されたボタンをタップすることで、別のシーンへと移動する処理を実装したいとします。SpriteKitでは、このような処理はシーン内でタッチイベント系のメソッドを用いて、例えば以下のように実装できます。

//SpriteKitにおける画面遷移の例(touchesBeganメソッド部分のみ抜粋)
//あるシーンから、ボタン(“button” という名前のラベルノード)をタップして、別のシーンへと移動する
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
        for touch in touches{
            let p = touch.locationInNode(self)
            
            //"button”という名前のラベルノードは、別所で既に定義されているものとする
            if let button = self.childNodeWithName("button"){
                if(button.containsPoint(p)){

                    //"SecondScene”シーンは、別所で既に定義されているものとする
                    let secondScene = SecondScene(size: self.size)
                    let transition = SKTransition.crossFadeWithDuration(1.0)
                    self.view?.presentScene(secondScene, transition: transition)
                }
            }
        }
    }

実際に画面遷移を担うのはシーンではなく、それを表示している親ビューの側です。しかし SKScene には自身の親ビューを参照するviewプロパティが存在しており、これを用いて親ビューに処理(presentSceneメソッド)を実行させることが出来ます。

一方、SceneKit のSCNSceneには、このようなviewプロパティが存在しません。そしてSCNSceneは UIResponder を継承していないため、タッチイベント関連のメソッドを利用することも出来ないようです。そのため、SpriteKit と同様の方法で画面遷移を実装することは出来ませんでした。
 

SceneKitにおける画面遷移の実装(あまりスマートではない方法)

SCNSceneではタッチイベント関連のメソッドは利用できませんが、ビュー(ビューコントローラー)側では利用することが出来ます。そこで、タッチイベントの処理は全てビューに任せるという単純な方法で、ひとまずSceneKitにおける画面遷移処理のサンプルを実装してみました。

まず Xcode(本記事執筆時点:Version 7.2)で「Game」テンプレート(Language: をSwiftに、Game Technology: をSceneKitにする)でプロジェクトを作成し、GameViewController.swiftの中身を以下のコードに書き換えます。

import UIKit
import QuartzCore
import SceneKit
import SpriteKit

class GameViewController: UIViewController {
    
    weak var scnView : SCNView?
    var firstScene : SCNScene?
    var secondScene : SCNScene?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        scnView = self.view as? SCNView
        self.firstScene = FirstScene()
        self.secondScene = SecondScene()
        
        scnView?.scene = self.firstScene
        scnView?.backgroundColor = UIColor(red: 0.2, green: 0.2, blue: 0.4, alpha: 1.0)        
    }
    
    override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
        if let touch = touches.first{
            let p = touch.locationInView(self.scnView)
            
            if let hitResults = self.scnView?.hitTest(p, options: nil){
                if hitResults.count > 0{
                    let result = hitResults.first?.node
                    
                    if result?.name! == "switch1"{
                        let transition = SKTransition.pushWithDirection(SKTransitionDirection.Left, duration:1.0)
                        self.scnView?.presentScene(self.secondScene!, withTransition: transition,
                                                                incomingPointOfView: nil, completionHandler: nil)
                        scnView?.backgroundColor = UIColor(red: 0.4, green: 0.4, blue: 0.2, alpha: 1.0)
                        
                    }
                    if result?.name! == "switch2"{
                        let transition = SKTransition.doorsOpenHorizontalWithDuration(1.0)
                        self.scnView?.presentScene(self.firstScene!, withTransition: transition,
                                                                incomingPointOfView: nil, completionHandler: nil)
                        scnView?.backgroundColor = UIColor(red: 0.2, green: 0.2, blue: 0.4, alpha: 1.0)
                    }
                }
            }
        }
    }
    
    override func shouldAutorotate() -> Bool {
        return true
    }
    
    override func prefersStatusBarHidden() -> Bool {
        return true
    }
    
    override func supportedInterfaceOrientations() -> UIInterfaceOrientationMask {
        if UIDevice.currentDevice().userInterfaceIdiom == .Phone {
            return UIInterfaceOrientationMask.AllButUpsideDown
        } else {
            return UIInterfaceOrientationMask.All
        }
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Release any cached data, images, etc that aren't in use.
    }
}

 
続いて、プロジェクトにFirstScene.swift、SecondScene.swiftというソースファイルを追加します。そして、それぞれの中身を以下のコードに書き換えます。

//FirstScene.swift(最初に表示するシーン)
import SceneKit

class FirstScene: SCNScene{
    
    var cameraNode : SCNNode?
    
    override init(){
        super.init()
        
        self.setObject()
        self.setCamera()
        self.setLight()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func setObject(){
        let box = SCNBox(width: 5.0, height: 5.0, length: 5.0, chamferRadius: 0.0)
        box.firstMaterial?.diffuse.contents = UIColor(red: 0.4, green: 1.0, blue: 0.4, alpha: 1.0)
        
        let boxNode = SCNNode()
        boxNode.geometry = box
        boxNode.position = SCNVector3(x: 0.0, y: 0.0, z: 0.0)
        boxNode.name = "switch1"
        self.rootNode.addChildNode(boxNode)
        boxNode.runAction(SCNAction.repeatActionForever(
            SCNAction.rotateByX(0.1, y: 0.2, z: 0.1, duration: 2.0)
        ))
    }
    
    func setCamera(){
        self.cameraNode = SCNNode()
        self.cameraNode?.camera = SCNCamera()
        self.cameraNode?.name = "camera"
        self.cameraNode?.position = SCNVector3(x: 0, y: 30, z: 30)
        self.cameraNode?.camera?.zFar = 1000.0
        self.rootNode.addChildNode(self.cameraNode!)
        
        if let center = self.rootNode.childNodeWithName("switch1", recursively: false){
            let cons = SCNLookAtConstraint(target: center)
            cons.influenceFactor = 1.0
            self.cameraNode!.constraints = [cons]
        }
    }
    
    func setLight(){
        let ambientLightNode = SCNNode()
        ambientLightNode.light = SCNLight()
        ambientLightNode.light?.type = SCNLightTypeAmbient
        ambientLightNode.light?.color = UIColor.darkGrayColor()
        self.rootNode.addChildNode(ambientLightNode)
        
        let lightNode = SCNNode()
        lightNode.light = SCNLight()
        lightNode.light?.type = SCNLightTypeSpot
        lightNode.light?.spotOuterAngle = 120
        lightNode.light?.color = UIColor.whiteColor()
        lightNode.light?.castsShadow = true
        lightNode.position = SCNVector3(x: 2, y: 5, z: 10)
        
        self.rootNode.addChildNode(lightNode)
    }
}


//SecondScene.swift(画面遷移後に表示するシーン)
import SceneKit

class SecondScene: SCNScene {
    
    var cameraNode : SCNNode?
    
    override init(){
        super.init()
        
        self.setObject()
        self.setCamera()
        self.setLight()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func setObject(){
        let sphere = SCNSphere(radius: 3.0)
        sphere.firstMaterial?.diffuse.contents = UIColor(red: 1.0, green: 0.4, blue: 0.4, alpha: 1.0)
        
        let sphereNode = SCNNode()
        sphereNode.geometry = sphere
        sphereNode.position = SCNVector3(x: 0.0, y: 0.0, z: 0.0)
        sphereNode.name = "switch2"
        self.rootNode.addChildNode(sphereNode)
    }
    
    func setCamera(){
        self.cameraNode = SCNNode()
        self.cameraNode?.camera = SCNCamera()
        self.cameraNode?.name = "camera"
        self.cameraNode?.position = SCNVector3(x: 0, y: 30, z: 30)
        self.cameraNode?.camera?.zFar = 1000.0
        self.rootNode.addChildNode(self.cameraNode!)
        
        if let center = self.rootNode.childNodeWithName("switch2", recursively: false){
            let cons = SCNLookAtConstraint(target: center)
            cons.influenceFactor = 1.0
            self.cameraNode!.constraints = [cons]
        }
    }
    
    func setLight(){
        let ambientLightNode = SCNNode()
        ambientLightNode.light = SCNLight()
        ambientLightNode.light?.type = SCNLightTypeAmbient
        ambientLightNode.light?.color = UIColor.darkGrayColor()
        self.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: 2, y: 5, z: 10)
        
        self.rootNode.addChildNode(lightNode)
    }
}


FirstScene.swiftでは最初に表示するシーンを定義します。ここでは回転する立方体(”switch1”という名前のノード)を画面中央に表示します。SecondScene.swiftには画面遷移後に表示するシーンを定義します。ここでは球体(”switch2”という名前のノード)を画面中央に表示します。これらの立体モデルがボタン代わりとなります。

そしてGameViewController.swiftのtouchBeganメソッドで、シーンの種類を問わずまとめてタッチイベントを処理します。現在ビューに表示されているのがFirstSceneであれば、立方体のタップ時に”switch1”ノードがヒットするので、そのときpresentScene メソッドで SecondScene へと移動します(なお、背景色の変更もここで行っています)。そしてSecondSceneが表示されているときは、球体のタップ時に”switch2”ノードがヒットするので、同様の手順でFirstScene へと移動します。

なお、SceneKitにおけるpresentSceneメソッドは以下のような形になっています。移動先のシーンとSKTransitionを設定するのはSpriteKitと同様ですが、それ以外に2つ引数があります。

//SceneKitにおけるpresentSceneメソッド
func presentScene(scene: SCNScene, withTransition transition: SKTransition, incomingPointOfView pointOfView: SCNNode?, completionHandler: (() -> Void)?)

pointOfView引数に、カメラ(SCNCamera)をセットしたノードを設定すると、移動先のシーンにおける視点をそのカメラに変更出来るようです(本サンプルでは必要ないので nil にしてあります)。またcompletionHandlerに、ブロックとして何らかの処理を設定すると、画面遷移の完了後にそれを実行してくれるようです(本サンプルでは nil にしてあります)。

このサンプルを実行すると下図のようになります。立方体、球体をタップする度に、2つのシーン間をアニメーション付きで移動します。
f:id:fleron:20151230230450p:plain

このように、SceneKitでも SKTransition を用いた画面遷移を実装することが出来ました。…しかしながら、やはりビューコントローラーのtouchBeganで全てのタッチイベントを一元処理するという方式はスマートではありません(シーンの数がもっと増えた場合、面倒な事になるのは想像がつきます)。やはりSpriteKitと同様に、タッチイベントの処理は各シーンにそれぞれ任せる方式に出来ればと思います。この点は課題です。

Swift 1.2におけるタッチイベント系メソッドの変更点

最近は本業の忙しさもあり、少々プログラミングから離れていたのですが、久々にXcodeを(更新があったのでアップデートしてから)起動してみたところ、以前は問題なくビルド出来たプロジェクト(Swift使用)でエラーが出てしまう問題に直面しました。

調べてみると、どうやらXcode 6.3からSwiftのバージョンが1.2にアップグレードし、それに伴っていくつか言語仕様の変更が生じたようです。例えばtouchesBeganなどのタッチイベント系のメソッドでは、以下のような引数touchesの型変更が行われており、前述のエラーはこのことが原因であったようです。

//従来のtouchesBeganメソッド
override func touchesBegan(touches: NSSet, withEvent event: UIEvent){

//Swift 1.2からの新しいtouchesBeganメソッド
override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) {

このSetというデータ型は、NSSetの代替としてSwift 1.2から新しく追加されたものですが、NSSetのようにanyObject()は利用出来ないようです。そのため例えば、touchesBeganメソッドでタッチ位置の座標を検出する場合、以下のようにSwift 1.2では従来と異なる記述が必要となるようです。

//従来のtouchesBeganメソッドによる、タッチ位置の座標検出例
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
        let p = touches.anyObject()!.locationInView(self.view) //変数pにタッチ位置の座標が入る
        ……

///Swift 1.2からの新しいtouchesBeganメソッドによる、タッチ位置の座標検出例
override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) {
        if let touch = touches.first as? UITouch{
            let p = touch.locationInView(self.view) //変数pにタッチ位置の座標が入る
            ……


(2015/12/29 追記)
2015/9/16にはSwift 2.0がリリースされており、それに伴い、またタッチイベント系のメソッドにわずかな変更が生じたようです。

//Swift 1.2のtouchesBeganメソッド
override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) {

//Swift 2.0の新しいtouchesBeganメソッド
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {

Swift 2.0で、touchesBeganメソッドによりタッチ位置の座標を検出するためには、以下のようにすればOKのようです。

//Swift 2.0からの新しいtouchesBeganメソッドによる、タッチ位置の座標検出例
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
       if let touch = touches.first{
            let p = touch.locationInView(self.view) //変数pにタッチ位置の座標が入る
            ……

SceneKitにおけるテクスチャの利用(1)

本記事では、SceneKitにおけるテクスチャの利用法についてメモします。

SCNGeometry系の立体モデルへのテクスチャ貼付

SCNBox、SCNSphereなど、SCNGeometryより派生する汎用立体モデルに、以下の画像をテクスチャとして貼り付けてみます。

f:id:fleron:20150330220119p:plain

単純な貼り付けだけなら非常に簡単です。まずプロジェクトにテクスチャ用の画像ファイル(例えば texture.png とします)を追加します。そして以下のコードのように、立体モデルのオブジェクトを作成し、マテリアルのdiffuse.contentsプロパティにUIImageで画像を設定するだけです。

//SCNBoxにテクスチャを貼り付ける例
let box = SCNBox(width: 1.0, height: 1.0, length: 1.0, chamferRadius: 0.0)
box.firstMaterial?.diffuse.contents = UIImage(named: "texture")


それぞれの立体モデルには、あらかじめテクスチャ座標が設定されており、それに従いテクスチャが貼り付けられます。試しに、様々な立体モデルにテクスチャを貼って並べてみました(以下の記事のサンプルコードをベースに作成)。
Introduction To SceneKit – Part 2 - We ❤ Swift
f:id:fleron:20150323235526p:plain
左から順にSphere、Plane、Box、Pyramid、Cylinder、Cone、Torus、Tube、Capsuleです。それぞれの立体モデルでどのようにテクスチャが貼り付けられるのか、よく分かると思います。

このようにSCNGeometry系の立体モデルには手軽にテクスチャを貼れるのですが、その反面融通はきかず、あらかじめ設定されたテクスチャ座標は原則的に変更不能であるようです(特殊なテクニックを使えば変更出来る可能性もありますが、少なくとも標準的なSceneKitの範疇では無理なようです)。

そのため、例えばSCNBoxでは全ての面に同じテクスチャを貼ることしか出来ません。各面に異なるテクスチャを貼ってサイコロを作ったり、特定の面にだけテクスチャを貼ったり…ということは出来ないようです。

(2015/04/07 追記)SCNGeometry系の立体モデルでは、モデルを構成する各面(各要素)に、異なるマテリアルを設定可能であることを知りました(materials プロパティに SCNMaterial の配列を設定)。これを利用すれば、SCNBoxの各面に異なるテクスチャを貼ったり、特定の面にだけテクスチャを貼ったりすることは可能となります。誤った情報を失礼致しました。
 

各面に異なるマテリアルを設定(2019/12/31追記)

非常に今更となりますが、先述したモデルの各面に異なるマテリアルを設定する方法について、具体例も記しておきたいと思います。以下の画像をSCNBoxの一面だけに貼り付けてみます(以前作成した、周期表アプリの素材を流用しました)。
f:id:fleron:20191231150129p:plain

//SCNBoxにテクスチャを貼り付ける例
//テクスチャを貼るモデルを生成。ここではSCNBox(立方体)とする。
let box = SCNBox(width: 2.0, height: 2.0, length: 2.0, chamferRadius: 0.0)
        
//テクスチャのマテリアルを生成(テクスチャのファイル名はtexture.pngとする)
let tex = SCNMaterial()
tex.diffuse.contents = UIImage(named: "texture.png")
        
//テクスチャを貼らない面用に、色だけを設定したマテリアルを作成
let blank = SCNMaterial()
blank.diffuse.contents = UIColor.white
        
//モデルのmaterialsプロパティに、マテリアルを配列として与える
//モデルの持つ各面(立方体の場合は6個)に、配列の要素それぞれが対応する
box.materials = [tex, blank, blank, blank, blank, blank]

このようにすれば、モデルの一面だけにテクスチャを貼ることが出来ます。もちろんmaterialsプロパティに与える配列の中身を書き換えれば、二面や三面に貼ったりすることも可能です。
f:id:fleron:20191231151923p:plain

補足ですが、以下のようにテクスチャのマテリアルに対して、multiply.contentsプロパティに色を設定すると、テクスチャの色を変えることも出来ます。

//テクスチャのマテリアルを生成(テクスチャのファイル名はtexture.pngとする)
let tex = SCNMaterial()
tex.diffuse.contents = UIImage(named: "texture.png")
//演算によって、テクスチャの色を設定した色に変える
//(元のテクスチャで白色に近い部分ほど、設定色に染まるようなイメージ)
tex.multiply.contents = UIColor.magenta
        
//テクスチャを貼らない面用のマテリアルを作成
let blank = SCNMaterial()
blank.diffuse.contents = UIColor.magenta

box.materials = [tex, blank, blank, blank, blank, blank]

f:id:fleron:20191231151943p:plain
 

COLLADA(.dae)ファイルによる、外部モデルデータのインポート

SceneKitで自由にテクスチャを貼ったモデルを扱いたい場合は、外部の3Dモデリングソフトでモデルを作成してテクスチャを貼り、そのデータをSceneKitにインポートするのが確実な方法のようです。SceneKitはCOLLADAファイル(拡張子 .dae)形式のモデルデータのインポートに対応しているようです。
COLLADA - ウィキペディア

3Dモデリングソフトは、COLLADAファイルの出力に対応していれば何でも良いのですが、本記事では「SketchUp」というソフトを利用する例について紹介してみたいと思います。SketchUpは非常に直感的で分かりやすい操作によるモデリングが可能であり、非商用目的であれば無償版(SketchUp Make)を利用することが出来ます。無償版でも充分な機能が備わっており、テクスチャの貼り付けやCOLLADAファイルの出力も可能です。
SketchUp公式サイト
SketchUp - ウィキペディア

ここではSketchUpで下図のように、立方体の各面に異なるテクスチャを貼ったサイコロのモデルを作成したとします。このモデルをSceneKitにインポートする手順についてメモします。
f:id:fleron:20150331001058p:plain
 

  • まずSketchUpのメニューで「ファイル」→「エクスポート」→「3Dモデル…」を選択し、モデルデータをCOLLADAファイルとして出力します(ここでは「dice」という名前で出力)。下図のようにCOLLADAファイルと、テクスチャ画像が格納されたフォルダが生成されます。

f:id:fleron:20150331003346p:plain
 

  • 新しいフォルダを作成し、生成したCOLLADAファイルとテクスチャ画像フォルダを格納します。そしてこの新しいフォルダの名前を「〜.scnassets」という名前に変更します(ここでは「dicemodel.scnassets」とします)。下図のように確認ダイアログが表示されますが「追加」ボタンをクリックすればOKです。

f:id:fleron:20150331003832p:plain
 

  • SceneKitのプロジェクトに、メニューの「File」→「Add File to 〜」などから、先程作成した「〜.scnassets」フォルダを追加します。これでこのフォルダが、モデルデータとテクスチャをまとめたアセットとしてプロジェクトに認識されます(現在のところ、Xcode上だけで .scnassets 形式のアセットを作成することは出来ず、このように名前を変えたフォルダを外部から追加する手順が必要なようです)。

f:id:fleron:20150331005000p:plain
 

  • あとは必要なコードを書くだけです。以下にサンプルコードを示します。
//COLLADAファイルをSCNSceneとしてインポート
let diceScene = SCNScene(named: "dicemodel.scnassets/dice.dae")!

//インポートしたシーンから、サイコロのモデルをノードとして抽出
//SketchUpの出力では、デフォルトのノード名が「SketchUp」となっている。後述するエディタで編集も可能
let diceNode = diceScene.rootNode.childNodeWithName("SketchUp", recursively: true)!

//後は、普通のノードと同様に、プロパティ設定やシーンへの追加をすればOK
diceNode.position = SCNVector3(x: 0.0, y : 30.0, z : 0.0)
self.scnView?.scene?.rootNode.addChildNode(diceNode)  
diceNode.physicsBody = SCNPhysicsBody.dynamicBody()


実はCOLLADAファイルは3Dモデルだけではなく、カメラや光源などの情報も含めることが可能な形式であり、そのままSCNSceneとしてインポートして、シーンとして利用することが可能です。しかし、必要なシーンは既に準備済みであり、ただ3Dモデルのみを利用したい場合は、サンプルコードのように3Dモデルをノードとして抽出することが出来ます。

後は通常のノードと同様に、必要なプロパティを設定してシーンに追加すればOKです。もちろん物理ボディの設定なども可能です。実際にCOLLADAファイルから、サイコロのモデルを抽出して表示させてみた例を下図に示します(適当な床の上に乗せてみました)。
f:id:fleron:20150331015011p:plain
 

  • なお補足的ですが、プロジェクト上でCOLLADAファイル(〜.dae)をクリックすると、下図のようなエディタが起動します。このエディタでは3Dモデルの観察、ノードの名前変更、更には簡易的なデータ編集も行うことが出来るようです。

f:id:fleron:20150331011126p:plain