平面を凸凹に見せる
目標
法線をテクスチャに入れて、それで平らな面を凸凹に見せます。このラボが終われば、ノーマルマップがなぜあの薄紫色なのか、接線空間がなぜ必要なのかを、説明できるようになります。
なぜ重要なのか
人が凹凸を見分ける手がかりの大部分は、明るさの変化です。明るさは、法線と光の方向の内積で決まるので、形状を変える代わりに、法線だけをピクセルごとに差し替えれば、同じ効果が出ます。三角形2つの平面に、レンガの凹凸が生じます。
ここで重要なのが、どの座標系の法線を入れるかです。ワールド空間の法線を入れると、物体が回転した瞬間に、すべて間違います。そのため、表面を基準にした座標系(接線空間)に入れ、その座標系で、「平ら」は常に(0, 0, 1)です。ノーマルマップが全体的に薄紫に見えるのが、その値の色です。
ステップ
/root/normalmapに、ツールボックスを置きます。/root/normalmap/encode.pyに、法線と色の間の変換を作ります。/root/normalmap/out/normalmap.pngに、半球の格子のノーマルマップを作ります。/root/normalmap/tbn.pyに、接線空間の3つの軸を作ります。/root/normalmap/out/flat.pngに、定数の法線で照らした平面を書きます。/root/normalmap/out/bumped.pngに、ノーマルマップで照らした同じ平面を書きます。/root/normalmap/out/l0.pngからl5.pngまでとout/07-sweep.txtに、光を動かした結果を書きます。
参考
- 絵は、
nohup python3 -m http.server 8080 -d /root/normalmap/out &で立てて、Webプレビューでhttp://localhost:8080/を開きます。6枚が回れば、凹凸が光に応じて反応するのが見えます。 - このラボの平面は、XY平面に標準のUVなので、接線空間がワールド空間と同じです。そのため、TBN変換が恒等になり、計算が単純になります。別の姿勢の物体なら、必ず経由する必要があります。
- よくあるミスの1つ目は、
decodeで正規化を抜かすことです。8ビットで切り捨てられて、長さが1でなくなり、明るさが微細にずれます。 - よくあるミスの2つ目は、色を整数に切り詰めるとき、255を超えることです。明るい所が、おかしな色に反転します。
描画のツールボックスを置く
/root/normalmap/gfxlib.pyを例のとおりに保存し、/root/normalmap/check.pyでテストパターンを描いて、/root/normalmap/out/00-check.pngを作ってください。パターンは、64x64の黒い背景に、(0,0)から(63,63)まで、白(255,255,255)の対角線を引き、そのあとに、(0,32)から(63,32)まで、赤(255,0,0)の横線を重ねて引いたものです。
このラボからは、PNGエンコーダーを作り直しません。最初のラボで手で作ったものと同じコードを、ツールとして配ります。ここで学ぶのは、ファイル形式ではないからです。
ラボのPodにはボリュームがないので、前のラボで作ったファイルが残っていません。そのため、ラボごとに、ツールボックスを置き直すことから始めます。
Canvas(w, h, bg)を作り、line(x0, y0, x1, y1, rgb)で2本の線を引いたあと、write_png(path)で保存してください。交差点(32,32)が赤になるには、横線をあとで引く必要があります。
このラボでは、ノーマルマップ自体と、それで照らした結果を、絵として書き出すのに使います。
法線を色にする
/root/normalmap/encode.pyに、encode(n)とdecode(rgb)を作ってください。encodeは、(q*0.5 + 0.5) * 255を3つの成分に適用した実数3つを、decodeは、c/255*2 - 1を適用したあと、正規化した単位ベクトルを返します。
法線の各成分は-1から1の間ですが、テクスチャのチャンネルは0から255の間です。範囲を移し替えることが、すべてです。
平らな部分の法線(0, 0, 1)は、(127.5, 127.5, 255)になります。ノーマルマップが全体的に薄紫色に見える理由が、これです。その色が、すなわち「ここは平らだ」という意味です。
decodeで正規化をする理由は、8ビットで切り捨てられて、長さがちょうど1ではなくなるからです。正規化しないと、内積がコサインではなくなって、明るさが微細にずれます。
長さが0になる入力((128,128,128)の付近ではなく、ちょうど原点に復元される値)では、0で割らないようにしてください。
ノーマルマップを作る
/root/normalmap/genmap.pyで、128x128のノーマルマップを/root/normalmap/out/normalmap.pngに書いてください。マスのサイズ32で分けて、cx = (x%32)/32*2 - 1、cy = (y%32)/32*2 - 1、r2 = cx*cx + cy*cyを求め、r2 < 1なら、法線を(cx, cy, sqrt(1-r2))に、そうでなければ(0, 0, 1)として、encodeして保存します。
(cx, cy, sqrt(1-r2))は、単位半球の上の点であり、その地点の法線です。半径1の球では、位置ベクトルと法線が同じだからです。
マス1つが32ピクセルなので、128x128には、4x4 = 16個のドームが入ります。マスの縁(r2 >= 1)は、平らな底です。
色を整数に切り詰めるとき、0–255の外に出ないようにしてください。encodeの結果は、本来その範囲の中ですが、丸めであふれることがあります。
できあがった絵は、薄紫の地に、丸い模様がある姿になるはずです。その薄紫が、(128,128,255)、つまり、平らです。
接線空間の3つの軸
/root/normalmap/tbn.pyにbuild_tbn(p0, p1, p2, uv0, uv1, uv2)を作って、(T, B, N)の3つの単位ベクトルを返してください。e1 = p1-p0、e2 = p2-p0、d1 = uv1-uv0、d2 = uv2-uv0、r = 1/(d1x*d2y - d2x*d1y)とおいて、T = (e1*d2y - e2*d1y)*r、B = (e2*d1x - e1*d2x)*r、N = normalize(cross(e1, e2))で、TとBも正規化します。
この式は、「UVがこれだけ変わるとき、位置がこれだけ変わる」という2つの方程式を、連立して解いたものです。Tは、表面上で、uが増える方向で、Bは、vが増える方向です。
分母が0になる場合があります。三角形のUVが1点にまとまっているか、1本の直線の上に並んでいるときで、そうした三角形には、接線空間が定義されません。0なら、T=(1,0,0)、B=(0,1,0)のように、適当な値を決めて、先へ進んでください。
XY平面の三角形に標準のUVを貼ると、T=(1,0,0)、B=(0,1,0)、N=(0,0,1)が出ます。つまり、接線空間が、そのままワールド空間になる特殊な場合です。
v方向を反転させると、Bも反転します。ノーマルマップの緑のチャンネルの慣習が分かれるのが、ここです。
定数の法線で照らす
/root/normalmap/light.pyで、128x128の平面を照らして、/root/normalmap/out/flat.pngを作ってください。法線は、すべてのピクセルで(0,0,1)、光はnormalize((0.0, 0.3, 0.8))、ambient 0.15、基本色(220,200,180)で、明るさは、ambient + (1-ambient)*max(0, dot(n, l))です。
法線がすべてのピクセルで同じなので、絵全体が1色になります。それが正常です。
このステップの目的は、次のステップと比べる基準を作ることです。形状も照明も同じなのに、法線だけを差し替えたとき、何が変わるかを見るには、基準が必要です。
色を整数に切り詰めるとき、255を超えないようにしてください。
この平面はXY平面で、UVも標準なので、接線空間が、そのままワールド空間です。そのため、次のステップで、TBN変換が恒等になり、計算が単純になります。
ノーマルマップで照らす
同じ平面、同じ光で、法線だけを、ステップ3のnormal_at(x, y)から取ってきて、/root/normalmap/out/bumped.pngを作ってください。
形状は、1つも変わっていません。相変わらず平面1つです。ピクセルごとに、法線を差し替えただけです。
ところが、結果は、半球が格子状に埋め込まれたように見えます。人が凹凸を見分ける手がかりが、明るさの変化だからです。
平均の明るさは、平らなほうと、大きくは変わらないはずです。明るくなった場所と暗くなった場所が、打ち消し合うからです。一方、明るさのばらつき(分散)は、はるかに大きくなります。
この平面は、XY平面に標準のUVなので、接線空間がワールド空間と同じです。そのため、TBN変換を省いても、結果が同じです。別の姿勢の物体なら、必ず経由する必要があります。
光を動かす
光のx成分を、-0.9から0.9まで、6段階(-0.9 + 1.8*k/5)で変えながら、ノーマルマップで照らした絵/root/normalmap/out/l0.pngからl5.pngまでを作り、各絵で、座標(32,32)からの32x32のマスの、左半分の平均の明るさから、右半分の平均の明るさを引いた値を、/root/normalmap/out/07-sweep.txtに、a0=からa5=として書いてください。そして、6つの名前を含めた/root/normalmap/out/index.htmlも作ってください。
光のy、z成分は、0.3と0.8のままにして、xだけを変えます。
左半分が明るければ、aは正、右が明るければ負です。光が左から右へ移っていくので、6つの値が、減り続ける必要があります。それが、凹凸が光に応じて反応するという証拠です。
平均は、チャンネルまですべて足して割ります。ピクセル1つが3つの値なので、16*32*3で割れば済みます。
ドーム1つが、光を受ける側だけ明るくなる様子が、6枚をつなげて見ると、生きて動いているように見えます。それが、ノーマルマップがすることです。