🎨

GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (9) 〜落穂拾い〜

2024/11/17に公開

はしがき

SwiftUIでSpriteKitのSKShaderを使って遊んでみようというテーマの記事の9回目です。
前回の記事 では、SpriteKitで 3Dコンテンツを表示する方法 を紹介しました。
今回は、ここまでの記事で書ききれなかった OpenGL ES 2.0とSKShaderのGLSLの差分 について、シェーダーのコードの移植時によく遭遇しそうなものを中心にとりあげます。

GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit シリーズの記事一覧

必要な前提知識

この記事では、以下に該当するようなプログラマーを想定読者としています。

  • Swiftの基本文法をおおむね理解している
  • SwiftUIの基本的な書きかたを知っている
  • フラグメントシェーダー(GLSL, HLSL, etc.)のコードを多少でも書いて動かしてみた経験がある

SwiftUIを使っていてシェーダーにも興味があるけれど、SpriteKitやSKShaderにはなじみがない、ぐらいのレベル感です。
シェーダーの入門知識の説明は省きますので、たとえばGLSLの変数の種類や型名(uniform, float, vec4, ...)、関数名(length, mix, ...)、スウィズル演算子や座標の正規化といった用語が前置きなしに出てきます。

環境

以下の環境で動作確認を行っています。

  • Xcode 15.2 (15C500b)
  • iOS Deployment Target 15.0
  • Apple Swift version 5.9.2 (swiftlang-5.9.2.2.56 clang-1500.1.0.2.5)
  • Swift Playgrounds 4.5 (*)
  • macOS Venture 13.6.6
  • MacBook Air M2 2022

* XcodeのPlayground(PlaygroundSupport)では動作しません

記載しているログはシミュレータの iPhone 13(iOS 15.5)で取得したものから抜粋しています。

変数

変数の初期化

SKShaderのGLSLでは、 floatint の変数宣言時に 値が初期化されません
0.0 などの値で暗黙の初期化が行われる環境からコードを移植する場合、初期化の処理が書かれていないことが原因で、シェーダーの実行結果が想定したようにならないケースがあります(初期化なしで += の演算をしているコードをつぶやきGLSL (twigl.app) などではよく見かけます)。

SampleSKShader.fsh
// 青色の出力を想定しているが、赤色が出力されてしまうシェーダーコード
void main() {
    vec4 red = vec4(1.0, 0.0, 0.0, 1.0);
    vec4 blue = vec4(0.0, 0.0, 1.0, 1.0);
    float l;  // 初期化なし
    l += 1.0;  // 初期化をしないままで += の演算を実行 -> NaNになります
    gl_FragColor = l == 1.0 ? blue : red;  // -> 変数lの値がNaNなので赤色が出力されます
    // Metalシェーディング言語 (MSL) のisnan関数を使うと、値がNaNであることを確認できます
}

上記のシェーダーコードを実行すると、Debug AreaのConsoleにメッセージが出力されます(メッセージの内容は環境によって多少異なります)。

warning: variable 'l' is uninitialized when used here
    l += 1.0;
    ^
note: initialize the variable 'l' to silence this warning
    float l;
           ^
            = 0.0

次のように、変数が続けて宣言されていて、最後の変数のみが初期化されているコードも見かけます。

SampleSKShader.fsh
// 青色の出力を想定しているが、赤色が出力されてしまうシェーダーコード
void main() {
    vec4 red = vec4(1.0, 0.0, 0.0, 1.0);
    vec4 blue = vec4(0.0, 0.0, 1.0, 1.0);
    float l, m = 1.0;  // 変数lは初期化されていない状態
    l += m;
    gl_FragColor = l == 1.0 ? blue : red;  // -> 変数lの値がNaNなので赤色が出力されます
}

コードを移植する際には、初期化されていない変数がまぎれていないか気をつけて確認・修正する必要があります。

SampleSKShader.fsh
  // 想定しているとおりに青色が出力されるシェーダーコード
  void main() {
      vec4 red = vec4(1.0, 0.0, 0.0, 1.0);
      vec4 blue = vec4(0.0, 0.0, 1.0, 1.0);
-     float l, m = 1.0;
+     float l = 0.0, m = 1.0;  // 変数lも初期化
      l += m;
      gl_FragColor = l == 1.0 ? blue : red;  // -> 変数lの値は1.0なので青色が出力されます
  }

グローバル変数

SKShaderのGLSLでは、 グローバル変数は使えません
グローバルスコープで変数を宣言すると、実行時に以下のエラーメッセージが出力されます。

error: program scope variable must reside in constant address space

uniform変数、attribute変数、varying変数などもグローバルスコープでは使えません。
main関数の外でこれらの変数を使用すると、以下のようなエラーメッセージが出力されます。

// uniform変数の u_time をグローバル関数内で使った場合
error: use of undeclared identifier 'u_time'

代替手段もおそらくなさそうです。面倒ですが、 main関数内で変数を宣言して、グローバル関数へ引数で渡していく ように書きかえるしかないようです。

SampleSKShader.fsh
  // uniform変数はSKScene側から渡せますが、シェーダーコード内での宣言は不要です (次項参照)
- uniform vec2 resolution;
- uniform float time;
  // マクロ定義ではuniform変数も利用できます
  // ただし定義した文字列をmain関数の外で使うとエラーになります
+ #define resolution u_resolution // u_resolutionという値を別途SKScene側から渡していて、それをresolutionという変数名でも使いたい場合の例です (次項参照)
+ #define time u_time

  // グローバル変数は削除します
- float radius = 0.5;

  // radiusとtimeの変数を引数で受け取るように書きかえます
- float ring(vec2 p) {
-     return 0.01 / abs(length(p) - radius - sin(time) * 0.1);
+ float ring(vec2 p, float radius, float _time) {
+     return 0.01 / abs(length(p) - radius - sin(_time) * 0.1);
  }

  void main() {
      vec2 p = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y);
      // グローバル変数radiusをローカル変数に書きかえます
+     float radius = 0.5;
      float d = 0.0;
      for (int i = 0; i < 2; i++) {
          // ローカル変数radiusとマクロ定義したtimeを引数で渡すように書きかえます
-         d += ring(p);
+         d += ring(p, radius, time);
          radius += 0.1;
      }
      d = clamp(d, 0.0, 1.0) * 0.5;
      d += sin(time) * 0.4;
      gl_FragColor = vec4(vec3(d), 1.0);  // -> 伸縮・明滅する二重の輪が出力されます
  }

