🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
译者前言: 本文译自[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) [![](https://box.kancloud.cn/2016-03-22_56f0e98a57c1d.jpg)](http://commons.wikimedia.org/wiki/File:Blender3d_NormalKoordinates.jpg) 蓝色箭头是面的法线,绿色和红色箭头可能是面的任何边缘向量。让我们用Blender的苏珊妮模型来了解这些法线向量。 打开Blender,加载苏珊妮网格,切换到“编辑模式”: ![](https://box.kancloud.cn/2016-03-22_56f0e98a69557.jpg) 通过点击它,然后按下“N”键打开网格的属性。在“显示网格”中,你能找到2个法线相关按钮。点击“显示面的法线”: ![](https://box.kancloud.cn/2016-03-22_56f0e98a7f0b1.jpg) 你将会得到类似这样的效果: ![](https://box.kancloud.cn/2016-03-22_56f0e98a92ff9.jpg) 我们之后将会定义一个光。这些光将成为教程中最简单的一个:一个点光源。这个点光源是简单的3D点(Vector3类型)。无论距离如何,我们的面接受光的数量是相同的。然后,我们将会简单的基于法线向量和光点向量的角度以及我们的面的中心来改变光的强度。 因此,光的方向将是:光的位置 - 面的中心位置 -> 这将会给我们光的方向向量。为了计算光向量和法线向量之间的角度,我们将使用点积:[http://en.wikipedia.org/wiki/Dot_product](http://en.wikipedia.org/wiki/Dot_product) ![](https://box.kancloud.cn/2016-03-22_56f0e98aab2cb.jpg) 该图来自:[逐像素光照](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); } } } }; ~~~ 要查看浏览器中的效果,请点击下面的截图: [![](https://box.kancloud.cn/2016-03-22_56f0e98aba4ad.jpg)](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个级别,使用插值在之前的教程中使用相同的算法对每个顶点之间的像素赋予颜色。使用这种插值,我们将得到三角形连续的光影效果。 ![](https://box.kancloud.cn/2016-03-22_56f0e98ad0e3d.jpg) 图片摘取自:[教程5.地形 - 光与顶点法线向量](http://www.uniqsoft.co.uk/directx/html/tut5/tut5.htm) 你可以在这张图中看出平面着色和高氏着色的区别。平面着色采用了居中的独有法线,高氏着色则使用了3个顶点法线。你还可以看看3D网格(棱锥),法线是每顶点每面。我的意思是相同的顶点将具有基于我们当前绘制面不同的法线。 让我们回到绘制三角面逻辑中来。有一个很好的方式来说明我们要做的阴影: ![](https://box.kancloud.cn/2016-03-22_56f0e98aebf8a.jpg) 摘自:[教程-创建法线贴图](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); } } } }; ~~~ 在浏览器中查看结果,请点击下面的截图: [![](https://box.kancloud.cn/2016-03-22_56f0e98b08a95.jpg)](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导出的一个圆环形模型: [![](https://box.kancloud.cn/2016-03-22_56f0e98b1e14a.jpg)](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)中,我们将看到应用了材质的模型,他看起来就像是这样: ![](https://box.kancloud.cn/2016-03-22_56f0e98b3572a.jpg) 而且我们也将看到一个使用WebGL引擎实现的完全相同的3D对象。然后,你就会明白为什么GPU是如此的重要,以提高实时3D渲染的表现!