🎨

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

2024/07/06に公開

はしがき

SwiftUIでSpriteKitのSKShaderを使って遊んでみようというテーマの記事の7回目です。
前回の記事 ではSKShaderでテクスチャと色を扱う際の応用例をいくつか紹介しました。
今回は SKShaderが使えるノードオブジェクト の全5種類を、簡単なサンプルコードとスクリーンショットを添えてざっと説明します。

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 が使えるノードオブジェクト

SKSpriteNode

SKSpriteNode は、画像や単一の色を表示できるノードです。
前回までの記事で使いかたを紹介していますので、詳細はそちらをご参照ください。

SKSpriteNodeとSKShader

SKSpriteNodeと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 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 spriteNode = SKSpriteNode()
        spriteNode.color = green
        spriteNode.size = CGSize(width: 250, height: 200)

        let shader = SKShader(
            source: """
                void main() {
                    float d = step(0.5, v_tex_coord.x - v_tex_coord.y * 0.3 + 0.15);
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    gl_FragColor = mix(SKDefaultShading(), yellow, d);
                }
            """
        )

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

SKShapeNode

SKShapeNode は図形を表示できるノードです。直線や曲線、四角・円・楕円といった形状のほか、頂点の座標を指定した多角形やスプライン曲線を使った図形なども表示できます。
図形には 塗り の部分があり、それぞれに色の指定やテクスチャの貼りつけ、 シェーダーの適用 ができます。

strokeShader プロパティ

strokeShader は、シェイプノードの の部分にシェーダーを適用できるプロパティです。

strokeShader プロパティに用いるシェーダーには、2種類のSKShader独自の変数があります。どちらも SpriteKitがあらかじめ用意してくれている ので、特別な準備なしでシェーダーのコード内で使えます。

変数名 種類 概要
u_path_length float uniform 線の全長(単位はポイント)を取得できます。
v_path_distance float varying 線の始点からの距離(単位はポイント)を取得できます。

これらの変数を使用したサンプルコードが以下になります。

let green = UIColor(red: 0.64, green: 0.81, blue: 0.46, alpha: 1.0)

// シェイプノードの頂点の座標をmutableな配列で用意します
var point = [
    CGPoint(x: -100, y: 0),
    CGPoint(x: -80, y: 50),
    CGPoint(x: -50, y: -50),
    CGPoint(x: -10, y: 25),
    CGPoint(x: 40, y: -25),
    CGPoint(x: 100, y: 0)
]

// シェーダーの適用先となるシェイプノードを作成します
let shapeNode = SKShapeNode(
    // 渡した頂点の座標をもとにスプライン曲線を描画します
    splinePoints: &point,
    // 頂点の数を渡します
    count: point.count
)

// 線の幅を指定します
shapeNode.lineWidth = 12

// 線の色を指定します
shapeNode.strokeColor = green

// 線の端の形状を指定します
shapeNode.lineCap = .round

let shader = SKShader(
    source: """
        void main() {
            // X座標が0.0〜1.0、Y座標が-1.0〜1.0になるように正規化します
            vec2 st = vec2(
                v_path_distance / u_path_length,
                // v_tex_coordの値はlineWidthの長さが1.0になるよう正規化されています
                v_tex_coord.y * 2.0 - 1.0
            );
            // 終端に向けて線を細くします
            float d = step(pow(st.x, 2.0), 1.0 - abs(st.y));
            // 色を用意します
            vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
            // 黄色から緑色(ノードがデフォルトで出力する色)のグラデーションを作ります
            vec4 color = mix(yellow, SKDefaultShading(), st.x);
            // 始端側を太い黄色、終端側を細い緑色で出力します
            gl_FragColor = mix(vec4(0.0), color, d);
        }
    """
)

// 線に対してシェーダーを適用します
shapeNode.strokeShader = shader

SKShapeNodeとstrokeに適用したSKShader

