Metal 入門 1-2 Rasterize と 3DCG のサワリ

<< 三角形描画

以前のバージョンは、よくある三角形描画(ラスタライズ含)のコードを(前項と同様)playground で実行して。。。というものだったんですが、それだけではさすがに発展性も何もなかろうと稿を改めた(改めている)。

また、この改変を機に使用言語を Objective-C (Obj-C)メインとした。
というのは、アップルの公式サンプルコードの大半が Obj-C で書かれてるから。
世間の解説サイトにあるコードの大半がこのサンプルを Swift で置き換えたか若干のアレンジを加えただけのもので、そんな手間のかかることをするくらいなら、公式サイトの出典を明示して解説を加えていった方が利用者の利便が高まると考えた。

新しいバージョンでも、オリジナル同様、三角形の描画を取り扱うが、当然、変化を加える。

なお、オリジナルの版も末尾に掲載しておく。

公式サンプル

ここで取り上げるコードはいわゆる Hello Triangle というやつで、以下の図のように

view 上に三角形を描画するものだ。

公式のページの解説もまあまあ充実している。

この手のサンプルは、よく「つまらない」と言われるのだが、このサンプルのキモは「頂点データを与えたとき、頂点を結ぶ平面を描画する」ということなので、当然、これはポリゴンによる 3DCG の基本となる。

このサンプルを単に「基本図形を描画する」、「ラスタライズの見本」程度に捉えていたのでは、それは「つまらない」ままだろう。

可能性を探る?ために、まずは簡単な改変。

三角形を「2つ」描画する

このサンプルをちょっぴり改変することで、三角形を「2つ」描画することができる。

例えば、こんな感じ。

改変すべき箇所は以下の通り。

まず、頂点データを三角形2つ分、都合6つ与えたいので、AAPLrenderer.m 内の drawInMTKView メソッドの triangleVertices[] を以下のように変える。

    static const AAPLVertex triangleVertices[] =
    {
        // 3D positions,    RGBA colors
        { {    0,  -300, 1 }, { 1, 0, 0, 1 } },
        { { -300,  -300, 1 }, { 0, 1, 0, 1 } },
        { {    0,   300, 1 }, { 0, 0, 1, 1 } },
        { {    0,   300, 1 }, { 1, 0, 0, 1 } },
        { {  300,   300, 1 }, { 0, 1, 0, 1 } },
        { {    0,   -300, 1 }, { 0, 0, 1, 1 } },
    };

 

2D postion が 3D position になっているが、面倒なら 2d データのまま、頂点を3つ加えればいい。
結果的に三角形を描画する回数が増えるので、同じく drawInMTKView メソッド内の以下の箇所を下記のように変更。

        // Draw the triangle. vertxCount =6 not 3
        [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                          vertexStart:0
                          vertexCount:6];
        /* original
        [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                          vertexStart:3
                          vertexCount:3];
         */

2d position のままの場合、実はこれだけの改変で三角形を「2つ」描画できる。
これは頂点の総個数が 6 とわかっているからできる改変で、プログラミングのお作法的には全く薦められない。
が、漫然とコードを眺めているよりははるかにマシでしょう。

「三角形の描画」からは外れてしまうが、このような場合は本来は TriangleStrip (MTLPrimitiveTypeTriangleStrip)を指定した方が効率がいい(頂点データの重複を避けられる)。

あくまでサンプルベースの改変だということをお忘れなく。

3次元処理

ところで、頂点をわざわざ3次元で与えたのには理由がある。
3次元処理をさせたいからだ。

オリジナルのコードでは、ビューポートに関するある種のパラメータ(具体的には nearZ, farZ というものだが)を固定値で設定しているため、例えば、遠近をつけるといった処理をするにはこのままの構成では無理がある。
が、その手前くらいまではいけるはずだ。

頂点データを3次元で与えたのだから、それらのデータを受け取る GPU (及び GPU を制御するコード)にそのことを教えてやり適切に処理させれば、データを3次元的に処理できる、というシナリオです。

一般的に 3DCG では、この辺の処理は、シェーダーが担当しており、Metal では主に ***.metal ファイルにその処理の手順を記述する。

