TinyRayTracer 用256行C++代碼構建一個可理解的光線追蹤器(1)

本文翻譯自:https://github.com/ssloy/tinyraytracer/wiki/Part-1:-understandable-raytracing

我現在在學圖形學相關的知識,看到知乎上有人推薦這一系列的教程,覺得很不錯。但直接看英文有些地方不太好理解,就試着翻譯了一篇,翻譯文章真的能逼自己逐字逐句把文章看完。這個系列的教程信息量很大,代碼量很少很適合像我這樣的初學者理解圖形學的一些基本原理。

第一部分:可理解的光線追蹤器

這篇是我 簡明計算機圖形學課程 中的一章。

這回我們來討論光線追蹤。照慣例我將避免使用第三方庫,這樣可以讓學生們知道在底層發生了什麼,也解釋了 tinykaboom project 用到的一些原理。

網上有大量的光線追蹤相關的文章,然而問題在於幾乎所有的這些文章都在展示那些成熟的非常難以理解程序。舉個例子,非常著名的 明信片光線追蹤器 挑戰。這段簡潔的程序令人印象深刻,但卻很難讓人理解它是怎麼工作的。我想詳細地教你怎樣實現,而不是向你展示我能完成圖像渲染。

注意:僅看我的代碼,或僅手捧一杯茶看這篇文章是沒有意義的。本文旨在讓你敲起鍵盤實現你自己的渲染引擎。你寫的渲染引擎會比我的還要好,你會體驗到編程的樂趣。

今天的目標是學習如何渲染一幅這樣的圖片:

out-envmap-duck

第 1 步:向磁盤中寫入圖片

我不想麻煩地使用窗口管理器、鼠標/鍵盤或者類似的東西。我們程序的結果將是一張存儲在磁盤上的圖片。所以,我們要做的第一件事是把圖片保存到磁盤上。 在這裏你可以找到保存圖片的代碼。我列出主文件中的內容:

#include <limits>
#include <cmath>
#include <iostream>
#include <fstream>
#include <vector>
#include "geometry.h"

void render() {
    const int width    = 1024;
    const int height   = 768;
    std::vector<Vec3f> framebuffer(width*height);

    for (size_t j = 0; j<height; j++) {
        for (size_t i = 0; i<width; i++) {
            framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0);
        }
    }

    std::ofstream ofs; // 把幀緩存保存到文件中
    ofs.open("./out.ppm");
    ofs << "P6\n" << width << " " << height << "\n255\n";
    for (size_t i = 0; i < height*width; ++i) {
        for (size_t j = 0; j<3; j++) {
            ofs << (char)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j])));
        }
    }
    ofs.close();
}

int main() {
    render();
    return 0;
}

main 函數中只調用了 render() 函數。 render() 函數裏面有什麼呢?首先,我定義 framebuffer 爲一個值類型爲Vec3f的一維數組,這些三維向量只是簡單地表示每個像素的 (r,g,b) 值。向量類在geometry.h中定義,我不會在文中介紹它們:二維和三維向量的運算真的很瑣碎(加、減、賦值、向量與標量相乘、數量積等)。

我把圖片保存在 ppm 格式 中,這是保存圖片最簡單的方法,儘管進一步查看圖片不是這麼方便。如果你想保存爲其他格式,我建議你使用第三方庫,例如 stb. 這是個很棒的庫:你只需要在項目中包含頭文件 stb_image_write.h 就可以用它來保存大多數圖片格式。

警告: 我的代碼有很多 ”bug“,我在隨後的代碼提交中修復了它們,但是之前提交的代碼會受到影響。如果你遇到了問題,可以查看這個 issue.

這一步的目的是確保我們能:a)在內存中創建圖片並給像素賦予不同的顏色 b)把結果保存到硬盤上。之後你可以通過第三方軟件查看它。下面是結果:

out

第 2 步,最重要的一步:光線追蹤

這是整個步驟鏈中最重要最難的一步。我想在代碼中定義一個球體並畫出來(不考慮材質和光照)。我們得到的圖片像下圖這樣:

out1

爲了方便,我每完成一個步驟後在我的代碼倉庫中做一次提交,GitHub 使得查看代碼變化變得很容易。 舉個例子,這裏展示了第二次代碼提交後的變化。

首先,我們怎樣才能在計算機內存中表示球體?四個數字就夠了:一個三維向量表示球的中心點,一個標量表示半徑:

struct Sphere {
    Vec3f center;
    float radius;

    Sphere(const Vec3f &c, const float &r) : center(c), radius(r) {}

    bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const {
        Vec3f L = center - orig;
        float tca = L*dir;
        float d2 = L*L - tca*tca;
        if (d2 > radius*radius) return false;
        float thc = sqrtf(radius*radius - d2);
        t0       = tca - thc;
        float t1 = tca + thc;
        if (t0 < 0) t0 = t1;
        if (t0 < 0) return false;
        return true;
    }
};

