OpenGL GLUT による立方パノラマ表示器

かって、 Apple QuickTime Cubic VR パノラマと知られていた、 360 度全方向パノラマがありました。 Google ストリート・ビューよりずっと前に、 全方向パノラマ表示をおこなうのに利用されていたのですが、 今はもう廃れてしまい、 QuickTime ファイル・フォーマットのドキュメントでも deprecate になっています。

この全方向パノラマの原理は立方体の 6 つの表面にパノラマ画像を投影し、 立方体の中心から回りを眺め回す仕掛けです。 パノラマ画像は、 それぞれの表面ごとに 6 枚の JPEG 画像に分けてある場合が多く、 QuickTime Cubic VR もパノラマ・ファイルに 6 枚の JPEG 画像を収納していました。 今ですと、 WebGL の cubemap が同じやりかたを採用しています。

立方パノラマの 6 枚の JPEG 画像が手元にあるとして、 それらを読み込んで OpenGL で立方体表面へ貼り付け、 中心に置いたカメラを回して眺め回すデスクトップ・アプリケーションを書いてみました。

Cubic Panorama Viewer from 6 JPEG faces by Ruby and OpenGL GLUT

このプログラムは 2 つの gem を使っています。 OpenGL と Image Magick のインクルード・ファイルと共用ライブラリをシステムへインストール済みとして、 gem コマンドで ruby ライブラリをインストールしておきます。

$ gem install opengl
$ gem install rmagick

6枚の画像に、 次の順に番号を降っておきます。 画像は、縦と横が同じピクセル数であり、 なおかつ、 縦と横が 2 のべき乗のピクセル数でなければならない縛りがあります。 QuickTime Cubic VR 用パノラマ画像の多くは、 この制約下で作ってあるはずなので、 パノラマ・ファイルの元にした 6 枚を使えるはずです。 パノラマ・ファイルから取り出した 6 枚でも良いでしょう。

${prefix}0.jpg  正面
${prefix}1.jpg  右面
${prefix}2.jpg  背面
${prefix}3.jpg  左面
${prefix}4.jpg  上面
${prefix}5.jpg  下面

画像の一例として、 私が 2002 年に横浜の汽車道で撮った円周魚眼画像から生成した、 古い立方パノラマの元画像をフォトライフへアップしておきました。 これを使ってスクリプトを試すには、 必ずリンク先の元の画像の大きさ (512px * 512px) で上の番号規則にしたがいファイルへセーブします。

r0.jpg:r0.jpg r1.jpg:r1.jpg r2.jpg:r2.jpg r3.jpg:r3.jpg r4.jpg:r4.jpg r5.jpg:r5.jpg

r0.jpg から r5.jpg までをセーブしたら、 次のコマンドで、 OpenGL GLUT のウィンドウが開きます。 終了は英小文字の q をタイプするかウィンドウのクローズ・ボックスをクリックします。

$ ruby cubpview.rb r

スクリプト中では画像とテクスチャ・マッピングの関係を次のようにしてあります。 JPEG ファイルをピクセル列に変換すると、 右上が原点の左手系 2 次元座標になります。 一方、 OpenGL の 3 次元座標は表示デバイス表面が X-Y 面になっていて、 表示デバイスから垂直に Z 軸が手前へ伸びる右手系座標になっています。 マウス操作等の便宜から、 OpenGL のモデル座標はそのままでテクスチャ貼り付け描画することにします。 このため、 テクスチャ・マッピングOpenGL 座標系で左上から始め、 左下、 右下、 右上の順に、 JPEG ファイルとの対応付けをおこなうようにします。

                             ^y       (0,0)--(1,0)
                             |/         | jpg  |
 (-+-)----(-++)           ---+--> x     |      |
   |  [4]   |               /|        (0,1)--(1,1)
   | 0,1,0  |              z
   |        |
 (++-)----(+++)----(-++)----(-+-)----(++-)     (vertex)
   |  [0]   |  [1]   |  [2]   |  [3]   |       [texture]
   | 1,0,0  | 0,0,1  | -1,0,0 | 0,0,-1 |       camera direction
   |        |        |        |        |
 (+--)----(+-+)----(--+)----(---)----(+--)
   |  [5]   |
   | 0,-1,0 |
   |        |
 (---)----(--+)

