aptpod Tech Blog

株式会社アプトポッドのテクノロジーブログです

Metal使ってる?iPhoneのGPUでペイントツールを作ってみる

f:id:apt-k-ueno:20200107190602j:plain aptpod Advent Calendar 2019 18日目を担当させていただきます 上野 と申します。
昨年も ARKit2.0が凄い。あなたの見ている方向を記録、可視化するデモ という記事で参加させていただきまして、
今年もiOS系で記事を書かせていただこうと思います。iOSアプリエンジニアのみなさんよろしくお願いします。

さて、今回のフォーカスする内容ですが、、、

皆さん、、Metalって使ってる、、、、?

昨年の記事では ARKitSceneKit といったフレームワークを使用していますが、あれももちろん 、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

f:id:aptpod_tech-writer:20191217134130p:plainf:id:aptpod_tech-writer:20191217134541p:plainf:id:aptpod_tech-writer:20191217134518p:plain

単純に画像を MetalView 上に描画する物だったり、 グラフィクスAPI開発の入門ではありがちですが単純に三角形の描画のサンプル、 何か凄い3Dのモデルを描画する方法だったり等ありますが、イマイチ何を作ろうか、、、と私はなりました。

いろいろ調べていく中で、MetalKit では実装方法さえ知っていれば Texture上にピクセル単位で簡単に色を塗ることができると分かりましたので、 タイトルにあるように簡易的な ペイントツール を作ろうと思います!今回は MTKView( MetalKitに含まれるView )Canvas となります。

描画までの流れ

  1. MTKViewのフレームサイズに応じて解像度(縦横のピクセル数)を決める。
  2. ピクセル数に応じた配列(テクスチャへ渡す用のバッファ)を作成。
  3. 画面がタッチされたらその座標と同等の配列のインデックスに選択されている色情報を挿入。
  4. 定期的に MetalKit からdrawのリクエスト(MTKViewDelegateのコールバック)が呼ばれるので、そのタイミングで用意したバッファと書き込む対象のテクスチャを渡す。
  5. シェーダにて描画する色情報を適切にテクスチャに渡す。

といった流れとなります。

実装開始 Metalをセットアップ

ではいつも通りの感じでプロジェクトを作ります。

f:id:aptpod_tech-writer:20191217134710p:plain

MetalKit Viewを設置しましょう。 f:id:aptpod_tech-writer:20191217150626p:plain

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 だったり、 libarycomputePipeline あたりはお作法のようなものなので 先ほどのリンク だったり こちら をご参照ください。
※かなり初歩的な所は深くは語りません。 ここで設定したシェーダの設定 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 に固定にし、そこから縦横比で求める事にしました。

ピクセル横 = フレーム幅 / フレーム高さ * ピクセル縦

そして、求めた解像度を MTKViewdrawableSize に 描画するサイズとして指定してあげましょう。

ですがこのままでは不十分で、後ほど (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)
            }
        }
    }

タッチされた座標を元に、用意したバッファに色情報を入れていきます。 isRedisGreenisBlueという項目が出てきましたが色情報を変更できる様にフラグを宣言しています。
先ほど作成した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つ説明します。

f:id:aptpod_tech-writer:20191217150759p:plain 引用元(※NVIDIAブログ)

GPUCPU とは違い基本的には 並列 で処理が実行されます。
上の図の例で言うと、処理したい関数 saxpy を再起的に呼び出し、同じスレッド内で連続して処理しているのに対し Cudasaxpy は非同期に処理され cuda 内のメモリに計算結果を反映しています。
今回 kMetalThreadGroupCount で指定している数値が並列処理が行われるスレッドの数になります。
commandBuffer( MTLCommandBuffer )computeEncoder( MTLComputeCommandEncoder )を使って、 テクスチャとバッファをMetal( GPU側 )に渡してあげましょう。

5. シェーダにて描画する色情報を適切にテクスチャに渡す

シェーダのセットアップ

f:id:aptpod_tech-writer:20191217150834p:plain

シェーダは 別ファイル となります。拡張子は .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座標情報でインデックス情報を算出し、渡したバッファから色情報を抽出します。

f:id:aptpod_tech-writer:20191217150917p:plain

そして、先ほどのテクスチャに色情報を渡してあげれば MTKView上に絵が描画されます!(パチパチ)

f:id:aptpod_tech-writer:20191217150949p:plain

ん?

謎の隙間がある?と思った方鋭いですね。
このままではキャンバス全体に対して色を塗りつぶすことができない場合があります。(※できる場合もある)

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のドキュメント にて ThreadGroupGirdSize についての記述はありますが、今回はなるべくバッファは余分に取らずあまりを出さないようにした方が管理が楽だったのでこの方法を取っています。

f:id:aptpod_tech-writer:20191217151115p:plain

+αその1、これでは線が引けないので引ける様にする

f:id:aptpod_tech-writer:20191217151027p:plain

このままではタッチイベントが発生したタイミングでキャンバスに色を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つである。これを若干拡張すると、円を描くことができる

f:id:aptpod_tech-writer:20191217151353p:plain

これで簡単なメモ書きぐらいには使えそうですね。

+αその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)
            }
        }
    }

できたもの

まとめ

MetalKitをふんだんに使ったアプリケーションって結構実装コストと学習コストも相まってなかなか使えないことが多い気がします。 この記事をみてMetalKitを使ったアプリケーション、機能の案が浮かんだ方、ラッキーでしたね。
正直私はハイパフォーマンスで絵を描ければなんでもできる気がしているので少しでも参考になったら嬉しいです。

今回のデモは前回同様githubで公開しますのでよかったら動かしたり、ご覧ください。
少しボリューミーな記事になったかと思いますがご覧いただきありがとうございました。

・iOS-MetalPaintDemo