🎨

GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (6) 〜テクスチャと色 後編〜

2024/06/29に公開

はしがき

SwiftUIでSpriteKitのSKShaderを使って遊んでみようというテーマの記事の6回目です。
前回の記事 ではテクスチャと色の基本をまとめました。
今回は テクスチャ について、少し応用的な使いかたやTips的なものをいくつか紹介したいと思います。

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)で撮影したものをベースにしています。

テクスチャの応用

テクスチャを uniform 変数としてシェーダーへ渡す(マルチテクスチャ)

シェーダーでテクスチャを表示する方法として、 前回の記事 で紹介した、 texture プロパティでノードに貼ったテクスチャを u_texture の組み込み変数を介して取得・表示するやりかた以外に、テクスチャの SKUniform オブジェクトを作成してシェーダーへ渡し、そのuniform変数を介して取得・表示するやりかたがあります。この方法を利用すると、 複数のテクスチャをシェーダーで扱うことができます
※uniform変数の渡しかたの基本については 第3回の記事 をご参照ください。

// テクスチャオブジェクトを引数に指定してSKUniformオブジェクトを作成します
let additionalTexture = SKUniform(
   name: "u_additional_texture",
   texture: SKTexture(imageNamed: "fileName")
)

let shader = SKShader(
    source: """
        void main() {
            // uniform変数で渡されたテクスチャを出力します
            gl_FragColor = texture2D(u_additional_texture, v_tex_coord);
        }
    """,
    // 作成したSKUniformオブジェクトをシェーダーに渡します
    uniforms: [additionalTexture]
)

ノイズのテクスチャを作成する SKTexture(noiseWithSmoothness smoothness: CGFloat, size: CGSize, grayscale: Bool) と、テクスチャのSKUniformオブジェクトを作成する SKUniform(name: String, texture: SKTexture?) を使用して、ノードに貼ったテクスチャを手書きイラスト風に加工してみましょう。

ノードに貼るテクスチャ用の画像は、前回の記事 と同じSVGファイルを使用しています。

ContentView.swift
// 〜略〜

class MySKScene: SKScene {
    override func didMove(to view: SKView) {
        let white = UIColor(red: 0.99, green: 0.97, blue: 0.93, alpha: 1.0)
        let orange = UIColor(red: 0.93, green: 0.73, blue: 0.38, alpha: 1.0)

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

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

        // 画像ファイルからテクスチャオブジェクトを作成してノードに適用します
        let texture = SKTexture(imageNamed: "yacht")
        node.texture = texture

        // ノードの色とブレンドの比率を指定します
        node.color = orange
        node.colorBlendFactor = 0.5

        // シェーダーを作成します
        let shader = SKShader(
            source: """
                void main() {
                    // uniform変数で渡されたノイズのテクスチャの色を取得します
                    vec4 noise = texture2D(u_noise_texture, v_tex_coord);
                    // ノードに適用されたテクスチャの色を取得します
                    // ノイズを利用して座標をランダムにずらし、輪郭を粗くします
                    vec4 texel = texture2D(u_texture, v_tex_coord + (noise.rg - 0.5) * 0.01);
                    // ノイズを利用してノードに適用されたテクスチャと色をmixし、ムラを出します
                    gl_FragColor = mix(texel, texel * v_color_mix, noise.b * 0.4);
                }
            """
        )

         // ノイズのテクスチャオブジェクトを作成します
         let noiseTexture = SKTexture(
             noiseWithSmoothness: 0.05,
             size: node.frame.size,
             grayscale: true
         )

         // ノイズのテクスチャオブジェクトをuniform変数としてシェーダーに渡します
         shader.addUniform(
             SKUniform(
                 name: "u_noise_texture",
                 texture: noiseTexture
             )
         )

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

        self.addChild(node)
    }
}

複数のテクスチャをSKShaderで利用する

輪郭線や塗りの部分に、画用紙に描いたような凸凹が足されました。

