«矩形の描き方 最新 SSAO ベースの SSRO 付きライブ放射照度マッピン..»

床井研究室


■ 2016年12月03日 [OpenGL][GLSL] 魚眼レンズ画像の平面展開のサンプルプログラム

2016年12月10日 20:44更新

みんな頑張っている(のか?)

うちの研究室の学生さんたちがとあるコンテストに応募して審査員特別賞をもらったらしいのだけど、入賞6チームのうち3チームがうちの大学 (というか主にうちの学科) だったこともあり、これがすごいのかすごくないのかよくわかんない状況のようです。彼らは優勝チームにはさすがに敵わないと思ったことと、同じ大学の他のチームに負けたことで、嬉しいのか嬉しくないのかよくわからないみたいなことを言ってました。その割には記念写真でいつものようにフザけたことをしてる (どうしてそこでやった) ところを見ると、実は嬉しかったんだろ?とりあえずおめでとう。

魚眼レンズ画像の平面展開のサンプルプログラム

前に魚眼レンズ画像の平面展開矩形の描き方について解説を書いたわけですけど、手法自体は教科書に書いてある話です。でも、そのうち学生さんや私自身がやってることでも必要になるので、改めてサンプルプログラムを書きました。「お父さん犬」と「偽ポールマッカートニー」は一応見といてください。ちなみに Y くんの本名について、この前のゼミの時に「何かのキャラクタにいたよね」みたいに言いましたけど、「お父さん犬」の本名だったことにさっき気づきました。

描画する図形

クリッピング空間全体を埋めるポリゴンを描きます。これには矩形の描き方の最後で解説した glDrawArrayInstancedI() を使った手法を用います。この手法では VBO を用いないので、VAO を作るだけです。

  // 図形を作成する
  //   頂点座標値を vertex shader で生成するので VBO は必要ない
  const GLuint shape([]() { GLuint shape; glGenVertexArrays(1, &shape); return shape; } ());

ここで vao を const にする必要はあんまりないんですけどね。気分の問題です気分。あと、魚眼レンズ画像のサンプリングに使うテクスチャ座標の算出は、前の魚眼レンズ画像の平面展開ではフラグメントシェーダで画素ごとに行っていたのですけど、聞きかじりめもさまに画素ごとにやると「OpenCV の方が圧倒的に速いじゃん」とご指摘を受けましたので、今回はバーテックスシェーダで生成することにします。実は私、以前はフラグメントシェーダでやってもそんなに遅くないって言ってたんですけど、それはやっぱりそれなりに重いんだなって実感することがありまして、試しにテクスチャ座標をそこそこ粗く生成してラスタライザで補間してみたら、レンズの歪み補正みたいななだらかな変化なら全然問題にならないって改めて思いまして、今回宗旨替えしました。ごめんなさい。

VBO を使わなければ、頂点属性の数や並び方を動的に変更することが容易になります。そこで、テクスチャ座標を計算する格子点の数、すなわち頂点属性の数だけをあらかじめ決めておき、ウィンドウのアスペクト比に合わせて縦横の数を決めるようにします。

ウィンドウ上の格子

こうすると、ウィンドウをリサイズしても、ウィンドウ内の格子の縦横はほぼ等間隔に保たれます。slices は横の格子点数、stacks は縦の格子点数ですが、stacks は描画する GL_TRIANGLE_STRIP の数になるので 1 を引いています。

    // スクリーンの矩形の格子点数
    //   標本点の数 (頂点数) n = x * y とするとき、これにアスペクト比 a = x / y をかければ、
    //   a * n = x * x となるから x = sqrt(a * n), y = n / x; で求められる。
    //   この方法は頂点属性を持っていないので実行中に標本点の数やアスペクト比の変更が容易。
    const GLsizei slices(static_cast(sqrt(window.getAspect() * screen_samples)));
    const GLsizei stacks(screen_samples / slices - 1); // 描画するインスタンスの数なので先に 1 を引いておく。

一方、クリッピング空間全面に描く格子の間隔は、この逆数になります。これを gap という uniform 変数でバーテックスシェーダに渡します。

