用JavaScript玩轉計算機圖形學(一)光線追蹤入門
2010-03-29 00:05 Milo Yip 閱讀(83999) 評論(114) 收藏 舉報
系列簡介
記得小時候讀過一本關于計算機圖形學(computer graphics, CG)的入門書,從此就愛上了CG。本系列希望,采用很多人認識的JavaScript語言去分享CG,令更多人有機會接觸,并愛上CG。
本系列的特點之一,是讀者能在瀏覽器里直接執行代碼,也可重覆修改代碼測試。透過這種互動,也許能更深刻體會內容。讀者只要懂得JavaScript(因為JavaScript很簡單,學過Java/C/C++/C#之類的語言也應沒問題)和一點點線性代數(linear algebra)就可以了。
筆者在大學期間并沒有修讀CG課程,雖然看過相關書籍,始終未親手做過全域光照的渲染器,本文也作為個人的學習分享。此外,筆者也差不多十年沒接觸JavaScript,希望各位不吝賜教。
本文簡介
多數程序員聽到3D CG,就會聯想到Direct3D、OpenGL等API。事實上,這些流行的API主要為實時渲染(real-time rendering)而設,一般采用光柵化(rasterization)方式,渲染大量的三角形(或其他幾何圖元種類(primitive types))。這種基于光柵化的渲染系統,只支持局部光照(local illumination)。換句話說,渲染幾何圖形的一個像素時,光照計算只能取得該像素的資訊,而不能訪問其他幾何圖形資訊。理論上,陰影(shadow)、反射(reflection)、折射(refraction)等為全局光照(global illumination)效果,實際上,柵格化渲染系統可以使用預處理(如陰影貼圖(shadow mapping)、環境貼圖(environment mapping))去模擬這些效果。
全局光照計算量大,一般也沒有特殊硬件加速(通常只使用CPU而非GPU),所以只適合離線渲染(offline rendering),例如3D Studio Max、Maya等工具。其中一個支持全局光照的方法,稱為光線追蹤(ray tracing)。光線追蹤能簡單直接地支持陰影、反射、折射,實現起來亦非常容易。本文的例子里,只用了數十行JavaScript代碼(除canvas外不需要其他特殊插件和庫),就能實現一個支持反射的光線追蹤渲染器。光線追蹤可以用來學習很多計算機圖形學的課題,也許比學習Direct3D/OpenGL更容易。現在,先介紹點理論吧。
光線追蹤
光柵化渲染,簡單地說,就是把大量三角形畫到屏幕上。當中會采用深度緩沖(depth buffer, z-buffer),來解決多個三角形重疊時的前后問題。三角形數目影響效能,但三角形在屏幕上的總面積才是主要瓶頸。
光線追蹤,簡單地說,就是從攝影機的位置,通過影像平面上的像素位置(比較正確的說法是取樣(sampling)位置),發射一束光線到場景,求光線和幾何圖形間最近的交點,再求該交點的著色。如果該交點的材質是反射性的,可以在該交點向反射方向繼續追蹤。光線追蹤除了容易支持一些全局光照效果外,亦不局限于三角形作為幾何圖形的單位。任何幾何圖形,能與一束光線計算交點(intersection point),就能支持。

上圖(來源)顯示了光線追蹤的基本方式。要計算一點是否在陰影之內,也只須發射一束光線到光源,檢測中間有沒有障礙物而已。不過光源和陰影留待下回分解。
初試畫板
光線追蹤的輸出只是一個影像(image),所謂影像,就是二維顏色數組。
要在瀏覽器內,用JavaScript生成一個影像,目前可以使用HTML 5的<canvas>。但現時Internet Explorer(直至版本8)還不支持<canvas>,其他瀏覽器如Chrome、Firefox、Opera等就可以。
以下是一個簡單的實驗,把每個象素填入顏色,左至右越來越紅,上至下越來越綠。
|
左邊的canvas定義如下: <canvas width="256" height="256" id="testCanvas"></canvas> 修改代碼試試看
|
這實驗說明,從canvas取得的影像資料canvas.getImageData(...).data是個一維數組,該數組每四個元素代表一個象素(按紅, 綠, 藍, alpha排列),這些象素在影像中從上至下、左至右排列。
解決實驗平臺的技術問題后,可開始從基礎類別開始實現。
基礎類
筆者使用基于物件(object-based)的方式編寫JavaScript。
三維向量
三維向量(3D vector)可謂CG里最常用型別了。這里三維向量用Vector3類實現,用(x, y, z)表示。 Vector3亦用來表示空間中的點(point),而不另建類。先看代碼:
Vector3 = function(x, y, z) { this.x = x; this.y = y; this.z = z; };
Vector3.prototype = {
copy : function() { return new Vector3(this.x, this.y, this.z); },
length : function() { return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); },
sqrLength : function() { return this.x * this.x + this.y * this.y + this.z * this.z; },
normalize : function() { var inv = 1/this.length(); return new Vector3(this.x * inv, this.y * inv, this.z * inv); },
negate : function() { return new Vector3(-this.x, -this.y, -this.z); },
add : function(v) { return new Vector3(this.x + v.x, this.y + v.y, this.z + v.z); },
subtract : function(v) { return new Vector3(this.x - v.x, this.y - v.y, this.z - v.z); },
multiply : function(f) { return new Vector3(this.x * f, this.y * f, this.z * f); },
divide : function(f) { var invf = 1/f; return new Vector3(this.x * invf, this.y * invf, this.z * invf); },
dot : function(v) { return this.x * v.x + this.y * v.y + this.z * v.z; },
cross : function(v) { return new Vector3(-this.z * v.y + this.y * v.z, this.z * v.x - this.x * v.z, -this.y * v.x + this.x * v.y); }
};
Vector3.zero = new Vector3(0, 0, 0);
這些類方法(如normalize、negate、add等),如果傳回Vector3類對象,都會傳回一個新建構的Vector3。這些三維向量的功能很簡單,不在此詳述。注意multiply和divide是與純量(scalar)相乘和相除。
Vector3.zero用作常量,避免每次重新構建。值得一提,這些常量必需在prototype設定之后才能定義。
光線
所謂光線(ray),從一點向某方向發射也。數學上可用參數函數(parametric function)表示:
當中,o即發謝起點(origin),d為方向。在本文的例子里,都假設d為單位向量(unit vector),因此t為距離。實現如下:
Ray3 = function(origin, direction) { this.origin = origin; this.direction = direction; }
Ray3.prototype = {
getPoint : function(t) { return this.origin.add(this.direction.multiply(t)); }
};
球體
球體(sphere)是其中一個最簡單的立體幾何圖形。這里只考慮球體的表面(surface),中心點為c、半徑為r的球體表面可用等式(equation)表示:
如前文所述,需要計算光線和球體的最近交點。只要把光線x = r(t)代入球體等式,把該等式求解就是交點。為簡化方程,設v=o - c,則:
因為d為單位向量,所以二次方的系數可以消去。 t的二次方程式的解為
若根號內為負數,即相交不發生。另外,由于這里只需要取最近的交點,因此正負號只需取負號。代碼實現如下:
Sphere = function(center, radius) { this.center = center; this.radius = radius; };
Sphere.prototype = {
copy : function() { return new Sphere(this.center.copy(), this.radius.copy()); },
initialize : function() {
this.sqrRadius = this.radius * this.radius;
},
intersect : function(ray) {
var v = ray.origin.subtract(this.center);
var a0 = v.sqrLength() - this.sqrRadius;
var DdotV = ray.direction.dot(v);
if (DdotV <= 0) {
var discr = DdotV * DdotV - a0;
if (discr >= 0) {
var result = new IntersectResult();
result.geometry = this;
result.distance = -DdotV - Math.sqrt(discr);
result.position = ray.getPoint(result.distance);
result.normal = result.position.subtract(this.center).normalize();
return result;
}
}
return IntersectResult.noHit;
}
};
實現代碼時,盡快用最少的運算剔除沒相交的情況(Math.sqrt是比較慢的函數)。另外,預計算了球體半徑r的平方,此為一個優化。
這里用到一個IntersectResult類,這個類只用來記錄交點的幾何物件(geometry)、距離(distance)、位置(position)和法向量(normal)。 IntersectResult.noHit的geometry為null,代表光線沒有和任何幾何物件相交。
IntersectResult = function() {
this.geometry = null;
this.distance = 0;
this.position = Vector3.zero;
this.normal = Vector3.zero;
};
IntersectResult.noHit = new IntersectResult();
攝影機
攝影機在光線追蹤系統里,負責把影像的取樣位置,生成一束光線。
由于影像的大小是可變的(多少像素寬x多少像素高),為方便計算,這里設定一個統一的取樣座標(sx, sy),以左下角為(0,0),右上角為(1 ,1)。
從數學角度來說,攝影機透過投影(projection),把三維空間投射到二維空間上。常見的投影有正投影(orthographic projection)、透視投影(perspective projection)等等。這里首先實現透視投影。 ]]>
透視攝影機
透視攝影機比較像肉眼和真實攝影機的原理,能表現遠小近大的觀察方式。透視投影從視點(view point/eye position),向某個方向觀察場景,觀察的角度范圍稱為視野(field of view, FOV)。除了定義觀察的向前(forward)是那個方向,還需要定義在影像平面中,何謂上下和左右。為簡單起見,暫時不考慮寬高不同的影像,FOV同時代表水平和垂直方向的視野角度。

