読者です 読者をやめる 読者になる 読者になる

しゅみぷろ

プログラミングとか

任意の点がモデル表面上に存在する場合にその位置のUVを算出する

C# Linear Algebra Unity UnityTexturePaint

はじめに

esprog.hatenablog.com

上記の記事で行っているように、動的にUVを算出してその部分にエフェクトを掛ける場合などでは

  • あるワールド座標上の点pがシーン内に配置されたモデルの表面上の点であるかどうか調べる
  • モデル表面上の点である場合は、$p$のUVを計算する

といった処理が必要になります。 Unityの場合Physics.*cast系メソッドの出力用パラメータにRaycastHit構造体を渡すことでTextureCoordを得ることが出来ます。 このTextureCoordの算出はCalculateRaycastTexCoordという関数呼び出しによって実現されているようですが、INTERNAL_CALLとなっていて詳細までは見ることが出来ませんでした。

3Dテクスチャペイントを行ったりするのに必要な知識だと思い、後学のために自作してみましたよというのが今回のエントリです。 これによってRayを明示的に使わなくても、任意のワールド座標$p$と判定するジオメトリさえわかっていればUVを求めることが出来ます。

注意点ですが、今のところ結構負荷の高い実装になってしまっています。この負荷の大部分はメッシュの持つすべての三角形平面について調査しているためで、モデルのサーフェスを構成する三角形平面が多いほど時間がかかることになります。なんとか計算しなくても良いものを弾くことができればもう少し効率的になるかなと思います。あるいはマルチスレッドで調査対象を分割してしまうのもアリです。

そもそももっと効率のいい方法等ご存知の方がいましたらご教示いただければ幸いです。

というわけで効率化は今後の課題とし、今回は「こんな感じの計算すればUV求められるよ!」っていうのを紹介するのみにとどめます。 少々数学知識が必要になります。詳細な説明は省き、知っていることが前提になっている部分もあります。以下の記事も参考になる部分があるかと思いますので紹介しておきます。

esprog.hatenablog.com

また、今回のサンプルを含むリポジトリを公開しています。よろしかったら参考までに。

github.com

アルゴリズム

「ある点$p$とメッシュが与えられ、その点$p$がメッシュの表面に含まれるとき、点$p$のUV座標値を求めたい」

という問題に対する回答を得るにあたって、以下の手順を踏むことにしました。

  1. メッシュを構成する三角形の集合内から、三角形の同一平面上に点$p$が存在するものを調べる
  2. 1をパスした三角形が点$p$を内部に含んでいるかどうかを調べる
  3. 2をパスした三角形の面積比から点$p$のUVを求める

順にそれぞれの方法について解説していきます。

三角形の同一平面上に点$p$が存在するものを調べる

与えられるメッシュが三角形ポリゴンの集合で表現されていることを仮定します。

f:id:es_program:20160508160957p:plain:w400

$p1,p2,p3$はメッシュのもつサーフェスを構成する三角形です。$p$は任意の点で、これらは共にModelSpaceで表されるものとします。上図のようにベクトル$v1,v2,vp$を定義し、$v1$と$v2$の外積を$nv$とします。

さて、ここまで定義すれば話は簡単で、$p1,p2,p3$の構成する平面内に$p$が存在するかどうかを調べるには、$nv$と$vp$の内積が0かどうかを判定するだけです。

$nv$は$v1$と$v2$の外積なので、$v1$と$v2$に対して垂直方向のベクトルであるはずです。点$p$が平面内に存在するということは、$vp$も$nv$と垂直である、というわけです。

三角形が点$p$を内部に含んでいるかどうかを調べる

f:id:es_program:20160508162800p:plain:w400

上図のように点とベクトルを定義します。

$a,b,c$はさきほどの$p1,p2,p3$と同じものです。$ab,bc,ca,ap,bp,cp$はそれぞれの点を始点、終点とするベクトルです。

ここまで定義すればこれもとても簡単で、それぞれの外積によって得られるベクトル$ca \times ap,ab \times bp,bc \times cp$が同じ方向を向いているか判定するだけです。点pが三角形外に存在した場合、外積によって求められるベクトルの向きが逆になり、これらのベクトルは違う向きを向くことになります。同じ方向を向いているかどうかの判定には内積が使えます。

三角形の面積比から点$p$のUVを求める

ここは少々複雑です。というのも、3次元空間内で単純にUVを線形補完してしまうと、透視投影の影響で歪みが発生します。これを考慮した補完をパースペクティブコレクトと呼ぶらしいのですが、これを行うためにプロジェクション変換を行う行列を用意する必要があります。

また、今回UVの算出に用いているのは面積比による補完です。

f:id:es_program:20160508164738p:plain:w400

三角形頂点と$p$の各点をModelSpaceからProjectionSpaceに変換し、同次座標を求め、これを通常座標に変換しZ(深度)を破棄したXY平面上の点として、面積比から補完します。

パースペクティブコレクトを実現するために、通常座標に直した($w$で割った)各点を使って線形補完し、最後に$w$の逆数を線形補間したもので割っています。

