🎨

GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (4) 〜経過時間とマウス座標〜

2024/06/15に公開

はしがき

SwiftUIでSpriteKitのSKShaderを使って遊んでみようというテーマの記事の4回目です。
前回の記事 では、SKShaderに変数を渡す方法について紹介しました。
今回は SKShader経過時間の変数マウス座標 についてとりあげます。

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 の経過時間

u_time 変数

SKShaderでは u_time 変数で経過時間を取得できます。 SpriteKitがuniform変数としてあらかじめ用意してくれている ので、特別な準備なしでシェーダーのコード内で使えます。

さっそく u_time 変数を利用してみましょう。以下のサンプルコードは、黄色い波線の色が変化するアニメーションのシェーダーです。

ContentView.swift
// 〜略〜

        // ノードを作成します
        let node = SKSpriteNode(color: .clear, size: self.frame.size)

        // シェーダーを作成します
        let shader = SKShader(
            source: """
                void main() {
                    // X座標が0.0〜1.0、Y座標が-1.0〜1.0になるように正規化します
                    vec2 st = vec2(v_tex_coord.x, v_tex_coord.y * 2.0 - 1.0);
                    // 波線のY座標を計算します
                    float y = sin(st.x * 3.1416 * 2.0);
                    // 波線を作ります
                    float d = distance(vec2(st.x, st.y * 3.0), vec2(st.x, y));
                    // 波線の太さが均等に見えるように調整します
                    d = step(d, 0.1 - abs(y) * 0.0293);
                    // 波線の色が2秒ごとに左から右へ流れて見えるように経過時間を使って計算します
                    d *= fract(st.x - u_time / 2.0);
                    // 色を用意します
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    // 波線を黄色で出力します
                    gl_FragColor = mix(vec4(0.0), yellow, d);
                }
            """
        )

        // 作成したシェーダーをノードに適用します
        node.shader = shader

        // 作成したノードをシーンへ追加します
        self.addChild(node)
    }
}

経過時間を使った波線のアニメーション

経過時間のサンプルコード全体
ContentView.swift
import SwiftUI
import SpriteKit

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

    var body: some View {
        ZStack {
            Color(white: 0.8)

            SpriteView(scene: self.currentScene)
                .frame(width: 300, height: 250)
        }
    }
}