上圖顯示,從攝影機上方顯示的幾個參數。 forward和right分別是向前和向右的單位向量。
因為視點是固定的,光線的起點不變。要生成光線,只須用取樣座標(sx, sy)計算其方向d。留意FOV和s的關系為:
把sx從[0, 1]映射到[-1,1],就可以用right向量和s,來計算r向量,代碼如下:
PerspectiveCamera = function(eye, front, up, fov) { this.eye = eye; this.front = front; this.refUp = up; this.fov = fov; };
PerspectiveCamera.prototype = {
initialize : function() {
this.right = this.front.cross(this.refUp);
this.up = this.right.cross(this.front);
this.fovScale = Math.tan(this.fov * 0.5 * Math.PI / 180) * 2;
},
generateRay : function(x, y) {
var r = this.right.multiply((x - 0.5) * this.fovScale);
var u = this.up.multiply((y - 0.5) * this.fovScale);
return new Ray3(this.eye, this.front.add(r).add(u).normalize());
}
};
代碼中fov為度數,轉為弧度才能使用Math.tan()。另外,fovScale預先乘了2,因為sx映射到[-1,1]每次都要乘以2。 sy和sx的做法一樣,把兩個在影像平面的向量,加上forward向量,就成為光線方向d。因之后的計算需要,最后把d變成單位向量。
渲染測試
寫了Vector3、Ray3、Sphere、IntersectResult、Camera五個類之后,終于可以開始渲染一點東西出來!
基本的做法是遍歷影像的取樣座標(sx, sy),用Camera把(sx, sy)轉為Ray3,和場景(例如Sphere)計算最近交點,把該交點的屬性轉為顏色,寫入影像的相對位置里。
把不同的屬性渲染出來,是CG編程里經常用的測試和調試手法。筆者也是用此方法,修正了一些錯誤。
渲染深度
深度(depth)就是從IntersectResult取得最近相交點的距離,因深度的范圍是從零至無限,為了把它顯示出來,可以把它的一個區間映射到灰階。這里用[0, maxDepth]映射至[255, 0],即深度0的像素為白色,深度達maxDepth的像素為黑色。
// renderDepth.htm
function renderDepth(canvas, scene, camera, maxDepth) {
// 從canvas取得imgdata和pixels,跟之前的代碼一樣
// ...
scene.initialize();
camera.initialize();
var i = 0;
for (var y = 0; y < h; y++) {
var sy = 1 - y / h;
for (var x = 0; x < w; x++) {
var sx = x / w;
var ray = camera.generateRay(sx, sy);
var result = scene.intersect(ray);
if (result.geometry) {
var depth = 255 - Math.min((result.distance / maxDepth) * 255, 255);
pixels[i ] = depth;
pixels[i + 1] = depth;
pixels[i + 2] = depth;
pixels[i + 3] = 255;
}
i += 4;
}
}
ctx.putImageData(imgdata, 0, 0);
}
|
這里的觀看方向是,正X軸向右,正Y軸向上,正Z軸向后。 修改代碼試試看
|
渲染法向量
相交測試也計算了幾何物件在相交位置的法向量,這里也可把它視覺化。法向量是一個單位向量,其每個元素的范圍是[-1, 1]。把單位向量映射到顏色的常用方法為,把(x, y, z)映射至(r, g, b),范圍從[-1, 1]映射至[0, 255]。
// renderNormal.htm
function renderNormal(canvas, scene, camera) {
// ...
if (result.geometry) {
pixels[i ] = (result.normal.x + 1) * 128;
pixels[i + 1] = (result.normal.y + 1) * 128;
pixels[i + 2] = (result.normal.z + 1) * 128;
pixels[i + 3] = 255;
}
// ...
}
|
球體上方的法向量是接近(0, 1, 0),所以是淺綠色(0.5, 1, 0.5)。 修改代碼試試看
|
材質
渲染深度和法向量只為測試和調試,要顯示物件的"真實"顏色,需要定義該交點向某方向(如往視點的方向)發出的光的顏色,稱之為幾個圖形的材質(material )。
材質的接口為function sample(ray, posiiton, normal) ,傳回顏色Color的對象。這是個極簡陋的接口,臨時做一些效果出來,有機會再詳談。
顏色
顏色在CG里最簡單是用紅、綠、藍三個通道(color channel)。為實現簡單的Phong材質,還加入了對顏色的簡單操作。
Color = function(r, g, b) { this.r = r; this.g = g; this.b = b };
Color.prototype = {
copy : function() { return new Color(this.r, this.g, this.b); },
add : function(c) { return new Color(this.r + c.r, this.g + c.g, this.b + c.b); },
multiply : function(s) { return new Color(this.r * s, this.g * s, this.b * s); },
modulate : function(c) { return new Color(this.r * c.r, this.g * c.g, this.b * c.b); }
};
Color.black = new Color(0, 0, 0);
Color.white = new Color(1, 1, 1);
Color.red = new Color(1, 0, 0);
Color.green = new Color(0, 1, 0);
Color.blue = new Color(0, 0, 1);
這Color類很像Vector3類,值得留意的是,顏色有調制(modulate)操作,其意義為兩個顏色中每個顏色通道相乘。
格子材質
CG世界里,國際象棋棋盤是最常見的測試用紋理(texture)。這里不考慮紋理貼圖(texture mapping)的問題,只憑(x, z)坐標計算某位置發出黑色或白色的光(黑色的光不叫光吧,哈哈)。
CheckerMaterial = function(scale, reflectiveness) { this.scale = scale; this.reflectiveness = reflectiveness; };
CheckerMaterial.prototype = {
sample : function(ray, position, normal) {
return Math.abs((Math.floor(position.x * 0.1) + Math.floor(position.z * this.scale)) % 2) < 1 ? Color.black : Color.white;
}
};
代碼中scale的意義為1坐標單位有多少個格子,例如scale=0.1即一個格子的大小為10x10。
Phong材質
這里實現簡單的Phong材質,因為未有光源系統,只用全域變量設置一個臨時的光源方向,并只計算漫射(diffuse)和鏡射(specular)。
PhongMaterial = function(diffuse, specular, shininess, reflectiveness) {
this.diffuse = diffuse;
this.specular = specular;
this.shininess = shininess;
this.reflectiveness = reflectiveness;
};
// global temp
var lightDir = new Vector3(1, 1, 1).normalize();
var lightColor = Color.white;
PhongMaterial.prototype = {
sample: function(ray, position, normal) {
var NdotL = normal.dot(lightDir);
var H = (lightDir.subtract(ray.direction)).normalize();
var NdotH = normal.dot(H);
var diffuseTerm = this.diffuse.multiply(Math.max(NdotL, 0));
var specularTerm = this.specular.multiply(Math.pow(Math.max(NdotH, 0), this.shininess));
return lightColor.modulate(diffuseTerm.add(specularTerm));
}
};
Phong的內容不在此述。
渲染材質
修改之前的渲染代碼,當碰到相交時,就向幾何對象取得material屬性,并調用sample方法函數取得顏色。
// rayTrace.htm
function rayTrace(canvas, scene, camera) {
// ...
if (result.geometry) {
var color = result.geometry.material.sample(ray, result.position, result.normal);
pixels[i] = color.r * 255;
pixels[i + 1] = color.g * 255;
pixels[i + 2] = color.b * 255;
pixels[i + 3] = 255;
}
// ...
}
修改代碼試試看
|
多個幾何物件
只渲染一個幾何物件太乏味,這節再加入一個無限平面,和介紹如何組合多個幾何物件。
平面
一個(無限)平面(Plane)在數學上可用等式定義:
n為平面的法向量,d為空間原點至平面的最短距離。光線和平面的相交計算很簡單,這里不詳述了。
Plane = function(normal, d) { this.normal = normal; this.d = d; };
Plane.prototype = {
copy : function() { return new plane(this.normal.copy(), this.d); },
initialize : function() {
this.position = this.normal.multiply(this.d);
},
intersect : function(ray) {
var a = ray.direction.dot(this.normal);
if (a >= 0)
return IntersectResult.noHit;
var b = this.normal.dot(ray.origin.subtract(this.position));
var result = new IntersectResult();
result.geometry = this;
result.distance = -b / a;
result.position = ray.getPoint(result.distance);
result.normal = this.normal;
return result;
}
};
并集
把多個幾何物件結合起來,可以使用集(set)的概念。這里最容易實現的操作,就是并集(union),即光線要找到一組幾個圖形的最近交點。無需改其他代碼,只加入一個Union類就可以:
Union = function(geometries) { this.geometries = geometries; };
Union.prototype = {
initialize: function() {
for (var i in this.geometries)
this.geometries[i].initialize();
},
intersect: function(ray) {
var minDistance = Infinity;
var minResult = IntersectResult.noHit;
for (var i in this.geometries) {
var result = this.geometries[i].intersect(ray);
if (result.geometry && result.distance < minDistance) {
minDistance = result.distance;
minResult = result;
}
}
return minResult;
}
};
可以看到,這里利用Javascript的多型(polymorphism)的特性,完全不用修改原來的代碼,就可以擴展功能。
如前所述,這里只考慮幾何幾何圖形的表面。如果考慮幾何圖形是實心的,就可以用構造實體幾何(constructive solid geometry, CSG)方法,提供并集、交集、補集等操作。容后再談。
反射
以上實現的,也只是局部照明。只要再加入一點點代碼,就可以實現反射。
下圖說明反射向量的計算方法:

