1 はじめの一歩
Apple の新しいグラフィックス API Metal の覚書。
たぶん、わかりやすいと思います。
猪股弘明 医師:精神科(精神保健指定医)
HorliX, OpenDolphin-2.7m 開発者
iOS? MacOS? いえいえ最初は Playground を使います
アプリまで作り込むつもりなら、プラットフォームはどちらかに決めないといけないが、まずは概念を掴みたいので Playground で動かしてみましょう(実際にはプログラムを走らせるとき Mac 向け iOS 向けのどちらかを選んでいることになりますが、下記コードはどちらでも動きます)。
使用言語は Swift? Objective-C?
「近い将来 Apple は Objective-C のサポートを打ち切る」なんて噂もあるわりに、Apple 公式の Metal サンプルコードは Objective-C で書かれていたりします。
個人的には C/C++ の既存リソースが使いやすい Objective-C (Obj-C)を簡単には終わらせられないと思うのですが・・・。
だから使用言語はどちらでもいいんじゃないかと思うのですが、ここでは使用者の多い(と思われる) Swift をメインに使っていきます。
1-1 最初のメタルプログラミング
これ ↓ です。
3点の頂点データから2次元のビュー上に三角形を描く、というよくあるアレですが、1ファイルに完結させているのはあまりないと思います。
import PlaygroundSupport
import MetalKit
typealias float3 = SIMD3<Float>
typealias float4 = SIMD4<Float>
var Color: float4 = [1, 0, 0, 0.5]
//device, frame, view, commandBuffer を準備する
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("GPU is not supported")
}
let frame = CGRect(x: 0, y: 0, width: 500, height: 500)
let view = MTKView(frame: frame, device: device)
view.clearColor = MTLClearColor(red: 1, green: 1, blue: 1, alpha: 1)
guard let drawable = view.currentDrawable else {
fatalError("Could not create a drawable")
}
guard let commandQueue = device.makeCommandQueue() else {
fatalError("Could not create a command queue")
}
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let renderPassDescriptor = view.currentRenderPassDescriptor,
let renderEncoder =
commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
else {
fatalError("Could not create a command buffer")
}
//shader とその library を設定する
let shader = """
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
float4 position [[position]];
float point_size [[point_size]];
};
vertex VertexOut vertex_main(constant float3 *vertices [[buffer(0)]],
uint id [[vertex_id]]){
VertexOut vertex_out {
.position = float4(vertices[id], 1),
.point_size = 20.0
};
return vertex_out;
}
fragment float4 fragment_main(constant float4 &color [[buffer(0)]]){
return color;
}
"""
let library = try device.makeLibrary(source: shader, options: nil)
let vertexFunction = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name: "fragment_main")
//pipeline を準備
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
let pipelineState = try! device.makeRenderPipelineState(descriptor: pipelineDescriptor)
renderEncoder.setRenderPipelineState(pipelineState)
//ようやくデータです
var vertices: [float3] = [
[-0.7, 0.8, 1],
[-0.7, -0.4, 1],
[ -0.2, -0.1, 1]
]
let Buffer = device.makeBuffer(bytes: &vertices,
length: MemoryLayout<float3>.stride * vertices.count,
options: [])
renderEncoder.setVertexBuffer(Buffer, offset: 0, index: 0)
renderEncoder.setFragmentBytes(&Color,
length: MemoryLayout<float4>.stride, index: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0,
vertexCount: vertices.count)
renderEncoder.endEncoding()
//commandBuffer を経由して描画したいデータをGPUに渡します
commandBuffer.present(drawable)
commandBuffer.commit()
PlaygroundPage.current.liveView = view
Xcode から Playground のプロジェクトを作成して、上のコードをコピペして実行で動くと思います。
こんな風に赤い三角形が描画できたら、成功です。
Xcode 自体の操作方法がわからんという方は、動画で。
1-1-1 コード解説
コード解説の前に、軽く Metal の仕組みを考えてみましょう。
ポンチ絵みたいなもんですが。
M1 Mac の発売前だと「メモリが共有で 8G と 16G ・・・、えー」みたいな感想が多かったように思いますが、実際に使ってみるとそのパフォーマンスにみなさん愕然としたわけです。
M1(arm) + Metal だとこの仕組みがどうやら効率的に働くようです。
(最近ではその秘密も徐々にわかってきました。この記事とか)
その代わり、画像を描画させるとき、この点を意識してコードを書く必要があります。
例えば、定数 shader に入っている内容が実際に GPU で動作させる処理です。
頂点と色の処理に特化されてますね。
ちなみに、各頂点での処理が(GPU のおかげで)並列処理可能なため高速で動作する、というのが Metal の速さの秘密の一つのようです。
最初のうちは、あまり細かいところまで気が回らないでしょうが、それでも shader の中身の書き方が、通常の Swift の文法に従っていないことにはすぐ気がつくのではないでしょうか。
どちらかといえば C/C++ に似ている。
実際、その通りで、この部分は MSL(Metal Shading Language)です。
C++ を元にしていますが、使うライブラリやコンパイラ・リンカまで独自仕様で、C/C++ とは別の言語です。
整理すると
・GPU サイドは、電子回路的に CPU とは違った構成ですから、それを最適に動作させるために独自言語を採用した
・CPU サイドは、それまでの継続性を重視するため、従来通り Obj-C/Swift を使わざるを得ない
という状況があったわけです。
ここで問題となってくるのは、異なる言語間でどうやってデータを共有(あるいは転送)するかということですが、ありがたいことに Obj-C/Swift は、取り扱うデータの型に関して厳密です。
変数一つが持つデータ量(バイト数)とデータ全体が持つデータ総量(または総個数)とデータ先頭のアドレスさえわかっていれば、異なる言語同士でもデータをやり取りできる、というのは、それほど違和感のある考え方ではないでしょう。
上のサンプルでその手続きをやっているのは、具体的には CPU サイドでは
let Buffer = device.makeBuffer(bytes: &vertices,
length: MemoryLayout.stride * vertices.count,
options: [])
の箇所です。変数の型とその長さを明示したバッファー(buffer)を作成しています。
そして、このバッファーを介してデータのやり取りをすれば、「共有メモリを介して GPU と CPU を強調して動作させる」という Metal の基本的なコンセプトが実現できそうです。
実際、ここで出てきた buffer は、GPU サイドでは
vertex VertexOut vertex_main(constant float3 *vertices [[buffer(0)]],
uint id [[vertex_id]])
として利用されています。
・・・まあ、ここから後の実務的な手続きが、初学者殺しなんですが。
気を取り直して。
プログラムから Metal を用いて GPU を操作する際、実際に必要になってくるのは(抽象化された)GPU とそれに送るコマンド群ですが、ややこしいのは後者でしょう。
ソースコードでも抽象化された GPU (device)は
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("GPU is not supported")
}
で割と簡単に準備できています。
問題は後者。
後者に関しては「コマンド」の構成を考慮する必要があります。
具体的な「コマンド」の構成は以下のようになっています。
図はこちらのアップル公式ドキュメントから取ってきました。
ある程度慣れてくると「Blit もここで顔を出していたか。うまく使う方法はないかなー」みたいな感想を持つと思いますが、最初のうちは「Metal で何か描画する際には準備が必要」くらいに思っていいでしょう。
もうちょっと踏み込みたい人は、CPU 側で用意した buffer がシェーダーの関数の引数として
vertex_main(constant float3 *vertices [[buffer(0)]],
uint id [[vertex_id]])
と現れている、くらいのところまで意識しておくといいでしょう。
全体としては CPU と GPU を協調して動かす必要があるためでしょうか、曖昧さがほとんどないかなりかっちりとした構成になっています。
ソースコード上では Queue → Buffer → Encoder の順に生成しています。
サンプルでも(Depth Stencil を除けば)、すべて顔を出してますね。
(もうちょっと詳しい解説動画はこちらに上げておきます)
現時点では、例えばコマンドバッファーで利用できるメソッドにはどんなものがあるか?といった知識はないでしょうから、アバウトな理解でいいと思います。
参考記事
『1-2 Rasterize と 3DCG のサワリ』以前は限定公開にしていたんですが、評判いいようなので一般公開しました。
『1-3 Depth 処理』改稿中
『1-4 シェーダーへのデータの渡し方など』改稿中
>> Rasterize 処理