🎨

GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (2) 〜座標〜

2024/06/02に公開

はしがき

SwiftUIでSpriteKitのSKShaderを使って遊んでみようというテーマの記事の2回目です。
第1回の記事 では、SpriteKitやSKShaderの概要と、SwiftUIでそれらを表示するための最小限のコードを紹介しました。
今回は SKShaderの座標 についてとりあげます。

GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit シリーズの記事一覧
  1. GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (1)
  2. GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (2) 〜座標〜(本記事)
  3. GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (3) 〜変数〜
  4. GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (4) 〜経過時間とマウス座標〜
  5. GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (5) 〜テクスチャと色 前編〜(準備中)
  6. GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (6) 〜テクスチャと色 後編〜(準備中)
  7. GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (7) 〜ノード〜(準備中)
  8. TBD ...

必要な前提知識

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

  • 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 の座標

SKShader で座標を取得できる変数の種類

シェーダーを書いていくうえでまず理解しておかなければならないのは 座標 についてでしょう。
SKShaderには座標を取得できる変数が2種類あります。
gl_FragCoordv_tex_coord です。

gl_FragCoord 変数

gl_FragCoord 変数では、OpenGL 2.xの組み込み変数と同じく ピクセル座標 を取得できます。
ただし、SKShaderで gl_FragCoord 変数を使う際の注意点が2つあります。

注意点1・原点

座標の原点シーンの左上 になっています。OpenGLの標準仕様では左下が原点なので、Y軸が反転して 上下が逆 になります。

なぜSKShaderでは左上が原点なのかというと、レンダラーであるMetalの座標系がそうなっているからだと考えられます。 前回の記事 でも書いたとおり、内部ではGLSLのコードをMSLに変換してMetalレンダラーで描画しています。この変換の際に、座標の右手系 ⇔ 左手系もいい感じに処理してくれればありがたかったのですが、どうやらそうはなっておらず、左手系の座標でGLSLのコードを扱う挙動になっているのでしょう。
このため、左下原点の環境用に作られたGLSLのコードを移植するときは、 Y軸を反転させないと元の出力を再現できません

念のため、座標の範囲が シーン に対するものであることも覚えておいてください。原点の位置はデバイスの画面の左上ではなく、SKShaderを適用するノードの左上でもありません。シーンの左上が基準になっています。

注意点2・単位

サイズの単位が ピクセル です。これは標準仕様と同じですが、どうしてピクセルであることに気をつけなければならないのかというと、SwiftUIやSpriteKitのコードでframeサイズを指定するときに使う単位が ポイント になっているからです。

前回の記事のサンプルコードでは、幅300ポイント×高さ250ポイント のframeサイズのシーンを作成しました。

ContentView.swiftから抜粋
SpriteView(scene: self.currentScene)
    .frame(width: 300, height: 250)
前回の記事のサンプルコード全体
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)
        let green = UIColor(red: 0.64, green: 0.81, blue: 0.46, alpha: 1.0)

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

        let node = SKSpriteNode()
        node.color = green
        node.size = CGSize(width: 250, height: 200)

        let shader = SKShader(
            source: """
                void main() {
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    gl_FragColor = yellow;
                }
            """
        )

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

シェーダー側で解像度を踏まえた計算をするためにシーンのサイズを扱いたいときに、 vec2 resolution = vec2(300.0, 250.0); としてしまうと、Retinaディスプレイでは 1 point = 1 pixel ではない ので計算が合わなくなってしまいます。ポイントサイズにRetinaの倍率を掛けて、ピクセルサイズを算出する必要があるのです。
Retinaの倍率はデバイスによって異なり、その値は UITraitCollection.current.displayScale プロパティで取得できます。

座標の正規化

注意点1・2を踏まえて、座標の正規化を行ってみます。
※下記コードは一例です。コード中に出てくるuniform変数の渡しかたについては、次回の記事 で解説します。