マルチテクスチャのサンプルコード全体
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 white = UIColor(red: 0.99, green: 0.97, blue: 0.93, alpha: 1.0)
        let orange = UIColor(red: 0.93, green: 0.73, blue: 0.38, alpha: 1.0)

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

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

        let texture = SKTexture(imageNamed: "yacht")
        node.texture = texture

        node.color = orange
        node.colorBlendFactor = 0.5

        let shader = SKShader(
            source: """
                void main() {
                    vec4 noise = texture2D(u_noise_texture, v_tex_coord);
                    vec4 texel = texture2D(u_texture, v_tex_coord + (noise.rg - 0.5) * 0.01);
                    gl_FragColor = mix(texel, texel * v_color_mix, noise.b * 0.4);
                }
            """
        )

        let noiseTexture = SKTexture(
            noiseWithSmoothness: 0.05,
            size: node.frame.size,
            grayscale: true
        )

        shader.addUniform(
            SKUniform(
                name: "u_noise_texture",
                texture: noiseTexture
            )
        )

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

テクスチャにフィルタをかける

SKTextureapplying メソッドを使うと、テクスチャに CIFilter をかけることができます。

// テクスチャオブジェクトを作成します
let texture = SKTexture(imageNamed: "fileName")

// 作成したテクスチャオブジェクトをもとにして、
// フィルタをかけた新たなテクスチャオブジェクトを作成します
let filteredTexture = texture.applying(
     CIFilter(
        // フィルタの種類として、ガウシアンブラーを指定します
        name: "CIGaussianBlur",
        // パラメータとして、ぼかす半径をピクセル値で指定します
        parameters: ["inputRadius": 10.0]
    )!
)

フィルタをかけたテクスチャをノードに貼り、そのノードにシェーダーを適用してさらに描画をカスタマイズするようなこともできます。

実際にフィルタを使ってみましょう。さきほど説明したマルチテクスチャの仕組みも利用して、ノードに貼ったテクスチャと、 CIPhotoEffectTonal のフィルタをかけてからuniform変数として渡したテクスチャを、シェーダーを使って合成する例をお見せします。

処理の流れをフロー図にすると上記のような感じです。

ContentView.swift
// 〜略〜

class MySKScene: SKScene {
    override func didMove(to view: SKView) {
        let white = UIColor(red: 0.99, green: 0.97, blue: 0.93, alpha: 1.0)

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

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

        // 画像ファイルからテクスチャオブジェクトを作成してノードに適用します
        let texture = SKTexture(imageNamed: "yacht")
        node.texture = texture

        // シェーダーを作成します
        let shader = SKShader(
            source: """
                void main() {
                    // ノードに適用されたテクスチャの色を取得します
                    vec4 texel = texture2D(u_texture, v_tex_coord);
                    // uniform変数で渡された、フィルタをかけたテクスチャの色を取得します
                    vec4 filtered = texture2D(u_filtered_texture, v_tex_coord);
                    // ノードの中心からの距離を計算します
                    float l = length(v_tex_coord * 2.0 - 1.0);
                    // 中心から離れるほどフィルタのかかった褪せた色で出力します
                    gl_FragColor = mix(texel, filtered, min(1.0, pow(l, 2.0)));
                }
            """
        )

        // フィルタをかけたテクスチャオブジェクトを作成します
        let filteredTexture = texture.applying(
            CIFilter(name: "CIPhotoEffectTonal")!
        )

        // フィルタをかけたテクスチャオブジェクトを
        // uniform変数としてシェーダーに渡します
        shader.addUniform(
            SKUniform(
                name: "u_filtered_texture",
                texture: filteredTexture
            )
        )

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

        self.addChild(node)
    }
}

フィルタをかけたテクスチャをSKShaderで合成する

ほのかに褪せたような雰囲気が出せました。

