今日の記事は、ARKitのAdvent Calendarの2日目の記事です。
概要
今回は、ARKitで平面検出を行っている映像データを使って、(疑似)IBLをしてみたいと思います。
ちなみに、ARKitをUnityで使う際の実装については前の記事で少し書いたので、ARKit?って人はそっちも読んでみてください。
ARで3Dモデルを表示するととても面白いしテンション上がりますが、やはりどこか浮いて見えてしまいます。
というのも、人間は立体感を「影・陰」から判断しているため、光の当たり方が少し違うだけで違和感が出てしまうのです。
そして当然ですが、なにもしなければ3Dモデルを照らすライトはビルドしたときに用意したライトのみになります。
しかしARに関わらず、Skyboxのテクセルを光源とみなす、いわゆる「グローバルイルミネーション」の機能を使えば、映像からライティングが可能となります。
今回の趣旨は、ARKitが利用している映像を利用して、疑似IBLを実現してみよう、という内容になります。
なので厳密には、IBL自体を自前で実装したわけではなく、あくまで疑似IBLです。
実際に適用した図が以下になります↓
ARKitの環境のテクスチャを使って擬似IBL的なことをしてみた。 pic.twitter.com/Dsc6tnuLUb
— edom18@VR (@edo_m18) 2017年12月2日
考え方
考え方はシンプルです。
- ARKitで利用される環境のテクスチャを取得する
- 取得したテクスチャを適度にぼかす(*1)
- ぼかしたテクスチャを、全天球に貼り付ける
- 全天球の中心に置いた専用カメラでCubeMapにレンダリングする
- 生成したCubeMapから色をフェッチしてブレンド
という手順です。
*1
ぼかす理由は、IBL自体がそもそも、描画する点から複数方向に向かってサンプルRayを飛ばし、その色を合成することで得られます。
それは、環境光が全方面(点を中心とした半球状の方向)から到達するためであり、それをシミュレーションするために様々な方向の光をサンプリングするわけです。
そしてそれを擬似的に、かつ簡易的に実現する方法として「ぼかし」を利用しているわけです。
以前書いたPBRについての記事も参考になると思います。(光のサンプリングという点で)
ARKitのテクスチャからぼかしテクスチャを得る
今回の目的達成のために、若干、ARKitのプラグインのUnityARVideoクラスのコードを編集しました。
_unityARVideo.VideoTextureY; _unityARVideo.VideoTextureCbCr;
本来はVideTextureがprivateなフィールドのためアクセスできませんが、IBL用にpublicにして取得できるようにしてあります。
実際にブラーを施している箇所は以下のようになります。
for (int i = 0; i < _renerList.Count; i++) { _renerList[i].material.SetFloat(“_IBLIntencity”, _IBLIntencity); } Texture2D textureY = _unityARVideo.VideoTextureY; Texture2D textureCbCr = _unityARVideo.VideoTextureCbCr; _yuvMat.SetTexture(“_textureY”, textureY); _yuvMat.SetTexture(“_textureCbCr”, textureCbCr); Graphics.Blit(null, _ARTexture, _yuvMat); _blur.ExecuteBlur(_ARTexture, _bluredTexture); _camera.RenderToCubemap(_cubeMap);
UnityARVideoクラスからテクスチャを取り出して、それをひとまず専用のRenderTextureにレンダリングします。
そしてレンダリングされた結果を、ブラー用のマテリアルでレンダリングしたものを全天球のテクスチャにします。
(上のコードにはありませんが、セットアップの時点で_bluredTextureが適切に割り当てられています)
そして最後に、_camera.RenderToCubemap(_cubeMap);を実行して、ぼかしたテクスチャをまとった全天球をCubemapに書き出している、というわけです。
キャラのシェーダ
キャラのシェーダのコード断片を載せておきます。
v2f vert(appdata i)
{
v2f o;
o.vertex = UnityObjectToClipPos(i.vertex);
o.normal = UnityObjectToWorldNormal(i.normal);
o.uv = i.texcoord;
return o;
}
float4 frag(v2f i) : SV_Target
{
float4 tex = tex2D(_MainTex, i.uv);
float4 cube = texCUBE(_Cube, i.normal) * _IBLIntencity;
return tex * cube;
}
やっていることはシンプルに、テクスチャの色と、生成したCubeMapからの色を合成しているだけですね。
いちおう、あとから調整できるようにIntencityも用意してあります。
ただあくまで今回のやつは疑似的なものです。
そもそも、カメラで撮影している前面の映像しかないですし、本来適用されたい色とはずれているため、色味を調整する、くらいの感じで利用するのがいいかなと思います。
とはいえ、環境光にまったく影響を受けないのはそれはそれで違和感があるので、少しでも変化があるとより自然になるのではないでしょうか。
その他メモ
さて、今回は以上なんですが、サンプルの実装をするにあたって、いくつか別のアプローチも試していたので、せっかくなのでメモとして残しておこうと思います。
Skyboxのマテリアルで直接レンダリング
前述したものは、オブジェクトとして全天球を用意してそれをカメラでCubemapに変換する方式でした。
こちらは、Skyboxのマテリアルとしてのシェーダを書いて実現しようとしたものです。
Unityでは、環境マップ用にSkyboxのマテリアルが設定できるようになっています。
そのマテリアルには、他のマテリアルとは若干異なる値が渡されます。
これを用いて、Skyboxのレンダリング結果自体を操作することで実現しようとしたものです。
具体的には、前述の例と同じくUnityARVideoからYCbCrの2種類のテクスチャを取得するところまでは同様です。
それを直接、Skyboxのマテリアルにセットし、シェーダ内ではUV座標を極座標に変換してダイレクトに、フェッチする位置を計算する、というものです。
極座標への変換については以前記事に書いたので参考にしてみてください。
まずはそのシェーダを下に書きます。
Skyboxシェーダ
Shader "Skybox/ARSkybox"
{
Properties
{
_TextureY("TextureY", 2D) = "white" {}
_TextureCbCr("TextureCbCr", 2D) = "black" {}
}
CGINCLUDE
#include "UnityCG.cginc"
#define PI 3.141592653589793
struct appdata
{
float4 position : POSITION;
float3 texcoord : TEXCOORD0;
};
struct v2f
{
float4 position : SV_POSITION;
float3 texcoord : TEXCOORD0;
};
float4x4 _DisplayTransform;
sampler2D _MainTex;
sampler2D _TextureY;
sampler2D _TextureCbCr;
v2f vert (appdata v)
{
v2f o;
o.position = UnityObjectToClipPos (v.position);
o.texcoord = v.texcoord;
return o;
}
half4 frag (v2f i) : COLOR
{
float u = atan2(i.texcoord.z, i.texcoord.x) / PI;
float v = acos(i.texcoord.y) / PI;
float2 uv = float2(v, u);
//
// 式を調べたら以下のものだったが、ARKitで使ってるのは少し違う?
//
// Y = 0.299R + 0.587G + 0.114B
// Cr = 0.500R - 0.419G - 0.081B
// Cb = -0.169R - 0.332G + 0.500B
//
// | Y | = | 0.299, 0.587, 0.114 | | R |
// | Cr | = | 0.500, -0.419, -0.081 | x | G |
// | Cb | = | -0.169, -0.332, 0.500 | | B |
//
// 逆行列をかけて求める。
//
// R = Y + 1.402Cr
// G = Y - 0.714Cr - 0.344Cb
// B = Y + 1.772Cb
//
// 計算結果が異なったが、いちおう残しておく
//
// float y = tex2D(_TextureY, uv).r;
// float2 cbcr = tex2D(_TextureCbCr, uv).rg;
// float r = y + 1.402 * cbcr.g;
// float g = y - 0.714 * cbcr.g - 0.344 * cbcr.r;
// float b = y + 1.772 * cbcr.r;
//
// return float4(r, g, b, 1.0);
float y = tex2D(_TextureY, uv).r;
float4 ycbcr = float4(y, tex2D(_TextureCbCr, uv).rg, 1.0);
const float4x4 ycbcrToRGBTransform = float4x4(
float4(1.0, +0.0000, +1.4020, -0.7010),
float4(1.0, -0.3441, -0.7141, +0.5291),
float4(1.0, +1.7720, +0.0000, -0.8860),
float4(0.0, +0.0000, +0.0000, +1.0000)
);
return mul(ycbcrToRGBTransform, ycbcr);
}
ENDCG
SubShader
{
Tags { "RenderType"="Background" "Queue"="Background" }
Pass
{
ZWrite Off
Cull Off
Fog { Mode Off }
CGPROGRAM
#pragma fragmentoption ARB_precision_hint_fastest
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
}
やっていることは、ARCameraから得たふたつのテクスチャ(※)を合成して、さらに全天球の位置を想定して、フェッチするUV座標を計算しています。
※ YCbCrフォーマットなので、YテクスチャとCbCrテクスチャの2枚を合成する必要があります。
Skybox用シェーダに渡されるtexcoord
通常、シェーダに渡されるtexcoordは対象のモデルのUVの値が使われます。
しかしSkyboxの場合はそもそもモデルデータではなく仮想のもののため、通常のtexcoordの値とは異なった値が渡ってきます。
(ちなみにSkyboxのtexcoordはfloat3型)
ではどんな値が渡ってくるのかというと、float3型の値で、ワールド空間でのXYZ方向が渡ってきます。
例えばZ軸方向に向いているベクトルは(0, 0, 1)、真上方向は(0, 1, 0)、といった具合に、ワールド空間での、原点からの方向ベクトルがそのまま渡ってきます。
なのでそれを想定して、以下のよう、極座標に位置するテクセルをフェッチするようなイメージでフェッチする位置を変換します。
※ 以下のコードは実際に使っているものではなく、通常のテクスチャからフェッチする場合の計算です。
float u = 1 - atan2(i.texcoord.z, i.texcoord.x) / PI; float v = 1 - acos(i.texcoord.y) / PI; float2 uv = float2(u, v);
やっていることはまず、V座標についてはY軸方向をフェッチするため、単純にYの値からアークコサインで角度を求め、それを$\pi$、つまり180°で割ることで正規化しています。
さらに上下を逆転させるため、その値を1から引き、最終的な位置を決定しています。
続いてU座標については、XZ平面でのベクトルの角度を求め、それを$\pi$で正規化することで得ています。
※ ちなみに、U座標については本来は360°の角度がありますが、「見ている方向」に限定すると180°がちょうどいいので、あえて180°で正規化し、正面と背面で同じテクスチャを利用するようにしています。
実際には、ARCameraから得られる結果が若干回転した画像になっていたため、以下のように調整しました。
float u = acos(i.texcoord.y) / PI; float v = atan2(i.texcoord.z, i.texcoord.x) / PI;
※ uとvの計算が逆になっていることに注意。
以上のように設定することで、下の図のように映像が全天球状態で表示されるようになります。

ハマったメモ
今回の例では(最終的には)問題なくなったんですが、ちょっとハマったのと知っておくといいかなと思った点をメモとして残しておきます。
ずばり、Cubemapを動的に反映させる方法、です。
最初、普通にRenderToCubemapを使ってCubemapにレンダリングしていたんですが、どうも最初の一回しか更新してくれない。(毎フレーム更新処理しているのに)
なんでかなーと色々調べていたところ、いくつかのパラメータ設定と更新の通知処理をしないとならないようでした。
そのときに実際に書いたコードを載せておきます。
using System.Collections; using System.Collections.Generic; using UnityEngine; public class CubeMapGenerator : MonoBehaviour { [SerializeField] private Camera _otherCamera; [SerializeField] private Cubemap _cubemap; [SerializeField] private Material _material; private void Start() { Debug.Log(RenderSettings.defaultReflectionMode); RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Custom; } private void LateUpdate() { if (Input.GetKeyDown(KeyCode.A)) { _otherCamera.RenderToCubemap(_cubemap); _cubemap.Apply(); DynamicGI.UpdateEnvironment(); RenderSettings.customReflection = _cubemap; } _otherCamera.transform.Rotate(Vector3.up, 1f); } }
こんな感じで、Cubemapを更新してやらないと2回目以降のものが反映されませんでした。