🎨

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

2024/07/13に公開

はしがき

SwiftUIでSpriteKitのSKShaderを使って遊んでみようというテーマの記事の8回目です。
前回の記事 ではSKShaderが使える5種類のノードオブジェクトについて解説しました。
今回は、SpriteKitで 3Dコンテンツを表示する方法 を紹介します。

仕組みとしては SK3DNode を介して3Dコンテンツ用のフレームワークである SceneKit を扱う方法になります。
SceneKitについてしっかり書くと数記事かけてもまとめきれなさそうなので、ごく基本的な 3Dオブジェクトの表示方法 と、 3DオブジェクトにGLSLシェーダーを適用する方法 に絞って説明したいと思います。
また、今回はシーンファイル(SceneKit Scene File)は使用せずに、コードの記述のみで3Dコンテンツを作成します。

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

ファーストステップ SpriteKit × SceneKit

SpriteKit に SceneKit の SCNScene を表示する

3DコンテンツをSKSceneに表示する最小限の手順は、次の8ステップになります。

  1. SceneKitimport する
  2. SCNScene オブジェクトを作成する
  3. SCNGeometry オブジェクト(3Dの形状データ)を作成する
  4. 3で作成した SCNGeometry オブジェクトを geometry引数 で渡して SCNNode オブジェクトを作成する
  5. 4で作成した SCNNode オブジェクトを、2で作成した SCNScene オブジェクトの rootNode プロパティに、 addChildNode メソッドを使って追加する
  6. SK3DNode オブジェクトを作成する
  7. 6で作成した SK3DNode オブジェクトの scnScene プロパティに、2で作成した SCNScene オブジェクトを格納する
  8. 6で作成した SK3DNode オブジェクトを、 SKScene オブジェクトに addChild メソッドを使って追加する

SCNSceneSKSceneSCNNodeSKNode のようにまぎらわしい名称が並びますが、 SCN と付いているのが SC e N eKit の 3Dコンテンツ用のオブジェクトで、 SK と付いているのが S prite K it の2Dコンテンツ用のオブジェクトです。

実際のコードを見てみましょう。

ContentView.swift
import SwiftUI
import SpriteKit

// SceneKitをインポートします --- (1)
import SceneKit

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)

        // SCNSceneオブジェクトを作成する処理をクロージャにまとめます (optional)
        let scnScene: SCNScene = {
            // SCNSceneオブジェクトを作成します --- (2)
            let scnScene = SCNScene()

            // SCNGeometryオブジェクトを作成します --- (3)
            // この例では直方体を作成します
            let geometry = SCNBox(
                width: 6,         // 幅を指定します
                height: 8,        // 高さを指定します
                length: 4,        // 奥行きを指定します
                chamferRadius: 1  // 辺と角の丸みを指定します
            )

            // SCNGeometryオブジェクトを引数で渡して
            // SCNNodeオブジェクトを作成します --- (4)
            let geometryNode = SCNNode(geometry: geometry)

            // SCNNodeオブジェクトの向きを調整します (optional)
            geometryNode.simdEulerAngles = simd_float3(
                x: 0,
                y: -0.2 * Float.pi,
                z: 0
            )
            // SCNVectorというデータ型を使う書きかたもあります
            // 公式ではiOS11以降はsimdの使用が推奨されています
            // geometryNode.eulerAngles = SCNVector3(
            //     x: 0,
            //     y: -0.2 * Float.pi,
            //     z: 0
            // )

            // SCNSceneのルートノードに
            // ジオメトリを設定したSCNNodeオブジェクトを追加します --- (5)
            scnScene.rootNode.addChildNode(geometryNode)

            return scnScene
        }()

        // SK3DNodeオブジェクトを200x200のサイズで作成します --- (6)
        let node = SK3DNode(
            viewportSize: CGSize(width: 200, height: 200)
        )

        // SK3DNodeのscnSceneプロパティにSCNSceneオブジェクトを格納します --- (7)
        node.scnScene = scnScene

        // SKSceneにSK3DNodeオブジェクトを追加します --- (8)
        self.addChild(node)
    }
}

SCNSceneの直方体の表示

SpriteKitの SKScene (青い四角の部分)の上に、モノトーンの直方体を配置したSceneKitの SCNScene が表示されました。

直方体以外にも、平面や球体やテキストなどの基本的な形状が 組み込みのGeometryオブジェクト として用意されています。 頂点データを用意して独自のGeometryを作成する他の3D制作ツールで作成したシーンコンテンツを読みこませて表示する 、といったこともできます。

SCNScene にライトとカメラを追加する

WebGLなどで3Dコンテンツを作成した経験があるかたは、さきほどのサンプルコードにライトやカメラの設定がなかったことに気づかれたかもしれません。
SK3DNode では デフォルトのライトとカメラ が有効になっているので、それらの記述がなくても3Dオブジェクトが画面上に表示されます。
もちろん自分でライトやカメラの設定を書くこともできます。

SCNScene に点光源と環境光のライトを用意し、直方体を少し見おろすような視点になるようにカメラも追加してみましょう。
ライトは SCNLight 、カメラは SCNCamera を使って設定します。

ContentView.swift
  // 〜略〜

  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 scnScene: SCNScene = {
              let scnScene = SCNScene()

              let geometry = SCNBox(
                  width: 6,
                  height: 8,
                  length: 4,
                  chamferRadius: 1
              )
              let geometryNode = SCNNode(geometry: geometry)
              geometryNode.simdEulerAngles = simd_float3(
                  x: 0,
                  y: -0.2 * Float.pi,
                  z: 0
              )
              scnScene.rootNode.addChildNode(geometryNode)

+             // ライト1 点光源
+
+             // ライト用のSCNNodeオブジェクトを作成します
+             let omniLightNode = SCNNode()
+             // lightプロパティにSCNLightオブジェクトを格納します
+             omniLightNode.light = SCNLight()
+             // ライトの種類を指定します
+             omniLightNode.light!.type = .omni
+             // ライトの明るさを指定します
+             omniLightNode.light!.intensity = 700
+             // ライトノードの位置を指定します
+             omniLightNode.simdPosition = simd_float3(x: 0, y: 300, z: 500)
+             // SCNSceneのルートノードに
+             // ライトを設定したSCNNodeオブジェクトを追加します
+             scnScene.rootNode.addChildNode(omniLightNode)
+
+             // ライト2 環境光
+
+             // ライト用のSCNNodeオブジェクトを作成します
+             let ambientLightNode = SCNNode()
+             // lightプロパティにSCNLightオブジェクトを格納します
+             ambientLightNode.light = SCNLight()
+             // ライトの種類を指定します
+             ambientLightNode.light!.type = .ambient
+             // ライトの色を指定します
+             ambientLightNode.light!.color = UIColor.systemOrange
+             // SCNSceneのルートノードに
+             // ライトを設定したSCNNodeオブジェクトを追加します
+             scnScene.rootNode.addChildNode(ambientLightNode)
+
+             // カメラ
+
+             // カメラ用のSCNNodeオブジェクトを作成します
+             let cameraNode = SCNNode()
+             // cameraプロパティにSCNCameraオブジェクトを格納します
+             cameraNode.camera = SCNCamera()
+             // カメラのZ軸方向の描画範囲を自動で調整します
+             cameraNode.camera!.automaticallyAdjustsZRange = true
+             // カメラノードの位置を指定します
+             cameraNode.simdPosition = simd_float3(x: 0, y: 5, z: 10)
+             // カメラノードの向きをgeometryNodeの方向に自動的に合わせます
+             cameraNode.constraints = [
+                 SCNLookAtConstraint(target: geometryNode)
+             ]
+             // SCNSceneのルートノードに
+             // カメラを設定したSCNNodeオブジェクトを追加します
+             scnScene.rootNode.addChildNode(cameraNode)

              return scnScene
          }()

          let node = SK3DNode(
              viewportSize: CGSize(width: 200, height: 200)
          )
          node.scnScene = scnScene
          self.addChild(node)
      }
  }