テクスチャにフィルタをかけるサンプルコード全体
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 white = UIColor(red: 0.99, green: 0.97, blue: 0.93, alpha: 1.0)

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

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

        let texture = SKTexture(imageNamed: "yacht")
        node.texture = texture

        let shader = SKShader(
            source: """
                void main() {
                    vec4 texel = texture2D(u_texture, v_tex_coord);
                    vec4 filtered = texture2D(u_filtered_texture, v_tex_coord);
                    float l = length(v_tex_coord * 2.0 - 1.0);
                    gl_FragColor = mix(texel, filtered, min(1.0, pow(l, 2.0)));
                }
            """
        )

        let filteredTexture = texture.applying(
            CIFilter(name: "CIPhotoEffectTonal")!
        )

        shader.addUniform(
            SKUniform(
                name: "u_filtered_texture",
                texture: filteredTexture
            )
        )

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

ノードをテクスチャにする(マルチシェーダー)

SKTexture オブジェクトを作成する手順として、 SKViewtexture メソッドを使ってノードをテクスチャにする方法があります。 前回の記事の最初 で、SVGファイルを使う代わりとして例示したテクスチャを作成するコードの中でも、このやりかたを利用しています。

// 画像ファイルからテクスチャオブジェクトを作成します
let texture = SKTexture(imageNamed: "fileName")

// テクスチャオブジェクトを引数で渡してノードを作成します
let node = SKSpriteNode(texture: texture)

// ノードに貼ったテクスチャに色をブレンドします
node.color = .purple
node.colorBlendFactor = 0.1

// ノードからテクスチャオブジェクトを作成します
// ブレンドした色が反映された新たなテクスチャが作られます
let newTexture = self.view?.texture(from: node)

// didMove(to view: SKView)のメソッド内であればview変数が使えるので、
// 以下のように書くこともできます
let newTexture = view.texture(from: node)

この方法を使うと、以下のフロー図のように、シェーダーを適用したノードをテクスチャ化して、そのテクスチャを貼ったノードにさらにシェーダーを適用する、という マルチシェーダーのような処理も実現できます

2つのシェーダーを適用させるサンプルコードを例示します。1段階目のシェーダーでテクスチャを変形させ、2段階目のシェーダーで残像風のぼかし処理を加えています。
※長くなるのでサンプルコードを折りたたみます。

2つのシェーダーを適用させるサンプルコード
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 white = UIColor(red: 0.99, green: 0.97, blue: 0.93, alpha: 1.0)

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

        // ノードからテクスチャオブジェクトを作成します
        let texture = {
            // 画像ファイルから作成したテクスチャオブジェクトを使って、
            // 1段階目のシェーダーの適用先となるノードを作成します
            let node = SKSpriteNode(
                texture: SKTexture(imageNamed: "yacht"),
                size: CGSize(width: 200, height: 200)
            )

            // 1段階目のシェーダーを作成します
            let shader = SKShader(
                source: """
                    void main() {
                        // テクスチャのヨットの位置を右下方向へ移動します
                        vec2 uv = v_tex_coord + vec2(-0.12, 0.24);
                        // ヨットの幅を狭めます
                        uv.x *= 1.15;
                        // ヨットの帆の部分の高さを増やします
                        uv.y -= max(0.0, (uv.y - 0.37) * 0.4);
                        // ヨットの船体部分の高さを減らします
                        uv.y += min(0.0, (uv.y - 0.37) * 1.2);
                        // 位置と大きさを調整したテクスチャを出力します
                        gl_FragColor = texture2D(u_texture, uv);
                    }
                """
            )

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

            // SKView.textureメソッドでノードをテクスチャオブジェクトにします
            return self.view?.texture(from: node)
        }()

        // 作成したテクスチャオブジェクトを使って、
        // 2段階目のシェーダーの適用先となるノードを作成します
        let node = SKSpriteNode(texture: texture)

        // 2段階目のシェーダーを作成します
        let shader = SKShader(
            source: """
                void main() {
                    // ノードに適用されたテクスチャの色を取得します
                    vec4 color = texture2D(u_texture, v_tex_coord);

                    for (int i = 1; i <= 2; i++) {
                        // ノードに適用されたテクスチャの色を
                        // 左へずらし、高さをやや小さくして取得します
                        vec4 texel = texture2D(
                            u_texture,
                            vec2(
                                v_tex_coord.x + exp(i * 0.6) / 30.0,
                                v_tex_coord.y / exp(i * -0.02) - exp(i * 0.002) + 1.0
                            )
                        );
                        // 色をぼかす割合を計算します
                        float weight = exp(i * -0.9);
                        // 取得したテクスチャの色をぼかします
                        texel = mix(vec4(0.0), texel, weight);
                        // 元のテクスチャと、色をぼかしたテクスチャの
                        // どちらを反映させるかを計算します
                        float d = v_tex_coord.x > 0.55 || color.a > 0.0 ? 1.0 : 0.0;
                        // 元のテクスチャの左側に、ぼかしたテクスチャを追加します
                        color = mix(texel, color, d);
                    }

                    // 残像風の加工を足したテクスチャを出力します
                    gl_FragColor = color;
                }
            """
        )

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

        self.addChild(node)
    }
}

