GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (8) 〜3D〜
はしがき
SwiftUIでSpriteKitのSKShaderを使って遊んでみようというテーマの記事の8回目です。
前回の記事 ではSKShaderが使える5種類のノードオブジェクトについて解説しました。
今回は、SpriteKitで 3Dコンテンツを表示する方法 を紹介します。
仕組みとしては SK3DNode を介して3Dコンテンツ用のフレームワークである SceneKit を扱う方法になります。
SceneKitについてしっかり書くと数記事かけてもまとめきれなさそうなので、ごく基本的な3Dオブジェクトの表示方法と、3DオブジェクトにGLSLシェーダーを適用する方法に絞って説明したいと思います。
GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit シリーズの記事一覧
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (1)
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (2) 〜座標〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (3) 〜変数〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (4) 〜経過時間とマウス座標〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (5) 〜テクスチャと色 前編〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (6) 〜テクスチャと色 後編〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (7) 〜ノード〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (8) 〜3D〜(本記事)
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (9) 〜落穂拾い〜
必要な前提知識
この記事では、以下に該当するようなプログラマーを想定読者としています。
- 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ステップになります。
-
SceneKit を
import
する - SCNScene オブジェクトを作成する
- SCNGeometry オブジェクト(3Dの形状データ)を作成する
- 3で作成した
SCNGeometry
オブジェクトを geometry引数 で渡して SCNNode オブジェクトを作成する - 4で作成した
SCNNode
オブジェクトを、2で作成したSCNScene
オブジェクトの rootNode プロパティに、 addChildNode メソッドを使って追加する - SK3DNode オブジェクトを作成する
- 6で作成した
SK3DNode
オブジェクトの scnScene プロパティに、2で作成したSCNScene
オブジェクトを格納する - 6で作成した
SK3DNode
オブジェクトを、SKScene
オブジェクトにaddChild
メソッドを使って追加する
SCNScene
と SKScene
、 SCNNode
と SKNode
のようにまぎらわしい名称が並びますが、 SCN
と付いているのが SC e N eKit の 3Dコンテンツ用のオブジェクトで、 SK
と付いているのが S prite K it の2Dコンテンツ用のオブジェクトです。
では、実際のコードを見てみましょう。
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)
}
}
SpriteKitの SKScene
上に、直方体を配置したSceneKitの SCNScene
が表示されました。
直方体以外にも、平面や球体やテキストなどの基本的な形状が 組み込みのGeometryオブジェクト として用意されています。 頂点データを用意して独自のGeometryを作成する 、 他の3D制作ツールで作成したシーンコンテンツを読みこませて表示する 、といったこともできます。
SCNScene にライトとカメラを追加する
WebGLなどで3Dコンテンツを作成した経験があるかたは、さきほどのサンプルコードにライトやカメラの設定がなかったことに気づかれたかもしれません。
SK3DNode
では デフォルトのライトとカメラ が有効になっているので、それらの記述がなくても3Dオブジェクトが画面上に表示できます。
もちろん自分でライトやカメラの設定を書くこともできます。
SCNScene
に点光源と環境光のライトを用意し、直方体を少し見おろすような視点になるようにカメラも追加してみましょう。
ライトは SCNLight 、カメラは SCNCamera を使って設定します。
// 〜略〜
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)
}
}
上方手前側にある点光源からの光と、オレンジ色の環境光が直方体に当たり、それを少し上の位置にあるカメラから撮影したような表示になりました。
Shader Modifiers in 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シェーダー で球体を変形させるシェーダーを作成して、 SCNGeometry
オブジェクトに適用する例です。
※長くなるのでサンプルコードを折りたたみます。
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;
// 移動させた頂点の接線と法線を調整します
_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
}()
球体の上半分の頂点が Y = 0.0 の位置(ジオメトリのローカル座標の原点である中央の高さ)に移動して、半球体ができました。
geometryシェーダーの input / output の構造体については こちらの公式ドキュメント に記載があります。 [4]
geometryシェーダーのサンプルコード全体
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シェーダー で、ジオメトリの面を座標によって違う色で塗るシェーダーを作成して適用する例です。
こちらは 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
}()
四角錐が、テクスチャ座標(左上が原点)の Y = 0.9 の位置を境に赤色と緑色のツートンカラーで塗られました。surfaceシェーダーのあとでライティングの処理が行われるので、光の当たる量が少ない右側面は色がやや暗くなっています。
surfaceシェーダーの input / output の構造体については こちらの公式ドキュメント に記載があります。 [5]
今回の例では diffuse (拡散反射光)の色のみカスタマイズしていますが、他にもspecularやreflectiveなどの各パラメータも扱えます。それぞれのパラメータについては 公式ドキュメントの SCNMaterial 配下の各ページ に説明があり、図が添えられている項目も多いので参考になると思います。
surfaceシェーダーのサンプルコード全体
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変数で渡してみます。
※長くなるのでサンプルコードを折りたたみます。
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
// SCNMaterialのshaderModifiersプロパティに、
// シェーダーの種類とソースコードを辞書データで格納します
material.shaderModifiers = [
// シェーダーの種類としてlightingModelを指定します
SCNShaderModifierEntryPoint.lightingModel: """
// 受け取るuniform変数を宣言します
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;
"""
]
// 塗りわける色数を指定するint値を
// uniform変数としてSCNMaterialのシェーダーに渡します
material.setValue(
Int(3),
forKey: "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シェーダーの input / output の構造体については こちらの公式ドキュメント に記載があります。
uniform変数の渡しかたについては こちらの公式ドキュメント に記載があります。 [6]
ジオメトリにマテリアルを適用する箇所で、ひとつ前のsurfaceシェーダーの例では geometry.firstMaterial = material
と書き、このlightingModelシェーダーの例では geometry.materials = [material]
という書きかたをしました。この2つは同じ意味になります。どちらで書いても、マテリアルがジオメトリのすべての面に適用されます。
面によって違うマテリアルを適用させることもできます。次のfragmentシェーダーではその例をお見せします。
lightingModelシェーダーのサンプルコード全体
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.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;
"""
]
material.setValue(
Int(3),
forKey: "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 move = SCNMatrix4MakeTranslation(0, -1, 0)
let rotate = SCNMatrix4MakeRotation(0.5 * Float.pi, 0, 0, 1)
materialTop.diffuse.contentsTransform = SCNMatrix4Mult(move, rotate)
// 共通で使う色を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シェーダーの input / output の構造体については こちらの公式ドキュメント に記載があります。
gl_FragCoord
と gl_FragColor
も使用可能で、 _output.color
ではなく gl_FragColor
で最終的な色を出力することもできます。
テクスチャのサンプラーの変数である u_diffuseTexture
など、SceneKitがあらかじめ用意している変数については こちらの公式ドキュメント に記載があります。 [8]
fragmentシェーダーのサンプルコード全体
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 move = SCNMatrix4MakeTranslation(0, -1, 0)
let rotate = SCNMatrix4MakeRotation(0.5 * Float.pi, 0, 0, 1)
materialTop.diffuse.contentsTransform = SCNMatrix4Mult(move, rotate)
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 がありますが、情報が少なく仕組みも複雑なので入門するにはややハードル高めです。
一方 SKEffectNode
の SKShader
であれば、3Dオブジェクトに対しての直接的な操作はできないものの、二次元平面上での変形や色のカスタマイズは前回までの記事の知識で可能なので、『もうひと工夫、ちょっと手を加えたい』というようなときの用途としてもってこいです。
SK3DNode
と SKShader
を組み合わせたサンプルコードを掲載します。
下のスクリーンショットの動く矢印部分が SK3DNode
を子ノードに追加した SKEffectNode
、白抜き文字とその背後の青色の部分が SKLabelNode
と SKShapeNode
、それ以外の部分はSwiftUIのViewです。
矢印の3Dオブジェクトが右側にあるタイトル文字を突っつくような動きを、 SKEffectNode
に適用した SKShader
で加えています。
※コードがだいぶ長くなってしまったので、ContentView、SCNScene、SKSceneのファイルを分けて、それぞれのコードを折りたたみます。
SceneKitとSKShaderを組み合わせたサンプルコード(ContentView)
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点あります。
-
SKEffectNode
の子ノードでSceneKitのジオメトリを表示させるには、 SCNMaterial.readsFromDepthBuffer プロパティの値をfalse
にする必要があります。
※サンプルコード中の (1) の箇所。 -
SKEffectNode
の子ノードでSceneKitのコンテンツをレンダリングすると、 SCNSceneのY軸の正負が反転する ようなので、関連箇所の修正が必要です。 [9]
※サンプルコード中の (2) のノードの回転方向や、 (3) の点光源のY軸の向きなど。
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)
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で表示するだけならば 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は少し先になる見込みですが、よろしければ最後までおつきあいください。
次記事 → GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (9) 〜落穂拾い〜
GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit シリーズの記事一覧
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (1)
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (2) 〜座標〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (3) 〜変数〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (4) 〜経過時間とマウス座標〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (5) 〜テクスチャと色 前編〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (6) 〜テクスチャと色 後編〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (7) 〜ノード〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (8) 〜3D〜(本記事)
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (9) 〜落穂拾い〜
参考リンク
- Drawing SpriteKit Content in a View | Apple Developer Documentation
- Applying Shaders to a Sprite | Apple Developer Documentation
- Creating a Custom Fragment Shader | Apple Developer Documentation
- Displaying 3D Content in a SpriteKit Scene | Apple Developer Documentation
- Organizing a Scene with Nodes | Apple Developer Documentation
- The Book of Shaders
- OpenGL ES 2.0 API Quick Reference Card (PDF)
-
shaderModifiers
の他にも SCNProgram や SCNTechnique などでシェーダーを書くことができます。 ↩︎ -
法線マップとは表面に凹凸感を与えられる特殊なテクスチャのことです。 ↩︎
-
法線の向きとは頂点や面の表裏の向きのことです。 ↩︎
-
geometryシェーダーの公式ドキュメントでは
_geometry.position
の型はvec3
と記載されていますが、筆者の環境ではvec4
になっていました。そのためドキュメント内のサンプルコードはそのままでは動きません。また、SCNShadable
のheaderファイルの記載では_geometry
構造体にfloat4 color
も含まれており、このプロパティを使って頂点の色を変更することができます。 ↩︎ -
surfaceシェーダーの公式ドキュメントは内容が古いようで、
SCNShadable
がリリースされたiOS 8よりもあとに追加されたmetalness
、roughness
、selfIllumination
やambientOcclusion
、iOS 13で追加されたclearCoat
関係のプロパティなどが_surface
構造体に含まれていません。SCNShadable
のheaderファイルにはこれらの記載もありますので、headerファイルをあわせて確認するとよいでしょう。 ↩︎ -
公式ドキュメントに載っているサンプルコードの言語がObjective-Cになっており、説明文にもObjective-Cオブジェクトを渡すような記述がありますが、本記事のサンプルコードのとおりSwiftの数値型のデータを渡せます。 ↩︎
-
こまかい話になってしまいますが、テクスチャに関してはハマりポイントがいろいろ出てきます。形状と面によってはテクスチャが裏返っている、元画像が同じファイルでもSKTextureで貼るかUIImageで貼るかによって色味が変わる、SKTextureを貼るとテクスチャ座標の原点が左下に変わる、SKTextureを貼ってcontentsTransformプロパティで変形すると裏返っていたテクスチャが表に戻る、等々。慣れるまでは試行錯誤を要求されがちです。 ↩︎
-
SCNShadable
の公式ドキュメントではu_boundingBox
の型はmat32
と記載されていますが、実際はmat2x3
になっています。SCNShadable
のGLSLではmat2x3
の型をサポートしておらず、代わりにMSLのfloat2x3
の型が使えます。 ↩︎ -
公式ドキュメントに該当する記述が見つけられなかったので、仕様動作なのかは不明です。今後SpriteKitやSceneKitに大きなアップデートが入る可能性は低いと思いますが、もしかすると将来のバージョンでは挙動が変わっているかもしれません。 ↩︎
Discussion