Panorama180 Movement demo : Algorithm

Reproduction of movable space using "Spatial cache" and "Spatial interpolation".
Developer : ft-lab.
Date : 04/25/2019 - 04/30/2019

戻る
English

はじめに

VR180などの180度パノラマ-3D画像を使用した場合、VR上ではカメラを中心に向きを変えることができますが移動することはできません。
3DoFの動作となります。
このパノラマ画像を使用したVR空間上で特定の方向に移動できるようにし、
また、極力負荷をかけない計算を行い、制限付きの6DoF操作ができるようにします。

※ ここでは、半球の180度パノラマで視差を考慮したステレオ情報を持つ状態を"180度パノラマ-3D"と呼ぶことにします。


なお、ここでは座標系をUnityでの左手系(スクリーンの右向きに+X、スクリーンの上向きに+Y、スクリーンの前方に+Z)で説明していきます。

概要

180度パノラマ-3Dの画像とDepthを複数枚用意し、これをスムーズになるようにつなぐ処理を行います。
180度パノラマ-3Dの画像は、それぞれ3D空間上の位置と正面の向き(Y軸中心の回転角度)情報を持ちます。
この複数の"素材"を、ここでは「空間キャッシュ」と呼ぶことにします。


シーン上の位置から「空間キャッシュ」の情報を補間したパノラマ画像を、GPUを使用してリアルタイムに表示します。
この補間をここでは「空間補間」と呼ぶことにします。
この「空間キャッシュ」と「空間補間」について説明していきます。

空間キャッシュ

3D空間上で、"180度パノラマ-3D画像"と"180度パノラマ-3D Depth画像"を複数サンプリングします。
この処理は、3DCGで表現された空間で行いました。
以下の素材情報が必要になります。
カメラを一定間隔で直線移動させ、この情報を保持します。
VRとしてパノラマ表示するため、「180度パノラマ-3D画像 (RGB)」は最低でも4096 x 2048ピクセルの解像度が望ましいです。
パノラマのRGB画像はjpeg形式でファイル保存します。
「180度パノラマ-3D画像 (Depth)」はRGB画像ほど大きくなくても問題ありません。
ここではRGB画像の1/4のサイズの1024 x 512ピクセルの解像度でDepthを保持するようにしました。

Depthの格納

Depth画像での1ピクセルは、以下の式で0.0-1.0の実数に変換して格納しています。
カメラから見たビュー座標での距離をzDist、カメラの近クリップ面までの距離をnearPlane、遠クリップ面までの距離をfarPlaneとします。
depth = (zDist < farPlane) ? ((zDist - nearPlane) / zDist) : 1.0;
非線形となり、全体的に白寄りになります。
この偏りにより、OpenEXR(exr)形式で圧縮オプションを指定してファイル保存すると、ファイルサイズを小さくする効果があります。
また、depth値は奥行きの判定で利用するため、ピクセル間のアンチエイリアスはかけないようにします。

カメラの中心位置と回転

180度パノラマ-3D画像(RGB/depth)を作成したときのカメラの中心位置と回転情報を保持します。
下図では、真上から見下ろした図になります。
c1/c2/c3がカメラの中心位置、r1/r2/r3がカメラの回転角度です。

このときのカメラの視線方向は、XZ平面に水平でY軸中心の回転で表現します。
180度のパノラマで画像を保存するため、回転は同じにしておきます(r1=r2=r3)。
以上の「空間キャッシュ」の処理は、素材を蓄えるための前処理になります。

空間補間

VRとして使用するシーンは、+Z方向を奥向きの真正面とします。
カメラの初期回転は(0, 0, 0)です。
素材としての「空間キャッシュ」を使い、 背景画像として球体に対してEquirectangularの180度パノラマを半球部分にUVマッピングします。

このとき、「空間キャッシュ」のカメラ位置にない大部分の背景画像はShaderを使用してリアルタイムに補間し再現します。
ただし、直線上に拘束する点に注意してください。
「空間キャッシュ」としてサンプリングしたカメラ位置をc1/c2/c3とします。

「空間キャッシュ」でのカメラ位置を変換

「空間キャッシュ」でサンプリングしたカメラ位置情報を、VRのシーン上の位置に変換します。

