5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Metalサンプルコード "DevicesAndCommands" をSwiftに移植する

Last updated at Posted at 2018-10-24

#デスクトップ向けMetalの資料を残したい
デスクトップ向けのMetalの情報が本当に見当たらない。というか肝心のAppleのサンプルコードすらObjective-Cで書かれている始末でどうしようもない。だもんでSwiftで書き直すことに挑戦した。Swiftをやり始めてから一ヶ月程度、アプリの制作経験すら全くないペーペーだが、苦労しつつもナントカ倒せたのでここに記す。Metal初学者の助けになれれば幸い。
###環境

  • macOS 10.14
  • Xcode 10.0

##サンプルコード"DevicesAndCommands"
https://developer.apple.com/documentation/metal/devices_and_commands
ここからダウンロードできる。実行すると、こんな感じに色がぼんやり変わっていくだけのウィンドウが表示される。ちなみにProjectのsigningが"none"だとビルドさせてくれないので"Team"にしておく。
Oct-25-2018 19-55-17.gif

アプリの構成は図のような感じ。普通こういう作例だとMTKViewのサブクラスを作ってdraw()の中を埋めましょうねというのをよく見かけるが、AppleのサンプルではMTKViewのサブクラスは作らない。その代わりに、"AAPLRenderer"というデリゲートを用意して具体的な描画をそちらに任せるというしゃらくせー構造になっている(デリゲートの設定はViewControllerのviewDidLoad()の中で行っている)。
イラスト3.png
(参考:MTKViewDelegate https://developer.apple.com/documentation/metalkit/mtkviewdelegate
初学者にとっては余計にややこやしいのではないかと思うが(実際ドはまりした)、この構造も変えないままSwiftに移植する。せっかくだからStoryBoardも使ってやろうじゃないか。
##移植作業
###新規プロジェクトの作成とカスタムビューの配置など
というわけで新規projectを「Cocoa app」で作る。
スクリーンショット 2018-10-24 10.14.03.png
無駄にMojaveである。続いてMain.storyboardを開いてobjectパネルから「Custom View」を選択して下のウィンドウ内に配置する。ちなみにmacOS向けのprojectでは「MTKView」という選択肢は表示されない。なんなら「OpenGL View」とか書いてある始末である。だもんでCustom Viewを配置してからClassを「MTKView」に割り当ててやる必要がある。プルダウンメニューにすら出てこないので手入力である。デスクトップの民は見捨てられたのだろうか。
スクリーンショット 2018-10-24 10.25.49.png
メゲずにViewControllerに向けてアウトレットを作成したら今度はレンダラーを作る。[File] ->[New] -> [File...]でNSObjectを継承したCocoa classを作り、MTKViewDelegateプロコトルに準拠するようにする。AAPLRendererと同じ名前だと紛らわしいので"SwiftRenderer"とした。import MetalKitを忘れずに。MTKViewDelegateと書いてしばらくするとXcodeが怒ってくるので、fixを押すと必要なメソッドを自動で追加してくれる。
スクリーンショット 2018-10-24 10.35.24.png
スクリーンショット 2018-10-24 10.35.08.png
###レンダラーの実装
さてレンダラーである。「描画が呼び出されるたびにcolorを少しずつ変化させる」という感じの造りになっている。AAPLRendereを見てみるとdeviceとかcommandQueueとか見慣れた単語があるが、Metalデバイスの取得はAAPLRendererでは直接行われない。どうしているかというと、ViewControllerから「.deviceが設定されたMTKView」を受け取って、AAPLRendererのイニシャライザでMTKViewからdeviceをお裾分けしてもらっている。ややこやしいが、この構造もそのまま移植する。

AAPLRenderer.m
@implementation AAPLRenderer
{
    id<MTLDevice> _device;
    id<MTLCommandQueue> _commandQueue;
}
AAPLRenderer.m
/// Initialize with the MetalKit view from which we'll obtain our Metal device
- (nonnull instancetype)initWithMetalKitView:(nonnull MTKView *)mtkView
{
    self = [super init];
    if(self)
    {
        _device = mtkView.device;

		_commandQueue = [_device newCommandQueue];
    }

    return self;
}

というわけでSwiftで書くと下記のようになる。.devicemakeCommandQueue()も帰ってくるのはOptional型(Metal非対応端末だとnilになる)なので、変数宣言の時点で?をつけておく。油断してるとmakeCommandQueue()の所の_device?で?を忘れてXcodeに怒られるハメになる。本当に口うるさいやつだ。そのくらいでちょうどいいのだとは思う。
SwiftRendererはNSObjectを継承しただけのクラスなので、イニシャライザの中でsuper.initする必要は無いらしい....と『詳解Swift』に書いてあった、ような気がする。

SwiftRenderer.swift
var _device:MTLDevice?
var _commandQueue:MTLCommandQueue?
 
init(by mtkView:MTKView){
        _device = mtkView.device
        _commandQueue = _device?.makeCommandQueue()
}

さて、色の変化はどうしているのかというと、-(color)makeFancyColorというメソッドが呼ばれるたびに少しずつ数値をずらしたcolor構造体を返すということになっている。

AAPLRenderer.m
- (Color)makeFancyColor
{
    static BOOL       growing = YES;
    static NSUInteger primaryChannel = 0;
    static float      colorChannels[] = {1.0, 0.0, 0.0, 1.0};

    const float DynamicColorRate = 0.015;
.....

このメソッド、一見するとどうやって毎回数値を保持しているのか疑問だったが、どうやらObjective-Cの場合、メソッド内でstaticと宣言された変数はコードブロックを抜けた後でも廃棄されずにそのままの数字で生き残り続けるらしい。(参考: http://south37.hatenablog.com/entry/2014/07/13/Objective-C%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B%E3%82%B7%E3%83%B3%E3%82%B0%E3%83%AB%E3%83%88%E3%83%B3%E3%81%AE%E3%81%8A%E8%A9%B1 )その仕組みで毎回の色変えを実現しているわけだが、Swiftで同じように書くとXcodeが真っ赤になって怒ってくるので、この変数たちはおとなしくSwiftRendererのプロパティとして宣言することにした。まあprivateて付けときゃいいだろ。

SwiftRenderer.swift
    private var growing = true
    private var primaryChannel:UInt = 0
    private var colorChannels:[Double] = [1.0, 0.0, 0.0, 1.0]
    private var DynamicColorRate:Double = 0.015
    
    struct Color {
        var red, green, blue, alpha:Double
    }

こういうのってDoubleじゃなくて普通CGFloatとか使うんじゃないの?と気を利かせて当初CGFloatと書いていたが、後に使うMTLClearColorMake(color.red, color.green, color.blue, color.alpha)関数が普通にDoubleを要求してきたので無駄骨に終わった。さきにゆって。

AAPLRenderer
NSUInteger dynamicChannelIndex = (primaryChannel+1)%3;
colorChannels[dynamicChannelIndex] += DynamicColorRate;

makeFancyColor()はケタあふれを利用して添字をアレする設計になっているが、そのままSwiftで書くと「テメー添字っつったらIntだろうが、なにUInt入れようとしてんだよ」と理不尽な怒られ方をするのでおとなしくIntに変換する。演算子の間もスペースを開けないとさらに怒ってくる。

SwiftRenderer
let dynamicChannelIndex:UInt = (primaryChannel + 1) % 3
    colorChannels[ Int(dynamicChannelIndex) ] += DynamicColorRate

さて、MTKViewのdraw()の代わりに呼び出される肝心のデリゲートメソッドdraw(in:)であるが、こちらはほぼ単純置き換えのベタ移植で済むので特筆することがない。SwiftらしさというとrenderPassDescriptorの生成でif-let文を使っている程度である。 view.currentDrawable自体はOptionalを返すものの、commandBuffer.presentはOptionalを受け付けないので、適宜強制アンラップ(!)をつけてやらねばならない。毎度のことながらXcodeが口うるさく教えてくれる。

SwiftRenderer.swift
func draw(in view: MTKView) {
     let color = self.makeFancyColor()
     view.clearColor = MTLClearColorMake(color.red, color.green, color.blue, color.alpha)
     let commandBuffer = _commandQueue?.makeCommandBuffer()
     commandBuffer?.label = "MyCommand"
        
      if let renderPassDescriptor = view.currentRenderPassDescriptor {
         let renderEncoder = commandBuffer?.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
         renderEncoder?.label = "MyRenderEncoder"
            
         renderEncoder?.endEncoding()
         commandBuffer!.present(view.currentDrawable!)
      }
      commandBuffer?.commit()
   }
}

ちなみにMTKViewDelegateのもう一つの必須メソッドmtkView(_ view: MTKView, drawableSizeWillChange size: CGSize)は特に何も実装しなくても問題ない。もともとAppleのサンプルコードもそうなっているので放置した。

##ViewControllerの実装
ViewControllerクラスに必要なメンバは、表示を担当するMTKViewと描画を担当するSwiftRendererである。ビューはすでにStoryBoardでアウトレットを設定してあるので、従って宣言は以下のようになる。

ViewController.swift
class ViewController: NSViewController {

    @IBOutlet weak var _view: MTKView!
                   var _renderer:SwiftRenderer?

_viewが強制アンラップになっているのはXcodeの仕業だが、_rendererをOptional型で宣言しているのには訳がある。
ここでSwiftRendererの生成と代入をサッサと済ませておけば本来話は早い。しかし前述したように、SwiftRendererの生成と初期化はMTKViewの.device取得を待たねばならない。んじゃこの時点で_view.deviceも当てちゃえばいいじゃんと思っても、_viewの宣言と同時にプロパティの中身をいじることはできない。それで_rendererを放置しておくと「テメー初期化済んでねーじゃねーか」とXcodeが怒ってくる。んじゃどうすりゃいいんだと結構悩んでいろいろやってみたところ、正解は、**「_rendrere変数をOptional型で宣言すればカスタムイニシャライザが要らないよ」**でした。これで午前中が無くなった。

ちなみに「テメー初期化済んでねーじゃねーか」を解決しようとして「んじゃ形だけでもinit()作るか」と思っていろいろやってみたが、NSViewControllerの初期化は普通のinit()ではダメらしく、んじゃ指定された形式でやろうとしてもバンドルがどうのこうのと余計なことを書かねばならず、正直いって理解しきれなかった。そもそもAppleのサンプルコードではそんなことはしてないんだから、なんとかしてカスタムイニシャライザを書かずに済む方法が無いか試してみたところ、viewDidLoad()の中で_rendererの宣言と初期化・代入をすれば怒られないことがわかった。

しかし、当たり前の話だがviewDidLoad()内で宣言された変数は、この関数を抜けると捨てられてしまう。こういうことをXcodeは全く指摘しない。なので、コンパイルも通るし実行時エラーも出さないのに何故か_rendererがデリゲートの仕事を全くしないという状況に陥った1。今にして思えばバカ丸出しだが、なんとかしてXcodeを黙らせようと必死であった。
MTKViewに.delegateが設定された後であっても、RendererとViewControllerとの関係は必須だった、ということなんだろう。ViewControllerが問い合わせ窓口のように機能しているのか。 ※(追記:←これは誤り。この場合、viewDidLoadが終わって関数内部の一時的な変数がリリースされた時点でSwiftRendererのインスタンスはどこからも参照されなくなるので、デリゲートうんぬんの前にレンダラーそのものが消滅してしまう)
###viewDidLoad()の実装

AAPLViewController
    _view = (MTKView *)self.view;
    _view.device = MTLCreateSystemDefaultDevice();

さてviewDidLoadの中で描画の準備とかデリゲートの設定などを行う。AAPLViewControllerでは、seif.viewをMTKView型にして代入することでMTKViewを作っている(ように見える)が、Swift版ではStoryBoardで既に_viewの中身を設定済みなので、すぐMTLCreateSystemDefaultDevice()して問題ない。

ViewController.swift
 _view.device = MTLCreateSystemDefaultDevice()
 if _view.device == nil {print("デバイスの取得に失敗")}
        
 _renderer = SwiftRenderer(by: _view)
 if _renderer == nil {print("Rendererの生成に失敗")}
        
 _view.delegate = _renderer
 _view.preferredFramesPerSecond = 60

いちいちifで失敗とかなんとか書かなくてもいいような気はするが(そもそもAppleのサンプルコードが問題なくビルドできた時点でMetal対応環境であることは自明である)、それを省略したせいでドハマリして文字通り1日費やすハメになってしまった。決して油断してはならない。
##できたーーー!!
Oct-25-2018 19-57-27.gif

というわけでやっと完成。長く苦しい戦いだった....

##教訓

  • 重要なチェックを怠ってはならない。
  • Xcodeが怒ってくるからといって小手先の解決に走ると視野を狭めるので気をつける。
  • 結局Metalの作法あんまよくわからん。

第二弾 "Hello Triangle"編につづく https://qiita.com/shunga/items/33937906188674b85a7b

  1. viewDidLoadの最後で直接_rendererを呼び出してdraw(in:)すると描画されるのが余計に混乱を産んだ(この直後に_rendererはリリースされてしまう)。後日XcodeのDebug Memory Graphを見たところ、やはりviewDidLoadを抜けた時点で_rendererは消滅していた。

5
2
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
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?