Edited at
MetalDay 3

MetalシェーダでSceneKitのマテリアルを描画する

SCNProgramというクラスを使って、SceneKitのマテリアルとかジオメトリの描画をMetalシェーダで行うことができます。

let program = SCNProgram()

program.fragmentFunctionName = "myFragment"
program.vertexFunctionName = "myVertex"
material.program = program

この program というプロパティは SCNShadable というプロトコルに用意されていて、そのプロトコルにSCNMaterialやSCNGeometryが適合しています。


SCNMaterial

open class SCNMaterial : NSObject, SCNAnimatable, SCNShadable, NSCopying, NSSecureCoding {


このへんはなかなか情報がないのですが、以下の記事が参考になりました。

最終的にはAppleの公式ドキュメントが頼りになります。

本記事では主にマテリアルの描画をMetalシェーダで行う場合のアレコレについて書きます。


基本的な実装:テクスチャ描画をMetalで行う

画像ファイルから読み込んだテクスチャをMetalで描画する場合、Metalシェーダ、SceneKitコードを以下のように実装します。


Metalシェーダ側


Shaders.metal

#include <metal_stdlib>

using namespace metal;

#include <SceneKit/scn_metal>

struct MyNodeBuffer {
float4x4 modelViewProjectionTransform;
};

struct MyVertexInput {
float3 position [[attribute(SCNVertexSemanticPosition)]];
float2 texCoords [[attribute(SCNVertexSemanticTexcoord0)]];
};

struct SimpleVertex
{
float4 position [[position]];
float2 texCoords;
};

vertex SimpleVertex myVertex(MyVertexInput in [[stage_in]],
constant SCNSceneBuffer& scn_frame [[buffer(0)]],
constant MyNodeBuffer& scn_node [[buffer(1)]])
{
SimpleVertex vert;
vert.position = scn_node.modelViewProjectionTransform * float4(in.position, 1.0);
vert.texCoords = in.texCoords;

return vert;
}

fragment half4 myFragment(SimpleVertex in [[stage_in]],
texture2d<float, access::sample> diffuseTexture [[texture(0)]])
{
constexpr sampler sampler2d(coord::normalized, filter::linear, address::repeat);
float4 color = diffuseTexture.sample(sampler2d, in.texCoords);
return half4(color);
}


通常のMetalシェーダ実装との違いとしては、



  • #include <SceneKit/scn_metal> が必要

  • 頂点の属性は、[[attribute(SCNVertexSemanticPosition)]][[attribute(SCNVertexSemanticTexcoord0)]] 等、必要なものを構造体で持っておく


  • modelViewProjectionTransformとかの変換行列は、あらかじめ用意されたものがあるので、必要なものをピックアップして構造体を定義する。このデータは [[buffer(1)]] に渡されてくる


Available_Fields_for_Per-Node_Shader_Data

float4x4 modelTransform;

float4x4 inverseModelTransform;
float4x4 modelViewTransform;
float4x4 inverseModelViewTransform;
float4x4 normalTransform; // Inverse transpose of modelViewTransform
float4x4 modelViewProjectionTransform;
float4x4 inverseModelViewProjectionTransform;
float2x3 boundingBox;
float2x3 worldBoundingBox;

といったあたりです。

参考記事に示したStackOverflowの回答ほぼそのまんまですが、これでSCNMaterialのテクスチャ描画がMetalによって行われるようになります。個人的な用途としてはフラグメントシェーダをカスタムしたかったので、頂点シェーダの実装はこれをそのまま使いまわしています。


SceneKitコード側


SomeClass.swift

let program = SCNProgram()

program.fragmentFunctionName = "myFragment"
program.vertexFunctionName = "myVertex"
material.program = program

let image = UIImage(named: filename)!
let imageProperty = SCNMaterialProperty(contents: image)
material.setValue(imageProperty, forKey: "diffuseTexture")


こちらはものすごくシンプルです。フラグメントシェーダにテクスチャを渡すために、


  • SCNMaterialPropertyオブジェクトとして用意

