aptpod Advent Calendar 2019 18日目を担当させていただきます 上野 と申します。
昨年も ARKit2.0が凄い。あなたの見ている方向を記録、可視化するデモ という記事で参加させていただきまして、
今年もiOS系で記事を書かせていただこうと思います。iOSアプリエンジニアのみなさんよろしくお願いします。
さて、今回のフォーカスする内容ですが、、、
皆さん、、Metalって使ってる、、、、?
昨年の記事では ARKit
、 SceneKit
といったフレームワークを使用していますが、あれももちろん 、UIKit
などに含まれるビューコンテンツやアニメーション、イメージなどのほとんどは Metal をコアに作られています。
Metalは、Apple製品に搭載されたGPUへアクセスを提供するAPI で基本的にUIに関わる部分ほとんどに使われているようです。
今回はそのMetalにフォーカスし、Metalの実装コストを下げた MetalKit を利用してデモアプリを作りたいと思います。
※Metalの事前知識が欲しいと言う方はよくまとめられた記事がありましたので こちら をご覧ください。
今回作る物
実際「よし、Metal(だけ)で何か作ってみるか、、、」となった場合に皆さんはパッと何か思いつくでしょうか?
試しにAppleで サンプル を見てみましたが、
Creating and Sampling Textures
Using a Render Pipeline to Render Primitives
Reflections with Layer Selection
単純に画像を MetalView 上に描画する物だったり、
グラフィクスAPI開発の入門ではありがちですが単純に三角形の描画のサンプル、
何か凄い3Dのモデルを描画する方法だったり等ありますが、イマイチ何を作ろうか、、、と私はなりました。
いろいろ調べていく中で、MetalKit では実装方法さえ知っていれば Texture上にピクセル単位で簡単に色を塗ることができると分かりましたので、 タイトルにあるように簡易的な ペイントツール を作ろうと思います!今回は MTKView( MetalKitに含まれるView ) が Canvas となります。
描画までの流れ
- MTKViewのフレームサイズに応じて解像度(縦横のピクセル数)を決める。
- ピクセル数に応じた配列(テクスチャへ渡す用のバッファ)を作成。
- 画面がタッチされたらその座標と同等の配列のインデックスに選択されている色情報を挿入。
- 定期的に
MetalKit
からdraw
のリクエスト(MTKViewDelegate
のコールバック)が呼ばれるので、そのタイミングで用意したバッファと書き込む対象のテクスチャを渡す。 - シェーダにて描画する色情報を適切にテクスチャに渡す。
といった流れとなります。
実装開始 Metalをセットアップ
ではいつも通りの感じでプロジェクトを作ります。
MetalKit Viewを設置しましょう。
ViewController.swift
import UIKit import MetalKit class ViewController: UIViewController, MTKViewDelegate { override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. self.setupMetalView() } // MARK:- Metal View @IBOutlet weak var metalView: MTKView! var mDevice = MTLCreateSystemDefaultDevice() var mCommandQueue: MTLCommandQueue! var mComputePiplineState: MTLComputePipelineState! var metalViewDrawableSize: CGSize? = nil var targetMetalTextureSize: CGSize = CGSize.zero var bufferWidth: Int = -1 var mTextureBuffer: MTLBuffer? func setupMetalView() { guard let library = self.mDevice?.makeDefaultLibrary() else { return } // Register Texture Shader guard let kernel = library.makeFunction(name: "computeTexture2d") else { return } guard let computePipeline = try? self.mDevice?.makeComputePipelineState(function: kernel) else { return } self.mComputePiplineState = computePipeline self.metalView.device = self.mDevice self.metalView.delegate = self self.metalView.framebufferOnly = false // ← これがないとXcode11以降では落ちます self.mCommandQueue = self.mDevice?.makeCommandQueue() } //MARK:- MTKViewDelegate func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} func draw(in view: MTKView) {} }
一旦こんな感じでViewControllerを書いてみました。
IBOutlet
で定義している metalView
はViewControllerに設置したもので、この辺りで出てきている mDevice
だったり、 libary
、computePipeline
あたりはお作法のようなものなので 先ほどのリンク だったり こちら をご参照ください。
※かなり初歩的な所は深くは語りません。
ここで設定したシェーダの設定 computeTexture2d
は後ほど解説します。
基本的には MTKViewDelegateに含まれる draw
のコールバックにて GPUへ必要なテクスチャやバッファ情報を渡す事になります。
それでは、ここからは先ほど示した描画までの流れにそって進めていきます。
1.MTKViewのフレームサイズに応じて解像度(縦横のピクセル数)を決める
ViewController.swift
// MARK:- viewDidAppear override func viewDidAppear(_ animated: Bool) { self.updateMetalViewDrawableSize() } // MARK:- viewWillTransition override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { coordinator.animate(alongsideTransition: nil) { (_) in self.updateMetalViewDrawableSize() } }
オートレイアウトで指定したViewのフレームサイズが決まる(変わる)タイミングって大体この2つですよね、
このタイミングで設置した MTKView
の実際のフレームサイズからピクセル数を決めましょう。
ViewController.swift
let kMetalTextureHeightDotSize: Int = 512 ... func updateMetalViewDrawableSize() { // Viewの実際のサイズから縦横比を参考に高さのドットサイズから幅のドットサイズを求める var width = Int(ceil((self.metalView.frame.width/self.metalView.frame.height)*CGFloat(kMetalTextureHeightDotSize))) // // ここには後ほど書き換えがあります(1) // self.targetMetalTextureSize = CGSize(width: width, height: kMetalTextureHeightDotSize) self.log("drawableSize:\(self.metalView.drawableSize), frame:\(self.metalView.frame) => targetMetalTextureSize:\(self.targetMetalTextureSize) - MetalView") self.metalView.drawableSize = self.targetMetalTextureSize }
今回は高さのピクセル数を 512
に固定にし、そこから縦横比で求める事にしました。
ピクセル横 = フレーム幅 / フレーム高さ * ピクセル縦
そして、求めた解像度を MTKView
の drawableSize
に 描画するサイズとして指定してあげましょう。
ですがこのままでは不十分で、後ほど (1)
の内容を説明します。
2. ピクセル数に応じた配列(テクスチャへ渡す用のバッファ)を作成
ViewController.swift
//MARK:- MTKViewDelegate func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { self.log("drawableSizeWillChange \(view.drawableSize) => size:\(size), frame:\(self.metalView.frame), targetSize:\(self.targetMetalTextureSize) - MTKViewDelegate") guard !self.targetMetalTextureSize.equalTo(CGSize.zero) else { return } if self.metalViewDrawableSize != nil { guard !self.metalViewDrawableSize!.equalTo(self.targetMetalTextureSize) else { // 前回と同じ値だった場合は更新しない return } } self.metalViewDrawableSize = self.targetMetalTextureSize self.bufferWidth = Int(self.metalViewDrawableSize!.width) self.setupMetalBuffer() }
先ほど MTKView
に解像度を指定しましたがあちらをセットすると
MTKViewDelegate
内の drawableSizeWillChange
がコールされます。
このタイミングで、指定した解像度が MTKView
に反映されますので、バッファの作成を行いましょう。
ViewController.swift
let kMetalTextureClearColor: simd_float4 = [255/255.0, 255/255.0, 255/255.0, 1.0] ... func setupMetalBuffer() { guard let device = self.mDevice else { return } let colors = [simd_float4].init(repeating: kMetalTextureClearColor, count: self.bufferWidth*kMetalTextureHeightDotSize) let bufferLength = colors.count * MemoryLayout<simd_float4>.stride // <- ここ要チェック self.mTextureBuffer = device.makeBuffer(bytes: UnsafeRawPointer(colors), length: bufferLength, options: .cpuCacheModeWriteCombined) }
ここ要チェック
とコメントしている箇所に注目してください。配列の数でバッファサイズを求めている処理で、今回はあまり関係ないですが、Metalのバッファ管理では かなり重要
です。
こちら で詳しく解説されていますがMelta側のバッファサイズとCPU側のバッファサイズが違ってしまう要因になりますので理解は必須です。
初期値(何も書かれていない色情報(白))と縦横の長さで配列を宣言、MTLDevice
でバッファを作成します。
3. 画面がタッチされたらその座標と同等の配列のインデックスに選択されている色情報を挿入
ViewController.swift
// タッチイベントが開始された override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = event?.touches(for: self.metalView)?.first else { return } let point = touch.location(in: self.metalView) self.lastPoint = nil self.drawCanvas(point: point) } // タッチ位置が移動した override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = event?.touches(for: self.metalView)?.first else { return } let point = touch.location(in: self.metalView) self.drawCanvas(point: point) } // タッチが終了した override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = event?.touches(for: self.metalView)?.first else { return } let point = touch.location(in: self.metalView) self.drawCanvas(point: point) } // タッチがキャンセルされた override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
タッチイベントを取得します。
UIViewController
は標準で override
でタッチイベントを取得できて簡単ですね。
タッチされた位置がMetalView
に対するタッチイベントのみ処理しています。
ViewController.swift
func drawCanvas(point: CGPoint) { let x: Int = Int(ceil((point.x/self.metalView.frame.width)*CGFloat(self.bufferWidth))) let y: Int = Int(ceil((point.y/self.metalView.frame.height)*CGFloat(kMetalTextureHeightDotSize))) var color: simd_float4 = [ self.isRed ? 1.0 : 0.0, self.isGreen ? 1.0 : 0.0, self.isBlue ? 1.0 : 0.0, 1.0 ] let dataSize = MemoryLayout<simd_float4>.stride if let ptr = self.mTextureBuffer?.contents() { if 0 <= x, x < self.bufferWidth, 0 <= y, y < kMetalTextureHeightDotSize { let index = (x * kMetalTextureHeightDotSize) + y memcpy(ptr.advanced(by: index*dataSize), &color, dataSize) } } }
タッチされた座標を元に、用意したバッファに色情報を入れていきます。
isRed
、isGreen
、isBlue
という項目が出てきましたが色情報を変更できる様にフラグを宣言しています。
先ほど作成したMetal用のバッファの contents()
と言うファンクションでバッファの先頭のポインタ、アドレス情報を取得します。この処理はC言語っぽいですが memcpy
で色情報をアドレスを指定してバッファにコピーしましょう。
これでバッファの準備は完了です。
4. MetalKit から定期的に呼ばれるdrawのリクエスト(MTKViewDelegateのコールバック)のタイミングで、用意したバッファと書き込む対象のテクスチャを渡す
ViewController.swift
let kMetalThreadGroupCount: Int = 16 ... func draw(in view: MTKView) { guard let drawable = view.currentDrawable else { return } guard let commandBuffer = self.mCommandQueue.makeCommandBuffer() else { return } guard let textureBuffer = self.mTextureBuffer else { return } let computeEncoder = commandBuffer.makeComputeCommandEncoder() computeEncoder?.setComputePipelineState(self.mComputePiplineState) let texture = drawable.texture // 書き込む対象のテクスチャをセット computeEncoder?.setTexture(texture, index: 0) // GPUに渡すテクスチャ用バッファをセット computeEncoder?.setBuffer(textureBuffer, offset: 0, index: 1) let threadGroupCount = MTLSizeMake(kMetalThreadGroupCount, kMetalThreadGroupCount, 1) let threadGroups = MTLSizeMake(Int(self.targetMetalTextureSize.width) / threadGroupCount.width, Int(self.targetMetalTextureSize.height) / threadGroupCount.height, 1) computeEncoder?.dispatchThreadgroups(threadGroups, threadsPerThreadgroup: threadGroupCount) computeEncoder?.endEncoding() commandBuffer.present(drawable) commandBuffer.commit() commandBuffer.waitUntilCompleted() }
今回の肝となる処理ですね、先ほど用意したバッファをMetal側に渡します。
Metalに限らず GPUプログラミング
では一般的な処理フローを1つ説明します。
GPU
は CPU
とは違い基本的には 並列
で処理が実行されます。
上の図の例で言うと、処理したい関数 saxpy
を再起的に呼び出し、同じスレッド内で連続して処理しているのに対し
Cuda
の saxpy
は非同期に処理され cuda
内のメモリに計算結果を反映しています。
今回 kMetalThreadGroupCount
で指定している数値が並列処理が行われるスレッドの数になります。
commandBuffer( MTLCommandBuffer )
とcomputeEncoder( MTLComputeCommandEncoder )
を使って、
テクスチャとバッファをMetal( GPU側
)に渡してあげましょう。
5. シェーダにて描画する色情報を適切にテクスチャに渡す
シェーダのセットアップ
シェーダは 別ファイル
となります。拡張子は .metal
で言語は C++
ですね。
PaintShader.metal
#include <metal_stdlib> using namespace metal; /* * |--|--|--|--| * | 0| 4| 8|12| * | 1| 5| 9|13| * | 2| 6|10|14| * | 3| 7|11|15| * |--|--|--|--| */ kernel void computeTexture2d(texture2d<half, access::write> output [[texture(0)]], device float4 *color_buffer [[buffer(1)]], uint2 gid [[thread_position_in_grid]]) { int h = output.get_height(); int index = (gid.x * h) + gid.y; half r = color_buffer[index].x; half g = color_buffer[index].y; half b = color_buffer[index].z; half a = color_buffer[index].w; output.write(half4(r, g, b, a), gid); }
はい、ここでMetalのセットアップで出てきた computeTexture2d
が出てきましたね。
引数の [[texture(0)]]
や [[buffer(1)]]
の数値は先ほど computeEncoder
でセットした際に指定したインデックス
です。
gid [[thread_position_in_grid]]
は指定したスレッドの数によって並列化した際の現在のインデックス(配列のアドレスのような物)情報
が設定されています。
この gid
の位置が書き込むキャンバスのピクセル位置情報となります。
X座標とY座標情報でインデックス情報を算出し、渡したバッファから色情報を抽出します。
そして、先ほどのテクスチャに色情報を渡してあげれば MTKView上に絵が描画されます!(パチパチ)
ん?
謎の隙間がある?と思った方鋭いですね。
このままではキャンバス全体に対して色を塗りつぶすことができない場合があります。(※できる場合もある)
ViewController.swift
func updateMetalViewDrawableSize() { // Viewの実際のフレームサイズから縦横比を参考に高さのドットサイズから幅のドットサイズを求める var width = Int(ceil((self.metalView.frame.width/self.metalView.frame.height)*CGFloat(kMetalTextureHeightDotSize))) // 書き換えた内容(1) ////// // 指定するThreadGroupCountで割り切れなければならない為調整をする let v: Int = width % kMetalThreadGroupCount if v > 0 { width -= v } // 書き換えた内容(1) /////// self.targetMetalTextureSize = CGSize(width: width, height: kMetalTextureHeightDotSize) self.log("drawableSize:\(self.metalView.drawableSize), frame:\(self.metalView.frame) => targetMetalTextureSize:\(self.targetMetalTextureSize) - MetalView") self.metalView.drawableSize = self.targetMetalTextureSize
そこで先ほど 1.
で 後ほど書き換えがあります(1)
と記述した内容になります。
kMetalThreadGroupCount
で割った場合の余りを幅から引くことで改善できます。
つまり、GPU側のスレッド数で割り切れない物は描画しきれない
という注意すべきポイントがあります。
Appleのドキュメント にて ThreadGroup
とGirdSize
についての記述はありますが、今回はなるべくバッファは余分に取らずあまりを出さないようにした方が管理が楽だったのでこの方法を取っています。
+αその1、これでは線が引けないので引ける様にする
このままではタッチイベントが発生したタイミングでキャンバスに色を1点塗るだけ似合ってしまうので線を引けるように対応します。
ViewController.swift
// 2点間の線を引く為のポイント一覧を取得する // 参考(プレゼンハムのアルゴリズム): https://ja.wikipedia.org/wiki/%E3%83%96%E3%83%AC%E3%82%BC%E3%83%B3%E3%83%8F%E3%83%A0%E3%81%AE%E3%82%A2%E3%83%AB%E3%82%B4%E3%83%AA%E3%82%BA%E3%83%A0 func getLinePoints(p0: CGPoint, p1: CGPoint) -> [CGPoint] { var points = [CGPoint]() var x0: Int = Int(p0.x) var y0: Int = Int(p0.y) let x1: Int = Int(p1.x) let y1: Int = Int(p1.y) let dx: Int = Int(abs(p1.x - p0.x)) // DeltaX let dy: Int = Int(abs(p1.y - p0.y)) // DeltaY let sx: Int = (p1.x>p0.x) ? 1 : -1 // StepX let sy: Int = (p1.y>p0.y) ? 1 : -1 // StepT var err = dx - dy while true { if x0 >= 0, y0 >= 0 { points.append(CGPoint(x: x0, y: y0)) } if x0 == x1, y0 == y1 { break } let e2 = 2*err if e2 > -dy { err -= dy x0 += sx } if e2 < dx { err += dx y0 += sy } } return points }
2点間の線を引くアルゴリズムは プレゼンハム のアルゴリズム
が有名ですね、
ウィキペディア にあった内容を利用させていだだきました。
ブレゼンハムのアルゴリズム(Bresenham's line algorithm)は、 与えられた始点と終点の間に連続した点を置き、近似的な直線を引くためのアルゴリズム。 ブレゼンハムの線分描画アルゴリズム、ブレゼンハムアルゴリズムとも。 コンピュータのディスプレイに直線を描画するのによく使われ、 整数の加減算とビットシフトのみで実装できるので多くのコンピュータで使用可能である。 コンピュータグラフィックスの分野の最初期のアルゴリズムの1つである。これを若干拡張すると、円を描くことができる
これで簡単なメモ書きぐらいには使えそうですね。
+αその2、描ける線の太さを変えられるようにする
ペイントツールといえば線の太さを変えれますよね、なのでそちらを再現したいと思います。
先ほどのウィキペディアの記述にあったように プレゼンハムのアルゴリズム
を利用すれば円を描くこともできます。
こちら にあったプログラムを参考にさせていただきました。
参考にしたものから円の中を塗りつぶせるように改良しましたが特にパフォーマンスは意識していないのでそちらはご容赦を。
ViewController.swift
// 中心点と半径から縁を描く為のポイント一覧を取得する // 参考(ブレゼンハム円描画のアルゴリズム): http://dencha.ojaru.jp/programs_07/pg_graphic_09a1.html func getCircleFillPoints(center: CGPoint, radius: Int) -> [CGPoint] { var points = [CGPoint]() let centerX: Int = Int(center.x) let centerY: Int = Int(center.y) var cx: Int = 0 var cy: Int = radius var d: Int = 2 - 2 * radius // Left Top var ltx: Int = 0 var lty: Int = 0 // Right Top var rtx: Int = 0 var rty: Int = 0 // Left Bottom var lbx: Int = 0 var lby: Int = 0 // Right Bottom var rbx: Int = 0 var rby: Int = 0 // Top(0, R) var vx: Int = cx + centerX var vy: Int = cy + centerY if vx >= 0, vy >= 0 { points.append(CGPoint(x: vx, y: vy)) } // Bottom(0, -R) vx = cx + centerX vy = -cy + centerY if vx >= 0, vy >= 0 { points.append(CGPoint(x: vx, y: vy)) } // Right(R, 0) vx = cy + centerX vy = cx + centerY if vx >= 0, vy >= 0 { points.append(CGPoint(x: vx, y: vy)) } // Left(-R, 0) vx = -cy + centerX vy = cx + centerY if vx >= 0, vy >= 0 { points.append(CGPoint(x: vx, y: vy)) } while true { if d > -cy { cy -= 1 d += 1 - 2 * cy } if d <= cx { cx += 1 d += 1 + 2 * cx } guard cy > 0 else { break } // Right Bottom (Bottom To Right) rbx = cx + centerX rby = cy + centerY if rbx >= 0, rby >= 0 { points.append(CGPoint(x: rbx, y: rby)) // 0 ~ 90 } // Left Bottom (Bottom To Left) lbx = -cx + centerX lby = cy + centerY if lbx >= 0, lby >= 0 { points.append(CGPoint(x: lbx, y: lby)) // 90 ~ 180 } // Left Top (Top To Left) ltx = -cx + centerX lty = -cy + centerY if ltx >= 0, lty >= 0 { points.append(CGPoint(x: ltx, y: lty))// 180 ~ 270 } // Right Top (Top To Right) rtx = cx + centerX rty = -cy + centerY if rtx >= 0, rty >= 0 { points.append(CGPoint(x: rtx, y: rty)) // 270 ~ 360 } // 上半分は上部分から左右に、下半分はした部分から左右に伸びている //print("[\(ltx), \(lty)], [\(rtx), \(rty)], [\(lbx), \(lby)], [\(rbx), \(rby)]") // Y軸は左右同じ地点を指している事から上版分、下半分でX軸の左端から右端にポイントを追加する事で円を塗り潰します。 for i in lbx...rbx { if i >= 0, rby >= 0 { points.append(CGPoint(x: i, y: rby)) } } for i in ltx...rtx { if i >= 0, lty >= 0 { points.append(CGPoint(x: i, y: lty)) } } } // 中心線 for i in centerX-radius...centerX+radius { if i >= 0, centerY >= 0 { points.append(CGPoint(x: i, y: centerY)) } } return points }
円の描画は左右の端から上もしくは下方向にループで描画していくので、左端から右端にループを伸ばして中の色情報を挿入しています。
バッファに色情報を入れる関数の全容
ViewController.swift
func drawCanvas(point: CGPoint) { let x: Int = Int(ceil((point.x/self.metalView.frame.width)*CGFloat(self.bufferWidth))) let y: Int = Int(ceil((point.y/self.metalView.frame.height)*CGFloat(kMetalTextureHeightDotSize))) var color: simd_float4 = [ self.isRed ? 1.0 : 0.0, self.isGreen ? 1.0 : 0.0, self.isBlue ? 1.0 : 0.0, 1.0 ] let dataSize = MemoryLayout<simd_float4>.stride if let ptr = self.mTextureBuffer?.contents() { if 0 <= x, x < self.bufferWidth, 0 <= y, y < kMetalTextureHeightDotSize { let index = (x * kMetalTextureHeightDotSize) + y self.log("draw color[[\(x), \(y)] => \(index)]: \(color)") memcpy(ptr.advanced(by: index*dataSize), &color, dataSize) } } // 現在地点と前回地点の間に線を入れます var linePoints: [CGPoint] = [CGPoint]() let newPoint = CGPoint.init(x: x, y: y) defer { self.lastPoint = newPoint } // 前回値を保持しておく if let last = self.lastPoint, !last.equalTo(newPoint) { // 2点間の線を引く為のポイント一覧を取得し追加する linePoints.append(contentsOf: self.getLinePoints(p0: last, p1: newPoint)) } else { // ※同じ点を描画する事になるのであまり処理効率は良く無いですが説明上描画した値を入れます。 linePoints.append(newPoint) } if self.pointSize > 1 { // 円描画 var cirlePoints: [CGPoint] = [CGPoint]() for p in linePoints { cirlePoints.append(contentsOf: self.getCircleFillPoints(center: p, radius: self.pointSize/2)) } linePoints.append(contentsOf: cirlePoints) } if linePoints.count > 0, let ptr = self.mTextureBuffer?.contents() { for p in linePoints { let x = Int(p.x) let y = Int(p.y) guard x < self.bufferWidth, y < kMetalTextureHeightDotSize else { continue } let index = (x * kMetalTextureHeightDotSize) + y memcpy(ptr.advanced(by: index*dataSize), &color, dataSize) } } }
できたもの
Metalで簡単なペイントツールを作ってみました #iOS #Metal #MetalKit #Texture #2D #Demo pic.twitter.com/yGmzw647Up
— aptueno (@aptueno) December 16, 2019
まとめ
MetalKitをふんだんに使ったアプリケーションって結構実装コストと学習コストも相まってなかなか使えないことが多い気がします。
この記事をみてMetalKitを使ったアプリケーション、機能の案が浮かんだ方、ラッキーでしたね。
正直私はハイパフォーマンスで絵を描ければなんでもできる気がしているので少しでも参考になったら嬉しいです。
今回のデモは前回同様githubで公開しますのでよかったら動かしたり、ご覧ください。
少しボリューミーな記事になったかと思いますがご覧いただきありがとうございました。