4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ARKit+SceneKit+Metal で 『君はラピュタ王の前にいるのだ』の再現

Last updated at Posted at 2020-11-08

ARで壁や床から3Dモデルが現れる演出をよく見かけるので挑戦。
題材にしたのは『天空の城ラピュタ』。ラピュタの展望室でモウロ将軍の前にムスカとシータが天井から現れるシーン。(床に穴が空く仕掛けがある部屋を「展望室」?とは思うがwikiの表現に倣う)

(モデルがレッサーパンダなのはおいて、、)完成イメージ
demo.png demo.gif
再現にあたり困ったのがキャラクターと天井の境界面の薄黄色の部分(ラピュタの技術では境界面は薄黄色になったりならなかったりしているが、ここでは薄黄色に統一)。以下、再現方法を解説します。

再現方法

①ムスカとシータのノードを上下にアニメーションさせる
②天井境界の薄黄色面を作るための奥行き情報(以下、デプス)を作成する
 次の3つを作る。
 ・天井の境界平面のデプス
 ・キャラクターを cullMode = back で描画したときのデプス
 ・キャラクターの cullMode = front で描画したときのデプス
③②の情報から境界面とキャラクターの断面部分を判定し、①の画像に薄黄色を加える。

レンダリングパス(Xcodeの Capture GPU Frame)
renderpass.png
以下、個別にみていきましょう。

①ムスカとシータのノードを上下にアニメーションさせる

これは Xcode の Scene Editor で設定。
・キャラクターを並べる(キャラクターモデルはWWDC2017 SceneKit Demoから借用)。キャラクターは取りまとめノードchar_parentの下にぶら下げる。
・境界面ノードslice_planechar_parentと同列に配置。この境界面ノードはアニメーションしない。

 →色はほぼ透明にする。
slice_plane_1.png
 →Rendering Order の値を小さくして、キャラクターよりも先に描画することで、背景にキャラクターが描画されないようにする。
slice_plane_2.png
 ここでCategory bit mask を設定している。後でデプスを生成する際に、境界面とキャラクターを区別するのに使う。境界面は4、キャラクターは2を設定する。

 →アニメーションの設定をする
scene_animation.png

②天井境界の薄黄色面を作るための奥行き情報を作成する

キャラクターの正面側(手前側)のデプス情報と、キャラクターの背面側(見えない側)のデプス情報を取得し、その差分でキャラクターの実体を得る。

  1. キャラクターの背面部分のデプスを取得
     →背面側のみ描画は cullMode=front という指定で行う(後述)
      キャラクターの断面は、ここで得られたデプスよりも大きい値となる。
  2. キャラクターの正面部分のデプスを取得
     →正面側のみ描画は cullMode=back という指定で行う(後述)
      キャラクターの断面は、ここで得られたデプスよりも小さい値となる。
  3. 境界面(天井平面)のデプスを取得
     上記1)2)のデプスでキャラクターの断面となるのは、この境界面のデプスの範囲とする。

上記3つ各々のデプス情報を SCNTechnique によるマルチパスレンダリングで生成。マルチパスレンダリングの定義は次の通り。

