標籤:

【翻譯】兩天學會光線追蹤(一)

【翻譯】兩天學會光線追蹤(一)

來自專欄 蒸汽機車

來源在這裡:Ray Tracing in One Weekend 電子書安利文 ,感謝 @秦春林THEGIBOOK 大佬的安利。

所以真的不是我標題胡吹大氣,這個命名就是這樣。

嘛,說翻譯也不對——原作者使用的是C++,而我用的是C#,而且敘述的時候也不會按照電子書來寫。總之就是一篇關於自己的理解的文章。

順帶一提本來我想完成第一部中提到的內容之後,再作為一整篇文章全發出來的,但是發現後面重構的地方有點多,所以就先發一篇稿子吧。


前言

為圖直觀和測試方便,本文使用的是Unity編輯器拓展插件的形式實現。

下文函數一律由如下形式使用:

CreatePng(WIDTH, HEIGHT, CreateColorForTestPNG(WIDTH, HEIGHT));

生成圖像

要做離線渲染器肯定要出圖,這裡我們導出成PNG格式。

Color[] CreateColorForTestPNG(int width, int height) { int l = width * height; Color[] colors = new Color[l]; for (int j = height - 1; j >= 0; j--) for (int i = 0; i < width; i++) { colors[i + j * width] = new Color( i / (float)width, j / (float)height, 0.2f); } return colors; }void CreatePng(int width, int height, Color[] colors) { if (width * height != colors.Length) { EditorUtility.DisplayDialog("ERROR", "長寬與數組長度無法對應!", "ok"); return; } Texture2D tex = new Texture2D(width, height, TextureFormat.ARGB32, false); tex.SetPixels(colors); tex.Apply(); byte[] bytes = tex.EncodeToPNG(); FileStream fs = new FileStream(IMG_PATH, FileMode.Create); BinaryWriter bw = new BinaryWriter(fs); bw.Write(bytes); fs.Close(); bw.Close(); }

效果如下:

射線

我們假定觀測者位於原點處,他將觀測上圖所示的截面。為了獲得每個像素的顏色,觀測者將向該像素所在的位置發射出一條射線進行採樣。因此我們新建了名為Ray的類:

public class Ray { public Vector3 original; public Vector3 direction; public Vector3 normalDirection; public Ray(Vector3 o, Vector3 d) { original = o; direction = d; normalDirection = d.normalized; } public Vector3 GetPoint(float t) { return original + t * direction; } }

有了Ray之後,我們就可以進行掃描工作了。

#region 第二版(測試射線、簡單的攝像機和背景) Color GetColorForTestRay(Ray ray) { float t = 0.5f * (ray.normalDirection.y + 1f); return (1 - t) * new Color(1,1,1) + t * new Color(0.5f, 0.7f, 1); } Color[] CreateColorForTestRay(int width, int height) { //視錐體的左下角、長寬和起始掃射點設定 Vector3 lowLeftCorner = new Vector3(-2, -1, -1); Vector3 horizontal = new Vector3(4, 0, 0); Vector3 vertical = new Vector3(0, 2, 0); Vector3 original = new Vector3(0, 0, 0); int l = width * height; Color[] colors = new Color[l]; for (int j = height - 1; j >= 0; j--) for (int i = 0; i < width; i++) { Ray r = new Ray(original, lowLeftCorner + horizontal * i / (float)width + vertical * j / (float)height); colors[i + j * width] = GetColorForTestRay(r); } return colors; } #endregion

效果如下:

簡單的球體

球體是非常簡單的形體,因為只需要一個球心和一個半徑,就能確認球體的位置。

若我們在場景中放置一個球體,那麼對球體進行採樣的時候就會涉及到如何判斷射線與球體相交的問題。上圖給出了示意圖。

假設一條射線為 mathbf{A}+t*mathbf{B} ,有一球體圓心為 mathbf{C} ,球上任意一點為 mathbf{p} 。如果射線與球只有一個交點,也就是使射線與球相切於 mathbf{p} 時,有:

dot((mathbf{A}+t*mathbf{B}-mathbf{C}),(mathbf{A}+t*mathbf{B}-mathbf{C}))=R*R

