『從零開始寫渲染器』 (七) 渲染模型

『從零開始寫渲染器』 (七) 渲染模型

來自專欄從光柵到光線追蹤

為了渲染模型,首先需要了解一些基礎的計算機圖形學理論的知識。

首先是 頂點(Vertex) ,頂點是三維空間的一個點,坐標由一個三元數表示。這個頂點也會包含UV和法線信息。

然後是 三角形(Triangle),三角形是由三個頂點在三維空間中組成的。

一般情況下,三角形會有一個 法線(Normal),這個法線是一條垂直於三個頂點所在平面的直線。

而且模型(Model)或者叫 網格(Mesh)都是由許多個三角形組成的

想要在渲染期中渲染一個模型,我們需要先渲染一個單獨的三角形。

這個三角形由三個頂點初始化。我們需要解決在三角形與射線相交的判斷。

判斷相交的流程是這樣的:

  • 通過三個點確定三角形所在平面
  • 確定平面與射線的交點
  • 判斷交點是否在三角形內,如果是,返回True和相交坐標點,反之返回False.

所以我們需要在三角形類里創建這樣的一個函數

private bool Intersects(Vector3 ray_origin,Vector3 ray_dir,out Vector3 point) { }

這裡放出判斷三角形與射線相交的其中兩種實現:

private bool Intersects(Vector3 ray_origin, Vector3 ray_dir, out Vector3 point) { point = new Vector3(); var dir = ray_dir.Normalized(); var v0_v1 = v1.point - v0.point; var v0_v2 = v2.point - v0.point; var ao = ray_origin - v0.point; var D = v0_v1[0] * v0_v2[1] * (-dir[2]) + v0_v1[1] * v0_v2[2] * (-dir[0]) + v0_v1[2] * v0_v2[0] * (-dir[1]) - (-dir[0] * v0_v2[1] * v0_v1[2] - dir[1] * v0_v2[2] * v0_v1[0] - dir[2] * v0_v2[0] * v0_v1[1]); var D1 = ao[0] * v0_v2[1] * (-dir[2]) + ao[1] * v0_v2[2] * (-dir[0]) + ao[2] * v0_v2[0] * (-dir[1]) - (-dir[0] * v0_v2[1] * ao[2] - dir[1] * v0_v2[2] * ao[0] - dir[2] * v0_v2[0] * ao[1]); var D2 = v0_v1[0] * ao[1] * (-dir[2]) + v0_v1[1] * ao[2] * (-dir[0]) + v0_v1[2] * ao[0] * (-dir[1]) - (-dir[0] * ao[1] * v0_v1[2] - dir[1] * ao[2] * v0_v1[0] - dir[2] * ao[0] * v0_v1[1]); var D3 = v0_v1[0] * v0_v2[1] * ao[2] + v0_v1[1] * v0_v2[2] * ao[0] + v0_v1[2] * v0_v2[0] * ao[1] - (ao[0] * v0_v2[1] * v0_v1[2] + ao[1] * v0_v2[2] * v0_v1[0] + ao[2] * v0_v2[0] * v0_v1[1]); var a = D1 / D; var b = D2 / D; var t = D3 / D; if (t < 0) return false; if (a < 0 || b < 0 || a + b >= 1) return false; point = ray_origin + ray_dir * t; return true; }

private bool Intersects(Vector3 ray_origin,Vector3 ray_dir,out Vector3 point) { point=new Vector3(); const float EPSILON = 0.0000001f; var edge1 = v1.point - v0.point; var edge2 = v2.point - v0.point; var h = Vector3.Cross(ray_dir,edge2); var a = Vector3.Dot(edge1,h); if (a > -EPSILON && a < EPSILON)return false; var f = 1 / a; var s = ray_origin - v0.point; var u = f * (Vector3.Dot(s,h)); if (u < 0.0 || u > 1.0)return false; var q = Vector3.Cross(s,edge1); var v = f * Vector3.Dot(ray_dir,q); if (v < 0.0 || u + v > 1.0)return false; var t = f * Vector3.Dot(edge2,q); if (t <= EPSILON) return false; point = ray_origin + ray_dir * t; return true; }

在我們獲取到射線與三角形的交點以後我們需要獲取這個點的UV

