Skip to main content

3Dモデルの仕組み(その2)

模様を付けましょう:UV座標

今までのモデルはすべて白い色で表示してきたが、実際にゲームで使うものは一定色ではない。前回作成した「MyCube」をベースにし、複雑な模様(テクスチャ)を付けてみましょう。具体的に、以下のようなサイコロを作ってみましょう

image.png

この効果を再現するため、2次元の画像を3次元の立方体に貼らないといけないが、そのためなんとかして、「ペーパークラフト」のように3Dのオブジェクトを真っ平にし、2Dに展開しなければならない。

image.png

image.png

3Dモデルの場合、このプロセスは「UV展開」という。「UV」は「正規化した画像座標」のことである。「正規化」とは画像のサイズに関係なく、必ず0〜1の間にある座標系である。

以下、TimmyがUV展開された画像である:

image.png

画像のサイズが異なっても、どちらとも同じUV座標系(0〜1)を持っているため、モデル自体はそのまま使える。

テクスチャサイズ:4096 x 4096

テクスチャサイズ:256 x 256

image.png

image.png

image.png

image.png

今回のサイコロのテクスチャは以下にある

image.png

これで、メッシュを作るときに、頂点ごとに「UV座標」を与え、それぞれの三角形は画像のどの部分を使えば良いか指定しなければならない。これおで、テクスチャを貼り付けるときに、正しく模様を表示される。

課題

「MyCube」を編集し、UV座標を追加してください。

  • CreateUVsのメソッドを追加し、上に表示された画像に基づいて、メッシュにUV座標を追加してください
  • たらしいマテリアルを作成し、Base Map をサイコロのテクスチャにし、結果を確認
MyCube スクリプト(編集分)
// UV座標
private Vector2[] uvs;

// Awake は Start よりも一歩速く実行する
// 内部初期化(他のコンポーネントに依存しない)ときにはおすすめ
private void Awake()
{
    CreateVertices();
    CreateTriangles();
    CreateUVs();

    mesh = new Mesh();
    mesh.vertices = vertices;
    mesh.triangles = triangles;
    mesh.uv = uvs;
    mesh.RecalculateNormals(); // 法線ベクトルを自動計算(※)
    
    // ゲームオブジェクトにMeshFilterを追加
    MeshFilter mf = gameObject.AddComponent<MeshFilter>();
    mf.mesh = mesh;
    
    // MeshRendererも追加
    MeshRenderer mr = gameObject.AddComponent<MeshRenderer>();
    mr.material = material;
}

// UV座標を作成する
// 返す:UV座標(2D)の配列
private void CreateUVs()
{
    uvs = new Vector2[24];

    // 前 (1)
    uvs[0] = new Vector2(0.375f, 0.75f);
    uvs[1] = new Vector2(0.625f, 0.75f);
    uvs[2] = new Vector2(0.625f, 1.00f);
    uvs[3] = new Vector2(0.375f, 1.00f);

    // 上 (3)
    uvs[11] = new Vector2(0.375f, 0.50f);
    uvs[18] = new Vector2(0.625f, 0.50f);
    uvs[22] = new Vector2(0.625f, 0.75f);
    uvs[23] = new Vector2(0.375f, 0.75f);

    // 後ろ (6)
    uvs[12] = new Vector2(0.625f, 0.25f);
    uvs[13] = new Vector2(0.375f, 0.25f);
    uvs[14] = new Vector2(0.375f, 0.50f);
    uvs[15] = new Vector2(0.625f, 0.50f);

    // 下(4)
    uvs[16] = new Vector2(0.375f, 0.00f);
    uvs[17] = new Vector2(0.625f, 0.00f);
    uvs[21] = new Vector2(0.625f, 0.25f);
    uvs[20] = new Vector2(0.375f, 0.25f);

    // 右 (5)
    uvs[9] = new Vector2(0.625f, 0.50f);
    uvs[5] = new Vector2(0.875f, 0.50f);
    uvs[6] = new Vector2(0.875f, 0.75f);
    uvs[10] = new Vector2(0.625f, 0.75f);

    // 左 (2)
    uvs[8] = new Vector2(0.375f, 0.50f);
    uvs[4] = new Vector2(0.125f, 0.50f);
    uvs[7] = new Vector2(0.125f, 0.75f);
    uvs[19] = new Vector2(0.375f, 0.75f);
}