ストレージ修飾子 (uniform, varying, attribute)

SKShaderのGLSLでは、 uniformvaryingストレージ修飾子を用いたグローバル変数の宣言もできません
これらの修飾子を使用してグローバル変数を宣言すると、実行時に以下のようなエラーメッセージが出力されます。

// uniform修飾子を使った場合
error: use of class template 'uniform' requires template arguments

// varying修飾子を使った場合
error: unknown type name 'varying'

uniform変数やattribute変数の扱いについては SpriteKit独自の仕様 があります。詳細は 第3回の記事 で説明していますのでご参照ください。

ストレージ修飾子 (const)

SKShaderのGLSLでは、定数を宣言する際に スコープローカルグローバル かによって 使う修飾子が異なります
ローカルスコープ では constグローバルスコープ では constant の修飾子を使います。

スコープ 修飾子
ローカル const
グローバル constant

グローバルスコープで const を使用すると、実行時に以下のエラーメッセージが出力されます。

error: program scope variable must reside in constant address space

グローバルスコープの constconstant に書きかえましょう。

SampleSKShader.fsh
- const float PI = 3.1415927;
+ constant float PI = 3.1415927;

  void main() {
      const vec4 red = vec4(1.0, 0.0, 0.0, 1.0);
      const vec4 blue = vec4(0.0, 0.0, 1.0, 1.0);
      float t = acos(cos(u_time)) / PI;
      gl_FragColor = mix(red, blue, t);  // -> 赤〜青に変化する色が出力されます
  }

パラメータ修飾子 (in, out, inout)

SKShaderのGLSLは inoutinout などの パラメータ修飾子は使えません
グローバル関数の引数でこれらの修飾子を使用すると、実行時に以下のようなエラーメッセージが出力されます。

// inを使った場合
error: use of undeclared identifier 'in'

// inoutを使った場合
error: use of undeclared identifier 'inout'

// unknown type nameというエラーになる場合もあります
// 引数の型の組み合わせによってエラーメッセージが変わるようです
error: unknown type name 'in'

in修飾子を消せばよい です。ストレージ修飾子の const が付いている場合は const + 型名 + 変数名 にすれば読み取り専用にできます。
outinout については、Metalシェーディング言語(MSL)の 参照渡し または ポインタ渡し で代用できます(書きかたは以下のサンプルコードをご覧ください。参照渡しのほうが書きかえ箇所が少なくてすみます)。関数呼び出し時点で仮引数の値が未定義になるかどうかは、呼び出し元で渡した変数が未定義の状態かどうかによります。ですので out の厳密な代用にはなりませんが、実引数の値が書きかえられれば十分というケースがほとんどだと思います。

パラメータ修飾子(非サポート) 書きかえ例
in
in float v
float v
const in
const in float v
const float v
out
out float v
thread float &v
inout
inout float v
thread float &v
SampleSKShader.fsh
  // inoutを参照渡しに書きかえる例