Vector2 GetUV(Vector3 p) { var f1 = v0.point - p; var f2 = v1.point - p; var f3 = v2.point - p; //計算面積和因子(參數順序無關緊要): var a = Vector3.Cross(v0.point - v1.point, v0.point - v2.point).Magnitude(); // 主三角形面積 a var a1= Vector3.Cross(f2, f3).Magnitude() / a; // p1 三角形面積 / a var a2= Vector3.Cross(f3, f1).Magnitude() / a; // p2 三角形面積 / a var a3= Vector3.Cross(f1, f2).Magnitude() / a; // p3 三角形面積 / a // 找到對應於點f的uv(uv1 / uv2 / uv3與p1 / p2 / p3相關): var uv = v0.uv * a1 + v1.uv * a2 + v2.uv * a3; return uv; }

下面是Triangle類的代碼

public class Triangle:Hitable { public Vertex v0, v1, v2; private Vector3 Normal; public Shader shader; public Triangle(Vertex a, Vertex b, Vertex c, Shader shader) { v0 = a; v1 = b; v2 =c; Normal =Vector3.Cross(b.point-a.point,c.point-a.point); this.shader = shader; } Vector2 GetUV(Vector3 p) { //省略// } public override bool Hit(Ray r, float t_min, float t_max, ref HitRecord rec) { if(Vector3.Dot(Normal,r.direction)>=0)return false; //背面剔除 if (!Intersects(r.origin, r.direction.Normalized(), out var p)) return false;//沒有撞擊 rec.t = Vector3.Distance(r.origin,p); rec.p = p; rec.shader = shader; var uvw = GetUV(rec.p,out p); rec.normal = Normal; rec.u = uvw.x; rec.v = uvw.y; return true; } public override bool BoundingBox(float t0, float t1, ref AABB box) { var bl = new Vector3( //最小坐標 Mathf.Min(Mathf.Min(v0.point[0], v1.point[0]), v2.point[0]), Mathf.Min(Mathf.Min(v0.point[1], v1.point[1]), v2.point[1]), Mathf.Min(Mathf.Min(v0.point[2], v1.point[2]), v2.point[2])); var tr = new Vector3( //最大坐標 Mathf.Max(Mathf.Max(v0.point[0], v1.point[0]), v2.point[0]), Mathf.Max(Mathf.Max(v0.point[1], v1.point[1]), v2.point[1]), Mathf.Max(Mathf.Max(v0.point[2], v1.point[2]), v2.point[2])); box = new AABB(bl-new Vector3(0.1f, 0.1f, 0.1f), tr+new Vector3(0.1f, 0.1f, 0.1f)); return true; } private bool Intersects(Vector3 ray_origin,Vector3 ray_dir,out Vector3 point) { //省略// } }

和 頂點類的代碼

public class Vertex { public Vector3 point; public Vector2 uv; public Vector3 normal; public Vertex(Vector3 point, Vector3 normal, float u, float v) { this.point = point; this.normal = normal; uv=new Vector2(u,v); } }

值得注意的是在三角形 Hit函數的第一行,判斷一下射線與三角形法線的夾角,以實現 背面剔除。

在計算包圍盒的時候需要將盒子稍微擴大一丟丟,不然會使包圍盒失效,因為三角形的包圍盒在某些情況下會創建厚度為0的盒子。

這樣我們就可以渲染一個單獨的三角形了。


在能夠渲染一個單獨的三角形後,我們需要一個能夠讀取模型文件的庫,這個這樣的庫在GitHub上有很多,但是如果你打算自己寫的話,可以先從Obj文件開始,Obj模型文件是文本文件,其格式為

# 頂點列表, (x,y,z[,w]) 坐標, w是可選的 默認值為 1.0. v 0.123 0.234 0.345 1.0 v ... ... # (u,v [w])坐標中的紋理坐標列表,這些坐標在0到1之間變化,w是可選的,默認為0。 vt 0.500 1 [0] vt ... ... # (x,y,z)形式的頂點法線列表; 法線可能不是單位向量。 vn 0.707 0.000 0.707 vn ... ... # (u [,v] [,w])形式的參數空間頂點。 vp 0.310000 3.210000 2.100000 vp ... ... # 多邊形面 f 1 2 3 f 3/1 4/2 5/3 f 6/4/1 3/5/3 7/6/5 f 7//1 8//2 9//3 f ... ... # 行元素 l 5 8 1 2 4 9

在你找到了合適的庫或者自己完成了一個庫以後。我們需要構建一個模型/網格 對象

這個對象就是一個簡單的,包含了很多三角形的BVH節點,這個對象在開始會自動讀取模型然後構建BVH樹。

public class Mesh { public Vertex[] vertices; public Shader shader; public Mesh(Model model,Shader s) { shader = s; vertices = new Vertex[model.indexs.Count]; for (var i = 0; i < model.indexs.Count; i++) { var index = model.indexs[i]; var point = model.points[index]; vertices[i] = new Vertex(point, model.norlmas[i], model.uvs[i].x, model.uvs[i].y); } } public Hitable Create() { var list = new List<Hitable>(); for (var i = 0; i < vertices.Length / 3; i++) list.Add(new Triangle(vertices[3 * i], vertices[3 * i + 1], vertices[3 * i + 2], shader)); return new BVHNode(list.ToArray(), list.Count, 0, 1); } public Mesh(ObjMesh model, Shader s) { shader = s; vertices = new Vertex[model.TriangleArray.Length]; for (var i = 0; i < model.TriangleArray.Length; i++) { var index = model.TriangleArray[i]; var point = model.VertexArray[index]; vertices[i] = new Vertex(Vector3.FromObj(point), Vector3.FromObj(model.NormalArray[index]), model.UVArray[index].x, model.UVArray[index].y); } } }

然後我們可以通過

world.list.Add(new Translate(new Mesh(ObjModelLoader.ObjLoader.load("tea.obj"), new Metal(Texture.WhiteTexture, 0 )).Create(),new Vector3(0,0,0)));

來創建一個模型物體。

效果如下

這個效果並不是我們想要的,因為這個模型過於稜角分明了。

通過法線模式我們可以看到

三角形的上的每一個點的法線都是相同的。而這個法線是通過三角形兩條邊的叉積算出來的。

在讀取模型的時候我們可能會遇到三角形的頂點法線不一致,所以這時的三角形的效果更像是一個曲面。這裡我說"像"是因為,撞擊點坐標依然是在三頂點所在平面上的,但是返回的法線卻應該是曲面上的。

所以我們需要用跟計算撞擊點UV的方法一樣 計算撞擊點的法線。

Vector2 GetUV(Vector3 p,out Vector3 normal) { var f1 = v0.point - p; var f2 = v1.point - p; var f3 = v2.point - p; //計算面積和因子(參數順序無關緊要): var a = Vector3.Cross(v0.point - v1.point, v0.point - v2.point).Magnitude(); // 主三角形面積 a var a1= Vector3.Cross(f2, f3).Magnitude() / a; // p1 三角形面積 / a var a2= Vector3.Cross(f3, f1).Magnitude() / a; // p2 三角形面積 / a var a3= Vector3.Cross(f1, f2).Magnitude() / a; // p3 三角形面積 / a // 找到對應於點f的uv(uv1 / uv2 / uv3與p1 / p2 / p3相關): var uv = v0.uv * a1 + v1.uv * a2 + v2.uv * a3; // 找到對應於點f的法線(法線1 / 法線2 / 法線3與p1 / p2 / p3相關): normal = v0.normal * a1 + v1.normal * a2 + v2.normal * a3; return uv; } public override bool Hit(Ray r, float t_min, float t_max, ref HitRecord rec) { if(Vector3.Dot(Normal,r.direction)>=0)return false; if (!Intersects(r.origin, r.direction.Normalized(), out var p)) return false; rec.t = Vector3.Distance(r.origin,p); rec.p = p; rec.shader = shader; var uvw = GetUV(rec.p,out p); rec.normal = p; //【這裡講法線設為之前計算UV的時候的Out對象】 rec.u = uvw.x; rec.v = uvw.y; return true; }

我們在計算UV的函數中返回這個點的法線。但是在背面剔除的時候依然使用整個三角形的法線,以節省效率。

效果:


推薦閱讀:

PRT相關 學習筆記(零基礎)
拓幻圖形學工程師教學手冊(第三講)|一字一字敲出OpenGL學習教程
【GDC2017】Terrain Technology and Tools
Unity3D如何將圖片以正確的像素顯示在屏幕上
計算機圖形學常用術語整理

TAG:渲染器 | 計算機圖形學 | 光線跟蹤 |