R為球體半徑,展開後有:

t*t*dot(mathbf{B},mathbf{B})+2*t*dot(mathbf{B}, mathbf{A}-mathbf{C})+dot(mathbf{A}-mathbf{C}, mathbf{A}-mathbf{C})-R*R=0

通過求根公式我們可以很容易的求出t,然後可得到 mathbf{p} 的位置:

#region 第三版(測試一個簡單的球體) bool isHitSphereForTestSphere(Vector3 center, float radius, Ray ray) { var oc = ray.original - center; float a = Vector3.Dot(ray.direction, ray.direction); float b = 2f * Vector3.Dot(oc, ray.direction); float c = Vector3.Dot(oc, oc) - radius * radius; //實際上是判斷這個方程有沒有根,如果有2個根就是擊中 float discriminant = b * b - 4 * a * c; return (discriminant > 0); } Color GetColorForTestSphere(Ray ray) { if (isHitSphereForTestSphere(new Vector3(0, 0, -1), 0.5f, ray)) return new Color(1, 0, 0); float t = 0.5f * ray.normalDirection.y + 1f; return (1 - t) * new Color(1, 1, 1) + t * new Color(0.5f, 0.7f, 1); } Color[] CreateColorForTestSphere(int width, int height) { //視錐體的左下角、長寬和起始掃射點設定 Vector3 lowLeftCorner = new Vector3(-2, -1, -1); Vector3 horizontal = new Vector3(4, 0, 0); Vector3 vertical = new Vector3(0, 2, 0); Vector3 original = new Vector3(0, 0, 0); int l = width * height; Color[] colors = new Color[l]; for (int j = height - 1; j >= 0; j--) for (int i = 0; i < width; i++) { Ray r = new Ray(original, lowLeftCorner + horizontal * i / (float)width + vertical * j / (float)height); colors[i + j * width] = GetColorForTestSphere(r); } return colors; } #endregion

效果如下:

球體的法線

法線對於後續的計算是非常重要的,在前一部分中我們只返回了bool值作為是否命中的標準,這裡我們把布爾值轉化成float形。

#region 第四版(測試球體的表面法線) float HitSphereForTestNormal(Vector3 center, float radius, Ray ray) { var oc = ray.original - center; float a = Vector3.Dot(ray.direction, ray.direction); float b = 2f * Vector3.Dot(oc, ray.direction); float c = Vector3.Dot(oc, oc) - radius * radius; //實際上是判斷這個方程有沒有根,如果有2個根就是擊中 float discriminant = b * b - 4 * a * c; if (discriminant < 0) { return -1; } else { //返回距離最近的那個根 return (-b - Mathf.Sqrt(discriminant)) / (2f * a); } } Color GetColorForTestNormal(Ray ray) { float t = HitSphereForTestNormal(new Vector3(0, 0, -1), 0.5f, ray); if (t > 0) { Vector3 normal = Vector3.Normalize(ray.GetPoint(t) - new Vector3(0,0,-1)); return 0.5f * new Color(normal.x + 1, normal.y + 1, normal.z + 1, 2f); } t = 0.5f * ray.normalDirection.y + 1f; return (1 - t) * new Color(1, 1, 1) + t * new Color(0.5f, 0.7f, 1); } Color[] CreateColorForTestNormal(int width, int height) { //視錐體的左下角、長寬和起始掃射點設定 Vector3 lowLeftCorner = new Vector3(-2, -1, -1); Vector3 horizontal = new Vector3(4, 0, 0); Vector3 vertical = new Vector3(0, 2, 0); Vector3 original = new Vector3(0, 0, 0); int l = width * height; Color[] colors = new Color[l]; for (int j = height - 1; j >= 0; j--) for (int i = 0; i < width; i++) { Ray r = new Ray(original, lowLeftCorner + horizontal * i / (float)width + vertical * j / (float)height); colors[i + j * width] = GetColorForTestNormal(r); } return colors; } #endregion

效果如下:

將Hit抽象出來