把d投射到n上(因n是單位向量,只需要點乘即可),就可以計算d在n上的長度,把d減去這長度兩倍的法向量,就是反射向量r。數學上可寫成:
一般材質并非完全反射(鏡子除外),因此這里為材質加上一個反射度(reflectiveness)的屬性。反射的功能很簡單,只要在碰到反射度非零的材質,就繼續向反射方向追蹤,并把結果按反射度來混合。例如一個材質的反射度為25%,則它傳回的顏色是75%本身顏色,加上25%反射傳回來的顏色。
另外,不斷反射會做成大量的運算,甚至乎永遠不能停止(考慮攝影機在兩個鏡子中間)。因此要限制反射的次數。含反射功能的光線追蹤代碼如下:
function rayTraceRecursive(scene, ray, maxReflect) {
var result = scene.intersect(ray);
if (result.geometry) {
var reflectiveness = result.geometry.material.reflectiveness;
var color = result.geometry.material.sample(ray, result.position, result.normal);
color = color.multiply(1 - reflectiveness);
if (reflectiveness > 0 && maxReflect > 0) {
var r = result.normal.multiply(-2 * result.normal.dot(ray.direction)).add(ray.direction);
ray = new Ray3(result.position, r);
var reflectedColor = rayTraceRecursive(scene, ray, maxReflect - 1);
color = color.add(reflectedColor.multiply(reflectiveness));
}
return color;
}
else
return Color.black;
}
function rayTraceReflection(canvas, scene, camera, maxReflect) {
// 從canvas取得imgdata和pixels,跟之前的代碼一樣
// ...
scene.initialize();
camera.initialize();
var i = 0;
for (var y = 0; y < h; y++) {
var sy = 1 - y / h;
for (var x = 0; x < w; x++) {
var sx = x / w;
var ray = camera.generateRay(sx, sy);
var color = rayTraceRecursive(scene, ray, maxReflect);
pixels[i++] = color.r * 255;
pixels[i++] = color.g * 255;
pixels[i++] = color.b * 255;
pixels[i++] = 255;
}
}
ctx.putImageData(imgdata, 0, 0);
}
修改代碼試試看
|
結語
能體會到計算機圖形學的有趣之處么?百多行簡單的JavaScript代碼,就繪畫出像真的影像,那種滿足感實非筆墨所能形容。
本文實現了一個簡單的光線追蹤渲染器,支持球體、平面、Phong材質、格子材質、多重反射等功能。讀者可以下載這組代碼,加入不同的擴展,也可以嘗試翻譯做熟悉的編程語言。很多光線追蹤用到的計算機圖形技術,也可以應用到實時圖形編程里,例如光源和材質的計算,基本上可以簡易翻譯做實時圖形的著色器(shader)編程。
游戲里采用光柵化渲染技術已有二十年以上,這幾年的硬件發展,使其他渲染方法也能用于實時應用。光線追蹤和其他類似的方法,有個當今重要優點,就是能高度平行化。采樣之間并沒有依賴性,例如256x256=65536個采樣,理論上,可使用65536個機器/核心獨立執行追蹤,那么完成時間只是最慢的一個取樣所需的時間。
筆者希望繼續撰寫這系列,例如包括以下內容:
- 其他幾何圖形(長方體、柱體、三角形、曲面、高度場、等值面、……)
- 光源(方向光源、點光源、聚光燈、陰影、ambient occlusion)
- 材質(Phong-Blinn、Oren-Nayar、Torrance-Sparrow、折射、 Fresnel、BRDF、BSDF……)
- 紋理(紋理座標、采樣、Perlin noise)
- 攝影機模型(正投射、全景、景深)
- 成像流程(漸進渲染、反鋸齒、后期處理)
- 優化方法(場景剖分、低階優化)
- 其他全局光照渲染方法
祈望得到大家的意見反饋。
參考
- Matt Pharr, Greg Humphreys, Physically Based Rendering, Morgan Kaufmann, 2004
- Wikipedia, Ray Tracing
- Slime, The JavaScript Raytracer
- SIGGRAPH HyperGraph Education Project, Ray Tracing
更新
- 2010年3月31日,網友HouSisong把本文代碼以C++實現,并完全保留了原設計,代碼可於他的博文下載。
浙公網安備 33010602011771號