ARで壁や床から3Dモデルが現れる演出をよく見かけるので挑戦。
題材にしたのは『天空の城ラピュタ』。ラピュタの展望室でモウロ将軍の前にムスカとシータが天井から現れるシーン。(床に穴が空く仕掛けがある部屋を「展望室」?とは思うがwikiの表現に倣う)
(モデルがレッサーパンダなのはおいて、、)完成イメージ
再現にあたり困ったのがキャラクターと天井の境界面の薄黄色の部分(ラピュタの技術では境界面は薄黄色になったりならなかったりしているが、ここでは薄黄色に統一)。以下、再現方法を解説します。
再現方法
①ムスカとシータのノードを上下にアニメーションさせる
②天井境界の薄黄色面を作るための奥行き情報(以下、デプス)を作成する
次の3つを作る。
・天井の境界平面のデプス
・キャラクターを cullMode = back で描画したときのデプス
・キャラクターの cullMode = front で描画したときのデプス
③②の情報から境界面とキャラクターの断面部分を判定し、①の画像に薄黄色を加える。
レンダリングパス(Xcodeの Capture GPU Frame)
以下、個別にみていきましょう。
①ムスカとシータのノードを上下にアニメーションさせる
これは Xcode の Scene Editor で設定。
・キャラクターを並べる(キャラクターモデルはWWDC2017 SceneKit Demoから借用)。キャラクターは取りまとめノードchar_parent
の下にぶら下げる。
・境界面ノードslice_plane
をchar_parent
と同列に配置。この境界面ノードはアニメーションしない。
→色はほぼ透明にする。
→Rendering Order の値を小さくして、キャラクターよりも先に描画することで、背景にキャラクターが描画されないようにする。
ここでCategory bit mask を設定している。後でデプスを生成する際に、境界面とキャラクターを区別するのに使う。境界面は4、キャラクターは2を設定する。
②天井境界の薄黄色面を作るための奥行き情報を作成する
キャラクターの正面側(手前側)のデプス情報と、キャラクターの背面側(見えない側)のデプス情報を取得し、その差分でキャラクターの実体を得る。
- キャラクターの背面部分のデプスを取得
→背面側のみ描画は cullMode=front という指定で行う(後述)
キャラクターの断面は、ここで得られたデプスよりも大きい値となる。 - キャラクターの正面部分のデプスを取得
→正面側のみ描画は cullMode=back という指定で行う(後述)
キャラクターの断面は、ここで得られたデプスよりも小さい値となる。 - 境界面(天井平面)のデプスを取得
上記1)2)のデプスでキャラクターの断面となるのは、この境界面のデプスの範囲とする。
上記3つ各々のデプス情報を SCNTechnique によるマルチパスレンダリングで生成。マルチパスレンダリングの定義は次の通り。
{
"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"
}
},
これは、シーン全体の描画の定義。draw
に DRAW_SCENE
を指定することでカメラキャプチャ画像+キャラクターの描画を行なっている。描画結果は色情報のみで、color_scene
という名前のバッファに格納。
"pass_slice" : {
"draw" : "DRAW_NODE",
"includeCategoryMask" : 4,
"outputs" : {
"depth" : "depth_slice"
},
"depthStates" : {
"clear" : true,
"func" : "less"
}
},
これは天井境界面の描画。includeCategoryMask
に4
を指定しており、境界平面のみ描画する設定としている。この描画では色情報は不要でデプスのみ depth_slice
という名前のバッファに格納。
"pass_cullback" : {
"draw" : "DRAW_NODE",
"includeCategoryMask" : 2,
"cullMode" : "back",
"outputs" : {
"depth" : "depth_cullback"
},
"depthStates" : {
"clear" : true,
"func" : "less"
}
},
これは、正面からみたときのキャラクターのデプス情報を取得するための定義。includeCategoryMask
に2
を指定しており、キャラクターのみ描画する設定としている。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
フラグメントシェーダー(後述)で合成して、最終画像としている。outputs
の color
に COLOR
と指定することで画面に描画される。
③②の情報から境界面とキャラクターの断面部分を判定し、①の画像に薄黄色を加える
これは前述の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つのデプス情報しか作っていないので、キャラクターの後ろに別のキャラクターがいる場合、後ろにいるキャラクターのデプスは手前のキャラクターのデプスで上書きされてしまい、境界面が描画されないという問題がある。
他に良い方法があると思うので、知っている方教えて欲しいです。
以下、試行錯誤過程で調べたり、試した内容です。
再現にあたり試した方法
-
大量のヒットテストをして境界面でのキャラクターの形を探る
境界面でSCNNode
のhitTestWithSegment(from:to:options:)
を横に100並べて、それをキャラクターの前面→背面、背面→前面でヒットテストすることで、キャラクターの表面位置を求めて、断面図を作ってみた。
→hitTestWithSegment の精度は期待するレベルではなくジオメトリの形とすこしずれた結果となり使えなかった。特に耳や足のように細かな箇所はヒット結果の位置が見た目と大きくずれた。本来の目的と全然異なる使い方なのでしょうがないとは思う。 -
境界面のジオメトリをリアルタイムに作成
・キャラクターのジオメトリを境界面でまっ平にしてその部分を薄黄色にする。
→例えば、足のようにジオメトリと境界面が複数箇所接しているときに、右足・左足のそれぞれでジオメトリを平にするのが相当面倒そう。試してはいない。
また、ジオメトリがローポリの場合、ガタガタすると思われるので、テッセレーション(?)でジオメトリを分割しておくとか必要になると思われる。
・キャラクターのジオメトリと境界面に接する部分で新たに平面ジオメトリ を作って境界面に配置する。
→これも、境界面の近くのジオメトリの頂点は取得できても、それから閉じた平面ジオメトリ を作るのが面倒と思われる(法線情報で頑張ればできるか??)。
→「mesh slicing」でぐぐるといくつか方法が見つかるが難しそうで手が止まった。
・Algorithm or software for slicing a mesh
→UE4だとリアルタイムでMesh Sliceができるっぽい。SceneKitにはなさそう。
・https://unrealengine.hatenablog.com/entry/2016/09/12/002115
全体ソースコード
・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);
}