💠

SwiftUI Metal Shader入門 + GLSL->MSLへの置き換え

2023/11/11に公開

はじめに

  • これまでMetalを触ろうと思うと、queueやpipelineなどの低レイヤーの話がでてきて、ちょっと手を出すにはハードルが高いものでした。
  • そんな折、WWDC2023でSwiftUIで使えるMetal Shaderのmodifierが登場しました。
  • これにより低レイヤーを意識せずに実装が可能となり、Metal Shaderを気軽に試せるようになりました。
  • ただWWDCでもWhat’s new in SwiftUIで軽く触れられるくらいで、また公式ドキュメントも簡潔にしか書かれていません。

  • 幸い技術記事は多いのでこれらを参考に、私の中で体系化した内容や、既存のシェーダー(ShaderToyのGLSL)をMetalへ書き換えるときの話を本記事で行いたいと思います。

参考

GitHub

Shader modifier入門

colorEffect入門

  • まずは実際にShaderをどのように書けるのかを試してみましょう。
  • 今回は引数で渡したColorに塗りつぶすようなShaderを作成します。
  • Shaderのmodifierは以下のように書けます。
  • ShaderFunctionで使用するシェーダーを指定します。
    • 今回はfillColorというシェーダーを指定しており、これは後ほど作成するMetalファイル内で実装します。
    • またargumentsはShaderに渡す引数で、Shader.Argumentというstructに変換して渡します。
    • 今回はSwiftUIのColorShader.Argumentに変換しています。
Rectangle()
    .frame(width: 100, height: 100)
    .colorEffect(
        Shader(function: ShaderFunction(library: .default,name: "fillColor"),
               arguments: [.color(Color.blue)])
    )
  • また以下のようにより簡潔に書くことが出来ます。
    • ShaderLibraryは@dynamicMemberLookupを実装しているのでこのように書けるとのこと(参考)
Rectangle()
    .frame(width: 100, height: 100)
    .colorEffect(
        ShaderLibrary.default.fillColor(.color(Color.blue))
    )
  • では先程宣言したfillColorというMetal Shaderを実装します。
  • 下記のように適当な名前でMetalファイルを作成します。

  • Shader関数は以下の通りです。
  • 言語はMetal Shader Language(以下MSL)を使います。
  • stitchableはMSL関数を表します(参考)
  • half4はこの関数の返り値の型で、fillColorが関数名です。
  • ここでは引数は3つあり、上2つがcolorEffectを使うとデフォルトで渡される値で、それぞれ現在のピクセルの位置と色となります。
    • ピクセル毎にこのMSL関数が呼ばれるので、ピクセル毎の操作が可能となっています。
  • 3つ目以降の引数がargumentsで渡した引数に対応します。
  • colorEffectの場合、関数の返り値が現在の位置のピクセルの色に反映されるため、今回の場合は元の図形がnewColorで塗りつぶされることになります。
#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] half4 fillColor
(
 float2 position, // 現在のピクセルの位置(デフォルトで与えられる)
 half4 color, // 現在のピクセルの色(デフォルトで与えられる)
 half4 newColor
 ) {    
    return newColor; // r, g, b, a
}
  • 以上でビルドをすると以下のように、指定した色で塗りつぶされたRectangleが表示されます。

  • また例えば下記のようにx座標が30px未満の場合に元の色を返す、という風にすると以下の通りになります。
#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] half4 fillColor
(
 float2 position, // 現在のピクセルの位置(デフォルトで与えられる)
 half4 color, // 現在のピクセルの色(デフォルトで与えられる)
 half4 newColor
 ) {
    if (position.x < 30 ) {
        return color;
    }
    return newColor; // r, g, b, a
}

  • またcolorEffectの別の例として、画像をモノクロ化するShaderの実装です。
  • ここでは元のViewの色を使用しており、現在のピクセルの色からグレーの色を算出しています。

Image(.xcode)
    .resizable()
    .frame(width: 100, height: 100)
    .colorEffect(
        ShaderLibrary.default.monochrome()
    )