- void getColor(int id, inout vec4 col) {
+ void getColor(int id, thread vec4 &col) {
      vec4 red = vec4(1.0, 0.0, 0.0, 1.0);
      vec4 blue = vec4(0.0, 0.0, 1.0, 1.0);
      col = id == 0 ? red : id == 1 ? blue : col;
  }

  void main() {
      vec4 color = vec4(1.0);
      getColor(0, color);
      gl_FragColor = color;  // -> 赤色が出力されます
  }
SampleSKShader.fsh
  // inoutをポインタ渡しに書きかえる例
- void getColor(int id, inout vec4 col) {
+ void getColor(int id, thread vec4 *col) {
      vec4 red = vec4(1.0, 0.0, 0.0, 1.0);
      vec4 blue = vec4(0.0, 0.0, 1.0, 1.0);
-     col = id == 0 ? red : id == 1 ? blue : col;
+     *col = id == 0 ? red : id == 1 ? blue : *col;
  }

  void main() {
      vec4 color = vec4(1.0);
-     getColor(0, color);
+     getColor(0, &color);
      gl_FragColor = color;  // -> 赤色が出力されます
  }

精度修飾子 (highp, mediump, lowp)

SKShaderのGLSLでは、変数宣言での mediumphighp といった 精度修飾子は無視されます
修飾子の指定にかかわらず、演算精度は float では32bit浮動小数点、 int では32bit符号付き整数になります。
精度を下げたい場合は、 halfshort といったMSLの型が使えます。
第3回の記事 で紹介した表の再掲ですが、GLSLの精度修飾子 + 型の精度とMSLの型の精度の対応は、だいたい以下のようになります。

GLSL MSL
highp float float
mediump float half
lowp float −(該当なし)
highp int int
mediump int short
lowp int char

変数宣言時の精度修飾子は無視されるだけなのでそのままでもいいのですが、 precision宣言はエラーになります#ifdef GL_ES 〜 #endif で囲まれている場合は無視されるのでエラーになりません)。
precision宣言があると、実行時に以下のエラーメッセージが出力されます。

error: unknown type name 'precision'

precision宣言の行は削除しておきましょう。

bvecn & ivecn

SKShaderのGLSLでは bvecnivecn の型が 非サポート です。
これらの型を使用すると、実行時に以下のようなエラーメッセージが出力されます。

// bvec2を使った場合
error: unknown type name 'bvec2'; did you mean 'vec2'?

// ivec4を使った場合
error: unknown type name 'ivec4'; did you mean 'vec4'?

第3回の記事 でも軽く紹介していますが、MSLの boolnintn の型が代わりに使えるので、そちらに書きかえられます。

GLSLの型(非サポート) 書きかえ例
bvec2, bvec3, bvec4 bool2, bool3, bool4
ivec2, ivec3, ivec4 int2, int3, int4

SKShaderのGLSLでは typedef が使えるので、以下のように型定義を追加するのもよいでしょう。

typedef bool2 bvec2;
typedef bool3 bvec3;
typedef bool4 bvec4;
typedef int2 ivec2;
typedef int3 ivec3;
typedef int4 ivec4;

演算子

スウィズル演算子 { s, t, p, q }

SKShaderのGLSLでは { s, t, p, q } のスウィズル演算子は 非サポート です。
このスウィズル演算子を使用すると、実行時に以下のようなエラーメッセージが出力されます。

error: illegal vector component name 's'

{ s, t, p, q } のスウィズル演算子は、 { x, y, z, w } または { r, g, b, a } に書きかえましょう。

乗算代入演算子 (vector *= matrix) とスウィズル演算子の組み合わせ

ベクトルと行列を *= の代入演算子を使って乗算する場合、 左辺のベクトルではスウィズル演算子が使えません

例えば、オブジェクトのxy平面を行列を使って回転させる目的で、次のようなコードが書かれていたりします。

SampleSKShader.fsh
mat2 rotate2d(float _angle) {
    return mat2(cos(_angle), -sin(_angle),
                sin(_angle), cos(_angle));
}

void main() {
    // 〜略〜
    // 左辺で .xy のスウィズル演算子が使われています
    p.xy *= rotate2d(u_time);
    // 〜略〜
}

このコードを実行すると、以下のようなエラーメッセージが出力されます。

error: non-const reference cannot bind to vector element
    p.xy *= rotate2d(u_time[0]);
    ^~~~
/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.5.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/GPUCompiler.framework/Libraries/lib/clang/31001.518/include/metal/metal_matrix:2684:59: note: passing argument to parameter 'a' here
METAL_FUNC thread vec<T, K> &operator*=(thread vec<T, K> &a, const thread matrix<T, K, K> &b)

乗算代入を 通常の乗算と代入 に書きかえればエラーは起きなくなります。

SampleSKShader.fsh
  mat2 rotate2d(float _angle) {
      return mat2(cos(_angle), -sin(_angle),
                  sin(_angle), cos(_angle));
  }
  
  void main() {
      // 〜略〜
-     p.xy *= rotate2d(u_time);
+     p.xy = p.xy * rotate2d(u_time);
      // 〜略〜
  }

または、必要なベクトルの要素をいったん別の変数に移して、乗算代入の左辺でスウィズル演算子を使わないように書きかえることでもエラーは避けられます。

SampleSKShader.fsh
  mat2 rotate2d(float _angle) {
      return mat2(cos(_angle), -sin(_angle),
                  sin(_angle), cos(_angle));
  }

  void main() {
      // 〜略〜
-     p.xy *= rotate2d(u_time);
+     vec2 _p = p.xy;
+     _p *= rotate2d(u_time);
+     p.xy = _p;
      // 〜略〜
  }

なお、乗算代入がエラーになるのは vec *= mat の組み合わせのみ で、 vec *= vecvec *= float では 左辺でスウィズル演算子を使っても問題ありません

排他的論理和 (^^)

SKShaderのGLSLでは 、排他的論理和の ^^ 演算子が 使えません
この演算子を使用すると、実行時に以下のエラーメッセージが出力されます。

error: type name requires a specifier or qualifier

// if文や三項演算子の条件式の部分で ^^ を使った場合などは
// unknown type nameというエラーになります
error: unknown type name 'rhs (右辺の変数名)'

ビット排他的論理和の ^ 演算子は使えるので、処理の内容的に支障がなければ、こちらに書きかえられます。

組み込み変数

gl_FragCoord 変数の座標

SKShaderのGLSLでは gl_FragCoord 変数で取得できる 座標の原点SKSceneの左上 になっています。OpenGLの標準仕様では左下が原点なので Y軸が反転 していることになります。
もとのシェーダーと同じ出力を得るには、 座標の上下を逆にする処理の追加 が必要です。
詳細は 第2回の記事 で説明していますのでご参照ください。

SampleSKShader.fsh
  void main() {
      // Y軸が -1.0〜1.0 になるように正規化されている場合は、Y座標の符号を反転させます
      vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
+     p.y *= -1.0;
      gl_FragColor = vec4(vec3(step(p.x, p.y)), 1.0);
  }
SampleSKShader.fsh
  void main() {
      // Y軸が 0.0〜1.0 になるように正規化されている場合は、1.0からY座標の値を減算します
      vec2 p = gl_FragCoord.xy / min(u_resolution.x, u_resolution.y);
+     p.y = 1.0 - p.y;
      gl_FragColor = vec4(vec3(step(p.x, p.y)), 1.0);
  }

組み込み関数

SKShaderのGLSLでは使えない組み込み関数を、分類ごとに表にまとめます。
これらの関数を使用すると、実行時に以下のようなエラーメッセージが出力されます。

error: use of undeclared identifier '関数名'

Angle & Trigonometry Functions

関数(非サポート) 書きかえ例
radians(T x) x * M_PI_F / 180.0
degrees(T x) x * 180.0 / M_PI_F

M_PI_F はMSLの組み込み定数で、PIのfloat型の値になります。half型の M_PI_H もあります。

関数の書きかえ例に関して、該当する関数の出現回数が少なければひとつずつ書きかえてもよいですが、たびたび出てくるときは #defineinline で定義すると効率的でしょう。

// マクロ定義する場合
#define radians(x) (x * M_PI_F / 180.0)

// インライン関数にする場合
inline float degrees(float x) { return x * 180.0 / M_PI_F; }

Matrix Functions

関数(非サポート) 書きかえ例
matrixCompMult(mat x, mat y) mat4(x[0] * y[0], x[1] * y[1], x[2] * y[2], x[3] * y[3])

Vector Relational Functions

関数(非サポート) 書きかえ例 備考
lessThan(T x, T y) x < y lessThan関数自体は使えますがGLSLの標準仕様とは挙動が異なります(詳細は下記補足を参照)
lessThanEqual(T x, T y) x <= y -
greaterThan(T x, T y) x > y -
greaterThanEqual(T x, T y) x >= y -
equal(T x, T y)
equal(bvec x, bvec y)
x == y -
notEqual(T x, T y)
notEqual(bvec x, bvec y)
x != y -

SKShaderのGLSLでは ベクトル同士でも比較演算子が使える ので、それらを使った演算に書きかえられます。

lessThan関数の補足

コンパイル時にSKShaderのソースを内部でMSLへ変換する際に生成されるコード [1] の中で #define lessThan(x,y) (1.0 - step(y,x)) という定義が追加されているので、 lessThan関数自体は使えます
しかし、引数で ivec ( intn ) 型の値が渡せない、戻り値の型が bvec ( booln ) ではなく vec ( floatn ) になっているなど、 GLSLの標準仕様とは異なる部分があります

引数でivec型の値を渡すと、実行時に以下のようなエラーメッセージが出力されます。

// int3(ivec3に相当)の値を引数で渡した場合
error: no matching function for call to 'step'
    vec3 r = lessThan(int3(0), int3(1));
             ^~~~~~~~~~~~~~~~~~~~~~~~~~
note: expanded from macro 'lessThan'
#define lessThan(x,y) (1.0 - step(y,x))
                             ^~~~

左辺値にbvec型を指定すると、実行時に以下のエラーメッセージが出力されます。

// bool3(bvec3に相当)の変数を左辺値に指定した場合
error: cannot initialize a variable of type 'bool3' (vector of 3 'bool' values) with an rvalue of type 'metal::float3' (aka 'float3')
    bool3 r = lessThan(vec3(0.0), vec3(1.0));
          ^   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

lessThan関数が使われているGLSLのコードでこういったエラーが発生した場合は、比較演算子 < を使うように書きかえるとよいでしょう。

Texture Lookup Functions

関数(非サポート) 備考
texture2D(sampler2D sampler, vec2 coord, float bias) bias引数が非サポートです
texture2D(sampler2D sampler, vec2 coord) は使えます
texture2DProj bias引数の有無やcoord引数の型に関わらず非サポートです
textureCube bias引数の有無に関わらず非サポートです

テクスチャ関数については、サンプラーとテクスチャ座標の2つの引数を渡すtexure2D関数 texture2D(sampler2D sampler, vec2 coord) のみが使え、 それ以外の各関数が非サポートです

texture2D 関数 (bias引数)

bias の値を含む3つの引数を渡すtexure2D関数 texture2D(sampler2D sampler, vec2 coord, float bias) を使った場合は、実行時に以下のようなエラーメッセージが出力されます。

error: no matching function for call to 'texture2D'
    vec4 texel = texture2D(u_texture, uv, 0.0);
                 ^~~~~~~~~
note: candidate function not viable: requires 2 arguments, but 3 were provided
inline float4 texture2D(texture2d<float> tex, float2 tex_coord) {
              ^

SKShaderには bias 引数の代替となる手段はなさそうです。 [2]

しかし、 前回の記事で紹介した SceneKitのShader Modifiers では、texture2D関数に bias 引数を指定できるので、こちらを代わりに使うという方法があります。
この bias 引数を機能させるには、テクスチャとサンプラーを Mipmapに対応 させてシェーダーへ渡すという手順が必要になります。

SK3DNode(SceneKit)を使った実装例を掲載します。
※長くなるのでサンプルコードを折りたたみます。

biasの引数をとるtexture2D関数のサンプルコード with SK3DNode

次の3点が実装のポイントになります。

  1. MetalKitimport する
    今回のサンプルコードでは MTLTexture オブジェクトを使用します。そのオブジェクトの作成に MTKTextureLoader を利用するため、 MetalKitimport します。
  2. Mipmapに対応した MTLTexture オブジェクトを作成する
    MTKTextureLoader を使って画像データからテクスチャを作成します。その際に generateMipmaps オプションを指定してMipmapを生成します。
    今回は SKTextureでモノクロのノイズ画像を生成 し、それを CGImageデータ にして MTKTextureLoadernewTextureメソッドの引数 で渡しています。 [3]
  3. テクスチャに対する フィルタの種類 を指定する
    フィルタの種類を指定することで、テクスチャに紐づくサンプラーもMipmapに対応するようになります。
ContentView.swift
import SwiftUI
import SpriteKit
import SceneKit

// MetalKitをインポートします --- (1)
import MetalKit

struct ContentView: View {
    var currentScene: SKScene {
        let scene = MySKScene()
        scene.scaleMode = .resizeFill
        return scene
    }

    var body: some View {
        SpriteView(scene: self.currentScene)
    }
}

class MySKScene: SKScene {
    override func didMove(to view: SKView) {
        self.anchorPoint = CGPoint(x: 0.5, y: 0.5)

        let scnScene: SCNScene = {
            let scnScene = SCNScene()

            // Mipmapに対応しているMTLTextureオブジェクトを作成します --- (2)
            // 処理をクロージャにまとめます (optional)
            let mipmappedTexture: MTLTexture? = { 
                // テクスチャ画像用にノイズのSKTextureオブジェクトを作成します (optional)
                let noiseTexture = SKTexture(
                    noiseWithSmoothness: 0.05,
                    size: self.frame.size,
                    grayscale: true
                )

                // テクスチャ画像のCGImageデータを取得します
                let cgImage = noiseTexture.cgImage()

                // MTKTextureLoaderオブジェクトを作成します
                let loader = MTKTextureLoader(
                    device: MTLCreateSystemDefaultDevice()!
                )

                // MTKTextureLoaderオブジェクトのnewTextureメソッドを使って
                // CGImageデータからMTLTextureオブジェクトを作成します
                return try? loader.newTexture(
                    cgImage: cgImage,
                    options: [
                        // Mipmapを生成します
                        .generateMipmaps: true
                    ]
                )
            }()

            guard let mipmappedTexture else { return scnScene }

            let material = SCNMaterial()

            // 作成したMTLTextureオブジェクトを、
            // SCNMaterialオブジェクトのdiffuseの画像コンテンツに指定します
            material.diffuse.contents = mipmappedTexture

            // diffuseの画像コンテンツに対するフィルタの種類を指定します --- (3)
            // フィルタの組み合わせによってはMipmapが有効にならない場合があります
            material.diffuse.minificationFilter = .nearest
            material.diffuse.magnificationFilter = .nearest
            material.diffuse.mipFilter = .nearest

            material.shaderModifiers = [
                SCNShaderModifierEntryPoint.fragment: """
                    // テクスチャ座標を取得します
                    vec2 uv = _surface.diffuseTexcoord;

                    // biasの値を計算します
                    vec2 n = vec2(2.0, 4.0);
                    vec2 p = uv * n;
                    float b = floor(p.x) + n.x * floor(p.y);
                    // ※ "bias" という語はMSLで予約語になっている(同名の関数がある)ので
                    // 変数名に使うことはできません

                    // マテリアルのdiffuseに適用されたテクスチャの色を取得します
                    // texture2D関数の3番目の引数でbiasを指定できます
                    vec4 texel = texture2D(u_diffuseTexture, uv, b);

                    // 取得したテクスチャの色を出力します
                    _output.color = texel;
                """
            ]

            // SCNGeometryオブジェクトを作成します
            // この例では平面をSKSceneと同じサイズで作成します
            let geometry = SCNPlane(
                width: self.frame.width,
                height: self.frame.height
            )
            geometry.firstMaterial = material

            let geometryNode = SCNNode(geometry: geometry)
            scnScene.rootNode.addChildNode(geometryNode)

            return scnScene
        }()

        let node = SK3DNode(viewportSize: self.frame.size)
        node.scnScene = scnScene
        self.addChild(node)
    }
}

texture2Dのbias引数を指定

texture2DProj 関数

texture2DProj は投影テクスチャマッピング(射影テクスチャマッピング)で利用するテクスチャ関数です。
シェーダーの共有サービスではあまり見かけない関数 [4] になりますが、3Dコンテンツの制作ではよく使う手法 [5] です。

この関数については、texture2D関数の coord 引数で vec2(uv.xy / uv.w) (もとがvec3の座標の場合は vec2(uv.xy / uv.z) )の値を渡せば、同様の結果を得ることができます(参考:GLSLの仕様書 - 8.7 Texture Lookup Functions の表)。

SceneKitで投影テクスチャマッピング用の変換行列を用意する方法の紹介もかねて、SK3DNode(SceneKit)を使った実装例を掲載します。
※長くなるのでサンプルコードを折りたたみます。

texture2DProj関数と同様のテクスチャを表示するサンプルコード with SK3DNode

今回のサンプルコードで、テクスチャ座標を準備する際の実装のポイントは、 テクスチャの投影用のカメラノードを用意 して、シェーダーへ渡す座標や行列の値の計算に利用するところです。
行列を直接計算して作成することもできますが、カメラノードを使うことで、向きの調整や、透視投影と平行投影の変更などが楽になります。

シェーダーコード内で座標の計算に使っている u_inverseViewTransformu_viewTransform といった行列については こちらの公式ドキュメント に記載があります。

シェーダーコード内のテクスチャ関数を使う箇所で bias 引数を指定したい場合は、 texture2D関数の項目 で説明したとおり、テクスチャとサンプラーをMipmapに対応させてシェーダーに渡す処理をさらに加えてください。

ContentView.swift
import SwiftUI
import SpriteKit
import SceneKit

// このサンプルコードでは、CITextImageGeneratorで絵文字を画像化し、
// それを投影するテクスチャ画像として使用します
// CIFilterをタイプセーフにするためにCIFilterBuiltinsをインポートします (optional)
import CoreImage.CIFilterBuiltins

struct ContentView: View {
    var currentScene: SKScene {
        let scene = MySKScene()
        scene.scaleMode = .resizeFill
        return scene
    }

    var body: some View {
        SpriteView(scene: self.currentScene)
    }
}

class MySKScene: SKScene {
    override func didMove(to view: SKView) {

        // 色を用意します (optional)
        let blue = UIColor(red: 0.42, green: 0.69, blue: 0.74, alpha: 1.0)
        let brown = UIColor(red: 0.71, green: 0.51, blue: 0.22, alpha: 1.0)
        let beige = CIColor(red: 0.93, green: 0.81, blue: 0.69)

        self.backgroundColor = blue
        self.anchorPoint = CGPoint(x: 0.5, y: 0.5)

        let scnScene: SCNScene = {
            let scnScene = SCNScene()

            let material = SCNMaterial()
            material.diffuse.contents = brown

            // 投影する画像のCGImageデータを作成します (optional)
            let projectionImage: CGImage? = {
                // 錨の絵文字を画像化するCIFilterオブジェクトを作成します
                let textImage = CIFilter.textImageGenerator()
                textImage.text = "⚓︎"
                textImage.fontName = "AppleSymbols"
                textImage.fontSize = 36
                textImage.scaleFactor = 12

                // 文字の色を変更するCIFilterオブジェクトを作成します
                // デフォルトの黒色から用意したベージュ色にします
                let colorClampFilter = CIFilter.colorClamp()
                colorClampFilter.inputImage = textImage.outputImage
                colorClampFilter.minComponents = CIVector(
                    x: beige.red,
                    y: beige.green,
                    z: beige.blue,
                    w: 0
                )
                colorClampFilter.maxComponents = CIVector(x: 1, y: 1, z: 1, w: 1)

                guard let ciImage = colorClampFilter.outputImage else { return nil }

                // 用意したCIImageオブジェクトをもとにCGImageデータを作成します
                let context = CIContext(
                    options: [
                        // ColorSpaceを指定します
                        // (CIContextのデフォルト値はリニアsRGB、用意したベージュ色のCIColorはsRGB)
                        .workingColorSpace: beige.colorSpace
                    ]
                )
                return context.createCGImage(
                    ciImage,
                    from: ciImage.extent
                )
            }()

            guard let projectionImage else { return scnScene }

            // CGImageデータをコンテンツに指定して
            // SCNMaterialPropertyオブジェクトを作成します
            let textureMaterialProperty = SCNMaterialProperty(
                contents: projectionImage
            )

            // 作成したSCNMaterialPropertyオブジェクトを
            // uniform変数としてSCNMaterialのシェーダーに渡します
            material.setValue(
                textureMaterialProperty,
                forKey: "projector_texture"
            )

            // 投影テクスチャマッピング用のカメラを作成します
            let projectionNode = SCNNode()
            projectionNode.camera = SCNCamera()
            // 右上手前の位置から原点のやや下方へ向けて投影します
            let angle = Float(0.2)
            let distance = Float(13.0)
            projectionNode.simdPosition = simd_float3(
                x: sin(angle * Float.pi) * distance,
                y: sin(0.25 * Float.pi) * distance,
                z: cos(angle * Float.pi) * distance
            )
            projectionNode.simdLook(at: simd_float3(x: 0, y: -3, z: 0))

            // デフォルトのカメラの設定では透視投影になります
            // 平行投影にしたいときはusesOrthographicProjectionプロパティをtrueにします
            // projectionNode.camera!.usesOrthographicProjection = true
            // 平行投影の倍率も指定できます
            // projectionNode.camera!.orthographicScale = 10.0

            // ワールド座標系での投影用カメラの座標または方向を
            // uniform変数としてSCNMaterialのシェーダーに渡します
            material.setValue(
                SCNVector4(
                    projectionNode.camera!.usesOrthographicProjection == false
                    // 透視投影の場合は座標を渡します
                    // シェーダーコード側での識別用にwの値を1とします
                    ? simd_float4(projectionNode.simdPosition, 1)
                    // 平行投影の場合は方向を渡します
                    // シェーダーコード側での識別用にwの値を0とします
                    : normalize(projectionNode.simdTransform * simd_float4(x: 0, y: 0, z: 1, w: 0))
                ),
                forKey: "projector_worldPosition"
            )

            // ワールド座標を投影用カメラ視点でのテクスチャ座標に変換するための行列を作成します
            // 処理をクロージャにまとめます (optional)
            let projectorMatrix = {
                // ビュー行列を作成します
                let viewMatrix = SCNMatrix4Invert(projectionNode.transform)

                // プロジェクション行列を作成します
                var projectionMatrix = projectionNode.camera!.projectionTransform(
                    withViewportSize: CGSize(
                        width: projectionImage.width,
                        height: projectionImage.height
                    )
                )

                // Metalの仕様に合わせて、クリップ座標のzの値が
                // Reversed-Zの値(nearが1、farが0)になるように
                // プロジェクション行列を変更します
                let far = projectionNode.camera!.zFar
                let near = projectionNode.camera!.zNear
                switch projectionNode.camera!.usesOrthographicProjection {
                case false:
                    // 透視投影用の変更
                    projectionMatrix.m33 = Float(near / (far - near))
                    projectionMatrix.m43 = Float((far * near) / (far - near))
                case true:
                    // 平行投影用の変更
                    projectionMatrix.m33 = Float(1.0 / (far - near))
                    projectionMatrix.m43 = Float(far / (far - near))
                }

                // テクスチャ行列を作成します
                let textureMatrix = SCNMatrix4(
                    m11: 0.5, m12:  0.0, m13: 0.0, m14: 0.0,
                    m21: 0.0, m22: -0.5, m23: 0.0, m24: 0.0,
                    m31: 0.0, m32:  0.0, m33: 1.0, m34: 0.0,
                    m41: 0.5, m42:  0.5, m43: 0.0, m44: 1.0
                )

                // 作成した各行列を合成します
                return SCNMatrix4Mult(
                    viewMatrix,
                    SCNMatrix4Mult(
                        projectionMatrix,
                        textureMatrix
                    )
                )
            }()

            // 作成した投影用のテクスチャ座標への変換行列を
            // uniform変数としてSCNMaterialのシェーダーに渡します
            material.setValue(
                projectorMatrix,
                forKey: "projector_viewProjectionTextureTransform"
            )

            material.shaderModifiers = [
                SCNShaderModifierEntryPoint.surface: """
                    // 受け取るuniform変数を宣言します
                    uniform sampler2D projector_texture;
                    uniform vec4 projector_worldPosition;
                    uniform mat4 projector_viewProjectionTextureTransform;

                    // ワールド座標系での座標の値を計算します
                    // ビュー座標系でのvec3型の座標の値が_surface.positionで得られます
                    // 組み込み変数のu_inverseViewTransformという変換行列とビュー座標を掛けて
                    // ワールド座標系の値にします
                    vec4 surface_worldPosition = u_inverseViewTransform * vec4(_surface.position, 1.0);

                    // 投影用のテクスチャ座標の値を計算します
                    // 事前に計算してシェーダーへ渡しておいた変換行列とワールド座標を掛けて
                    // 投影用のテクスチャ座標の値にします
                    vec4 uv = projector_viewProjectionTextureTransform * surface_worldPosition;

                    // テクスチャの色を取得します
                    // texture2D関数の2番目の引数で uv.xy / uv.w の座標の値を渡すことで
                    // texture2DProj関数と同様の結果が得られます
                    uv /= uv.w;
                    vec4 texel = texture2D(projector_texture, uv.xy);

                    // 座標がテクスチャの投影範囲内(0〜1のあいだ)にあるかどうかを判定します
                    bool k = all(abs(uv.xyz - 0.5) <= 0.5);

                    // 投影範囲外にある座標にテクスチャを反映させないようにします
                    texel.a *= k;  // MSLではbool型からfloat型への暗黙の型変換がサポートされています

                    // 投影用カメラ側を向いていない面にテクスチャを反映させないようにします
                    if (texel.a > 0.0) {
                        // 法線の値をビュー座標系からワールド座標系に変換するための行列を作成します
                        mat3 worldNormalTranform = transpose(  // transeposeはMSLの関数で、転置行列が得られます
                            mat3(
                                u_viewTransform[0].xyz,
                                u_viewTransform[1].xyz,
                                u_viewTransform[2].xyz
                            )
                        );

                        // ワールド座標系での法線の値を計算します
                        vec3 surface_worldNormal = normalize(worldNormalTranform * _surface.normal);

                        // 投影用カメラの方向を計算します
                        vec3 projector_direction = projector_worldPosition.w
                            // 透視投影用
                            ? normalize(projector_worldPosition.xyz - surface_worldPosition.xyz)
                            // 平行投影用
                            : projector_worldPosition.xyz;

                        // 法線と投影用カメラの方向のあいだの角度を計算します
                        float d = dot(surface_worldNormal, projector_direction);

                        // 投影用カメラ側を向いていない面にテクスチャを反映させないようにします
                        texel.a *= step(0.0001, d);
                    }

                    // 投影対象となった位置にテクスチャの色を出力します
                    _surface.diffuse.rgb = mix(_surface.diffuse.rgb, texel.rgb, texel.a);
                """
            ]

            let geometry = SCNBox(
                width: 10,
                height: 10,
                length: 10,
                chamferRadius: 1
            )
            geometry.firstMaterial = material

            let geometryNode = SCNNode(geometry: geometry)
            geometryNode.simdEulerAngles = simd_float3(x: 0, y: 0.2 * Float.pi, z: 0)
            scnScene.rootNode.addChildNode(geometryNode)

            let ambientLightNode = SCNNode()
            ambientLightNode.light = SCNLight()
            ambientLightNode.light!.type = .ambient
            ambientLightNode.light!.color = UIColor(white: 0.8, alpha: 1.0)
            scnScene.rootNode.addChildNode(ambientLightNode)

            // シーンの描画用のカメラを作成します
            let cameraNode = SCNNode()
            cameraNode.camera = SCNCamera()
            cameraNode.simdPosition = simd_float3(x: 0, y: 16, z: 30)
            cameraNode.simdLook(at: simd_float3(x: 0, y: 0, z: 0))
            scnScene.rootNode.addChildNode(cameraNode)

            return scnScene
        }()

        let node = SK3DNode(viewportSize: self.frame.size)
        node.scnScene = scnScene
        self.addChild(node)
    }
}

texture2DProjと同様のテクスチャ表示

textureCube 関数

textureCube はキューブ環境マッピングで利用するテクスチャ関数です。
3Dコンテンツの制作では、背景の空や映りこみの表現のためによく使われます。 [6]

SKShaderではキューブマップテクスチャが扱えません。
代わりに、 bias 引数のケースと同様にSceneKitを利用するとよいでしょう。 SceneKitのShader Modifiers では、texture2D関数にキューブマップテクスチャの画像(サンプラー)とvec3の座標を引数で渡すことが可能で、textureCube関数と同様の結果を得ることができます。

SK3DNode(SceneKit)を使った実装例を掲載します。
※長くなるのでサンプルコードを折りたたみます。

textureCube関数と同様のテクスチャを表示するサンプルコード with SK3DNode

次の3点が実装のポイントになります。

  1. MetalKitimport する
    今回のサンプルコードでは MTLTexture オブジェクトを使用します。そのオブジェクトの作成に MTKTextureLoader を利用するため、 MetalKitimport します。
  2. キューブマップの MTLTexture オブジェクトを作成する [7]
    MTKTextureLoader を使って画像データからテクスチャを作成します。1枚の画像をキューブマップテクスチャ化するときは cubeLayout オプションを指定します。指定できるレイアウトの種類は .vertical のみで、元になる画像データを横1:縦6のアスペクト比で用意しておく必要があります。
    今回用意した画像は下図をご参照ください。SKSpriteNodeとSKShaderのコードで描画しているので、別途ファイルとして用意する必要はありません。
  3. キューブマップ用のテクスチャ座標を計算する
    反射ベクトルと scn_frame.viewToCubeTransform という変換行列を利用して、キューブマップ用のテクスチャ座標を計算できます。この行列については こちらの公式ドキュメント に記載があります。

キューブマップテクスチャの元画像

シェーダーコード内のテクスチャ関数を使う箇所で bias 引数を指定したい場合は、 texture2D関数の項目 で説明したとおり、テクスチャとサンプラーをMipmapに対応させてシェーダーに渡す処理をさらに加えてください。

ContentView.swift
import SwiftUI
import SpriteKit
import SceneKit

// MetalKitをインポートします --- (1)
import MetalKit

struct ContentView: View {
    var currentScene: SKScene {
        let scene = MySKScene()
        scene.scaleMode = .resizeFill
        return scene
    }

    var body: some View {
        SpriteView(scene: self.currentScene)
    }
}

class MySKScene: SKScene {
    override func didMove(to view: SKView) {
        let ivory = UIColor(red: 0.93, green: 0.89, blue: 0.85, alpha: 1.0)
        self.backgroundColor = ivory
        self.anchorPoint = CGPoint(x: 0.5, y: 0.5)

        let scnScene: SCNScene = {
            let scnScene = SCNScene()

            let material = SCNMaterial()

            // キューブマップのMTLTextureオブジェクトを作成します --- (2)
            // 処理をクロージャにまとめます (optional)
            let cubemapTexture: MTLTexture? = {
                // テクスチャ画像用に市松模様のSKTextureオブジェクトを作成します (optional)
                // SKSpriteNodeに適用したSKShaderで市松模様を出力し、
                // それをSKTextureとしてレンダリングします
                let node = SKSpriteNode()
                // ノードのサイズを横1:縦6の割合にします
                node.size = CGSize(width: 200, height: 1200)
                node.shader = SKShader(
                    source: """
                        void main() {
                            // 6色に塗りわけるための色を作ります
                            vec2 p = v_tex_coord * vec2(1.0, 6.0);
                            int l = ceil(p.y);
                            vec3 color =
                                l == 6 ? vec3(0.91, 0.28, 0.04)    // +X面 赤
                                : l == 5 ? vec3(0.80, 0.49, 0.69)  // -X面 紫
                                : l == 4 ? vec3(0.97, 0.71, 0.00)  // +Y面 黄
                                : l == 3 ? vec3(0.34, 0.67, 0.35)  // -Y面 緑
                                : l == 2 ? vec3(0.00, 0.38, 0.69)  // +Z面 青
                                : vec3(0.63, 0.85, 0.94);          // -Z面 水
                            // 市松模様に塗ります
                            p = fract(p) * 7.0;
                            float d = mod(floor(p.x) + floor(p.y), 2.0);
                            gl_FragColor = vec4(mix(color, vec3(1.0), d), 1.0);
                        }
                    """
                )
                guard let skTexture = self.view?.texture(from: node) else { return nil }

                // SKTextureオブジェクトを、CGImageデータ、UIImageデータを経由して
                // Data型に変換します
                let cgImage = skTexture.cgImage()
                let uiImage = UIImage(cgImage: cgImage)
                guard let imageData = uiImage.pngData() else { return nil }

                // MTKTextureLoaderオブジェクトを作成します
                let loader = MTKTextureLoader(
                    device: MTLCreateSystemDefaultDevice()!
                )

                // MTKTextureLoaderオブジェクトのnewTextureメソッドを使って
                // Data型の画像データからMTLTextureオブジェクトを作成します
                // ※CGImageデータからではcubeLayoutオプションの指定が反映されないため、
                // Data型の画像データを使う必要があります
                return try? loader.newTexture(
                    data: imageData,
                    options: [
                        // アスペクト比1:6の縦長の画像をキューブマップテクスチャにします
                        .cubeLayout: MTKTextureLoader.CubeLayout.vertical
                    ]
                )
            }()

            guard let cubemapTexture else { return scnScene }

            // MTLTextureオブジェクトをコンテンツに指定して
            // SCNMaterialPropertyオブジェクトを作成します
            let textureMaterialProperty = SCNMaterialProperty(
                contents: cubemapTexture
            )

            // 作成したSCNMaterialPropertyオブジェクトを
            // uniform変数としてSCNMaterialのシェーダーに渡します
            material.setValue(
                textureMaterialProperty,
                forKey: "cubemap_texture"
            )

            material.shaderModifiers = [
                SCNShaderModifierEntryPoint.surface: """
                    // 受け取るuniform変数を宣言します
                    uniform samplerCube cubemap_texture;

                    // キューブマップのテクスチャ座標を計算します --- (3)
                    vec3 ref = reflect(_surface.position, _surface.normal);
                    ref = (scn_frame.viewToCubeTransform * vec4(ref, 0.0)).xyz;

                    // テクスチャの色を取得します
                    // texture2D関数の1番目の引数でsamplerCube型のテクスチャデータを、
                    // 2番目の引数でvec3型のテクスチャ座標を渡すことで、
                    // textureCube関数と同様の結果が得られます
                    vec4 texel = texture2D(cubemap_texture, ref);

                    // 取得したテクスチャの色を出力します
                    _surface.diffuse.rgb = mix(texel.rgb, _surface.diffuse.rgb, 0.4);
                """
            ]

            let geometry = SCNTorus(ringRadius: 4, pipeRadius: 2)
            geometry.firstMaterial = material

            let geometryNode = SCNNode(geometry: geometry)
            scnScene.rootNode.addChildNode(geometryNode)

            let cameraNode = SCNNode()
            cameraNode.camera = SCNCamera()
            cameraNode.simdPosition = simd_float3(x: 0, y: 15, z: 20)
            cameraNode.simdLook(at: simd_float3(x: 0, y: 0, z: 0))
            scnScene.rootNode.addChildNode(cameraNode)

            return scnScene
        }()

        let node = SK3DNode(viewportSize: self.frame.size)
        node.scnScene = scnScene
        self.addChild(node)
    }
}

textureCubeと同様のテクスチャ表示

まとめ

今回はOpenGL ES 2.0とSKShaderのGLSLのこまかな差分をまとめて紹介しました。

移植時によく発生するエラーでまだ載せられていないものもあると思うので、気がついたら追記する予定です。
最後のテクスチャ関数のところはSKShaderではなくSceneKitの説明ばかりになってしまいましたけれども、この項目を書くにあたってSceneKitまわりをいろいろと調べなおしていて勉強になった(かつ日本語の情報が見つけづらかった)ことも多かったので、そのあたりもそのうち記事として残せたらなあと構想しています。


それでは、SwiftUI × SpriteKitシリーズは、この9記事目でいったん締めようと思います。
第1回のはしがきで需要があるかわからないと書きましたが、お読みくださるかたはチラホラいらっしゃるようで、記事の内容が誰かの問題解決の助けになれていたり、SKShaderを試してみるきっかけを提供できていたのならうれしい限りです。ありがとうございました。


GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit シリーズの記事一覧

参考リンク

脚注
  1. 生成されるMSLのコードは、GPUフレームをキャプチャ し、"Render Pipeline" -> "SKShader_FragFunc" を開くと内容が確認できます。 ↩︎

  2. SKTextureにも usesMipmaps というMipmapを生成するプロパティがありますが、生成したMipmapをSKShaderで利用することはできないようです。似た出力を実現したい場合には、解像度ごとの各テクスチャ画像を用意してシェーダーへ渡し、自力で計算して画像を出しわけるといった方法をとることになるでしょう。 ↩︎

  3. MTKTextureLoadernewTexture メソッドでは、 URLAsset Catalogのファイル名 などを引数で指定して読みこませることもできます。 ↩︎

  4. 執筆時点では、Shadertoyでの textureProj の検索結果は0件でした。 ↩︎

  5. SceneKitの SCNLight にある gobo プロパティも投影テクスチャマッピングの機能を持っています。 ↩︎

  6. SceneKitの SCNScene にある background プロパティ や lightingEnvironment プロパティ、 SCNMaterial にある reflective プロパティもキューブ環境マッピングの機能を持っています。 ↩︎

  7. キューブマップテクスチャを作成する方法は、サンプルコードのやりかた以外にも、Asset CatalogにCube Texture Setのデータを追加しておいて MTKTextureLoader を使って読みこむ、Model I/Oの MDLSkyCubeTexture クラスを使う(太陽と空のキューブマップテクスチャが生成できます)など、複数の手段があります。 ↩︎

Discussion