SCNSceneにライトとカメラを追加

上方手前側にある点光源からの光と、オレンジ色の環境光が直方体に当たり、それを少し上の位置にあるカメラから撮影したような表示になりました。

Shader Modifiers in SceneKit

SceneKitでは、 SCNGeometry オブジェクト、または SCNMaterial オブジェクトの shaderModifiers プロパティにシェーダーのデータを格納することで、 ジオメトリにシェーダーを適用できます[1]

SCNMaterial はジオメトリの外観を設定できるオブジェクトです。テクスチャや法線マップ [2] を貼るほかにも、表面の色・質感・反射モデルの種類 といった属性を指定することができます。
SCNMaterial オブジェクトを SCNGeometry オブジェクトに適用することで、形状の見た目を変更できます。

シェーディング言語は Metal(MSL)OpenGL(GLSL) が使えます。本記事のサンプルコードでは GLSL を使います。

シェーダーの種類

SpriteKitの SKShader で書けるのはフラグメントシェーダーだけでしたが、 SceneKitの shaderModifiers では次の 4種類のシェーダー を書くことができます。

種類 概要
geometry 形状の頂点の位置の移動や法線の向き [3] の変更ができるシェーダーです
surface 形状の表面の色やテクスチャをカスタマイズできるシェーダーです
lightingModel ライティングをカスタマイズできるシェーダーです
fragment 最終的に出力するピクセルの色をカスタマイズできるシェーダーです

それぞれ簡単な適用例を紹介していきます。

geometry シェーダーの適用例

形状の頂点の位置の移動や法線の向きの変更ができる、 geometryシェーダー を適用する例です。

特定の範囲にある頂点の位置を移動するシェーダーを作成して、球体のジオメトリを変形させてみます。
※長くなるのでサンプルコードを折りたたみます。

geometryシェーダーのサンプルコード(SCNScene作成部分のみ抜粋)
let scnScene: SCNScene = {
    let scnScene = SCNScene()

    // SCNGeometryオブジェクトを作成します
    // この例では球体を作成します
    let geometry = SCNSphere(
        radius: 1.0  // 球の半径を指定します
    )

    // SCNGeometryのshaderModifiersプロパティに、
    // シェーダーの種類とソースコードを辞書データで格納します
    geometry.shaderModifiers = [
        // シェーダーの種類としてgeometryを指定します
        SCNShaderModifierEntryPoint.geometry: """
            // シェーダーのコードスニペットをテキストで書きます
            // void main() { } の宣言は不要です

            // 球体の上半分を平らにします
            bool k = _geometry.position.y <= 0.0;
            _geometry.position.y *= k;
            // ※MSLではbool型からfloat型への暗黙の型変換がサポートされています
            // Shader ModifiersのGLSLでも、MSLと同様に暗黙の型変換が行われます

            // 移動させた頂点の接線と法線を調整します
            _geometry.tangent.y *= k;
            _geometry.tangent.xyz = k ? _geometry.tangent.xyz : normalize(_geometry.tangent.xyz);
            _geometry.normal = k ? _geometry.normal : vec3(0.0, 1.0, 0.0);
        """
    ]

    // SCNGeometryオブジェクトを引数で渡して
    // SCNNodeオブジェクトを作成します
    let geometryNode = SCNNode(geometry: geometry)
    geometryNode.simdRotation = simd_float4(
        x: 1,
        y: 0,
        z: 0,
        w: 0.1 * Float.pi
    )
    scnScene.rootNode.addChildNode(geometryNode)

    let omniLightNode = SCNNode()
    omniLightNode.light = SCNLight()
    omniLightNode.light!.type = .omni
    omniLightNode.light!.intensity = 300
    omniLightNode.simdPosition = simd_float3(x: 50, y: 100, z: 50)
    scnScene.rootNode.addChildNode(omniLightNode)

    let ambientLightNode = SCNNode()
    ambientLightNode.light = SCNLight()
    ambientLightNode.light!.type = .ambient
    ambientLightNode.light!.color = UIColor.systemYellow
    scnScene.rootNode.addChildNode(ambientLightNode)

    return scnScene
}()

geometoryシェーダーで形状を変える

球体の上半分の頂点が Y = 0.0 の位置(ジオメトリのローカル座標の原点である中央の高さ)に移動して、半球体ができました。

geometryシェーダーの input / output の構造体については こちらの公式ドキュメント に記載があります。 [4]

geometryシェーダーのサンプルコード全体
ContentView.swift
import SwiftUI
import SpriteKit
import SceneKit

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 scnScene: SCNScene = {
            let scnScene = SCNScene()

            let geometry = SCNSphere(
                radius: 1.0
            )

            geometry.shaderModifiers = [
                SCNShaderModifierEntryPoint.geometry: """
                    bool k = _geometry.position.y <= 0.0;
                    _geometry.position.y *= k;
                    _geometry.tangent.y *= k;
                    _geometry.tangent.xyz = k ? _geometry.tangent.xyz : normalize(_geometry.tangent.xyz);
                    _geometry.normal = k ? _geometry.normal : vec3(0.0, 1.0, 0.0);
                """
            ]

            let geometryNode = SCNNode(geometry: geometry)
            geometryNode.simdRotation = simd_float4(
                x: 1,
                y: 0,
                z: 0,
                w: 0.1 * Float.pi
            )
            scnScene.rootNode.addChildNode(geometryNode)

            let omniLightNode = SCNNode()
            omniLightNode.light = SCNLight()
            omniLightNode.light!.type = .omni
            omniLightNode.light!.intensity = 300
            omniLightNode.simdPosition = simd_float3(x: 50, y: 100, z: 50)
            scnScene.rootNode.addChildNode(omniLightNode)

            let ambientLightNode = SCNNode()
            ambientLightNode.light = SCNLight()
            ambientLightNode.light!.type = .ambient
            ambientLightNode.light!.color = UIColor.systemYellow
            scnScene.rootNode.addChildNode(ambientLightNode)

            return scnScene
        }()

        let node = SK3DNode(
            viewportSize: CGSize(width: 200, height: 200)
        )
        node.scnScene = scnScene
        self.addChild(node)
    }
}