ContentView.swift
  // 〜略〜

          let shader = SKShader(
              source: """
                  void main() {
-                     vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
-                     gl_FragColor = yellow;
+                     // 短辺に合わせて座標を正規化します
+                     vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
+                     // Y座標を上下反転させます
+                     st.y *= -1.0;
+                     // X座標の値を赤色、Y座標の値を緑色で出力します
+                     gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
                  }
              """
          )

+         // シーンの解像度の値をuniform変数としてシェーダーに渡します
+         shader.addUniform(
+             SKUniform(
+                 // シェーダー内で使用する変数名を指定します
+                 name: "u_resolution",
+                 // シーンの解像度(ピクセル値)を計算して渡します
+                 vectorFloat2: vector_float2(
+                     // シーンの幅にRetinaの倍率を掛けます
+                     Float(self.frame.width * UITraitCollection.current.displayScale),
+                     // シーンの高さにRetinaの倍率を掛けます
+                     Float(self.frame.height * UITraitCollection.current.displayScale)
+                 )
+             )
+         )

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

gl_FragCoordの座標を正規化して出力

サンプルコード全体
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()
        node.size = CGSize(width: 250, height: 200)

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
                    st.y *= -1.0;
                    gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
                }
            """
        )

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

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

座標が正規化されて、シーンの中央が原点 (0.0, 0.0)、右上が (1.2, 1.0) になりました。
このスクリーンショットだけでは座標がわかりづらいと思うので、こんどは中心から円を描いてみます。

ContentView.swift
  // 〜略〜

          let shader = SKShader(
              source: """
                  void main() {
                      vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
                      st.y *= -1.0;
-                     gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
+                     // 原点からの距離を計算します
+                     float l = length(st);
+                     // 色を用意します
+                     vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
+                     // 原点からの距離を黄色で出力します
+                     gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
                  }
              """
          )

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

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

gl_FragCoordの座標を正規化して原点から円を描く

中央の座標が (0.0, 0.0) になっていることが、よりわかりやすくなったのではないかと思います。
しかし、ノードの高さ(200ポイント)がシーンの高さ(250ポイント)よりも短いので、円の上下がはみ出してしまっていますね。ノードのサイズをシーンと同じにしてみるとどうなるでしょうか。

ContentView.swift
  // 〜略〜

-         node.size = CGSize(width: 250, height: 200)
+         // ノードのサイズをシーンのサイズと同じにします
+         node.size = self.frame.size

          let shader = SKShader(
              source: """
                  void main() {
                      vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
                      st.y *= -1.0;
                      float l = length(st);
                      vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                      gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
                  }
              """
          )

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

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

ノードとシーンのサイズを同じにして円を描く

円の上下がぴったり収まりました。
このとおり、 gl_FragCoord 変数では シーンの左上を原点 とした ピクセル座標 が取得できます。

gl_FragCoord変数のサンプルコード全体
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()
        node.size = self.frame.size

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
                    st.y *= -1.0;
                    float l = length(st);
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
                }
            """
        )

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

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

v_tex_coord 変数

v_tex_coord 変数では、ノードのテクスチャ座標を取得できます。 SpriteKitがvarying変数としてあらかじめ用意してくれている ので、特別な準備なしでシェーダーのコード内で使えます。

gl_FragCoord変数とは違って ノードの左下が原点 (0.0, 0.0) で、 右上が (1.0, 1.0) になるように正規化されています。
テクスチャに関してはまた別の記事で紹介するとして、まずは実際にシェーダーを描いて座標を確認してみましょう。

ContentView.swift
// 〜略〜

        // ノードのサイズを250x200にします
        node.size = CGSize(width: 250, height: 200)

        let shader = SKShader(
            source: """
                void main() {
                    // テクスチャ座標を取得します
                    vec2 st = v_tex_coord;
                    // X座標の値を赤色、Y座標の値を緑色で出力します
                    gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
                }
            """
        )

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

v_tex_coordでテクスチャ座標を出力

