🌎

SwiftUI SceneViewによる3D表現

2022/12/11に公開

こんにちは。株式会社ZOZO 計測プラットフォーム開発本部 計測アプリ部 iOSブロックの@na9ainです。

みなさんは、SwiftUIで3Dモデルを使った表現をしたいと思ったことはありませんか?
iOS 14.0からSwiftUIではSceneViewというものが使えるようになり、手軽にSceneKitによる3D表現ができるようになりました。
この記事では、使用例やコードを交えながら、SceneViewの使い方を説明していきます。

SceneViewの使用例

早速、SceneViewを実際に使ってみた例から紹介します。

demo_01.gif

惑星の3Dモデルを、スワイプしたり、ピンチしたりすることで回転・拡大・移動させるビューを作ってみました。

SceneViewの使い方

複雑でとっつきづらい印象があるSceneKitですが、SceneViewの使い方は意外と簡単なんです。
例えば、このような数行のコードから

ContentView.swift
import SwiftUI
import SceneKit

struct ContentView: View {

    var body: some View {
        SceneView(
            scene: SCNScene(named: "Earth.usdz"),
            options: [.autoenablesDefaultLighting, .allowsCameraControl]
        )
    }
}

simpleview.gif

このようなビューができあがります。
上記のコードでは、scene: SCNScene(named: "Earth.usdz")の部分でEarth.usdzという3Dモデルのファイルをロードしたシーンを生成し、options: [.autoenablesDefaultLighting, .allowsCameraControl]で3Dモデルの照明に関する設定と、ジェスチャによる視点の回転・拡大・移動を有効にしています。

SceneViewで実現できること

さて、数行のコードで3Dモデルをいじることができるビューができあがりましたが、SceneViewでできることをさらに掘り下げてみましょう。

SceneViewのイニシャライザがこちらになります。

init(
    scene: SCNScene? = nil,
    pointOfView: SCNNode? = nil,
    options: SceneView.Options = [],
    preferredFramesPerSecond: Int = 60,
    antialiasingMode: SCNAntialiasingMode = .multisampling4X,
    delegate: SCNSceneRendererDelegate? = nil,
    technique: SCNTechnique? = nil
)

基本的には、これらの引数によって設定できる範囲が、 SceneViewでできる表現の範囲になります。
それぞれの引数について、簡単に説明していきます。

  • scene: SCNScene? シーンを設定
  • pointOfView: SCNNode? 初期視点を設定
  • options: SceneView.Options
    • allowsCameraControl ジェスチャによるシーン内のカメラ操作を有効にする設定
    • autoenablesDefaultLighting デフォルト照明を有効にする設定(照明がないシーンではモデルが真っ暗なので、これを使うと便利)
    • jitteringEnabled 視点をわずかにジッタリングすることでシーン内の3Dモデルのエッジを滑らかにする設定
    • rendersContinuously シーンに変化があったときのみレンダリングする設定(バッテリー消費を抑えられる)
    • temporalAntialiasingEnabled 一時的なアンチアイリアスを有効にする設定
  • preferredFramesPerSecond: Int 1秒あたりにレンダリングするフレーム数を設定
  • antialiasingMode: SCNAntialiasingMode アンチエイリアスのモードを設定
  • delegate: SCNSceneRendererDelegate? 以下で説明
  • technique: SCNTechnique? 以下で説明

SCNSceneRendererDelegateについて

SCNSceneRendererDelegateに準拠したクラスを用意し、デリゲートメソッドにお好みの実装することで表現の幅を広げられます。
例えば、簡単な例ですが、以下のようにデリゲートメソッドの中でレンダラーにSCNDebugOptionsを設定することで

SceneCoordinator.swift
import SceneKit

class SceneCoordinator: NSObject, SCNSceneRendererDelegate {
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        renderer.debugOptions = [.renderAsWireframe]
    }
}
ContentView.swift
import SwiftUI
import SceneKit

struct ContentView: View {

	var sceneCoordinator = SceneCoordinator()

    var body: some View {
        SceneView(
            scene: SCNScene(named: "Earth.usdz"),
            options: [.autoenablesDefaultLighting, .allowsCameraControl],
            delegate: sceneCoordinator
        )
    }
}

wireframe.png

3Dモデルのワイヤーフレームを表示させることができます。

SCNTechniqueについて

