#デスクトップ向け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"にしておく。
アプリの構成は図のような感じ。普通こういう作例だとMTKViewのサブクラスを作ってdraw()の中を埋めましょうねというのをよく見かけるが、AppleのサンプルではMTKViewのサブクラスは作らない。その代わりに、"AAPLRenderer"というデリゲートを用意して具体的な描画をそちらに任せるというしゃらくせー構造になっている(デリゲートの設定はViewControllerのviewDidLoad()の中で行っている)。
(参考:MTKViewDelegate https://developer.apple.com/documentation/metalkit/mtkviewdelegate )
初学者にとっては余計にややこやしいのではないかと思うが(実際ドはまりした)、この構造も変えないままSwiftに移植する。せっかくだからStoryBoardも使ってやろうじゃないか。
##移植作業
###新規プロジェクトの作成とカスタムビューの配置など
というわけで新規projectを「Cocoa app」で作る。
無駄にMojaveである。続いてMain.storyboardを開いてobjectパネルから「Custom View」を選択して下のウィンドウ内に配置する。ちなみにmacOS向けのprojectでは「MTKView」という選択肢は表示されない。なんなら「OpenGL View」とか書いてある始末である。だもんでCustom Viewを配置してからClassを「MTKView」に割り当ててやる必要がある。プルダウンメニューにすら出てこないので手入力である。デスクトップの民は見捨てられたのだろうか。
メゲずにViewControllerに向けてアウトレットを作成したら今度はレンダラーを作る。[File] ->[New] -> [File...]でNSObjectを継承したCocoa classを作り、MTKViewDelegateプロコトルに準拠するようにする。AAPLRendererと同じ名前だと紛らわしいので"SwiftRenderer"とした。import MetalKit
を忘れずに。MTKViewDelegate
と書いてしばらくするとXcodeが怒ってくるので、fixを押すと必要なメソッドを自動で追加してくれる。
###レンダラーの実装
さてレンダラーである。「描画が呼び出されるたびにcolorを少しずつ変化させる」という感じの造りになっている。AAPLRendereを見てみるとdeviceとかcommandQueueとか見慣れた単語があるが、Metalデバイスの取得はAAPLRendererでは直接行われない。どうしているかというと、ViewControllerから「.device
が設定されたMTKView」を受け取って、AAPLRendererのイニシャライザでMTKViewからdeviceをお裾分けしてもらっている。ややこやしいが、この構造もそのまま移植する。
@implementation AAPLRenderer
{
id<MTLDevice> _device;
id<MTLCommandQueue> _commandQueue;
}
/// 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で書くと下記のようになる。.device
もmakeCommandQueue()
も帰ってくるのはOptional型(Metal非対応端末だとnilになる)なので、変数宣言の時点で?をつけておく。油断してるとmakeCommandQueue()
の所の_device?
で?を忘れてXcodeに怒られるハメになる。本当に口うるさいやつだ。そのくらいでちょうどいいのだとは思う。
SwiftRendererはNSObjectを継承しただけのクラスなので、イニシャライザの中でsuper.init
する必要は無いらしい....と『詳解Swift』に書いてあった、ような気がする。
var _device:MTLDevice?
var _commandQueue:MTLCommandQueue?
init(by mtkView:MTKView){
_device = mtkView.device
_commandQueue = _device?.makeCommandQueue()
}
さて、色の変化はどうしているのかというと、-(color)makeFancyColor
というメソッドが呼ばれるたびに少しずつ数値をずらしたcolor構造体を返すということになっている。
- (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
て付けときゃいいだろ。
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を要求してきたので無駄骨に終わった。さきにゆって。
NSUInteger dynamicChannelIndex = (primaryChannel+1)%3;
colorChannels[dynamicChannelIndex] += DynamicColorRate;
makeFancyColor()
はケタあふれを利用して添字をアレする設計になっているが、そのままSwiftで書くと「テメー添字っつったらIntだろうが、なにUInt入れようとしてんだよ」と理不尽な怒られ方をするのでおとなしくIntに変換する。演算子の間もスペースを開けないとさらに怒ってくる。
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が口うるさく教えてくれる。
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でアウトレットを設定してあるので、従って宣言は以下のようになる。
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に ※(追記:←これは誤り。この場合、viewDidLoadが終わって関数内部の一時的な変数がリリースされた時点でSwiftRendererのインスタンスはどこからも参照されなくなるので、デリゲートうんぬんの前にレンダラーそのものが消滅してしまう).delegate
が設定された後であっても、RendererとViewControllerとの関係は必須だった、ということなんだろう。ViewControllerが問い合わせ窓口のように機能しているのか。
###viewDidLoad()の実装
_view = (MTKView *)self.view;
_view.device = MTLCreateSystemDefaultDevice();
さてviewDidLoad
の中で描画の準備とかデリゲートの設定などを行う。AAPLViewControllerでは、seif.viewをMTKView型にして代入することでMTKViewを作っている(ように見える)が、Swift版ではStoryBoardで既に_viewの中身を設定済みなので、すぐMTLCreateSystemDefaultDevice()
して問題ない。
_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日費やすハメになってしまった。決して油断してはならない。
##できたーーー!!
というわけでやっと完成。長く苦しい戦いだった....
##教訓
- 重要なチェックを怠ってはならない。
- Xcodeが怒ってくるからといって小手先の解決に走ると視野を狭めるので気をつける。
- 結局Metalの作法あんまよくわからん。
第二弾 "Hello Triangle"編につづく https://qiita.com/shunga/items/33937906188674b85a7b
-
viewDidLoadの最後で直接
_renderer
を呼び出してdraw(in:)すると描画されるのが余計に混乱を産んだ(この直後に_renderer
はリリースされてしまう)。後日XcodeのDebug Memory Graphを見たところ、やはりviewDidLoadを抜けた時点で_renderer
は消滅していた。 ↩