マルチシェーダーでテクスチャを加工する

シンボルをテクスチャにする

SF Symbolsやカスタムシンボルを、単純に SKTexture(image: UIImage(systemName: "シンボル名")) などで SKTexture オブジェクトにしてノードに適用すると、 黒の単色で塗られたシンボルが表示されますUIImage.SymbolConfigurationUIImage.withTintColorシンボルに色を設定しても無視されてしまいSKSpriteNodecolor プロパティや colorBlendFactor プロパティで色を調整することも難しくなるので不便です。

シンボル自体の色やcolorプロパティの設定を活かせるかたちでテクスチャにしたい場合の対応策として、シンボルのUIImageを UIGraphicsImageRendererimage(actions:) メソッドで別のUIImageに書き出してから SKTexture オブジェクトを作成する方法があります。 [1]

実装例は以下のようになります。
※長くなるのでサンプルコードを折りたたみます。
※コードの途中、シンボルをリサイズする処理で泥くさい書きかたをしているのと、シンボルの種類や実行環境によってはうまくリサイズされないケースもあるかもしれないので、参考程度にご覧ください。

シンボルをテクスチャにするサンプルコード
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 white = UIColor(red: 0.99, green: 0.97, blue: 0.93, alpha: 1.0)
        let orange = UIColor(red: 0.93, green: 0.73, blue: 0.38, alpha: 1.0)

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

        // テクスチャを適用するノードのサイズです
        let textureSize = 200.0

        // シンボルからテクスチャオブジェクトを作成します
        let symbolTexture: SKTexture? = {
            guard let image = UIImage(
                // 別途用意したカスタムシンボルを読みこみます
                named: "custom.info.circle.fill"
                // Swift Playgroundsで試す場合は、適当な既存のシンボルを指定してください
                // systemName: "info.circle.fill"
            )?.applyingSymbolConfiguration(
                // シンボルのサイズを指定します
                UIImage.SymbolConfiguration(
                    pointSize: textureSize
                )
            )?.applyingSymbolConfiguration(
                // パレットカラーで色づけします
                UIImage.SymbolConfiguration(
                    paletteColors: [
                        .systemBlue,
                        .init(white: 0.82, alpha: 1.0),
                        .init(white: 0.95, alpha: 1.0)
                    ]
                )
            ) else { return nil }
            // モノクロにしたいときは withTintColor で色を指定できます
            // 白色にしておくと、シェーダーやノードのプロパティを利用した色のコントロールがしやすいです
            // .withTintColor(.white)

            // デフォルトで付与されるシンボルの周囲の余白をつめ、
            // シンボルのアスペクト比を保ったまま画像を正方形にするためにリサイズします
            // テクスチャにしたときの余白やアスペクト比が問題にならない場合は、
            // このリサイズの処理は省略できます
            let renderer = UIGraphicsImageRenderer(
                size: CGSize(width: textureSize, height: textureSize)
            )
            let coloredSymbolImage = renderer.image { _ in
                // デフォルトの余白部分を含むシンボルのサイズを取得します
                let originalSize = [
                    image.size.width,
                    image.size.height
                ]

                // 余白部分を除いたシンボルのサイズを取得します
                let cgImageSize: [CGFloat] = [
                    CGFloat(image.cgImage?.width ?? 0),
                    CGFloat(image.cgImage?.height ?? 0)
                ].enumerated().map {
                    $0.element.isZero
                    ? originalSize[$0.offset]
                    : $0.element / image.scale
                }

                // 余白部分を除いたシンボルの大きさを、
                // textureSizeで指定した大きさへリサイズするための倍率を計算します
                let scale = textureSize / cgImageSize.max()!

                // リサイズ時のシンボルの描画サイズを計算します
                let drawSize = originalSize.map { $0 * scale }

                // シンボルのUIImageオブジェクトに含まれるcontentInsetsの値を
                // 取得できるプロパティがなさそうなので、
                // 正規表現でUIImage.descriptionの文字列から抽出します
                let contentInsets = {
                    let pattern = "contentInsets[={ ]+(?<insets>(?:[0-9.]+[, ]*){4})"
                    let description = image.description
                    let defaultValue = [Double](repeating: 0.0, count: 4)

                    guard let regex = try? NSRegularExpression(
                        pattern: pattern
                    ) else { return defaultValue }

                    guard let match = regex.firstMatch(
                        in: description,
                        range: NSRange(location: 0, length: description.utf16.count)
                    ) else { return defaultValue }

                    let matchedString = NSString(string: description)
                        .substring(with: match.range(withName: "insets"))
                    return matchedString.components(separatedBy: ",").map {
                        Double($0.trimmingCharacters(in: .whitespaces)) ?? 0.0
                    }
                }()

                // デフォルトの余白の大きさをもとに
                // 配置のバランスを調整するためのオフセット率を計算します
                let offsetRatio = [
                    contentInsets[1] / (contentInsets[1] + contentInsets[3]),
                    contentInsets[0] / (contentInsets[0] + contentInsets[2])
                ].map { $0.isNaN ? 0.5 : $0 }

                // リサイズ時の原点の位置のオフセット値を計算します
                let offset = drawSize.enumerated().map {
                    (textureSize - $0.element) * offsetRatio[$0.offset]
                }

                // シェーダーでテクスチャを移動や縮小させたときに
                // 端のピクセルが引きのばされて描画がくずれるのを防ぐために、
                // 周囲に最大1ポイントの余白を付与するための値を計算します
                let padding = drawSize.map { $0 / drawSize.max()! }

                // シンボルをリサイズして描画します
                image.draw(
                    in: CGRect(
                        x: offset[0] + padding[0],
                        y: offset[1] + padding[1],
                        width: drawSize[0] - padding[0] * 2.0,
                        height: drawSize[1] - padding[1] * 2.0
                    )
                )
            }
            // リサイズの処理はここまで

            // リサイズしたシンボルのUIImageオブジェクトから
            // SKTextureオブジェクトを作成します
            return SKTexture(image: coloredSymbolImage)

            // リサイズ処理が不要な場合は、次の3行で済みます
            // let renderer = UIGraphicsImageRenderer(size: image.size)
            // let coloredSymbolImage = renderer.image { _ in image.draw(at: CGPoint.zero) }
            // return SKTexture(image: coloredSymbolImage)
        }()

        // シンボルのテクスチャ・色・サイズを引数で渡してノードを作成します
        let node = SKSpriteNode(
            texture: symbolTexture,
            color: orange,
            size: symbolTexture?.size() ?? CGSize.zero
        )
        node.colorBlendFactor = 1.0

        let shader = SKShader(
            source: """
                void main() {
                    // uniform変数で渡されたノイズのテクスチャの色を取得します
                    vec4 noise = texture2D(u_noise_texture, v_tex_coord);
                    // シンボルのテクスチャの色を、ランダムにRGBをずらして取得します
                    vec4 texel = vec4(
                        texture2D(u_texture, v_tex_coord + vec2(noise.r * 0.02, -0.002)).r,
                        texture2D(u_texture, v_tex_coord - vec2(noise.g * 0.02, 0.004)).g,
                        texture2D(u_texture, v_tex_coord + vec2(0.001, noise.b * 0.004)).b,
                        texture2D(u_texture, v_tex_coord).a
                    );
                    // シンボルの輪郭をランダムに崩します
                    texel.a = min(1.0, texel.a + noise.a * dot(texel.rgb, vec3(1.0)));
                    // 走査線風の模様を作ります
                    float l = 160.0;
                    l = smoothstep(0.3, 0.8, sin(v_tex_coord.y * l));
                    l = 0.3 + l * mix(0.6, 1.2, noise.a);
                    // シンボルと模様を重ねて出力します
                    gl_FragColor = mix(texel, texel * v_color_mix, l);
                }
            """
        )

        shader.addUniform(
            SKUniform(
                name: "u_noise_texture",
                texture: SKTexture(
                    noiseWithSmoothness: 0.2,
                    size: node.frame.size,
                    grayscale: false
                )
            )
        )

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