surface シェーダーの適用例

形状の表面の色やテクスチャをカスタマイズできる、 surfaceシェーダー を適用する例です。

座標によって違う色で塗るシェーダーを作成して、四角錐のジオメトリの表面を着色してみます。
さきほどのgeometryシェーダーでは SCNGeometry オプジェクトの shaderModifiers プロパティにシェーダーを格納して適用しました。こんどは SCNMaterial を使う方法を試します。
※長くなるのでサンプルコードを折りたたみます。

surfaceシェーダーのサンプルコード(SCNScene作成部分のみ抜粋)
let scnScene: SCNScene = {
    let scnScene = SCNScene()

    // SCNGeometryオブジェクトを作成します
    // この例では四角錘を作成します
    let geometry = SCNPyramid(
        width: 2,   // 幅を指定します
        height: 3,  // 高さを指定します
        length: 1   // 奥行きを指定します
    )

    // SCNMaterialオブジェクトを作成します
    let material = SCNMaterial()

    // SCNMaterialのshaderModifiersプロパティに、
    // シェーダーの種類とソースコードを辞書データで格納します
    material.shaderModifiers = [
        // シェーダーの種類としてsurfaceを指定します
        SCNShaderModifierEntryPoint.surface: """
            // 色を用意します
            vec4 red = vec4(0.82, 0.07, 0.15, 1.0);
            vec4 green = vec4(0.16, 0.43, 0.23, 1.0);
            // テクスチャ座標のYの値を使って、面を2色で塗ります
            _surface.diffuse = _surface.diffuseTexcoord.y > 0.9 ? green : red;
        """
    ]

    // SCNGeometryのfirstMaterialプロパティに
    // SCNMaterialオブジェクトを格納します
    geometry.firstMaterial = material

    // SCNGeometryオブジェクトを引数で渡して
    // SCNNodeオブジェクトを作成します
    let geometryNode = SCNNode(geometry: geometry)
    geometryNode.simdOrientation = simd_quaternion(
        -0.2 * Float.pi,
        geometryNode.simdWorldUp
    )
    scnScene.rootNode.addChildNode(geometryNode)

    let omniLightNode = SCNNode()
    omniLightNode.light = SCNLight()
    omniLightNode.light!.type = .omni
    omniLightNode.light!.intensity = 300
    omniLightNode.simdPosition = simd_float3(x: -100, y: 100, z: 50)
    scnScene.rootNode.addChildNode(omniLightNode)

    let ambientLightNode = SCNNode()
    ambientLightNode.light = SCNLight()
    ambientLightNode.light!.type = .ambient
    scnScene.rootNode.addChildNode(ambientLightNode)

    return scnScene
}()

surfaceシェーダーで面の色を変える

四角錐が、テクスチャ座標(左上が原点)の Y = 0.9 の位置を境に、赤色と緑色のツートンカラーで塗られました。surfaceシェーダーのあとでライティングの処理が行われるので、光の当たる量が少ない右側面は色がやや暗くなっています。

surfaceシェーダーの input / output の構造体については こちらの公式ドキュメント に記載があります。 [5]

今回の例では diffuse (拡散反射光)の色のみカスタマイズしていますが、他にもspecularやreflectiveなどの各パラメータも扱えます。それぞれのパラメータについては 公式ドキュメントの SCNMaterial 配下の各ページ に説明があり、図が添えられている項目も多いので参考になると思います。

surfaceシェーダーのサンプルコード全体
ContentView.swift
import SwiftUI
import SpriteKit
import SceneKit

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.0, green: 0.71, blue: 0.87, alpha: 1.0)

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

        let scnScene: SCNScene = {
            let scnScene = SCNScene()

            let geometry = SCNPyramid(
                width: 2,
                height: 3,
                length: 1
            )

            let material = SCNMaterial()

            material.shaderModifiers = [
                SCNShaderModifierEntryPoint.surface: """
                    vec4 red = vec4(0.82, 0.07, 0.15, 1.0);
                    vec4 green = vec4(0.16, 0.43, 0.23, 1.0);
                    _surface.diffuse = _surface.diffuseTexcoord.y > 0.9 ? green : red;
                """
            ]

            geometry.firstMaterial = material

            let geometryNode = SCNNode(geometry: geometry)
            geometryNode.simdOrientation = simd_quaternion(
                -0.2 * Float.pi,
                geometryNode.simdWorldUp
            )
            scnScene.rootNode.addChildNode(geometryNode)

            let omniLightNode = SCNNode()
            omniLightNode.light = SCNLight()
            omniLightNode.light!.type = .omni
            omniLightNode.light!.intensity = 300
            omniLightNode.simdPosition = simd_float3(x: -100, y: 100, z: 50)
            scnScene.rootNode.addChildNode(omniLightNode)

            let ambientLightNode = SCNNode()
            ambientLightNode.light = SCNLight()
            ambientLightNode.light!.type = .ambient
            scnScene.rootNode.addChildNode(ambientLightNode)

            return scnScene
        }()

        let node = SK3DNode(
            viewportSize: CGSize(width: 200, height: 200)
        )
        node.scnScene = scnScene
        self.addChild(node)
    }
}

lightingModel シェーダーの適用例

ライティングをカスタマイズできる、 lightingModelシェーダー を適用する例です。

光の当たり具合で生じる色の明暗をくっきりと段階的に塗りわけるシェーダーを作成して、円環体(トーラス体)のジオメトリをセルアニメのような見た目にしてみます。
塗りわける色数はシーン側で指定して、uniform変数でシェーダーへ渡す実装にします。
Shader Modifiersでuniform変数をシェーダーへ渡す方法は こちらの公式ドキュメント [6] に説明があります。ここに記載されている setValue(_:forKey:) メソッドを使う書きかたを試します。
※長くなるのでサンプルコードを折りたたみます。