SKShapeNodeとSKShader(strokeShader)のサンプルコード全体
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)

        var point = [
            CGPoint(x: -100, y: 0),
            CGPoint(x: -80, y: 50),
            CGPoint(x: -50, y: -50),
            CGPoint(x: -10, y: 25),
            CGPoint(x: 40, y: -25),
            CGPoint(x: 100, y: 0)
        ]

        let shapeNode = SKShapeNode(
            splinePoints: &point,
            count: point.count
        )

        shapeNode.lineWidth = 12
        shapeNode.strokeColor = green
        shapeNode.lineCap = .round

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = vec2(
                        v_path_distance / u_path_length,
                        v_tex_coord.y * 2.0 - 1.0
                    );
                    float d = step(pow(st.x, 2.0), 1.0 - abs(st.y));
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    vec4 color = mix(yellow, SKDefaultShading(), st.x);
                    gl_FragColor = mix(vec4(0.0), color, d);
                }
            """
        )

        shapeNode.strokeShader = shader
        self.addChild(shapeNode)
    }
}

fillShader プロパティ

fillShader は、シェイプノードの 塗り の部分にシェーダーを適用できるプロパティです。

シェーダーのコードの書きかたは基本的にSKSpriteNodeと同じです。

let green = UIColor(red: 0.64, green: 0.81, blue: 0.46, alpha: 1.0)

// シェーダーの適用先となるシェイプノードを作成します
let shapeNode = SKShapeNode(
    // シーンの中央に200x200の四角形を描画します
    rect: CGRect(
        origin: CGPoint(x: -100, y: -100),
        size: CGSize(width: 200, height: 200)
    ),
    // 半径20の角丸にします
    cornerRadius: 20
)

// 線の幅を指定します
// 0を指定して線を非表示にします (デフォルト値は1.0)
shapeNode.lineWidth = 0

// 塗りの色を指定します
shapeNode.fillColor = green

let shader = SKShader(
    source: """
        void main() {
            // 座標を正規化します
            vec2 st = v_tex_coord * 2.0 - 1.0;
            // 右上にドッグイア風の形を作ります
            float d = step(1.0, dot(st, vec2(0.9)));
            // 色を用意します
            vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
            // 右上の部分を黄色で、それ以外の部分を緑色で出力します
            gl_FragColor = mix(SKDefaultShading(), yellow, d);
        }
    """
)

// 塗りに対してシェーダーを適用します
shapeNode.fillShader = shader

SKShapeNodeとfillに適用したSKShader

SKShapeNodeとSKShader(fillShader)のサンプルコード全体
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 shapeNode = SKShapeNode(
            rect: CGRect(
                origin: CGPoint(x: -100, y: -100),
                size: CGSize(width: 200, height: 200)
            ),
            cornerRadius: 20
        )

        shapeNode.lineWidth = 0
        shapeNode.fillColor = green

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = v_tex_coord * 2.0 - 1.0;
                    float d = step(1.0, dot(st, vec2(0.9)));
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    gl_FragColor = mix(SKDefaultShading(), yellow, d);
                }
            """
        )

        shapeNode.fillShader = shader
        self.addChild(shapeNode)
    }
}

SKEmitterNode

SKEmitterNode はパーティクル(粒子)のエフェクトを表示できるノードです。生成座標、角度、速度、色など、さまざまなパラメータを設定してパーティクルを発生させることができます。テクスチャやシェーダーも適用できます。

let green = UIColor(red: 0.64, green: 0.81, blue: 0.46, alpha: 1.0)

// シェーダーの適用先となるエミッターノードを作成します
let emitterNode = SKEmitterNode()

// 粒子の1秒あたりの生成数を指定します
emitterNode.particleBirthRate = 600

// 何個で粒子の出現を終了させるかを指定します
emitterNode.numParticlesToEmit = 60

// 粒子の消えるまでの秒数を指定します
emitterNode.particleLifetime = 2

// 粒子の発生する位置の範囲を指定します
emitterNode.particlePositionRange = CGVector(dx: 125, dy: 125)

// 粒子のサイズを指定します
emitterNode.particleSize = CGSize(width: 50, height: 50)

// 粒子の色を指定します
emitterNode.particleColor = green

// 粒子の1秒あたりの透明度の変化量を指定します
emitterNode.particleAlphaSpeed = -1 / emitterNode.particleLifetime