[[ stitchable ]] half4 monochrome
(
 float2 position,
 half4 color
 ) {
    half v = (color.r + color.g + color.b) / 3.0;
    return half4(v, v, v, 1.0);
}
  • このようにcolorEffect(_:isEnabled:)は、下記の用途で利用できるmodifierといえます。
    • 自由に図形などの描画を行いたい場合
    • 元のViewの色を操作したい場合

Shader modifierの種類とその使い分け

  • さて先程modifierはいくつか種類があると言いましたが、下記の3つがあります。
  • colorEffect(_:isEnabled:)
    • 元のViewの色を操作したい場合(例: モノクロ化・赤色を抜く)
    • 自由に図形などの描画を行いたい場合(元のViewからの情報は描画領域だけを採用する)
  • distortionEffect(_:maxSampleOffset:isEnabled:)
    • 元の画像を変形させたい場合
    • (個人的な考えですが、単体で使う用途が浮かばず、多くの場合でlayerEffectの方が採用されそう)
  • layerEffect(_:maxSampleOffset:isEnabled:)
    • 元のViewを操作してエフェクトをつけたい場合(例: ドット化・ページをめくる効果)
    • colorEffectとdistortionEffectの両方の機能を併せ持つ(※個人的な印象)
    • 実際にこれら2つを使わずにlayerEffectに置き換えられる思います。

distortionEffect入門

  • distortionEffectではcolorEffectと同様に現在のピクセル位置が引数として渡されます。
  • 返り値はピクセル位置を返し、返り値で返したピクセルの色が現在の位置のピクセルの色となります。
  • 表現が難しいので今回のShaderで説明すると…
    • 各ピクセルの参照する色は、現在位置から10px右にいった位置の色を採用する
    • 例えば(x, y) = (10, 10)のピクセルの色は、(20, 10)のピクセルの色とする
    • その結果図形としては10px左にずれた描画となる
#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] float2 shiftToLeft
(
 float2 position // 現在のピクセル位置
 ) {
    return float2(position.x + 10, position.y);
}
  • ※ 見やすいようにいくつか図形を追加しています
ZStack {
    Color.yellow.opacity(0.4)
    Rectangle()
        .stroke(lineWidth: 1)
        .frame(width: 100, height: 100)
        .zIndex(1)
    Rectangle()
        .foregroundStyle(.blue)
        .frame(width: 100, height: 100)
        .distortionEffect(
            ShaderLibrary.default.shiftToLeft(),
            maxSampleOffset: .zero
        )
}
.frame(width: 150, height: 150)
  • またこれだとずらした分の描画がされていない状態です。
  • そこで下記のようにコードを追加してやると、全てが描画されることになります。

Rectangle()
    .foregroundStyle(.blue)
    .frame(width: 100, height: 100)
+   .padding(.horizontal, 10)
+   .drawingGroup()
    .distortionEffect(
        ShaderLibrary.default.shiftToLeft(),
-       maxSampleOffset: .zero  // 図形がカットされる
+       maxSampleOffset: .init(width: 10, height: 0)
    )
  • この辺りの話が私の中でまだ消化しきれていないので、参考元の記事を参照いただけたらと思います。
  • (私の曖昧な理解)
    • paddingを使ってViewの描画範囲を広げる
    • paddingはShaderの対象とならないので、drawingGroupでSwiftUIの一つのViewとして扱うようにする
    • maxSampleOffsetでShader modifierの描画範囲を広げる?
      • (ドキュメントを見てもよく分からないですね…)

distortionEffect(_:maxSampleOffset:isEnabled:)
maxSampleOffset
すべてのソース ピクセルに対する、返されたソース ピクセル位置と宛先ピクセル位置の間の各軸の最大距離。

layerEffect入門

colorEffectとdistortionEffectの両方の機能を併せ持つ(※個人的な印象)

  • この通りcolorEffectdistortionEffectで実装したShaderを、layerEffectで実装することができます。

  • Shaderの実装は以下の通りです。
  • 大きな特徴として引数に現在位置の他にSwiftUI::Layer layerを取ることです。
    • #include <SwiftUI/SwiftUI_Metal.h>で使えるようになり、layer.sample(position)のように任意の位置の色を取得することが出来ます。
  • この機能が果たす役割は大きく、これによりShaderの色々な表現が可能となります。
  • また返り値はcolorEffectと同様に現在位置の色を返します。
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;

