譯者前言:
本文譯自[MSDN](http://blogs.msdn.com/b/davrous/archive/2013/06/13/tutorial-series-learning-how-to-write-a-3d-soft-engine-from-scratch-in-c-typescript-or-javascript.aspx),原作者為[David Rousset](https://social.msdn.microsoft.com/profile/david%20rousset/),文章中如果有我的額外說明,我會加上【譯者注:】。
正文開始:
這可能是整個系列中最棒的部分:如何處理光照!在之前,我們已經搞定了讓每個面隨機顯示一種顏色。現在我們要進行改變,計算出光的角度,讓每個面有更好的光照效果。第一種方法叫做平面著色。它使用面法線,用這個方法我們也會看到不同面的效果。但是高氏著色則會讓我們更進一步,它使用頂點法線,然后每一個像素使用3個法線進行插值計算顏色。
在本教程的最后,你應該可以得到這樣一個非常酷的渲染效果:
[點擊運行](http://david.blob.core.windows.net/softengine3d/part5/index.html)
本章教程是以下系列的一部分:
[1 – 編寫相機、網格和設備對象的核心邏輯](http://blog.csdn.net/teajs/article/details/49989681)
[2 – 繪制線段和三角形來獲得線框渲染效果](http://blog.csdn.net/teajs/article/details/49998675)
[3 – 加載通過Blender擴展導出JSON格式的網格](http://blog.csdn.net/teajs/article/details/50001659)
[4 –填充光柵化的三角形并使用深度緩沖](http://blog.csdn.net/teajs/article/details/50010073)
[4b – 額外章節:使用技巧和并行處理來提高性能](http://blog.csdn.net/teajs/article/details/50054509)
5 – 使用平面著色和高氏著色處理光 (本文)
[6 – 應用紋理、背面剔除以及一些WebGL相關](http://blog.csdn.net/teajs/article/details/50762852)
**平面著色**
**概念**
為了能夠應用平面著色算法,我們首先需要計算面的法線向量。我們一旦得到了它,我們還需要知道該法線向量和光向量之間的角度。為了更精確,我們將使用[點積](http://en.wikipedia.org/wiki/Dot_product)返回給我們兩個向量之間角的余弦。
因為這樣的值可能是-1和1之間的數,我們將它們收緊到0-1之間。我們的面根據最終的光量值來計算顏色。總之,我們的面最終顏色將是 =?color * Math.Max(0, cos(angle))。
讓我們從法線向量開始。維基百科定義[法線(幾何體)](http://en.wikipedia.org/wiki/Normal_of_the_plane)指出:“對于[凸](http://en.wikipedia.org/wiki/Convex_set)[多邊形](http://en.wikipedia.org/wiki/Polygon)(如[三角形](http://en.wikipedia.org/wiki/Triangle)),一個表面法線可被計算為多邊形兩(非平行)邊向量的[叉積](http://en.wikipedia.org/wiki/Cross_product)”。
為了說明這一點,你可以在Blender文檔中看到一個有趣的內容:[Blender 3D:入門到精通 - Normal_coordinates](http://en.wikibooks.org/wiki/Blender_3D:_Noob_to_Pro/Printable_Version#Normal_coordinates)
[](http://commons.wikimedia.org/wiki/File:Blender3d_NormalKoordinates.jpg)
藍色箭頭是面的法線,綠色和紅色箭頭可能是面的任何邊緣向量。讓我們用Blender的蘇珊妮模型來了解這些法線向量。
打開Blender,加載蘇珊妮網格,切換到“編輯模式”:

通過點擊它,然后按下“N”鍵打開網格的屬性。在“顯示網格”中,你能找到2個法線相關按鈕。點擊“顯示面的法線”:

你將會得到類似這樣的效果:

我們之后將會定義一個光。這些光將成為教程中最簡單的一個:一個點光源。這個點光源是簡單的3D點(Vector3類型)。無論距離如何,我們的面接受光的數量是相同的。然后,我們將會簡單的基于法線向量和光點向量的角度以及我們的面的中心來改變光的強度。
因此,光的方向將是:光的位置 - 面的中心位置 -> 這將會給我們光的方向向量。為了計算光向量和法線向量之間的角度,我們將使用點積:[http://en.wikipedia.org/wiki/Dot_product](http://en.wikipedia.org/wiki/Dot_product)

該圖來自:[逐像素光照](http://www.john-chapman.net/content.php?id=3)(由John Chapman撰寫的文章)
代碼
一般情況下,我們將首先需要計算法線向量。幸運的是,Blender將為我們計算這些法線向量。更妙的是,它輸出的每個頂點的法線,我們將在第二部分使用。因此,要計算我們的法線向量,我們只需要取3個頂點的法線向量,將他們累加后除以3。
我們需要重構一下以前的代碼,一遍能夠處理這些新的概念。到現在為止,我們只用到了Vector3類型的頂點數組。這已經不夠了。我們還需要更多的數據:與頂點相關的法線(對于高氏著色而言)以及3D投影坐標。實際上,當前投影只在2D完成。我們需要保持3D坐標投影才能夠算出3D世界中的各種向量。
然后,我們將創建一個包含3個Vector3類型的結構:法線向量到頂點以及世界坐標,這些坐標是我們目前一直在使用的。
這個ProcessScanLine方法必須進行插值更多的數據(比如高氏著色中每個頂點的法線)。因此,我們將創建一個*ScanLineData*結構。
【譯者注:C#代碼】
~~~
public class Mesh
{
public string Name { get; set; }
public Vertex[] Vertices { get; private set; }
public Face[] Faces { get; set; }
public Vector3 Position { get; set; }
public Vector3 Rotation { get; set; }
public Mesh(string name, int verticesCount, int facesCount)
{
Vertices = new Vertex[verticesCount];
Faces = new Face[facesCount];
Name = name;
}
}
public struct Vertex
{
public Vector3 Normal;
public Vector3 Coordinates;
public Vector3 WorldCoordinates;
}
~~~
~~~
public struct ScanLineData
{
public int currentY;
public float ndotla;
public float ndotlb;
public float ndotlc;
public float ndotld;
}
~~~
【譯者注:TypeScript代碼】
~~~
export interface Vertex {
Normal: BABYLON.Vector3;
Coordinates: BABYLON.Vector3;
WorldCoordinates: BABYLON.Vector3;
}
export class Mesh {
Position: BABYLON.Vector3;
Rotation: BABYLON.Vector3;
Vertices: Vertex[];
Faces: Face[];
constructor(public name: string, verticesCount: number, facesCount: number) {
this.Vertices = new Array(verticesCount);
this.Faces = new Array(facesCount);
this.Rotation = new BABYLON.Vector3(0, 0, 0);
this.Position = new BABYLON.Vector3(0, 0, 0);
}
}
export interface ScanLineData {
currentY?: number;
ndotla?: number;
ndotlb?: number;
ndotlc?: number;
ndotld?: number;
}
~~~
JavaScript代碼與之前教程中的代碼沒有變化,因此我們不用改變什么。除了進行結構修改。第一種是通過Blender導出的Json文件,我們需要加載的每個頂點的法線以及建立頂點對象,而不是頂點數組中的Vector3類型的對象:
【譯者注:C#代碼】
~~~
// 首先填充我們網格的頂點數組
for (var index = 0; index < verticesCount; index++)
{
var x = (float)verticesArray[index * verticesStep].Value;
var y = (float)verticesArray[index * verticesStep + 1].Value;
var z = (float)verticesArray[index * verticesStep + 2].Value;
// 加載Blender導出的頂點法線
var nx = (float)verticesArray[index * verticesStep + 3].Value;
var ny = (float)verticesArray[index * verticesStep + 4].Value;
var nz = (float)verticesArray[index * verticesStep + 5].Value;
mesh.Vertices[index] = new Vertex{ Coordinates= new Vector3(x, y, z), Normal= new Vector3(nx, ny, nz) };
}
~~~
【譯者注:TypeScript代碼】
~~~
// 首先填充我們網格的頂點數組
for (var index = 0; index < verticesCount; index++) {
var x = verticesArray[index * verticesStep];
var y = verticesArray[index * verticesStep + 1];
var z = verticesArray[index * verticesStep + 2];
// 加載Blender導出的頂點法線
var nx = verticesArray[index * verticesStep + 3];
var ny = verticesArray[index * verticesStep + 4];
var nz = verticesArray[index * verticesStep + 5];
mesh.Vertices[index] = {
Coordinates: new BABYLON.Vector3(x, y, z),
Normal: new BABYLON.Vector3(nx, ny, nz),
WorldCoordinates: null
};
}
~~~
【譯者注:JavaScript代碼】
~~~
// 首先填充我們網格的頂點數組
for (var index = 0; index < verticesCount; index++) {
var x = verticesArray[index * verticesStep];
var y = verticesArray[index * verticesStep + 1];
var z = verticesArray[index * verticesStep + 2];
// 加載Blender導出的頂點法線
var nx = verticesArray[index * verticesStep + 3];
var ny = verticesArray[index * verticesStep + 4];
var nz = verticesArray[index * verticesStep + 5];
mesh.Vertices[index] = {
Coordinates: new BABYLON.Vector3(x, y, z),
Normal: new BABYLON.Vector3(nx, ny, nz),
WorldCoordinates: null
};
}
~~~
這里是所有已更新的方法/功能:
-Project()在正在工作的頂點結構中,投射(使用世界矩陣)頂點的三維坐標,使得每個頂點被正常投射。
-DrawTriangle()輸入一些頂點結構,調用 NDotL 與 ComputeNDotL 算出結果,然后用這些數據調用 ProcessScanLine 函數。
-ComputeNDotL()計算法線和光的方向之間角度的余弦。
-ProcessScanLine()使用NDotL值改變顏色并發送到DrawTriangle。我們目前每個三角形只有1種顏色,因為我們使用的是平面渲染。
如果你已經對之前的教程消化完畢并且理解了本章開頭的概念,那么你只需要閱讀下面的代碼就能知道有哪些改變:
【譯者注:C#代碼】
~~~
// 將三維坐標和變換矩陣轉換成二維坐標
public Vertex Project(Vertex vertex, Matrix transMat, Matrix world)
{
// 將坐標轉換為二維空間
var point2d = Vector3.TransformCoordinate(vertex.Coordinates, transMat);
// 在三維世界中轉換坐標和法線的頂點
var point3dWorld = Vector3.TransformCoordinate(vertex.Coordinates, world);
var normal3dWorld = Vector3.TransformCoordinate(vertex.Normal, world);
// 變換后的坐標起始點是坐標系的中心點
// 但是,在屏幕上,我們以左上角為起始點
// 我們需要重新計算使他們的起始點變成左上角
var x = point2d.X * renderWidth + renderWidth / 2.0f;
var y = -point2d.Y * renderHeight + renderHeight / 2.0f;
return new Vertex
{
Coordinates = new Vector3(x, y, point2d.Z),
Normal = normal3dWorld,
WorldCoordinates = point3dWorld
};
}
// 在兩點之間從左到右繪制一條線段
// papb -> pcpd
// pa, pb, pc, pd在之前必須已經排好序
void ProcessScanLine(ScanLineData data, Vertex va, Vertex vb, Vertex vc, Vertex vd, Color4 color)
{
Vector3 pa = va.Coordinates;
Vector3 pb = vb.Coordinates;
Vector3 pc = vc.Coordinates;
Vector3 pd = vd.Coordinates;
// 由當前的y值,我們可以計算出梯度
// 以此再計算出 起始X(sx) 和 結束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的話,梯度強制為1
var gradient1 = pa.Y != pb.Y ? (data.currentY - pa.Y) / (pb.Y - pa.Y) : 1;
var gradient2 = pc.Y != pd.Y ? (data.currentY - pc.Y) / (pd.Y - pc.Y) : 1;
int sx = (int)Interpolate(pa.X, pb.X, gradient1);
int ex = (int)Interpolate(pc.X, pd.X, gradient2);
// 開始Z值和結束Z值
float z1 = Interpolate(pa.Z, pb.Z, gradient1);
float z2 = Interpolate(pc.Z, pd.Z, gradient2);
// 從左(sx)向右(ex)繪制一條線
for (var x = sx; x < ex; x++)
{
float gradient = (x - sx) / (float)(ex - sx);
var z = Interpolate(z1, z2, gradient);
var ndotl = data.ndotla;
// 基于光向量和法線向量之間角度的余弦改變顏色值
DrawPoint(new Vector3(x, data.currentY, z), color * ndotl);
}
}
// 計算光向量和法線向量之間角度的余弦
// 返回0到1之間的值
float ComputeNDotL(Vector3 vertex, Vector3 normal, Vector3 lightPosition)
{
var lightDirection = lightPosition - vertex;
normal.Normalize();
lightDirection.Normalize();
return Math.Max(0, Vector3.Dot(normal, lightDirection));
}
public void DrawTriangle(Vertex v1, Vertex v2, Vertex v3, Color4 color)
{
// 進行排序,p1總在最上面,p2總在最中間,p3總在最下面
if (v1.Coordinates.Y > v2.Coordinates.Y)
{
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.Y > v3.Coordinates.Y)
{
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.Y > v2.Coordinates.Y)
{
var temp = v2;
v2 = v1;
v1 = temp;
}
Vector3 p1 = v1.Coordinates;
Vector3 p2 = v2.Coordinates;
Vector3 p3 = v3.Coordinates;
// 法線面上的向量是該法線面和每個頂點法線面中心點的平均值
Vector3 vnFace = (v1.Normal + v2.Normal + v3.Normal) / 3;
Vector3 centerPoint = (v1.WorldCoordinates + v2.WorldCoordinates + v3.WorldCoordinates) / 3;
// 光照位置
Vector3 lightPos = new Vector3(0, 10, 10);
// 計算光向量和法線向量之間夾角的余弦
// 它會返回介于0和1之間的值,該值將被用作顏色的亮度
float ndotl = ComputeNDotL(centerPoint, vnFace, lightPos);
var data = new ScanLineData { ndotla = ndotl };
// 計算線條的方向
float dP1P2, dP1P3;
// http://en.wikipedia.org/wiki/Slope
// 計算斜率
if (p2.Y - p1.Y > 0)
dP1P2 = (p2.X - p1.X) / (p2.Y - p1.Y);
else
dP1P2 = 0;
if (p3.Y - p1.Y > 0)
dP1P3 = (p3.X - p1.X) / (p3.Y - p1.Y);
else
dP1P3 = 0;
// 在第一種情況下,三角形是這樣的:
// P1
// -
// --
// - -
// - -
// - - P2
// - -
// - -
// -
// P3
if (dP1P2 > dP1P3)
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
data.currentY = y;
if (y < p2.Y)
{
ProcessScanLine(data, v1, v3, v1, v2, color);
}
else
{
ProcessScanLine(data, v1, v3, v2, v3, color);
}
}
}
// 在第二種情況下,三角形是這樣的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
data.currentY = y;
if (y < p2.Y)
{
ProcessScanLine(data, v1, v2, v1, v3, color);
}
else
{
ProcessScanLine(data, v2, v3, v1, v3, color);
}
}
}
}
~~~
【譯者注:TypeScript代碼】
~~~
// 將三維坐標和變換矩陣轉換成二維坐標
public project(vertex: Vertex,
transMat: BABYLON.Matrix,
world: BABYLON.Matrix): Vertex {
// 將坐標轉換為二維空間
var point2d = BABYLON.Vector3.TransformCoordinates(vertex.Coordinates, transMat);
// 在三維世界中轉換坐標和法線的頂點
var point3DWorld = BABYLON.Vector3.TransformCoordinates(vertex.Coordinates, world);
var normal3DWorld = BABYLON.Vector3.TransformCoordinates(vertex.Normal, world);
// 變換后的坐標起始點是坐標系的中心點
// 但是,在屏幕上,我們以左上角為起始點
// 我們需要重新計算使他們的起始點變成左上角
var x = point2d.x * this.workingWidth + this.workingWidth / 2.0;
var y = -point2d.y * this.workingHeight + this.workingHeight / 2.0;
return ({
Coordinates: new BABYLON.Vector3(x, y, point2d.z),
Normal: normal3DWorld,
WorldCoordinates: point3DWorld
});
}
// 在兩點之間從左到右繪制一條線段
// papb -> pcpd
// pa, pb, pc, pd在之前必須已經排好序
public processScanLine(data: ScanLineData,
va: Vertex,
vb: Vertex,
vc: Vertex,
vd: Vertex,
color: BABYLON.Color4): void {
var pa = va.Coordinates;
var pb = vb.Coordinates;
var pc = vc.Coordinates;
var pd = vd.Coordinates;
// 由當前的y值,我們可以計算出梯度
// 以此再計算出 起始X(sx) 和 結束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的話,梯度強制為1
var gradient1 = pa.y != pb.y ? (data.currentY - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (data.currentY - pc.y) / (pd.y - pc.y) : 1;
var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;
// 開始Z值和結束Z值
var z1: number = this.interpolate(pa.z, pb.z, gradient1);
var z2: number = this.interpolate(pc.z, pd.z, gradient2);
// 從左(sx)向右(ex)繪制一條線
for (var x = sx; x < ex; x++) {
var gradient: number = (x - sx) / (ex - sx);
var z = this.interpolate(z1, z2, gradient);
var ndotl = data.ndotla;
// 基于光向量和法線向量之間角度的余弦改變顏色值
this.drawPoint(new BABYLON.Vector3(x, data.currentY, z), new BABYLON.Color4(color.r * ndotl, color.g * ndotl, color.b * ndotl, 1));
}
}
// 計算光向量和法線向量之間角度的余弦
// 返回0到1之間的值
public computeNDotL(vertex: BABYLON.Vector3,
normal: BABYLON.Vector3,
lightPosition: BABYLON.Vector3): number {
var lightDirection = lightPosition.subtract(vertex);
normal.normalize();
lightDirection.normalize();
return Math.max(0, BABYLON.Vector3.Dot(normal, lightDirection));
}
public drawTriangle(v1: Vertex,
v2: Vertex,
v3: Vertex,
color: BABYLON.Color4): void {
// 進行排序,p1總在最上面,p2總在最中間,p3總在最下面
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.y > v3.Coordinates.y) {
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
var p1 = v1.Coordinates;
var p2 = v2.Coordinates;
var p3 = v3.Coordinates;
// 法線面上的向量是該法線面和每個頂點法線面中心點的平均值
var vnFace = (v1.Normal.add(v2.Normal.add(v3.Normal))).scale(1 / 3);
var centerPoint = (v1.WorldCoordinates.add(v2.WorldCoordinates.add(v3.WorldCoordinates))).scale(1 / 3);
// 光照位置
var lightPos = new BABYLON.Vector3(0, 10, 10);
// 計算光向量和法線向量之間夾角的余弦
// 它會返回介于0和1之間的值,該值將被用作顏色的亮度
var ndotl = this.computeNDotL(centerPoint, vnFace, lightPos);
var data: ScanLineData = {
ndotla: ndotl
};
// 計算線條的方向
var dP1P2: number;
var dP1P3: number;
// http://en.wikipedia.org/wiki/Slope
// 計算斜率
if (p2.y - p1.y > 0) dP1P2 = (p2.x - p1.x) / (p2.y - p1.y);
else dP1P2 = 0;
if (p3.y - p1.y > 0) dP1P3 = (p3.x - p1.x) / (p3.y - p1.y);
else dP1P3 = 0;
// 在第一種情況下,三角形是這樣的:
// P1
// -
// --
// - -
// - -
// - - P2
// - -
// - -
// -
// P3
if (dP1P2 > dP1P3) {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
this.processScanLine(data, v1, v3, v1, v2, color);
} else {
this.processScanLine(data, v1, v3, v2, v3, color);
}
}
}
// 在第二種情況下,三角形是這樣的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
this.processScanLine(data, v1, v2, v1, v3, color);
} else {
this.processScanLine(data, v2, v3, v1, v3, color);
}
}
}
}
~~~
【譯者注:JavaScript代碼】
~~~
// 將三維坐標和變換矩陣轉換成二維坐標
Device.prototype.project = function (vertex, transMat, world) {
// 將坐標轉換為二維空間
var point2d = BABYLON.Vector3.TransformCoordinates(vertex.Coordinates, transMat);
// 在三維世界中轉換坐標和法線的頂點
var point3DWorld = BABYLON.Vector3.TransformCoordinates(vertex.Coordinates, world);
var normal3DWorld = BABYLON.Vector3.TransformCoordinates(vertex.Normal, world);
// 變換后的坐標起始點是坐標系的中心點
// 但是,在屏幕上,我們以左上角為起始點
// 我們需要重新計算使他們的起始點變成左上角
var x = point2d.x * this.workingWidth + this.workingWidth / 2.0;
var y = -point2d.y * this.workingHeight + this.workingHeight / 2.0;
return ({
Coordinates: new BABYLON.Vector3(x, y, point2d.z),
Normal: normal3DWorld,
WorldCoordinates: point3DWorld
});
};
// 在兩點之間從左到右繪制一條線段
// papb -> pcpd
// pa, pb, pc, pd在之前必須已經排好序
Device.prototype.processScanLine = function (data, va, vb, vc, vd, color) {
var pa = va.Coordinates;
var pb = vb.Coordinates;
var pc = vc.Coordinates;
var pd = vd.Coordinates;
// 由當前的y值,我們可以計算出梯度
// 以此再計算出 起始X(sx) 和 結束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的話,梯度強制為1
var gradient1 = pa.y != pb.y ? (data.currentY - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (data.currentY - pc.y) / (pd.y - pc.y) : 1;
var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;
// 開始Z值和結束Z值
var z1 = this.interpolate(pa.z, pb.z, gradient1);
var z2 = this.interpolate(pc.z, pd.z, gradient2);
// 從左(sx)向右(ex)繪制一條線
for (var x = sx; x < ex; x++) {
var gradient = (x - sx) / (ex - sx);
var z = this.interpolate(z1, z2, gradient);
var ndotl = data.ndotla;
// 基于光向量和法線向量之間角度的余弦改變顏色值
this.drawPoint(new BABYLON.Vector3(x, data.currentY, z),
new BABYLON.Color4(color.r * ndotl, color.g * ndotl, color.b * ndotl, 1));
}
};
// 計算光向量和法線向量之間角度的余弦
// 返回0到1之間的值
Device.prototype.computeNDotL = function (vertex, normal, lightPosition) {
var lightDirection = lightPosition.subtract(vertex);
normal.normalize();
lightDirection.normalize();
return Math.max(0, BABYLON.Vector3.Dot(normal, lightDirection));
};
Device.prototype.drawTriangle = function (v1, v2, v3, color) {
// 進行排序,p1總在最上面,p2總在最中間,p3總在最下面
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.y > v3.Coordinates.y) {
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
var p1 = v1.Coordinates;
var p2 = v2.Coordinates;
var p3 = v3.Coordinates;
// 法線面上的向量是該法線面和每個頂點法線面中心點的平均值
var vnFace = (v1.Normal.add(v2.Normal.add(v3.Normal))).scale(1 / 3);
var centerPoint = (v1.WorldCoordinates.add(v2.WorldCoordinates.add(v3.WorldCoordinates))).scale(1 / 3);
// 光照位置
var lightPos = new BABYLON.Vector3(0, 10, 10);
// 計算光向量和法線向量之間夾角的余弦
// 它會返回介于0和1之間的值,該值將被用作顏色的亮度
var ndotl = this.computeNDotL(centerPoint, vnFace, lightPos);
var data = { ndotla: ndotl };
// 計算線條的方向
var dP1P2;
var dP1P3;
// http://en.wikipedia.org/wiki/Slope
// 計算斜率
if (p2.y - p1.y > 0)
dP1P2 = (p2.x - p1.x) / (p2.y - p1.y); else
dP1P2 = 0;
if (p3.y - p1.y > 0)
dP1P3 = (p3.x - p1.x) / (p3.y - p1.y); else
dP1P3 = 0;
// 在第一種情況下,三角形是這樣的:
// P1
// -
// --
// - -
// - -
// - - P2
// - -
// - -
// -
// P3
if (dP1P2 > dP1P3) {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
this.processScanLine(data, v1, v3, v1, v2, color);
} else {
this.processScanLine(data, v1, v3, v2, v3, color);
}
}
}
// 在第二種情況下,三角形是這樣的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
this.processScanLine(data, v1, v2, v1, v3, color);
} else {
this.processScanLine(data, v2, v3, v1, v3, color);
}
}
}
};
~~~
要查看瀏覽器中的效果,請點擊下面的截圖:
[](http://david.blob.core.windows.net/softengine3d/part5sample1/index.html)
3D軟件渲染引擎:[在瀏覽器中查看Html5平面著色演示](http://david.blob.core.windows.net/softengine3d/part5sample1/index.html)
在我的聯想X1 Carbon (酷睿i7 lvy Bridge)中,使用 Internet Explorer 11(這似乎是我的Windows8.1機器中最快的瀏覽器) 我跑這個640x480的實現大約可以跑到 35FPS,并且在 Surface RT 中大約可以得到 4FPS 每秒的執行速度。C#的并行版本渲染同樣的場景則可以運行在 60FPS速度下。
你可以在這里下載執行這一平面渲染解決方案:
- C#:?[SoftEngineCSharpPart5FlatShading.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart5FlatShading.zip)
- TypeScript:?[SoftEngineTSPart5FlatShading.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineTSPart5FlatShading.zip)
- JavaScript:?[SoftEngineJSPart5FlatShading.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineJSPart5FlatShading.zip)
高氏著色
概念
以如果你已經成功的理解了平面著色,那么你會發現高氏著色并不復雜。這次我們不僅針對每個面賦予一個顏色,而是根據三角形的頂點使用3個法線。然后我們定義顏色的3個級別,使用插值在之前的教程中使用相同的算法對每個頂點之間的像素賦予顏色。使用這種插值,我們將得到三角形連續的光影效果。

圖片摘取自:[教程5.地形 - 光與頂點法線向量](http://www.uniqsoft.co.uk/directx/html/tut5/tut5.htm)
你可以在這張圖中看出平面著色和高氏著色的區別。平面著色采用了居中的獨有法線,高氏著色則使用了3個頂點法線。你還可以看看3D網格(棱錐),法線是每頂點每面。我的意思是相同的頂點將具有基于我們當前繪制面不同的法線。
讓我們回到繪制三角面邏輯中來。有一個很好的方式來說明我們要做的陰影:

摘自:[教程-創建法線貼圖](http://www.bencloward.com/tutorials_normal_maps2.shtml)(作者:Ben Cloward)
在該圖中,假設上方頂點有一個>90度夾角的光的方向,它的顏色應該是黑色的(光的最小級別 = 0)。想象一下現在的其他兩個頂點法線與光的方向角度為0度,這意味著他們應受到光的最大級別(1)。
為了填充我們的三角形,我們還需要用到插值來使每個頂點之間的顏色有一個很好的過渡。
實現代碼
因為代碼非常簡單,稍作閱讀就能夠理解我實現的顏色插值了。
【譯者注:C#代碼】
~~~
// 在兩點之間從左往右畫條線
// papb -> pcpd
// pa, pb, pc, pd 需要先進行排序
void ProcessScanLine(ScanLineData data, Vertex va, Vertex vb, Vertex vc, Vertex vd, Color4 color)
{
Vector3 pa = va.Coordinates;
Vector3 pb = vb.Coordinates;
Vector3 pc = vc.Coordinates;
Vector3 pd = vd.Coordinates;
// 由當前的y值,我們可以計算出梯度
// 以此再計算出 起始X(sx) 和 結束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的話,梯度強制為1
var gradient1 = pa.Y != pb.Y ? (data.currentY - pa.Y) / (pb.Y - pa.Y) : 1;
var gradient2 = pc.Y != pd.Y ? (data.currentY - pc.Y) / (pd.Y - pc.Y) : 1;
int sx = (int)Interpolate(pa.X, pb.X, gradient1);
int ex = (int)Interpolate(pc.X, pd.X, gradient2);
// 開始Z值和結束Z值
float z1 = Interpolate(pa.Z, pb.Z, gradient1);
float z2 = Interpolate(pc.Z, pd.Z, gradient2);
var snl = Interpolate(data.ndotla, data.ndotlb, gradient1);
var enl = Interpolate(data.ndotlc, data.ndotld, gradient2);
// 從左(sx)向右(ex)繪制一條線
for (var x = sx; x < ex; x++)
{
float gradient = (x - sx) / (float)(ex - sx);
var z = Interpolate(z1, z2, gradient);
var ndotl = Interpolate(snl, enl, gradient);
// 使用光的向量和法線向量之間的角度余弦來改變顏色值
DrawPoint(new Vector3(x, data.currentY, z), color * ndotl);
}
}
public void DrawTriangle(Vertex v1, Vertex v2, Vertex v3, Color4 color)
{
// 進行排序,p1總在最上面,p2總在最中間,p3總在最下面
if (v1.Coordinates.Y > v2.Coordinates.Y)
{
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.Y > v3.Coordinates.Y)
{
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.Y > v2.Coordinates.Y)
{
var temp = v2;
v2 = v1;
v1 = temp;
}
Vector3 p1 = v1.Coordinates;
Vector3 p2 = v2.Coordinates;
Vector3 p3 = v3.Coordinates;
// 光照位置
Vector3 lightPos = new Vector3(0, 10, 10);
// 計算光向量和法線向量之間夾角的余弦
// 它會返回介于0和1之間的值,該值將被用作顏色的亮度
float nl1 = ComputeNDotL(v1.WorldCoordinates, v1.Normal, lightPos);
float nl2 = ComputeNDotL(v2.WorldCoordinates, v2.Normal, lightPos);
float nl3 = ComputeNDotL(v3.WorldCoordinates, v3.Normal, lightPos);
var data = new ScanLineData { };
// 計算線條的方向
float dP1P2, dP1P3;
// http://en.wikipedia.org/wiki/Slope
// 計算斜率
if (p2.Y - p1.Y > 0)
dP1P2 = (p2.X - p1.X) / (p2.Y - p1.Y);
else
dP1P2 = 0;
if (p3.Y - p1.Y > 0)
dP1P3 = (p3.X - p1.X) / (p3.Y - p1.Y);
else
dP1P3 = 0;
if (dP1P2 > dP1P3)
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
data.currentY = y;
if (y < p2.Y)
{
data.ndotla = nl1;
data.ndotlb = nl3;
data.ndotlc = nl1;
data.ndotld = nl2;
ProcessScanLine(data, v1, v3, v1, v2, color);
}
else
{
data.ndotla = nl1;
data.ndotlb = nl3;
data.ndotlc = nl2;
data.ndotld = nl3;
ProcessScanLine(data, v1, v3, v2, v3, color);
}
}
}
else
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
data.currentY = y;
if (y < p2.Y)
{
data.ndotla = nl1;
data.ndotlb = nl2;
data.ndotlc = nl1;
data.ndotld = nl3;
ProcessScanLine(data, v1, v2, v1, v3, color);
}
else
{
data.ndotla = nl2;
data.ndotlb = nl3;
data.ndotlc = nl1;
data.ndotld = nl3;
ProcessScanLine(data, v2, v3, v1, v3, color);
}
}
}
}
~~~
【譯者注:TypeScript代碼】
~~~
// 在兩點之間從左往右畫條線
// papb -> pcpd
// pa, pb, pc, pd 需要先進行排序
public processScanLine(data: ScanLineData, va: Vertex, vb: Vertex,
vc: Vertex, vd: Vertex, color: BABYLON.Color4): void {
var pa = va.Coordinates;
var pb = vb.Coordinates;
var pc = vc.Coordinates;
var pd = vd.Coordinates;
// 由當前的y值,我們可以計算出梯度
// 以此再計算出 起始X(sx) 和 結束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的話,梯度強制為1
var gradient1 = pa.y != pb.y ? (data.currentY - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (data.currentY - pc.y) / (pd.y - pc.y) : 1;
var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;
// 開始Z值和結束Z值
var z1: number = this.interpolate(pa.z, pb.z, gradient1);
var z2: number = this.interpolate(pc.z, pd.z, gradient2);
var snl = this.interpolate(data.ndotla, data.ndotlb, gradient1);
var enl = this.interpolate(data.ndotlc, data.ndotld, gradient2);
// 從左(sx)向右(ex)繪制一條線
for (var x = sx; x < ex; x++) {
var gradient: number = (x - sx) / (ex - sx);
var z = this.interpolate(z1, z2, gradient);
var ndotl = this.interpolate(snl, enl, gradient);
// 使用光的向量和法線向量之間的角度余弦來改變顏色值
this.drawPoint(new BABYLON.Vector3(x, data.currentY, z),
new BABYLON.Color4(color.r * ndotl, color.g * ndotl, color.b * ndotl, 1));
}
}
public drawTriangle(v1: Vertex, v2: Vertex, v3: Vertex, color: BABYLON.Color4): void {
// 進行排序,p1總在最上面,p2總在最中間,p3總在最下面
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.y > v3.Coordinates.y) {
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
var p1 = v1.Coordinates;
var p2 = v2.Coordinates;
var p3 = v3.Coordinates;
// 光照位置
var lightPos = new BABYLON.Vector3(0, 10, 10);
// 計算光向量和法線向量之間夾角的余弦
// 它會返回介于0和1之間的值,該值將被用作顏色的亮度
//var ndotl = this.computeNDotL(centerPoint, vnFace, lightPos);
var nl1 = this.computeNDotL(v1.WorldCoordinates, v1.Normal, lightPos);
var nl2 = this.computeNDotL(v2.WorldCoordinates, v2.Normal, lightPos);
var nl3 = this.computeNDotL(v3.WorldCoordinates, v3.Normal, lightPos);
var data: ScanLineData = { };
// 計算線條的方向
var dP1P2: number; var dP1P3: number;
// http://en.wikipedia.org/wiki/Slope
// 計算斜率
if (p2.y - p1.y > 0)
dP1P2 = (p2.x - p1.x) / (p2.y - p1.y);
else
dP1P2 = 0;
if (p3.y - p1.y > 0)
dP1P3 = (p3.x - p1.x) / (p3.y - p1.y);
else
dP1P3 = 0;
if (dP1P2 > dP1P3) {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++)
{
data.currentY = y;
if (y < p2.y) {
data.ndotla = nl1;
data.ndotlb = nl3;
data.ndotlc = nl1;
data.ndotld = nl2;
this.processScanLine(data, v1, v3, v1, v2, color);
}
else {
data.ndotla = nl1;
data.ndotlb = nl3;
data.ndotlc = nl2;
data.ndotld = nl3;
this.processScanLine(data, v1, v3, v2, v3, color);
}
}
}
else {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++)
{
data.currentY = y;
if (y < p2.y) {
data.ndotla = nl1;
data.ndotlb = nl2;
data.ndotlc = nl1;
data.ndotld = nl3;
this.processScanLine(data, v1, v2, v1, v3, color);
}
else {
data.ndotla = nl2;
data.ndotlb = nl3;
data.ndotlc = nl1;
data.ndotld = nl3;
this.processScanLine(data, v2, v3, v1, v3, color);
}
}
}
}
~~~
【譯者注:JavaScript代碼】
~~~
// 在兩點之間從左往右畫條線
// papb -> pcpd
// pa, pb, pc, pd 需要先進行排序
Device.prototype.processScanLine = function (data, va, vb, vc, vd, color) {
var pa = va.Coordinates;
var pb = vb.Coordinates;
var pc = vc.Coordinates;
var pd = vd.Coordinates;
// 由當前的y值,我們可以計算出梯度
// 以此再計算出 起始X(sx) 和 結束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的話,梯度強制為1
var gradient1 = pa.y != pb.y ? (data.currentY - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (data.currentY - pc.y) / (pd.y - pc.y) : 1;
var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;
// 開始Z值和結束Z值
var z1 = this.interpolate(pa.z, pb.z, gradient1);
var z2 = this.interpolate(pc.z, pd.z, gradient2);
var snl = this.interpolate(data.ndotla, data.ndotlb, gradient1);
var enl = this.interpolate(data.ndotlc, data.ndotld, gradient2);
// 從左(sx)向右(ex)繪制一條線
for (var x = sx; x < ex; x++) {
var gradient = (x - sx) / (ex - sx);
var z = this.interpolate(z1, z2, gradient);
var ndotl = this.interpolate(snl, enl, gradient);
// 使用光的向量和法線向量之間的角度余弦來改變顏色值
this.drawPoint(new BABYLON.Vector3(x, data.currentY, z),
new BABYLON.Color4(color.r * ndotl, color.g * ndotl, color.b * ndotl, 1));
}
};
Device.prototype.drawTriangle = function (v1, v2, v3, color) {
// 進行排序,p1總在最上面,p2總在最中間,p3總在最下面
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.y > v3.Coordinates.y) {
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
var p1 = v1.Coordinates;
var p2 = v2.Coordinates;
var p3 = v3.Coordinates;
// 光照位置
var lightPos = new BABYLON.Vector3(0, 10, 10);
// 計算光向量和法線向量之間夾角的余弦
// 它會返回介于0和1之間的值,該值將被用作顏色的亮度
var nl1 = this.computeNDotL(v1.WorldCoordinates, v1.Normal, lightPos);
var nl2 = this.computeNDotL(v2.WorldCoordinates, v2.Normal, lightPos);
var nl3 = this.computeNDotL(v3.WorldCoordinates, v3.Normal, lightPos);
var data = {};
// 計算線條的方向
var dP1P2;
var dP1P3;
// http://en.wikipedia.org/wiki/Slope
// 計算斜率
if (p2.y - p1.y > 0)
dP1P2 = (p2.x - p1.x) / (p2.y - p1.y); else
dP1P2 = 0;
if (p3.y - p1.y > 0)
dP1P3 = (p3.x - p1.x) / (p3.y - p1.y); else
dP1P3 = 0;
if (dP1P2 > dP1P3) {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
data.ndotla = nl1;
data.ndotlb = nl3;
data.ndotlc = nl1;
data.ndotld = nl2;
this.processScanLine(data, v1, v3, v1, v2, color);
} else {
data.ndotla = nl1;
data.ndotlb = nl3;
data.ndotlc = nl2;
data.ndotld = nl3;
this.processScanLine(data, v1, v3, v2, v3, color);
}
}
}
else {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
data.ndotla = nl1;
data.ndotlb = nl2;
data.ndotlc = nl1;
data.ndotld = nl3;
this.processScanLine(data, v1, v2, v1, v3, color);
} else {
data.ndotla = nl2;
data.ndotlb = nl3;
data.ndotlc = nl1;
data.ndotld = nl3;
this.processScanLine(data, v2, v3, v1, v3, color);
}
}
}
};
~~~
在瀏覽器中查看結果,請點擊下面的截圖:
[](http://david.blob.core.windows.net/softengine3d/part5sample2/index.html)
3D軟件渲染引擎:[使用Html5在你的瀏覽器中查看高氏著色示例](http://david.blob.core.windows.net/softengine3d/part5sample2/index.html)
你將會看到,性能/FPS幾乎相同,與平面著色算法相比,你將有一個更加美好的渲染效果。另外有一個更好的算法名為Phong著色算法。
這里有另外一個使用Html5在瀏覽器中的測試場景,它使用了Blender導出的一個圓環形模型:
[](http://david.blob.core.windows.net/softengine3d/part5sample3/index.html)
3D軟件渲染引擎:[查看圓環模型使用高氏著色的示例](http://david.blob.core.windows.net/softengine3d/part5sample3/index.html)
你可以在這里下載執行這一高氏著色解決方案:
- C#:?[SoftEngineCSharpPart5GouraudShading.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart5GouraudShading.zip)
- TypeScript:?[SoftEngineTSPart5GouraudShading.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineTSPart5GouraudShading.zip)
- JavaScript:?[SoftEngineJSPart5GouraudShading.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineJSPart5GouraudShading.zip)
在[下一個,也是最終教程](http://blogs.msdn.com/b/davrous/archive/2013/07/18/tutorial-part-6-learning-how-to-write-a-3d-software-engine-in-c-ts-or-js-texture-mapping-back-face-culling-amp-webgl.aspx)中,我們將看到應用了材質的模型,他看起來就像是這樣:

而且我們也將看到一個使用WebGL引擎實現的完全相同的3D對象。然后,你就會明白為什么GPU是如此的重要,以提高實時3D渲染的表現!