今回は、 ウィンドウの大きさを固定して、 fov の範囲計算をさぼっています。

require 'opengl'
require 'glu'
require 'glut'
require 'rmagick'

module Panorama
  class Cubic
    include Gl, Glu, Glut

    WINDOW_WIDTH = 480
    WINDOW_HEIGHT = 360

    Point = Struct.new(:x, :y)

#@< initialize を定義します@>
#@< run を定義します@>

private

#@< init_window を定義します@>
#@< 立方パノラマをテクスチャにします@>
#@< 画面を描画します@>
#@< mouse ドラッグでカメラの向きを変更します@>
#@< キータイプでカメラの向きを変更します@>
  end
end

if $0 == __FILE__
  Panorama::Cubic.new(ARGV.shift).run
end

初期化では、 カメラの向きを扱うインスタンス変数を初期化します。 カメラの向きは度を表す整数で扱うことにします。 point_start と angle_start の 2 つのインスタンス変数はマウス操作用です。

#@< initialize を定義します@>=
    def initialize(prefix)
      @prefix = prefix || ''
      @angle_step = 5 # degree
      @xztheta    = 0 # degree
      @yzphi      = 0 # degree
      @fov_step  = 10
      @fov      =  80
      @texture  = nil
      @point_start = Point.new(0, 0)
      @angle_start = Point.new(@xztheta, @yzphi)
    end

実行すると、 ウィンドウを初期化して、 6 枚の JPEG 画像を読み込みます。

#@< run を定義します@>=
    def run()
      init_window("Cubic Panorama - #{@prefix}", WINDOW_WIDTH, WINDOW_HEIGHT)
      init_cubic_panorama(@prefix)
      glutMainLoop()
    end

描画ウィンドウはダブル・バッファにしてチラつきを防止しています。 ウィンドウを初期化したら、 GLUT のイベント通知に再描画、 マウス・ドラッグ、 キー入力のそれぞれを登録します。

#@< init_window を定義します@>=
    def init_window(title, width, height)
      glutInit()
      glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB)
      glutInitWindowSize(width, height)
      glutCreateWindow(title)
      glutDisplayFunc(self.method(:handle_display).to_proc)
      glutMouseFunc(self.method(:handle_mouse).to_proc)
      glutMotionFunc(self.method(:handle_motion).to_proc)
      glutKeyboardFunc(self.method(:handle_keypress).to_proc)
    end

6 面の JPEG 画像を 6 つのテクスチャへ読み込んでおきます。

#@< 立方パノラマをテクスチャにします@>=
    def init_cubic_panorama(prefix)
      glEnable(GL_TEXTURE_2D)
      @texture = glGenTextures(6)
      load_surface("#{prefix}0.jpg", @texture[0]) # front
      load_surface("#{prefix}1.jpg", @texture[1]) # right
      load_surface("#{prefix}2.jpg", @texture[2]) # back
      load_surface("#{prefix}3.jpg", @texture[3]) # left
      load_surface("#{prefix}4.jpg", @texture[4]) # top
      load_surface("#{prefix}5.jpg", @texture[5]) # bottom
    end

#@< 面を JPEG ファイルから読み込んでテクスチャを作ります@>
#@< 面の JPEG ファイルがテクスチャの制約を満たすことをチェックします@>

それぞれの画像からテクスチャを作ります。 まず、 JPEG ファイルを rmagick で読み込んで、 JPEG からピクセルの並びに展開し、 テクスチャにします。

#@< 面を JPEG ファイルから読み込んでテクスチャを作ります@>=
    def load_surface(src, texture)
      img = Magick::Image.read(src).first
      check_image_size(src, img)
      blob = img.to_blob {|e| e.format = "RGB"; e.depth = 8 }
      glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
      glBindTexture(GL_TEXTURE_2D, texture)
      glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP)
      glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP)
      glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
      glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
      glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB,
        img.columns.to_i, img.rows.to_i, 0, GL_RGB, GL_UNSIGNED_BYTE, blob)
    end