[[ stitchable ]] half4 monochromeWithLayerEffect
(
 float2 position,
 SwiftUI::Layer layer
 ) {
    half4 color = layer.sample(position);
    half v = (color.r + color.g + color.b) / 3.0;
    return half4(v, v, v, 1.0);
}

[[ stitchable ]] half4 shiftToLeftWithLayerEffect
(
 float2 position,
 SwiftUI::Layer layer
 ) {
    float2 newPosition = float2(position.x + 10, position.y);
    half4 color = layer.sample(newPosition);
    return color;
}
  • SwiftUI側の実装は以下の通りです。
  • maxSampleOffsetなどはdistortionEffectと同じですね。
Image(.xcode)
    .resizable()
    .frame(width: 100, height: 100)
    .layerEffect(
        ShaderLibrary.default.monochromeWithLayerEffect(),
        maxSampleOffset: .zero
    )
ZStack {
    Color.yellow.opacity(0.4)
    Rectangle()
        .stroke(lineWidth: 1)
        .frame(width: 100, height: 100)
        .zIndex(1)
    Rectangle()
        .foregroundStyle(.blue)
        .frame(width: 100, height: 100)
        .padding(.horizontal, 10)
        .drawingGroup()
        .layerEffect(
            ShaderLibrary.default.shiftToLeftWithLayerEffect(),
            maxSampleOffset: .init(width: 10, height: 0)
        )
}
.frame(width: 150, height: 150)
  • 以上Shader modifierの使い方や使い分けの話でした。

GLSL->MSLへの置き換え

  • 実際に1からShaderを書くというよりは、既存のShaderを流用したいことが多いと思いますので、実際にGLSL(ShaderToy)をMSLに置き換える例を見ていきます。
  • 話の中でGLSLやMSLの文法の細かい話で不明点があれば、後述の「MSLとGLSLの文法」を参照いただけたらと思います。

関数宣言と型

  • まず下記のGLSLで書かれたShaderをMSLに置き換えてみます。

https://www.shadertoy.com/view/Md23DV

  • ShaderToyで与えられるfragColorfragCoordはそれぞれ現在のピクセルの色と位置です。
#elif TUTORIAL == 3

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
	vec3 color = vec3(0.0, 1.0, 1.0);
	float alpha = 1.0;
	
	vec4 pixel = vec4(color, alpha);
	fragColor = pixel;
}
  • まずは関数の宣言をMSLのものに置き換えます。
[[ stitchable ]] half4 aqua
(
 float2 position,
 SwiftUI::Layer layer
 ) {
    vec3 color = vec3(0.0, 1.0, 1.0);
    float alpha = 1.0;
    
    vec4 pixel = vec4(color, alpha);
    fragColor = pixel;
}
  • 次に型を置き換えます。
  • 例えばvec3はfloat3に置き換えます。
[[ stitchable ]] half4 aqua
(
 float2 position,
 SwiftUI::Layer layer
 ) {
-   vec3 color = vec3(0.0, 1.0, 1.0);
+   float3 color = float3(0.0, 1.0, 1.0);
    float alpha = 1.0;
    
-   vec4 pixel = vec4(color, alpha);
+   float4 pixel = float4(color, alpha);
    fragColor = pixel;
}
  • GLSLではfragColorに代入した値がそのピクセルの色として反映されます。
  • 一方MSLの返り値は先述の通り、returnの返り値がピクセルの色で、型はhalf4の変数なのでそのようにします。
    • vecの置換は基本floatでよく、色に関する場合のみMSL側でhalfが登場するという認識で良いと思います。
    • floatは32bit、halfが16bitで精度の差があります。
[[ stitchable ]] half4 aqua
(
 float2 position,
 SwiftUI::Layer layer
 ) {
    float3 color = float3(0.0, 1.0, 1.0);
    float alpha = 1.0;
    
    float4 pixel = float4(color, alpha);
-   fragColor = pixel;    
+   return half4(pixel);
}
  • 以上でMSLに書き直せました!
  • 基本はこのように機械的に置き換えを行っていけばOKです。

