射影変換を考える

自分的メモ。
3D計算における射影変換は、単純に言えば奥のものを真ん中に寄せる計算である。奥のものを真ん中に寄せるためには座標をzで割ることになるが、それで真ん中に寄るということは、画面のど真ん中が座標系の原点(0,0)になっていないといけない。この原点への移動がビュー変換である。
といってもよくわからないのでとりあえず画像をひとつ。

これは射影変換の説明に必ず出てくる図である。xyzが交差する点が原点(0,0,0)で、右が+x、上が+y、奥が+zとなる。ちょっと傾けてある。右上にある台形がDirectX的には視錐台と呼ばれるエリアで、この範囲を直方体に変換して2D投影するわけだ。

この図で言うところの原点はユーザーの視点となる。台形の小さいほう(原点に近いほう)の面はスクリーンの面で、この面の中心をz軸が通過する。台形の小さいほうの面までのz軸上の距離はznと呼ばれる。znより近い物体はスクリーンより手前という扱いになるのでクリップされて描画されない。大きいほうの面はzfと呼ばれて、これより遠い物体は遠すぎるのでクリップされて描画されない。台形の上下左右にはみ出すものは画面外なのでクリップされて描画されない。
台形の近いほうの面のサイズは、この世界をどのサイズに描画するかを表していて、これを小さくすると世界の物体は大きく描画されるし、大きくすると世界全体を見渡せるような描画になる。サイズは射影変換行列を作るときに指定することになる。大きいほうの面はzfの値によってどこまでも大きくなるので遠距離の物体をどこまで描画するかという描画性能とご相談てな感じの話になる。

原点とznの距離が近いとユーザーが画面に顔を近づけたようなイメージで世界を広く(視野角を広く)描画することができるが、現実としてユーザーの視点と画面との距離はプログラムで制御できないのでznを小さくしすぎると広角レンズを極端にしたような変な描画になる。適性な距離はユーザーが使うディスプレイやPCを使う環境によって変わってくるため原理的に難しく、経験的に視野角60度ぐらいだとか70度ぐらいだとかそんな話があったような気がする。
結局のところ、画面の視野角はznと近いほうの面のサイズから算出される。znを小さくすると広角になるし、面のサイズを大きくすると広角になる。2つのパラメータの兼ね合いである。逆に視野角とzn、画面のアスペクト比があれば近いほうの面の縦横サイズを算出することもできる。どちらかのパラメータがあれば射影変換行列を生成することができる。

図を作るのに使ったコードは以下に。

require 'dxruby'

class Line
  def initialize(ps, pe)
    @ps = ps
    @pe = pe
  end

  def draw
    Window.draw_line(@ps.x, @ps.y, @pe.x, @pe.y, C_WHITE)
  end
end

p1 = Vector.new(-320.0, 240.0, 0, 1)
p2 = p1 * Vector.new(-1, 1, 1, 1)
p3 = p1 * Vector.new(-1, -1, 1, 1)
p4 = p1 * Vector.new(1, -1, 1, 1)

points = [p1, p2, p3, p4]

p1 = Vector.new(-320.0 / 1000 * 1600, 240.0 / 1000 * 1600, 600, 1)
p2 = p1 * Vector.new(-1, 1, 1, 1)
p3 = p1 * Vector.new(-1, -1, 1, 1)
p4 = p1 * Vector.new(1, -1, 1, 1)

points << p1 << p2 << p3 << p4

p1 = Vector.new(-1000.0, 0.0, -500, 1)
p2 = Vector.new(1000.0, 0.0, -500, 1)
p3 = Vector.new(0.0, -700.0, -500, 1)
p4 = Vector.new(0.0, 700.0, -500, 1)
p5 = Vector.new(0.0, 0.0, -1500.0, 1)
p6 = Vector.new(0.0, 0.0, 1500.0, 1)

points << p1 << p2 << p3 << p4 << p5 << p6

view = Matrix.rotation_x(-30) * Matrix.rotation_y(30) * Matrix.look_at(Vector.new(0, 0, -3000), Vector.new(0, 0, 0), Vector.new(0, 1, 0))
proj = Matrix.projection(640, 480, 1000, 10000)
vp = Matrix.new([[ 640.0 / 2.0, 0.0, 0.0, 0.0],
                 [ 0.0, -480.0 / 2.0, 0.0, 0.0],
                 [ 0.0, 0.0, -1.0, 0.0],
                 [ 640 / 2.0, 480 / 2.0, 0.0, 1.0]])

ps = points.map {|p| p = p * view * proj; p = p / p.w * vp}

line = []
line <<  Line.new(ps[0],ps[1]) << Line.new(ps[1],ps[2]) << Line.new(ps[2],ps[3]) << Line.new(ps[3],ps[0])
line <<  Line.new(ps[4],ps[5]) << Line.new(ps[5],ps[6]) << Line.new(ps[6],ps[7]) << Line.new(ps[7],ps[4])
line <<  Line.new(ps[0],ps[4]) << Line.new(ps[1],ps[5]) << Line.new(ps[2],ps[6]) << Line.new(ps[3],ps[7])
line <<  Line.new(ps[8],ps[9]) << Line.new(ps[10],ps[11]) << Line.new(ps[12],ps[13])

font = Font.new(32)

Window.loop do
  Sprite.draw(line)
  Window.draw_font(ps[9].x, ps[9].y, "x", font)
  Window.draw_font(ps[11].x, ps[11].y, "y", font)
  Window.draw_font(ps[13].x, ps[13].y, "z", font)
end