lightingModelシェーダーのサンプルコード(SCNScene作成部分のみ抜粋)
let scnScene: SCNScene = {
    let scnScene = SCNScene()

    // SCNGeometryオブジェクトを作成します
    // この例では円環体を作成します
    let geometry = SCNTorus(
        ringRadius: 5,  // 円の半径を指定します
        pipeRadius: 3   // 筒の半径を指定します
    )

    // SCNMaterialオブジェクトを作成します
    let material = SCNMaterial()

    // 面を塗る色を指定します
    material.diffuse.contents = UIColor.brown

    // 面を塗りわける色数を指定するint値を
    // uniform変数としてSCNMaterialのシェーダーに渡します
    material.setValue(
        Int(3),
        forKey: "number_of_colors"
    )

    // SCNMaterialのshaderModifiersプロパティに、
    // シェーダーの種類とソースコードを辞書データで格納します
    material.shaderModifiers = [
        // シェーダーの種類としてlightingModelを指定します
        SCNShaderModifierEntryPoint.lightingModel: """
            // 受け取るuniform変数を宣言します
            uniform int number_of_colors;
            // uniform変数で指定した色数で塗りわけられるように
            // 光の当たり具合で生じる色の明暗を段階的に変化させます
            float d = dot(_surface.normal, _light.direction);
            vec3 color = _lightingContribution.diffuse + _light.intensity.rgb * pow(d, 2.0);
            _lightingContribution.diffuse = floor(min(0.999, color) * number_of_colors) / number_of_colors;
        """
    ]

    // SCNGeometryのmaterialsプロパティに
    // SCNMaterialオブジェクトを配列で格納します
    geometry.materials = [material]

    // SCNGeometryオブジェクトを引数で渡して
    // SCNNodeオブジェクトを作成します
    let geometryNode = SCNNode(geometry: geometry)
    geometryNode.simdOrientation = simd_quatf(
        ix: sin(0.2 * Float.pi),
        iy: 0,
        iz: 0,
        r: cos(0.2 * Float.pi)
    )
    scnScene.rootNode.addChildNode(geometryNode)

    let omniLightNode = SCNNode()
    omniLightNode.light = SCNLight()
    omniLightNode.light!.type = .omni
    omniLightNode.light!.intensity = 700
    omniLightNode.simdPosition = simd_float3(x: 50, y: 300, z: 500)
    scnScene.rootNode.addChildNode(omniLightNode)

    let ambientLightNode = SCNNode()
    ambientLightNode.light = SCNLight()
    ambientLightNode.light!.type = .ambient
    scnScene.rootNode.addChildNode(ambientLightNode)

    return scnScene
}()

lightingModelシェーダーで光の当たりかたを変える

セルアニメ風に塗られたドーナツが描画されました。

lightingModelシェーダーの input / output の構造体については こちらの公式ドキュメント に記載があります。

ジオメトリにマテリアルを適用する箇所で、ひとつ前のsurfaceシェーダーの例では geometry.firstMaterial = material と書き、このlightingModelシェーダーの例では geometry.materials = [material] という書きかたをしました。この2つは同じ意味になります。どちらで書いても、マテリアルがジオメトリのすべての面に適用されます。
面によって違うマテリアルを適用させることもできます。次のfragmentシェーダーではその方法を使ってみます。

lightingModelシェーダーのサンプルコード全体
ContentView.swift
import SwiftUI
import SpriteKit
import SceneKit

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

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

        let scnScene: SCNScene = {
            let scnScene = SCNScene()

            let geometry = SCNTorus(
                ringRadius: 5,
                pipeRadius: 3
            )

            let material = SCNMaterial()
            material.diffuse.contents = UIColor.brown

            material.setValue(
                Int(3),
                forKey: "number_of_colors"
            )

            material.shaderModifiers = [
                SCNShaderModifierEntryPoint.lightingModel: """
                    uniform int number_of_colors;
                    float d = dot(_surface.normal, _light.direction);
                    vec3 color = _lightingContribution.diffuse + _light.intensity.rgb * pow(d, 2.0);
                    _lightingContribution.diffuse = floor(min(0.999, color) * number_of_colors) / number_of_colors;
                """
            ]

            geometry.materials = [material]

            let geometryNode = SCNNode(geometry: geometry)
            geometryNode.simdOrientation = simd_quatf(
                ix: sin(0.2 * Float.pi),
                iy: 0,
                iz: 0,
                r: cos(0.2 * Float.pi)
            )
            scnScene.rootNode.addChildNode(geometryNode)

            let omniLightNode = SCNNode()
            omniLightNode.light = SCNLight()
            omniLightNode.light!.type = .omni
            omniLightNode.light!.intensity = 700
            omniLightNode.simdPosition = simd_float3(x: 50, y: 300, z: 500)
            scnScene.rootNode.addChildNode(omniLightNode)

            let ambientLightNode = SCNNode()
            ambientLightNode.light = SCNLight()
            ambientLightNode.light!.type = .ambient
            scnScene.rootNode.addChildNode(ambientLightNode)

            return scnScene
        }()

        let node = SK3DNode(
            viewportSize: CGSize(width: 200, height: 200)
        )
        node.scnScene = scnScene
        self.addChild(node)
    }
}

fragment シェーダーの適用例

最終的に出力するピクセルの色をカスタマイズできる、 fragmentシェーダー を適用する例です。

円柱のジオメトリの側面・上面・底面の3つの面用にそれぞれシェーダーを作成して、各面に適用させてみます。
上面・底面にはテクスチャを貼り、シェーダーでテクスチャのサイズや向きを調整するようなカスタマイズも行います。 [7]
テクスチャ用の画像は 第5回の記事 で使用したSVGファイルと同じものです。
※長くなるのでサンプルコードを折りたたみます。なお、このサンプルコードはSwift Playgroundsではテクスチャを貼った面が正常に描画されません。

