译者前言:
本文译自MSDN。原作者为David Rousset,文章中假设有我的额外说明。我会加上【译者注:】。
正文開始:
这可能是整个系列中最优秀的部分:怎样处理光照!
在之前。我们已经搞定了让每一个面随机显示一种颜色。如今我们要进行改变,计算出光的角度,让每一个面有更好的光照效果。第一种方法叫做平面着色。它使用面法线,用这种方法我们也会看到不同面的效果。可是高氏着色则会让我们更进一步。它使用顶点法线,然后每一个像素使用3个法线进行插值计算颜色。
在本教程的最后。你应该能够得到这样一个很酷的渲染效果:
本章教程是下面系列的一部分:
5 – 使用平面着色和高氏着色处理光 (本文)
平面着色
概念
为了可以应用平面着色算法,我们首先须要计算面的法线向量。我们一旦得到了它。我们还须要知道该法线向量和光向量之间的角度。
为了更精确。我们将使用点积返回给我们两个向量之间角的余弦。
由于这种值可能是-1和1之间的数。我们将它们收紧到0-1之间。我们的面依据终于的光量值来计算颜色。
总之。我们的面终于颜色将是 = color * Math.Max(0, cos(angle))。
让我们从法线向量開始。维基百科定义法线(几何体)指出:“对于凸多边形(如三角形)。一个表面法线可被计算为多边形两(非平行)边向量的叉积”。
为了说明这一点。你能够在Blender文档中看到一个有趣的内容:Blender 3D:入门到精通 - Normal_coordinates
蓝色箭头是面的法线。绿色和红色箭头可能是面的不论什么边缘向量。让我们用Blender的苏珊妮模型来了解这些法线向量。
打开Blender。载入苏珊妮网格,切换到“编辑模式”:
通过点击它,然后按下“N”键打开网格的属性。
在“显示网格”中。你能找到2个法线相关button。点击“显示面的法线”:
你将会得到类似这种效果:
我们之后将会定义一个光。
这些光将成为教程中最简单的一个:一个点光源。这个点光源是简单的3D点(Vector3类型)。不管距离怎样,我们的面接受光的数量是同样的。然后,我们将会简单的基于法线向量和光点向量的角度以及我们的面的中心来改变光的强度。
因此,光的方向将是:光的位置 - 面的中心位置 -> 这将会给我们光的方向向量。
为了计算光向量和法线向量之间的角度,我们将使用点积:http://en.wikipedia.org/wiki/Dot_product
该图来自:逐像素光照(由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); } } } };
要查看浏览器中的效果。请点击以下的截图:
3D软件渲染引擎:在浏览器中查看Html5平面着色演示
在我的联想X1 Carbon (酷睿i7 lvy Bridge)中。使用 Internet Explorer 11 (这似乎是我的Windows8.1机器中最快的浏览器) 我跑这个640x480的实现大约能够跑到 35FPS。而且在 Surface RT 中大约能够得到 4FPS 每秒的执行速度。C#的并行版本号渲染相同的场景则能够执行在 60FPS速度下。
你能够在这里下载运行这一平面渲染解决方式:
- C#: SoftEngineCSharpPart5FlatShading.zip
- TypeScript: SoftEngineTSPart5FlatShading.zip
- JavaScript: SoftEngineJSPart5FlatShading.zip
高氏着色
概念
以假设你已经成功的理解了平面着色,那么你会发现高氏着色并不复杂。这次我们不仅针对每一个面赋予一个颜色。而是依据三角形的顶点使用3个法线。
然后我们定义颜色的3个级别,使用插值在之前的教程中使用同样的算法对每一个顶点之间的像素赋予颜色。使用这样的插值,我们将得到三角形连续的光影效果。
图片摘取自:教程5.地形 - 光与顶点法线向量
你能够在这张图中看出平面着色和高氏着色的差别。平面着色採用了居中的独有法线。高氏着色则使用了3个顶点法线。
你还能够看看3D网格(棱锥),法线是每顶点每面。我的意思是同样的顶点将具有基于我们当前绘制面不同的法线。
让我们回到绘制三角面逻辑中来。
有一个非常好的方式来说明我们要做的阴影:
摘自:教程-创建法线贴图(作者:Ben Cloward)
在该图中,如果上方顶点有一个>90度夹角的光的方向。它的颜色应该是黑色的(光的最小级别 = 0)。
想象一下如今的其它两个顶点法线与光的方向角度为0度,这意味着他们应受到光的最大级别(1)。
为了填充我们的三角形,我们还须要用到插值来使每一个顶点之间的颜色有一个非常好的过渡。
实现代码
由于代码很easy。稍作阅读就行理解我实现的颜色插值了。
【译者注: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); } } } };
在浏览器中查看结果,请点击以下的截图:
3D软件渲染引擎:使用Html5在你的浏览器中查看高氏着色演示样例
你将会看到。性能/FPS差点儿同样,与平面着色算法相比。你将有一个更加美好的渲染效果。另外有一个更好的算法名为Phong着色算法。
这里有另外一个使用Html5在浏览器中的測试场景,它使用了Blender导出的一个圆环形模型:
3D软件渲染引擎:查看圆环模型使用高氏着色的演示样例
你能够在这里下载运行这一高氏着色解决方式:
- C#: SoftEngineCSharpPart5GouraudShading.zip
- TypeScript: SoftEngineTSPart5GouraudShading.zip
- JavaScript: SoftEngineJSPart5GouraudShading.zip
在下一个,也是终于教程中。我们将看到应用了材质的模型,他看起来就像是这样:
并且我们也将看到一个使用WebGL引擎实现的全然同样的3D对象。然后,你就会明确为什么GPU是如此的重要,以提高实时3D渲染的表现!