クリッピング空間に描く格子
    // スクリーンの格子間隔
    //   クリッピング空間全体を埋める四角形は [-1, 1] の範囲すなわち縦横 2 の大きさだから、
    //   それを縦横の (格子数 - 1) で割って格子の間隔を求める。
    glUniform2f(gapLoc, 2.0f / (slices - 1), 2.0f / stacks);

投影面の設定

uniform 変数 screen に魚眼レンズ画像を展開する投影面を設定します。left, right, bottom, top は、glFrustum() における前方面の領域に相当します。

投影面の設定
    // スクリーンのサイズと中心位置
    //   screen[0] = (right - left) / 2
    //   screen[1] = (top - bottom) / 2
    //   screen[2] = (right + left) / 2
    //   screen[3] = (top + bottom) / 2
    const GLfloat screen[] = { window.getAspect(), 1.0f, 0.0f, 0.0f };
    glUniform4fv(screenLoc, 1, screen);

uniform 変数 focal には視点の投影面からの距離を設定します。これも glFrustum() の near に相当しますが、前方面・後方面によるクリッピングや院面消去処理を行っているわけではありません。なお focal が小さくなるほど画面の変化が急激になりますので、ここでは focal が大きいほどマウスホイールの動きに対して focal が大きく変化するようにしています。

    // スクリーンまでの焦点距離
    //   window.getWheel() は [-100, 49] の範囲を返す。
    //   したがって焦点距離 focal は [1 / 3, 1] の範囲になる。
    //   これは焦点距離が長くなるにしたがって変化が大きくなる。
    glUniform1f(focalLoc, -50.0f / (window.getWheel() - 50.0f));

魚眼レンズ画像の設定

魚眼レンズには等距離射影方式のものを想定しています。これによる円形の投影像が下図のように画像の中心にあり、投影像の直径が画像の高さと一致しているものとします。uniform 変数 circle の t 要素 circle.t には、投影像の最外周における中心からの画角 (魚眼レンズの画角) fov をラジアンで設定します。また、魚眼レンズの画角が縦方向と横方向で等しければ、circle.s = circle.t = fov です。

画像上の魚眼レンズの投影像

uniform 変数 circle の p 要素 circle.p と q 要素 circle.q 要素には、魚眼レンズの投影像の中心の画像の中心からの位置を、画像の高さの範囲を [-1, 1] として設定してください。魚眼レンズの投影像が画像の中心にあれば、これらは 0 です。

魚眼レンズの投影像の中心位置

また、魚眼レンズの投影像の直径が画像の高さと一致していなければ、投影像の中心を画像の中心と一致させたうえで、画像の上下端の位置における角度を、その魚眼レンズの画角に設定してください。

投影像の高さが画像の高さと一致していないとき

図形の描画

あとは視線の回転行列を設定し、テクスチャと VAO を結合してから、glDrawArraysInstanced() で GL_TRIANGLE_STRIP を描きます。一つの GL_TRIANGLE_STRIP の頂点数は slices × 2、インスタンス数は (slices がすでに 1 減じてあるとして) slices です。

    // 視線の回転行列
    glUniformMatrix4fv(rotationLoc, 1, GL_FALSE, window.getLeftTrackball().get());
 
    // キャプチャした画像をテクスチャに転送する
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, image);
    camera.transmit();
 
    // テクスチャユニットを指定する
    glUniform1i(imageLoc, 0);
 
    // 図形を描画する
    glBindVertexArray(shape);
    glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, slices * 2, stacks);

魚眼レンズのバーテックスシェーダ

バーテックスシェーダではシェーダの組み込み変数 gl_VertexID と gl_InstanceID をもとにクリッピング空間における頂点位置を求めます。

#version 150 core
 
//
// 魚眼レンズ画像の平面展開
//
 
// スクリーンの格子間隔
uniform vec2 gap;
 
// スクリーンの大きさと中心位置
uniform vec4 screen;
 
// スクリーンまでの焦点距離
uniform float focal;
 
// スクリーンを回転する変換行列
uniform mat4 rotation;
 
// 背景テクスチャの半径と中心位置
uniform vec4 circle;
 
// 背景テクスチャ
uniform sampler2D image;
 