fragmentシェーダーのサンプルコード(SCNScene作成部分のみ抜粋)
let scnScene: SCNScene = {
    let scnScene = SCNScene()

    // SCNGeometryオブジェクトを作成します
    // この例では円柱を作成します
    let geometry = SCNCylinder(
        radius: 3,  // 円の半径を指定します
        height: 1   // 高さを指定します
    )

    // SCNMaterialオブジェクトを3つ作成します
    let materialSide = SCNMaterial()    // 側面用
    let materialTop = SCNMaterial()     // 上面用
    let materialBottom = SCNMaterial()  // 底面用

    // 上面用と底面用のSCNMaterialオブジェクトにテクスチャを適用します
    let diffuseTexture = SKTexture(imageNamed: "yacht")
    materialTop.diffuse.contents = diffuseTexture
    materialBottom.diffuse.contents = diffuseTexture

    // 上面用のテクスチャを反時計回りに90度回転します
    let rotate = SCNMatrix4MakeRotation(0.5 * Float.pi, 0, 0, 1)
    let move = SCNMatrix4MakeTranslation(1, 0, 0)
    materialTop.diffuse.contentsTransform = SCNMatrix4Mult(rotate, move)
    // ※contentsTransformによる変形処理とともに、裏返しに貼られたSKTextureが
    // 自動的に表向きにひっくり返ります

    // 共通で使う色をuniform変数としてシェーダーに渡します
    let pink = SCNVector4(0.95, 0.66, 0.72, 1.0)
    let white = SCNVector4(1.0, 0.99, 0.93, 1.0)
    materialSide.setValue(pink, forKey: "outer_color")
    materialTop.setValue(pink, forKey: "outer_color")
    materialBottom.setValue(pink, forKey: "outer_color")
    materialTop.setValue(white, forKey: "inner_color")
    materialBottom.setValue(white, forKey: "inner_color")

    // SCNMaterialのshaderModifiersプロパティに
    // シェーダーの種類とソースコードを辞書データで格納します
    materialSide.shaderModifiers = [
        // シェーダーの種類としてfragmentを指定します
        SCNShaderModifierEntryPoint.fragment: """
            // 受け取るuniform変数を宣言します
            uniform vec4 outer_color;
            // 外枠の色を出力します
            _output.color = outer_color;
            // ライティングによる陰影を一定の割合で反映します
            _output.color.rgb *= 0.6 + _lightingContribution.diffuse * 0.5;
        """
    ]
    materialTop.shaderModifiers = [
        SCNShaderModifierEntryPoint.fragment: """
            // 受け取るuniform変数を宣言します
            uniform vec4 outer_color;
            uniform vec4 inner_color;

            // テクスチャ座標を正規化します
            vec2 st = _surface.diffuseTexcoord * 2.0 - 1.0;

            // テクスチャ取得用の座標に縮小の調整を加えます
            vec2 uv = st * 0.68 + 0.5;
            // マテリアルのdiffuseに適用されたテクスチャの色を取得します
            vec4 texel = texture2D(u_diffuseTexture, uv);
            // 縮小したぶん、テクスチャの端のピクセルが引き伸ばされないようにします
            texel.a = uv.x < 0.0 || 1.0 < uv.x ? 0.0 : texel.a;

            // 円周よりやや小さい円を作成します
            float l = step(0.85, length(st));
            // 円の内側と外側を塗りわけます
            vec4 color = mix(inner_color, outer_color, l);

            // 色とテクスチャを重ねて出力します
            _output.color = mix(color, texel, texel.a);
            // ライティングによる陰影を一定の割合で反映します
            _output.color.rgb *= 0.6 + _lightingContribution.diffuse * 0.5;
        """
    ]
    materialBottom.shaderModifiers = [
        SCNShaderModifierEntryPoint.fragment: """
            // テクスチャ座標の回転もシェーダーで行う例です

            // 受け取るuniform変数を宣言します
            uniform vec4 outer_color;
            uniform vec4 inner_color;

            // 座標回転用の関数を定義します
            mat2 rotate2d(float _angle) {
                return mat2(
                    cos(_angle), -sin(_angle),
                    sin(_angle), cos(_angle)
                );
            }

            // グローバル関数定義とmain部分のあいだに #pragma body が必要です
            #pragma body;

            // テクスチャ座標を正規化します
            vec2 st = _surface.diffuseTexcoord * 2.0 - 1.0;

            // テクスチャ取得用の座標に、回転・縮小の調整を加えます
            vec2 uv = rotate2d(3.1416 * -0.5) * st;
            uv = uv * 0.68 + 0.5;
            // マテリアルのdiffuseに適用されたテクスチャの色を取得します
            vec4 texel = texture2D(u_diffuseTexture, uv);
            // 縮小したぶん、テクスチャの端のピクセルが引き伸ばされないようにします
            texel.a = uv.x < 0.0 || 1.0 < uv.x ? 0.0 : texel.a;

            // 円周よりやや小さい円を作成します
            float l = step(0.85, length(st));
            // 円の内側と外側を塗りわけます
            vec4 color = mix(inner_color, outer_color, l);

            // 色とテクスチャを重ねて出力します
            _output.color = mix(color, texel, texel.a);
            // ライティングによる陰影を一定の割合で反映します
            _output.color.rgb *= 0.6 + _lightingContribution.diffuse * 0.5;
        """
    ]

    // SCNGeometryのmaterialsプロパティに
    // SCNMaterialオブジェクトを配列で格納します
    // 配列に格納されたマテリアルが、各面にそれぞれ適用されます
    geometry.materials = [
        materialSide,   // 側面
        materialTop,    // 上面
        materialBottom  // 底面
    ]

    // SCNGeometryオブジェクトを引数で渡して
    // SCNNodeオブジェクトを作成します
    let geometryNode = SCNNode(geometry: geometry)
    geometryNode.simdRotation = simd_float4(
        x: 1,
        y: 0,
        z: 0,
        w: 0.5 * Float.pi
    )
    scnScene.rootNode.addChildNode(geometryNode)

    // SCNNodeオブジェクトを回転させます
    geometryNode.runAction(
        SCNAction.repeatForever(
            SCNAction.rotateBy(x: 0, y: 1, z: 0, duration: 1)
        )
    )

    // 影を描画するためにフロアのSCNGeometryオブジェクトのノードを作成します
    let floorNode = SCNNode(geometry: SCNFloor())
    // フロアジオメトリにマテリアルを追加します
    floorNode.geometry!.firstMaterial = SCNMaterial()
    // 影のみを描画するようにマテリアルの反射モデルを指定します
    floorNode.geometry!.firstMaterial!.lightingModel = .shadowOnly
    // 縦にした円柱のジオメトリのやや下の位置にフロアノードを配置します
    floorNode.position.y = geometry.boundingBox.min.x - 4
    scnScene.rootNode.addChildNode(floorNode)

    // 影を描画するための指向性ライトを追加します
    let directionalLightNode = SCNNode()
    directionalLightNode.light = SCNLight()
    directionalLightNode.light!.type = .directional
    // 影を落とす機能を有効にします
    directionalLightNode.light!.castsShadow = true
    // 影の透明度を指定します
    directionalLightNode.light!.shadowColor = UIColor(white: 0, alpha: 0.3)
    // ライトノードの向きを指定します
    directionalLightNode.simdOrientation = simd_quatf(
        angle: -0.2 * Float.pi,
        axis: simd_normalize(simd_float3(x: 0.2, y: 0.02, z: 0))
    )
    scnScene.rootNode.addChildNode(directionalLightNode)

    let cameraNode = SCNNode()
    cameraNode.camera = SCNCamera()
    // カメラのZ軸方向の描画範囲を指定します
    cameraNode.camera!.zFar = 40
    cameraNode.simdPosition = simd_float3(x: 0, y: -1, z: 10)
    scnScene.rootNode.addChildNode(cameraNode)

    return scnScene
}()

fragmentシェーダーで最終的な色を変える

空中で回転する、金太郎飴のような柄の円柱ができました。

fragmentシェーダーの input / output の構造体については こちらの公式ドキュメント に記載があります。
gl_FragCoordgl_FragColor も使用可能で、 _output.color ではなく gl_FragColor で最終的な色を出力することもできます。

テクスチャのサンプラーの変数である u_diffuseTexture など、SceneKitがあらかじめ用意している変数については こちらの公式ドキュメント に記載があります。 [8]