這段代碼中唯一重要的部分是一個允許你檢測一個給定光線(源於 orig 射向 dir )是否與我們的球體相交的函數。關於光線與球的相交檢測算法的詳細描述可以查看這裏,我很建議你先看看這個然後再來看我的代碼。

光線追蹤是怎麼工作的?很簡單。在第一步中我們僅用漸變的顏色填充圖片:

    for (size_t j = 0; j<height; j++) {
        for (size_t i = 0; i<width; i++) {
            framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0);
        }
    }

現在對於每個像素,我們生成一束從源頭射出的穿過該像素的光線,之後檢查該光線與球體是否相交:

raytracer
如果沒有和球體相交就用color1畫出該像素,否則用color2畫該像素:

Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) {
    float sphere_dist = std::numeric_limits<float>::max();
    if (!sphere.ray_intersect(orig, dir, sphere_dist)) {
        return Vec3f(0.2, 0.7, 0.8); // 背景色
    }
    return Vec3f(0.4, 0.4, 0.3);
}

void render(const Sphere &sphere) {[...]
    for (size_t j = 0; j<height; j++) {
        for (size_t i = 0; i<width; i++) {
            float x =  (2*(i + 0.5)/(float)width  - 1)*tan(fov/2.)*width/(float)height;
            float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.);
            Vec3f dir = Vec3f(x, y, -1).normalize();
            framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), dir, sphere);
        }
    }[...]
}

現在,我建議你拿起一支筆在紙上檢查所有的計算過程(光線-球體相交以及光線掃過圖片的過程)。我們的攝像機由以下幾個因素確定:

  • 圖像寬度
  • 圖像高度
  • 視場角
  • 攝像機位置,Vec3f(0.0.0)
  • 觀察方向,沿着 z 軸負方向

讓我來解釋一下怎樣計算要追蹤的光線的初始方向。在主循環中有以下式子

	float x =  (2*(i + 0.5)/(float)width  - 1)*tan(fov/2.)*width/(float)height;
	float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.);

這式子哪來的?很簡單,攝像機放置在原點且面向 z 軸負方向。下面這幅圖顯示了從攝像機上方向下看的情形,y 軸指向屏幕外:

raytracer

攝像頭放置在原點, 場景被投影在位於 z=-1 平面的屏幕上。視場角決定了能顯示在屏幕上的區域。在上圖中屏幕寬度爲16像素(譯註:一段紅線段爲一個像素),你能計算出它在世界座標系中的長度嗎?很簡單:讓我們把焦點放在由紅線、灰線以及灰色虛線組成的三角形上。很容易看出 tan(field of view / 2) = (screen width) * 0.5 / (screen-camera distance)。我們把屏幕放在離攝像機距離爲 1 的地方,這樣 (screen width) = 2 * tan(field of view / 2)

現在我們想把一個向量投射到距屏幕左邊第十二個像素的中心處,也就是我們想計算出圖中的藍色向量該怎麼做?從屏幕左邊到向量箭頭處的距離是多少?首先,這個距離是 12 + 0.5 像素。我們知道屏幕上 16 個像素對應於 2 * tan(fov/2) 個世界單位。也就是說向量的箭頭處位於從左邊數起第 (12+0.5)/16 * 2*tan(fov/2) 個世界單位處,或者說從屏幕和 z 軸交點處起向右 (12+0.5) * 2/16 * tan(fov/2) - tan(fov/2) 個世界單位。再屏幕縱橫比的影響算上,最終就得到了計算光線方向的式子。

第 3 步:添加更多球體

最困難的階段已經過去,接下來就比較輕鬆了。我們已經知道了怎樣畫出一個球體,再畫更多的球體就不會花很多時間了。 查看代碼中變化的部分,最終生成的結果如下圖:

out2

第 4 步:光照

除了缺少光照,我們的圖片在各個方面看起來都很完美。在文章接下來的部分我們將討論光照。讓我們添加幾個點光源吧:

struct Light {
    Light(const Vec3f &p, const float &i) : position(p), intensity(i) {}
    Vec3f position;
    float intensity;
};

計算真實的全局光照是一件非常非常困難的任務,所以我們將繪製不真實但看似真實的圖片來騙過我們的眼睛。首先:爲什麼冬天很冷而夏天很熱?因爲地球表面溫度取決於太陽光線的入射角。太陽昇到距地平線越高的位置,地表面越亮。反之,距地平線越低越暗。當太陽降到地平線下時,我們甚至都無法看見它發出的光了。

回到我們的球體上:假設我們從攝像頭髮出一束光(並不是真正的光)停在球體表面。怎樣才能知道交點處的光照強度呢?事實上,只要看該點的法向量與光線的方向向量的夾角就夠了。該角越小被照的表面越亮。回顧一下,兩個向量ab的數量積等於他們的模乘以兩向量夾角的餘弦:a * b = |a||b| cos(alpha(a,b))。如果我們使用單位向量,則他們的點積就是物體表面的光照強度。