這裡就要談到Signed Distance Field,譯為有向距離場、帶符號距離場都行。GPU Gems 3中是這麼描述sdf的:「SDF是由到(多邊形模型)物體表面最近距離的採樣網格。作為慣例,使用負值來表示物體內部,使用正值表示物體外部。SDF理念對於圖形圖像及相關領域具有很大的誘惑力。它經常被用於布料動畫碰撞檢測、多物體動力學、變形物體、mesh網格生成、運動規劃和雕刻。」

@Milo Yip 的《用C語言畫光》系列專欄深入淺出地撰寫了如何渲染在平面上傳播的光,是非常難得的教程,強力推薦。請允許我引用大佬對SDF的描述(引自用 C 語言畫光(一):基礎):

phi(mathbf{x}) > 0 ,表示坐標 mathbf{x} 位於場景形狀之外,且 mathbf{x} 與最近形狀邊界的距離為 phi(mathbf{x})

phi(mathbf{x}) < 0 ,表示坐標 mathbf{x} 位於場景形狀之內,且 mathbf{x} 與最近形狀邊界的距離為 -phi(mathbf{x})

phi(mathbf{x})= 0 ,說明 mathbf{x} 剛好在形狀邊界上。

例如,圓心為 mathbf{c} 、半徑為 r 的圓形 SDF 定義為(更精確的說法是圓盤/disk):

phi_	ext{circle}(mathbf{x}) = left | mathbf{x} - mathbf{c} 
ight |-r	ag{3}

只不過我們這裡要處理的是球體而已。我們先定義好一個抽象類Hitable,其餘SDF類都將繼承它並實現Hit方法,該方法描述了特定的SDF。通過一個集合(HitableList),我們可以將SDF組合成各式各樣的形狀。

在命中SDF之後,我們將得到一個特定的HitRecord來描述此次命中。現在我們只需要描述三個信息:延長長度、交點、交點處的法線方向。之後我們還會新增更多的信息。

