Imaginary Code

from kougaku-navi.net

Processingで3Dグラフィックスを扱う上での注意点

Processingのグラフィックスの描画系は内部でOpenGLを利用していますが、ProcessingのAPIは特有の癖があるのでOpenGLと同じ感覚でやっていると時々戸惑うことがあります。3DグラフィックスまわりのProcessing特有の仕様について気をつけておくべき点をここにメモしておきます(Processing 3.5.4において動作確認)。

(2017/7/19)主に行列のところを加筆しました。
(2019/5/15)文章を少し手直ししました。本質的な意味は変えてません。
(2020/11/19)座標系の向きの図を更新し、プロジェクション行列について追記しました。
(2021/9/6)図中の行列の記号をU・P・V・Mに置き換えました。意味は変わっていません。modelX()およびscreenX()についての説明を追記しました。
(2022/1/7)「3D描画と2D描画を組み合わせたいときは?」を追記しました。
(2022/1/8)「pushMatrix(), popMatrix()の挙動」を追記しました。

 

座標系

  • Processingは左手系を採用している。
  • 視点を中心とする座標系(カメラ座標系)の座標軸の向きは、x軸が右方向、y軸が下方向、z軸が手前方向となっている。
  • この座標軸の向きの違いがプロジェクション行列に影響する(後述)。



3Dグラフィックスで扱われる行列とその取得方法

  • Processingの内部で以下の行列が管理されている。
    • プロジェクション行列(projection)
    • モデルビュー行列(modelview)
    • モデルビュー行列の逆行列(modelviewInv)
    • ビュー行列(camera)
    • ビュー行列の逆行列(cameraInv)
    • プロジェクション行列とモデルビュー行列を乗算した行列(projmodelview)



  • ワールド座標系とローカル座標系のあいだのモデリング変換を表わすモデル行列は定義されていない。モデル行列が欲しい場合は、ビュー行列の逆行列とモデルビュー行列を乗算して求めればよい。
  • プロジェクション行列やモデルビュー行列はPAppletのメンバ変数である「g」から取得できる。「g」はPGraphicsクラスのオブジェクトだが、その派生クラスであるPGraphicsOpenGL(またはPGraphics3D)でキャストしてやるとmodelviewやprojectionにアクセスできる。
  • 各行列への参照を取得できるので、行列の値を書き変えてジオメトリを直接コントロールすることもできる。
  • 行列の値を直接書き変える場合はペアになっている逆行列(modelviewの場合はmodelviewInv)のほうも更新したほうがよいようだ。
  • projectionまたはmodelviewを書き変えた場合は、projmodelviewを更新するために ((PGraphicsOpenGL)g).updateProjmodelview() を実行する必要があるようだ。
  • 行列のコピーが欲しい場合は .get() で取得すればよい。
行列への「参照」が欲しい場合(内部のパラメータを直接書き変えたい場合)
// プロジェクション行列
PMatrix3D projection = ((PGraphicsOpenGL)g).projection;

// モデルビュー行列
PMatrix3D modelview = ((PGraphicsOpenGL)g).modelview;

// モデルビュー行列の逆行列
PMatrix3D modelviewInv = ((PGraphicsOpenGL)g).modelviewInv;

// ビュー行列
PMatrix3D camera = ((PGraphicsOpenGL)g).camera;

// ビュー行列の逆行列
PMatrix3D cameraInv = ((PGraphicsOpenGL)g).cameraInv;

// プロジェクション行列とモデルビュー行列を乗算した行列
PMatrix3D projmodelview = ((PGraphicsOpenGL)g).projmodelview;

行列の「コピー」が欲しい場合
// プロジェクション行列
PMatrix3D projection = ((PGraphicsOpenGL)g).projection.get();

// モデルビュー行列
PMatrix3D modelview = ((PGraphicsOpenGL)g).modelview.get();

// モデルビュー行列の逆行列
PMatrix3D modelviewInv = ((PGraphicsOpenGL)g).modelviewInv.get();

// ビュー行列
PMatrix3D camera = ((PGraphicsOpenGL)g).camera.get();

// ビュー行列の逆行列
PMatrix3D cameraInv = ((PGraphicsOpenGL)g).cameraInv.get();

// プロジェクション行列とモデルビュー行列を乗算した行列
PMatrix3D projmodelview = ((PGraphicsOpenGL)g).projmodelview.get();

デフォルトの視点位置

  • デフォルトではワールド座標系≠カメラ座標系であることに注意。
  • デフォルトの視点位置は以下の通り。
    • 目の位置:( width/2, height/2, (height/2)/tan(PI/6) )
    • 注視点の位置:( width/2, height/2, 0 )
  • これは、画面の左上隅が(0, 0, 0)、右下隅が(width-1, height-1, 0)にそれぞれ対応するような視点の配置になっている。ただし、これは透視投影の視野角がデフォルトの60度であることを考慮した計算のため、perspective()などで視野角が変更されるとこの関係はくずれる。