SCNTechniqueでは、レンタリングをカスタマイズすることができます。
具体的には、MetalかOpenGLのシェーダーとDeveloper Documentationで確認できる定義済みのKeyとValueを持つ辞書型のSCNTechniqueを用意し、引数として与えることでカスタマイズができます。
こちらの記事を参考に、MetalのシェーダーとSCNTechniqueを用意したところ、ファミコンを彷彿とさせるレトロな感じのレンダリングにカスタマイズすることができました。

scntechnique.gif

SCNTechnique 実装例
  1. 以下の2ファイルを作成
sample.metal
#include <metal_stdlib>
using namespace metal;
#include <SceneKit/scn_metal>

struct custom_vertex_t
{
    float4 position [[attribute(SCNVertexSemanticPosition)]];
};

struct out_vertex_t
{
    float4 position [[position]];
    float2 uv;
};

constexpr sampler s = sampler(coord::normalized,
                              address::repeat,
                              filter::nearest);

vertex out_vertex_t pixelate_pass_through_vertex(custom_vertex_t in [[stage_in]],
                                                 constant SCNSceneBuffer& scn_frame [[buffer(0)]])
{
    out_vertex_t out;
    out.position = in.position;
    out.uv = float2((in.position.x + 1.0) * 0.5 , (in.position.y + 1.0) * -0.5);
    return out;
};

fragment half4 pixelate_pass_through_fragment(out_vertex_t vert [[stage_in]],
                                              texture2d<float, access::sample> colorSampler [[texture(0)]])
{
    float4 fragment_color = colorSampler.sample( s, vert.uv);
    return half4(fragment_color);
};
sample.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>passes</key>
    <dict>
        <key>pixelate_scene</key>
        <dict>
            <key>draw</key>
            <string>DRAW_SCENE</string>
            <key>inputs</key>
            <dict/>
            <key>outputs</key>
            <dict>
                <key>color</key>
                <string>color_scene</string>
            </dict>
            <key>colorStates</key>
            <dict>
                <key>clear</key>
                <true/>
                <key>clearColor</key>
                <string>sceneBackground</string>
            </dict>
        </dict>
        <key>resample_pixelation</key>
        <dict>
            <key>draw</key>
            <string>DRAW_QUAD</string>
            <key>program</key>
            <string>doesntexist</string>
            <key>metalVertexShader</key>
            <string>pixelate_pass_through_vertex</string>
            <key>metalFragmentShader</key>
            <string>pixelate_pass_through_fragment</string>
            <key>inputs</key>
            <dict>
                <key>colorSampler</key>
                <string>color_scene</string>
            </dict>
            <key>outputs</key>
            <dict>
                <key>color</key>
                <string>COLOR</string>
            </dict>
        </dict>
    </dict>
    <key>sequence</key>
    <array>
        <string>pixelate_scene</string>
        <string>resample_pixelation</string>
    </array>
    <key>targets</key>
    <dict>
        <key>color_scene</key>
        <dict>
            <key>type</key>
            <string>color</string>
            <key>size</key>
            <string>64x114</string>
        </dict>
    </dict>
    <key>symbols</key>
    <dict/>
</dict>
</plist>
  1. sample.plistからロードした[String: Any]型の辞書をもとにイニシャライズした'SCNTechnique'をSceneViewの引数に渡す
ContentView
import SwiftUI
import SceneKit

struct ContentView: View {

    var body: some View {
        SceneView(
            scene: SCNScene(named: "Earth.usdz"),
            options: [.autoenablesDefaultLighting, .allowsCameraControl],
	    technique: SCNTechnique(dictionary: techniqueDict!)
        )
    }
}

extension ContentView {
    var techniqueDict: [String: Any]? {
        if let path = Bundle.main.path(forResource: "sample", ofType: "plist"),
           let dict = NSDictionary(contentsOfFile: path) as? [String : Any] {
            return dict
        }
        return nil
    }
}

SceneViewで実現できないこと

シンプルな実装ができる一方で、SCNViewにできても、SceneViewにはできないことがあります。
発見した中でも、使用するにあたって特に考慮すべきだと感じたものはこちらです。

  • 背景色の変更
    • SCNViewのように.backgroundColorから背景を透過させる、みたいなことができない
  • カメラ操作設定の変更
    • SCNViewのように.cameraControlConfigurationからカメラの操作感度などをカスタマイズすることができない

まとめ

とっつきづらい印象があるSceneKitですが、SceneViewを使えば簡単に3D表現を行うことができます。また、SCNSceneRendererDelegateのデリゲートメソッドやSCNTechniqueから凝った表現をすることもできます。
みなさんもぜひ、SceneViewを使ってみてください。

Discussion