シンボルをテクスチャにしてSKShaderで加工する

※スクリーンショットは Light Appearance で撮影しています。

色の応用

UIColor を変数としてシェーダーへ渡す

uniform変数やattribute変数でUIColorの色をシェーダーへ渡したいときは、RGBAの各数値でvec4の変数を作ります。

UIColorのRGBAの各数値を取得する手段として UIColor.getRed メソッドがあります。

var red = CGFloat.zero
var green = CGFloat.zero
var blue = CGFloat.zero
var alpha = CGFloat.zero

UIColor.blue.getRed(&red, green: &green, blue: &blue, alpha: &alpha)

let color = SKUniform(
    name: "u_color",
    vectorFloat4: vector_float4(
        Float(red),
        Float(green),
        Float(blue),
        Float(alpha)
    )
)

このようにしてRGBAの各数値を使った変数が作れます。
ただ、 UIColor.getRed メソッドは、色の値を格納するための変数をあらかじめ用意しておかなければならないので、少しばかり手間です(いちどextensionでも書いてしまえばあとはラクにすみますが)。

別の手段として、いったん CIColor オブジェクトに変換するという方法もあります。このオブジェクトでは直接RGBAの各数値が取得できます。

let ciColor = CIColor(color: UIColor.blue)

let color = SKUniform(
    name: "u_color",
    vectorFloat4: vector_float4(
        Float(ciColor.red),
        Float(ciColor.green),
        Float(ciColor.blue),
        Float(ciColor.alpha)
    )
)