boundingRectと座標系

  • 続いて下記を置き換えてみます。

https://www.shadertoy.com/view/Md23DV

  • ※ y座標の確認をしたいため、元のコードのr.xr.yに置き換えています。
#elif TUTORIAL == 7

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
   // (0, 0) ~ (1, 1)に座標を正規化
	vec2 r = vec2(fragCoord.x / iResolution.x,
				  fragCoord.y / iResolution.y);
	
	vec3 color1 = vec3(0.841, 0.582, 0.594);
	vec3 color2 = vec3(0.884, 0.850, 0.648);
	vec3 color3 = vec3(0.348, 0.555, 0.641);
	vec3 pixel;
	
	if( r.y < 1.0/3.0) {
		pixel = color1;
	} else if( r.y < 2.0/3.0 ) {
		pixel = color2;
	} else {
		pixel = color3;
	}
	
	fragColor = vec4(pixel, 1.0);
}
  • まず先程と同様の置き換えを行います。
[[ stitchable ]] half4 tutorial7
(
 float2 position,
 SwiftUI::Layer layer,
 float4 bounds // x,y,z,w = (x, y, width, height)
 ) {
    // (0, 0) ~ (1, 1)に座標を正規化
    float2 r = float2(fragCoord.x / iResolution.x,
                      fragCoord.y / iResolution.y);
    
    float3 color1 = float3(0.841, 0.582, 0.594);
    float3 color2 = float3(0.884, 0.850, 0.648);
    float3 color3 = float3(0.348, 0.555, 0.641);
    float3 pixel;
        
    if( r.y < 1.0/3.0) {
        pixel = color1;
    } else if( r.y < 2.0/3.0 ) {
        pixel = color2;
    } else {
        pixel = color3;
    }
            
    return half4(half3(pixel), 1.0);
}
  • また今回元のViewの大きさが必要となるので、Shader.Argumentで定義されているboundingRectをargumentsに渡しています。
Rectangle()
    .frame(width: 100, height: 100)
    .layerEffect(
        ShaderLibrary.default.tutorial7(.boundingRect),
        maxSampleOffset: .zero
    )
  • GLSLにiResolutionとありますが、これはfloat2型でViewの大きさの情報です。
  • MSLでは先程のboundingRectで渡したfloat4 boundsに該当します。
// Viewの幅
iResolution.x -> bounds.z
// Viewの高さ
iResolution.y -> bounds.w
  • またfragCoordpositionに該当します。
  • 以上の話を反映するとコードは以下の通りになります。
[[ stitchable ]] half4 tutorial7
(
 float2 position,
 SwiftUI::Layer layer,
 float4 bounds // x,y,z,w = (x, y, width, height)
 ) {
    // (0, 0) ~ (1, 1)に座標を正規化
    float2 fragCoord = position;
    float2 r = float2(fragCoord.x / bounds.z,
                      fragCoord.y / bounds.w);
    
    float3 color1 = float3(0.841, 0.582, 0.594);
    float3 color2 = float3(0.884, 0.850, 0.648);
    float3 color3 = float3(0.348, 0.555, 0.641);
    float3 pixel;
        
    if( r.y < 1.0/3.0) {
        pixel = color1;
    } else if( r.y < 2.0/3.0 ) {
        pixel = color2;
    } else {
        pixel = color3;
    }
            
    return half4(half3(pixel), 1.0);
}
  • ただしこれをビルドして表示を確認すると、y座標が反転しまっています。
  • これはGLSLでは原点が左下、MSLでは原点が左上でy座標が反転しているためです。

  • そのため正規化したあとにy座標を反転させるように補正が必要となります。
float2 r = float2(fragCoord.x / bounds.z,
                  fragCoord.y / bounds.w);
r = float2(r.x, 1.0 - r.y);

  • ただ私はしばらくこの方法で書いていたのですが問題点があり、それは座標の正規化の方法が左下が原点(0, 0) ~ (1, 1)中心が原点(-1, -1) ~ (1, 1)の2種類があることです。
  • 後者の場合はy座標に-1をかければいいのですが、正規化の書き方も個人差があり、どちらの座標系か毎回判断するのが手間でした。
  • そこで最初から元の座標を反転させればどちらにも対応できるのではという考えに至り、最終的に下記のように補正をすることにしました。
