SceneKitでMetalのシェーダを利用する(SCNProgram)

Metalを使いたい場合にネックとなるのが、シーンの構築とかモデル・テクスチャの管理。 なので、その面倒な部分をSceneKitに任せたいという時の話。

今回は描画周りにMetalのシェーダを使うパターン。

主にSceneKitでカスタムシェーダを使いたい場合は、

といった辺りがあるみたい。

SCNTechniqueはマルチパスのレンダリングに使うのがメインっぽい。 (これも試したけどシェーダへカスタム変数を渡す辺りでつまずいて放置)

SCNShadableMetalでの使えそうなサンプルがなかったので断念。

という訳で、WWDCのセッションの資料にあったSCNProgramを使って実装。

下準備

プロジェクトはXcodeのデフォルトのテンプレートのGameを流用している。 作成時のGame TechnologyではSceneKitを選択する。

SceneKitの設定

SCNProgramの作成

以下のようにしてSCNProgramを生成してシェーダの関数名を設定する

let program = SCNProgram()
program.vertexFunctionName = "textureVertex"
program.fragmentFunctionName = "textureFragment"

生成したSCNProgramを適用させたいマテリアルに設定する

let material = ship.childNodes.first?.geometry?.firstMaterial!
material.program = program

シェーダの準備

通常どおりMetalのファイルを追加した後に、

#include <SceneKit/scn_metal>

とする。 これは後述のSceneKitとデータのやりとりに必要。

データの渡し方

SceneKitから描画に必要なデータ(座標や変換行列、テクスチャなど)を シェーダへ渡す方法

VertexShader側

頂点属性(位置とか法線とかuv座標とか)

まずは、

struct VertexInput {
    float3 position [[ attribute(SCNVertexSemanticPosition) ]];
    float2 texcoord [[ attribute(SCNVertexSemanticTexcoord0) ]];
};

という感じで、頂点属性の中で必要なものを構造体で定義する。 すると、変数名の後ろのAttribute Qualifier(”[[]]“で囲まれた部分)で指定したものが [[stage_in]]にバインドされて自動で渡されてくる。

指定できるものはドキュメントのTable 1 SceneKit Vertex Attribute Qualifiers for Metal Shadersを参照

フレーム定数(ビューの変換行列など)

あらかじめSCNSceneBufferという構造体が用意されており、これらはその中に入っている。 このデータは[[buffer(0)]]にバインドされて渡されてくる。

構造体の定義はドキュメントのFrame-Constant Dataの項目を参照

ノード毎のデータ(モデルの変換行列など)

これは、あらかじめ用意された構造体がなく、代わりに必要なものをピックアップして 以下のように自分で構造体を定義する。

struct NodeBuffer {
    float4x4 modelViewProjectionTransform;
};

すると、そのデータが[[buffer(1)]]にバインドされてシェーダに渡される。

ピックアップできるものはドキュメントのListing 1 Available Fields for Per-Node Shader Dataを参照

カスタム変数

上記以外の変数は構造体として定義が必要。定義自体は通常通りに行う。 ただし、バインドされるバッファは2以降になる。

実装

まとめると、VertexShaderの宣言部分は以下の通り

vertex output myVertex(input in [[ stage_in ]],
                       constant SCNSceneBuffer& scn_frame [[ buffer(0) ]],
                       constant NodeBuffer& scn_node [[ buffer(1) ]],
                       constant CustomBuffer& custom [[ buffer(2) ]]) {

ここでの重要なポイントは引数名。 - scn_framescn_nodeは固定
(違う名前にすると正しくバインドされない) - カスタム変数の引数名customSceneKitからデータを渡す時に使う

中での処理は必要な計算をして、それをFragmentShaderに渡すという、 通常のMetalのシェーダと同じ実装を行う。

FragmentShader側

テクスチャ

テクスチャを利用したい場合は、特に事前の定義などは不要で通常通り宣言する

fragment half4 textureFragment(VertexOut in [[ stage_in ]],
                               texture2d<float> texture [[ texture(0) ]]) {

ただしここでも重要なポイントは引数名(詳細は後述)。

SceneKit側

カスタム変数とテクスチャ以外は自動でバインドされる
(=SceneKit側の処理は特にない)

カスタム変数

シェーダ側と同じ構造体のデータを準備する辺りは通常通り。

そのデータをシェーダ側にバインドするのは以下のようにsetValueを利用する

var custom = CustomBuffer(color: float4(0, 0, 0, 1))
material.setValue(NSData(bytes: &custom, length:sizeof(CustomBuffer)),
                  forKey: "custom")

setValueSCNProgramをセットしたのと同じ対象(今回はmaterial)に行う。
また、valueNSDataとしてバイナリで渡し、keyはシェーダでの宣言と同じ名前にする。

テクスチャ

今回のモデルではマテリアルのdiffuseにテクスチャが設定されているので、まずそれを取得する。 その後、SCNMaterialPropertyの形式にしてから変数と同様にsetValueする

guard let contents = material.diffuse.contents else { return }
material.setValue(SCNMaterialProperty(contents: contents),
                  forKey: "texture")

ポイントは、SCNMaterialPropertyを生成しなおしてからセットすること。
直接diffuseの中のデータをsetValueすると正しくデータが渡されない。

命名の注意点

シェーダの引数名とSceneKit側でsetValuekeyは一致させる必要がある。

さらに大事な点として、setValueKVOを利用しているので、 命名時にはオブジェクトのプロパティとかぶる様な名前をつけてはいけない。
(例えばcolorなどは実行時にエラーログが出て連携ができない)

感想

今回はサンプルもなくとても苦戦した。。。特にシェーダとSceneKit間のデータのやりとり辺りは、 ドキュメントにも細かく書いてなくて苦労した。
妙に親切に頂点属性などをバインドしてくれると思ったら、 引数名固定だったり、テクスチャの再生成が必要だったりと落とし穴もいっぱい・・・

あと、引数名がスネークケースなのもいただけない。 他がキャメルケースなのでここは統一して欲しかった。

ただ、Metalのバインドの辺りの仕組みはどうなっているのか興味深いので、 もっといろいろ触ってみたい。

参考リンク

開発環境

ソース

こちら