OpenGL のテクスチャに貼り付ける画像では、 縦横サイズが 2 のべき乗になっていなければいけません。 なお、 この制約は QuickTime Cubic VR でも同じです。 この条件をチェックして違反しているときは例外を投げます。 2 のべき乗かどうかは 1 になっているビットが 1 個かどうかで判定します。 サイズが 2 のべき乗になっているとき、 1 になっているビットは 1 個に等しくなります。 1 になっているビットが 1 個のとき、 ワードのうちで 1 になっているビットのうち最下位をクリアして得られるワードがゼロに等しくなります。

#@< 面の JPEG ファイルがテクスチャの制約を満たすことをチェックします@>=
    def check_image_size(src, img)
      if img.columns.to_i != img.rows.to_i
        raise "#{src}: image is not square (#{img.columns}, #{img.rows})."
      end
      x = img.columns.to_i
      if x <= 32 or (x & (x - 1)) != 0  # x & (x - 1) は 1 になっている最下位ビットをクリアする
        raise "#{src}: image size is not power of 2 (#{img.columns})."
      end
      true
    end

まずは表示です。 座標変換行列を更新してから、 テクスチャ貼り付けをおこないます。 マウスやキーボードで操作した後の、 現在のカメラの向きは xz 面は @xztheta、 yz 面は @yzphi に入ります。 過去の経験から、 カメラの向きを任意方向の主軸で回転させるとかえって操作がやりにくいと感じたので、 y 軸を主軸とする球座標系を使っています。

#@< 画面を描画します@>=
    def handle_display()
      glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
      glMatrixMode(GL_PROJECTION)
      glLoadIdentity()
      gluPerspective(@fov, WINDOW_WIDTH.to_f / WINDOW_HEIGHT.to_f, 0.5, 100.0)
      glMatrixMode(GL_MODELVIEW)
      glLoadIdentity()
      r = Math.cos(@yzphi * Math::PI / 180.0)
      x = r * Math.cos(@xztheta * Math::PI / 180.0)
      z = r * Math.sin(@xztheta * Math::PI / 180.0)
      y = Math.sin(@yzphi * Math::PI / 180.0)
      gluLookAt(0.0, 0.0, 0.0,  x, y, z,  0, 1, 0)
      draw_cube_surfaces()
      glFlush()
      glutSwapBuffers()
    end

#@< draw_cube_surfaces を定義します@>
#@< draw_surface を定義します@>

立方体の全表面にテクスチャを貼り付けます。 JPEG と 立方体表面で左上→左下→右下→右上の順にテクスチャ・マッピングをします。

#@< draw_cube_surfaces を定義します@>=
    VERTEX = [
      [+1.0, +1.0, -1.0], [+1.0, -1.0, -1.0],
      [+1.0, +1.0, +1.0], [+1.0, -1.0, +1.0],
      [-1.0, +1.0, +1.0], [-1.0, -1.0, +1.0],
      [-1.0, +1.0, -1.0], [-1.0, -1.0, -1.0]
    ]

    def draw_cube_surfaces()
      glEnable(GL_TEXTURE_2D)
      draw_surface(@texture[0], VERTEX[0], VERTEX[1], VERTEX[3], VERTEX[2])
      draw_surface(@texture[1], VERTEX[2], VERTEX[3], VERTEX[5], VERTEX[4])
      draw_surface(@texture[2], VERTEX[4], VERTEX[5], VERTEX[7], VERTEX[6])
      draw_surface(@texture[3], VERTEX[6], VERTEX[7], VERTEX[1], VERTEX[0])
      draw_surface(@texture[4], VERTEX[6], VERTEX[0], VERTEX[2], VERTEX[4])
      draw_surface(@texture[5], VERTEX[1], VERTEX[7], VERTEX[5], VERTEX[3])
    end

立方体の一つの面にテクスチャを貼り付けます。 テクスチャの左上→左下→右下→右は、 JPEG の座標をゼロから 1.0 へ規格化して、 原点が左上、 y 軸が下向きの左手系の 2 次元座標になっています。

