javascript
用JavaScript玩转计算机图形学(一)光线追踪入门
系列簡介
記得小時候讀過一本關于計算機圖形學(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等就可以。
以下是一個簡單的實驗,把每個象素填入顏色,左至右越來越紅,上至下越來越綠。
Run
| ? | 左邊的canvas定義如下:
修改代碼試試看
|
這實驗說明,從canvas取得的影像資料canvas.getImageData(...).data是個一維數組,該數組每四個元素代表一個象素(按紅, 綠, 藍, alpha排列),這些象素在影像中從上至下、左至右排列。
解決實驗平臺的技術問題后,可開始從基礎類別開始實現。
基礎類
筆者使用基于物件(object-based)的方式編寫JavaScript。
三維向量
三維向量(3D vector)可謂CG里最常用型別了。這里三維向量用Vector3類實現,用(x, y, z)表示。 Vector3亦用來表示空間中的點(point),而不另建類。先看代碼:
| 1234567891011121314151617 | 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為距離。實現如下:
| 12345 | 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的二次方程式的解為
若根號內為負數,即相交不發生。另外,由于這里只需要取最近的交點,因此正負號只需取負號。代碼實現如下:
| 1234567891011121314151617181920212223242526272829 | 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,代表光線沒有和任何幾何物件相交。
| 12345678 | 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向量,代碼如下:
| 123456789101112131415 | 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的像素為黑色。
| 12345678910111213141516171819202122232425262728 | // renderDepth.htmfunction?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);} |
Run
| ? | 這里的觀看方向是,正X軸向右,正Y軸向上,正Z軸向后。 修改代碼試試看
|
渲染法向量
相交測試也計算了幾何物件在相交位置的法向量,這里也可把它視覺化。法向量是一個單位向量,其每個元素的范圍是[-1, 1]。把單位向量映射到顏色的常用方法為,把(x, y, z)映射至(r, g, b),范圍從[-1, 1]映射至[0, 255]。
| 1 2 3 4 5 6 7 8 9 10 11 | // 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; ????????????} ????// ... } |
Run
| ? | 球體上方的法向量是接近(0, 1, 0),所以是淺綠色(0.5, 1, 0.5)。 修改代碼試試看
|
材質
渲染深度和法向量只為測試和調試,要顯示物件的"真實"顏色,需要定義該交點向某方向(如往視點的方向)發出的光的顏色,稱之為幾個圖形的材質(material )。
材質的接口為function sample(ray, posiiton, normal) ,傳回顏色Color的對象。這是個極簡陋的接口,臨時做一些效果出來,有機會再詳談。
顏色
顏色在CG里最簡單是用紅、綠、藍三個通道(color channel)。為實現簡單的Phong材質,還加入了對顏色的簡單操作。
| 1234567891011121314 | 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)坐標計算某位置發出黑色或白色的光(黑色的光不叫光吧,哈哈)。
| 1234567 | 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)。
| 123456789101112131415161718192021 | PhongMaterial = function(diffuse, specular, shininess, reflectiveness) {????this.diffuse = diffuse;????this.specular = specular;????this.shininess = shininess;????this.reflectiveness = reflectiveness;};// global tempvar?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方法函數取得顏色。
| 123456789101112 | // rayTrace.htmfunction?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;????????????}????// ...} |
Run
| ? | 修改代碼試試看
|
多個幾何物件
只渲染一個幾何物件太乏味,這節再加入一個無限平面,和介紹如何組合多個幾何物件。
平面
一個(無限)平面(Plane)在數學上可用等式定義:
n為平面的法向量,d為空間原點至平面的最短距離。光線和平面的相交計算很簡單,這里不詳述了。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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類就可以:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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。數學上可寫成:
<img src="http://latex.codecogs.com/png.latex?\mathbf{r}%20=%20\mathbfze8trgl8bvbq%20-%202(\mathbf{d%20\cdot%20n})\bf{n}" "="" style="border: 0px; display: block; margin-left: auto; margin-right: auto; max-width: 900px;">一般材質并非完全反射(鏡子除外),因此這里為材質加上一個反射度(reflectiveness)的屬性。反射的功能很簡單,只要在碰到反射度非零的材質,就繼續向反射方向追蹤,并把結果按反射度來混合。例如一個材質的反射度為25%,則它傳回的顏色是75%本身顏色,加上25%反射傳回來的顏色。
另外,不斷反射會做成大量的運算,甚至乎永遠不能停止(考慮攝影機在兩個鏡子中間)。因此要限制反射的次數。含反射功能的光線追蹤代碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | 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); } |
Run
| ? | 修改代碼試試看
|
結語
能體會到計算機圖形學的有趣之處么?百多行簡單的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++實現,并完全保留了原設計,代碼可於他的博文下載。
from:?http://www.cnblogs.com/miloyip/archive/2010/03/29/1698953.html
總結
以上是生活随笔為你收集整理的用JavaScript玩转计算机图形学(一)光线追踪入门的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 清华大学计算机图形学课程
- 下一篇: 用JavaScript玩转计算机图形学(