光の計算の入門:法線ベクトル

3Dでの光の計算が大変複雑なテーマであるが、最も単純なケースから光の計算、および法線ベクトルについて理解していきましょう。まず、以下のようなシーンについて考えてみましょう。

image.png

image.png

図:光源、光線と表面の関係。ここで一番単純な光源を使用(すべての光線は並行である)

この場合は、光源からいくつかの光線が発射し、表面に当たる。ここで、平面の傾きにより、明るさがどう変わるのか解析してみましょう。表面が光源と真正面になるときに、一番明るく見えるが、表面が表面が光線と並行すると、光が当たらず、真っ黒になってしまう。

image.png

image.png

loading.gifimage.png

image.png

image55.gif

従って、表面の明るさを計算するため、その「向き」は必須な乗法である。この「表面の向き」は単位ベクトルとして表現し、「法線ベクトル」と呼ぶ。(英:Normal Vector)

image.png

なので、光線と法線ベクトルの角度「0度」であるとき、表面の明るさの「倍率」は「1」である。逆に、光線と法線ベクトルの角度が「90度」であると、表面の明るさの「倍率」は「0」になる。

「0度」のときに「1」になり、「90度」の時「0」になる関数はすでにある!そう、「コサイン」だ!また、内積で2つのベクトルの間にある角度のコサインを求めることができるので、光の明るさは内積で計算ができる。

image.png

image.png

内積の計算を思い出すと…

image.png

「n」(表面の法線)と「l」(光線のベクトル)は単位ベクトルだったら、ノルムは「1」となるので、結局:

image.png

Unityは光の計算をしてくれるが、3Dモデルの法線ベクトルを渡さないといけない。今までは「Mesh.RecalculateNormals()」で自動計算してきましたが、手動で調整したいときもある。その時は頂点ごとに表面の向きを設定する。

では、なぜ法線ベクトルを調整したいのか?法線ベクトルの向きによって、光の計算が変わるため、法線ベクトルを変えることにより、ゲームエンジンをダマすことができる。具体的には、真っ平の表面でも丸めたり、凸凹に見えるようにできる。

次の図を見てください:左は普通の表面であるが、法線ベクトルはすべて同じ向きになり、表面の明るさは一定であるが、わざと法線ベクトルを傾くと、場所により明るさが異なり、丸く見えるエフェクトを再現ができる

image.png

image.png

image.png

image.png

法線ベクトルを変えることにより、光の計算が変わり「丸く見える」という効果を再現できる

練習:

以下の円筒を作成するスクリプト「MyCylinder」を解析してみてください。特に、sinとcosの使い方。次、Unityで実行して、表示結果を見てください。その後、法線ベクトルを直し、丸めてみましょう。

image.png

image.png

360度の円の「n」面に割って、左の図のように、1面ずつを作っていく。高さと半径を調整できるようにしましょう。

// 円筒を作る
public class MyCylinder : MonoBehaviour
{
	// 半径
	[SerializeField]
	float radius;

	// 高さ
	[SerializeField]
	float height;

	// 面の数
	[SerializeField]
	int numSide;

	// 立方体のマテリアル
	[SerializeField] 
	private Material material;

	// 頂点の配列
	private Vector3[] vertices;
	
	// 三角形の配列
	private int[] triangles;
	
	// 作成したメッシュ
	private Mesh mesh;
	
	// Awake は Start よりも一歩速く実行する
	// 内部初期化(他のコンポーネントに依存しない)ときにはおすすめ
	private void Awake()
	{
		CreateVertices();
		CreateTriangles();

		mesh = new Mesh();
		mesh.vertices = vertices;
		mesh.triangles = triangles;
		mesh.RecalculateNormals(); // 法線ベクトルを自動計算
		
		// ゲームオブジェクトにMeshFilterを追加
		MeshFilter mf = gameObject.AddComponent<MeshFilter>();
		mf.mesh = mesh;
		
		// MeshRendererも追加
		MeshRenderer mr = gameObject.AddComponent<MeshRenderer>();
		mr.material = material;
	}
	
