LoginSignup
12
4

More than 3 years have passed since last update.

ARKit+SceneKit+Metalで光学迷彩①

Last updated at Posted at 2020-10-08

光学迷彩っぽいことをやってみた

やりたいこと

『GHOST IN THE SHELL/攻殻機動隊』のあれ。

 完成イメージ
demo1.gif

光学迷彩っぽくする方法

光学迷彩にする3Dモデルを透明(=背景色)で出力することで実現する。
このとき、3Dモデルの背景色を法線方向にちょっとずらして取得・出力することで、3Dモデルの形状にそって背景が歪むため、それっぽく見える。

参考にしたサイト:wgld.org

マルチパスレンダリング

ARにてオブジェクトの背景を歪ませるには次のステップが必要(きちんとした方法は他にもたくさんあると思うが一例として)。
①背景(=カメラのキャプチャ+光学迷彩させない3Dモデル)画像を用意する
②光学迷彩させる3Dモデルの法線情報を用意する
③①と②を組み合わせる。
 ・②の光学迷彩対象部分は①の背景を歪めて出力
 ・それ以外の部分は①の背景をそのまま出力

ここで、①②③のいずれもレンダリングを行うが、最終的に画面に出力すのは③の結果である。①②の出力結果は内部的にメモリに保持する。SceneKitでこれを実現するのがSCNTechniqueでレンダリングを複数行い、それらを組み合わせることができる。

このSCNTechniqueについては情報が少なく、なんだかんだで SCNTechnique | Apple Developer Document に情報が一番ある(と思う)。

今回試したマルチパスレンダリングの定義がこちら。

technique.json
{
    "targets" : {
        "color_scene" : { "type" : "color" },
        "depth_scene" : { "type" : "depth" },
        "color_node"  : { "type" : "color" },
        "depth_node"  : { "type" : "depth" }
    },
    "passes" : {
        "pass_scene" : {
            "draw" : "DRAW_SCENE",
            "excludeCategoryMask" : 2,
            "outputs" : {
                "color" : "color_scene",
                "depth" : "depth_scene"
            },
            "colorStates" : {
                "clear" : true,
                "clearColor" : "sceneBackground"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },
        "pass_node" : {
            "draw" : "DRAW_NODE",
            "includeCategoryMask" : 2,
            "metalVertexShader" : "node_vertex",
            "metalFragmentShader" : "node_fragment",
            "outputs" : {
                "color" : "color_node",
                "depth" : "depth_node"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },
        "pass_mix" : {
            "draw" : "DRAW_QUAD",
            "inputs" : {
                "colorScene" : "color_scene",
                "depthScene" : "depth_scene",
                "colorNode"  : "color_node",
                "depthNode"  : "depth_node",
            },
            "metalVertexShader" : "mix_vertex",
            "metalFragmentShader" : "mix_fragment",
            "outputs" : {
                "color" : "COLOR"
            },
            "colorStates" : {
                "clear" : "true"
            }
        }
    },
    "sequence" : [
        "pass_scene",
        "pass_node",
        "pass_mix"
    ]
}

少しづつ見ていきましょう。

①背景(=カメラのキャプチャ+光学迷彩させない3Dモデル)画像を用意する

        "pass_scene" : {
            "draw" : "DRAW_SCENE",
            "excludeCategoryMask" : 2,
            "outputs" : {
                "color" : "color_scene",
                "depth" : "depth_scene"
            },
            "colorStates" : {
                "clear" : true,
                "clearColor" : "sceneBackground"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },

"pass_scene" が ①背景の描画の定義。
"draw" : "DRAW_SCENE" はシーン全体の描画指示で、出力結果を"outputs"にある、"color" : "color_scene""depth" : "depth_scene" に出力するようにしている。後述するが、"color" : "COLOR" とすると、最終的に画面に出力される。
で、"color_scene"は何かというと、色情報を保存しておくバッファ。ここに保存しておくことで、後々、この情報を使った処理が可能になる。
次に、"depth_scene"は何かというと、描画した3Dオブジェクトの奥行きの情報を保存しておくバッファ。後で、光学迷彩対象の3Dモデルと画像を組み合わせるときに、背景の3Dモデルとどちらが手前にあるのか判定するのに使用する。
あと、光学迷彩対象は描画をしないように "excludeCategoryMask" : 2 としている。光学迷彩対象の SCNNodecategoryBitMask に2を設定しておく。

②光学迷彩させる3Dモデルの法線情報を用意する

        "pass_node" : {
            "draw" : "DRAW_NODE",
            "includeCategoryMask" : 2,
            "metalVertexShader" : "node_vertex",
            "metalFragmentShader" : "node_fragment",
            "outputs" : {
                "color" : "color_node",
                "depth" : "depth_node"
            },
            "depthStates" : {
                "clear" : true,
                "func" : "less"
            }
        },

"pass_node" が ②光学迷彩させる3Dモデルの法線情報の描画の定義。
"draw" : "DRAW_NODE" は特定のノードを描画するという意味で、ここでは"includeCategoryMask"に指定した Category Bit Maskを持つノードを対象にしている(逆に、光学迷彩対象としない3Dモデルは includeCategoryMask のビットとは異なる値にする)。
"outputs""color_node""depth_node" で、それぞれノードの色と奥行きの情報を格納するバッファ。
"node_vertex""node_fragment" はノードの法線の情報を色情報として "color_node" に出力するためのシェーダー(後述)。

③①と②を組み合わせる。

        "pass_mix" : {
            "draw" : "DRAW_QUAD",
            "inputs" : {
                "colorScene" : "color_scene",
                "depthScene" : "depth_scene",
                "colorNode"  : "color_node",
                "depthNode"  : "depth_node",
            },
            "metalVertexShader" : "mix_vertex",
            "metalFragmentShader" : "mix_fragment",
            "outputs" : {
                "color" : "COLOR"
            },
            "colorStates" : {
                "clear" : "true"
            }
        }

"pass_mix" が ①と②を組み合わせる描画の定義。
"draw" : "DRAW_QUAD" は他で作られた色や奥行きの情報を処理するときに使う。"inputs"には前述した描画定義の出力バッファを指定しており、これをインプットとして、"mix_vertex""mix_fragment" で加工し、"outputs" : { "color" : "COLOR"} を指定することで最終的な画像を出力させる。

シェーダー

次にSCNTechniqueで指定したシェーダーについて。

shader.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)]];   // 頂点座標
    float2 normal [[attribute(SCNVertexSemanticNormal)]];       // 法線
};