let shader = SKShader(
    source: """
        void main() {
            // 粒子のアルファ値の1->0への変化を経過時間として利用します
            float t = SKDefaultShading().a;
            // シーンの中央から外側へ放射状に座標を移動させます
            // 移動量は徐々に少なくします
            vec2 p = v_tex_coord * 2.0 - 1.0;
            vec2 st = gl_FragCoord.xy * 2.0 - u_resolution;
            st.y *= -1.0;
            vec2 origin = st - p * u_size;
            float th = atan(origin.y, origin.x);
            p += vec2(cos(th), sin(th)) * (-0.5 + pow(t, 3.0));
            // 丸い発光体を作ります
            // 出現位置がシーンの外側になるほどサイズを小さくします
            float l = length(origin) / min(u_resolution.x, u_resolution.y);
            float d = 1.2 - length(p * mix(-5.0, 15.0, l));
            d = pow(max(0.0, d), 3.0);
            // シーン上の半径0.4の円の輪郭付近に出現した粒子のみ表示します
            d *= l < 0.3 || 0.5 < l ? 0.0 : t;
            // 色を用意します
            vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
            // 黄色から緑色(ノードがデフォルトで出力する色)のグラデーションを作ります
            vec4 color = mix(SKDefaultShading(), yellow, d);
            // シーンの外側へ向けて移動する、円型に並んだ発光体を出力します
            gl_FragColor = mix(vec4(0.0), color, d);
        }
    """
)

// シーンの解像度の値をuniform変数としてシェーダーに渡します
shader.addUniform(
    SKUniform(
        name: "u_resolution",
        vectorFloat2: vector_float2(
            Float(self.frame.width * UITraitCollection.current.displayScale),
            Float(self.frame.height * UITraitCollection.current.displayScale)
        )
    )
)

// 粒子のサイズの値をuniform変数としてシェーダーに渡します
shader.addUniform(
    SKUniform(
        name: "u_size",
        vectorFloat2: vector_float2(
            Float(emitterNode.particleSize.width * UITraitCollection.current.displayScale),
            Float(emitterNode.particleSize.height * UITraitCollection.current.displayScale)
        )
    )
)

emitterNode.shader = shader

SKEmitterNodeとSKShader

SKEmitterNodeと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 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 emitterNode = SKEmitterNode()
        emitterNode.particleBirthRate = 600
        emitterNode.numParticlesToEmit = 60
        emitterNode.particleLifetime = 2
        emitterNode.particlePositionRange = CGVector(dx: 125, dy: 125)
        emitterNode.particleSize = CGSize(width: 50, height: 50)
        emitterNode.particleColor = green
        emitterNode.particleAlphaSpeed = -1 / emitterNode.particleLifetime

        let shader = SKShader(
            source: """
                void main() {
                    float t = SKDefaultShading().a;
                    vec2 p = v_tex_coord * 2.0 - 1.0;
                    vec2 st = gl_FragCoord.xy * 2.0 - u_resolution;
                    st.y *= -1.0;
                    vec2 origin = st - p * u_size;
                    float th = atan(origin.y, origin.x);
                    p += vec2(cos(th), sin(th)) * (-0.5 + pow(t, 3.0));
                    float l = length(origin) / min(u_resolution.x, u_resolution.y);
                    float d = 1.2 - length(p * mix(-5.0, 15.0, l));
                    d = pow(max(0.0, d), 3.0);
                    d *= l < 0.3 || 0.5 < l ? 0.0 : t;
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    vec4 color = mix(SKDefaultShading(), yellow, d);
                    gl_FragColor = mix(vec4(0.0), color, d);
                }
            """
        )

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

        shader.addUniform(
            SKUniform(
                name: "u_size",
                vectorFloat2: vector_float2(
                    Float(emitterNode.particleSize.width * UITraitCollection.current.displayScale),
                    Float(emitterNode.particleSize.height * UITraitCollection.current.displayScale)
                )
            )
        )

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

SKTileMapNode

SKTileMapNode は、テクスチャをマス目状に敷きつめて表示できるノードです。どこのマスにテクスチャを配置するか配列で指定したり、四隅や上下左右の辺といった場所ごとにどのテクスチャを配置するか自動で指定したりできます。
ノードにシェーダーを適用すると、配置した全マス目のテクスチャでシェーダーが実行されます。

以下のサンプルコードですが、実は筆者自身がこのノードを使った経験がほとんどなく、あまりよい例ではないと思います(少なくともこのノードの機能をうまく活かせてはいないでしょう)。その点を念頭に置いていただき、雰囲気をちょっと眺めてみる程度の気持ちでご覧ください。

let green = UIColor(red: 0.64, green: 0.81, blue: 0.46, alpha: 1.0)
let yellow = UIColor(red: 0.99, green: 0.83, blue: 0.46, alpha: 1.0)

// タイルのサイズを用意します
let tileSize = CGSize(width: 25, height: 25)