プロジェクション行列

  • Processingのプロジェクション行列はOpenGLやUnityのものとは異なり、2列目の要素の符号が反転している。
  • これはカメラ座標系のY軸が下向き(OpenGLやUnityでは上向き)で、正規化デバイス座標系のy軸が上向きであることに由来する。



draw()開始時に行われるモデルビュー行列の初期化

  • 毎フレーム、draw()開始時に、モデルビュー行列が自動的に初期化される。
  • どのように初期化されるのかというと、モデルビュー行列(modelview)がビュー行列(camera)と同じ値になる。
  • この挙動は「ビュー行列は常に保持されていて、draw()開始時にモデル行列だけを単位行列にリセットしている」と捉えることができる。
  • ビュー行列はdraw()開始時にリセットされないため、ビュー行列に変更を加える操作(camera()やresetMatrix())を行うと、以降のフレームにも影響が出る。draw()の冒頭で毎回camera()やresetMatrix()を実行している場合は特に気にする必要はないが、draw()の外や特定のタイミングでそれらの操作を実行する場合は留意したい。

camera()の挙動

  • camera()はワールド座標系に対する視点の位置・姿勢を設定する関数である。
  • camera()を実行すると、内部で保有している5つの行列が更新される(modelview、modelviewInv、camera、cameraInv、projmodelview)
  • camera()を実行すると、パラメータで指定された視点になるようにビュー行列(camera)が設定される。また、モデルビュー行列(modelview)はビュー行列と同じ値になる。これは「モデル行列が単位行列に初期化された」と解釈することもできる。
  • camera()を実行すると、それ以降のフレームにおいて、draw()開始時のモデルビュー行列はcamera()で設定されたビュー行列で初期化されるようになる。ただし、camera()を実行している箇所をpushMatrix()とpopMatrix()で囲んでいた場合はこの限りではない。
  • 上方向ベクトル (upX, upY, upZ) は実は下方向ベクトルじゃないの?という疑念がある。OpenGLのgluLookAt()由来の仕様だと思われるが、Y軸が下に向いているのに (0, 1, 0) で上方向を表わすのは気持ちが悪い。フォーラムでも同様の指摘がある。
  • ビュー行列の初期状態は以下の通り。
    • ここでwidthとheightは画面サイズを表わす。
    • camera()関数にデフォルトパラメータを与えた時のビュー行列がこれになる。つまり、上図のカメラ配置にするための行列。
 1    0    0       -width/2
 0    1    0       -height/2
 0    0    1    -(height/2)/tan(PI/6)
 0    0    0          1

resetMatrix()の挙動

  • resetMatrix()を実行すると、modelview、modelviewInv、camera、cameraInvが単位行列に初期化され、projmodelviewがprojectionと同じ値になる。
  • このため、resetMatrix()実行直後はワールド座標系上に視点が置かれている状態になる。
  • resetMatrix()を実行すると、それ以降のフレームにおいてdraw()開始時のモデルビュー行列は単位行列になる(ビュー行列も単位行列になってしまっているから)。ただし、resetMatrix()を実行している箇所をpushMatrix()とpopMatrix()で囲んでいた場合はこの限りではない。
  public void resetMatrix() {
    modelview.reset();
    modelviewInv.reset();
    projmodelview.set(projection);

    // For consistency, since modelview = camera * [all other transformations]
    // the camera matrix should be set to the identity as well:
    camera.reset();
    cameraInv.reset();
  }

applyMatrix()の挙動

  • 引数で指定されたパラメータで表現される行列がmodelviewおよびprojmodelviewに乗算される。また、modelviewInvも再計算される。
  protected void applyMatrixImpl(float n00, float n01, float n02, float n03,
                                 float n10, float n11, float n12, float n13,
                                 float n20, float n21, float n22, float n23,
                                 float n30, float n31, float n32, float n33) {
    modelview.apply(n00, n01, n02, n03,
                    n10, n11, n12, n13,
                    n20, n21, n22, n23,
                    n30, n31, n32, n33);
    modelviewInv.set(modelview);
    modelviewInv.invert();

    projmodelview.apply(n00, n01, n02, n03,
                        n10, n11, n12, n13,
                        n20, n21, n22, n23,
                        n30, n31, n32, n33);
  }

setMatrix()の挙動

  • 実装上はresetMatrix()とapplyMatrix()を組み合わせた動作になっている。
  public void setMatrix(PMatrix3D source) {
    resetMatrix();
    applyMatrix(source);
  }