fragmentシェーダーのサンプルコード全体
ContentView.swift
import SwiftUI
import SpriteKit
import SceneKit

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 green = UIColor(red: 0.72, green: 0.82, blue: 0.53, alpha: 1.0)

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

        let scnScene: SCNScene = {
            let scnScene = SCNScene()

            let geometry = SCNCylinder(
                radius: 3,
                height: 1
            )

            let materialSide = SCNMaterial()
            let materialTop = SCNMaterial()
            let materialBottom = SCNMaterial()

            let diffuseTexture = SKTexture(imageNamed: "yacht")
            materialTop.diffuse.contents = diffuseTexture
            materialBottom.diffuse.contents = diffuseTexture

            let rotate = SCNMatrix4MakeRotation(0.5 * Float.pi, 0, 0, 1)
            let move = SCNMatrix4MakeTranslation(1, 0, 0)
            materialTop.diffuse.contentsTransform = SCNMatrix4Mult(rotate, move)

            let pink = SCNVector4(0.95, 0.66, 0.72, 1.0)
            let white = SCNVector4(1.0, 0.99, 0.93, 1.0)
            materialSide.setValue(pink, forKey: "outer_color")
            materialTop.setValue(pink, forKey: "outer_color")
            materialBottom.setValue(pink, forKey: "outer_color")
            materialTop.setValue(white, forKey: "inner_color")
            materialBottom.setValue(white, forKey: "inner_color")

            materialSide.shaderModifiers = [
                SCNShaderModifierEntryPoint.fragment: """
                    uniform vec4 outer_color;
                    _output.color = outer_color;
                    _output.color.rgb *= 0.6 + _lightingContribution.diffuse * 0.5;
                """
            ]
            materialTop.shaderModifiers = [
                SCNShaderModifierEntryPoint.fragment: """
                    uniform vec4 outer_color;
                    uniform vec4 inner_color;
                    vec2 st = _surface.diffuseTexcoord * 2.0 - 1.0;
                    vec2 uv = st * 0.68 + 0.5;
                    vec4 texel = texture2D(u_diffuseTexture, uv);
                    texel.a = uv.x < 0.0 || 1.0 < uv.x ? 0.0 : texel.a;
                    float l = step(0.85, length(st));
                    vec4 color = mix(inner_color, outer_color, l);
                    _output.color = mix(color, texel, texel.a);
                    _output.color.rgb *= 0.6 + _lightingContribution.diffuse * 0.5;
                """
            ]
            materialBottom.shaderModifiers = [
                SCNShaderModifierEntryPoint.fragment: """
                    uniform vec4 outer_color;
                    uniform vec4 inner_color;
                    mat2 rotate2d(float _angle) {
                        return mat2(
                            cos(_angle), -sin(_angle),
                            sin(_angle), cos(_angle)
                        );
                    }
                    #pragma body;
                    vec2 st = _surface.diffuseTexcoord * 2.0 - 1.0;
                    vec2 uv = rotate2d(3.1416 * -0.5) * st;
                    uv = uv * 0.68 + 0.5;
                    vec4 texel = texture2D(u_diffuseTexture, uv);
                    texel.a = uv.x < 0.0 || 1.0 < uv.x ? 0.0 : texel.a;
                    float l = step(0.85, length(st));
                    vec4 color = mix(inner_color, outer_color, l);
                    _output.color = mix(color, texel, texel.a);
                    _output.color.rgb *= 0.6 + _lightingContribution.diffuse * 0.5;
                """
            ]

            geometry.materials = [
                materialSide,
                materialTop,
                materialBottom
            ]

            let geometryNode = SCNNode(geometry: geometry)
            geometryNode.simdRotation = simd_float4(
                x: 1,
                y: 0,
                z: 0,
                w: 0.5 * Float.pi
            )
            scnScene.rootNode.addChildNode(geometryNode)

            geometryNode.runAction(
                SCNAction.repeatForever(
                    SCNAction.rotateBy(x: 0, y: 1, z: 0, duration: 1)
                )
            )

            let floorNode = SCNNode(geometry: SCNFloor())
            floorNode.geometry!.firstMaterial = SCNMaterial()
            floorNode.geometry!.firstMaterial!.lightingModel = .shadowOnly
            floorNode.position.y = geometry.boundingBox.min.x - 4
            scnScene.rootNode.addChildNode(floorNode)

            let directionalLightNode = SCNNode()
            directionalLightNode.light = SCNLight()
            directionalLightNode.light!.type = .directional
            directionalLightNode.light!.castsShadow = true
            directionalLightNode.light!.shadowColor = UIColor(white: 0, alpha: 0.3)
            directionalLightNode.simdOrientation = simd_quatf(
                angle: -0.2 * Float.pi,
                axis: simd_normalize(simd_float3(x: 0.2, y: 0.02, z: 0))
            )
            scnScene.rootNode.addChildNode(directionalLightNode)

            let cameraNode = SCNNode()
            cameraNode.camera = SCNCamera()
            cameraNode.camera!.zFar = 40
            cameraNode.simdPosition = simd_float3(x: 0, y: -1, z: 10)
            scnScene.rootNode.addChildNode(cameraNode)

            return scnScene
        }()

        let node = SK3DNode(
            viewportSize: CGSize(width: 200, height: 200)
        )
        node.scnScene = scnScene
        self.addChild(node)
    }
}

SceneKit × SKShader

SceneKitの3DコンテンツをSpriteKitの SK3DNode で表示させ、それをさらに 前回の記事で紹介した SKEffectNode の子ノードに追加すると、 SceneKitのコンテンツに対してもSKShaderが適用できるようになります

SceneKitのポストプロセスの仕組みとしては SCNTechnique がありますが、情報が少なく仕組みも複雑なので入門するにはややハードル高めです。
一方、 SKEffectNodeSKShader であれば、3Dオブジェクトに対しての直接的な操作はできないものの、二次元平面上での変形や色のカスタマイズは前回までの記事の知識で可能なので、『もうひと工夫、ちょっと手を加えたい』というようなときの用途としてもってこいです。

SK3DNodeSKShader を組み合わせたサンプルコードを掲載します。
下のスクリーンショットの動く矢印部分が SK3DNode を子ノードに追加した SKEffectNode 、白抜き文字とその背後の青色の部分が SKLabelNodeSKShapeNode 、それ以外の部分はSwiftUIのViewです。
矢印の3Dオブジェクトが右側にあるタイトル文字を突っつくような動きを、 SKEffectNode に適用した SKShader で加えています。

SK3DNodeにSKShaderを適用する

※コードがだいぶ長くなってしまったので、ContentView、SCNScene、SKSceneのファイルを分けて、それぞれのコードを折りたたみます。