元のサンプルでは頂点データの型は AAPLShaderTypes.h で定義されている。
以下のように AAPLVertex を修正すると3次元の位置データが受け取れるようになる。

typedef struct
{
    //vector_float2 position;//original
    vector_float3 position;//3d
    vector_float4 color;
} AAPLVertex;

シェーダーをもう一箇所ほど修正し、改めてデータを以下のように与える。

    static const AAPLVertex triangleVertices[] =
    {
        // 3D positions,    RGBA colors
        { {    0,  -300, 1 }, { 1, 0, 0, 1 } },
        { { -300,  -300, 1 }, { 0, 1, 0, 1 } },
        { {    0,   300, 1 }, { 0, 0, 1, 1 } },
        { {    0,   300, 1 }, { 1, 0, 0, 1 } },
        { {  300,   300, -2 }, { 0, 1, 0, 1 } },//oridinaly z= 0..1 but z=-2 no problem!
        { {    0,   -300, 1 }, { 0, 0, 1, 1 } },
    };

画面左上隅のデータの z 座標を -2 としている。

すると・・

描画される三角形2つのうち z 座標を操作した方の三角形の半分が消えている。

まるで操作した頂点が画面の奥の方にあり、描画範囲を超えてしまったかのように。

2次元描画をさせていたつもりなのに、なぜ、こんなことが起こるのだろう?

Metal 3 次元処理の基礎

結論から書いてしまうとこのコードは 3次元処理をしている

Metal が実際に採用している座標系は以下のようなものだ。

この図も公式の解説サイトから取って来たものだ。
特別な知識は何も使っていない。

x, y は左下を (-1, -1) として右(x 軸)上(y 軸)に向かうに従って増加する。
z は、画面の手前から奥に向かうに従って増加するので、いわゆる左手系というやつだ。(なお、OpenGL は右手系)

今回の場合は、z = -2 としたので、座標の決め方が上の図の通りならば、この頂点は画面の奥の方にあることになる。

z の数値から予想されるように、ちょうど半分隠れている。

GPU がデータを加工するシェーダー(のオリジナル)では

out.position = vector_float4(0.0, 0.0, 0.0, 1.0);

と z = 0 として z の情報を潰しているが、せっかく 4 次元で処理しているのだから、元のデータの z 情報を渡すように改変してしまえば、簡易的ながら Metal での3次元処理ができることになる。

一番簡単なのは、元のデータの z をそのまま出力することでしょう。

行列による処理のさわり

サンプルコードにちょっとした改変を加えることで3次元処理の入り口まで来てしまった。

冒頭で Metal を勉強する際に Obj-C の方がいいのでは?というような主張をしたのは、こういった背景があるからだ。

ちなみに、このサンプルコードは Swift では提供されていない
プログラミングの入門書を書くような人が Swift で書き直した本を出版するまで待つほど馬鹿馬鹿しいことはない。また、現在、流通している(日本語で書かれた)Metal 入門書で3次元処理まで言及している本は(2023 年現時点では)全くない。

Obj-C や C/C++ の経験が少しでもあれば、サンプルを読むのはそれほど難しくないでしょうから、こちらを学習の素材として使った方が得られるものが多いんじゃないでしょうか、というちょっとした提案です。

そして、ここまで来たなら、3DCG で頻出の行列を用いた処理のサワりくらいまで説明したい。

素材は、もちろん HelloTriangle です(笑)。

変換行列 Transform Matrix 

シェーダープログラムをちょっと細工すると上の三角形は以下のようになる。見ての通り z 軸方向(画面の手前から奥に向かう方向を正)に 45 度ほど回転している。

これはシェーダーに以下のコードを追加し、

   simd_float4x4 mR ={{0.707f,0.707f,0.0f,0.0f},{-0.707f,0.707,0.0f,0.0f},
        {0.0f,0.0f,1.0f,0},{0.0f,0.0f,0.0f,1.0f}};

