ARKit + Metalで空中に水を浮かべる方法
ARKit + Metalで空中に水を浮かべてみました。
結構きれいなものができましたので、作り方をご紹介します。
仕上がり
大まかな流れ
大まかな流れは次のとおりです。
・タップされた位置に球体を追加する
・カメラから取り込んだ映像を貼り付ける
・球体を歪ませて浮いている水のように見せる
・表面に揺れる水のようなテクスチャを追加する
それではさっそく1つ1つ説明します。
1. ユーザーがテーブルなど平面の位置をタップしたら球体を追加する
タップされたら平面にアンカーを追加し、そこに球体を追加します。
これについては、同じことを以前にも解説しているのでごらんください。
2. 球体のテクスチャにカメラから取り込んだ映像を貼り付ける
カメラから取り込んだ映像をテクスチャとして球体に貼り付けます。今回はその映像をMetalのシェーダーで加工したいので、シェーダに渡します。
まず、事前にシェーダーが受け取る情報を定義しておきます。また、それらの情報を配列として保持しておきます。
struct GlobalData2 {
var x: Float = 0
var y: Float = 0
var id: Int32 = 0
}
class WaterBubbleViewController: UIViewController, ARSCNViewDelegate {
private var globalData: [GlobalData2] = []
・
・
・
次に、アンカーノードと球体を作成します。そして、シェーダーに球体の個体番号やカメラから取得した映像をテクスチャとして渡します。
// ノードの作成
let sphereNode = SCNNode()
// 球体の作成
sphereNode.geometry = SCNSphere(radius: 0.02)
sphereNode.position.y += Float(0.05)
// ノードからを取り出す
guard let material = sphereNode.geometry?.firstMaterial else {return}
// Metalシェーダーの指定
let program = SCNProgram()
program.vertexFunctionName = "vertexShader2"
program.fragmentFunctionName = "fragmentShader2"
sphereNode.geometry?.firstMaterial?.program = program
// シェーダーに渡すデータを用意する
// idは、球体ごとの個体番号を示す。この個体番号はシェーダーで個体ごとに異なるパラメータで変形するために使用する
var data = GlobalData2()
data.id = Int32(globalData.count)
// 経過時間をシェーダーに渡す(時間とともに映像を変化させるためにシェーダーが使用する)
let time = Float(Date().timeIntervalSince(startDate))
globalData.time = time
// シェーダーに渡すデータをインスタンス変数に退避する
globalData += [data]
// シェーダーに渡す
let uniformsData = Data(bytes: &data, count: MemoryLayout<GlobalData2>.size)
sphereNode.geometry?.firstMaterial?.setValue(uniformsData, forKey: "globalData")
// カメラの情報を画像データとして取得し、テクスチャーとして渡す
if let cameraImage = captureCamera() {
let imageProperty = SCNMaterialProperty(contents: cameraImage)
material.setValue(imageProperty, forKey: "diffuseTexture")
}
// ライトを追加
material.lightingModel = .lambert
// あとで検索できるようにノードに名前をつける
sphereNode.name = bubbleNodeName // bubbleNodeNameは "Bubble"という文字列
カメラからの情報を取得するcaptureCameraメソッドは次のようになっています。スマホの向きに応じて回転をかける必要があります。
func captureCamera() -> CGImage?{
guard let frame = sceneView.session.currentFrame else {return nil}
let pixelBuffer = frame.capturedImage
var image = CIImage(cvPixelBuffer: pixelBuffer)
let transform = frame.displayTransform(for: orientation, viewportSize: viewportSize).inverted()
image = image.transformed(by: transform)
let context = CIContext(options:nil)
guard let cameraImage = context.createCGImage(image, from: image.extent) else {return nil}
return cameraImage
}
このあたりはこちらの本の「19.3 Metal によるカスタムレンダリング」の項目を参考に作りました。
4. カメラからの入力画像を更新する
先程カメラからの入力をテクスチャとして設定しましたが、1回設定しただけだと、静止画になってしまいます。
水面にカメラ映像をリアルタイムに反映させるために、常にカメラ画像をキャプチャーして渡す必要があります。
やり方としてはタイマーを1/60秒毎に発動し、その中で処理をしています。タイマーはviewDidLoadの中で仕込んでおきます。
override func viewDidLoad() {
・
・
・
// 1/60秒ごとに起動する
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true, block: { (timer) in
self.updateNodesTexture()
})
}
テクスチャーを更新するupdateNodesTextureメソッドは次のようになっています。
球体のノードを取り出して、それぞれについてシェーダーに情報を渡しています。
func updateNodesTexture() {
// 最新の経過時間を得る
let time = Float(Date().timeIntervalSince(startDate))
// カメラの情報を画像データとして取得する
guard let cameraImage = captureCamera() else {return}
let imageProperty = SCNMaterialProperty(contents: cameraImage)
// ノードの中から球体だけを抽出する
let nodes = sceneView.scene.rootNode.childNodes.compactMap {
$0.childNode(withName: bubbleNodeName, recursively: true) // bubbleNodeNameは"Bubble"という文字列
}
// 1つ1つの球体についての処理
for (i, node) in nodes.enumerated() {
// テクスチャーを最新のカメラ映像で更新する
guard let material = node.geometry?.firstMaterial else {return}
guard let parent = node.parent else {return}
material.diffuse.contents = cameraImage
material.setValue(imageProperty, forKey: "diffuseTexture")
var data = globalData[i]
// 最新の経過時間を渡す
data.time = time
// カメラ映像のうち、実際に使用する領域を計算し、シェーダーに渡す(後述)
let area = calcClipArea(node: parent)
data.x = area.x
data.y = area.y
let uniformsData = Data(bytes: &data, count: MemoryLayout<GlobalData2>.size)
node.geometry?.firstMaterial?.setValue(uniformsData, forKey: "globalData")
}
}
5. 水が光を屈折させているような効果を作る
カメラの画像を単純にテクスチャとして貼り付けただけだと、カメラ全体の画像が1つ1つの水に張り付くことになり、すべての水が同じ絵になってしまい不自然です。
そこで、水の背後にある画像を拡大してテクスチャーに貼ることにします。
こうすることで、下のように1つ1つの水がその後ろにある映像を屈折しながら映し出しているように見せることができます。
その領域を特定しているのが、上のコードで使っていたcalcClipAreaメソッドです。
水が表示されているスクリーン上の位置を特定し、それをシェーダーに渡しています。
func calcClipArea(node: SCNNode) -> (x: Float, y: Float){
// 実際のスクリーンサイズを得る
let width = self.view.frame.width
let height = self.view.frame.height
// 水のオブジェクトのワールド座標上の位置をスクリーン座標に変換する
let screenPos = sceneView.projectPoint(node.position)
// スクリーンサイズで正規化する(シェーダーで演算しやすくするため)
let x = screenPos.x / Float(width)
let y = screenPos.y / Float(height)
return (x: x, y: y)
}
上で渡された情報をもとにフラグメントシェーダーでカメラ映像から領域を取り出しています。
具体的には、水のスクリーン座標位置を中心として5倍に拡大した画像をテクスチャーにしています。
もっと正確にやろうとしたら水の大きさを計算して、その領域を取り出すのがベストですが、この方法でも概ね良好な結果が得られたので採用しています。
fragment float4 fragmentShader2(ColorInOut2 in [[ stage_in] ],
texture2d<float, access::sample> diffuseTexture [[texture(0)]],
device GlobalData2 &globalData [[buffer(1)]])
{
constexpr sampler sampler2d(coord::normalized, filter::linear, address::repeat);
// テクスチャー座標を得る
float2 uv = in.texCoords;
// 経過時間を得る
float time =globalData.time;
// スクリーン座標を得る
float2 screenPos =float2(globalData.x, globalData.y);
// 拡大率を5倍とする
float2 alpha = float2(5, 5);
// スクリーン座標を中心とする
float2 center = screenPos;
// 拡大する領域の座標を計算する。centerを中心として5倍拡大することになる
float2 samp = ((uv - center) / alpha) + center;
// テクスチャーから画像を得る
float4 color = diffuseTexture.sample(sampler2d, samp);
// 表面に水のような効果を追加する(後述)
float4 water = waterColor(time, uv);
float4 result = color + water*0.5;
return result;
}
なお、このようにフラグメントシェーダーで画像を拡大する処理はMetalではなくUnityですがこの本に詳しく載っていました。
6. テクスチャに揺れる水のような効果を追加する
先程のフラグメントシェーダーでwaterColorという関数が呼ばれていましたが、これは水面のような映像を実現するためのものです。
float4 waterColor(float time, float2 sp) {
float2 p = sp * 15.0 - float2(20.0);
float2 i = p;
float c = 0.0; // brightness; larger -> darker
float inten = 0.025; // brightness; larger -> brighter
float speed = 1.5; // larger -> slower
float speed2 = 3.0; // larger -> slower
float freq = 0.8; // ripples
float xflow = 1.5; // flow speed in x direction
float yflow = 0.0; // flow speed in y direction
for (int n = 0; n < 8; n++) {
float t = time * (1.0 - (3.0 / (float(n) + speed)));
i = p + float2(cos(t - i.x * freq) + sin(t + i.y * freq) + (time * xflow), sin(t - i.y * freq) + cos(t + i.x * freq) + (time * yflow));
c += 1.0 / length(float2(p.x / (sin(i.x + t * speed2) / inten), p.y / (cos(i.y + t * speed2) / inten)));
}
c /= float(8);
c = 1.5 - sqrt(c);
return float4(float3(c * c * c * c), 0.0) + float4(0.0, 0.4, 0.55, 1);
}
この処理は、GLSL Sandboxにあったものを、MSLに変換して作りました。
このような水面が得られます。
なお、このテクスチャーとカメラ映像を合成する必要があります。水面のテクスチャーが半透明になるように0.5をかけてカメラ映像と足しています。
float4 result = color + water*0.5;
7. 球体を歪ませて浮いている水のようにする
画像にもある通り、球体は上下左右に歪んでいます。また、時間経過とともに歪みが変化します。これは頂点シェーダと呼ばれるもので実現しています。
方法としては、水の頂点座標をcos関数によって他の座標軸の情報や、時間経過とともに変化させています。
vertex ColorInOut2 vertexShader2(VertexInput2 in [[ stage_in ]],
constant SCNSceneBuffer& scn_frame [[buffer(0)]],
constant NodeBuffer2& scn_node [[ buffer(1) ]],
device GlobalData2 &globalData [[buffer(2)]])
{
ColorInOut2 out;
// 水の座標
float3 pos = in.position;
// 例えばx座標は、y座標の位置をcosにかけることでyが変化する毎に波打つようにする。
// scn_frame.timeは時間で、時間経過とともに波打つようにする
// globalData.idはswift側から渡した個体番号で、個体ごとに変化を作り出している
// *100や0.005は、程よいバランスで球体が歪んで水っぽくなるようにするためにしている
pos.x += cos(pos.y*100 + scn_frame.time + globalData.id)*0.005;
pos.y += cos(pos.x*100 + scn_frame.time + globalData.id)*0.005;
pos.z += cos(pos.y*100 + scn_frame.time + globalData.id)*0.005;
// ARKitのMVP座標変換を掛けている
float4 transformed = scn_node.modelViewProjectionTransform * float4(pos, 1.0);
out.position = transformed;
out.texCoords = in.texCoords;
return out;
}
なお、図形の変形処理はこれまたUnityですがこちらの本が参考になりました。
以上です。
なお、若干宣伝ぽくて恐縮ですが、私はフリーランスエンジニアをしております。
このような効果をアプリで実現したいという方がいらっしゃいましたら、
効果の部分の作成だけでも受託できますのでお気軽にご相談下さい。
連絡先名:TokyoYoshida
連絡先: yoshidaforpublic@gmail.com