4
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?

記事投稿キャンペーン 「2024年!初アウトプットをしよう」

Metalシェーダー入門:50行で三角形を描画してみよう

Last updated at Posted at 2024-01-12

Metalシェーダー開発

  • とにかく綺麗なグラフィックを描画してみたい!
  • 数百万単位の大量のパーティクルを描画したい!
  • 3Dを描画してみたい!
  • オリジナルの画像処理プログラムを書きたい!
  • 機械学習モデルをGPUを使って自前で実装してみたい!
  • 物理シミュレーションを実装してみたい!

そんな方はぜひMetalでシェーダーを書いてみましょう!
ただ、今回の記事は入門編ということでめちゃめちゃ簡単なところからやっていきます。

今回つくるもの

image.png

シェーダーの基礎、三角形を描画していきます。

macOSアプリとして実装していくので、Swiftでのアプリ開発経験があるとこの記事は読みやすいと思います。
コードはiOSの実機でも動きます。(シミュレーターでは動きません)
ちなみに完成コードはたったの50行!

準備

プロジェクトの作成

Xcodeを開いて、Create New Project...をクリックします。

image.png

macOSのAppを選択してNext

image.png

ProductName、Organization Identifierを適当にいれて、InterfaceはSwiftUI、LanguageはSwiftを選択してNext

image.png

適当な保存先を選択してCreate

image.png

こんな画面がでてきたらOKです

image.png

パッケージの追加

この記事ではMetalシェーダー入門ということで、0からすべてを書くわけではなくEasyMetalShaderという、Metalシェーダーを書く上でめんどくさい部分をすべて裏でやってくれるライブラリを使います。

File > Add Package Dependencies...

image.png

右上の検索バーに下のリンクをコピペして検索
https://github.com/yukiny0811/EasyMetalShader
EasyMetalShaderというライブラリがでてきたら、Dependency Ruleのところを"Up to Next Minor Version 3.0.0" < にした上で Add Packageをクリックしましょう。

image.png

こんな感じででてきたらAdd Packageで

image.png

この状態で一度実行してみると、このようなエラーがでてくると思います。
EasyMetalShaderが信頼できるライブラリかどうかを確認してね、というエラーです。
このライブラリは僕が作っているので大丈夫ですが、気になる方はEasyMetalShaderの元実装を見に行ってください。
このエラーのところをクリックしましょう。

image.png

こんな画面がでてくるので、Trust & Enableで。

image.png

最後に、Previewは今回使わないので消しましょう。

image.png

これで準備OKです!

レンダラーの作成

これからシェーダーを書いていく前に、その書いたシェーダーを画面上に描画するためのレンダラーを作ります。

まず、ライブラリをimportします。

ContentView.swift
import SwiftUI
import EasyMetalShader //ここ

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

レンダラークラスを作ります。
このクラスがアニメーションのループなどをすべて裏で管理してくれます。

ContentView.swift
class MyRenderer: ShaderRenderer {
    override func draw(view: MTKView, drawable: CAMetalDrawable) {
        //この中身が毎フレーム実行される
    }
}

SwiftUIのViewとして表示できるようにします。
ContentViewを書き換えてください。

ContentView.swift
struct ContentView: View {
    let renderer = MyRenderer()
    var body: some View {
        EasyShaderView(renderer: renderer)
    }
}

これで一度実行してみましょう!

image.png

こんなかんじの何もない画面が出ればいったんOKです!

シェーダーの作成

ついにシェーダー本体を書いていきます!

シェーダーを定義するクラスの作成

下のコードのように、MyShaderという名前のクラスをつくりましょう!
@EMRenderShaderをつけることによって、面倒なシェーダーの設定をすべて裏でやってくれるようになります。

ContentView.swift
@EMRenderShader
class MyShader {

    // ここにvertexシェーダーを書く
    var vertImpl: String {
        ""
    }

    // ここにfragmentシェーダーを書く
    var fragImpl: String {
        ""
    }

    // ここは今は使わない
    var customMetalCode: String {
        ""
    }
}

シェーダーパイプラインの作成