さらに別の手段として、UIColor.cgColor.components プロパティを利用する方法もあります。このプロパティでも直接RGBAの各数値が取得できます。

let color = SKUniform(
    name: "u_color",
    vectorFloat4: vector_float4(
        Float(UIColor.blue.cgColor.components![0]),
        Float(UIColor.blue.cgColor.components![1]),
        Float(UIColor.blue.cgColor.components![2]),
        Float(UIColor.blue.cgColor.alpha)
    )
)

CGColor.components プロパティは、 取得元の色の種類によってはnilやエラーが返ってくることもあるので注意が必要 [2] です。パフォーマンスも UIColor.getRedCIColor のやりかたに比べると遅くなりますが、別の変数を作らなくてよいので、確実にcomponentsの配列に値が入るとわかっているケースならばシンプルに書けて便利です。 [3]


シェーダーで出力する色をSwiftUI側から指定したいとき、View上で色を Color から UIColor に変換してSKSceneへ渡し、上記のメソッドやプロパティを利用してuniform変数やattribute変数を作成してシェーダーで使う、といったことも可能です。
Asset CatalogのColor Setに登録した色も同様に変数にしてシェーダーへ渡せますし、Appearanceによってシェーダーの出力する色を変更させることもできます。

以下の飾り枠を表示するサンプルコードでは、

  1. SwiftUIから渡したインディゴ色(uniform変数)
  2. シェーダーを適用するノードの color プロパティで指定した黒色(v_color_mix変数)
  3. 内枠の部分を描画しているノードで使っているオレンジ色(uniform変数)

の3色を使ってシェーダーで模様を描画しています。
※長くなるのでサンプルコードを折りたたみます。

UIColorをuniform変数で渡すサンプルコード
ContentView.swift
import SwiftUI
import SpriteKit

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

        // インディゴ色をシーンへ渡します
        scene.baseColor = UIColor(Color.indigo)  // --- (1)

        return scene
    }

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

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