tequnique.json
{
    "targets" : {
        "color_scene"     : { "type" : "color" },
        "depth_slice"     : { "type" : "depth" },
        "depth_cullback"  : { "type" : "depth" },
        "depth_cullfront" : { "type" : "depth" }
    },
    "passes" : {
        "pass_scene" : {
            "draw"    : "DRAW_SCENE",
            "outputs" : {
                "color" : "color_scene"
            }
        },
        "pass_slice" : {
            "draw"                : "DRAW_NODE",
            "includeCategoryMask" : 4,
            "outputs" : {
                "depth" : "depth_slice"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },
        "pass_cullback" : {
            "draw"                : "DRAW_NODE",
            "includeCategoryMask" : 2,
            "cullMode"            : "back",
            "outputs" : {
                "depth" : "depth_cullback"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },
        "pass_cullfront" : {
            "draw"                : "DRAW_NODE",
            "includeCategoryMask" : 2,
            "cullMode"            : "front",
            "outputs" : {
                "depth" : "depth_cullfront"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },
        "pass_mix" : {
            "draw"   : "DRAW_QUAD",
            "inputs" : {
                "colorScene"     : "color_scene",
                "depthSlice"     : "depth_slice",
                "depthCullBack"  : "depth_cullback",
                "depthCullFront" : "depth_cullfront"
            },
            "metalVertexShader"   : "mix_vertex",
            "metalFragmentShader" : "mix_fragment",
            "outputs" : {
                "color" : "COLOR"
            },
            "colorStates" : {
                "clear"      : "true",
                "clearColor" : "0.0 0.0 0.0 0.0"
            }
        }
    },
    "sequence" : [
        "pass_scene",
        "pass_slice",
        "pass_cullback",
        "pass_cullfront",
        "pass_mix"
    ]
}

少しずつみていきましょう。

        "pass_scene" : {
            "draw"    : "DRAW_SCENE",
            "outputs" : {
                "color" : "color_scene"
            }
        },

これは、シーン全体の描画の定義。drawDRAW_SCENE を指定することでカメラキャプチャ画像+キャラクターの描画を行なっている。描画結果は色情報のみで、color_scene という名前のバッファに格納。

        "pass_slice" : {
            "draw"                : "DRAW_NODE",
            "includeCategoryMask" : 4,
            "outputs" : {
                "depth" : "depth_slice"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },

これは天井境界面の描画。includeCategoryMask4を指定しており、境界平面のみ描画する設定としている。この描画では色情報は不要でデプスのみ depth_slice という名前のバッファに格納。

        "pass_cullback" : {
            "draw"                : "DRAW_NODE",
            "includeCategoryMask" : 2,
            "cullMode"            : "back",
            "outputs" : {
                "depth" : "depth_cullback"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },

これは、正面からみたときのキャラクターのデプス情報を取得するための定義。includeCategoryMask2を指定しており、キャラクターのみ描画する設定としている。cullMode には back を指定しており、見えている部分を描画し、見えていない(背面)は描画しないようにしている(デフォルトはback)。この描画では色情報は不要でデプスのみ depth_cullback という名前のバッファに格納。

        "pass_cullfront" : {
            "draw"                : "DRAW_NODE",
            "includeCategoryMask" : 2,
            "cullMode"            : "front",
            "outputs" : {
                "depth" : "depth_cullfront"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },

これは、キャラクターの背面側のデプス情報を取得するための定義。"pass_cullback"と同様だが、cullMode には front を指定している。

        "pass_mix" : {
            "draw"   : "DRAW_QUAD",
            "inputs" : {
                "colorScene"     : "color_scene",
                "depthSlice"     : "depth_slice",
                "depthCullBack"  : "depth_cullback",
                "depthCullFront" : "depth_cullfront"
            },
            "metalVertexShader"   : "mix_vertex",
            "metalFragmentShader" : "mix_fragment",
            "outputs" : {
                "color" : "COLOR"
            },
            "colorStates" : {
                "clear"      : "true",
                "clearColor" : "0.0 0.0 0.0 0.0"
            }
        }

これは、最終的にカメラキャプチャ+キャラクター+キャラクター断面を表示させる定義。
inputs に指定された各描画パスの出力結果(色情報、デプス)をmetalFragmentShader で指定されたmix_fragment フラグメントシェーダー(後述)で合成して、最終画像としている。outputscolorCOLOR と指定することで画面に描画される。

③②の情報から境界面とキャラクターの断面部分を判定し、①の画像に薄黄色を加える

これは前述のmix_fragmentシェーダーで行う。
処理内容はソース内のコメントにある通りで、デプス情報で薄い黄色を表示するかどうかを判定し、シーン全体の色に加算している。

fragment half4 mix_fragment(MixColorInOut vert [[stage_in]],
                            constant SCNSceneBuffer& scn_frame [[buffer(0)]],  // 描画フレームの情報
                            texture2d<float, access::sample> colorScene [[texture(0)]],
                            depth2d<float,   access::sample> depthSlice [[texture(1)]],
                            depth2d<float,   access::sample> depthCullBack [[texture(2)]],
                            depth2d<float,   access::sample> depthCullFront [[texture(3)]])
{
    // 天井境界面のデプス
    float ds = depthSlice.sample(s, vert.uv);
    // 視点から見て手前を向いているポリゴンのデプス
    float db = depthCullBack.sample(s, vert.uv);
    // 視点から見て反対を向いたポリゴンのデプス
    float df = depthCullFront.sample(s, vert.uv);
    
    float4 sliceColor = float4(0.0, 0.0, 0.0, 0.0);
    if (df < ds) {
        // キャラクターの背面側より境界面が手前
        if (ds < db) {
            // さらに、キャラクターの前面側より境界面が後ろ
            sliceColor = float4(0.5, 0.5, 0.0, 0.0);    // 薄い黄色
        }
    }
    // カメラキャプチャ画像を含むシーン全体の画像に境界面の色を足す
    float4 fragment_color = colorScene.sample(s, fract(vert.uv));
    fragment_color += sliceColor;   // ざつな処理だと思うが、色の取り扱いに詳しくないので機会があれば見直す。
    
    return half4(fragment_color);
}

ここまでで説明は終わり。

ジオメトリとジオメトリの接触断面に色をつける方法はググっても見つからなかった。
今回、試行錯誤してなんとなく見れるようになったと思うが、この方法は境界面のデプスの他に、キャラクターの前面・背面の2つのデプス情報しか作っていないので、キャラクターの後ろに別のキャラクターがいる場合、後ろにいるキャラクターのデプスは手前のキャラクターのデプスで上書きされてしまい、境界面が描画されないという問題がある。
他に良い方法があると思うので、知っている方教えて欲しいです。
以下、試行錯誤過程で調べたり、試した内容です。

再現にあたり試した方法

  1. 大量のヒットテストをして境界面でのキャラクターの形を探る
      境界面で SCNNodehitTestWithSegment(from:to:options:) を横に100並べて、それをキャラクターの前面→背面、背面→前面でヒットテストすることで、キャラクターの表面位置を求めて、断面図を作ってみた。
      →hitTestWithSegment の精度は期待するレベルではなくジオメトリの形とすこしずれた結果となり使えなかった。特に耳や足のように細かな箇所はヒット結果の位置が見た目と大きくずれた。本来の目的と全然異なる使い方なのでしょうがないとは思う。

  2. 境界面のジオメトリをリアルタイムに作成
     ・キャラクターのジオメトリを境界面でまっ平にしてその部分を薄黄色にする。
      →例えば、足のようにジオメトリと境界面が複数箇所接しているときに、右足・左足のそれぞれでジオメトリを平にするのが相当面倒そう。試してはいない。
       また、ジオメトリがローポリの場合、ガタガタすると思われるので、テッセレーション(?)でジオメトリを分割しておくとか必要になると思われる。
     ・キャラクターのジオメトリと境界面に接する部分で新たに平面ジオメトリ を作って境界面に配置する。
      →これも、境界面の近くのジオメトリの頂点は取得できても、それから閉じた平面ジオメトリ を作るのが面倒と思われる(法線情報で頑張ればできるか??)。
      →「mesh slicing」でぐぐるといくつか方法が見つかるが難しそうで手が止まった。
       ・Algorithm or software for slicing a mesh
      →UE4だとリアルタイムでMesh Sliceができるっぽい。SceneKitにはなさそう。  
       ・https://unrealengine.hatenablog.com/entry/2016/09/12/002115

全体ソースコード

・swift

ViewController.swift
class ViewController: UIViewController, ARSCNViewDelegate {

    @IBOutlet weak var scnView: ARSCNView!
    
    private let device = MTLCreateSystemDefaultDevice()!
    private var charNode: SCNNode!
    private var isTouching = false      // タッチ検知中
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // キャラクター読み込み。WWDC2017 SceneKit Demoを借用 https://developer.apple.com/videos/play/wwdc2017/604/
        guard let scene = SCNScene(named: "art.scnassets/scene.scn"),
              let charNode = scene.rootNode.childNode(withName: "char_node", recursively: true) else { return }
        self.charNode = charNode
        self.charNode.isHidden = true
        // Scene Technique セットアップ
        self.setupSCNTechnique()
        // AR Session 開始
        self.scnView.delegate = self
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        self.scnView.session.run(configuration, options: [.removeExistingAnchors, .resetTracking])
    }
    //
    // フレームごとに呼び出される
    //
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime _: TimeInterval) {
        
        if isTouching {
            // 画面がタッチされた
            isTouching = false
            DispatchQueue.main.async {
                // 表示済みならスキップ
                guard self.charNode.isHidden else { return }
                
                let bounds = self.scnView.bounds
                let screenCenter = CGPoint(x: bounds.midX, y: bounds.midY)
                let results = self.scnView.hitTest(screenCenter, types: [.existingPlaneUsingGeometry])
                guard let existingPlaneUsingGeometryResult = results.first(where: { $0.type == .existingPlaneUsingGeometry }),
                      let _ = existingPlaneUsingGeometryResult.anchor as? ARPlaneAnchor else {
                    // 画面中央に平面がないので何もしない
                    return
                }
                // 画面中央にムスカとシータのノードを配置
                let position = existingPlaneUsingGeometryResult.worldTransform.columns.3
                self.scnView.scene.rootNode.addChildNode(self.charNode)
                self.charNode.simdPosition = SIMD3<Float>(position.x, position.y, position.z)
                self.charNode.isHidden = false
            }
        }
    }
    
    private func setupSCNTechnique() {
        guard let path = Bundle.main.path(forResource: "technique", ofType: "json") else { return }
        let url = URL(fileURLWithPath: path)
        guard let techniqueData = try? Data(contentsOf: url),
              let dict = try? JSONSerialization.jsonObject(with: techniqueData) as? [String: AnyObject] else { return }
        // マルチパスレンダリングを有効にする
        let technique = SCNTechnique(dictionary: dict)
        scnView.technique = technique
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let _ = touches.first else { return }
        isTouching = true
    }
}

・Metal

#include <metal_stdlib>
using namespace metal;
#include <SceneKit/scn_metal>

// SceneKit -> Shader の受け渡し型
// 定義は https://developer.apple.com/documentation/scenekit/scnprogram 参照
struct VertexInput {
    float4 position [[attribute(SCNVertexSemanticPosition)]];   // 頂点座標
};

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

vertex MixColorInOut mix_vertex(VertexInput in [[stage_in]],
                                        constant SCNSceneBuffer& scn_frame [[buffer(0)]])
{
    MixColorInOut out;
    out.position = in.position;
    // 座標系を -1.0 ~ 1.0 -> 0.0 ~ 1.0 に変換。y軸は反転。
    out.uv = float2((in.position.x + 1.0) * 0.5 , (in.position.y + 1.0) * -0.5);
    return out;
}

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

fragment half4 mix_fragment(MixColorInOut vert [[stage_in]],
                            constant SCNSceneBuffer& scn_frame [[buffer(0)]],  // 描画フレームの情報
                            texture2d<float, access::sample> colorScene [[texture(0)]],
                            depth2d<float,   access::sample> depthSlice [[texture(1)]],
                            depth2d<float,   access::sample> depthCullBack [[texture(2)]],
                            depth2d<float,   access::sample> depthCullFront [[texture(3)]])
{
    // 天井境界面のデプス
    float ds = depthSlice.sample(s, vert.uv);
    // 視点から見て手前を向いているポリゴンのデプス
    float db = depthCullBack.sample(s, vert.uv);
    // 視点から見て反対を向いたポリゴンのデプス
    float df = depthCullFront.sample(s, vert.uv);
    
    float4 sliceColor = float4(0.0, 0.0, 0.0, 0.0);
    if (df < ds) {
        // キャラクターの背面側より境界面が手前
        if (ds < db) {
            // さらに、キャラクターの前面側より境界面が後ろ
            sliceColor = float4(0.5, 0.5, 0.0, 0.0);    // 薄い黄色
        }
    }
    // カメラキャプチャ画像を含むシーン全体の画像に境界面の色を足す
    float4 fragment_color = colorScene.sample(s, fract(vert.uv));
    fragment_color += sliceColor;   // ざつな処理だと思うが、色の取り扱いに詳しくないので機会があれば見直す。
    
    return half4(fragment_color);
}
4
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?