- float2 fragCoord = position;
+ float2 fragCoord = float2(position.x, bounds.z - position.y);
  • 最終的なコードとしては以下の通りです。
[[ stitchable ]] half4 tutorial7
(
 float2 position,
 SwiftUI::Layer layer,
 float4 bounds // x,y,z,w = (x, y, width, height)
 ) {
    float2 fragCoord = float2(position.x, bounds.z - position.y);
    // (0, 0) ~ (1, 1)に座標を正規化
    float2 r = float2(fragCoord.x / bounds.z,
                      fragCoord.y / bounds.w);
    
    float3 color1 = float3(0.841, 0.582, 0.594);
    float3 color2 = float3(0.884, 0.850, 0.648);
    float3 color3 = float3(0.348, 0.555, 0.641);
    float3 pixel;
        
    if( r.y < 1.0/3.0) {
        pixel = color1;
    } else if( r.y < 2.0/3.0 ) {
        pixel = color2;
    } else {
        pixel = color3;
    }
            
    return half4(half3(pixel), 1.0);
}

その他の置き換えの例

  • 以上が本題で、以下は細かい話を書いています。

MSLとGLSLの文法

  • 私が文法や仕様に関して参考にしたページを下記にまとめます。

GLSLの文法

#define M_PI 3.1415926535897932384626433832795

ShaderToyの仕様

fragCoordが「今から色を決めようとしているピクセルの座標(左下原点)」
fragColorが「xyzw成分にそれぞれそのピクセルのRGBA成分をもったベクトル」です。

よくShadertoyで使われているが、TouchDesignerでやると地味に厄介なのがiMouseです。ShadertoyのiMouseは4つの値を保持しています。
『マウスの左ボタンが押されている間のマウス座標XY』と『マウス左ボタンが押された瞬間のマウス座標XY』です。この後半のが厄介で、マウスがボタンが押されるのをやめた瞬間にXY座標が0になる挙動をします。

MSLの文法

定数定義

#define pi float(3.14159265359)
#define blue half4(0.0, 0.0, 1.0, 1.0)
#define radius float(0.1)

modとfmodの差異

GLSL

https://qiita.com/edo_m18/items/71f6064f3355be7e4f45
x-y*floor(x/y)

MSL

https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf

template<typename Tx, typename Ty>
inline Tx mod(Tx x, Ty y)
{
    return x - y * floor(x / y);
}

mix

  • MSLにmixが定義されていないので関数を定義します。

https://mike-neko.github.io/blog/metal-function/
T mix(T x, T y, T a)
x + (y – x) * a

template<typename Txy, typename Ta>
inline Txy mix(Txy x, Txy y, Ta a)
{
    return x + (y - x) * a;
}

textureLod

  • こちらの記事がとても参考になります。

iOS 17 天気アプリの雨粒演出を作ってみた
その方によるとSwiftUI::Layerにあるsample関数の実装で、samplerのaddress::clamp_to_edgeというオプションをつけないままsamplerを固定しているためにlayerEffectで画像サイズを取得できないという問題があるようです。 そのため自分でsamplerを用意し、layer.texでtextureに直接アクセスして適用することで問題を解決できるということです。

inout

  • GLSLでは参照渡しのinoutが使える。
void disk(vec2 r, vec2 center, float radius, vec3 color, inout vec3 pixel) {
	if( length(r-center) < radius) {
		pixel = color;
	}
}
  • 一方MSLではinoutは難しそうなので代わりに返り値を返す関数で代用する
    • inout Equivalentの話を見ても難しくて分からずじまいでした。。
half3 disk(float2 r, float2 center, float radius, half3 color, half3 pixel) {
    if( length(r-center) < radius) {
        return color;
    }
    return pixel;
}

引数早見表

  • よくShaderに時間やドラッグ位置を渡す場合があるのでその早見表です。

経過時間を渡す例