// 背景テクスチャのサイズ
vec2 size = textureSize(image, 0);
 
// 背景テクスチャのテクスチャ空間上のスケール
vec2 scale = vec2(0.5 * size.y / size.x, -0.5) / circle.st;
 
// 背景テクスチャのテクスチャ空間上の中心位置
vec2 center = circle.pq + 0.5;
 
// テクスチャ座標
out vec2 texcoord;
 
void main(void)
{
  // 頂点位置
  //   各頂点において gl_VertexID が 0, 1, 2, 3, ... のように割り当てられるから、
  //     x = gl_VertexID >> 1      = 0, 0, 1, 1, 2, 2, 3, 3, ...
  //     y = 1 - (gl_VertexID & 1) = 1, 0, 1, 0, 1, 0, 1, 0, ...
  //   のように GL_TRIANGLE_STRIP 向けの頂点座標値が得られる。
  //   y に gl_InstaceID を足せば glDrawArrayInstanced() のインスタンスごとに y が変化する。
  //   これに格子の間隔 gap をかけて 1 を引けば縦横 [-1, 1] の範囲の点群 position が得られる。
  int x = gl_VertexID >> 1;
  int y = gl_InstanceID + 1 - (gl_VertexID & 1);
  vec2 position = vec2(x, y) * gap - 1.0;

これをそのままラスタライザに送れば、ビューポート全面を埋めるポリゴンを描きます。

  // 頂点位置をそのままラスタライザに送ればクリッピング空間全面に描く
  gl_Position = vec4(position, 0.0, 1.0);

一方、position にスクリーンの大きさ screen.st をかけて中心位置 screen.pq を足せば、スクリーン上の点の位置 p が得られます。視線は視点からこの点に向かうベクトルですから、視点が原点にあれば、これは (p, -focal) になります。これに視線の回転行列 rotation をかけて正規化して、視線の単位ベクトル vector を求めます。

  // 視線ベクトル
  //   position にスクリーンの大きさ screen.st をかけて中心位置 screen.pq を足せば、
  //   スクリーン上の点の位置 p が得られるから、原点にある視点からこの点に向かう視線は、
  //   焦点距離 focal を Z 座標に用いて (p, -focal) となる。
  //   これを回転したあと正規化して、その方向の視線単位ベクトルを得る。
  vec2 p = position * screen.st + screen.pq;
  vec4 vector = normalize(rotation * vec4(p, -focal, 0.0));

この視線ベクトルからテクスチャ座標を求めます。これについては前々回の魚眼レンズ画像の平面展開を参照してください。

  // テクスチャ座標
  texcoord = acos(-vector.z) * normalize(vector.xy) * scale + center;
}

RICOH THETA S のバーテックスシェーダ

RICOH THETA S のバーテックスシェーダでは一本の視線ベクトルから二つのテクスチャ座標値を求めます。

#version 150 core
 
//
// RICOH THETA S のライブストリーミング映像の平面展開
//
 
// スクリーンの格子間隔
uniform vec2 gap;
 
// スクリーンの大きさと中心位置
uniform vec4 screen;
 
// スクリーンまでの焦点距離
uniform float focal;
 
// スクリーンを回転する変換行列
uniform mat4 rotation;
 
// 背景テクスチャの半径と中心位置
uniform vec4 circle;
 
// 背景テクスチャ
uniform sampler2D image;
 
// 背景テクスチャのサイズ
vec2 size = textureSize(image, 0);
 
// 背景テクスチャの後方カメラ像のテクスチャ空間上の半径と中心
vec2 radius_b = circle.st * vec2(-0.25, 0.25 * size.x / size.y);
vec2 center_b = vec2(radius_b.s - circle.p + 0.5, radius_b.t - circle.q);
 
// 背景テクスチャの前方カメラ像のテクスチャ空間上の半径と中心
vec2 radius_f = vec2(-radius_b.s, radius_b.t);
vec2 center_f = vec2(center_b.s + 0.5, center_b.t);
 
// テクスチャ座標
out vec2 texcoord_b;
out vec2 texcoord_f;
 