// タイルを敷く位置のデータを用意します
// ループ処理しやすいようにタプルの配列でデータを作成しています
// (0, 0) が一番左下のマス目になります
let tilePosGreen: [(column: Int, row: Int)] = [
    (0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6),
    (1, 0), (1, 3), (1, 6),
    (2, 0), (2, 3), (2, 6),
    (3, 0), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (3, 6),
    (5, 0), (5, 1), (5, 2), (5, 3), (5, 4), (5, 5), (5, 6),
    (6, 0), (6, 3), (6, 6),
    (7, 0), (7, 3), (7, 6),
    (8, 0), (8, 1), (8, 2), (8, 3), (8, 4), (8, 5), (8, 6)
]
let tilePosYellow: [(column: Int, row: Int)] = [
    (0, 0), (0, 3), (0, 6),
    (1, 0), (1, 3), (1, 6),
    (2, 0), (2, 3), (2, 6),
    (3, 0), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (3, 6),
    (5, 6),
    (6, 6),
    (7, 6),
    (8, 0), (8, 1), (8, 2), (8, 3), (8, 4), (8, 5), (8, 6)
]

// 緑色と黄色にそれぞれ塗った四角形のノードからテクスチャを作成します
let textureGreen = self.view?.texture(
    from: SKSpriteNode(color: green, size: tileSize)
)
let textureYellow = self.view?.texture(
    from: SKSpriteNode(color: yellow, size: tileSize)
)

// テクスチャからタイルグループを作成します
let groupGreen = SKTileGroup(
    tileDefinition: SKTileDefinition(
        texture: textureGreen!
    )
)
let groupYellow = SKTileGroup(
    tileDefinition: SKTileDefinition(
        texture: textureYellow!
    )
)

// タイルグループからタイルセットを作成します
let tileSet = SKTileSet(
    tileGroups: [groupGreen, groupYellow]
)

// シェーダーの適用先となるタイルマップノードを作成します
let tileMapNode = SKTileMapNode(
    // 作成したタイルセットを渡します
    tileSet: tileSet,
    // タイルを敷くマス目全体の列数と行数を渡します
    columns: 9,
    rows: 7,
    // タイルのサイズを渡します
    tileSize: tileSize
)

// タイルをマス目に配置します
tilePosGreen.forEach {
    tileMapNode.setTileGroup(
        groupGreen,
        forColumn: $0.column,
        row: $0.row
    )
}
tilePosYellow.forEach {
    tileMapNode.setTileGroup(
        groupYellow,
        forColumn: $0.column,
        row: $0.row
    )
}

let shader = SKShader(
    source: """
        void main() {
            // 座標を正規化します
            vec2 st = v_tex_coord * 2.0 - 1.0;
            // 黄色をより強く、緑色を弱く出力するため
            // ノードに設定した色のRGB値のうち
            // 赤の値を色の強さとして利用します
            float l = SKDefaultShading().r;
            // 円型を作ります
            float d = pow(l, 2.0) - length(st * 1.2);
            d = pow(max(0.0, 0.4 + d), 2.0);
            // 円型をノードに設定した色で出力します
            gl_FragColor = mix(vec4(0.0), SKDefaultShading(), d);
        }
    """
)

tileMapNode.shader = shader

SKTileMapNodeとSKShader