class MySKScene: SKScene {
    override func didMove(to view: SKView) {
        let blue = UIColor(red: 0.29, green: 0.59, blue: 0.78, alpha: 1.0)

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

        let node = SKSpriteNode(color: .clear, size: self.frame.size)

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = vec2(v_tex_coord.x, v_tex_coord.y * 2.0 - 1.0);
                    float y = sin(st.x * 3.1416 * 2.0);
                    float d = distance(vec2(st.x, st.y * 3.0), vec2(st.x, y));
                    d = step(d, 0.1 - abs(y) * 0.0293);
                    d *= fract(st.x - u_time / 2.0);
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    gl_FragColor = mix(vec4(0.0), yellow, d);
                }
            """
        )

        node.shader = shader
        self.addChild(node)
    }
}

このように経過時間は簡単に使えます。他所のGLSLのコードを移植するときも、経過時間に該当する変数( iTimetime )を u_time へ書きかえれば動きます。

SKShader のマウス座標

マウス座標 (*) は変数としてあらかじめ用意されていないので、SKScene側で座標を取得してSKShaderへ渡す処理を自前で実装しなければなりません。前回紹介した 変数の渡しかた を理解しておく必要があるので、前記事を適宜読みかえしていただければと思います。
* iPhoneやiPadでは『タッチ座標』と呼ぶほうが適切ですが、この記事では基本的に『マウス座標』と表記します。

マウス座標の値をどういうふうに表現するかについてもいくつか選択肢があると思いますが、今回はShadertoyの挙動を参考に実装してみます(ShadertoyのMouseの見本)。

今回実装するマウス座標の変数の仕様

  • 型は vec4
  • gl_FragCoordと同じ座標軸 を持っている
  • .xy にはマウスのボタンが現在押されている座標が格納される
  • .zw にはマウスのボタンを押下した(ドラッグを開始した)地点の座標が格納される
  • 初期座標は左下端で、 .zw の符号は マイナス になっている
  • マウスのボタン押下時(mousedownイベント相当)に .zw の符号が プラス になる
  • ドラッグ中(mousemoveイベント相当)は .w の符号が マイナス になる
  • クリック(ドラッグ)終了時(mouseupイベント相当)に .zw の符号が マイナス になる
  • クリック(ドラッグ)終了後も座標の数値は保持される

マウスのボタンを押下した地点の座標を示す .zw の符号の反転が込みいっているので整理しましょう。

マウス操作 .zw座標の符号
無操作 (-z, -w)
ボタン押下 (+z, +w)
ドラッグ中 (+z, -w)
クリック(ドラッグ)終了 = 無操作 (-z, -w)

まとめると、

  • .z はマウスのボタンを押しているあいだプラスになる
  • .w はマウスのボタンを押した直後のみプラスになる
  • それ以外ではどちらもマイナスになる

という挙動です。

SpriteKit のタッチイベントの種類

SpriteKitのSKSceneでは、タッチイベントに関する以下のメソッドをoverrideで実装できます。これらを使ってマウス座標を取得して、SKShaderへ値を渡します。

メソッド 用途
touchesBegan マウスのボタン押下時に発生するイベントに対する処理を実装します
touchesMoved ドラッグ中に発生するイベントに対する処理を実装します
touchesEnded クリック(ドラッグ)終了時に発生するイベントに対する処理を実装します

touchesCancelledtouchesEstimatedPropertiesUpdated を使った実装は今回は省略します。

実装サンプル

ここからは少しずつサンプルコードを示しながら説明します。

マウス座標の位置に花を描く

最終的にこのような出力になるようにシェーダーを実装していきます。
※紹介するコードは一例です。画像中にあるタッチイベントのテキストは、別途編集を加えて表示させています。

1. マウス座標を格納するインスタンス変数の作成

まず、マウス座標を格納するuniform変数のオブジェクトを、SKSceneのインスタンス変数として作成します。タッチイベント発生時の処理で、このuniform変数の値を更新していきます。
以降、『uniform変数』と書いた場合はこのインスタンス変数のことを指します。

ContentView.swift
// 〜略〜

class MySKScene: SKScene {

    // マウス座標を格納するuniform変数を作成します
    let mouseLocation = SKUniform(
        name: "u_mouse"
    )

    override func didMove(to view: SKView) {
// 〜略〜

このサンプルでは、シェーダー内で使用する変数名を u_mouse にしていますが、任意の変数名を指定できます。

2. didMove メソッド内でのマウスの初期座標の設定

マウス座標のuniform変数を作成する時点では変数名のみを設定しておき、次の didMove メソッド内で、初期座標がシーンの左下になるようにvec4の値を設定します。

ここまで詳しい説明を省いてきましたが、 didMove は、SKSceneがViewに表示されるタイミングで呼ばれるメソッドです。シーンに最初から表示させておきたいノードオブジェクトやアクションなどをここで実装します。
didMove メソッド内では self.frame.widthself.frame.height プロパティで、シーンの横・縦のサイズをポイント値で取得できます。

ContentView.swift
// 〜略〜

    override func didMove(to view: SKView) {

        // シーンの中央を基準にノードが配置されるようにします
        // デフォルトではシーンの左下の原点が基準になっています
        // anchorPointの設定は、タッチイベント発生時の処理で取得できる
        // マウス座標の値にも影響します
        self.anchorPoint = CGPoint(x: 0.5, y: 0.5)  // --- (2)

        // ノードを作成します
        let node = SKSpriteNode(color: .clear, size: self.frame.size)

        // シェーダーを作成します
        let shader = SKShader(
            source: 〜後述〜
        )

        // マウスの初期座標がシーンの左下になるように
        // uniform変数の値を設定します --- (1)
        self.mouseLocation.vectorFloat4Value = vector_float4(
            Float.zero,
            Float(self.frame.height * UITraitCollection.current.displayScale),
            Float(-0.0),
            Float(-self.frame.height * UITraitCollection.current.displayScale)
        )

        // シーンの解像度の値でuniform変数を作成します
        let resolution = SKUniform(
            name: "u_resolution",
            vectorFloat2: vector_float2(
                Float(self.frame.width),
                Float(self.frame.height)
            ) * Float(UITraitCollection.current.displayScale)
        )

        // マウス座標の値とシーンの解像度の値を、
        // uniform変数としてシェーダーに渡します
        shader.uniforms = [self.mouseLocation, resolution]

        // 作成したシェーダーをノードに適用します
        node.shader = shader

        // 作成したノードをシーンへ追加します
        self.addChild(node)
    }

// 〜略〜

シーンの解像度の計算に関しては 第2回の記事 で解説していますので、詳細はそちらをご参照ください。 UITraitCollection.current.displayScale プロパティを利用してRetinaの倍率を掛けてピクセル値にするところが大事な点です。

(1) マウスの初期座標の計算

コード中の (1) の箇所で、インスタンス変数 mouseLocationvectorFloat4Value プロパティの値を、マウスの初期座標であるシーンの左下に設定しています。X座標は 0.0 、Y座標は シーンの高さのピクセル値 で、 .zw の符号は マイナス です。
「初期座標を左下にするということは、シーンの左下が原点で (0.0, 0.0) なのだから .xyzw は (0.0, 0.0, -0.0, -0.0) になるのでは」と思うかもしれません。しかしここで考慮しなければならないのが、

  • gl_FragCoordと同じ座標軸 を持っている

という、今回実装するマウス座標の仕様です。
SKShaderの gl_FragCoord に関しても 第2回の記事 で書きましたが、 原点がシーンの左上 になっています。この gl_FragCoord の座標軸における『左下』の座標は (0.0, シーンの高さ) なので、Y座標には シーンの高さ × Retinaの倍率 を計算して得られるピクセル値を設定します。

[参考]gl_FragCoordの座標軸

参考までに、 gl_FragCoord を0.0〜1.0の範囲にする正規化のみを行った状態で、座標を色で出力してみます。

void main() {
    // 座標が0.0〜1.0になるように正規化します
    vec2 st = gl_FragCoord.xy / u_resolution;
    // X座標の値を赤色、Y座標の値を緑色で出力します
    gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}

gl_FragCoordの座標軸

このように、シーンの左上が原点(黒色)になっています。

(2) アンカーポイントの設定

コード中の (2) の箇所の anchorPoint についてもここまで説明を省略していましたが、このプロパティでは、 ノードオブジェクトを配置するときの基準となる位置比率 で指定できます。
SKSceneanchorPoint プロパティのデフォルト値はシーン左下の (0.0, 0.0) で、 SKSpriteNodeanchorPoint プロパティのデフォルト値はノード中央の (0.5, 0.5) です。

スクリーンショットで見比べたほうがわかりやすいと思うので、シーンとノードのアンカーポイントの値を何パターンか組み合わせたものを並べてみます。青色の部分がシーンの背景で、緑色の四角形がシーンに配置されたノードオブジェクトです。

アンカーポイントの組み合わせによる配置の違い

上段左のNo.1が、アンカーポイントを設定しないデフォルト値の状態での配置です。
ノードのアンカーポイントである中央 (0.5, 0.5) の位置が、シーンのアンカーポイントである左下 (0.0, 0.0) の位置へくるので、ノードの左半分と下半分がシーンの領域外に出ています。

下段左のNo.4が、当シリーズ記事のコード例でこれまで利用してきた設定です。
ノードのアンカーポイントである中央 (0.5, 0.5) の位置が、シーンのアンカーポイントである中央 (0.5, 0.5) の位置へきて、ノードがシーンの真ん中に表示されています。

描画したいコンテンツによって最適なアンカーポイントの設定は異なるでしょうから、状況に応じて値を決めるとよいと思います。ノードオブジェクトの位置は position プロパティでも調整が可能です。

また、上記コード例のコメントで、

// anchorPointの設定は、タッチイベント発生時の処理で取得できる
// マウス座標の値にも影響します

と書きました。いったい何に影響があるのかというと、 シーンのアンカーポイントの位置が、取得できるマウス座標の原点になる のです。次のセクションで関連するコードが出てくるので、頭の片隅に入れておいてください。

3. タッチイベント発生時の処理

マウス座標をシェーダーへ渡す準備が整ったところで、マウスを操作(タッチ操作)したときの座標を取得するコードを書いていきます。

3-1. マウスのボタン押下時のマウス座標(touchesBegan)

touchesBegan メソッドでマウスのボタンの押下を開始した地点の座標を取得し、その値でマウス座標のuniform変数を更新してシェーダーへ渡しましょう。冒頭の数行は、UIKitでタッチイベントの処理を実装した経験があるかたには見慣れたコードだと思います。

ContentView.swift
// 〜略〜

    // マウスのボタン押下時の処理 (mousedownイベント相当)
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {

        // 最初のタッチオブジェクトを取得します
        guard let touch = touches.first else { return }

        // シーンに対するタッチ座標を取得します
        let location = touch.location(in: self)

        // 取得したタッチ座標を、シェーダーへ渡すマウス座標に適した値に変換します --- (1)
        let locationInShader = CGPoint(
            // X座標について、シーンのanchorPointの位置に関わらず
            // シーンの左端が0.0、右端がシーンの幅のピクセル値
            // になるように計算します
            x: (location.x - self.frame.origin.x) * UITraitCollection.current.displayScale,

            // Y座標について、シーンのanchorPointの位置に関わらず
            // シーンの上端が0.0、下端がシーンの高さのピクセル値
            // になるように計算します
            // gl_FragCoordの座標軸に合わせて左上を原点としています
            y: (self.frame.height - (location.y - self.frame.origin.y)) * UITraitCollection.current.displayScale
        )

        // 計算して得られたマウス座標で、uniform変数の .xyzw の値を更新します
        self.mouseLocation.vectorFloat4Value = vector_float4(
            Float(locationInShader.x),
            Float(locationInShader.y),

            // Shadertoyではマウスのボタンを押下した地点の座標が .zw に格納されます
            // ボタンを押した時点の符号はプラスになります
            Float(locationInShader.x),
            Float(locationInShader.y)
        )
    }

// 〜略〜

コード中の (1) の箇所の計算について、もう少し説明します。
直前の let location = touch.location(in: self) で、シーンに対するタッチ座標を取得しています。 location 変数に入る xy のプロパティの値は、シーンのアンカーポイントの位置を原点 (0.0, 0.0) としたタッチ座標の値で、単位はポイントです。
このタッチ座標の値を、シェーダー内で扱うのに適した、シーンの左上を原点としたピクセル単位のマウス座標へと変換します。

シーンで取得できるタッチ座標 シェーダーで扱いたいマウス座標
座標の原点 シーンのアンカーポイントの位置 シーンの左上(gl_FragCoordの原点)
座標系 右手系 左手系(gl_FragCoordに合わせる)
数値の単位 ポイント ピクセル

X座標の変換の部分では、

x: (location.x - self.frame.origin.x) * UITraitCollection.current.displayScale

という計算をしています。
self.frame.origin プロパティでは、アンカーポイントの位置を基準としたシーンの原点(左下)の位置が得られます。シーンのアンカーポイントが x: 0.5 で、シーンの幅が width: 300.0 なら、返ってくる値は x: -150.0 です。
シーンのタッチ座標からこの値を引いて、さらにRetinaの倍率を掛ければ、シェーダー用のマウスのX座標になります。

Y座標の変換の部分では、

y: (self.frame.height - (location.y - self.frame.origin.y)) * UITraitCollection.current.displayScale

という計算をしています。
X座標と同じくアンカーポイントぶんの調整に加えてY軸の上下の反転を織りこむため self.frame.height(シーンの高さ) - (location.y - self.frame.origin.y) とします。それにRetinaの倍率を掛ければ、シェーダー用のマウスのY座標になります。

マウス座標の計算方法はいろいろ考えられますが、アンカーポイントの設定に関わらず座標が取得できて使いまわしがきくので、今回はこのやりかたで実装しました。
他の実装例としては、座標の基準とするための空ノードをシーンの左上にひとつ配置しておき、 let location = touch.location(in: <シーンの左上に配置したノードオブジェクト>) で最初から左上原点のタッチ座標を取得しておいて、Y座標の符号の反転とピクセル値への変換を行ってシェーダーへ渡す、といった方法もシンプルでよいでしょう。

3-2. ドラッグ中のマウス座標(touchesMoved)

次はドラッグ中の処理へ進みます。
さきほどの (1) のマウス座標の変換処理をこちらでも使うので、再利用できるようにメソッドへ切りだしておきます。

ContentView.swift
  // 〜略〜

+     private func convertLocationForShader(_ location: CGPoint) -> CGPoint {
+         return CGPoint(
+             x: (location.x - self.frame.origin.x) * UITraitCollection.current.displayScale,
+             y: (self.frame.height - (location.y - self.frame.origin.y)) * UITraitCollection.current.displayScale
+         )
+     }

      override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
          guard let touch = touches.first else { return }
          let location = touch.location(in: self)

-         let locationInShader = CGPoint(
-             x: (location.x - self.frame.origin.x) * UITraitCollection.current.displayScale,
-             y: (self.frame.height - (location.y - self.frame.origin.y)) * UITraitCollection.current.displayScale
-         )
+         let locationInShader = convertLocationForShader(location)

          self.mouseLocation.vectorFloat4Value = vector_float4(
              Float(locationInShader.x),
              Float(locationInShader.y),
              Float(locationInShader.x),
              Float(locationInShader.y)
          )
      }

  // 〜略〜

touchesMoved メソッドを使って、ドラッグ中の現在のマウスの座標を取得して、その値をシェーダーへ渡します。このメソッドは、ドラッグ操作をしているあいだ、タッチする位置が移動するたびに繰り返し呼ばれます。

ContentView.swift
// 〜略〜

    // ドラッグ中の処理 (mousemoveイベント相当)
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {

        // 最初のタッチオブジェクトを取得します
        guard let touch = touches.first else { return }

        // シーンに対するタッチ座標を取得します
        let location = touch.location(in: self)

        // タッチ座標がシーンの範囲外だった場合は無視します --- (2)
        guard self.frame.contains(location) else { return }

        // 取得したタッチ座標を、シェーダーへ渡すマウス座標に適した値に変換します
        let locationInShader = convertLocationForShader(location)

        // 計算して得られたマウス座標で、uniform変数の .xy の値を更新します --- (3)
        self.mouseLocation.vectorFloat4Value[0] = Float(locationInShader.x)
        self.mouseLocation.vectorFloat4Value[1] = Float(locationInShader.y)

        // Shadertoyではmousemove時に .w の値がマイナスになります --- (4)
        if self.mouseLocation.vectorFloat4Value[3].sign == .plus {
            self.mouseLocation.vectorFloat4Value[3].negate()
        }
    }

// 〜略〜

シーンのタッチ座標をシェーダー用のマウス座標へ変換するまでの処理は touchesBegan での内容とおおむね同じですが、タッチ座標がシーンの範囲の外側だった場合にそれ以降の処理を行わないよう、(2) の箇所にguard文を追加しています。

(3) の箇所で、uniform変数の .xy の値のみ、現在マウスを動かしている座標へ更新しています。

(4) の箇所では、

  • ドラッグ中(mousemoveイベント相当)は .w の符号が マイナス になる

という仕様を満たすために、uniform変数の .w の符号を negate メソッドでプラスからマイナスにしています。
touchesMoved メソッドは繰り返し呼ばれるため、いったん負にした値を再度 negate メソッドで反転させて正値に戻してしまわないように、if文で符号がプラスかどうかを確認しています。

3-3. クリック(ドラッグ)終了時のマウス座標(touchesEnded)

touchesEnded メソッドを使って、クリック(ドラッグ)を終了した時点のマウス座標を取得して、その値をシェーダーへ渡します。

ContentView.swift
// 〜略〜

    // クリック(ドラッグ)終了時の処理 (mouseupイベント相当)
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {

        // 最初のタッチオブジェクトを取得します
        guard let touch = touches.first else { return }

        // シーンに対するタッチ座標を取得します
        let location = touch.location(in: self)

        // タッチ座標がシーンの範囲外だった場合は無視します
        guard self.frame.contains(location) else { return }

        // 取得したタッチ座標を、シェーダーへ渡すマウス座標に適した値に変換します
        let locationInShader = convertLocationForShader(location)

        // 計算して得られたマウス座標で、uniform変数の .xy の値を更新します
        self.mouseLocation.vectorFloat4Value[0] = Float(locationInShader.x)
        self.mouseLocation.vectorFloat4Value[1] = Float(locationInShader.y)

        // Shadertoyではmouseup時に .z の値がマイナスになります --- (5)
        self.mouseLocation.vectorFloat4Value[2].negate()

        // mousemoveを経ずにmouseupした場合にも .w の値をマイナスにします --- (6)
        if self.mouseLocation.vectorFloat4Value[3].sign == .plus {
            self.mouseLocation.vectorFloat4Value[3].negate()
        }
    }

// 〜略〜

得られたマウス座標でuniform変数の .xy の値を更新するところまでは、 touchesMoved と同じです。

(5) の箇所では、

  • クリック(ドラッグ)終了時(mouseupイベント相当)に .zw の符号が マイナス になる

という仕様を満たすために、uniform変数の .z の符号をプラスからマイナスにしています。
また、 touchesBegan 後、 touchesMoved を経ずに touchesEnded イベントが発生した場合はuniform変数の .w の符号がプラスのままなので、(6) の箇所でマイナスになるようにしています。

4. シェーダーのソースコード

最後に、説明をあとまわしにしていたシェーダーのソースコードです。
マウス座標に花型を描画するシェーダーで、マウスのボタンを押した直後は花型をピンク色で、それ以外のときは黄色で表示します。ドラッグ中は、ドラッグを開始した位置に少し小さな花型を薄い黄色で表示します。

ContentView.swift
// 〜略〜

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

        let node = SKSpriteNode(color: .clear, size: self.frame.size)

        // シェーダーを作成します
        let shader = SKShader(
            source: """
                void main() {
                    // 描画領域の座標を、短辺が0.0〜1.0になるように正規化します
                    vec2 st = gl_FragCoord.xy / min(u_resolution.x, u_resolution.y);

                    // マウス座標を、短辺が0.0〜1.0になるように正規化します
                    vec4 m = u_mouse / min(u_resolution.x, u_resolution.y);

                    // 花型の形状の作成で使う変数を初期化します
                    vec2 p = vec2(0.0);  // 座標調整用
                    float r = 0.0;       // 形状の半径用
                    float d = 0.0;       // 形状用

                    // 色を用意します
                    vec4 blue = vec4(0.29, 0.59, 0.78, 1.0);
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    vec4 pink = vec4(0.94, 0.69, 0.75, 1.0);

                    // 出力する色用の変数を用意します
                    vec4 color = blue;

                    // ドラッグ中はドラッグ開始座標に薄黄色の花型を描画します
                    if (u_mouse.z > 0.0) {
                        // 花型の半径を指定します
                        r = 0.2;
                        // ドラッグ開始座標を中心に花型が描画されるように座標を調整します
                        p = (st - abs(m.zw)) / r;
                        // 花型を作ります
                        d = step(length(p), abs(sin(atan(p.y, p.x) * 4.0)));
                        // 花型を薄黄色で出力します
                        color = mix(color, yellow, min(0.6, d));
                    }

                    // 花型の半径を指定します
                    r = 0.25;
                    // マウス座標を中心に花型が描画されるように座標を調整します
                    p = (st - m.xy) / r;
                    // 花型を作ります
                    d = step(length(p), abs(sin(atan(p.y, p.x) * 4.0)));
                    // 花型を、マウスのボタン押下直後はピンク色で、それ以外は黄色で出力します
                    gl_FragColor = mix(color, u_mouse.w > 0.0 ? pink : yellow, d);
                }
            """
        )

// 〜略〜

これで完成です!

マウス座標の位置に花を描く

マウス座標のサンプルコード全体
ContentView.swift
import SwiftUI
import SpriteKit

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

    var body: some View {
        ZStack {
            Color(white: 0.8)

            SpriteView(scene: self.currentScene)
                .frame(width: 300, height: 250)
        }
    }
}

class MySKScene: SKScene {

    let mouseLocation = SKUniform(
        name: "u_mouse"
    )

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

        let node = SKSpriteNode(color: .clear, size: self.frame.size)

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = gl_FragCoord.xy / min(u_resolution.x, u_resolution.y);
                    vec4 m = u_mouse / min(u_resolution.x, u_resolution.y);

                    vec2 p = vec2(0.0);
                    float r = 0.0;
                    float d = 0.0;

                    vec4 blue = vec4(0.29, 0.59, 0.78, 1.0);
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    vec4 pink = vec4(0.94, 0.69, 0.75, 1.0);
                    vec4 color = blue;

                    if (u_mouse.z > 0.0) {
                        r = 0.2;
                        p = (st - abs(m.zw)) / r;
                        d = step(length(p), abs(sin(atan(p.y, p.x) * 4.0)));
                        color = mix(color, yellow, min(0.6, d));
                    }

                    r = 0.25;
                    p = (st - m.xy) / r;
                    d = step(length(p), abs(sin(atan(p.y, p.x) * 4.0)));
                    gl_FragColor = mix(color, u_mouse.w > 0.0 ? pink : yellow, d);
                }
            """
        )

        self.mouseLocation.vectorFloat4Value = vector_float4(
            Float.zero,
            Float(self.frame.height * UITraitCollection.current.displayScale),
            Float(-0.0),
            Float(-self.frame.height * UITraitCollection.current.displayScale)
        )

        let resolution = SKUniform(
            name: "u_resolution",
            vectorFloat2: vector_float2(
                Float(self.frame.width),
                Float(self.frame.height)
            ) * Float(UITraitCollection.current.displayScale)
        )

        shader.uniforms = [self.mouseLocation, resolution]
        node.shader = shader
        self.addChild(node)
    }

    private func convertLocationForShader(_ location: CGPoint) -> CGPoint {
        return CGPoint(
            x: (location.x - self.frame.origin.x) * UITraitCollection.current.displayScale,
            y: (self.frame.height - (location.y - self.frame.origin.y)) * UITraitCollection.current.displayScale
        )
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let location = touch.location(in: self)

        let locationInShader = convertLocationForShader(location)

        self.mouseLocation.vectorFloat4Value = vector_float4(
            Float(locationInShader.x),
            Float(locationInShader.y),
            Float(locationInShader.x),
            Float(locationInShader.y)
        )
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let location = touch.location(in: self)

        guard self.frame.contains(location) else { return }

        let locationInShader = convertLocationForShader(location)

        self.mouseLocation.vectorFloat4Value[0] = Float(locationInShader.x)
        self.mouseLocation.vectorFloat4Value[1] = Float(locationInShader.y)

        if self.mouseLocation.vectorFloat4Value[3].sign == .plus {
            self.mouseLocation.vectorFloat4Value[3].negate()
        }
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let location = touch.location(in: self)

        guard self.frame.contains(location) else { return }

        let locationInShader = convertLocationForShader(location)

        self.mouseLocation.vectorFloat4Value[0] = Float(locationInShader.x)
        self.mouseLocation.vectorFloat4Value[1] = Float(locationInShader.y)

        self.mouseLocation.vectorFloat4Value[2].negate()

        if self.mouseLocation.vectorFloat4Value[3].sign == .plus {
            self.mouseLocation.vectorFloat4Value[3].negate()
        }
    }
}

まとめ

今回は、経過時間の変数の使いかたと、マウス座標を取得してSKShaderへ渡す方法についてご紹介しました。

これらを活用すると、ユーザーがタッチした地点を中心に印象的なエフェクトを表示する、みたいな演出も作れますね。
また、ここまでに紹介してきた解像度、経過時間、マウス座標のuniform変数があれば、Shadertoyなどで公開されているさまざまなシェーダーコードがSKShaderでも試せるようになったのではないかと思います。

さて次回は、SKSpriteNodeとSKShaderでの テクスチャ の基本的な扱いかたについてまとめる予定です。よろしければ引き続きおつきあいください。


次記事 → GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (5) 〜テクスチャと色 前編〜

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

参考リンク

Discussion