// 前後のテクスチャの混合比
out float blend;
 
void main(void)
{
  // 頂点位置
  //   各頂点において gl_VertexID が 0, 1, 2, 3, ... のように割り当てられるから、
  //     x = gl_VertexID >> 1      = 0, 0, 1, 1, 2, 2, 3, 3, ...
  //     y = 1 - (gl_VertexID & 1) = 1, 0, 1, 0, 1, 0, 1, 0, ...
  //   のように GL_TRIANGLE_STRIP 向けの頂点座標値が得られる。
  //   y に gl_InstaceID を足せば glDrawArrayInstanced() のインスタンスごとに y が変化する。
  //   これに格子の間隔 gap をかけて 1 を引けば縦横 [-1, 1] の範囲の点群 position が得られる。
  int x = gl_VertexID >> 1;
  int y = gl_InstanceID + 1 - (gl_VertexID & 1);
  vec2 position = vec2(x, y) * gap - 1.0;
 
  // 頂点位置をそのままラスタライザに送ればクリッピング空間全面に描く
  gl_Position = vec4(position, 0.0, 1.0);
 
  // 視線ベクトル
  //   position にスクリーンの大きさ screen.st をかけて中心位置 screen.pq を足せば、
  //   スクリーン上の点の位置 p が得られるから、原点にある視点からこの点に向かう視線は、
  //   焦点距離 focal を Z 座標に用いて (p, -focal) となる。
  //   これを回転したあと正規化して、その方向の視線単位ベクトルを得る。
  vec2 p = position * screen.st + screen.pq;
  vec4 vector = normalize(rotation * vec4(p, -focal, 0.0));

ここまでは (定数の計算を除き) 単眼の魚眼レンズと同じです。ここから RICOH THETA S 独特の処理になります。まず、このレンズの画角は表裏とも 180°であるとして、視線ベクトルの Z 軸に対する角度を求めます。これは [π, 0] になりますから、これを2倍してπで割ったものを1から引いて [-1, 1] の範囲に直します。

  // この方向ベクトルの相対的な仰角
  //   1 - acos(vector.z) * 2 / π → [-1, 1]
  float angle = 1.0 - acos(vector.z) * 0.63661977;
これを使って前後のテクスチャの混合比を求めます。これには組み込み関数 smoothstep() を使うと便利です。この edge0 を -0.02 程度、edge1 を 0.02 程度にしておけば、angle が負のところでは 0、正のところでは 1 になり、それらが 0 付近で三次 Hermite 曲線によりなだらかに結ばれます。
  // 前後のテクスチャの混合比
  blend = smoothstep(-0.02, 0.02, angle);

THETA は縦に使うと画像の中心に上方向が写るので、座標の x 値と y 軸を入れ替えてから視線ベクトルを求めます。

  // この方向ベクトルの yx 上での方向ベクトル
  vec2 orientation = normalize(vector.yx) * 0.885;

あとは背景テクスチャにおける前後のテクスチャ座標を求めて、ラスタライザに送ります。

  // テクスチャ座標
  texcoord_b = (1.0 - angle) * orientation * radius_b + center_b;
  texcoord_f = (1.0 + angle) * orientation * radius_f + center_f;
}

そしてフラグメントシェーダでは、テクスチャの2か所をサンプリングしてブレンドします。

#version 150 core
#extension GL_ARB_explicit_attrib_location : enable
 
//
//   RICOH THETA S のライブストリーミング映像の平面展開
//
 
// 背景テクスチャ
uniform sampler2D image;
 
// テクスチャ座標
in vec2 texcoord_b;
in vec2 texcoord_f;
 
// 前後のテクスチャの混合比
in float blend;
 
// フラグメントの色
layout (location = 0) out vec4 fc;
 
void main(void)
{
  // 前後のテクスチャの色をサンプリングする
  vec4 color_b = texture(image, texcoord_b);
  vec4 color_f = texture(image, texcoord_f);
 
  // サンプリングした色をブレンドしてフラグメントの色を求める
  fc = mix(color_f, color_b, blend);
}

編集 «矩形の描き方 最新 SSAO ベースの SSRO 付きライブ放射照度マッピン..»