class MySKScene: SKScene {

    // SwiftUI側から色を渡すための変数を用意します
    var baseColor: UIColor = .clear

    override func didMove(to view: SKView) {

        // シーンの背景色をSwiftUI側から渡された色にします (optional)
        self.backgroundColor = baseColor

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

        // シェーダーの適用先となるノードを作成します
        // colorプロパティには黒色を指定しておきます
        let node = SKSpriteNode(
            color: UIColor.black,  // --- (2)
            size: CGSize(width: 250, height: 200)
        )

        // オレンジ色の内枠を表示するためのノードを作成してシーンに追加します
        let borderNode = SKSpriteNode(
            color: UIColor.systemOrange,  // --- (3)
            size: CGSize(
                width: node.frame.width + 10,
                height: node.frame.height + 10
            )
        )
        self.addChild(borderNode)

        // UIColor.getRedメソッドの使用例

        // SwiftUI側から渡された色のRGBA値を取得します
        var red = CGFloat.zero
        var green = CGFloat.zero
        var blue = CGFloat.zero
        var alpha = CGFloat.zero

        baseColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)

        // 取得したRGBA値を使ってuniform変数を作成します
        let mainColor = SKUniform(
            name: "u_main_color",
            vectorFloat4: vector_float4(
                Float(red),
                Float(green),
                Float(blue),
                Float(alpha)
            )
        )

        // CIColorオブジェクトの使用例

        // 内枠のノードの色に指定されているUIColorオブジェクトから
        // CIColorオブジェクトを作成します
        let ciColor = CIColor(color: borderNode.color)

        // CIColorオブジェクトのRGBA値のプロパティを使って
        // uniform変数を作成します
        let subColor = SKUniform(
            name: "u_sub_color",
            vectorFloat4: vector_float4(
                Float(ciColor.red),
                Float(ciColor.green),
                Float(ciColor.blue),
                Float(ciColor.alpha)
            )
        )

        let shader = SKShader(
            source: """
                void main() {
                    // 上端と下端に半円型を並べます
                    float n = 4.6;  // 半円の数
                    vec2 p = v_tex_coord * 2.0 - 1.0;
                    p.x *= n / 2.0;
                    float d = fract(p.x) - 0.5;
                    d = step(tan(d * 1.5708) * d * 2.0, abs(p.y) * 4.0 - 2.7);
                    // 地の部分と半円型の部分を、uniform変数で渡された色で塗りわけます
                    vec4 color = mix(u_main_color, u_sub_color, d);
                    // 半円型を1つおきにノードの色で塗りかさねて出力します
                    d *= mod(floor(p.x) + floor(p.y), 2.0);
                    gl_FragColor = mix(color, v_color_mix, d * 0.8);
                }
            """,
            // 2色のuniform変数をシェーダーに渡します
            uniforms: [mainColor, subColor]
        )

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

UIColorをuniform変数で渡してシェーダーを描く

※スクリーンショットは Light Appearance で撮影しています。

SKScene(SpriteView)の背景を透過する

SpriteView をSwiftUIの他のViewと重ねて表示させるとき、シーンの背景を透過したいケースがあると思います。

  1. SKScenebackgroundColor プロパティで .clear を指定する
  2. SpriteViewoptions 引数で .allowsTransparency を指定する

この2つの設定で、シーンの背景を透過できます。

import SwiftUI
import SpriteKit

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

        // シーンの背景色を透明にします --- (1)
        // SKSceneのdidMoveメソッド内で指定してもかまいません
        scene.backgroundColor = .clear

        return scene
    }

    var body: some View {
        SpriteView(
            scene: self.currentScene,
            // 背景の透過を有効にします --- (2)
            options: [.allowsTransparency]
        )
    }
}

下の例のような感じで、SwiftUIのViewにシェーダーを重ねて見せることできます。
ミント色とティール色のグラデーションをかけた角丸四角形と白色のテキスト部分がSwiftUIで、ドット柄のアニメーション部分がSKShaderです。
※長くなるのでサンプルコードを折りたたみます。

