2020年12月30日水曜日

Unityで自前のステレオカメラ画像をHMDに表示する方法

両目用のMR/AR用のビデオ透過型HMD(Vive Proなど)に自前で準備したステレオカメラの画像を正しく表示する方法のメモ. 本来難しい事は何もないはずだけど,Unityの機能のブラックボックス部分を正しく扱うことが一番難しかった.

動作環境

  • Unity 2018.4.27.f1
  • Cg/HLSL(d3dcompiler_47)
  • Vive Pro Eye

ステレオカメラ画像をHMDに表示する方法

カメラの前に視界を覆う大きなオブジェクトを置いて,そのオブジェクトにステレオカメラの画像をテクスチャとして貼り付ける. オブジェクトはどんな形でも問題無い.無難には平面でも,非線形のレンズ歪みがきつい場合は,十分な数のポリゴンに分割されていないといけない. ポリゴンの頂点と画像の画素とが対応付けられればいい.この対応付けを求めるために,シェーダ上では,頂点座標はそのままUnityにおまかせして,テクスチャ座標の計算だけ自前で行う. 頂点座標からテクスチャ座標への変換は,オブジェクト(モデル)座標→シーンカメラ(ビュー)座標→ステレオカメラ座標→画像座標(歪み有り)→テクスチャ座標の順に変換する.

  1. オブジェクト座標→シーンカメラ座標:UnityObjectToViewPos関数におまかせする
  2. シーンカメラ座標→ステレオカメラ座標:OpenCVのstereoRectify関数で求めた回転行列の逆行列を掛ける
  3. ステレオカメラ座標→画像座標:OpenCVのCalibrateCamera関数で求めた内部パラメータで自前で画像座標に変換する
  4. 画像座標→テクスチャ座標:Unityのテクスチャ座標に合わせする2次元の並進,スケール変換をする

Unityのステレオカメラは標準ステレオを過程してるけど,標準ステレオじゃなければ,stereoRectify関数で求めた回転行列を掛ける前に,一旦標準ステレオに変換する行列をかければいいだけ.

画像とカメラパラメータの受け渡し

C#側

画像をテクスチャとして設定する.UnityのエディタからGUIで設定してもいい. バイト列の先頭が画像の左上になる.左下が原点.

byte[] readBinary = File.ReadAllBytes("texture.jpg");
texture = new Texture2D(w, h * 2, TextureFormat.RGB24, false);
texture.LoadImage(readBinary);
texture.Apply();
GetComponent<Renderer>().material.mainTexture = texture;

カメラパラメータもSetFloatとSetMatrixで送るだけ.SetMatrixで送られた結果,シェーダ側でどのようなデータで格納されるのかよく分からなければ,行列の要素全部バラバラのfloatで送ればいい.

var material = this.GetComponent<Renderer>().material;
material.SetFloat("_fx_l", fx_l);
material.SetFloat("_fy_l", fy_l);
   :
Vector4 col1 = new Vector4(mat[0][0], mat[1][0], mat[2][0]);
Vector4 col2 = new Vector4(mat[0][1], mat[1][1], mat[2][1]);
Vector4 col3 = new Vector4(mat[0][2], mat[1][2], mat[2][2]);
Vector4 col4 = new Vector4(0.0f, 0.0f, 0.0f, 1.0f);
Matrix4x4 m_vs = new Matrix4x4(col1, col2, col3, col4);
material.SetMatrix("_m_vs_l", m_hc);
   :

シェーダ側パラメータ

これらは,C#から送った両眼用のカメラパラメータをシェーダ側で受け取るための変数. 「_l」,「_r」は左目,右目用という意味.「_vs」は「view座標系→stereoカメラ座標系」の意味.

int _w, _h;
float4x4 _m_vs_l, _m_vs_r;
float _fx_l, _fy_l, _cx_l, _cy_l, _k1_l, _k2_l, _k3_l, _p1_l, _p2_l;
float _fx_r, _fy_r, _cx_r, _cy_r, _k1_r, _k2_r, _k3_r, _p1_r, _p2_r;

上の変数だけでも十分だけど,扱うづらいので各眼のカメラパラメータを全部一つの変数にまとめるための構造体を準備する. w,hは画像の解像度.今回試したのは各カメラの1200$\times$1200画素の画像.両眼で上下に結合させて1200$\times$2400画素の画像をテクスチャとして登録した. ただし,ここでは解像度依存の箇所は無い.「画像とカメラパラメータの受け渡し」で_wと_hに横,縦の解像度が設定されていればいい. この記事内で画像(texture.jpg)内で左右眼用の画像の配置が関係するのは,「フラグメントシェーダでテクスチャマッピング」だけ.