「空間キャッシュ」でのカメラのY回転の角度をr1とします。
VR空間上のはじめのカメラの位置をStPosとし、c1をStPosと同一位置にします。
c1/c2/c3を、c1を中心にしてY軸中心に-r1の角度分回転させます。
// Y軸中心に-r1度回転する行列.
Matrix4x4 rotYMat = Matrix4x4.Rotate(Quaternion.Euler(0.0f, -r1, 0.0f));

// カメラ位置を変換.
Vector3 basePos = c1;
c1 = rotYMat.MultiplyVector(c1 - basePos) + StPos;
c2 = rotYMat.MultiplyVector(c2 - basePos) + StPos;
c3 = rotYMat.MultiplyVector(c3 - basePos) + StPos;
この変換により、「空間キャッシュ」でサンプリングしたシーンでのカメラ位置と180度パノラマ-3D画像の向きが、
「空間補間」のシーン上のものに変換されます。

パノラマ画像は+Z方向が正面になります。

VR-HMDの位置に対応する、線分上の垂線位置を計算

VR空間上でのカメラ(VR-HMD)のワールド座標位置をPとします。
Pがc1-c2-c3の線分上から距離r内に存在するかを、Pから線分上に下した垂線距離で判定します。

Pがrの距離よりも大きい場合は背景更新をスキップします。
Pがrの距離内の場合、c1-c2、c2-c3のどちらに垂線の足(P')があるかを計算します。
P'がc1-c2の間にある場合は、c1とc2の位置での180度パノラマ-3D画像のRGBテクスチャとDepthテクスチャをShaderに渡します。

もし、c1からc2までを0.0から1.0の数値で表現した場合、P'でのブレンド値をBとします。
Bが0.0の場合はc1のパノラマ画像をそのまま採用、Bが1.0の場合はc2のパノラマ画像をそのまま採用します。
それ以外の場合(0.0 < B < 1.0)は、ピクセルごとに補間します。大部分は補間することになります。

depthテクスチャの前処理変換

Depthテクスチャは、荒い解像度のものを使用しています。


解像度を低くすることで、テクスチャリソースの削減と、VR-HMDの位置移動が行われた際のちらつきを抑えています。
また、ブラーをかけることで、移動での空間補間を安定させる効果があります。

この変換は、Depthテクスチャを読み込んだ際にテクスチャごとに前処理で行っておきます。
このとき、ピクセル間でアンチエイリアス処理をかけないようにします。
Depthテクスチャのピクセル値を逆算してビュー座標でのZ距離を求めて使用するため、
Depthのピクセル値を補間すると本来存在しない距離がでてきてしまうことになります。

ブレンド値により単純に合成した場合

c1からc2の間を0.0-1.0としたときのBの値により単純に合成した場合は、滑らかにはならずぶれたような表現になります。

計算は以下のようになります。
float4 col1 = tex2D(_Tex1, uv);     // カメラc1でのテクスチャ_Tex1での指定のUVでの色を取得.
float4 col2 = tex2D(_Tex2, uv);     // カメラc2でのテクスチャ_Tex2での指定のUVでの色を取得.
float4 col = lerp(col1, col2, B);   // ブレンド値B(0.0 - 1.0)により合成.
lerpは「col = col1 * (1.0 - B) + col2 * B;」の線形補間の計算になります。

奥行きを考慮して空間を補間

背景としてEquirectangularとして球にUVマッピングしたパノラマ画像を描画する際に、fragment Shaderで1ピクセルごとに走査することになります。
このときに、UV値よりShaderの180度パノラマの1方向を計算することができます。
float3 calcVDir (float2 _uv) {
  float theta = UNITY_PI2 * (_uv.x - 0.5);
  float phi   = UNITY_PI * (_uv.y - 0.5);
  float sinP = sin(phi);
  float cosP = cos(phi);
  float sinT = sin(theta);
  float cosT = cos(theta);
  float3 vDir = float3(cosP * sinT, sinP, cosP * cosT);
  return vDir;
}
UNITY_PIはπ(3.141592...)の定数値です。UNITY_PI2は(UNITY_PI * 2.0)です。
極座標から方向ベクトルを計算しています。

UVから計算された方向ベクトルvDirを使用してカメラc1とc2からレイを飛ばし、 Depthを参照してワールド座標での交差位置を計算します。
ここでの「レイを飛ばす」とはレイシューティングするわけではなく、
方向ベクトルから視線方向のUVを逆算し、その位置でのDepth値を取得する処理になります。

ワールド位置からUVを計算するには、以下の処理を行います。
前述の「UV値よりShaderの180度パノラマの1方向を計算」の操作の逆を行うことになります。
float2 calcWPosToUV (float3 wPos, float3 centerPos) {
  float3 vDir = normalize(wPos - centerPos);
  float sinP = vDir.y;
  float phi = asin(sinP);    // from -π/2 to +π/2 (-90 to +90 degrees).
  float cosP = cos(phi);
  if (abs(cosP) < 1e-5) cosP = 1e-5;
  float sinT = vDir.x / cosP;
  float cosT = vDir.z / cosP;
  sinT = max(sinT, -1.0);
  sinT = min(sinT,  1.0);
  cosT = max(cosT, -1.0);
  cosT = min(cosT,  1.0);
  float a_s = asin(sinT);
  float a_c = acos(cosT);
  float theta = (a_s >= 0.0) ? a_c : (UNITY_PI2 - a_c);
			
  float2 uv = float2((theta / UNITY_PI2) + 0.5, (phi / UNITY_PI) + 0.5);
  if (uv.x < 0.0) uv.x += 1.0;
  if (uv.x > 1.0) uv.x -= 1.0;
  return uv;
}
Depth値は、DepthテクスチャからUVを使用して取得することができます。
float depth1 = tex2D(_TexDepth1, uv).r;
float depth2 = tex2D(_TexDepth2, uv).r;
_TexDepth1はc1のカメラ位置でのDepthテクスチャです。
_TexDepth2はc2のカメラ位置でのDepthテクスチャです。
これは、RGBテクスチャから色を取得する場合と同じ手順です。ただし、Depth値取得の場合はピクセル間の線形補間をしないようにします。
Depthテクスチャでの1ピクセルの値は0.0-1.0の間の値が入っており、これを以下の式でビュー座標でのZ距離に変換します。
float zDist1 = (depth1 >= 0.99999) ? farPlane : (nearPlane / (1.0 - depth1));
float zDist2 = (depth2 >= 0.99999) ? farPlane : (nearPlane / (1.0 - depth2));
Z距離よりワールド座標位置を計算します。
float3 wPos1 = (vDir * zDist1) + c1;
float3 wPos2 = (vDir * zDist2) + c2;
UV値から、指定のカメラでのDepthを考慮したワールド座標での衝突位置を計算する関数を以下のようにまとめました。

/**
 * UV位置と方向ベクトルより、衝突するワールド座標位置を計算.
 * @param[in] depthTex  depthテクスチャ.
 * @param[in] uv        UV値.
 * @param[in] cPos      カメラのワールド座標での中心.
 * @param[in] vDir      視線ベクトル.
 */
float3 calcUVToWorldPos (sampler2D depthTex, float2 uv, float3 cPos, float3 vDir) {
  float depth = tex2D(depthTex, uv).r;
			
  // depth値から、カメラからの距離に変換.
  depth = (depth >= 0.99999) ? farPlane : (nearPlane / (1.0 - depth));
  depth = min(depth, farPlane);

  // 衝突したワールド座標位置.
  return (vDir * depth) + cPos;
}
wPos1からwPos2の間が一直線につながる場合(線形に遷移する場合)、Bの位置での交差位置は以下のように計算できます。
float3 wPosC = lerp(wPos1, wPos2, B);

このwPosCは、この段階では正しい値かどうかは不明です。
このときwPosCが正しいかの確認は、Bの位置からvDirの方向に延ばした線分上にwPosCが存在し、かつ、カメラc1またはc2から見えているかどうか、で判断します。
float3 wPosC0 = lerp(c1, c2, B);  // c1-c2直線上のBのワールド位置.

// ワールド座標でのwPosCを、c1のカメラでのUV値に変換.
float2 newUV1 = calcWPosToUV(wPosC, c1);

// ワールド座標でのwPosCを、c2のカメラでのUV値に変換.
float2 newUV2 = calcWPosToUV(wPosC, c2);

// UV値より、それぞれのワールド座標位置を計算.
float3 wPosA = calcUVToWorldPos(_TexDepth1, newUV1, c1, normalize(wPosC - c1));
float3 wPosB = calcUVToWorldPos(_TexDepth2, newUV2, c2, normalize(wPosC - c2));

float angle1 = dot(normalize(wPosA - wPosC0), vDir);
float angle2 = dot(normalize(wPosB - wPosC0), vDir);
ここで計算されたangle1とangle2が限りなく1.0に近い場合は、
wPosCがvDirの直線上にあり、c1またはc2のカメラ位置から見えている衝突である、ということになります。
つまり、ここで計算されたnewUV1とnewUV2のUV値からピクセル色を取得して採用できる、ということになります。


// c1でのRGBテクスチャを_Tex1、c2でのRGBテクスチャを_Tex2とする.
// 計算されたUVより、ピクセル色を取得.						
float4 col1 = tex2D(_Tex1, newUV1);
float4 col2 = tex2D(_Tex2, newUV2);
float4 col = float4(0, 0, 0, 1);
if (angle1 > 0.99999 && angle2 > 0.99999) {
  col = lerp(col1, col2, B);
}

wPos1-wPos2の間は直線状に遷移するとは限りません。
以下のように凸凹がある場合は、
Depthを考慮して計算されたwPosAとwPosBは、wPosC0からvDir方向に伸ばした直線上に存在しないというのを確認できます。


また、c1のカメラから見たwPosAのみwPosC0からvDir方向に伸ばした直線上に存在する、という場合(またはその逆)もあります。

この場合は、c1のカメラから見えているサンプリング情報はあるがc2のカメラからは見えていない、ということになります。
この場合は、c1のカメラでのRGBテクスチャからピクセル色を採用します。
// 計算されたUVより、ピクセル色を取得.						
float4 col1 = tex2D(_Tex1, newUV1);
float4 col2 = tex2D(_Tex2, newUV2);
float4 col = float4(0, 0, 0, 1);
if (angle1 > 0.99999 && angle2 <= 0.99999) {
  col = col1;
} else if (angle2 > 0.99999 && angle1 <= 0.99999) {
  col = col2;
}
まとめると、推測された交点位置wPosCが正しいかの判断は、以下の4パターンがあることになります。
以上は、c1のカメラからvDir方向の衝突をwPos1、c2のカメラからvDir方向の衝突をwPos2、としたときに、
wPos1からwPos2が線形に遷移する場合にうまくいくパターンになります。
ピクセルごとの空間補間の大部分は、この方法で正しく補間されたピクセル色を推測できます。
以下は、エラーとなる部分を赤色にしたものです。


ここでエラーになったピクセルは、補間をもう少し詰めます。
wPos1とwPos2を計算したときに使用したDepth値から計算されたZ距離で、 小さいほうの距離(minDepth)と大きいほうの距離(maxDepth)を元に、 wPosC0からvDir方向の直線上の位置を計算し、wPosC1、wPosC2とします。

このwPosC1とwPosC2が、c1またはc2のカメラから見て見えているかを計算します。
上画像の場合は、wPosCよりもwPosC2のほうが求める値に近いことになります。
wPosCで推測値が正しいかチェック、wPosC1で推測値が正しいかチェック、wPosC2で推測値が正しいかチェック、としたとき、
残りのエラーになる部分を赤色で表現すると、以下のようにエラーの箇所は減りました。


エラーの箇所は残り少ないため、計算されたUV値から得られる2つのピクセル色を使い、 合成値Bにより単純合成とした場合は以下のようになりました。

以上で、2つのカメラからのRGB/Depthテクスチャがあればある程度は空間補間できるのが確認できました。
「空間キャッシュ」でサンプリングするパノラマ画像のカメラ位置の距離が離れている場合(サンプリング数が足りない場合)には、エラーが増えることになります。

別案として、カメラがc1からc2に移動する場合のピクセルごとの遷移が線形でない場合は、荒いルックアップテーブル(テクスチャ)を前処理で作成して参照、という手段もとれそうです。
また、今回の説明では2つのカメラからの直線的な補間ですが、2つ以上のカメラからの補間により移動の自由度を上げることができるかもしれません。

ソースコード

GitHubの以下に、Unityのプロジェクトとして公開しています。
https://github.com/ft-lab/Unity_Panorama180Movement

素材作成時の使用アセット

このページでのキャプチャ画像は、Unity Asset Storeから以下のアセットを使用しています。
パノラマ画像のレンダリングシーンとして、"Asia-Pacific Common Residential Theme Pack"を使用しました。
180度パノラマ-3D画像の生成は"Panorama180 Render"(ver.1.0.2)を使用して出力しました。