サンプルコード全体
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()
        node.size = CGSize(width: 250, height: 200)

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = v_tex_coord;
                    gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
                }
            """
        )

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

中央を原点にして円を描くと次のようになります。

ContentView.swift
  // 〜略〜

          node.size = CGSize(width: 250, height: 200)

          let shader = SKShader(
              source: """
                  void main() {
-                     vec2 st = v_tex_coord;
-                     gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
+                     // 右上が (1.0, 1.0) に正規化された状態を維持しつつ、原点を中央に移動させます
+                     vec2 st = v_tex_coord * 2.0 - 1.0;
+                     // 原点からの距離を計算します
+                     float l = length(st);
+                     // 色を用意します
+                     vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
+                     // 原点からの距離を黄色で出力します
+                     gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
                  }
              """
          )

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

v_tex_coordの座標を正規化して原点から円を描く

ノードの縦横の長さが違うので楕円になってしまいました。アスペクト比を考慮した正規化を行って、正円に整えましょう。
※下記コードは一例です。コード中に出てくるattribute変数の渡しかたについては、次回の記事 で解説します。

ContentView.swift
  // 〜略〜

          node.size = CGSize(width: 250, height: 200)

          let shader = SKShader(
              source: """
                  void main() {
-                     vec2 st = v_tex_coord * 2.0 - 1.0;
+                     // アスペクト比を考慮して短辺に合わせた正規化をしつつ、原点を中央に移動させます
+                     vec2 st = (v_tex_coord * 2.0 - 1.0) * a_aspect_ratio;
                      float l = length(st);
                      vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                      gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
                  }
              """
          )

+         // attribute変数をノードから受け取るための設定をシェーダーに追加します
+         shader.attributes = [
+             SKAttribute(
+                 // シェーダー内で使用する変数名を指定します --- (1)
+                 name: "a_aspect_ratio",
+                 // 変数の型を指定します
+                 type: .vectorFloat2
+             )
+         ]

+         // ノードのアスペクト比の値をattribute変数としてノードに設定します
+         node.setValue(
+             SKAttributeValue(
+                 // ノードのアスペクト比を計算して渡します
+                 vectorFloat2: vector_float2(
+                     // ノードの幅:ノードの短辺の長さ の比率を計算します
+                     Float(node.frame.width / min(node.frame.width, node.frame.height)),
+                     // ノードの高さ:ノードの短辺の長さ の比率を計算します
+                     Float(node.frame.height / min(node.frame.width, node.frame.height))
+                 )
+             ),
+             // (1)で指定した変数名をここでも指定します
+             forAttribute: "a_aspect_ratio"
+         )

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

アスペクト比を考慮した正規化を行い正円を描く

正円がノードの中央に収まりました。

v_tex_coord変数のサンプルコード全体
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()
        node.size = CGSize(width: 250, height: 200)

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = (v_tex_coord * 2.0 - 1.0) * a_aspect_ratio;
                    float l = length(st);
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
                }
            """
        )

        shader.attributes = [
            SKAttribute(
                name: "a_aspect_ratio",
                type: .vectorFloat2
            )
        ]

        node.setValue(
            SKAttributeValue(
                vectorFloat2: vector_float2(
                    Float(node.frame.width / min(node.frame.width, node.frame.height)),
                    Float(node.frame.height / min(node.frame.width, node.frame.height))
                )
            ),
            forAttribute: "a_aspect_ratio"
        )

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

まとめ

今回はSKShaderの座標について紹介してきました。

各セクションでも強調しましたが、座標の原点が、

対象 原点
SKShader の gl_FragCoord シーンの左上
SKShader の v_tex_coord ノードの左下
OpenGL の gl_FragCoord 左下
SpriteKit 左下
SwiftUI / UIKit 左上

という混乱しそうな状況になっているので、整理して覚えておくとよいでしょう。

次回は、サンプルコード中にも出てきた SKShaderに変数を渡す方法 についてとりあげます。よろしければ引き続きご覧ください。


次記事 → GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (3) 〜変数〜

参考リンク

Discussion