#@< draw_surface を定義します@>=
    def draw_surface(texture, left_top, left_bottom, right_bottom, right_top)
      glBindTexture(GL_TEXTURE_2D, texture)
      glBegin(GL_POLYGON)
      glTexCoord2f(0.0, 0.0)
      glVertex3fv(left_top)
      glTexCoord2f(0.0, 1.0)
      glVertex3fv(left_bottom)
      glTexCoord2f(1.0, 1.0)
      glVertex3fv(right_bottom)
      glTexCoord2f(1.0, 0.0)
      glVertex3fv(right_top)
      glEnd()
    end

カメラは原点にあり、 当初は 3 次元の x 軸に向いています。 マウス・ドラッグすると、 カメラの向きが回転していきます。 カメラの回転は 3 次元の y 軸を主軸とする球座標系上でおこないます。 主軸を任意の向きにすることも試してみたのですが、 使いにくく感じたので、 主軸の 1 本を固定することにしました。 マウスのボタン押下イベントで現在のマウスの画面座標とカメラの向きを記録しておきます。 その後、 マウス移動イベントを追跡して、 カメラの向きを更新しては再描画を繰り返します。 y-z 面の向き yzphi は、 -90 度から 90 度の間に限定しています。

#@< mouse ドラッグでカメラの向きを変更します@>=
    def handle_mouse(button, state, x, y)
      if state == 0
        @point_start.x, @point_start.y = x, y
        @angle_start.x, @angle_start.y = @xztheta, @yzphi
      end
    end

    def handle_motion(x, y)
      fac = 0.2
      @xztheta = ((@angle_start.x + (x - @point_start.x) * fac) % 360).to_i
      phi = (@angle_start.y + (@point_start.y - y) * fac).to_i
      if -90 <= phi and phi <= 90
        @yzphi = phi
      end
      glutPostRedisplay()
    end

キータイプでもカメラの向きを変更できるようにしています。 キー割り当ては vi エディタ風にしてあり、 h で左へ、 j で下へ、 k で上へ、 l で右へ動かします。 他に、 u はカメラの向きを初期の向きへ戻します。 他に、 i でズームイン、 o でズームアウト、 q でアプリケーションを終了します。

#@< キータイプでカメラの向きを変更します@>=
    def handle_keypress(key, x, y)
      case key
      when ?h
        rotate_horizontal(-@angle_step)
      when ?l
        rotate_horizontal(@angle_step)
      when ?k
        rotate_vertical(@angle_step)
      when ?j
        rotate_vertical(-@angle_step)
      when ?u
        reset_angle()
      when ?i
        zoom(-@fov_step)
      when ?o
        zoom(@fov_step)
      when ?q
        exit(0)
      end
    end

#@< カメラを水平方向に回転します@>
#@< カメラを垂直方向に回転します@>
#@< カメラを初期の向きへ戻します@>
#@< カメラのズームを変更します@>

水平方向にカメラの向きを 5 度刻みで動かします。

#@< カメラを水平方向に回転します@>=
    def rotate_horizontal(theta_step)
      @xztheta = (@xztheta + theta_step) % 360
      glutPostRedisplay()
    end

垂直方向にカメラを、 -90 度から 90 度の範囲で 5 度 刻みで動かします。

#@< カメラを垂直方向に回転します@>=
    def rotate_vertical(phi_step)
      if -90 <= @yzphi + phi_step and @yzphi + phi_step <= +90
        @yzphi += phi_step
        glutPostRedisplay()
      end
    end

カメラを初期の向きへ戻します。 初期の向きは x 軸方向です。

#@< カメラを初期の向きへ戻します@>=
    def reset_angle()
      @xztheta = @yzphi = 0
      glutPostRedisplay()
    end

カメラのズームを変更します。 大きくしすぎると、 テクスチャの貼り付け範囲外に出てしまい、 ウィンドウに黒塗り領域が生じてしまいます。 ウィンドウの縦横比率から範囲の上限を計算することもできるはずですが、 今回は手抜きして 80 に固定しています。

#@< カメラのズームを変更します@>=
    def zoom(fov_step)
      if 40 <= @fov + fov_step and @fov + fov_step <= 80
        @fov += fov_step
        glutPostRedisplay()
      end
    end