推奨: NSDT エディタを使用して、プログラム可能な 3D シーンをすばやく構築します
Matplotlib は、多くの機能 (およびいくつかの制限) を備えた非常に優れた 3D インターフェイスを備えており、ユーザーの間で非常に人気があります。しかし、一部のユーザーにとって (あるいはほとんどのユーザーにとって) 3D は依然としてある種の黒魔術であると考えられています。そこで、この記事では、いくつかの概念を理解すれば 3D レンダリングが非常に簡単になることを説明したいと思います。これを実証するために、60 行の Python コードと 1 回の Matplotlib 呼び出しを使用して、3D 軸を使用せずに上のバニーをレンダリングします。
所有しているモデルが .OBJ 形式でない場合は、オンライン 3D 形式変換ツールである NSDT 3DConvert を使用して、モデルを.OBJ 形式に変換できます。
1.ウサギをロードします
まず、モデルをロードする必要があります。Stanford Rabbit の簡易バージョンを使用します。このファイルは、最も単純な形式の 1 つである wavefront .ob 形式を使用しているため、この記事 (およびこのモデル) の仕事を行う非常に単純な (ただしエラーが発生しやすい) ローダーを作成しましょう。
V, F = [], []
with open("bunny.obj") as f:
for line in f.readlines():
if line.startswith('#'):
continue
values = line.split()
if not values:
continue
if values[0] == 'v':
V.append([float(x) for x in values[1:4]])
elif values[0] == 'f':
F.append([int(x) for x in values[1:4]])
V, F = np.array(V), np.array(F)-1
V は頂点のセット (必要に応じて 3D 点) になり、F は面のセット (= 三角形) になります。各三角形は、頂点配列に対する 3 つのインデックスによって記述されます。次に、バニー全体がユニット ボックスに収まるように頂点を正規化しましょう。
V = (V-(V.max(0)+V.min(0))/2)/max(V.max(0)-V.min(0))
これで、頂点の X、Y 座標のみを取得し、Z 座標を削除することで、モデルを最初に確認できます。これを行うには、不規則なポリゴンのコレクションを効率的にレンダリングできる強力な PolyCollection オブジェクトを使用できます。多数の三角形をレンダリングしたいので、これは完全に一致します。したがって、最初に三角形を抽出し、Z 座標を削除します。
T = V[F][...,:2]
これでレンダリングできるようになりました。
fig = plt.figure(figsize=(6,6))
ax = fig.add_axes([0,0,1,1], xlim=[-1,+1], ylim=[-1,+1],
aspect=1, frameon=False)
collection = PolyCollection(T, closed=True, linewidth=0.1,
facecolor="None", edgecolor="black")
ax.add_collection(collection)
plt.show()
次のようなもの (bunny-1.py) が得られるはずです。
2. 透視図法
先ほど作成したレンダリングは実際には正投影ですが、上のウサギは透視投影を使用しています。
どちらの場合も、投影を定義する正しい方法は、最初に表示ボリューム、つまり画面上にレンダリングしたい 3D 空間のボリュームを定義することです。これを行うには、カメラを基準にして表示ボリューム (視錐台) を囲む 6 つのクリッピング プレーン (左、右、上、下、遠、近) を考慮する必要があります。カメラの位置と視線方向を定義すると、各平面は単一のスカラーで説明できます。この表示ボリュームを取得したら、正投影または透視投影を使用してスクリーンに投影できます。
幸いなことに、これらの投影法はよく知られており、4x4 行列を使用して表すことができます。
def frustum(left, right, bottom, top, znear, zfar):
M = np.zeros((4, 4), dtype=np.float32)
M[0, 0] = +2.0 * znear / (right - left)
M[1, 1] = +2.0 * znear / (top - bottom)
M[2, 2] = -(zfar + znear) / (zfar - znear)
M[0, 2] = (right + left) / (right - left)
M[2, 1] = (top + bottom) / (top - bottom)
M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
M[3, 2] = -1.0
return M
def perspective(fovy, aspect, znear, zfar):
h = np.tan(0.5*radians(fovy)) * znear
w = h * aspect
return frustum(-w, w, -h, h, znear, zfar)
透視投影の場合、遠方の平面に対する近方の平面のサイズを (多かれ少なかれ) 設定する開口角を指定する必要もあります。したがって、絞りを大きくすると、多くの「歪み」が発生します。
ただし、上記の 2 つの関数を見ると、これらの関数が 4x4 行列を返し、座標が 3D であることがわかります。では、これらのマトリックスをどのように使用するのでしょうか? 答えは同次座標です。簡単に言うと、同次座標は 3D での変換と投影の処理に最適です。この場合、(ベクトルではなく) 頂点を扱っているため、すべての頂点に 4 番目の座標 (w) として 1 を追加するだけです。次に、内積を使用して透視変換を適用できます。
V = np.c_[V, np.ones(len(V))] @ perspective(25,1,1,100).T
最後のステップとして、同次座標を繰り込む必要があります。これは、各頂点が常に w=1 になるように、変換された各頂点を最後のコンポーネント (w) で除算することを意味します。
V /= V[:,3].reshape(-1,1)
これで、結果を再度表示できます (bunny-2.py)。
ああ、奇妙な結果だ。どうしたの?問題は、カメラが実際にウサギの中にあることです。正しくレンダリングするには、バニーをカメラから遠ざけるか、カメラをバニーから遠ざける必要があります。次のことをしましょう。カメラは現在 (0,0,0) にあり、z 方向を見上げています (錐台変換のため)。したがって、透視変換の前に、カメラを負の z 方向に少し動かす必要があります。
V = V - (0,0,3.5)
V = np.c_[V, np.ones(len(V))] @ perspective(25,1,1,100).T
V /= V[:,3].reshape(-1,1)
これで、(bunny-3.py) を取得できるはずです。
3. モデル、ビュー、投影 (MVP)
明白ではないかもしれませんが、最終的なレンダリングは実際には透視変換です。見やすくするために、バニーを回転させます。このためには、回転行列 (4x4) が必要です。また、平行移動行列を定義することもできます。
def translate(x, y, z):
return np.array([[1, 0, 0, x],
[0, 1, 0, y],
[0, 0, 1, z],
[0, 0, 0, 1]], dtype=float)
def xrotate(theta):
t = np.pi * theta / 180
c, s = np.cos(t), np.sin(t)
return np.array([[1, 0, 0, 0],
[0, c, -s, 0],
[0, s, c, 0],
[0, 0, 0, 1]], dtype=float)
def yrotate(theta):
t = np.pi * theta / 180
c, s = np.cos(t), np.sin(t)
return np.array([[ c, 0, s, 0],
[ 0, 1, 0, 0],
[-s, 0, c, 0],
[ 0, 0, 0, 1]], dtype=float)
次に、適用する変換をモデル (ローカル変換)、ビュー (グローバル変換)、投影の観点から分類して、すべてを同時に実行できるグローバル MVP マトリックスを計算できるようにします。
model = xrotate(20) @ yrotate(45)
view = translate(0,0,-3.5)
proj = perspective(25, 1, 1, 100)
MVP = proj @ view @ model
ここで次のように書きます。
V = np.c_[V, np.ones(len(V))] @ MVP.T
V /= V[:,3].reshape(-1,1)
(bunny-4.py) を取得する必要があります。
次に、違いがわかるように絞りを少し調整してみましょう。バニーの見かけのサイズが同じになるように、カメラからの距離も調整する必要があることに注意してください (bunny-5.py)。
4.深度ソート
では、三角形 (bunny-6.py) を埋めてみましょう。
ご覧のとおり、結果は「興味深い」ものであり、完全に間違っています。問題は、PolyCollection が指定された順序で三角形を描画し、後ろから前に三角形を描画したいことです。これは、深さに応じてそれらを並べ替える必要があることを意味します。幸いなことに、MVP 変換を適用すると、この情報はすでに計算されています。これは新しい Z 座標に保存されます。ただし、これらの Z 値は頂点ベースであるため、三角形を並べ替える必要があります。したがって、平均 Z 値を三角形の深さの代用として採用します。これは、三角形が比較的小さく、互いにつながっていない場合にうまく機能します。
T = V[:,:,:2]
Z = -V[:,:,2].mean(axis=1)
I = np.argsort(Z)
T = T[I,:]
すべてが正しくレンダリングされるようになりました (bunny-7.py)。
深度バッファを使用して色を追加しましょう。各三角形の深さに基づいて色を付けます。PolyCollection オブジェクトの利点は、NumPy 配列を使用して各三角形の色を指定できることです。そのため、これを実行しましょう。
zmin, zmax = Z.min(), Z.max()
Z = (Z-zmin)/(zmax-zmin)
C = plt.get_cmap("magma")(Z)
I = np.argsort(Z)
T, C = T[I,:], C[I,:]
すべてが正しくレンダリングされるようになりました (bunny-8.py)。
最終的なスクリプトには 57 行があります (ただし、読みにくいです)。
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.collections import PolyCollection
def frustum(left, right, bottom, top, znear, zfar):
M = np.zeros((4, 4), dtype=np.float32)
M[0, 0] = +2.0 * znear / (right - left)
M[1, 1] = +2.0 * znear / (top - bottom)
M[2, 2] = -(zfar + znear) / (zfar - znear)
M[0, 2] = (right + left) / (right - left)
M[2, 1] = (top + bottom) / (top - bottom)
M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
M[3, 2] = -1.0
return M
def perspective(fovy, aspect, znear, zfar):
h = np.tan(0.5*np.radians(fovy)) * znear
w = h * aspect
return frustum(-w, w, -h, h, znear, zfar)
def translate(x, y, z):
return np.array([[1, 0, 0, x], [0, 1, 0, y],
[0, 0, 1, z], [0, 0, 0, 1]], dtype=float)
def xrotate(theta):
t = np.pi * theta / 180
c, s = np.cos(t), np.sin(t)
return np.array([[1, 0, 0, 0], [0, c, -s, 0],
[0, s, c, 0], [0, 0, 0, 1]], dtype=float)
def yrotate(theta):
t = np.pi * theta / 180
c, s = np.cos(t), np.sin(t)
return np.array([[ c, 0, s, 0], [ 0, 1, 0, 0],
[-s, 0, c, 0], [ 0, 0, 0, 1]], dtype=float)
V, F = [], []
with open("bunny.obj") as f:
for line in f.readlines():
if line.startswith('#'): continue
values = line.split()
if not values: continue
if values[0] == 'v': V.append([float(x) for x in values[1:4]])
elif values[0] == 'f' : F.append([int(x) for x in values[1:4]])
V, F = np.array(V), np.array(F)-1
V = (V-(V.max(0)+V.min(0))/2) / max(V.max(0)-V.min(0))
MVP = perspective(25,1,1,100) @ translate(0,0,-3.5) @ xrotate(20) @ yrotate(45)
V = np.c_[V, np.ones(len(V))] @ MVP.T
V /= V[:,3].reshape(-1,1)
V = V[F]
T = V[:,:,:2]
Z = -V[:,:,2].mean(axis=1)
zmin, zmax = Z.min(), Z.max()
Z = (Z-zmin)/(zmax-zmin)
C = plt.get_cmap("magma")(Z)
I = np.argsort(Z)
T, C = T[I,:], C[I,:]
fig = plt.figure(figsize=(6,6))
ax = fig.add_axes([0,0,1,1], xlim=[-1,+1], ylim=[-1,+1], aspect=1, frameon=False)
collection = PolyCollection(T, closed=True, linewidth=0.1, facecolor=C, edgecolor="black")
ax.add_collection(collection)
plt.show()