実装

using System.Collections;
using UnityEngine;

/// <summary>
/// サーフェス上の点pからuvを算出する
/// </summary>
public class CalcUV : MonoBehaviour
{
  private void Update()
  {
    if(Input.GetMouseButtonDown(0))
    {
      var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
      RaycastHit hitInfo;
      if(Physics.Raycast(ray, out hitInfo))
      {
        var meshRenderer = hitInfo.transform.GetComponent<MeshFilter>();
        var mesh = meshRenderer.sharedMesh;
        for(var i = 0; i < mesh.triangles.Length; i += 3)
        {
          #region 1.ある点pが与えられた3点において平面上に存在するか

          var index0 = i + 0;
          var index1 = i + 1;
          var index2 = i + 2;

          var p1 = mesh.vertices[mesh.triangles[index0]];
          var p2 = mesh.vertices[mesh.triangles[index1]];
          var p3 = mesh.vertices[mesh.triangles[index2]];
          var p = hitInfo.transform.InverseTransformPoint(hitInfo.point);

          var v1 = p2 - p1;
          var v2 = p3 - p1;
          var vp = p - p1;

          var nv = Vector3.Cross(v1, v2);
          var val = Vector3.Dot(nv, vp);
          //適当に小さい少数値で誤差をカバー
          var suc = -0.000001f < val && val < 0.000001f;

          #endregion 1.ある点pが与えられた3点において平面上に存在するか

          #region 2.同一平面上に存在する点pが三角形内部に存在するか

          if(!suc)
            continue;
          else
          {
            var a = Vector3.Cross(p1 - p3, p - p1).normalized;
            var b = Vector3.Cross(p2 - p1, p - p2).normalized;
            var c = Vector3.Cross(p3 - p2, p - p3).normalized;

            var d_ab = Vector3.Dot(a, b);
            var d_bc = Vector3.Dot(b, c);

            suc = 0.999f < d_ab && 0.999f < d_bc;
          }

          #endregion 2.同一平面上に存在する点pが三角形内部に存在するか

          #region 3.点pのUV座標を求める

          if(!suc)
            continue;
          else
          {
            var uv1 = mesh.uv[mesh.triangles[index0]];
            var uv2 = mesh.uv[mesh.triangles[index1]];
            var uv3 = mesh.uv[mesh.triangles[index2]];

            //PerspectiveCollect(透視投影を考慮したUV補間)
            Matrix4x4 mvp = Camera.main.projectionMatrix * Camera.main.worldToCameraMatrix * hitInfo.transform.localToWorldMatrix;
            //各点をProjectionSpaceへの変換
            Vector4 p1_p = mvp * p1;
            Vector4 p2_p = mvp * p2;
            Vector4 p3_p = mvp * p3;
            Vector4 p_p = mvp * p;
            //通常座標への変換(ProjectionSpace)
            Vector2 p1_n = new Vector2(p1_p.x, p1_p.y) / p1_p.w;
            Vector2 p2_n = new Vector2(p2_p.x, p2_p.y) / p2_p.w;
            Vector2 p3_n = new Vector2(p3_p.x, p3_p.y) / p3_p.w;
            Vector2 p_n = new Vector2(p_p.x, p_p.y) / p_p.w;
            //頂点のなす三角形を点pにより3分割し、必要になる面積を計算
            var s = 0.5f * ((p2_n.x - p1_n.x) * (p3_n.y - p1_n.y) - (p2_n.y - p1_n.y) * (p3_n.x - p1_n.x));
            var s1 = 0.5f * ((p3_n.x - p_n.x) * (p1_n.y - p_n.y) - (p3_n.y - p_n.y) * (p1_n.x - p_n.x));
            var s2 = 0.5f * ((p1_n.x - p_n.x) * (p2_n.y - p_n.y) - (p1_n.y - p_n.y) * (p2_n.x - p_n.x));
            //面積比からuvを補間
            var u = s1 / s;
            var v = s2 / s;
            var w = 1 / ((1 - u - v) * 1 / p1_p.w + u * 1 / p2_p.w + v * 1 / p3_p.w);
            var uv = w * ((1 - u - v) * uv1 / p1_p.w + u * uv2 / p2_p.w + v * uv3 / p3_p.w);

            //uvが求まったよ!!!!
            Debug.Log(uv + ":" + hitInfo.textureCoord);
            return;
          }

          #endregion 3.点pのUV座標を求める
        }
      }
    }
  }
}

マウスクリック位置のUVを求めるサンプルです。最後にUnityのRaycastHitのTextureCoordと照らしあわせて確認するログを表示させています。

f:id:es_program:20160508153928g:plain:w400

f:id:es_program:20160508154345g:plain:w400

f:id:es_program:20160508154522g:plain:w400

ちゃんと正しい結果が得られているかと思います。これで、位置さえわかればUVを算出することができます。

追記

バグ見つけました。 詳細は以下で。

esprog.hatenablog.com

esprog.hatenablog.com