SceneKitとSKShaderを組み合わせたサンプルコード(ContentView)
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 {
            // SwiftUIのViewの背景色を薄ピンク色にします
            Color(red: 0.89, green: 0.73, blue: 0.74)

            VStack {
                // SpriteKitのシーンを300x80のサイズで表示します
                SpriteView(
                    scene: self.currentScene,
                    // 背景の透過を有効にします
                    options: [.allowsTransparency]
                )
                .frame(width: 300, height: 80)

                Text("Lorem ipsum dolor sit amet, consectetur adipisci elit, sed eiusmod tempor incidunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint obcaecat cupiditat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
                    .font(.system(size: 18))
                    .foregroundStyle(Color(red: 0.0, green: 0.33, blue: 0.51))
                    .padding(.horizontal, 30)
            }
            .background() {
                RoundedRectangle(cornerRadius: 50)
                    .fill(Color.white)
                    .padding(.bottom, 8)
                    .offset(y: 40)
            }
            .padding(.horizontal, 50)
            .offset(y: -40)
        }
    }
}
SceneKitとSKShaderを組み合わせたサンプルコード(SCNScene)

SCNSceneのコードの内容に関しては注釈が2点あります。

  1. SKEffectNode の子ノードでSceneKitのジオメトリを表示させるには、 SCNMaterial.readsFromDepthBuffer プロパティの値を false にする必要があります。
    ※サンプルコード中の (1) の箇所。
  2. SKEffectNode の子ノードでSceneKitのコンテンツをレンダリングすると、 SCNSceneのY軸の正負が反転する ようなので、関連箇所の修正が必要です。 [9]
    ※サンプルコード中の (2) のノードの回転方向や、 (3) の点光源のY軸の向きなど。
MySCNScene.swift
import SceneKit