因此在cast_ray函數中,我們將函數返回的顏色中用考慮了光源影響的顏色代替之前的固定值顏色:

Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) {
    [...]
    float diffuse_light_intensity = 0;
    for (size_t i=0; i<lights.size(); i++) {
        Vec3f light_dir      = (lights[i].position - point).normalize();
        diffuse_light_intensity  += lights[i].intensity * std::max(0.f, light_dir*N);
    }
    return material.diffuse_color * diffuse_light_intensity;
}

關於這一步修改的代碼可以查看這裏,下圖是結果:

out3

第 5 步:鏡面光照

點積的技巧能很好地近似無光澤表面的光照情況,即慢反射光照。如果我們想繪製有光澤的表面該怎麼做呢?例如我想得到類似這樣的圖片:

out4

代碼只需要一點點變化。簡而言之,觀察方向與反射光線之間的角度越小,在有光澤的表面上的光照越亮。

這種實現慢反射光照和鏡面光照的技巧是 Phong reflection model。維基百科中很詳細地介紹了這個光照模型,最好和源代碼一起閱讀。下面這幅圖有助於理解:

在這裏插入圖片描述

第 6 步:陰影

爲什麼我們的圖片中有光照卻沒有陰影?這可不行!我想得到下面這樣的圖片:

out5

僅僅改動6行代碼就可以讓我們得到陰影: 在畫每個點時我們只要確保當前點與光源之間沒有相交於場景中的物體即可。如果有交點,我們就略過該光源。這裏有一個小技巧:讓點沿着法線方向移動:

Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;

爲什麼是這樣?我們只不過把原點放到了物體的表面,(除了計算誤差外)任何從這個點發出的光線將與該物體自身相交。(譯註:我的理解,把像素點沿着攝像機投影方向移動到被投影的物體表面,如果從光源射過來的光線在到達該投影點前與其他物體碰撞,則光線到不了該點也就是該點不會被照亮。判斷點指向光線方向與點投影方向是否相同並讓點沿着點指向光線方向移動一小段距離,是爲了排除光線與點所在的物體相交造成的干擾)

第 7 步:反射光

說起來你可能不信,給我們的渲染引擎加入反射光只需要添加三行代碼:

	Vec3f reflect_dir = reflect(dir, N).normalize();
    Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; // offset the original point to avoid occlusion by the object itself
    Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1);

在這查看代碼: 當光線與球體相交時,我們僅計算反射光(在之前鏡面光照中用的函數的幫助下!)並在反射光的方向上遞歸地調用cast_ray函數。確保你設置了遞歸深度再開始,我把它設置爲 4,你可以嘗試從 0 開始的其他值看看圖片會發生什麼變化。下圖是我設置遞歸深度爲 4 的反光圖片結果:

out6

第 8 步:折射光

如果我們知道了反射光怎麼做,那麼折射光就簡單了。我們需要添加一個函數來計算折射光(使用斯涅爾定律),以及在遞歸函數cast_ray中添加另外三行代碼。下面是我們得到的結果,離屏幕最近的球變成了「玻璃球」,它同時進行折射和反射:

out7

第 9 步:球以外的物體

到現在我們只渲染了球體,因爲他是最簡單的幾何體之一。讓我們來添加一個平面,一張經典的棋盤。爲了達到這個目的只需要添加十幾行代碼

生成的結果如下:

out8

正如承諾的那樣,代碼總共256行,來看看吧

第 10 步:家庭作業

我們已經有了很大的進步:我們學習了怎樣向場景中添加物體,怎樣計算複雜的光照。讓我給你留兩個作業吧。所有的準備工作已經在這個家庭作業分支中完成了。每個作業至多需要 10 行代碼。

作業 1:環境貼圖

到現在爲止,如果光線沒有和任何物體相交,我們僅僅把像素設置爲一個背景顏色常量。但事實上爲什麼一定要設置成一個常量呢?讓我們找一張全景圖(文件: envmap.jpg)並把它設置爲背景。爲了減少工作量,我將使用stb庫以便於讀寫jpg格式的圖片。我們會得到這樣的圖片:

env

作業 2:呱呱-呱呱!

我們已經可以渲染球體和平面了(參考第 9 步的棋盤格)。那讓我們來畫一些三角形網格吧!我已經寫好了一段代碼讓你可以讀取.obj文件,我也添加了一個光線-三角形交叉檢測的函數。現在把鴨子加到我們的場景中應該很簡單了:

env-duck

總結

我的主要目標是展示一個有趣(且簡單!)的項目來進行編程。我很確信想成爲一個優秀的程序員必須做大量的業餘項目。我不知道你怎麼看,但我個人對財務軟件和掃雷遊戲一點也不感興趣,儘管他們的代碼複雜度類似。幾個小時兩百五十多行的代碼我們實現了一個光線追蹤器。在幾天時間內我們可以完成一個五百多行的軟光柵。通過計算機圖形學來學習編程真的很酷!


歡迎關注我的公衆號:江達小記

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章