struct CameraParameters{
    int w, h;
    float fx, fy, cx, cy, k1, k2, k3, p1, p2;
    float4x4 m_vs;
};

透視投影+レンズ歪み補正の関数

この関数はUnityのオブジェクトの各頂点の座標(左手系)を画像座標に変換する. 画像座標は左上が原点で水平右方向にx軸正方向,下方向にy軸正方向とイメージしていて問題ない. return直前で解像度で画像の解像度で割り算して0から1の無次元に変換している. 難しいのはコメントに書いているとおり,オブジェクト座標系,Unityのカメラ座標系,OpenCVのカメラ座標系を正しく理解して,正しく変換すること 正しい理解のためにマニュアルやブログを読んでも,確信を持てなかったので自分で全部確認した. 座標系の確認方法は,「ややこしいUnityの座標系の確認方法」を参照して欲しい.

float2 VertexToTexture(float4 vertex, CameraParameters p)
{			
    //v.vertexは左手系(Unityエディタと同じ)

    //オブジェクト座標(左手) -> Unityカメラ座標(右手座標:v,z軸後ろ,y軸上)
    float4 xyz1_v = float4(UnityObjectToViewPos(vertex), 1);
    //OpenCVでのカメラ(右手系,z軸後,y軸上)
    xyz1_v = float4(xyz1_v.x, -xyz1_v.y, -xyz1_v.z, 1);

    //Unityカメラ座標 (右手座標:v) -> Stereoカメラ座標 (右手座標:s)
    float4 xyz1_s = mul(p.m_vs, xyz1_v);
    float2 xy = xyz1_s.xy / xyz1_s.z;

    //レンズ歪み補正()
    float xp = xy.x;
    float yp = xy.y;
    float r2 = xp*xp + yp*yp;
    float r4 = r2 * r2;
    float r6 = r4 * r2;
    float xpp = xp*(1 + p.k1*r2 + p.k2*r4 + p.k3*r6) + 2*p.p1*xp*yp + p.p2*(r2 + 2*xp*xp);
    float ypp = yp*(1 + p.k1*r2 + p.k2*r4 + p.k3*r6) + p.p1*(r2 + 2*yp*yp) + 2*p.p2*xp*yp;

    //画像座標をテクスチャに変換
    float u = (p.fx * xpp + p.cx)/p.w;
    float v = (p.fy * ypp + p.cy)/p.h;
    return float2(u, v);
}

float4 xyz1_s = mul(p.m_vs, xyz1_v);の部分が一番のポイント.ここで回転変換してる.

バーテクスシェーダで各眼へ透視投影

両眼で異なる変換にするための場合分け. 上記の関数の引数に設定する変数をunity_StereoEyeIndexで分ける.

v2f vert (appdata v)
{
    v2f o;

    //仮想カメラの投影位置は変更なし
    o.vertex = UnityObjectToClipPos(v.vertex);

    //テクスチャマッピングのため
    //実カメラ画像への投影位置を計算する
    //左眼
    if (unity_StereoEyeIndex == 0) {
        CameraParameters p = {_w, _h, _fx_l, _fy_l, _cx_l, _cy_l, _k1_l, _k2_l, _k3_l, _p1_l, _p2_l, _m_vs_l};
    }
    //右眼
    else {
        CameraParameters p = {_w, _h, _fx_r, _fy_r, _cx_r, _cy_r, _k1_r, _k2_r, _k3_r, _p1_r, _p2_r, _m_vs_r};
    }
    o.uv = VertexToTexture(v.vertex, p);
    return o;
}

フラグメントシェーダでテクスチャマッピング

両眼カメラの画像をテクスチャとしてどのように1枚の画像に配置したかが関係してくるのは,多分ここだけ. テクスチャ座標は左下が原点なので,上下反転させた画像(texture.jpg)をテクスチャにしている. 同じ意味のコードはバーテスクシェーダ側に書いても問題ない.

fixed4 frag (v2f i) : SV_Target
{
    float2 uv;
    float4 color;
    //左目
    if (unity_StereoEyeIndex == 0) {
        uv.x = i.uv.x;
        uv.y = i.uv.y*0.5;
        if (uv.x < 0 || 1 < uv.x || uv.y < 0 || 0.5 < uv.y) 
            color = fixed4(0.0f, 0.0f, 0.0f, 0.0f);
        else
            color = tex2D(_MainTex, uv);
    }
    //右目
    else {
        uv.x = i.uv.x;
        uv.y = i.uv.y*0.5 + 0.5;
        if (uv.x < 0 || 1 < uv.x || uv.y < 0.5 || 1.0 < uv.y) 
            color = fixed4(0.0f, 0.0f, 0.0f, 0.0f);
        else
            color = tex2D(_MainTex, uv);					
    }
    return color;
}

参考