MyRendererクラスを編集しましょう。
シェーダーパイプラインについてはいつか話すので、ひとまずコピペしてみましょう。
解説はコード中にコメントとして書いておきます。

ContentView.swift
class MyRenderer: ShaderRenderer {

    let myShader = MyShader(targetPixelFormat: .bgra8Unorm)
    
    override func draw(view: MTKView, drawable: CAMetalDrawable) {

        // コマンドバッファの作成
        let dispatch = EMMetalDispatch()

        // RenderCommandEncoderの作成
        // renderTargetTextureは描画先のテクスチャ。今回はViewの画面。
        // needsClearは今回はtrueでもfalseでもどっちでも大丈夫です。
        dispatch.render(renderTargetTexture: drawable.texture, needsClear: true) { [self] encoder in
        
            // シェーダーを使って実際に描画を予約します。
            // 画面上(drawable.texture) に対して、verticesで渡した頂点データをもとに、三角形(.triangle)を描画します。
            myShader.dispatch(encoder, textureSizeReference: drawable.texture, primitiveType: .triangle, vertices: [])
        }

        // 画面への描画を予約する
        dispatch.present(drawable: drawable)

        // コマンドバッファの送信
        dispatch.commit()
    }
}

パイプラインはこれで完成です!
コードはこんな感じになっているはずです。

image.png

シェーダーの基礎知識

コードの続きを書いていく前に、今回必要なシェーダーについての基礎知識を軽く解説します。
シェーダーにはいろいろと種類があるのですが、今回使うのはvertex shaderと呼ばれるものとfragment shaderと呼ばれるものです。
vertex shaderは、CPUから受け取った図形の頂点データを加工し、fragment shaderに渡す役割を担っています。
vertex shaderからfragment shaderに値が渡される際、頂点データがピクセルのデータに変換されます。(ここらへんは雑に説明してます)
fragment shaderは、この変換された後のピクセルごとに処理を行い、各ピクセルの色を決めるシェーダーです。

難しいと思うので、いったん書いてみましょう。

頂点データを作成する

今回は2Dの平面上に三角形を書いていきますが、そのためにはMetalの画面の座標系を知っておく必要があります。
Metalではデフォルトでこのような座標系になっています。
画面の中心を(0, 0)として、画面右方向にx軸、画面上方向にy軸が伸びている感じです。

image.png

このことを踏まえて、記事の最初にも載せたこの三角形を見てみましょう。

image.png

座標はこのようになります。

  • 左下:(-1, -1)
  • 上:(0, 1)
  • 右下:(1, -1)

image.png

これをもとにコードを書いていきます。
EasyMetalShaderでは、頂点データはVertexInputという型にしてシェーダーにわたす必要があります。
下のコードのようにMyRendererのdraw()の中に書いてみましょう。

ContentView.swift
class MyRenderer: ShaderRenderer {
    
    let myShader = MyShader(targetPixelFormat: .bgra8Unorm)
    
    override func draw(view: MTKView, drawable: CAMetalDrawable) {
        
        // vvvvvvv 追加
        var vertex1 = VertexInput()
        vertex1.input0 = simd_float4(-1, -1, 0, 1) //左下
        
        var vertex2 = VertexInput()
        vertex2.input0 = simd_float4(0, 1, 0, 1) //上
        
        var vertex3 = VertexInput()
        vertex3.input0 = simd_float4(1, -1, 0, 1) //右下
        // ^^^^^^^
        
        let dispatch = EMMetalDispatch()
        dispatch.render(renderTargetTexture: drawable.texture, needsClear: true) { [self] encoder in
            myShader.dispatch(encoder, textureSizeReference: drawable.texture, primitiveType: .triangle, vertices: [])
        }
        dispatch.present(drawable: drawable)
        dispatch.commit()
    }
}

上のコードのように、VertexInput型にはinput0~input9まで、それぞれsimd_float4型の値を入れることができます。
simd_float4型は、Floatが4つ入る型という風に考えてもらえれば大丈夫です。
simd_float4(-1, -1, 0, 1)とすると、x座標が-1、y座標が-1、z座標が0として頂点データを作成することができます。
4つめの1はいったん気にしないでおきましょう。いつか記事で紹介したいと思います。