出力を決めている out.position を次のように置き換えて実現している。

  //out.position = vector_float4(0.0, 0.0, 0.0, 1.0);//original
    out.position = mR * vector_float4(vertices[vertexID].position.x, vertices[vertexID].position.y, vertices[vertexID].position.z, 1.0);
    out.position.xy = out.position.xy / (viewportSize / 2.0);

これを読んでいる人がどの程度の到達度を目指し、現時点でどの程度の画像処理の知識を有しているか皆目見当もつかないが、簡単なサンプルでも行列を用いた変換処理ができるわけです。

なお、左手系での z 軸周りの θ 回転を意味する行列 mR は

cosθ -sinθ  0  0
sinθ  cosθ  0  0
0      0        1  0
0      0        0  1

で表現できます。
上のコードでは cos(45°) = 0.707f としてこの行列演算をさせているわけです。
任意の軸周りのより一般化された回転行列はしばらくお待ちを。この後、出てきます。

この調子で拡大/縮小行列と移動行列を用意すれば、モデルに関する変換行列は揃うことになるが、毎度毎度シェーダーにベタ書きするわけにもいかない。

シェーダーの側から C++ などで書かれた計算ライブラリを呼び出せれば便利なのだが、現在の MSL(シェーダーを記述している言語。***.metal ファイル)の仕様上、これは簡単ではない。MSL 自体は C++14 を元にしているが、C++ の標準ライブラリは使えないし、リンカも独自のものを採用している。以前のバージョンの MSL では、コンパイル済みの C++ コードを呼び出せたという情報もネット上ではあるにはあったが、そんなコロコロ変わる仕様をあてにするわけにもいかない。

では、どうするか?

ベストではないが、ベタ書きよりいくらかマシなのは、ヘッダーファイルに行列を返すインライン関数を用意しておくことだろう。

例えば、(一般的な)回転行列は

inline simd_float4x4 matrix_make_rows(
                                   float m00, float m10, float m20, float m30,
                                   float m01, float m11, float m21, float m31,
                                   float m02, float m12, float m22, float m32,
                                   float m03, float m13, float m23, float m33) {
    return (matrix_float4x4){ {
        { m00, m01, m02, m03 },
        { m10, m11, m12, m13 },
        { m20, m21, m22, m23 },
        { m30, m31, m32, m33 } } };
}

static inline simd_float4x4 matrix_rotation(float radians, vector_float3 axis) {

    float ct = cos(radians);
    float st = sin(radians);
    float ci = 1 - ct;
    float x = axis.x, y = axis.y, z = axis.z;
    return matrix_make_rows(
                        ct + x * x * ci, x * y * ci - z * st, x * z * ci + y * st, 0,
                    y * x * ci + z * st,     ct + y * y * ci, y * z * ci - x * st, 0,
                    z * x * ci - y * st, z * y * ci + x * st,     ct + z * z * ci, 0,
                                      0,                   0,                   0, 1);
}

となるので、これをヘッダファイルのどこかに挿入する。

移動・スケール・回転行列をそれぞれ mT, mS, mR とすれば、変換後の座標 out.position は

out.position = mT * mS * mR * vector_float4(vertices[vertexID].position.x, vertices[vertexID].position.y, vertices[vertexID].position.z, 1.0);

となる。

パラメータに適当な値を代入して私の環境で実際に実行すると

となった。

見てわかる通り、元の図形を

45°回転→ x-y 平面上で 0.5f 倍縮小→(200, 200) 移動

したものだ。

サンプルの限界

この調子でプロジェクション行列も準備すれば、それなりの 3D のビューアになりそうだ。

やってやれなくもないとは思うが、そろそろこのサンプルの限界に近づいてきた感もある。

これまで、CPU-GPU 間のデータの受け渡しを担ってきたのは setVertexBytes というメソッドだが、このメソッドでは、4kbytes 以上のデータを取り扱えない。

より深刻かつ本質的な問題は、元のサンプルの性質上 GPU に渡しているのは頂点データのみであって、変換行列を決定する各種パラメータなどではないといったプログラムの基本構成上の特徴だ。

