【Unity】接空間について
はじめに
本記事では、接空間に関する変換方法についてまとめてみました。
インターネット上にたくさん情報はあるのですが、それぞれ実装が違うように見えたり頭が混乱したので、備忘録のような形で記録にしてみました。
本記事はUnityを前提に解説しますが、DirectX/OpenGLの違いも交えて解説するかもしれないし、しないかもしれない。
目次
接空間とは
接空間とは、ポリゴンの表面を中心に、UV.x(U)の向き=+X軸、UV.y(V)の向き=+Y軸、法線の向き=+Z軸とした空間のことです。
一番大事なポイントは UV軸=XY軸 という特徴です。
UV軸=XY軸とすることで、ポリゴンの表面の向きが真正面になるので、法線の向きに依存する描画は基本的に情報が扱いやすくなります。
ポリゴン表面の法線の向きが正面(+Z軸)なので、左手系のUnityからすると3軸のいずれかが反転している(鏡映)のですが、
前後が反転している空間と思えばスムーズに理解が進むかと思います。
自分はその空間の見え方を考えてしまうタイプなのですが、同じタイプの方向けに言葉を変えると、
ポリゴンの表面上に 仰向けに寝ている人の視点を中心とした空間です。ただし前述にもあった通り、1軸が反転していることに注意です。
接空間の向きに関して、数学的な定義と厳密には違いますが、CGで用いられる接空間はこのような認識で大丈夫だと思います。
ちなみに手持ちの本では、頂点座標空間 や サーフェス座標空間 といった呼び方もしていました。
接空間が使われる例
ノーマルマップを用いて陰影を計算する際に利用されます。
ノーマルマップの大半が青色なのは大体が+Z軸の向き(正面の向き)を示しているからで、ポリゴン表面の法線情報を欠いた接空間の情報になっています。なので、ポリゴンに張り付けた際は +Z軸の向き が 法線の向き になるように調整してから計算する必要があります。ただし、法線の向きはノーマルマップテクスチャにアクセスする=ピクセルシェーダにてアクセスすることになります。ピクセルシェーダで得た法線を行列積するよりも、頂点シェーダで光線の向き等を接空間に変換した方が処理負荷を軽減できる!ということから、接空間が利用される流れになります。
接空間へ変換する回転行列の作成
まず、接空間へ変換する回転行列(float3x3)に必要な情報は何かを考えてみます。
回転行列はベクトルと積を計算することで、回転後のベクトルを求めることが出来ます。
ここで、回転行列に X Y Z の各軸に沿った単位ベクトルを乗算すると、回転行列の列ベクトルがそのまま出てくることが分かります。
つまり、次のように値を入れてあげれば、回転行列になることになります。
- 行列の1列目に右手方向(Right Vector)
- 行列の2列目に頭上方向(Up Vector)
- 行列の3列目に正面方向(Forward Vector)
今回ほしいのは接空間…すなわちポリゴン上のいる人が仰向けになっている姿勢なので、次のようになります。*1
- 法線(normal)が正面方向
- 接線(tangent)が右手方向
- 法線と接線の外積から得られる従法線(binormal)が頭上方向
ただし、このままだと従法線がVの向きと反対側を向いてしまうことになるので、法線と接線の外積から得られる向きと反対の向きを従法線とします。
鏡映してしまうのは実はこれのせいです。反転している1軸の正体は実はY軸上下反転だったのです。
これらの法線/接線/従法線のベクトルを列に並べれば、ワールド空間から見た接空間の姿勢が作れます。(上の図の右側)
つまり接空間→ワールド(ローカル)空間の向きに変換する行列が作れます。
ワールド(ローカル)空間→接空間の向きに変換する行列は、この回転行列の逆行列を作ります。
回転行列は逆行列=転置行列という特徴があるので、次のような形にすればOKです。
ただし、掛け合わせる順番を必ずmul(Matrix, Vector)
の順で計算してください。(理由は後述します)
実装
行列の作り方は把握できたので、実際にプログラムにしてみます。
接空間 ⇔ ローカル(オブジェクト)空間の変換
// ローカル空間のまま float3 normal = i.normal; float3 tangent = i.tangent.xyz; float3 binormal = cross(normal, tangent) * i.tangent.w * unity_WorldTransformParams.w; // ローカル空間 → 接空間 の変換行列 float3x3 localToTangentMatrix = float3x3(tangent.xyz, binormal, normal); tangentVector = mul(localToTangentMatrix, localVector); // ローカル空間 → 接空間 に変換(こちらの方が若干スマート) tangentVector = float3(dot(tangent, localVector), dot(binormal, localVector), dot(normal, localVector)); // 接空間 → ローカル空間 の変換行列 float3x3 tangentToLocalMatrix = float3x3(tangent.x, binormal.x, normal.x, tangent.y, binormal.y, normal.y, tangent.z, binormal.z, normal.z); float3x3 tangentToLocalMatrix = transpose(localToTangentMatrix); localVector = mul(tangentToLocalMatrix, tangnetVector); // 接空間 → ローカル空間 の変換行列(こちらの方が若干スマート) localVector = tangent * tangentVector.x + binormal * tangentVector.y + normal * tangentVector.z;
接空間 ⇔ ワールド空間の変換
// ワールド空間にしておく float3 normal = UnityObjectToWorldNormal(i.normal); // 変更点はここだけ float3 tangent = mul(unity_ObjectToWorld, i.tangent.xyz); // 変更点はここだけ float3 binormal = cross(normal, tangent) * i.tangent.w * unity_WorldTransformParams.w; // 変更点はここだけ // ワールド空間 → 接空間 の変換行列 float3x3 worldToTangentMatrix = float3x3(tangent.xyz, binormal, normal); tangentVector = mul(worldToTangentMatrix, worldVector); // ローカル空間 → 接空間 に変換(こちらの方が若干スマート) tangentVector = float3(dot(tangent.xyz, worldVector), dot(binormal, worldVector), dot(normal, worldVector)); // 接空間 → ワールド空間 の変換行列 float3x3 tangentToWorldMatrix = float3x3(tangent.x, binormal.x, normal.x, tangent.y, binormal.y, normal.y, tangent.z, binormal.z, normal.z); float3x3 tangentToWorldMatrix = transpose(worldToTangentMatrix); worldVector = mul(tangentToWorldMatrix, tangnetVector); // 接空間 → ローカル空間 の変換行列(こちらの方が若干スマート) worldVector = tangent * tangentVector.x + binormal * tangentVector.y + normal * tangentVector.z;
i.tangent.w と unity_WorldTransformParams.w
従法線を算出する際にさりげなく登場した i.tangent.w
と unity_WorldTransformParams.w
ですが、Unityで従法線を算出する際は、これらを乗算することがルールとなっております。いやいや…こいつら誰やねん!となると思いますが、先程従法線をVの向きに合わせるために反転させたのは、実はこの2つの変数を乗算して実現しています。
i.tangent.w
モデルのインポート時に Tangent を算出/インポートする設定を行った際に、一緒に付属して w要素 も入力されます。
この w要素 は通常は -1 となっているのですが、UV.x(U)の向き あるいは UV.y(V)の向き のいずれかが逆にマッピングされた箇所は +1 となります。
i.tangent
は頂点データなので、ポリゴンの面ごとに反転かどうかが判断されます。
これは U か V の向きがいずれかが反転されてマッピングされていた場合は、姿勢も反転することで整合をとっています。
従法線の向きは通常で逆向きにしているので、この条件に当てはまった場合は元の向きに戻るような形になります(鏡面の状態はキープしています)。
ちなみに、U と V のどちらも反転されてマッピングされた場合は、-1 のままとなります(反転の反転なので)。
unity_WorldTransformParams.w
i.tangent.w
以外にも UV が反転してしまう可能性があります。それは Transform の scale です。
この w要素 は通常は +1 となっているのですが、スケールの X Y Z のいずれかが反転(負数)していた場合は -1 となります。
スケールはオブジェクトごとに反転かどうかが判断されます。
列優先(Column-major-order)と行優先(Row-major-order)
さて、ここから接空間とはちょっと離れた内容になりますが、あわせて知っておくべきノウハウとなります。
それは、行列の列優先と行優先についてです。
データ保持形式(メモリレイアウト)の列優先/行優先
行列のデータ(float 16個)は1次元配列としてデータを格納しています。
行列の要素を格納する順序は、列方向/行方向で並べる2つのパターンがあります。
どちらの順序で並ぶのかはライブラリによって決められています。
本記事では列優先データ順/行優先データ順と呼ぶことにします。
列優先データ順 | 行優先データ順 |
---|---|
Unity(C#), OpenGL, PhysX | Unity(Shader), DirectX, OpenCV |
数学的表現の列優先/行優先
データの保持とは別に、行列およびベクトルと掛け合わせる際の計算順序も2つのパターンに分かれます。
行列にかけ合わせる際のベクトルが列ベクトルなら列優先、行ベクトルなら行優先となります。
本記事では,列優先表現/行優先表現と呼ぶことにします。
列優先表現 | 行優先表現 |
---|---|
Unity, OpenGL, OpenCV, PhysX | DirectX |
mul(a, b)で計算するときの注意点
UnityのShaderで行列演算を行う場合はmul(a,b)
メソッドを使用します。
このmul(a,b)
メソッドは2通りの書き方ができます。お察しの通り、列優先表現の計算と行優先表現の計算です。
引数aにベクトルを入れた場合、行ベクトルとみなされ行優先表現で計算されます。
引数bにベクトルを入れた場合、列ベクトルとみなされ列優先表現で計算されます。
つまり、用意された行列がどちらの優先表現で作られたかで、どちらの引数にベクトルを入れるかが変わります!
列優先表現 | 行優先表現 |
---|---|
mul(M, v) |
mul(v, M) |
行列の演算結果はどちらもとなります。
ちょっと余談ですが、なんで mul() 関数って存在するの?と思ってちょっと調べました。
通常v' = M * v
という書き方で演算できる方がシンプルだと思います。
OpenGLではこの書き方で演算できますが、UnityおよびDirectXでは各要素を掛け合わせるという演算をしてしまうようです。
// OpenGL vec4 VectorOut = MatrixB * MatrixA * VectorIn; vec3 VectorAB = VectorA * VectorB; // 各要素の掛け合わせ // VectorAB = { a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w }; mat4 MatrixAB = MatrixB * MatrixA; // MatrixAB = 行列行列積 // for(int i=0; i<4; ++i){ // for(int j=0; j<4; ++j){ // for(int k=0; k<4; ++k){ // MatrixAB[i][j] = a[i][k] * b[k][j]; // }}}
// Unity / HLSL float4 VectorOut = mul(mul(Vin, MatrixA ), MatrixB ); float4 VectorAB = VectorA * VectorB; // 各要素の掛け合わせ // VectorAB = { a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w }; float4x4 MatrixAB = MatrixA * MatrixB; // 各要素の掛け合わせ // for(int i=0; i<4; ++i){ // for(int j=0; j<4; ++j){ // MatrixAB[i][j] = a[i][j] * b[i][j]; // }}}
(【syghの新フラグメント置き場】さんの引用になります)
存在理由については憶測となってしまいますが、DirectXのほうは…
- ベクトル同士の乗算が各要素の掛け合わせなら行列もそうあるべきと思った説
- 行列の掛け合わせも手段として残しておきたいと思った説
- 行列の演算順を明確になりやすい形にしたかった説
- 上の例の場合、OpenGLの方が多くなってそう?(式が左から右に評価されているのであれば、行列行列積の後に行列列ベクトル積してそう)
つまり、存在理由は分かりません。
どうして2パターンあるの?
使う側からすると「頼むから統一してくれ…」って思いますよね。思いました。
Microsoftは逆にするのが好きなんですか?ABXYボタンみたいに?って思いましたよね。
私は行列積をDirectXで勉強していたので、Unityを初めて触った時はアレルギー反応みたいなものがありました。
行列の演算を行う際に、データの並び次第ではデータをCPUキャッシュメモリに乗りやすく/効果的に使えるようになるので、演算が早くなるというのがあるらしいです。そういう高速化はやったことがないのでフワっとしか分からないのですが、高速化という点で列にするか行にするか各ライブラリで選ばれた結果ということみたいですね。
Unity上での注意点
列優先表現/行優先表現
まず 列優先表現 / 行優先表現 に関して、mul(UNITY_MATRIX_MVP, i.vertex)
のように、Unityでは 列優先表現 で統一しているようですので、こちらに合わせる書き方で進めていけば混乱も少なくなるかな?と思われます。
列優先データ順/行優先データ順
そして 列優先データ順 / 行優先データ順 に関して、Unity上では、
- C#で格納する場合は
new Matrix4x4(column0, column1, column2, column3)
で 列優先データ順 - Shaderで格納する場合は
float4x4(row0, row1, row2, row3)
で 行優先データ順
となることに注意する必要があります。
つまり、Shaderでfloat3x3(right, up, forward)
のように行列を作ると行ベクトルが並ぶ形で格納されるので、列優先表現の視点で見ると転置行列となってしまいます。C#⇔Shader間のメモリレイアウトはいずれも列優先表現となるようにUnityが調整してくれているので、Unityで扱う行列はすべて列優先表現だと思って問題なさそうです。Shader上で行列を作るときだけ注意!という感じです。
Unityで行優先表現をしたら間違いなの?
色々と思うところはあるのですが、Shaderでfloat3x3(right, up, forward)
と行列を作ってと計算するのは、計算も正しくできますし、特に間違いではないと思います!既にUnityが「C#側=列優先データ順」「Shader側=行優先データ順」というところでややこしいので、どのように気を付ければ良いのか難しいところですね…
個人的な意見といたしましては、列優先/行優先のことを知らなくても、Shaderでfloat3x3(right, up, forward)
で行優先データ順の行列が作られることは知っている人が多いような気もしますし、私のように行列の並びで覚えている人も多い?ような気もするので、行優先表現に変えてしまうと、読み間違えたり & 可読性は下がる ことになっちゃうような気がします。かく言う自分も全然意識していなかったので、次から気を付けようと思いました。
接空間行列を作る際の注意点
接空間行列はまさにShader上で行列を作るケースです。
接空間に変換する行列を作ったつもりが、実は逆行列になってしまっていたということもなりかねないので注意が必要です。
Unityの場合は、C#側とShader側で列優先データ順/行優先データ順が変わるということもあり、ここで列優先/行優先の話をしたかったという感じです。特に接空間のようなShaderで行列を作ることに関しては、このUnityの仕様でややこしさが倍増しているように思います。
また、Unityの用意されている関数にUnityWorldSpaceLightDir(vertexPos)
やUnityWorldSpaceViewDir(vertexPos)
等ありますが、
ライトやカメラの向きではなく、ライトやカメラへの方向を返す仕様なのも若干トラップなので注意が必要かもしれないです。
まとめ
色々なサイトでまとまっていたものを、さらに関係性が強いもの同士をまとめたような記事になりました。接空間の存在は知ってたけど、メリットが良く分かっていなかったので、同じような認識の人に届いたら嬉しいです。
1点使用上の注意としまして、モデルのインポート時に normals および tangets を取得するよう必ず行ってください。また、MeshFilter.meshの頂点データ(バッファ)に normals および tangents が入力されていないと固定値となってしまい、正しく計算できません。特に頂点データを動的に変更する場合等にご注意いただければと思います。
参考資料
*1:モデルのインポート時に normals および tangets を計算かインポートしてください。又、MeshFilter.meshの頂点データ(バッファ)を動的に設定する場合、 normals および tangents に入力しておかないと固定値となってしまうのでご注意ください