  • SCNMaterialの"diffuseTexture"にセット

しています。こうすることで、シェーダ側の引数 diffuseTexture [[texture(0)]] にテクスチャデータが渡されます。キー名/引数名は一致していれば何でもokです。


SceneKitコードからMetalシェーダに任意の値を渡す


浮動小数点数を渡す

たとえばフラグメントシェーダの引数にFloat型の「経過時間」を渡したい場合、次のようにします。


Metalシェーダ側


Shaders.metal

fragment half4 myFragment(SimpleVertex in [[stage_in]],

constant float &time [[buffer(0)]])


SceneKitコード側


SomeClass.swift

private lazy var startTime = Date()



SomeClass.swift

Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true, block: { (timer) in

self.updateTime()
})


SomeClass.swift

private func updateTime() {

var time = Float(Date().timeIntervalSince(startTime))
let timeData = Data(bytes: &time, count: MemoryLayout<Float>.size)
node.geometry?.firstMaterial?.setValue(timeData, forKey: "time")
}

テクスチャを渡したときとほぼ一緒ですが、今回は Float の値を渡したいので、以下の点が違います。



  • SCNMaterialProperty ではなく Data で渡す


  • [[texture(0)]] ではなく [[buffer(0)]] で受け取る


構造体を渡す

上の例では Float 型の値をMetalシェーダに渡したわけですが、たとえば Uniforms という構造体を次のように定義して、これをシェーダに渡したいとします。

struct Uniforms {

var resolution: float2
}

やることはほぼ同じです。


SomeClasss.swift

let screenSize = UIScreen.main.nativeBounds.size

var uniforms = Uniforms(resolution: float2(Float(screenSize.width), Float(screenSize.height)))
let uniformsData = Data(bytes: &uniforms, count: MemoryLayout<Uniforms>.size)
node.geometry?.firstMaterial?.setValue(uniformsData, forKey: "uniforms")

Metalシェーダ側では Uniforms &uniforms [[buffer(0)]] という引数で値を受け取れます。


配列を渡す

例えばLineという線分の描画情報(始点・終点etc)の入った構造体を配列でMetalシェーダに渡したいとします。


SceneKitコード側

ここでのポイントは、バッファサイズを handleBindingOfBufferNamed:frequency:usingBlock: で渡してやること。こうしないと、GPUで処理するときにバッファサイズが合わないっていう実行時エラーになります。


SomeClass.mm

std::vector<Line> lines = // 略;

NSData *linesData = [Utils dataFromLines:lines];
NSUInteger numLines = lines.count;
NSData *numLinesData = [NSData dataWithBytes:&numLines length:sizeof(NSUInteger)];
[program handleBindingOfBufferNamed:@"lines" frequency:SCNBufferFrequencyPerFrame usingBlock:^(id<SCNBufferStream> _Nonnull buffer, SCNNode * _Nonnull node, id<SCNShadable> _Nonnull shadable, SCNRenderer * _Nonnull renderer) {
[buffer writeBytes:(void *)linesData.bytes length:linesData.length];
}];


Utils.mm

+ (NSData *)dataFromLines:(std::vector<Line>)lines

{
NSMutableData *data = [NSMutableData data];
for (int i=0; i<lines.size(); i++) {
Line line = lines[i];
NSData *lineData = [NSData dataWithBytes:&line length:sizeof(Line)];
[data appendData:lineData];
}
return data;
}

この実装をしていたときC++のコードとの連携の関係で、ここだけObjective-C++ですが、まぁポイントはSwiftでも同様です。

このAPIはググってもAppleのリファレンスしか出てこなくて、本当に情報がなかったです...


Metalシェーダ側


Shaders.metal

fragment half4 myFragment(SimpleVertex in [[stage_in]],

const device Line *lines [[buffer(0)]],
constant uint &numLines [[buffer(1)]])

要素数も別引数で受け取る必要がある、という点に注意。


透過を有効にする

シェーダでアルファを指定したり、discard_fragment してもデフォルトでは透過にならないので、

program.isOpaque = false;

をやっておきます。


作例

SceneKitでSCNBoxをジオメトリとするノードを置いたサンプルをベースに、

none.gif

GLSL Sandboxを参考にMetalシェーダを書いてみます。

参考: [iOS] Metalシェーダことはじめ - WebGL/GLSLの豊富なサンプルを参考にする - Qiita

color.gif

voronoi.gif

※実際の開発でこういうシェーダを利用したかったのではなく、円とか線を3D空間上に描画するのにSceneKit+Metalを利用しました。