	// 頂点を作成する
	private void CreateVertices()
	{
		// 必要な頂点の数
		int numVertex = 10 * numSide;
		vertices = new Vector3[numVertex];

		// 必要な角度(ラジアン)
		float angle = (360.0f / numSide) * Mathf.Deg2Rad;

		// 1面ずつを作っていく
		int idx = 0;
		for (int i = 0; i < numSide; i++)
		{
			float startAngle = i * angle;
			float endAngle = startAngle + angle;

			// 上の面			
			float y = height / 2;
			vertices[idx++] = new Vector3(0, y, 0); // 0

			float x = Mathf.Cos(startAngle) * radius;
			float z = Mathf.Sin(startAngle) * radius;
			vertices[idx++] = new Vector3(x, y, z); // 1

			x = Mathf.Cos(endAngle) * radius;
			z = Mathf.Sin(endAngle) * radius;
			vertices[idx++] = new Vector3(x, y, z); // 2

			// 下の面
			y = -height / 2;
			vertices[idx++] = new Vector3(0, y, 0); // 3

			x = Mathf.Cos(startAngle) * radius;
			z = Mathf.Sin(startAngle) * radius;
			vertices[idx++] = new Vector3(x, y, z); // 4

			x = Mathf.Cos(endAngle) * radius;
			z = Mathf.Sin(endAngle) * radius;
			vertices[idx++] = new Vector3(x, y, z); // 5

			// サイド(重複)
			vertices[idx] = vertices[idx - 5]; idx++; // 1の重複 (6)
			vertices[idx] = vertices[idx - 5]; idx++; // 2の重複 (7)
			vertices[idx] = vertices[idx - 4]; idx++; // 4の重複 (8)
			vertices[idx] = vertices[idx - 4]; idx++; // 5の重複 (9)
		}
	}

    // 三角形を作成する
	private void CreateTriangles()
	{
		int triCount = 4 * 3 * numSide;
		triangles = new int[triCount];
		int idx = 0;

		for (int i = 0; i < numSide; i++)
		{
			// 指数の「オフィス」(ずらす分)
			int offset = i * 10;

			// 上
			triangles[idx++] = 1 + offset;
			triangles[idx++] = 0 + offset;
			triangles[idx++] = 2 + offset;

			// 下
			triangles[idx++] = 4 + offset;
			triangles[idx++] = 5 + offset;
			triangles[idx++] = 3 + offset;

			// サイド
			triangles[idx++] = 8 + offset;
			triangles[idx++] = 6 + offset;
			triangles[idx++] = 7 + offset;

			triangles[idx++] = 8 + offset;
			triangles[idx++] = 7 + offset;
			triangles[idx++] = 9 + offset;
		}
	}
}
MyCylinder スクリプト(編集分)
// 法線ベクトル(頂点ごと)
private Vector3[] normals;

// Awake は Start よりも一歩速く実行する
// 内部初期化(他のコンポーネントに依存しない)ときにはおすすめ
private void Awake()
{
	CreateVertices();
	CreateTriangles();
	CreateNormals();

	mesh = new Mesh();
	mesh.vertices = vertices;
	mesh.normals = normals;
	mesh.triangles = triangles;
	//mesh.RecalculateNormals(); // 法線ベクトルを自動計算
	
	// ゲームオブジェクトにMeshFilterを追加
	MeshFilter mf = gameObject.AddComponent<MeshFilter>();
	mf.mesh = mesh;
	
	// MeshRendererも追加
	MeshRenderer mr = gameObject.AddComponent<MeshRenderer>();
	mr.material = material;
}

// 各頂点の法線ベクトルを作成
private void CreateNormals()
{
	// 必要な方法ベクトルの数
	int numVertex = 10 * numSide;
	normals = new Vector3[numVertex];

	// 1面ずつを処理していく
	for (int i = 0; i < numSide; i++)
	{
		int offset = i * 10;

		// 上の面
		normals[0 + offset] = Vector3.up;
		normals[1 + offset] = Vector3.up;
		normals[2 + offset] = Vector3.up;

		// 下の面
		normals[3 + offset] = Vector3.down;
		normals[4 + offset] = Vector3.down;
		normals[5 + offset] = Vector3.down;

		// サイドの面
		// 差分で、法線ベクトルを計算(単位ベクトル)
		Vector3 n = (vertices[1 + offset] - vertices[0 + offset]).normalized;
		normals[6 + offset] = n;
		normals[8 + offset] = n;

		n = (vertices[2 + offset] - vertices[0 + offset]).normalized;
		normals[7 + offset] = n;
		normals[9 + offset] = n;
	}
}	

法線ベクトルの細かい調整:
 法線マップ(Normal Maps)