次に、作った3つの頂点を、シェーダーに渡します。
コードが横に長くなってしまったので、少し見やすいように整形しています。

ContentView.swift
class MyRenderer: ShaderRenderer {
    
    let myShader = MyShader(targetPixelFormat: .bgra8Unorm)
    
    override func draw(view: MTKView, drawable: CAMetalDrawable) {
        
        var vertex1 = VertexInput()
        vertex1.input0 = simd_float4(-1, -1, 0, 1)
        
        var vertex2 = VertexInput()
        vertex2.input0 = simd_float4(0, 1, 0, 1)
        
        var vertex3 = VertexInput()
        vertex3.input0 = simd_float4(1, -1, 0, 1)
        
        let dispatch = EMMetalDispatch()
        dispatch.render(renderTargetTexture: drawable.texture, needsClear: true) { [self] encoder in
            myShader.dispatch(
                encoder,
                textureSizeReference: drawable.texture,
                primitiveType: .triangle,
                vertices: [vertex1, vertex2, vertex3] //ここ編集!
            )
        }
        dispatch.present(drawable: drawable)
        dispatch.commit()
    }
}

vertex shaderを書く

vertex shaderを書いていきます!
長い道のりでしたが、もう少しです!

EasyMetalShaderでは、Metalシェーダーのコードは文字列として書いていきます。
MyShaderクラスのvertImplの中身を編集しましょう。

ContentView.swift
@EMRenderShader
class MyShader {
    
    var vertImpl: String {
        "rd.position = vertexInput.input0;" //ここ!
    }
    
    var fragImpl: String {
        ""
    }
    
    var customMetalCode: String {
        ""
    }
}

EasyMetalShaderのvertex shaderには、rdという変数が事前に定義されています。
このrdという変数に設定した値がfragment shaderに渡されるので、vertex shaderではこのrdのパラメーターを設定していくことが主目的です。

今回はrd.positionという値に頂点座標を設定しています。
rd.positionに代入しているvertexInput.input0という変数も、EasyMetalShaderで事前に定義されている変数です。この中には、先程書いたVertexInputの中の値が入っています。

先程はVertexInput型のinput0というパラメーターに頂点座標を設定したので、vertex shaderの中でもvertexInput.input0という変数の中に頂点データがそのまま入っています。

vertex shaderで三角形(.triangle)を描画するときは、CPUから送られてきた頂点の数だけこのvertex shaderが呼ばれます。
それぞれの頂点に対して1回このvertex shaderのプログラムが実行されます。つまり今回は3回呼ばれています。

fragment shaderを書く

今回は三角形を描画するように設定しているので、vertex shaderから送られてきた3つの頂点データをもとに三角形が形成されます。
fragment shaderでは、この3つの頂点で囲われているすべてのピクセル1つ1つに対して行っていく処理を書きます。
つまり、1つの3角形を描画するたびにこのfragment shaderは数十万回呼ばれます。

下の画像はイメージ図です。

image.png

いったん単色で塗りつぶしてみましょう。
MyShaderクラスをこのように編集してみてください。

ContentView.swift
@EMRenderShader
class MyShader {
    
    var vertImpl: String {
        "rd.position = vertexInput.input0;"
    }
    
    var fragImpl: String {
        "return float4(1, 0, 0, 1);" //ここ!
    }
    
    var customMetalCode: String {
        ""
    }
}

fragment shaderでは、色のデータをreturnすることが主目的です。
EasyMetalShaderでは、色のRGBAに対応するfloat4型の値を返す必要があります。

今回はreturn float4(1, 0, 0, 1)なので、赤色になるようなコードを書いてみます。

ここまで書けたら実行してみましょう!

image.png

画面に三角形が描画されました!

グラデーションを書いていく

ここまでできたら、次は記事最初の画像のようなグラデーションを書いてみましょう。

頂点データを書き換える

MyRendererクラスのdraw()の中で定義した、頂点データを編集します。
今までは座標のデータしかいれていなかったので、ここに色のデータを追加しようと思います。

.swift
// MyRendererクラス > drawメソッドの中の一部