class MySCNScene: SCNScene {
    override init() {
        super.init()

        // SCNGeometryオブジェクトを作成します
        // この例ではテキストを作成します
        let text = SCNText(
            string: "➽",       // 文字列を指定します
            extrusionDepth: 8  // 奥行きを指定します
        )
        // フォント名とサイズを指定します
        text.font = UIFont(name: "Futura-Bold", size: 40)
        // 面取りの半径を指定します
        text.chamferRadius = 1
        // 形状のなめらかさを指定します
        text.flatness = 0.6

        // ジオメトリが持つデフォルトのSCNMaterialのshaderModifiersプロパティに、
        // シェーダーの種類とソースコードを辞書データで格納します
        text.firstMaterial?.shaderModifiers = [
            SCNShaderModifierEntryPoint.geometry: """
                // テキストの上下左右の位置を中央揃えにします
                // デフォルトでは1文字目の左下付近の位置が中央になっています
                vec2 size = u_boundingBox[1].xy - u_boundingBox[0].xy;
                _geometry.position.xy -= u_boundingBox[0].xy + size / 2.0;
            """,
            SCNShaderModifierEntryPoint.surface: """
                // テキスト部分のサイズを計算します
                vec3 size = u_boundingBox[1] - u_boundingBox[0];
                // 座標が0.0〜1.0になるように正規化します
                vec3 st = (_surface.position - u_boundingBox[0]) / size;
                // 色を用意します
                vec4 blue = vec4(0.09, 0.38, 0.63, 1.0);
                vec4 lightblue = vec4(0.86, 0.93, 0.94, 1.0);
                // 青色と水色の2色を使った横方向のグラデーションで面を塗ります
                _surface.diffuse = mix(blue, lightblue, st.x);
            """
        ]

        // ジオメトリをSKEffectNode上で表示できるようにするために
        // readsFromDepthBufferプロパティの値をfalseにします
        text.firstMaterial?.readsFromDepthBuffer = false // --- (1)

        let geometoryNode = SCNNode(geometry: text)
        self.rootNode.addChildNode(geometoryNode)

        // SCNNodeオブジェクトを回転させます
        geometoryNode.runAction(
            SCNAction.repeatForever(
                SCNAction.rotateBy(x: -1, y: 0, z: 0, duration: 1) // --- (2)
            )
        )

        let omniLightNode = SCNNode()
        omniLightNode.light = SCNLight()
        omniLightNode.light!.type = .omni
        // ライトノードの位置を指定します
        omniLightNode.simdPosition = simd_float3(x: 50, y: -100, z: 150) // --- (3)
        self.rootNode.addChildNode(omniLightNode)

        let ambientLightNode = SCNNode()
        ambientLightNode.light = SCNLight()
        ambientLightNode.light!.type = .ambient
        self.rootNode.addChildNode(ambientLightNode)

        let cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        // 投影法を平行投影にします
        cameraNode.camera!.usesOrthographicProjection = true
        // 平行投影の倍率を指定します
        cameraNode.camera!.orthographicScale = 20
        cameraNode.camera!.zFar = 60
        cameraNode.simdPosition = simd_float3(x: 0, y: 0, z: 30)
        self.rootNode.addChildNode(cameraNode)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
SceneKitとSKShaderを組み合わせたサンプルコード(SKScene)
MySKScene.swift
import SpriteKit

class MySKScene: SKScene {
    override func didMove(to view: SKView) {

        // シーンの背景色を透明にします
        self.backgroundColor = .clear

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

        // SK3DNodeオブジェクトを60x40のサイズで作成します
        let node = SK3DNode(
            viewportSize: CGSize(
                width: 60,
                height: 40
            )
        )

        // SK3DNodeのscnSceneプロパティにSCNSceneオブジェクトを格納します
        node.scnScene = MySCNScene()

        // エフェクトノードを作成します
        let arrowNode: SKEffectNode = {
            let effectNode = SKEffectNode()

            // 3Dノードをエフェクトノードの子ノードに追加します
            effectNode.addChild(node)

            // エフェクトノードに彩度を下げるフィルタを適用します
            effectNode.filter = CIFilter(
                name: "CIColorControls",
                parameters: ["inputSaturation": 0.4]
            )!

            let shader = SKShader(
                source: """
                    void main() {
                        // テクスチャ座標の変形を計算するための変数を用意します
                        vec2 uv = v_tex_coord;
                        // 右方向に伸び縮みするアニメーションが行われるように
                        // テクスチャ座標を変形させます
                        float t = acos(cos(u_time * 3.0)) / 3.1416;
                        float k = abs(1.0 - pow(t, 3.0) * 1.7) * 0.1;
                        uv.x *= 0.97 + k;
                        // 中央部分が縦に伸び縮みするアニメーションが行われるように
                        // テクスチャ座標を変形させます
                        float l = 1.0 - abs(uv.x * 2.0 - 1.0);
                        l = (0.07 - k * 1.2) * pow(l, 3.0);
                        uv.y += l * (uv.y * 2.0 - 1.0);
                        // 変形するテクスチャ座標の値を使って
                        // エフェクトノードの色を取得します
                        gl_FragColor = texture2D(u_texture, uv);
                    }
                """
            )

            effectNode.shader = shader
            return effectNode
        }()

        // ラベルノードを作成します
        let textNode: SKLabelNode = {
            let labelNode = SKLabelNode(text: "ATTENSION!")
            labelNode.fontName = "Futura-Bold"
            labelNode.fontSize = node.frame.height * 0.65
            labelNode.verticalAlignmentMode = .center
            return labelNode
        }()

        // ラベルノードの背景にするシェイプノードを作成します
        let lineNode: SKShapeNode = {
            let width = textNode.frame.width
            let height = textNode.frame.height

            var point = [
                CGPoint(x: width * -0.56, y: height * 0.52),
                CGPoint(x: width * -0.55, y: height * -0.15),
                CGPoint(x: width * -0.51, y: height * -0.77),
                CGPoint(x: width * -0.49, y: height * -0.06),
                CGPoint(x: width * -0.44, y: height * 0.53),
                CGPoint(x: width * -0.38, y: height * -0.47),
                CGPoint(x: width * -0.27, y: height * 0.44),
                CGPoint(x: width * -0.18, y: height * -0.46),
                CGPoint(x: width * -0.07, y: height * 0.49),
                CGPoint(x: width * 0.02, y: height * -0.46),
                CGPoint(x: width * 0.14, y: height * 0.46),
                CGPoint(x: width * 0.23, y: height * -0.48),
                CGPoint(x: width * 0.32, y: height * 0.47),
                CGPoint(x: width * 0.42, y: height * -0.44),
                CGPoint(x: width * 0.47, y: height * 0.49),
                CGPoint(x: width * 0.52, y: height * 0.24),
                CGPoint(x: width * 0.56, y: height * -0.43)
            ]

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

            shapeNode.lineWidth = height * 1.3
            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.y * 2.0 - 1.0
                        );
                        // 始端と終端の線を細くします
                        float start = pow(1.0 - st.x, 3.0);
                        float end = pow(st.x, 8.0);
                        float k = max(start, end);
                        float l = step(k, 1.0 - abs(st.y) * k);
                        // 色を用意します
                        vec4 blue = vec4(0.09, 0.38, 0.63, 1.0);
                        vec4 lightblue = vec4(0.86, 0.93, 0.94, 1.0);
                        // 水色から青色のグラデーションを作ります
                        vec4 color = mix(blue, lightblue, start);
                        // 始端側を水色、終端側を青色で出力します
                        gl_FragColor = mix(vec4(0.0), color, l);
                    }
                """
            )

            shapeNode.strokeShader = shader
            return shapeNode
        }()

        // ラベルノードとその背景のシェイプノードの位置をやや右に移動します
        textNode.position.x += node.frame.width * 0.2
        lineNode.position.x = textNode.position.x

        // エフェクトノードの位置をラベルノードの左に移動します
        arrowNode.position.x = textNode.frame.minX - node.frame.width * 0.6

        self.addChild(lineNode)
        self.addChild(textNode)
        self.addChild(arrowNode)
    }
}

サンプルコードでは、 第6回の記事で紹介した SpriteView の背景透過の手法も使っています。
SceneKitの3DコンテンツをSwiftUIで表示するだけならば、 SpriteView ではなく SceneView を使うという方法もあります。しかし、 SceneView には背景透過のオプションがありません(執筆時点)。背景を透過したいときは UIViewRepresentable で独自のViewを実装するといった手間が必要になります。
SpriteView であれば数行のコードで背景を透過できるので、3DコンテンツをSwiftUIの別のViewと重ねたいときに便利です。

SKShader が活用できる、背景の透過が簡単といった SK3DNode のメリットを挙げましたが、
もちろん、SpriteKitとSceneKitのフレームワークを二段重ねで使うことで負荷が増えてしまうことや、 SK3DNode では対応していないオプションがある(allowsCameraControl 、他)ことなどデメリットもあります。

まとめ

今回はSK3DNodeを使った3Dコンテンツの表示とシェーダーの適用方法をご紹介しました。

冒頭にも書いたとおり、用語の説明などをだいぶ省略してしまったので、3Dコンテンツの制作経験がないかたにはわかりづらいところも多かったと思いますが、ひとまずは『標準フレームワークだけでもこんな表現ができるんだ』ということがお伝えできていたら何よりです。
2D(SpriteKit)と3D(SceneKit)のミックスに関しては、また別の機会にじっくり書きたいなあと思案しております。

次回は、SKShader落穂拾いとして、ここまでの記事で書ききれなかったこまごまとした OpenGL ES 2.0とSKShaderのGLSLの差分 などをまとめる予定です。 記事のUPは少し先になる見込みですが第9回の記事 をUPしました)、よろしければ最後までおつきあいください。


次記事 → GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (9) 〜落穂拾い〜

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

参考リンク

脚注
  1. shaderModifiers の他にも SCNProgramSCNTechnique などでシェーダーを書くことができます。 ↩︎

  2. 法線マップとは表面に凹凸感を与えられる特殊なテクスチャのことです。 ↩︎

  3. 法線の向きとは頂点や面の表裏の向きのことです。 ↩︎

  4. geometryシェーダーの公式ドキュメントでは _geometry.position の型は vec3 と記載されていますが、筆者の環境では vec4 になっていました。そのため公式ドキュメント内のサンプルコードはそのままでは動きません。また、 SCNShadable のheaderファイルの記載では _geometry 構造体に float4 color も含まれており、このプロパティを使って頂点の色を変更することができます。 ↩︎

  5. surfaceシェーダーの公式ドキュメントは内容が古いようで、 SCNShadable がリリースされたiOS 8よりもあとに追加された metalnessroughnessselfIlluminationambientOcclusion 、iOS 13で追加された clearCoat 関係のプロパティなどが _surface 構造体に含まれていません。 SCNShadable のheaderファイルにはこれらの記載もありますので、headerファイルをあわせて確認するとよいでしょう。 ↩︎

  6. 公式ドキュメントに載っているサンプルコードの言語がObjective-Cになっており、説明文にもObjective-Cオブジェクトを渡すような記述がありますが、本記事のサンプルコードのとおりSwiftの数値型のデータを渡せます。 ↩︎

  7. こまかい話になってしまいますが、テクスチャに関してはハマりポイントがいろいろ出てきます。元画像が同じファイルでもUIImageで貼るかSKTextureで貼るかによって色味が変わる、形状と面によってはテクスチャが裏返っている、SKTextureを貼るとテクスチャ座標の原点が左下に変わる、SKTextureを貼ってcontentsTransformプロパティで変形するとテクスチャの上下がひっくり返る、等々。慣れるまでは試行錯誤を要求されがちです。 ↩︎

  8. SCNShadable の公式ドキュメントでは u_boundingBox の型は mat32 と記載されていますが、実際は mat2x3 になっています。 SCNShadable のGLSLでは mat2x3 の型をサポートしておらず、代わりにMSLの float2x3 の型が使えます。 ↩︎

  9. 公式ドキュメントに該当する記述が見つけられなかったので、仕様動作なのかは不明です。今後SpriteKitやSceneKitに大きなアップデートが入る可能性は低いと思いますが、もしかすると将来のバージョンでは挙動が変わっているかもしれません。 ↩︎

Discussion