public class HitRecord { public float t; public Vector3 p; public Vector3 normal; } public abstract class Hitable { public abstract bool Hit(Ray ray, float t_min, float t_max, ref HitRecord rec); } public class Sphere : Hitable { public Vector3 center; public float radius; public Sphere (Vector3 cen, float rad) { center = cen; radius = rad; } public override bool Hit(Ray ray, float t_min, float t_max, ref HitRecord rec) { var oc = ray.original - center; float a = Vector3.Dot(ray.direction, ray.direction); float b = 2f * Vector3.Dot(oc, ray.direction); float c = Vector3.Dot(oc, oc) - radius * radius; //實際上是判斷這個方程有沒有根,如果有2個根就是擊中 float discriminant = b * b - 4 * a * c; if (discriminant > 0) { //帶入並計算出最靠近射線源的點 float temp = (-b - Mathf.Sqrt(discriminant)) / a * 0.5f; if (temp < t_max && temp > t_min) { rec.t = temp; rec.p = ray.GetPoint(rec.t); rec.normal = (rec.p - center).normalized; return true; } //否則就計算遠離射線源的點 temp = (-b + Mathf.Sqrt(discriminant)) / a * 0.5f; if (temp < t_max && temp > t_min) { rec.t = temp; rec.p = ray.GetPoint(rec.t); rec.normal = (rec.p - center).normalized; return true; } } return false; } } public class HitableList : Hitable { public List<Hitable> list; public HitableList() { list = new List<Hitable>(); } /// <summary> /// 返回所有Hitable中最靠近射線源的命中信息 /// </summary> /// <param name="ray"></param> /// <param name="t_min"></param> /// <param name="t_max"></param> /// <param name="rec"></param> /// <returns></returns> public override bool Hit(Ray ray, float t_min, float t_max, ref HitRecord rec) { HitRecord tempRecord = new HitRecord(); bool hitAnything = false; float closest = t_max; foreach(var h in list) { if (h.Hit(ray, t_min, closest, ref tempRecord)) { hitAnything = true; closest = tempRecord.t; rec = tempRecord; } } return hitAnything; } }

通過將Hit這一過程抽象出來之後,我們製作兩個球,一個大球用於充作地表,一個小球用於顯示。

#region 第五版(測試Hit的抽象) Color GetColorForTestHitRecord(Ray ray, HitableList hitableList) { HitRecord record = new HitRecord(); if (hitableList.Hit(ray, 0f, float.MaxValue, ref record)) { return 0.5f * new Color(record.normal.x + 1, record.normal.y + 1, record.normal.z + 1, 2f); } float t = 0.5f * ray.normalDirection.y + 1f; return (1 - t) * new Color(1, 1, 1) + t * new Color(0.5f, 0.7f, 1); } Color[] CreateColorForTestHitRecord(int width, int height) { //視錐體的左下角、長寬和起始掃射點設定 Vector3 lowLeftCorner = new Vector3(-2, -1, -1); Vector3 horizontal = new Vector3(4, 0, 0); Vector3 vertical = new Vector3(0, 2, 0); Vector3 original = new Vector3(0, 0, 0); int l = width * height; HitableList hitableList = new HitableList(); hitableList.list.Add(new Sphere(new Vector3(0, 0, -1), 0.5f)); hitableList.list.Add(new Sphere(new Vector3(0, -100.5f, -1), 100f)); Color[] colors = new Color[l]; for (int j = height - 1; j >= 0; j--) for (int i = 0; i < width; i++) { Ray r = new Ray(original, lowLeftCorner + horizontal * i / (float)width + vertical * j / (float)height); colors[i + j * width] = GetColorForTestHitRecord(r, hitableList); } return colors; } #endregion

效果如下:

蒙特卡洛

在上圖中,我們注意到球面的邊緣還是非常銳利的。因為相對於一個很低的解析度來說,每個像素只發射一條射線去採樣顯得尤其不足。為了在邊緣處獲得更好的表現,我們自然而然想到的是向一個像素中裝填更多的射線去採樣。

但是這些射線該如何分布呢?這背後就要用到蒙特卡洛模擬法了。由概率定義知,某事件的概率可以用大量試驗中該事件發生的頻率來估算,當樣本容量足夠大時,可以認為該事件的發生頻率即為其概率。因此,可以先對影響其可靠度的隨機變數進行大量的隨機抽樣,然後把這些抽樣值一組一組地代入功能函數式,確定結構是否失效,最後從中求得結構的失效概率。蒙特卡羅法正是基於此思路進行分析的。

感謝當代計算機的充足算力,加之離線渲染時我們對時間的相對不敏感,像素內填充的射線隨機發射即可,10條射線效果不夠就50條,50條不夠就100條,500條。這裡我們選擇使用每像素100條射線。

#region 第六版(測試抗鋸齒) Color GetColorForTestAntialiasing(Ray ray, HitableList hitableList) { HitRecord record = new HitRecord(); if (hitableList.Hit(ray, 0f, float.MaxValue, ref record)) { return 0.5f * new Color(record.normal.x + 1, record.normal.y + 1, record.normal.z + 1, 2f); } float t = 0.5f * ray.normalDirection.y + 1f; return (1 - t) * new Color(1, 1, 1) + t * new Color(0.5f, 0.7f, 1); } Color[] CreateColorForTestAntialiasing(int width, int height) { //視錐體的左下角、長寬和起始掃射點設定 Vector3 lowLeftCorner = new Vector3(-2, -1, -1); Vector3 horizontal = new Vector3(4, 0, 0); Vector3 vertical = new Vector3(0, 2, 0); Vector3 original = new Vector3(0, 0, 0); int l = width * height; HitableList hitableList = new HitableList(); hitableList.list.Add(new Sphere(new Vector3(0, 0, -1), 0.5f)); hitableList.list.Add(new Sphere(new Vector3(0, -100.5f, -1), 100f)); Color[] colors = new Color[l]; Camera camera = new Camera(original, lowLeftCorner, horizontal, vertical); float recip_width = 1f / width; float recip_height = 1f / height; for (int j = height - 1; j >= 0; j--) for (int i = 0; i < width; i++) { Color color = new Color(0,0,0); for (int s = 0; s < SAMPLE; s++) { Ray r = camera.CreateRay((i + Random.Range(0f, 1f)) * recip_width, (j + Random.Range(0f, 1f)) * recip_height); color += GetColorForTestAntialiasing(r, hitableList); } color *= SAMPLE_WEIGHT; color.a = 1f; colors[i + j * width] = color; } return colors; } #endregion

從下圖效果可以看到,由於使用了複數隨機的射線採樣,邊緣處的像素顯得更為模糊,打到了抗鋸齒的效果。

散射模型

現實生活中,我們經常接觸到一些看起來暗淡粗糙的物體,他們之所以顯得不那麼光鮮,是因為當光線照射到他們之上時,其凹凸不平的表面令光線缺乏統一的反射方向。

為了模擬該效果,我們在光線入射點正上方取一個單位球體,在其內部隨機取一個點作為其反射的方向,然後從入射點方向再次發射一條射線進行採樣。

#region 第七版(測試Diffuse) //此處用於取得無序的反射方向,並用於模擬散射模型 Vector3 GetRandomPointInUnitSphereForTestDiffusing() { Vector3 p = 2f * new Vector3(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f)) - Vector3.one; p = p.normalized * Random.Range(0f, 1f); //Vector3 p = Vector3.zero; //do //{ // p = 2f * new Vector3(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f)) - Vector3.one; //} //while (p.sqrMagnitude > 1f); //效率有點低了 return p; } Color GetColorForTestDiffusing(Ray ray, HitableList hitableList) { HitRecord record = new HitRecord(); if (hitableList.Hit(ray, 0.0001f, float.MaxValue, ref record)) { Vector3 target = record.p + record.normal + GetRandomPointInUnitSphereForTestDiffusing(); //此處假定有50%的光被吸收,剩下的則從入射點開始取隨機方向再次發射一條射線 return 0.5f * GetColorForTestDiffusing(new Ray(record.p, target - record.p), hitableList); } float t = 0.5f * ray.normalDirection.y + 1f; return (1 - t) * new Color(1, 1, 1) + t * new Color(0.5f, 0.7f, 1); } Color[] CreateColorForTestDiffusing(int width, int height) { //視錐體的左下角、長寬和起始掃射點設定 Vector3 lowLeftCorner = new Vector3(-2, -1, -1); Vector3 horizontal = new Vector3(4, 0, 0); Vector3 vertical = new Vector3(0, 2, 0); Vector3 original = new Vector3(0, 0, 0); int l = width * height; HitableList hitableList = new HitableList(); hitableList.list.Add(new Sphere(new Vector3(0, 0, -1), 0.5f)); hitableList.list.Add(new Sphere(new Vector3(0, -100.5f, -1), 100f)); Color[] colors = new Color[l]; Camera camera = new Camera(original, lowLeftCorner, horizontal, vertical); float recip_width = 1f / width; float recip_height = 1f / height; for (int j = height - 1; j >= 0; j--) for (int i = 0; i < width; i++) { Color color = new Color(0, 0, 0); for (int s = 0; s < SAMPLE; s++) { Ray r = camera.CreateRay((i + Random.Range(0f, 1f)) * recip_width, (j + Random.Range(0f, 1f)) * recip_height); color += GetColorForTestDiffusing(r, hitableList); } color *= SAMPLE_WEIGHT; //為了使球體看起來更亮,改變gamma值 //color = new Color(Mathf.Sqrt(color.r), Mathf.Sqrt(color.g), Mathf.Sqrt(color.b), 1f); color.a = 1f; colors[i + j * width] = color; } return colors; } #endregion

值得注意的是最後部分的gamma值修改。簡而言之,gamma函數就是物理光強和主觀灰度之間的映射關係,因為人眼中的灰度並非線性,而gamma函數恰好可以用一個冪函數表示。因此這裡使用了開平方的方式來提高亮度。

效果如下,分別為修改gamma值前和之後的效果:


暫時就寫這麼多,github之後再說,在加班這麼多天之後,我……需……要……休假!!!!!!!!!!!!!!!!!!!!


推薦閱讀:

一款針對離線渲染的基於Nvidia Optix的AI加速降噪工具
Ray Marching 101

TAG:光線跟蹤 |