// SceneKit -> Shader の受け渡し型(ノード毎)
// 定義は https://developer.apple.com/documentation/scenekit/scnprogram 参照
struct PerNodeBuffer {
    float4x4 modelViewProjectionTransform;
};

struct NodeColorInOut {
    float4 position [[position]];
    float4 normal;
};

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

// ノード用頂点シェーダー
vertex NodeColorInOut node_vertex(VertexInput in [[stage_in]],
                                  constant SCNSceneBuffer& scn_frame [[buffer(0)]],  // 描画フレームの情報
                                  constant PerNodeBuffer& scn_node [[buffer(1)]])    // Node毎の情報
{
    NodeColorInOut out;
    out.position = scn_node.modelViewProjectionTransform * in.position;
    out.normal = scn_node.modelViewProjectionTransform * float4(in.normal, 1.0);
    return out;
}

// ノード用フラグメントシェーダー
fragment half4 node_fragment(NodeColorInOut vert [[stage_in]])
{
    // 使用する法線はx, yのみ。色情報として扱うので、-1.0 ~ 1.0 -> 0.0 ~ 1.0 に変換しておく
    float4 color =  float4((vert.normal.x + 1.0) * 0.5 , (vert.normal.y + 1.0) * 0.5, 0.0, 0.0);
    return half4(color);        // 法線を色情報として出力。この情報で光学迷彩対象の背景を歪める
}

// シーン全体とノード法線の合成用頂点シェーダー
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,    // clamp_to_edge/clamp_to_border(iOS14)はだめ。
                              filter::nearest);

// シーン全体とノード法線の合成用フラグメントシェーダー
fragment half4 mix_fragment(MixColorInOut vert [[stage_in]],
                            texture2d<float, access::sample> colorScene [[texture(0)]],
                            depth2d<float,   access::sample> depthScene [[texture(1)]],
                            texture2d<float, access::sample> colorNode [[texture(2)]],
                            depth2d<float,   access::sample> depthNode [[texture(3)]])
{
    float ds = depthScene.sample(s, vert.uv);    // シーン全体描画時のデプス
    float dn = depthNode.sample(s, vert.uv);     // ノード描画時のデプス

    float4 fragment_color;
    if (dn > ds) {
        // 光学迷彩対象のオブジェクトがシーンで描画したオブジェクトの手前にあるので、光学迷彩効果を出す
        float3 normal_map = colorNode.sample(s, vert.uv).rgb;
        // 0.0 ~ 1.0 -> -1.0 ~ 1.0 に戻して座標として使えるようにする
        normal_map.xy = normal_map.xy * 2 - 1.0;
        // 採用する背景色の位置をノードの法線方向(xy平面)に少しずらして取得することを歪んだ背景にする
        float2 uv = vert.uv + normal_map.xy * 0.1;
        if (uv.x > 1.0 ||  uv.x < 0.0) {
            // 画面の外の色を採用しないようにする(samplerのaddressingで解決したかったがうまくいかなかった)
            fragment_color = colorScene.sample(s, fract(vert.uv));
        } else {
            fragment_color = colorScene.sample(s, fract(uv));
        }
    } else {
        // 光学迷彩対象のオブジェクトがシーンで描画したオブジェクトの奥にあるので、シーン側の色をそのまま採用
        fragment_color = colorScene.sample(s, fract(vert.uv));
    }

    return half4(fragment_color);
}