SKTileMapNodeと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 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)
        let yellow = UIColor(red: 0.99, green: 0.83, blue: 0.46, alpha: 1.0)

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

        let tileSize = CGSize(width: 25, height: 25)

        let tilePosGreen: [(column: Int, row: Int)] = [
            (0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6),
            (1, 0), (1, 3), (1, 6),
            (2, 0), (2, 3), (2, 6),
            (3, 0), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (3, 6),
            (5, 0), (5, 1), (5, 2), (5, 3), (5, 4), (5, 5), (5, 6),
            (6, 0), (6, 3), (6, 6),
            (7, 0), (7, 3), (7, 6),
            (8, 0), (8, 1), (8, 2), (8, 3), (8, 4), (8, 5), (8, 6)
        ]
        let tilePosYellow: [(column: Int, row: Int)] = [
            (0, 0), (0, 3), (0, 6),
            (1, 0), (1, 3), (1, 6),
            (2, 0), (2, 3), (2, 6),
            (3, 0), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (3, 6),
            (5, 6),
            (6, 6),
            (7, 6),
            (8, 0), (8, 1), (8, 2), (8, 3), (8, 4), (8, 5), (8, 6)
        ]

        let textureGreen = self.view?.texture(
            from: SKSpriteNode(color: green, size: tileSize)
        )
        let textureYellow = self.view?.texture(
            from: SKSpriteNode(color: yellow, size: tileSize)
        )

        let groupGreen = SKTileGroup(
            tileDefinition: SKTileDefinition(
                texture: textureGreen!
            )
        )
        let groupYellow = SKTileGroup(
            tileDefinition: SKTileDefinition(
                texture: textureYellow!
            )
        )

        let tileSet = SKTileSet(
            tileGroups: [groupGreen, groupYellow]
        )

        let tileMapNode = SKTileMapNode(
            tileSet: tileSet,
            columns: 9,
            rows: 7,
            tileSize: tileSize
        )

        tilePosGreen.forEach {
            tileMapNode.setTileGroup(
                groupGreen,
                forColumn: $0.column,
                row: $0.row
            )
        }
        tilePosYellow.forEach {
            tileMapNode.setTileGroup(
                groupYellow,
                forColumn: $0.column,
                row: $0.row
            )
        }

        let shader = SKShader(
            source: """
                void main() {
                    vec2 st = v_tex_coord * 2.0 - 1.0;
                    float l = SKDefaultShading().r;
                    float d = pow(l, 2.0) - length(st * 1.2);
                    d = pow(max(0.0, 0.4 + d), 2.0);
                    gl_FragColor = mix(vec4(0.0), SKDefaultShading(), d);
                }
            """
        )

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

SKEffectNode

SKEffectNode は、自身の子ノード (*) にエフェクトを適用できるノードです。テクスチャ画像を貼った SKSpriteNode を子ノードに追加して CIFilter をかけたり、SKWarpGeometry で変形させたりすることができます。
* 子ノードというのは、SKNode.addChild などのメソッドを使って、自身の子として追加したノードのことです。

SKLabelNode のような shader プロパティを持っていないノードを子ノードに追加して、親ノードである SKEffectNode にシェーダーを適用すれば、直接シェーダーを利用できないノードに対してもシェーダーで描画をカスタマイズできます。

ちなみに SKSceneSKEffectNode のサブクラスです。シーンオブジェクトの shouldEnableEffects プロパティを true にしてシェーダーを適用すると、シーン全体にシェーダーを表示させることもできます。

SKEffectNode はレンダリングを別バッファで行うため、多用すると描画コストが上がる点には注意が必要です。子ノードのレンダリング結果をキャッシュしてパフォーマンスを改善する shouldRasterize プロパティなどもあります。

以下のサンプルコードでは、 SKLabelNode で作成したテキストに対して SKEffectNode を利用して CIFilter をかけて、さらに SKShader で最終的な色を調整しています。

// エフェクトノードに追加する子ノードとして
// ラベルノードを3つ用意します

// 中央の文字のラベルノードを作ります
let text = SKLabelNode(
    // AttributedStringを表示する文字として指定します
    attributedText: NSAttributedString(
        string: "Special Menu",
        attributes: [
            NSAttributedString.Key.font: UIFont(
                name: "BodoniSvtyTwoOSITCTT-Bold",
                size: 38
            )!,
            NSAttributedString.Key.foregroundColor: UIColor.white,
            NSAttributedString.Key.tracking: 2.1
        ]
    )
)
// 文字の上下の位置揃えを中央に指定します
text.verticalAlignmentMode = .center

// 上側の飾り罫のラベルノードを作ります
let decorationTop = SKLabelNode(
    text: String(repeating: "\u{007b}", count: 7)
)
// フォント名を指定します
decorationTop.fontName = "BodoniOrnamentsITCTT"
// フォントサイズを指定します
decorationTop.fontSize = text.frame.height * 0.9
// 文字の上下の位置揃えを中央に指定します
decorationTop.verticalAlignmentMode = .center
// ノードの上下の位置を調整します
decorationTop.position.y = text.frame.height * 1.5

// 下側の飾り罫のラベルノードを、上側の飾り罫のノードのコピーをもとに作ります
let decorationBottom = decorationTop.copy() as! SKLabelNode
// ノードの上下の位置を調整します
decorationBottom.position.y *= -1
// ノードを180度回転させます
decorationBottom.zRotation = CGFloat.pi

// シェーダーの適用先となるエフェクトノードを作成します
let effectNode = SKEffectNode()