var vertex1 = VertexInput()
vertex1.input0 = simd_float4(-1, -1, 0, 1)
vertex1.input1 = simd_float4(1, 0, 0, 1) // ここ追加!赤色

var vertex2 = VertexInput()
vertex2.input0 = simd_float4(0, 1, 0, 1)
vertex2.input1 = simd_float4(0, 1, 0, 1) // ここ追加!緑色

var vertex3 = VertexInput()
vertex3.input0 = simd_float4(1, -1, 0, 1)
vertex3.input1 = simd_float4(0, 0, 1, 1) // ここ追加!青色

このように、それぞれの頂点に別々の色を入れてみましょう。
input1に入れてあげます。

vertexシェーダーとfragmentシェーダーを書き換える

ContentView.swift
@EMRenderShader
class MyShader {
    
    var vertImpl: String {
        "rd.position = vertexInput.input0;"
        "rd.color = vertexInput.input1;" // ここ追加!
    }
    
    var fragImpl: String {
        "return rd.color;" // ここ編集!
    }
    
    var customMetalCode: String {
        ""
    }
}

vertex shaderで、rdcolorというプロパティに頂点データのinput1の値を代入します。

fragment shaderでは、この送られてきたrdcolorプロパティをそのままreturnしてあげます。

それでは実行してみましょう!

image.png

完成です!

このように、vertex shaderで代入した値は、fragment shaderに届くときには基本的にすべて勝手にグラデーションになるように線形補完されます。

さいごに

今回はシェーダーの基本となる三角形を色付きで描画してみました。
vertexシェーダーとfragmentシェーダーの役割をなんとなく理解していただけたかと思います。

ちなみに、シェーダーで何か作品を作るとなると、作業時間の9割はシェーダーパイプラインの作成に時間を割かれます。
EasyMetalShaderライブラリはこの時間を大幅に短縮してくれるライブラリですので、よければスターをつけていただけると励みになります!

完成コード

たったの50行!

ContentView.swift
import SwiftUI
import EasyMetalShader

struct ContentView: View {
    let renderer = MyRenderer()
    var body: some View {
        EasyShaderView(renderer: renderer)
    }
}

class MyRenderer: ShaderRenderer {
    
    let myShader = MyShader(targetPixelFormat: .bgra8Unorm)
    
    override func draw(view: MTKView, drawable: CAMetalDrawable) {
        
        var vertex1 = VertexInput()
        vertex1.input0 = simd_float4(-1, -1, 0, 1)
        vertex1.input1 = simd_float4(1, 0, 0, 1)
        
        var vertex2 = VertexInput()
        vertex2.input0 = simd_float4(0, 1, 0, 1)
        vertex2.input1 = simd_float4(0, 1, 0, 1)
        
        var vertex3 = VertexInput()
        vertex3.input0 = simd_float4(1, -1, 0, 1)
        vertex3.input1 = simd_float4(0, 0, 1, 1)
        
        let dispatch = EMMetalDispatch()
        dispatch.render(renderTargetTexture: drawable.texture, needsClear: true) { [self] encoder in
            myShader.dispatch(encoder, textureSizeReference: drawable.texture, primitiveType: .triangle, vertices: [vertex1, vertex2, vertex3])
        }
        dispatch.present(drawable: drawable)
        dispatch.commit()
    }
}

@EMRenderShader
class MyShader {
    var vertImpl: String {
        "rd.position = vertexInput.input0;"
        "rd.color = vertexInput.input1;"
    }
    var fragImpl: String {
        "return rd.color;"
    }
    var customMetalCode: String {
        ""
    }
}

今後書くかもしれないMetal入門記事

  • Compute Shaderで発光している感じをアニメーションしてみよう
  • 大量のパーティクルを描画してみよう
  • 3D空間に描画してみよう
  • 四角形を書いてみよう
  • 3Dモデルを描画してみよう
  • 3Dモデルにテクスチャを貼って描画してみよう
  • Compute Shaderで画像処理プログラムを書いてみよう
  • インタラクションできるシェーダーを書いてみよう
  • Stable Fluidをシェーダーで実装してみよう

参考

4
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
4
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?