ポイントになるところは3箇所。

1. 光学迷彩対象のノードの法線情報の出力

// ノード用フラグメントシェーダー
fragment half4 node_fragment(NodeColorInOut vert [[stage_in]])
{
    // 使用する法線はx, yのみ。色情報として扱うので、-1.0 ~ 1.0 -> 0.0 ~ 1.0 に変換しておく
    float4 color =  float4((vert.normal.x + 1.0) * 0.5 , (vert.normal.y + 1.0) * 0.5, 0.0, 0.0);
    return half4(color);        // 法線を色情報として出力。この情報で光学迷彩対象の背景を歪める
}

ノードのフラグメントシェーダーで、法線情報(xyz)をそのまま色(rgb)情報として出力している。

2.光学迷彩対象のオブジェクトと対象外のオブジェクトとの前後比較

// シーン全体とノード法線の合成用フラグメントシェーダー
fragment half4 mix_fragment(MixColorInOut vert [[stage_in]],
    texture2d<float, access::sample> colorScene [[texture(0)]],
    depth2d<float,   access::sample> depthScene [[texture(1)]],
    texture2d<float, access::sample> colorNode [[texture(2)]],
    depth2d<float,   access::sample> depthNode [[texture(3)]])
{
    float ds = depthScene.sample(s, vert.uv);    // シーン全体描画時のデプス
    float dn = depthNode.sample(s, vert.uv);     // ノード描画時のデプス

    float4 fragment_color;
    if (dn > ds) {
        // 光学迷彩対象のオブジェクトがシーンで描画したオブジェクトの手前にあるので、光学迷彩効果を出す
    } else {
        // 光学迷彩対象のオブジェクトがシーンで描画したオブジェクトの奥にあるので、シーン側の色をそのまま採用
    }

シーン全体を描画したときの奥行き情報dsと光学迷彩対象オブジェクトの奥行き情報dnを比較して、dnが大きければ、背景を歪めることで光学迷彩効果を出し、小さければ背景色をそのまま表示する。
ちょっとわかりづらいが↓を見ると、光学迷彩対象のレッサーパンダが奥にあるときには手前のレッサーパンダは歪んでいないことがわかる。
demo2.gif

3.背景を歪める

// 光学迷彩対象のオブジェクトがシーンで描画したオブジェクトの手前にあるので、光学迷彩効果を出す
float3 normal_map = colorNode.sample(s, vert.uv).rgb;
// 0.0 ~ 1.0 -> -1.0 ~ 1.0 に戻して座標として使えるようにする
normal_map.xy = normal_map.xy * 2 - 1.0;
// 採用する背景色の位置をノードの法線方向(xy平面)に少しずらして取得することを歪んだ背景にする
float2 uv = vert.uv + normal_map.xy * 0.1;

今回の光学迷彩は背景を透明に出力しつつも、背景の色を3Dモデルの法線の方向の離れた場所から取得して歪んだ感じを出している。
「②光学迷彩させる3Dモデルの法線情報を用意する」で出力した法線情報を使って、背景画像の座標を移動し、その場所の色を3Dモデル上の色としている。

SCNTechniqueのセットアップ

SCNTechnique をセットアップするSwift側のコードはこんな感じ。

ViewController.swift
import ARKit
import SceneKit

class ViewController: UIViewController, ARSCNViewDelegate {

    @IBOutlet weak var scnView: ARSCNView!

    private var rootNode: SCNNode!

    override func viewDidLoad() {
        super.viewDidLoad()
        // キャラクター読み込み。WWDC2017 SceneKit Demoを借用 https://developer.apple.com/videos/play/wwdc2017/604/
        guard let scene = SCNScene(named: "art.scnassets/max.scn"),
              let rootNode = scene.rootNode.childNode(withName: "root", recursively: true) else { return }
        self.rootNode = rootNode
        self.rootNode.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(_: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor, self.rootNode.isHidden else { return }
        self.rootNode.simdPosition = planeAnchor.center
        self.rootNode.isHidden = false
        DispatchQueue.main.async {
            // 検出した平面上にオブジェクトを表示
            node.addChildNode(self.rootNode)
        }
    }

    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
    }
}

注意事項

いろいろ試していたら気が付いたのだが、ピープルオクルージョンを有効にすると SCNTechnique が無効化される!!
(ピープルオクルージョン自体、マルチパスレンダリングっぽい仕組みなので、そちらが優先されるということ?)

let configuration = ARWorldTrackingConfiguration()
configuration.frameSemantics = [.personSegmentation]  // これを指定するとSCNTechniqueは動かない!

TODO

  • 迷彩っぷりが綺麗すぎてそれっぽくない(つるつるな)ので、ノイズをのせる
  • 顔とかジオメトリの一部は迷彩にしない https://wirelesswire.jp/2016/02/50064/
12
4
1

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
12
4