// 用意したラベルノード3つをエフェクトノードに追加します
effectNode.addChild(text)
effectNode.addChild(decorationTop)
effectNode.addChild(decorationBottom)

// エフェクトノードに CIHeightFieldFromMask のフィルタを適用します
// 子ノードの文字のエッジ付近が黒く、エッジから離れるほど白い
// グレースケール画像が得られます
effectNode.filter = CIFilter(
    name: "CIHeightFieldFromMask",
    parameters: ["inputRadius": 3]
)

let shader = SKShader(
    source: """
        void main() {
            // エフェクトノードの色を u_texture を使って取得します
            vec4 texel = texture2D(u_texture, v_tex_coord);
            // 色を用意します
            vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
            vec4 green = vec4(0.64, 0.81, 0.46, 1.0);
            // レトロなスタンプ風のグラデーションを作ります
            vec4 color = mix(yellow, green, texel.r);
            color = mix(color, vec4(0.0), pow(texel.r, 4.0));
            // 作成したグラデーションで文字を出力します
            gl_FragColor = mix(vec4(0.0), color, texel.a);
        }
    """
)

effectNode.shader = shader

SKEffectNodeとSKShader

SKEffectNodeと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 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 text = SKLabelNode(
            attributedText: NSAttributedString(
                string: "Special Menu",
                attributes: [
                    NSAttributedString.Key.font: UIFont(
                        name: "BodoniSvtyTwoOSITCTT-Bold",
                        size: 38
                    )!,
                    NSAttributedString.Key.foregroundColor: UIColor.white,
                    NSAttributedString.Key.tracking: 2.1
                ]
            )
        )
        text.verticalAlignmentMode = .center

        let decorationTop = SKLabelNode(
            text: String(repeating: "\u{007b}", count: 7)
        )
        decorationTop.fontName = "BodoniOrnamentsITCTT"
        decorationTop.fontSize = text.frame.height * 0.9
        decorationTop.verticalAlignmentMode = .center
        decorationTop.position.y = text.frame.height * 1.5

        let decorationBottom = decorationTop.copy() as! SKLabelNode
        decorationBottom.position.y *= -1
        decorationBottom.zRotation = CGFloat.pi

        let effectNode = SKEffectNode()

        effectNode.addChild(text)
        effectNode.addChild(decorationTop)
        effectNode.addChild(decorationBottom)

        effectNode.filter = CIFilter(
            name: "CIHeightFieldFromMask",
            parameters: ["inputRadius": 3]
        )

        let shader = SKShader(
            source: """
                void main() {
                    vec4 texel = texture2D(u_texture, v_tex_coord);
                    vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
                    vec4 green = vec4(0.64, 0.81, 0.46, 1.0);
                    vec4 color = mix(yellow, green, texel.r);
                    color = mix(color, vec4(0.0), pow(texel.r, 4.0));
                    gl_FragColor = mix(vec4(0.0), color, texel.a);
                }
            """
        )

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

前回の記事で書きました が、 SKEffectNode でもマルチシェーダーが可能です。
下図のような流れで複数のシェーダーを適用させて、アニメーションするマルチシェーダーを実行することもできます。

繰り返しになりますが、 SKEffectNode を多用すると描画コストが上がることには留意すべきでしょう。
前述の shouldRasterize プロパティを true にするとレンダリングの負荷は下がる代わりに、子ノードのシェーダーのアニメーションは動かなくなります(前回紹介したノードをテクスチャにする方法 と同じように、静止画の状態になります)。

また、 SKEffectNode の子ノードでは、ポイント値からピクセル値に変換する倍率が、Retinaの倍率に関係なく 2.0 になるようなので、ピクセル解像度をシェーダーの座標計算で使う場合は気をつけてください。公式ドキュメントに該当の記述が見あたらず、シミュレータの仕様や不具合の可能性も考えられるので、図中の "First shader" にあたる箇所で実行するシェーダーで座標を扱うときは v_tex_coord を使っておくほうが無難だと思います。

まとめ

今回はSKShaderが使える各種ノードについて紹介しました。

図形や文字などのさまざまなノードにシェーダー、さらにテクスチャやエフェクトを組み合わせると、表現の幅がいっそう広がって、バラエティ豊かなコンテンツをSwiftUI上に表示できるようになりますね。

次回は、SpriteKitで 3Dコンテンツを表示する方法 についてとりあげたいと思います。よろしければ引き続きご覧ください。


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

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

参考リンク

Discussion