pushMatrix(), popMatrix()の挙動

  • pushMatrix()は現在の座標系の状態(幾何学的変換)を行列スタックに保存する関数。pushMatrix()を実行すると、modelview、modelviewInv、camera、cameraInv の4つの行列がそれぞれのスタックに保存される。
  • popMatrix()はその逆。すなわち、行列スタックに保存されている座標系の状態を復元する関数で、modelview、modelviewInv、camera、cameraInv の4つの行列がそれぞれ復元される。
  • モデルビュー行列だけでなくビュー行列も保持される点や、プロジェクション行列は保持されない点に留意したい。
  public void pushMatrix() {
    if (modelviewStackDepth == MATRIX_STACK_DEPTH) {
      throw new RuntimeException(ERROR_PUSHMATRIX_OVERFLOW);
    }
    modelview.get(modelviewStack[modelviewStackDepth]);
    modelviewInv.get(modelviewInvStack[modelviewStackDepth]);
    camera.get(cameraStack[modelviewStackDepth]);
    cameraInv.get(cameraInvStack[modelviewStackDepth]);
    modelviewStackDepth++;
  }

  public void popMatrix() {
    if (modelviewStackDepth == 0) {
      throw new RuntimeException(ERROR_PUSHMATRIX_UNDERFLOW);
    }
    modelviewStackDepth--;
    modelview.set(modelviewStack[modelviewStackDepth]);
    modelviewInv.set(modelviewInvStack[modelviewStackDepth]);
    camera.set(cameraStack[modelviewStackDepth]);
    cameraInv.set(cameraInvStack[modelviewStackDepth]);
    updateProjmodelview();
  }
  • なお、スタックできる上限は32回となっている。
  static protected final int MATRIX_STACK_DEPTH = 32;

modelX(), modelY(), modelZ() の挙動

  • これらはモデリング変換を行う関数で、ローカル座標系における座標値をワールド座標系における座標値に変換する。
  • 内部では modelview と cameraInv を使って計算が行われている。

screenX(), screenY(), screenZ() の挙動

  • これらはローカル座標系における座標値をウィンドウ座標系における座標値に変換する関数である。
  • 内部では modelview、projection、width、height を使い、モデルビュー変換、投影変換、ビューポート変換に相当する計算を行っている。

 

(おまけ)3D描画において透過処理がうまくいかないときは?

透明物体の描画がおかしいときや文字の背景部分が正しく透けないときは、オブジェクトの描画処理の前に以下の一行を入れると改善する。

  hint(ENABLE_DEPTH_SORT);

hint()はレンダラの動作条件を設定する関数で、ENABLE_DEPTH_SORTはデプスソート(奥のオブジェクトからレンダリングする処理)の有効化を意味する。
 

(おまけ2)3D描画と2D描画を組み合わせたいときは?



3D描画を行うプログラムにおいて、画面上に文字や円などの2Dグラフィックスを描画したいことはたびたびあるが、2Dグラフィックスも3Dグラフィックスとして扱われるため、意図通りに表示させるには少し工夫が必要になる。以下に例を示す。

void setup() {
  size(500, 500, P3D);
}

void draw() {
  background(100);
  
  // 3D描画
  camera(0, -200, 500, 0, 0, 0, 0, 1, 0);  
  perspective();
  rotateY(frameCount*0.01);  
  box(100);
  
  // 2D描画
  pushMatrix();
  ortho();
  resetMatrix();
  translate(-width/2.0, -height/2.0);
  hint(DISABLE_DEPTH_TEST);
  ellipse(mouseX, mouseY, 50, 50);
  textSize(50);
  text("This is test.", 30, 60);
  hint(ENABLE_DEPTH_TEST);
  popMatrix();
}

3D描画時にはperspective()によって透視投影に設定し、2D描画時にはortho()によって平行投影にしている。また、2D描画時にはresetMatrix()によってカメラの位置姿勢をリセットしたうえで、画面の左上隅が原点となるようにtranslate()によって座標系を移動させている。さらに画面の最前面に2Dグラフィックスを描画するために、深度テスト(3D物体の前後関係の判定処理)を一時的に無効化している。pushMatrix()とpopMatrix()で囲うのを忘れると、カメラの位置姿勢がリセットされたままになってしまうので忘れずに。
 
上の例ではperspective()とorthoを切り替えていたが、切り替えずに座標系を「そういう風に見える位置」に移動させる手もある。具体的には以下のようにする。

void setup() {
  size(500, 500, P3D);
}

void draw() {
  background(100);
  
  // 3D描画
  camera(0, -200, 500, 0, 0, 0, 0, 1, 0);  
  rotateY(frameCount*0.01);  
  box(100);
  
  // 2D描画
  pushMatrix();
  resetMatrix();
  translate(-width/2.0, -height/2.0, ((PGraphicsOpenGL)g).projection.m11 * height/2.0);
  hint(DISABLE_DEPTH_TEST);
  ellipse(mouseX, mouseY, 50, 50);
  textSize(50);
  text("This is test.", 30, 60);
  hint(ENABLE_DEPTH_TEST);
  popMatrix();
}

移動させる位置はカメラの視野角に依存するので、プロジェクション行列のパラメータを参照して決定している。やや玄人向けの方法だが、こちらではperspective()やortho()を明示的に使わずに3D描画と2D描画を両立している。

 

さらなる探究