struct TimeSampleView: View {
    
    private let startDate = Date()
    
    var body: some View {
        TimelineView(.animation) { context in
            let elapsedTime = context.date.timeIntervalSince1970 - self.startDate.timeIntervalSince1970
            RoundedRectangle(cornerRadius: 12)
                .foregroundStyle(.black)
                .frame(width: 100, height: 100)
                .layerEffect(
                    ShaderLibrary.default.timeSample(
                        .float(elapsedTime)
                    ),
                    maxSampleOffset: .zero
                )
        }
    }
}
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;


[[ stitchable ]] half4 timeSample
(
 float2 position,
 SwiftUI::Layer layer,
 float secs
 ) {
    float r = abs(sin(secs * 1.0));
    float g = abs(cos(secs * 1.3));
    float b = abs(tan(secs * 1.5));
    return half4(r, g, b, 1.0);
}

ドラッグ位置を渡す例

  • 余談ですがwithAnimationで使えないAnimationがいくつかあるような動きをしていて、例えばデフォルトや.springはViewの更新が行われませんでした。
struct DragGestureSample: View {
    
    @State private var draggingLocation: CGPoint = .init(x: 50, y: 50)
    
    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .foregroundStyle(.black)
            .frame(width: 100, height: 100)
            .layerEffect(
                ShaderLibrary.default.draggingLocationSample(
                    .boundingRect,
                    .float2(draggingLocation)
                ),
                maxSampleOffset: .zero
            )
            .gesture(
                DragGesture(coordinateSpace: .local).onChanged { value in
                    draggingLocation = value.location
                }.onEnded { value in
                    withAnimation(.easeInOut(duration: 0.1)) {
                        draggingLocation = .init(x: 50, y: 50)
                    }
                }
            )
    }
}
  • GLSL(ShaderToy)ではドラッグ位置はiMouseに相当します。
  • MSLのロジックは気にしなくて良いですが、positionと同様に正規化が必要なところがポイントです。

https://note.com/toyoshimorioka/n/nf908ce35d0ea
ShadertoyのiMouseは4つの値を保持しています。

『マウスの左ボタンが押されている間のマウス座標XY』と『マウス左ボタンが押された瞬間のマウス座標XY』です。この後半のが厄介で、マウスがボタンが押されるのをやめた瞬間にXY座標が0になる挙動をします。

half3 disk(float2 r, float2 center, float radius, half3 fillColor, half3 backgroundColor) {
    if( length(r-center) < radius) {
        return fillColor;
    }
    return backgroundColor;
}

[[ stitchable ]] half4 draggingLocationSample
(
 float2 _position,
 SwiftUI::Layer layer,
 float4 bounds, // x,y,z,w = (x, y, width, height)
 float2 _draggingLocation
 ) {
    // 中心を原点とする正規化
    float2 position = float2(_position.x, bounds.w - _position.y);
    float2 draggingLocation = float2(_draggingLocation.x, bounds.w - _draggingLocation.y);
    float2 uv =  2.0 * float2(position.xy - 0.5 * bounds.zw ) / bounds.w;
    float2 uv_draggingLocation =  2.0 * float2(draggingLocation.xy - 0.5 * bounds.zw ) / bounds.w;
    
    half3 backgroundColor = half3(0.3);
    half3 yellow = half3(1.00, 0.329, 0.298);
    half3 pixel = disk(uv, uv_draggingLocation, 0.5, yellow, backgroundColor);
    
    return half4(pixel, 1.0);
}

SwiftUI Metal Shaderの制限

  • 現状(2023-11-11現在)SwiftUI Metal Shaderにはいくつか制限があります。

SwiftUIのViewにのみ対応

https://developer.apple.com/documentation/swiftui/view/layereffect(_:maxsampleoffset:isenabled:)
Important
Views backed by AppKit or UIKit views may not render into the filtered layer. Instead, they log a warning and display a placeholder image to highlight the error.

Shaderに渡せるImageは1つだけ

  • 現状argumentsでShaderに渡せるImageは1つだけとなっています。

image(_:)
Currently only one image parameter is supported per Shader instance.

Discussion