3DCG のプログラミング的には、座標 + 行列 + テクスチャ などのデータを丸ごと GPU に渡して諸々の計算をさせた方が効率がいいはずだ。
実際、他のプログラマブルなグラフィック API ではそうしていると思う。

この欠点は、このサンプルに限らず、アップルの 3D の公式サンプル群全般に言えることで、渡しているデータは座標メインのものがなぜか多い。(アップルからしたら、サンプルで提示したいのは Metal の新しい機能であって 3DCG のレクチャーをしたいわけではないから、当然なのかもしれないが)

そろそろプログラムの構成を変えたプロジェクトを新規におこしたいのだが、その前に一つやっておきたいことがある。

空間?上に四面体を描く

やりたいことといっても大したものではない。

ここまできたなら、3次元物体を描画させたいというごくごく自然な目標です。

三角形で構成できる最小の空間上の物体は四面体なので、頂点データを4つ、描画の都合上、計12回与えればいい。

結果は、例えば

となる。

depth 処理など全くしていないので、描画順序によっては上のようにいかにも物体らしい陰影?がつく訳ではないと思うが、何度か試せばできるでしょう。

また、プロジェクション行列を試しに作用させると

という結果になる。

工夫次第では、他にも透過処理などを加えることも可能で、私があれこれ(魔)改造したら、結局、以下のようなものができてしまった。

3DCG の基本的な機能はほぼ揃っているのではないかと思う。

課題

このシリーズの元々の構成では、次は depth 処理を取り扱っていたのだが、3DCG の入り口あたりにきてしまったので、その方向で何かできないかが次の課題。

ここまでくると流石に参考になるサンプルもないので、新規で起こす必要がある。

 


オリジナル版

Metal を用いて三角形は描画できるようになった。
次は、この三角形に華を添えよう。
こんな具合に。

三角形内部の色を単色ではなく、3頂点で指定した色をいい感じに「混ぜ」込んでいることがわかると思う。

図は、こちらより

 

コードはこのようになる。


//test for rasterization
//猪股弘明

import PlaygroundSupport
import MetalKit

typealias float3 = SIMD3<Float>
typealias float4 = SIMD4<Float>
struct Vert{
    let posi: float3
    let color: float4
}

//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: 0, green: 0, blue: 0, 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 RasterizerData
{
    float4 position [[position]];
    float4 color;
};
struct Vert{
    float3 posi;
    float4 color;
};

vertex RasterizerData vertexShader(constant Vert *vertices [[buffer(0)]], uint vid [[vertex_id]])
{
    RasterizerData out{
        .position = float4(vertices[vid].posi, 1),
        .color = vertices[vid].color
    };

    return out;
}

fragment float4 fragmentShader(RasterizerData in [[stage_in]])
{
    return in.color;
}
"""
let library = try device.makeLibrary(source: shader, options: nil)
let vertexFunction = library.makeFunction(name: "vertexShader")
let fragmentFunction = library.makeFunction(name: "fragmentShader")

//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)

//ようやくデータです
let vertices = [Vert(posi: [0, 0.6,1], color: [1, 0, 0, 1]),
                Vert(posi: [-0.6, -0.4,1], color: [0, 1, 0, 1]),
                Vert(posi: [0.6, -0.4,1], color: [0, 0, 1, 1])]
let Buffer = device.makeBuffer(bytes: vertices,
                                       length: MemoryLayout<Vert>.stride * vertices.count,
                                       options: [])
renderEncoder.setVertexBuffer(Buffer, offset: 0, index: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0,
                             vertexCount: vertices.count)
renderEncoder.endEncoding()

//commandBuffer を経由して描画したいデータをGPUに渡します
commandBuffer.present(drawable)
commandBuffer.commit()

PlaygroundPage.current.liveView = view

単純に三角形を描画させたときとは、コードが違ってくる。

各頂点には、位置情報に加えて色情報が必要になってくるので、構造体 Vert を定義した。
シェーダーはこれらの情報を引き取らなければならないため、シェーダー側でも再定義している。

なお、こちらの人は、swift からシェーダープログラムを呼び出すため、ラッパーをつくっているようだが、型があってればおそらく不要だ。

 

猪股弘明

Depth処理 >>