シーンの背景を透過するサンプルコード
ContentView.swift
import SwiftUI
import SpriteKit

struct ContentView: View {
    var currentScene: SKScene {
        let scene = MySKScene()
        scene.scaleMode = .resizeFill
        // シーンの背景色を透明にします
        scene.backgroundColor = .clear
        return scene
    }

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

            Text("NEW ARRIVAL")
                .font(.largeTitle)
                .fontWeight(.semibold)
                .foregroundStyle(.white)
                .frame(width: 300, height: 100)
                .background {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(RadialGradient(
                            gradient: Gradient(colors: [.mint, .teal]),
                            center: .top,
                            startRadius: 2,
                            endRadius: 200
                        ))
                        .overlay {
                            SpriteView(
                                scene: self.currentScene,
                                // 背景の透過を有効にします
                                options: [.allowsTransparency]
                            )
                            .frame(width: 290, height: 90)
                            .clipShape(RoundedRectangle(cornerRadius: 5))
                        }
                }
        }
    }
}

class MySKScene: SKScene {
    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 p = v_tex_coord * a_aspect_ratio;
                    // 右方向へ移動する斜線を作ります
                    float m = sin(p.x * 3.1416 * -0.36 - p.y * 1.2 + u_time * 1.7);
                    // 水玉模様を作ります
                    // 移動する斜線の 中心 > 左右端 で水玉の大きさが変化するようにします
                    float r = pow(m, 3.0) * 0.35;  // 水玉の半径
                    p *= 12.0;                     // 1列あたりの水玉の数
                    float d = step(length(fract(p) - 0.5), r);
                    // 水玉を斜め格子状に置きます
                    d *= mod(floor(p.x) + floor(p.y), 2.0);
                    // 水玉模様を薄い紺色で出力します
                    vec4 navy = vec4(0.18, 0.34, 0.45, 1.0);
                    gl_FragColor = mix(vec4(0.0), navy, d * 0.4);
                }
            """
        )

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

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

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

背景が透明なシェーダーをSwiftUIと重ねて表示する

※スクリーンショットは Light Appearance で撮影しています。

まとめ

テクスチャと色を扱う方法について、前後編の2回にわたってとりあげました。

SKShaderでもマルチテクスチャやマルチシェーダーのような作りこみもできますし、SwiftUIで出番が多いであろうシンボルも、テクスチャにしてシェーダーで個性的なエフェクトを追加できます。ぜひ試してみてください。

次回は、SKSpriteNode以外にもある SKShaderが使えるノードオブジェクト を紹介したいと思います。よろしければ引き続きご覧ください。


次記事 → GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (7) 〜ノード〜

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

参考リンク

脚注
  1. (2024/11/22 更新)本記事の初版では、シンボルをテクスチャにするやりかたとして UIImage.pngData メソッドを使う方法を紹介していました。しかしながら、 UIGraphicsImageRenderer を利用して別のUIImageに書き出した時点でシンボルに設定した色が反映される挙動であったことがわかり、なおかつ、パフォーマンスの面でもこちらのほうが有利だったため、 UIImage.pngData メソッドの記述を削除し、 UIGraphicsImageRenderer を使う内容に変更しました。 ↩︎

  2. CGColor.components プロパティでRGBA値が取得できないパターンとしては、 Color.accentColor.cgColor の戻り値が nil になる例が挙げられます。この場合、 UIColor.init(_:) でColorからUIColorに変換すると値が取得できるようになります。また、 UIColor.whiteUIColor(white: 0.5, alpha: 1.0) などのグレースケールの色は、componentsの配列が [0.5, 1.0] のような2要素のみとなり、 components![2] の値を取得しようとすると Fatal error: Index out of range が発生します。 ↩︎

  3. (2024/11/22 更新)本記事の初版では、飾り枠を表示するサンプルコードで UIColor.cgColor.components プロパティを使用する例を掲載していました。しかしながら、エラーになるケースがある点と、 UIColor.getRedCIColor を使う方法よりも処理が重い点を考慮して、CIColor を使用する例に変更しました。 ↩︎

Discussion