SCNProgramというクラスを使って、SceneKitのマテリアルとかジオメトリの描画をMetalシェーダで行うことができます。
let program = SCNProgram()
program.fragmentFunctionName = "myFragment"
program.vertexFunctionName = "myVertex"
material.program = program
この program
というプロパティは SCNShadable というプロトコルに用意されていて、そのプロトコルにSCNMaterialやSCNGeometryが適合しています。
open class SCNMaterial : NSObject, SCNAnimatable, SCNShadable, NSCopying, NSSecureCoding {
このへんはなかなか情報がないのですが、以下の記事が参考になりました。
最終的にはAppleの公式ドキュメントが頼りになります。
- SCNShadable - SceneKit | Apple Developer Documentation
- SCNProgram - SceneKit | Apple Developer Documentation
本記事では主にマテリアルの描画をMetalシェーダで行う場合のアレコレについて書きます。
##基本的な実装:テクスチャ描画をMetalで行う
画像ファイルから読み込んだテクスチャをMetalで描画する場合、Metalシェーダ、SceneKitコードを以下のように実装します。
###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)]]
に渡されてくる
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コード側
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シェーダ側
fragment half4 myFragment(SimpleVertex in [[stage_in]],
constant float &time [[buffer(0)]])
####SceneKitコード側
private lazy var startTime = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true, block: { (timer) in
self.updateTime()
})
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
}
やることはほぼ同じです。
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で処理するときにバッファサイズが合わないっていう実行時エラーになります。
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];
}];
+ (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シェーダ側
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をジオメトリとするノードを置いたサンプルをベースに、
GLSL Sandboxを参考にMetalシェーダを書いてみます。
参考: [iOS] Metalシェーダことはじめ - WebGL/GLSLの豊富なサンプルを参考にする - Qiita
※実際の開発でこういうシェーダを利用したかったのではなく、円とか線を3D空間上に描画するのにSceneKit+Metalを利用しました。