七,应用纹理、背面剔除以及一些WebGL相关
最后更新于:2022-04-01 10:02:50
译者前言:
本文译自[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/),文章中如果有我的额外说明,我会加上【译者注:】。
正文开始:
下面是本系列的最后一个章节了。我们将看到如何从Blender中导出贴图和纹理坐标来使我们的网格应用纹理。如果你已经成功的了解了之前的教程,应用一些纹理对你来说应该是小菜一碟。主要概念依旧是在每个顶点间插补一些数据。在本章的第二部分中,我们将看到如何提高我们的渲染算法性能。为此,我们将使用背面剔除来使得只有我们能看到的部分被绘制。但是更进一步,我们会用最后的秘密武器:GPU。那么你将会明白为什么OpenGL/WebGL和DirectX这些技术对实时3D游戏非常重要。它们有助于利用GPU而不是CPU来渲染我们的3D对象。想要真正的看到差异,我们将在加载一个名为Babylon.js的WebGL 3D引擎中使用完全相同的模型JSON文件。渲染FPS将会好的多,尤其是在低端设备!
在本教程的最后,你将可以在自己的3D软件渲染引擎中看到这样的渲染效果:
[点击运行](https://david.blob.core.windows.net/softengine3d/part6/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 – 使用平面着色和高氏着色处理光](http://blog.csdn.net/teajs/article/details/50103367)
6 – 应用纹理、背面剔除以及一些WebGL相关 (本文)
进一步深入:我和David Catuhe做了一个免费的拥有8个单元的课程供你学习基础3D知识,比如WebGL和[Babylon.js](http://www.babylonjs.com/)。第一个模块是包含本系列教程的40分钟的视频版本:[介绍WebGL 3D、Html5和Babylon.js](http://www.microsoftvirtualacademy.com/training-courses/introduction-to-webgl-3d-with-html5-and-babylon-js)。你将学习到很多关于如何使用WebGL制作浏览器中运行的3D游戏!一探究竟。它是免费并且充满乐趣的。
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98b539b1.jpg)](http://www.microsoftvirtualacademy.com/training-courses/introduction-to-webgl-3d-with-html5-and-babylon-js)
纹理映射
**概念**
首先让我们从维基百科的定义开始:[纹理映射](http://en.wikipedia.org/wiki/Texture_mapping):“纹理贴图应用(映射)到形状或多边形表面”。这个过程类似于应用一个图案到一个纯白色的盒子上。在一个多边形的每个顶点分配一个纹理坐标(在2D情况下也被称为UV坐标),通过显式分配或由程序定义。在图像中的一个位置上取样然后贴在一个多边形上,以此产生视觉效果。
让我们试着去了解它的准确意思。
第一次我试图想象我们如何能够将纹理应用到一个立方体3D网格。然后我想拍摄图像充当我们的纹理并将其映射到每个立方体的面。这可以在一个简单的例子中工作的很好。但是,第一个问题是:如果我希望每个立方体面应用不同的图像/纹理该怎么办?第一个想法是采取6种不同的图像在不同的6个面。为了更加精确,取6幅图像,将他们分割成被映射到一个立方体的12个三角形中的2个三角形。
下面这张图片将简单的帮助你理解:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98b7d586.jpg)
我们可以用非常类似的方法顺利的在3D引擎中工作。想象一下,这个图像将作为我们的立方体纹理被应用。认为它是一个颜色字节的二维数组。我们可以使用2D坐标将该阵列中的值移动到每个立方体的顶点,以此来获得类似的东西:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98ba254f.jpg)
此图片来源于:[Texturing a cube in Blender, and going to pull my hair out](http://www.sluniverse.com/php/vb/content-creation/65233-texturing-cube-blender-going-pull.html)
这些2D纹理坐标被称为UV坐标。
**注**:我询问了一个3D大神为什么它们被称为U和V呢?答案是令人惊讶而且显而易见的:”嗯,因为它们在X、Y、Z之前“(26个字母排列)。我期待一个更复杂的答案!![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98bbe0df.gif)
现在你可能会问自己如何处理苏珊妮,我们的美丽的猴子脑袋,不是吗?
对于这种网状的,我们也将使用3D映射一个单一的2D图像。要建立相应的纹理,我们需要您计划2D网格视图。此操作被称为展开(unwrap)操作。如果您是一个可怜的开发者比如我,相信我,你需要一个像我的朋友[Michel Rousseau](http://blogs.msdn.com/designmichel)一样的3D设计师,在这个阶段来帮助您!而这也正是我都做了什么:寻求帮助。![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca4488de4a.gif)
使用苏珊妮作为一个例子,在展开操作之后,设计者将会得到这样的结果:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98bd3832.jpg)
那么设计师会在这样一个二维视图中画画以便于在我们的引擎中使用纹理。在本例中,Michel Rousseau来做这项工作,这里是他自己的苏珊妮版本:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98be840f.jpg)
我知道第一次去试着理解纹理映射这个结果显得比较奇怪。但是,你应该已经可以看到一些东西,看起来纹理右下角是眼睛。这部分将在使用3D简单对称区分操作映射到苏珊妮的两个眼睛。
你现在知道了纹理贴图的基本知识。要明确了解它是如何工作的,请阅读我在网络上找到的这些额外资源:
- [教程16 - 纹理映射基础](http://ogldev.atspace.co.uk/www/tutorial16/tutorial16.html),请阅读第一部分,这将有助于你了解如何将UV坐标映射(在0-1之间)在我们的网格三角形
- [Blender 2.6 手册](http://wiki.blender.org/index.php/Doc:2.6/Manual/Textures/Mapping/UV/Unwrapping) - 网格的UV贴图,描述各种映射类型
- [教程5 - 纹理映射](http://www.real3dtutorials.com/tut00005.php),请阅读第一部分这一定会帮助你至少知道如何映射到一个立方体。
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-02_56d6ad910ce86.gif)
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98c20771.jpg)
代码
现在,我们准备开发新代码了。我们将有几项工作要完成:
1 - 创建一个纹理类将图像加载进来充当纹理并根据UV坐标返回每像素插值坐标的颜色
2 - 添加/传递 在合成渲染流程中的纹理信息
3 - 解析由[Blender的 Babylon导出插件](http://blogs.msdn.com/b/davrous/archive/2013/06/17/tutorial-part-3-learning-how-to-write-a-3d-soft-engine-in-c-ts-or-js-loading-meshes-exported-from-blender.aspx)导出的JSON文件去加载UV坐标
纹理逻辑
在Html5和TypeScript/JavaScript中,我们当然要通过动态创建一个Canvas元素加载材质并得到它相关的图像数据,以用来获得我们的颜色字节数组。
而在C#/XAML中,我们要创建一个WriteableBitmap,设置源并在加载完图像后获得其缓冲区属性以此获取我们的颜色字节数组。
【译者注:C#代码】
~~~
public class Texture
{
private byte[] internalBuffer;
private int width;
private int height;
// 材质尺寸需要是2的次方(如:512x512、1024x1024等)
public Texture(string filename, int width, int height)
{
this.width = width;
this.height = height;
Load(filename);
}
async void Load(string filename)
{
var file = await Windows.ApplicationModel.Package.Current.InstalledLocation.GetFileAsync(filename);
using (var stream = await file.OpenReadAsync())
{
var bmp = new WriteableBitmap(width, height);
bmp.SetSource(stream);
internalBuffer = bmp.PixelBuffer.ToArray();
}
}
// 获得Blender导出的UV坐标并将其对应的像素颜色返回
public Color4 Map(float tu, float tv)
{
// 图像尚未加载
if (internalBuffer == null)
{
return Color4.White;
}
// 使用%运算符来循环/重复需要的这个纹理
int u = Math.Abs((int) (tu*width) % width);
int v = Math.Abs((int) (tv*height) % height);
int pos = (u + v * width) * 4;
byte b = internalBuffer[pos];
byte g = internalBuffer[pos + 1];
byte r = internalBuffer[pos + 2];
byte a = internalBuffer[pos + 3];
return new Color4(r / 255.0f, g / 255.0f, b / 255.0f, a / 255.0f);
}
}
~~~
【译者注:TypeScript代码】
~~~
export class Texture {
width: number;
height: number;
internalBuffer: ImageData;
// 材质尺寸需要是2的次方(如:512x512、1024x1024等)
constructor(filename: string, width: number, height: number) {
this.width = width;
this.height = height;
this.load(filename);
}
public load(filename: string): void {
var imageTexture = new Image();
imageTexture.height = this.height;
imageTexture.width = this.width;
imageTexture.onload = () => {
var internalCanvas: HTMLCanvasElement = document.createElement("canvas");
internalCanvas.width = this.width;
internalCanvas.height = this.height;
var internalContext: CanvasRenderingContext2D = internalCanvas.getContext("2d");
internalContext.drawImage(imageTexture, 0, 0);
this.internalBuffer = internalContext.getImageData(0, 0, this.width, this.height);
};
imageTexture.src = filename;
}
// 获得Blender导出的UV坐标并将其对应的像素颜色返回
public map(tu: number, tv: number): BABYLON.Color4 {
if (this.internalBuffer) {
// 使用%运算符来循环/重复需要的这个纹理
var u = Math.abs(((tu * this.width) % this.width)) >> 0;
var v = Math.abs(((tv * this.height) % this.height)) >> 0;
var pos = (u + v * this.width) * 4;
var r = this.internalBuffer.data[pos];
var g = this.internalBuffer.data[pos + 1];
var b = this.internalBuffer.data[pos + 2];
var a = this.internalBuffer.data[pos + 3];
return new BABYLON.Color4(r / 255.0, g / 255.0, b / 255.0, a / 255.0);
}
// 图像尚未加载
else {
return new BABYLON.Color4(1, 1, 1, 1);
}
}
}
~~~
【译者注:JavaScript代码】
~~~
var Texture = (function () {
// 材质尺寸需要是2的次方(如:512x512、1024x1024等)
function Texture(filename, width, height) {
this.width = width;
this.height = height;
this.load(filename);
}
Texture.prototype.load = function (filename) {
var _this = this;
var imageTexture = new Image();
imageTexture.height = this.height;
imageTexture.width = this.width;
imageTexture.onload = function () {
var internalCanvas = document.createElement("canvas");
internalCanvas.width = _this.width;
internalCanvas.height = _this.height;
var internalContext = internalCanvas.getContext("2d");
internalContext.drawImage(imageTexture, 0, 0);
_this.internalBuffer = internalContext.getImageData(0, 0, _this.width, _this.height);
};
imageTexture.src = filename;
};
// 获得Blender导出的UV坐标并将其对应的像素颜色返回
Texture.prototype.map = function (tu, tv) {
if (this.internalBuffer) {
// 使用%运算符来循环/重复需要的这个纹理
var u = Math.abs(((tu * this.width) % this.width)) >> 0;
var v = Math.abs(((tv * this.height) % this.height)) >> 0;
var pos = (u + v * this.width) * 4;
var r = this.internalBuffer.data[pos];
var g = this.internalBuffer.data[pos + 1];
var b = this.internalBuffer.data[pos + 2];
var a = this.internalBuffer.data[pos + 3];
return new BABYLON.Color4(r / 255.0, g / 255.0, b / 255.0, a / 255.0);
}
// 图像尚未加载
else {
return new BABYLON.Color4(1, 1, 1, 1);
}
};
return Texture;
})();
SoftEngine.Texture = Texture;
~~~
传递纹理信息流程
我不会深入到每一个细节,下面有完整的代码下载,让我们来看看你都需要做些什么:
- 添加一个纹理属性到Mesh类和一个Vector2属性名称为TextureCoordinates的Vertex结构
- 更新ScanLineData中嵌入8个单精度小数/数字:每个顶点的UV坐标(ua, ub, uc, ud和va, vb, vc, vd)。
- 更新Project方法/函数返回一个新的Vertex和TextureCoordinates原封不动的使用(传递)
- 传递一个Texture对象作为DrawTriangle方法/函数的最后一个参数到ProcessScanLine
- 填充新的ScanLineData结构在drawTriangle和相应的UV坐标
- 把UV插值到ProcessScanLine函数的Y上得到SU/SV和EU/EV(start U/start V end U/End V)然后插值U,V在X上,就可以找到它在纹理中对应的颜色。将此颜色与对象本身的颜色(在本教程中一般它是白色)还有法线进行NDotL操作得到光量并进行混合。
**注**:我们的Project方法可以被看作是我们命名为“Vertex Shader”的3D硬件引擎,并且ProcessScanLine可以被看作是"Pixel Shader"引擎。
这篇文章仅在ProcessScanLine中有新的更新部分:
【译者注:C#代码】
~~~
void ProcessScanLine(ScanLineData data, Vertex va, Vertex vb, Vertex vc, Vertex vd, Color4 color, Texture texture)
{
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);
// 将法线插值到Y中
var snl = Interpolate(data.ndotla, data.ndotlb, gradient1);
var enl = Interpolate(data.ndotlc, data.ndotld, gradient2);
// 将纹理坐标插值到Y中
var su = Interpolate(data.ua, data.ub, gradient1);
var eu = Interpolate(data.uc, data.ud, gradient2);
var sv = Interpolate(data.va, data.vb, gradient1);
var ev = Interpolate(data.vc, data.vd, gradient2);
// 从左(sx)向右(ex)绘制一条线
for (var x = sx; x < ex; x++)
{
float gradient = (x - sx) / (float)(ex - sx);
// 将Z坐标、法线和纹理坐标插值到X中
var z = Interpolate(z1, z2, gradient);
var ndotl = Interpolate(snl, enl, gradient);
var u = Interpolate(su, eu, gradient);
var v = Interpolate(sv, ev, gradient);
Color4 textureColor;
if (texture != null)
textureColor = texture.Map(u, v);
else
textureColor = new Color4(1, 1, 1, 1);
// 使用光向量、法线向量的角度余弦值以及材质颜色来改变原本颜色值
DrawPoint(new Vector3(x, data.currentY, z), color * ndotl * textureColor);
}
}
~~~
【译者注:TypeScript代码】
~~~
public processScanLine(data: ScanLineData, va: Vertex, vb: Vertex, vc: Vertex, vd: Vertex, color: BABYLON.Color4, texture?: Texture): 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);
// 将法线插值到Y中
var snl = this.interpolate(data.ndotla, data.ndotlb, gradient1);
var enl = this.interpolate(data.ndotlc, data.ndotld, gradient2);
// 将纹理坐标插值到Y中
var su = this.interpolate(data.ua, data.ub, gradient1);
var eu = this.interpolate(data.uc, data.ud, gradient2);
var sv = this.interpolate(data.va, data.vb, gradient1);
var ev = this.interpolate(data.vc, data.vd, gradient2);
// 从左(sx)向右(ex)绘制一条线
for (var x = sx; x < ex; x++) {
var gradient: number = (x - sx) / (ex - sx);
// 将Z坐标、法线和纹理坐标插值到X中
var z = this.interpolate(z1, z2, gradient);
var ndotl = this.interpolate(snl, enl, gradient);
var u = this.interpolate(su, eu, gradient);
var v = this.interpolate(sv, ev, gradient);
var textureColor;
if (texture)
textureColor = texture.map(u, v);
else
textureColor = new BABYLON.Color4(1, 1, 1, 1);
// 使用光向量、法线向量的角度余弦值以及材质颜色来改变原本颜色值
this.drawPoint(new BABYLON.Vector3(x, data.currentY, z),
new BABYLON.Color4(color.r * ndotl * textureColor.r,
color.g * ndotl * textureColor.g,
color.b * ndotl * textureColor.b, 1));
}
}
~~~
【译者注:JavaScript代码】
~~~
Device.prototype.processScanLine = function (data, va, vb, vc, vd, color, texture) {
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);
// 将法线插值到Y中
var snl = this.interpolate(data.ndotla, data.ndotlb, gradient1);
var enl = this.interpolate(data.ndotlc, data.ndotld, gradient2);
// 将纹理坐标插值到Y中
var su = this.interpolate(data.ua, data.ub, gradient1);
var eu = this.interpolate(data.uc, data.ud, gradient2);
var sv = this.interpolate(data.va, data.vb, gradient1);
var ev = this.interpolate(data.vc, data.vd, gradient2);
// 从左(sx)向右(ex)绘制一条线
for (var x = sx; x < ex; x++) {
var gradient = (x - sx) / (ex - sx);
// 将Z坐标、法线和纹理坐标插值到X中
var z = this.interpolate(z1, z2, gradient);
var ndotl = this.interpolate(snl, enl, gradient);
var u = this.interpolate(su, eu, gradient);
var v = this.interpolate(sv, ev, gradient);
var textureColor;
if (texture)
textureColor = texture.map(u, v);
else
textureColor = new BABYLON.Color4(1, 1, 1, 1);
// 使用光向量、法线向量的角度余弦值以及材质颜色来改变原本颜色值
this.drawPoint(new BABYLON.Vector3(x, data.currentY, z),
new BABYLON.Color4(color.r * ndotl * textureColor.r,
color.g * ndotl * textureColor.g,
color.b * ndotl * textureColor.b, 1));
}
};
~~~
如果你已经按照之前的所有教程建立过自己的版本,那么请下载我的解决方案进行对比并更新你的项目。
从Babylon JSON文件格式中载入信息
为了能够很好的渲染你在本章最开始所看到的效果,你需要加载由[Michel Rousseau](https://twitter.com/rousseau_michel)所修改的贴图以及从Blender导出的苏珊妮模型的最新版本。因此,请下载这两个文件:
- 从Blender中导出的苏珊妮模型以及纹理坐标集合:
[http://david.blob.core.windows.net/softengine3d/part6/monkey.babylon](http://david.blob.core.windows.net/softengine3d/part6/monkey.babylon)
- 要加载的512x512大小的纹理贴图:
[http://david.blob.core.windows.net/softengine3d/part6/Suzanne.jpg](http://david.blob.core.windows.net/softengine3d/part6/Suzanne.jpg)
David Catuhe所导出的Babylon.JSON格式文件包含了很多我们本章节不会涵盖的细节。例如,贴材质到模型上。实际上,设计者可以使用特殊材质来贴到一个网格中。在我们的例子中,我们只打算处理漫反射纹理。如果你想要实现更多,还请以David Catuhe的文章作为基础:[Babylon.js:在您的游戏中使用标准材质](http://blogs.msdn.com/b/eternalcoding/archive/2013/07/01/babylon-js-unleash-the-standardmaterial-for-your-babylon-js-game.aspx)
接下来,我将只与你分享有变化的主体部分:加载和分析JSON文件的方法/函数。
~~~
// 以异步方式加载JSON文件
public async Task<Mesh[]> LoadJSONFileAsync(string fileName)
{
var meshes = new List<Mesh>();
var materials = new Dictionary<String,Material>();
var file = await Windows.ApplicationModel.Package.Current.InstalledLocation.GetFileAsync(fileName);
var data = await Windows.Storage.FileIO.ReadTextAsync(file);
dynamic jsonObject = Newtonsoft.Json.JsonConvert.DeserializeObject(data);
for (var materialIndex = 0; materialIndex < jsonObject.materials.Count; materialIndex++)
{
var material = new Material();
material.Name = jsonObject.materials[materialIndex].name.Value;
material.ID = jsonObject.materials[materialIndex].id.Value;
if (jsonObject.materials[materialIndex].diffuseTexture != null)
material.DiffuseTextureName = jsonObject.materials[materialIndex].diffuseTexture.name.Value;
materials.Add(material.ID, material);
}
for (var meshIndex = 0; meshIndex < jsonObject.meshes.Count; meshIndex++)
{
var verticesArray = jsonObject.meshes[meshIndex].vertices;
// 模型面
var indicesArray = jsonObject.meshes[meshIndex].indices;
var uvCount = jsonObject.meshes[meshIndex].uvCount.Value;
var verticesStep = 1;
// 在顶点数组中根据纹理坐标来使每顶点跳帧数自动选择6、8或10
switch ((int)uvCount)
{
case 0:
verticesStep = 6;
break;
case 1:
verticesStep = 8;
break;
case 2:
verticesStep = 10;
break;
}
// 有趣的顶点信息数字
var verticesCount = verticesArray.Count / verticesStep;
// 面数是逻辑上的大小除以3(A,B,C)得到
var facesCount = indicesArray.Count / 3;
var mesh = new Mesh(jsonObject.meshes[meshIndex].name.Value, verticesCount, facesCount);
// 首先我们填充网格的顶点数组
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)
};
if (uvCount > 0)
{
// 加载纹理坐标
float u = (float)verticesArray[index * verticesStep + 6].Value;
float v = (float)verticesArray[index * verticesStep + 7].Value;
mesh.Vertices[index].TextureCoordinates = new Vector2(u, v);
}
}
// 然后填充模型面数组
for (var index = 0; index < facesCount; index++)
{
var a = (int)indicesArray[index * 3].Value;
var b = (int)indicesArray[index * 3 + 1].Value;
var c = (int)indicesArray[index * 3 + 2].Value;
mesh.Faces[index] = new Face { A = a, B = b, C = c };
}
// 获取你在Blender中设置的位置
var position = jsonObject.meshes[meshIndex].position;
mesh.Position = new Vector3((float)position[0].Value, (float)position[1].Value, (float)position[2].Value);
if (uvCount > 0)
{
// 材质
var meshTextureID = jsonObject.meshes[meshIndex].materialId.Value;
var meshTextureName = materials[meshTextureID].DiffuseTextureName;
mesh.Texture = new Texture(meshTextureName, 512, 512);
}
meshes.Add(mesh);
}
return meshes.ToArray();
}
~~~
【译者注:TypeScript代码】
~~~
private CreateMeshesFromJSON(jsonObject): Mesh[] {
var meshes: Mesh[] = [];
var materials: Material[] = [];
for (var materialIndex = 0; materialIndex < jsonObject.materials.length; materialIndex++) {
var material: Material = {};
material.Name = jsonObject.materials[materialIndex].name;
material.ID = jsonObject.materials[materialIndex].id;
if (jsonObject.materials[materialIndex].diffuseTexture)
material.DiffuseTextureName = jsonObject.materials[materialIndex].diffuseTexture.name;
materials[material.ID] = material;
}
for (var meshIndex = 0; meshIndex < jsonObject.meshes.length; meshIndex++) {
var verticesArray: number[] = jsonObject.meshes[meshIndex].vertices;
// 模型面
var indicesArray: number[] = jsonObject.meshes[meshIndex].indices;
var uvCount: number = jsonObject.meshes[meshIndex].uvCount;
var verticesStep = 1;
// 在顶点数组中根据纹理坐标来使每顶点跳帧数自动选择6、8或10
switch (uvCount) {
case 0:
verticesStep = 6;
break;
case 1:
verticesStep = 8;
break;
case 2:
verticesStep = 10;
break;
}
// 有趣的顶点信息数字
var verticesCount = verticesArray.length / verticesStep;
// 面数是逻辑上的大小除以3(A,B,C)得到
var facesCount = indicesArray.length / 3;
var mesh = new SoftEngine.Mesh(jsonObject.meshes[meshIndex].name, verticesCount, facesCount);
// 首先我们填充网格的顶点数组
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)
};
if (uvCount > 0) {
// 加载纹理坐标
var u = verticesArray[index * verticesStep + 6];
var v = verticesArray[index * verticesStep + 7];
mesh.Vertices[index].TextureCoordinates = new BABYLON.Vector2(u, v);
}
else {
mesh.Vertices[index].TextureCoordinates = new BABYLON.Vector2(0, 0);
}
}
// 然后填充模型面数组
for (var index = 0; index < facesCount; index++) {
var a = indicesArray[index * 3];
var b = indicesArray[index * 3 + 1];
var c = indicesArray[index * 3 + 2];
mesh.Faces[index] = {
A: a,
B: b,
C: c
};
}
// 获取你在Blender中设置的位置
var position = jsonObject.meshes[meshIndex].position;
mesh.Position = new BABYLON.Vector3(position[0], position[1], position[2]);
if (uvCount > 0) {
var meshTextureID = jsonObject.meshes[meshIndex].materialId;
var meshTextureName = materials[meshTextureID].DiffuseTextureName;
mesh.Texture = new Texture(meshTextureName, 512, 512);
}
meshes.push(mesh);
}
return meshes;
}
~~~
【译者注:JavaScript代码】
~~~
Device.prototype.CreateMeshesFromJSON = function (jsonObject) {
var meshes = [];
var materials = [];
for (var materialIndex = 0; materialIndex < jsonObject.materials.length; materialIndex++) {
var material = {};
material.Name = jsonObject.materials[materialIndex].name;
material.ID = jsonObject.materials[materialIndex].id;
if (jsonObject.materials[materialIndex].diffuseTexture)
material.DiffuseTextureName = jsonObject.materials[materialIndex].diffuseTexture.name;
materials[material.ID] = material;
}
for (var meshIndex = 0; meshIndex < jsonObject.meshes.length; meshIndex++) {
var verticesArray = jsonObject.meshes[meshIndex].vertices;
// 模型面
var indicesArray = jsonObject.meshes[meshIndex].indices;
var uvCount = jsonObject.meshes[meshIndex].uvCount;
var verticesStep = 1;
// 在顶点数组中根据纹理坐标来使每顶点跳帧数自动选择6、8或10
switch (uvCount) {
case 0:
verticesStep = 6;
break;
case 1:
verticesStep = 8;
break;
case 2:
verticesStep = 10;
break;
}
// 有趣的顶点信息数字
var verticesCount = verticesArray.length / verticesStep;
// 面数是逻辑上的大小除以3(A,B,C)得到
var facesCount = indicesArray.length / 3;
var mesh = new SoftEngine.Mesh(jsonObject.meshes[meshIndex].name, verticesCount, facesCount);
// 首先我们填充网格的顶点数组
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)
};
if (uvCount > 0) {
// 加载纹理坐标
var u = verticesArray[index * verticesStep + 6];
var v = verticesArray[index * verticesStep + 7];
mesh.Vertices[index].TextureCoordinates = new BABYLON.Vector2(u, v);
}
else {
mesh.Vertices[index].TextureCoordinates = new BABYLON.Vector2(0, 0);
}
}
// 然后填充模型面数组
for (var index = 0; index < facesCount; index++) {
var a = indicesArray[index * 3];
var b = indicesArray[index * 3 + 1];
var c = indicesArray[index * 3 + 2];
mesh.Faces[index] = {
A: a,
B: b,
C: c
};
}
// 获取你在Blender中设置的位置
var position = jsonObject.meshes[meshIndex].position;
mesh.Position = new BABYLON.Vector3(position[0], position[1], position[2]);
if (uvCount > 0) {
var meshTextureID = jsonObject.meshes[meshIndex].materialId;
var meshTextureName = materials[meshTextureID].DiffuseTextureName;
mesh.Texture = new Texture(meshTextureName, 512, 512);
}
meshes.push(mesh);
}
return meshes;
};
~~~
有了这些修改,我们现在可以看到这个使用高氏着色算法渲染出美丽的苏珊妮模型了:
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98c3c3d0.jpg)](http://david.blob.core.windows.net/softengine3d/part6sample1/index.html)
3D软件渲染引擎:[在浏览器中使用Html5查看苏珊妮纹理和高氏着色示例](http://david.blob.core.windows.net/softengine3d/part6sample1/index.html)
你可以在这里下载执行这一纹理映射算法解决方案:
- C#: [SoftEngineCSharpPart6Sample1.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart6Sample1.zip)
- TypeScript: [SoftEngineTSPart6Sample1.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineTSPart6Sample1.zip)
- JavaScript: [SoftEngineJSPart6Sample1.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineJSPart6Sample1.zip)
性能差异不会很大。在我的机器中C#版本用1600x900的分辨率以18帧每秒的速度运行,而 用Html5版本用640x480的分辨率以15帧每秒的速度运行在IE11中。
在使用GPU方案之前,让我们来看看你的3D软件渲染引擎最终优化方案。
背面剔除
让我们再次从维基百科的定义开始:[背面剔除](http://en.wikipedia.org/wiki/Back-face_culling):”在[计算机图形学](http://en.wikipedia.org/wiki/Computer_graphics)中,背面剔除用来确定图形对象的[多边形](http://en.wikipedia.org/wiki/Polygon)是否可见实施背面剔除的一种方法是通过丢弃其[表面法线](http://en.wikipedia.org/wiki/Surface_normal)和相机到多边形向量的[点积](http://en.wikipedia.org/wiki/Dot_product)大于或等于零的所有多边形“。
这个想法是我们的例子中在每个网格表面法线预计算的时候,在加载并解析阶段使用之前教程中的平面着色相同的算法来完成。一旦这样做,在Render方法/函数,我们将改变表面法线在世界视图(相机观看世界)的坐标,并检查它的Z值。如果它>=0,意味着我们不会绘制这个三角形,这个模型面在镜头中不可见。
3D软件渲染引擎:[在浏览器中使用Html5查看苏珊妮纹理、高氏着色以及启用了背面剔除的示例](http://david.blob.core.windows.net/softengine3d/part6sample2/index.html)
你可以在这里下载执行这一背面剔除解决方案:
- C#: [SoftEngineCSharpPart6Sample2.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart6Sample2.zip)
- TypeScript: [SoftEngineTSPart6Sample2.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineTSPart6Sample2.zip)
- JavaScript: [SoftEngineJSPart6Sample2.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineJSPart6Sample2.zip)
**注**:你会发现我的背面剔除方案有一些小Bug。极少数应该被绘制的三角形没有被绘制。这是因为我们应该调整法线的变换,以便考虑到摄像机当前视角。当前算法使得我们有了一个正交相机,但却有些不同。解决这个问题对你来说应该是一个很好的锻炼!![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-02-17_56c446a99dec4.gif)
提升性能是很有意思的事,我们大约得到了66%的性能提升,我在IE11环境下从平均每秒15帧转向启用背面剔除,从而提升到了25帧每秒。
通过Babylon.js使用WebGL进行渲染
如今的3D游戏,理所应当要使用GPU来渲染。本系列教程的真正目的是建立自己的3D软件渲染引擎,了解3D相关的基本知识。一旦你能明白这几章的内容,使用OpenGL/WebGL或DirectX将会是得心应手的。
在我们周围,一直都有一套框架可以使得开发人员非常容易的构建Html5 3D游戏。它就是由David Catuhe构建的Babylon.js。
David已经开始在他的博客上写了一系列教程以便让大家知道如何使用他自己写的WebGL 3D引擎。入口在这里:[Babylon.js:使用JavaScript的Html5 WebGL引擎来构建3D游戏](http://blogs.msdn.com/b/eternalcoding/archive/2013/06/27/babylon-js-a-complete-javascript-framework-for-building-3d-games-with-html-5-and-webgl.aspx)
通过这一系列教程:[Babylon.js:如何加载并使用由Blender导出的.babaylon文件](http://blogs.msdn.com/b/eternalcoding/archive/2013/06/28/babylon-js-how-to-load-a-babylon-file-produced-with-blender.aspx),你就可以在浏览器中使用GPU来加速我们的模型!
如果你由IE11,Chrome或FireFox或任何可以执行WebGL的设备/浏览器的话,你可以在这里测试效果:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98c5fa50.jpg)
Babylon.js - WebGL的3D引擎:[预览苏珊娜模型纹理以及硬件加速效果!](http://david.blob.core.windows.net/softengine3d/part6webgl/index.html)
由于使用了WebGL,我们得到了一个巨大的性能提升。比如,在我的Surface RT的Windows8.1中,使用IE11,我从3D软件渲染引擎的640x480分辨率绘制4帧每秒的速度提升到了WebGL渲染引擎的1366x768分辨率下的60帧每秒的速度!
本系列教程已完结。
六,平面着色和高氏着色
最后更新于:2022-04-01 10:02:48
译者前言:
本文译自[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://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98a57c1d.jpg)](http://commons.wikimedia.org/wiki/File:Blender3d_NormalKoordinates.jpg)
蓝色箭头是面的法线,绿色和红色箭头可能是面的任何边缘向量。让我们用Blender的苏珊妮模型来了解这些法线向量。
打开Blender,加载苏珊妮网格,切换到“编辑模式”:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98a69557.jpg)
通过点击它,然后按下“N”键打开网格的属性。在“显示网格”中,你能找到2个法线相关按钮。点击“显示面的法线”:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98a7f0b1.jpg)
你将会得到类似这样的效果:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98a92ff9.jpg)
我们之后将会定义一个光。这些光将成为教程中最简单的一个:一个点光源。这个点光源是简单的3D点(Vector3类型)。无论距离如何,我们的面接受光的数量是相同的。然后,我们将会简单的基于法线向量和光点向量的角度以及我们的面的中心来改变光的强度。
因此,光的方向将是:光的位置 - 面的中心位置 -> 这将会给我们光的方向向量。为了计算光向量和法线向量之间的角度,我们将使用点积:[http://en.wikipedia.org/wiki/Dot_product](http://en.wikipedia.org/wiki/Dot_product)
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/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://docs.gechiui.com/gc-content/uploads/sites/kancloud/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://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98ad0e3d.jpg)
图片摘取自:[教程5.地形 - 光与顶点法线向量](http://www.uniqsoft.co.uk/directx/html/tut5/tut5.htm)
你可以在这张图中看出平面着色和高氏着色的区别。平面着色采用了居中的独有法线,高氏着色则使用了3个顶点法线。你还可以看看3D网格(棱锥),法线是每顶点每面。我的意思是相同的顶点将具有基于我们当前绘制面不同的法线。
让我们回到绘制三角面逻辑中来。有一个很好的方式来说明我们要做的阴影:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/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://docs.gechiui.com/gc-content/uploads/sites/kancloud/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://docs.gechiui.com/gc-content/uploads/sites/kancloud/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://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98b3572a.jpg)
而且我们也将看到一个使用WebGL引擎实现的完全相同的3D对象。然后,你就会明白为什么GPU是如此的重要,以提高实时3D渲染的表现!
五,额外章节,使用技巧和并行处理来提高性能
最后更新于:2022-04-01 10:02:46
译者前言:
本文译自[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/),文章中如果有我的额外说明,我会加上【译者注:】。
正文开始:
在之前的教程中,我们学习了如何通过C#、TypeScript、JavaScript使用光栅化和深度缓冲来填充我们的三角形。由于我们的3D软件渲染引擎使用的是CPU运算,因此它将耗费大量的CPU处理时间。不过倒是有一个好消息,那就是CPU大多是多核心的。那么,我们可以想象一下使用并行处理来提高引擎性能。不过我们只能在C#中这么做,至于为什么Html5不可以,我将会稍后做出解释。我们在此篇章中可以学到一些简单的技巧,以此达到优化渲染性能的目的。而且实际上,我们也得到了从5 FPS提升到50 FPS的结果,这可是10倍的性能提升!
本章教程是以下系列的一部分:
[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 – 额外章节:使用技巧和并行处理来提高性能(本文)
[5 – 使用平面着色和高氏着色处理光 ](http://blogs.msdn.com/b/davrous/archive/2013/07/03/tutorial-part-5-learning-how-to-write-a-3d-software-engine-in-c-ts-or-js-flat-amp-gouraud-shading.aspx)
[6 – 应用纹理、背面剔除以及一些WebGL相关](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)
**计算FPS**
首先第一步我们需要知道FPS,这是性能体现的一个重要指标。我们不论在C#、TypeScript或JavaScript中都可以实现FPS的统计。
首先我们需要知道两帧之间的执行间隔时间。
我们要进行循环渲染,在Html5中使用requestAnimationFrame,在XAML中使用Composition.Rendering,它们会自动管理循环时间。
一般而言,最佳间隔时间在 1000(每秒毫秒数) / 60(显示器最大刷新率) 毫秒内,也就是 ≈ 16.666667 毫秒。
这里有一个David和我一起做的[基准测试专题](http://blogs.msdn.com/b/eternalcoding/archive/2013/05/21/benchmarking-a-html5-game-html5-potatoes-gaming-bench.aspx)。
综上所诉,在C#中,添加一个新的XAML TextBlock元素,将其命名为"fps",用于显示当前FPS值:
~~~
DateTime previousDate;
void CompositionTarget_Rendering(object sender, object e)
{
// Fps
var now = DateTime.Now;
var currentFps = 1000.0 / (now - previousDate).TotalMilliseconds;
previousDate = now;
fps.Text = string.Format("{0:0.00} fps", currentFps);
// Rendering loop
device.Clear(0, 0, 0, 255);
foreach (var mesh in meshes)
{
mesh.Rotation = new Vector3(mesh.Rotation.X, mesh.Rotation.Y + 0.01f, mesh.Rotation.Z);
device.Render(mera, mesh);
}
device.Present();
}
~~~
我在我的联想 Carbon X1 Touch(分辨率为1600x900)电脑上运行前面的C#版示例,每秒5 FPS。我的联想电脑CPU为Intel Core I7-3667U(4个逻辑处理器)以及一个HD4000核心显卡。
**优化和并行处理策略**
WinRT应用程序使用.Net Framework4.5版本,其中默认(TPL)包含了任务并行库(Task Parallel Library)。如果你留心写算法的方式,并且你的算法可以并行处理,那么使之并行化处理将变得非常容易。如果你不了解这个概念,可以先看看[并行处理的.Net Framework4: 入门](http://blogs.msdn.com/b/csharpfaq/archive/2010/06/01/parallel-programming-in-net-framework-4-getting-started.aspx)
**避免接触UI控件**
多线程/多任务的第一条规则是没有一点关于UI控件代码存在。只有UI线程可以接触/操控图形控件。但在我们的例子中,有一段代码访问到bmp.PixelWidth和bmp.PixelHeight。bmp是WriteableBitmap类型,它被认为是一个UI元素,而不是线程安全的。这就是为什么,我们需要先修改这些代码才能使他们“并行”。在之前的教程中,我们就已经开始这么做了,那就是将bmp中的PixelWidth和PixelHeight的引用全部更改为renderWidth和renderHeight就可以了。
顺便说一句,这个规则不仅是并行处理所必须的,也是一般的性能优化。因此,通过简单的缓存机制把WriteableBitmap转移到我们的变量中便可以达到从5 FPS提升到45 FPS!
在Html5中这个规则也是非常重要的。你应该避免直接访问DOM元素的属性,因为DOM操作非常慢。所以,它并不以16毫秒每帧的运行速度在运行。我们最好始终缓存接下来要使用到的值。不过我们已经在之前的教程中这么做了。
**自给自足**
第二个原则是,代码将在多个可用核心中自给自足。你的代码不能等待很长时间,否则将抵消并行处理的优势。不过幸运的是,在我们的例子中,一直遵循着这个规则。
你可能已经看到,我们有几个地方用Parallel.For替换了经典的循环以此来并行处理。
第一个例子是DrawTriangle方法中。我们将用并行处理用几条线画三角形。在此,你可以很容易的将2个正常循环替换为Parallel.For循环:
~~~
if (dP1P2 > dP1P3)
{
Parallel.For((int)p1.Y, (int)p3.Y + 1, y =>
{
if (y < p2.Y)
{
ProcessScanLine(y, p1, p3, p1, p2, color);
}
else
{
ProcessScanLine(y, p1, p3, p2, p3, color);
}
});
}
else
{
Parallel.For((int)p1.Y, (int)p3.Y + 1, y =>
{
if (y < p2.Y)
{
ProcessScanLine(y, p1, p2, p1, p3, color);
}
else
{
ProcessScanLine(y, p2, p3, p1, p3, color);
}
});
}
~~~
但是,在我的情况下,输出的结果有一点点出乎意料。我的性能被降低了,竟然从45 FPS跌到了40 FPS!那么,是什么原因导致了这个问题呢?
那么,出现这样的情况,显然不是使用并行处理就够了。我们需要花更多的时间用于上下文切换,也就是从一个核心移动到另一个核心而不是真正并行处理。你可以使用Visual Studio 2012的嵌入式分析工具:[Visual Studio 2012并行可视化](http://msdn.microsoft.com/en-us/library/dd537632.aspx)来查看。
下面是第一种并行方式的核心利用图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98a02e39.jpg)
各种颜色都与工作线程有关,这真的没有效率可言。下面再看看**非并行版本**的核心利用图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98a216a8.jpg)
我们只有一个线程在工作(绿色的),所显示的是由操作系统派出的一些核心。即使我们不明确使用并行,CPU也会自动使用多核心处理。显然我们的第一种并行方案上下文切换太频繁了。
**互斥锁和适当循环的并行处理**
嗯,我猜你和我得出了同样的结论。并行化的drawTriangle循环方案似乎并没有成为一个很好的选择。我们需要另外的东西来让线程切换更加高效。我们要并行处理多个三角形,而不是绘制一个三角形。总之,每一个核心都要绘制一个完整的三角形。
这种方法的问题将会在PutPixel方法中出现。现在我们要并行处理几个面,我们可能会陷入2个内核/线程试图并行访问同一个像素的情况。这是,我们只需要保护要访问的像素就可以了。事实上,如果我们用更多时间去保护正在工作中的数据,并行化将会非常有用。
解决的办法是使用一个锁:
~~~
private object[] lockBuffer;
public Device(WriteableBitmap bmp)
{
this.bmp = bmp;
renderWidth = bmp.PixelWidth;
renderHeight = bmp.PixelHeight;
// 后台缓冲区大小值是要绘制的像素
// 屏幕(width*height) * 4 (R,G,B & Alpha值)
backBuffer = new byte[renderWidth * renderHeight * 4];
depthBuffer = new float[renderWidth * renderHeight];
lockBuffer = new object[renderWidth * renderHeight];
for (var i = 0; i < lockBuffer.Length; i++)
{
lockBuffer[i] = new object();
}
}
// 调用此方法把一个像素绘制到指定的X, Y坐标上
public void PutPixel(int x, int y, float z, Color4 color)
{
// 我们的后台缓冲区是一维数组
// 这里我们简单计算,将X和Y对应到此一维数组中
var index = (x + y * renderWidth);
var index4 = index * 4;
// 使用锁来保护缓冲区不被并行处理扰乱
lock (lockBuffer[index])
{
if (depthBuffer[index] < z)
{
return; // 深度测试不通过
}
depthBuffer[index] = z;
backBuffer[index4] = (byte)(color.Blue * 255);
backBuffer[index4 + 1] = (byte)(color.Green * 255);
backBuffer[index4 + 2] = (byte)(color.Red * 255);
backBuffer[index4 + 3] = (byte)(color.Alpha * 255);
}
}
~~~
使用第二种方法,从45 FPS提升到了53 FPS。你可能会认为这点性能提升并不能让人影响深刻。但是在接下来的教程中,drawTriangle方法将更加复杂,比如处理阴影和照明之类的,并行处理几乎可以得到两倍的性能提升。
我们还可以看看这个并行处理方法分析的核心利用图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98a3d654.jpg)
比较以前的核心图,你就会明白为什么它更加高效了。
你可以在这里下载含有这种优化的C#版本解决方案:
- C#:[SoftEngineCSharpPart4Bonus.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart4Bonus.zip)
那么,在Html5/JavaScript中为什么不能应用这种方法呢?
Html5在JavaScript中卫开发人员提供了一个新的API用于处理类似的情况。它叫Web Workers,使用它可以处理使用多核心的情况。
David Catuhe和我已经在这三篇话题中讨论过这个东西:
- [介绍Html5的Web Workers:JavaScript的多线程方法](http://blogs.msdn.com/b/davrous/archive/2011/07/15/introduction-to-the-html5-web-workers-the-javascript-multithreading-approach.aspx):如果你还不知道Web Workers,那么你应该先阅读这篇文章
- [使用Web Workers,用于提高图像处理性能](http://blogs.msdn.com/b/eternalcoding/archive/2012/09/20/using-web-workers-to-improve-performance-of-image-manipulation.aspx):一篇很有意思的文章,我们使用Web Workers来获得像素操作的性能提升
- [教程系列:在Windows 8中使用WinJS和WinRT构建一个有趣的Html5摄像头应用程序](http://blogs.msdn.com/b/davrous/archive/2012/10/02/tutorial-series-using-winjs-amp-winrt-to-build-a-fun-html5-camera-application-for-windows-8-4-4.aspx):在这个教程中使用Web Workers来进行摄像头拍摄的图像效果处理
我们使用message与workers进行沟通。这意味着大多数时间被用于从UI线程给workers线程传输拷贝数据。我们只有很少部分的类型可以使用。事实上,随着[转换对象](http://www.w3.org/html/wg/drafts/html/master/infrastructure.html#transferable-objects),当转移到workers时原始对象将从呼叫者上下文(UI线程)中清除。
但是在我们加速Html5的3D软件渲染引擎时,这并不是主要的问题。在发送数据的时候将有*memcpy()*进行操作,这是一个非常快速的过程。但真正的问题是,当workers完成了处理后,需要将结果发送回主线程而且这个主线程还需要将数据重新填充回像素数组中。这个操作将很简单的抵消掉所有Web Workers带来的性能增益。
所以最后,我还没有找到一个有效的并行处理方法来实现Html5中3D软件渲染引擎的性能提升。不过,可能我有一些东西还不知道。如果您可以解决当前Web Workers的局限,获得到一个显著的性能提升的话,我非常乐意接受建议! :)
在接下来的教程中,我们将讨论[平面着色和高氏着色](http://blogs.msdn.com/b/davrous/archive/2013/07/03/tutorial-part-5-learning-how-to-write-a-3d-software-engine-in-c-ts-or-js-flat-amp-gouraud-shading.aspx)。我们的实现将开始真正耀眼! :)
四,填充光栅化的三角形并使用深度缓冲
最后更新于:2022-04-01 10:02:43
译者前言:
本文译自[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/),文章中如果有我的额外说明,我会加上【译者注:】。
正文开始:
在前面的教程中,我们学习了如何在C#、TypeScript或JavaScript中编写3D软件渲染引擎中的从Blender加载导出网格这一章节。
我们已经能够在引擎中加载从Blender导出的Json文件了。那么到现在为止,我们的渲染效果依然只是简单的线框渲染。但是,在本章我们将讲解如何使用三角形光栅化算法来填充三角形。然后,我们将使用深度缓冲,以避免在后面的面跑到前面来的问题。
本章教程是以下系列的一部分:
[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 –填充光栅化的三角形并使用深度缓冲(本文)
[4b – 额外章节:使用技巧和并行处理来提高性能](http://blog.csdn.net/teajs/article/details/50054509)
[5 – 使用平面着色和高氏着色处理光 ](http://blogs.msdn.com/b/davrous/archive/2013/07/03/tutorial-part-5-learning-how-to-write-a-3d-software-engine-in-c-ts-or-js-flat-amp-gouraud-shading.aspx)
[6 – 应用纹理、背面剔除以及一些WebGL相关](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)
通过本章节,你将能够看到这样的效果:
[点击运行](http://david.blob.core.windows.net/softengine3d/part4/index.html)
**光栅化**
【译者注:感谢四川-平生小哥为我们翻译此段】
有很多不同类型的光栅化算法。我甚至知道在我的团队中有人向知名的GPU厂商提出了自己的光栅化算法。也多亏了他,我现在知道了什么是[折行书](https://en.wikipedia.org/wiki/Boustrophedon)并一直使用至今。 :-)
为了更正规,我们将在本教程中实现一个简单而有效的光栅化算法。正如我们在CPU中运行3D软件渲染引擎一般,它会耗费我们的大量CPU运算。当然,现今这个功能已经直接用GPU来帮我们完成。
先让我们做一个练习。请拿出一张纸,然后画一个三角形,嗯……任意你所能画出来的三角形。我们要找出一个通用的方法可以得出任意类型的三角形。
如果我们按Y轴对每个三角形的三个点进行排序,保证 P1 后面是 P2, 然后是 P3 的话,最终将出现两种可能的情况:
![三角形的两种情况](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e989ca528.jpg "三角形的两种情况")
你将会看到这两种情况:
P2 在 P1 和 P3 的右侧 或 P2在 P1 和 P3 的左侧。在本教程中,由于我们始终是从左到右(sx 到 se)的顺序画线,所以就按照这个假设来处理这两种情况。
此外,我们要顺着左图中的红线自上而下 (从 P1.Y 到 P3.Y) 从左向右绘制。但是当到达P2.Y时我们需要稍微改变一下逻辑,因为这时两种情况的斜率都会发生改变。这就是为什么我们将扫描线处理分为两个步骤:从 P1.Y 向下移动到 P2.Y,然后从P2.Y 最终移动到 P3.Y。
要了解我们使用算法的全部逻辑,可以在维基百科中找到词条:[http://en.wikipedia.org/wiki/Slope](http://en.wikipedia.org/wiki/Slope "http://en.wikipedia.org/wiki/Slope")。它只是一些基本的数学运算。
为了能够适应这两种情况,你只需要进行简单的运算:
dP1P2 = P2.X - P1.X / P2.Y - P1.Y
dP1P3 = P3.X - P1.X / P3.Y - P1.Y
那我们如何得知是属于哪种情况呢?
P2 在右的第一种情况:dP1P2 > dP1P3
P2 在左的第二种情况:dP1P3 > dP1P2
现在已经有了算法的基本逻辑,我们需要知道如何计算上图中每条线上的 sx(起始的 x 坐标) 和 ex(结束的 x 坐标) 之间的 x。因此要首先计算出 sx 和 ex。由于我们知道当前所扫描到的 y 值、P1P3 和 P1P3 的斜率,因此我们不难得出 sx 和 ex 值。
以情况1为例。首先利用当前的 y 值来计算梯度。它将告诉我们在 P1.Y 和 P2.Y之间进行处理时,我们当前所处的阶段。
梯度 = 当前的y值 - P1.Y / P2.Y - P1.Y
因为 x 和 y 是线性连接,所以我们可以基于该梯度,利用 P1.X 和 P3.X 来计算 sx 插值,并利用 P1.X 和 P2.X来计算 ex 插值。
如果您能够理解插值这个概念,那么你就能够理解剩下所有关于光纤和材质。而且你能够很好的阅读相关代码,也能够从头开始自己重写代码,而无须复制、粘贴下面的代码。
如果还不是很清楚的话,这里有一些关于光栅化的文章以供阅读:
[- 3D软件渲染引擎 - 第一部分](http://www.codeproject.com/Articles/170296/3D-Software-Rendering-Engine-Part-I)
[- 三角形光栅化](https://lva.cg.tuwien.ac.at/ecg/wiki/doku.php?id=students:fill_rasterization)
[- 填充三角形的软件光栅化算法](http://www.sunshine2k.de/coding/java/TriangleRasterization/TriangleRasterization.html)
现在,基于我们现有的算法描述说明让我们开始编写代码。首先,从设备对象删除 drawLine 和 drawBline 函数,并用下面的代码进行替换:
【译者注:C#代码】
~~~
// 将三维坐标和变换矩阵转换成二维坐标
public Vector3 Project(Vector3 coord, Matrix transMat)
{
// 进行坐标变换
var point = Vector3.TransformCoordinate(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.X * bmp.PixelWidth + bmp.PixelWidth / 2.0f;
var y = -point.Y * bmp.PixelHeight + bmp.PixelHeight / 2.0f;
return (new Vector3(x, y, point.Z));
}
// 如果二维坐标在可视范围内则绘制
public void DrawPoint(Vector2 point, Color4 color)
{
// 判断是否在屏幕内
if (point.X >= 0 && point.Y >= 0 && point.X < bmp.PixelWidth && point.Y < bmp.PixelHeight)
{
// 绘制一个点
PutPixel((int)point.X, (int)point.Y, color);
}
}
~~~
【译者注:TypeScript代码】
~~~
// 将三维坐标和变换矩阵转换成二维坐标
public project(coord: BABYLON.Vector3, transMat: BABYLON.Matrix): BABYLON.Vector3 {
// 进行坐标变换
var point = BABYLON.Vector3.TransformCoordinates(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.x * this.workingWidth + this.workingWidth / 2.0;
var y = -point.y * this.workingHeight + this.workingHeight / 2.0;
return (new BABYLON.Vector3(x, y, point.z));
}
// 如果二维坐标在可视范围内则绘制
public drawPoint(point: BABYLON.Vector2, color: BABYLON.Color4): void {
// 判断是否在屏幕内
if(point.x >= 0 && point.y >= 0 && point.x < this.workingWidth && point.y < this.workingHeight) {
// 绘制一个点
this.putPixel(point.x, point.y, color);
}
}
~~~
【译者注:JavaScript代码】
~~~
// 将三维坐标和变换矩阵转换成二维坐标
Device.prototype.project = function (coord, transMat) {
var point = BABYLON.Vector3.TransformCoordinates(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.x * this.workingWidth + this.workingWidth / 2.0 >> 0;
var y = -point.y * this.workingHeight + this.workingHeight / 2.0 >> 0;
return (new BABYLON.Vector3(x, y, point.z));
};
// 如果二维坐标在可视范围内则绘制
Device.prototype.drawPoint = function (point, color) {
// 判断是否在屏幕内
if (point.x >= 0 && point.y >= 0 && point.x < this.workingWidth
&& point.y < this.workingHeight) {
// 绘制一个点
this.putPixel(point.x, point.y, color);
}
};
~~~
我们仅仅做了一些准备,下面则是最重要的部分,基于先前解释过的三角形的逻辑进行绘制。
【译者注:C#代码】
~~~
// 限制数值范围在0和1之间
float Clamp(float value, float min = 0, float max = 1)
{
return Math.Max(min, Math.Min(value, max));
}
// 过渡插值
float Interpolate(float min, float max, float gradient)
{
return min + (max - min) * Clamp(gradient);
}
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd在之前必须已经排好序
void ProcessScanLine(int y, Vector3 pa, Vector3 pb, Vector3 pc, Vector3 pd, Color4 color)
{
// 由当前的y值,我们可以计算出梯度
// 以此再计算出 起始X(sx) 和 结束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的话,梯度强制为1
var gradient1 = pa.Y != pb.Y ? (y - pa.Y) / (pb.Y - pa.Y) : 1;
var gradient2 = pc.Y != pd.Y ? (y - 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);
// 从左(sx)向右(ex)绘制一条线
for (var x = sx; x < ex; x++)
{
DrawPoint(new Vector2(x, y), color);
}
}
public void DrawTriangle(Vector3 p1, Vector3 p2, Vector3 p3, Color4 color)
{
// 进行排序,p1总在最上面,p2总在最中间,p3总在最下面
if (p1.Y > p2.Y)
{
var temp = p2;
p2 = p1;
p1 = temp;
}
if (p2.Y > p3.Y)
{
var temp = p2;
p2 = p3;
p3 = temp;
}
if (p1.Y > p2.Y)
{
var temp = p2;
p2 = p1;
p1 = temp;
}
// 反向斜率
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++)
{
if (y < p2.Y)
{
ProcessScanLine(y, p1, p3, p1, p2, color);
}
else
{
ProcessScanLine(y, p1, p3, p2, p3, color);
}
}
}
// 对于第二种情况来说,三角形是这样的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
if (y < p2.Y)
{
ProcessScanLine(y, p1, p2, p1, p3, color);
}
else
{
ProcessScanLine(y, p2, p3, p1, p3, color);
}
}
}
}
~~~
【译者注:TypeScript代码】
~~~
// 限制数值范围在0和1之间
public clamp(value: number, min: number = 0, max: number = 1): number {
return Math.max(min, Math.min(value, max));
}
// 过渡插值
public interpolate(min: number, max: number, gradient: number) {
return min + (max - min) * this.clamp(gradient);
}
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd在之前必须已经排好序
public processScanLine(y: number, pa: BABYLON.Vector3, pb: BABYLON.Vector3,
pc: BABYLON.Vector3, pd: BABYLON.Vector3, color: BABYLON.Color4): void {
// 由当前的y值,我们可以计算出梯度
// 以此再计算出 起始X(sx) 和 结束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的话,梯度强制为1
var gradient1 = pa.y != pb.y ? (y - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (y - 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;
// 从左(sx)向右(ex)绘制一条线
for (var x = sx; x < ex; x++) {
this.drawPoint(new BABYLON.Vector2(x, y), color);
}
}
public drawTriangle(p1: BABYLON.Vector3, p2: BABYLON.Vector3,
p3: BABYLON.Vector3, color: BABYLON.Color4): void {
// 进行排序,p1总在最上面,p2总在最中间,p3总在最下面
if (p1.y > p2.y) {
var temp = p2;
p2 = p1;
p1 = temp;
}
if (p2.y > p3.y) {
var temp = p2;
p2 = p3;
p3 = temp;
}
if (p1.y > p2.y) {
var temp = p2;
p2 = p1;
p1 = temp;
}
// 反向斜率
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++)
{
if (y < p2.y) {
this.processScanLine(y, p1, p3, p1, p2, color);
}
else {
this.processScanLine(y, p1, p3, p2, p3, color);
}
}
}
// 对于第二种情况来说,三角形是这样的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++)
{
if (y < p2.y) {
this.processScanLine(y, p1, p2, p1, p3, color);
}
else {
this.processScanLine(y, p2, p3, p1, p3, color);
}
}
}
}
~~~
【译者注:JavaScript代码】
~~~
// 限制数值范围在0和1之间
Device.prototype.clamp = function (value, min, max) {
if (typeof min === "undefined") { min = 0; }
if (typeof max === "undefined") { max = 1; }
return Math.max(min, Math.min(value, max));
};
// 过渡插值
Device.prototype.interpolate = function (min, max, gradient) {
return min + (max - min) * this.clamp(gradient);
};
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd在之前必须已经排好序
Device.prototype.processScanLine = function (y, pa, pb, pc, pd, color) {
// 由当前的y值,我们可以计算出梯度
// 以此再计算出 起始X(sx) 和 结束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的话,梯度强制为1
var gradient1 = pa.y != pb.y ? (y - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (y - 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;
// 从左(sx)向右(ex)绘制一条线
for (var x = sx; x < ex; x++) {
this.drawPoint(new BABYLON.Vector2(x, y), color);
}
};
Device.prototype.drawTriangle = function (p1, p2, p3, color) {
// 进行排序,p1总在最上面,p2总在最中间,p3总在最下面
if (p1.y > p2.y) {
var temp = p2;
p2 = p1;
p1 = temp;
}
if (p2.y > p3.y) {
var temp = p2;
p2 = p3;
p3 = temp;
}
if (p1.y > p2.y) {
var temp = p2;
p2 = p1;
p1 = temp;
}
// 反向斜率
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++) {
if (y < p2.y) {
this.processScanLine(y, p1, p3, p1, p2, color);
} else {
this.processScanLine(y, p1, p3, p2, p3, color);
}
}
}
// 对于第二种情况来说,三角形是这样的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
if (y < p2.y) {
this.processScanLine(y, p1, p2, p1, p3, color);
} else {
this.processScanLine(y, p2, p3, p1, p3, color);
}
}
}
};
~~~
你已经了解了如何处理两种三角形的填写以及扫描线中所做的操作了。
最后,你需要更新渲染函数,用drawTriangle来替代drawLine和drawBline。我们还用了不同的灰色填充每个三角形。不然的话,整个画面一片灰你根本就看不出效果来。我们将在接下来的教程中学习到如何恰当的处理光照。
【译者注:C#代码】
~~~
var faceIndex = 0;
foreach (var face in mesh.Faces)
{
var vertexA = mesh.Vertices[face.A];
var vertexB = mesh.Vertices[face.B];
var vertexC = mesh.Vertices[face.C];
var pixelA = Project(vertexA, transformMatrix);
var pixelB = Project(vertexB, transformMatrix);
var pixelC = Project(vertexC, transformMatrix);
var color = 0.25f + (faceIndex % mesh.Faces.Length) * 0.75f / mesh.Faces.Length;
DrawTriangle(pixelA, pixelB, pixelC, new Color4(color, color, color, 1));
faceIndex++;
}
~~~
【译者注:TypeScript代码】
~~~
for (var indexFaces = 0; indexFaces < cMesh.Faces.length; indexFaces++) {
var currentFace = cMesh.Faces[indexFaces];
var vertexA = cMesh.Vertices[currentFace.A];
var vertexB = cMesh.Vertices[currentFace.B];
var vertexC = cMesh.Vertices[currentFace.C];
var pixelA = this.project(vertexA, transformMatrix);
var pixelB = this.project(vertexB, transformMatrix);
var pixelC = this.project(vertexC, transformMatrix);
var color: number = 0.25 + ((indexFaces % cMesh.Faces.length) / cMesh.Faces.length) * 0.75;
this.drawTriangle(pixelA, pixelB, pixelC, new BABYLON.Color4(color, color, color, 1));
}
~~~
【译者注:JavaScript代码】
~~~
for (var indexFaces = 0; indexFaces < cMesh.Faces.length; indexFaces++) {
var currentFace = cMesh.Faces[indexFaces];
var vertexA = cMesh.Vertices[currentFace.A];
var vertexB = cMesh.Vertices[currentFace.B];
var vertexC = cMesh.Vertices[currentFace.C];
var pixelA = this.project(vertexA, transformMatrix);
var pixelB = this.project(vertexB, transformMatrix);
var pixelC = this.project(vertexC, transformMatrix);
var color = 0.25 + ((indexFaces % cMesh.Faces.length) / cMesh.Faces.length) * 0.75;
this.drawTriangle(pixelA, pixelB, pixelC, new BABYLON.Color4(color, color, color, 1));
}
~~~
结果应该是这样的:
[运行代码](http://david.blob.core.windows.net/softengine3d/part4sample1/index.html)
这是怎么回事?为什么感觉这么奇怪?!嗯~这是因为我们没有正确的把正面的三角形画在正面。【译者注:我是这么翻译的,你就这么一看~】
如何使用深度缓冲
我们需要对当前的Z值在缓冲区中进行比较。
如果当前要绘制的像素Z值是最前面的(最靠近屏幕),则可以绘制。
然而,如果当前Z值大于前面的像素,则可以被丢弃。
我们需要一个东西用来保存深度缓冲区。因此,我们声明一个新的数组,并将其命名为深度缓冲区(depthBuffer)。该数组的大小等于 屏幕(width * height)。
每次调用 clear() 函数时深度缓冲区内的每一个元素都需要一个非常高的默认Z值。
在putPixel(函数/方法)中,我们需要测试已存储在缓冲区中某个指定的像素Z值。此外,我们以前的逻辑接收Vector2用于绘制在屏幕上。现在我们将其改为Vector3用于增加Z值。因为现在我们将需要这部分新信息用于正确绘制面片。
最后,在我们的三角形内,同样需要一个类似 x 值插值的方式对 z 值进行插值。
总之,这里是你所需要对设备对象更新的代码:
【译者注:C#代码】
~~~
private byte[] backBuffer;
private readonly float[] depthBuffer;
private WriteableBitmap bmp;
private readonly int renderWidth;
private readonly int renderHeight;
public Device(WriteableBitmap bmp)
{
this.bmp = bmp;
renderWidth = bmp.PixelWidth;
renderHeight = bmp.PixelHeight;
// 后台缓冲区大小值是要绘制的像素
// 屏幕(width*height) * 4 (R,G,B & Alpha值)
backBuffer = new byte[bmp.PixelWidth * bmp.PixelHeight * 4];
depthBuffer = new float[bmp.PixelWidth * bmp.PixelHeight];
}
// 清除后台缓冲区为指定颜色
public void Clear(byte r, byte g, byte b, byte a)
{
// 清除后台缓冲区
for (var index = 0; index < backBuffer.Length; index += 4)
{
// Windows使用BGRA,而不是Html5中使用的RGBA
backBuffer[index] = b;
backBuffer[index + 1] = g;
backBuffer[index + 2] = r;
backBuffer[index + 3] = a;
}
// 清除深度缓冲区
for (var index = 0; index < depthBuffer.Length; index++)
{
depthBuffer[index] = float.MaxValue;
}
}
// 调用此方法把一个像素绘制到指定的X, Y坐标上
public void PutPixel(int x, int y, float z, Color4 color)
{
// 我们的后台缓冲区是一维数组
// 这里我们简单计算,将X和Y对应到此一维数组中
var index = (x + y * renderWidth);
var index4 = index * 4;
if (depthBuffer[index] < z)
{
return; // 深度测试不通过
}
depthBuffer[index] = z;
backBuffer[index4] = (byte)(color.Blue * 255);
backBuffer[index4 + 1] = (byte)(color.Green * 255);
backBuffer[index4 + 2] = (byte)(color.Red * 255);
backBuffer[index4 + 3] = (byte)(color.Alpha * 255);
}
// 将三维坐标和变换矩阵转换成二维坐标
public Vector3 Project(Vector3 coord, Matrix transMat)
{
// 进行坐标变换
var point = Vector3.TransformCoordinate(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.X * bmp.PixelWidth + bmp.PixelWidth / 2.0f;
var y = -point.Y * bmp.PixelHeight + bmp.PixelHeight / 2.0f;
return (new Vector3(x, y, point.Z));
}
// 如果二维坐标在可视范围内则绘制
public void DrawPoint(Vector3 point, Color4 color)
{
// 判断是否在屏幕内
if (point.X >= 0 && point.Y >= 0 && point.X < bmp.PixelWidth && point.Y < bmp.PixelHeight)
{
// 绘制一个点
PutPixel((int)point.X, (int)point.Y, point.Z, color);
}
}
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd在之前必须已经排好序
void ProcessScanLine(int y, Vector3 pa, Vector3 pb, Vector3 pc, Vector3 pd, Color4 color)
{
// 由当前的y值,我们可以计算出梯度
// 以此再计算出 起始X(sx) 和 结束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的话,梯度强制为1
var gradient1 = pa.Y != pb.Y ? (y - pa.Y) / (pb.Y - pa.Y) : 1;
var gradient2 = pc.Y != pd.Y ? (y - 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);
DrawPoint(new Vector3(x, y, z), color);
}
}
~~~
【译者注:TypeScript代码】
~~~
// 后台缓冲区大小值是要绘制的像素
// 屏幕(width*height) * 4 (R,G,B & Alpha值)
private backbuffer: ImageData;
private workingCanvas: HTMLCanvasElement;
private workingContext: CanvasRenderingContext2D;
private workingWidth: number;
private workingHeight: number;
// 等同于backbuffer.data
private backbufferdata;
private depthbuffer: number[];
constructor(canvas: HTMLCanvasElement) {
this.workingCanvas = canvas;
this.workingWidth = canvas.width;
this.workingHeight = canvas.height;
this.workingContext = this.workingCanvas.getContext("2d");
this.depthbuffer = new Array(this.workingWidth * this.workingHeight);
}
// 用指定颜色清除后台缓冲区
public clear(): void {
// 使用默认颜色清除后台缓冲区
this.workingContext.clearRect(0, 0, this.workingWidth, this.workingHeight);
// 缓存后台缓冲区
this.backbuffer = this.workingContext.getImageData(0, 0, this.workingWidth, this.workingHeight);
// 清除深度缓冲区
for (var i = 0; i < this.depthbuffer.length; i++) {
// 使用最大值填充
this.depthbuffer[i] = 10000000;
}
}
// 调用此方法把一个像素绘制到指定的X, Y坐标上
public putPixel(x: number, y: number, z: number, color: BABYLON.Color4): void {
this.backbufferdata = this.backbuffer.data;
// 我们的后台缓冲区是一维数组
// 这里我们简单计算,将X和Y对应到此一维数组中
var index: number = ((x >> 0) + (y >> 0) * this.workingWidth);
var index4: number = index * 4;
if (this.depthbuffer[index] < z) {
return; // 深度测试不通过
}
this.depthbuffer[index] = z;
// 在Html5 canvas中使用RGBA颜色空间
this.backbufferdata[index4] = color.r * 255;
this.backbufferdata[index4 + 1] = color.g * 255;
this.backbufferdata[index4 + 2] = color.b * 255;
this.backbufferdata[index4 + 3] = color.a * 255;
}
// 将三维坐标和变换矩阵转换成二维坐标
public project(coord: BABYLON.Vector3, transMat: BABYLON.Matrix): BABYLON.Vector3 {
// 进行坐标变换
var point = BABYLON.Vector3.TransformCoordinates(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.x * this.workingWidth + this.workingWidth / 2.0;
var y = -point.y * this.workingHeight + this.workingHeight / 2.0;
return (new BABYLON.Vector3(x, y, point.z));
}
// 如果二维坐标在可视范围内则绘制
public drawPoint(point: BABYLON.Vector3, color: BABYLON.Color4): void {
// 判断是否在屏幕内
if (point.x >= 0 && point.y >= 0 && point.x < this.workingWidth && point.y < this.workingHeight) {
// 绘制一个点
this.putPixel(point.x, point.y, point.z, color);
}
}
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd在之前必须已经排好序
public processScanLine(y: number, pa: BABYLON.Vector3, pb: BABYLON.Vector3, pc: BABYLON.Vector3, pd: BABYLON.Vector3, color: BABYLON.Color4): void {
// 由当前的y值,我们可以计算出梯度
// 以此再计算出 起始X(sx) 和 结束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的话,梯度强制为1
var gradient1 = pa.y != pb.y ? (y - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (y - 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);
this.drawPoint(new BABYLON.Vector3(x, y, z), color);
}
}
~~~
【译者注:JavaScript代码】
~~~
function Device(canvas) {
this.workingCanvas = canvas;
this.workingWidth = canvas.width;
this.workingHeight = canvas.height;
this.workingContext = this.workingCanvas.getContext("2d");
this.depthbuffer = new Array(this.workingWidth * this.workingHeight);
}
// 用指定颜色清除后台缓冲区
Device.prototype.clear = function () {
// 使用默认颜色清除后台缓冲区
this.workingContext.clearRect(0, 0, this.workingWidth, this.workingHeight);
// 缓存后台缓冲区
this.backbuffer = this.workingContext.getImageData(0, 0, this.workingWidth, this.workingHeight);
// 清除深度缓冲区
for (var i = 0; i < this.depthbuffer.length; i++) {
// 使用最大值填充
this.depthbuffer[i] = 10000000;
}
};
// 调用此方法把一个像素绘制到指定的X, Y坐标上
Device.prototype.putPixel = function (x, y, z, color) {
this.backbufferdata = this.backbuffer.data;
// 我们的后台缓冲区是一维数组
// 这里我们简单计算,将X和Y对应到此一维数组中
var index = ((x >> 0) + (y >> 0) * this.workingWidth);
var index4 = index * 4;
if (this.depthbuffer[index] < z) {
return; // 深度测试不通过
}
this.depthbuffer[index] = z;
// 在Html5 canvas中使用RGBA颜色空间
this.backbufferdata[index4] = color.r * 255;
this.backbufferdata[index4 + 1] = color.g * 255;
this.backbufferdata[index4 + 2] = color.b * 255;
this.backbufferdata[index4 + 3] = color.a * 255;
};
// 将三维坐标和变换矩阵转换成二维坐标
Device.prototype.project = function (coord, transMat) {
// 进行坐标变换
var point = BABYLON.Vector3.TransformCoordinates(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.x * this.workingWidth + this.workingWidth / 2.0;
var y = -point.y * this.workingHeight + this.workingHeight / 2.0;
return (new BABYLON.Vector3(x, y, point.z));
};
// 如果二维坐标在可视范围内则绘制
Device.prototype.drawPoint = function (point, color) {
// 判断是否在屏幕内
if (point.x >= 0 && point.y >= 0 && point.x < this.workingWidth && point.y < this.workingHeight) {
// 绘制一个点
this.putPixel(point.x, point.y, point.z, color);
}
};
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd在之前必须已经排好序
Device.prototype.processScanLine = function (y, pa, pb, pc, pd, color) {
// 由当前的y值,我们可以计算出梯度
// 以此再计算出 起始X(sx) 和 结束X(ex)
// 如果pa.Y == pb.Y 或者 pc.Y== pd.y的话,梯度强制为1
var gradient1 = pa.y != pb.y ? (y - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (y - 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);
this.drawPoint(new BABYLON.Vector3(x, y, z), color);
}
};
~~~
使用这些新代码,你将获得的效果:
[运行代码](http://david.blob.core.windows.net/softengine3d/part4/index.html)
同样的,你可以下载源代码:
C#:[SoftEngineCSharpPart4.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart4.zip)
TypeScript:[SoftEngineTSPart4.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineTSPart4.zip)
JavaScript:[SoftEngineJSPart4.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineJSPart4.zip) 或只需右键点击 -> 查看框架的源代码
在下一章节,第五个教程中,我们将看到如何模拟光照着色效果,我们将得到这样的画面:
![高氏着色](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e989db68b.jpg "高氏着色")
三,加载通过Blender扩展导出JSON格式的网格
最后更新于:2022-04-01 10:02:41
译者前言:
本文译自[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/),文章中如果有我的额外说明,我会加上【译者注:】。
正文开始:
在之前的教程我们学习如何[用C#、TypeScript或JavaScript绘制线条及三角形](http://blog.csdn.net/teajs/article/details/49998675),我们已经开始看到3D网格的线框渲染效果了。但是,我们只显示了一个立方体……嗯……甚至连一个简单的立方体都已经有12个面片了!难道我们要手动处理比这更复杂的对象?!天……但愿不是如此。
3D建模有助于3D设计人员和开发人员之间的协作。设计人员可以利用其最喜欢的工具来构建场景或网格(3D Studio Max、Maya、Blender等……)。然后,他将作品导出为开发者可以加载的文件格式。开发者将最终将网格加载进实时3D引擎中。有很多种格式可以这么做。在我们的例子中,将使用Json格式。实际上,David Catuhe已经做了从Blender中导出.babylon后缀的Json格式文件导出器了。我们马上就可以看到如何解析该文件并显示在我们可爱的软件渲染引擎中了!
Blender是一个免费的3D建模软件,你可以在这里进行下载:[http://www.blender.org/download/get-blender/](http://www.blender.org/download/get-blender/)
你可以用Python编写Blender的插件,不过我们已经做了一个导出器了。
本章教程是以下系列的一部分:
[1 – 编写相机、网格和设备对象的核心逻辑](http://blog.csdn.net/teajs/article/details/49989681)
[2 – 绘制线段和三角形来获得线框渲染效果](http://blog.csdn.net/teajs/article/details/49998675)
3 – 加载通过Blender扩展导出JSON格式的网格(本文)
[4 –填充光栅化的三角形并使用深度缓冲](http://blog.csdn.net/teajs/article/details/50010073)
[4b – 额外章节:使用技巧和并行处理来提高性能](http://blog.csdn.net/teajs/article/details/50054509)
[5 – 使用平面着色和高氏着色处理光 ](http://blogs.msdn.com/b/davrous/archive/2013/07/03/tutorial-part-5-learning-how-to-write-a-3d-software-engine-in-c-ts-or-js-flat-amp-gouraud-shading.aspx)
[6 – 应用纹理、背面剔除以及一些WebGL相关](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)
通过本章节,你将能够看到这样的效果:
[点击运行](http://david.blob.core.windows.net/softengine3d/part3/index.html)
而且你会发现在前面两个章节中,你已经完成了大部分工作。
在Blender中安装Babylon导出器并生成你的场景
如果你已经安装了Blender,请从这里下载我们的Babylon导出器:[io_export_babylon.py](http://david.blob.core.windows.net/softengine3d/io_export_babylon.py)
将此文件复制到安装Blender目录的 \script\addons 目录下(例如:我本机的目录就是 "C:\Program Files\Blender Foundation\Blender\2.67\scripts\addons")。
你需要激活我们的插件在用户首选项中。选择“文件” -> “用户首选项”和“扩展中心”选项卡。搜索“babylon”,并勾选激活。
![激活插件](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e989230a5.jpg "激活插件")
你可以在Blender中做你任何想做的事。如果你像我一样真的很不善于3D建模,那么这里有一个很酷的选项。直接在菜单栏中选择“添加” -> “网格” -> “猴子”
![添加网格](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e989358e3.jpg "添加网格")
然后,你应该可以看到这样的画面:
![Blender建模](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e9895c8ed.jpg "Blender建模")
最后一步,将其导出为.babylon文件格式(也就是我们的Json文件)。在菜单栏中选择 “文件” -> “导出” -> "Babylon.js"
![导出Babylon](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98981833.jpg "导出Babylon")
文件名为“monkey.babylon”。
注意:这只猴子名字叫苏珊妮(Suzanne)并且是3D/游戏社区中非常有名的。通过了解它,你会觉得这个团队很酷并为此感到骄傲!欢迎加入! ;)
加载导出的Json文件并将其显示
我将告诉你,在这篇文章的开头,我们已经建立了所需的所有逻辑,可以显示更复杂的苏珊妮网格。我们有面片、网格和顶点的逻辑,这就是我们所需要的了。
Babylone导出器为我们导出了超过我们所需要的数据并存放在了Json文件中。例如:纹理支持、灯光等等。这就是为什么我们在解析的时候直接跳到目前唯一注重的:顶点和面片部分了,因为线框渲染并不需要更多其他数据。
注意:C#开发人员,你需要通过NuGet从Newtonsoft安装一个Json.Net库,就像我们第一章中安装SharpDX一样。事实上,Json解析并不像浏览器中的JavaScript一样原生支持.Net。
我们先在设备(Device)对象中添加加载逻辑:
【译者注:C#代码】
~~~
// 以异步加载方式加载Json文件
public async Task<Mesh[]> LoadJSONFileAsync(string fileName)
{
var meshes = new List<Mesh>();
var file = await Windows.ApplicationModel.Package.Current.InstalledLocation.GetFileAsync(fileName);
var data = await Windows.Storage.FileIO.ReadTextAsync(file);
dynamic jsonObject = Newtonsoft.Json.JsonConvert.DeserializeObject(data);
for (var meshIndex = 0; meshIndex < jsonObject.meshes.Count; meshIndex++)
{
var verticesArray = jsonObject.meshes[meshIndex].vertices;
// 面片
var indicesArray = jsonObject.meshes[meshIndex].indices;
var uvCount = jsonObject.meshes[meshIndex].uvCount.Value;
var verticesStep = 1;
// 取决于纹理坐标的数量,我们动态的选择6步进、8步进以及10步进值
switch ((int)uvCount)
{
case 0:
verticesStep = 6;
break;
case 1:
verticesStep = 8;
break;
case 2:
verticesStep = 10;
break;
}
// 我们感兴趣的顶点信息数量
var verticesCount = verticesArray.Count / verticesStep;
// 面片的数量是索引数组长度除以3(一个面片有三个顶点索引)
var facesCount = indicesArray.Count / 3;
var mesh = new Mesh(jsonObject.meshes[meshIndex].name.Value, verticesCount, facesCount);
// 首先填充我们网格的顶点数组
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;
mesh.Vertices[index] = new Vector3(x, y, z);
}
// 然后填充面片数组
for (var index = 0; index < facesCount; index++)
{
var a = (int)indicesArray[index * 3].Value;
var b = (int)indicesArray[index * 3 + 1].Value;
var c = (int)indicesArray[index * 3 + 2].Value;
mesh.Faces[index] = new Face { A = a, B = b, C = c };
}
// 获取在Blender中设置的位置坐标
var position = jsonObject.meshes[meshIndex].position;
mesh.Position = new Vector3((float)position[0].Value, (float)position[1].Value, (float)position[2].Value);
meshes.Add(mesh);
}
return meshes.ToArray();
}
~~~
【译者注:TypeScript代码】
~~~
// 以异步加载方式加载Json文件
// 加载完成后向回调函数传入解析完成的网格
public LoadJSONFileAsync(fileName: string, callback: (result: Mesh[]) => any): void {
var jsonObject = {};
var xmlhttp = new XMLHttpRequest();
xmlhttp.open("GET", fileName, true);
var that = this;
xmlhttp.onreadystatechange = function () {
if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
jsonObject = JSON.parse(xmlhttp.responseText);
callback(that.CreateMeshesFromJSON(jsonObject));
}
};
xmlhttp.send(null);
}
private CreateMeshesFromJSON(jsonObject): Mesh[] {
var meshes: Mesh[] = [];
for (var meshIndex = 0; meshIndex < jsonObject.meshes.length; meshIndex++) {
var verticesArray: number[] = jsonObject.meshes[meshIndex].vertices;
// 面片
var indicesArray: number[] = jsonObject.meshes[meshIndex].indices;
var uvCount: number = jsonObject.meshes[meshIndex].uvCount;
var verticesStep = 1;
// 取决于纹理坐标的数量,我们动态的选择6步进、8步进以及10步进值
switch (uvCount) {
case 0:
verticesStep = 6;
break;
case 1:
verticesStep = 8;
break;
case 2:
verticesStep = 10;
break;
}
// 我们感兴趣的顶点信息数量
var verticesCount = verticesArray.length / verticesStep;
// 面片的数量是索引数组长度除以3(一个面片有三个顶点索引)
var facesCount = indicesArray.length / 3;
var mesh = new SoftEngine.Mesh(jsonObject.meshes[meshIndex].name, verticesCount, facesCount);
// 首先填充我们网格的顶点数组
for (var index = 0; index < verticesCount; index++) {
var x = verticesArray[index * verticesStep];
var y = verticesArray[index * verticesStep + 1];
var z = verticesArray[index * verticesStep + 2];
mesh.Vertices[index] = new BABYLON.Vector3(x, y, z);
}
// 然后填充面片数组
for (var index = 0; index < facesCount; index++) {
var a = indicesArray[index * 3];
var b = indicesArray[index * 3 + 1];
var c = indicesArray[index * 3 + 2];
mesh.Faces[index] = {
A: a,
B: b,
C: c
};
}
// 获取在Blender中设置的位置坐标
var position = jsonObject.meshes[meshIndex].position;
mesh.Position = new BABYLON.Vector3(position[0], position[1], position[2]);
meshes.push(mesh);
}
return meshes;
}
~~~
【译者注:JavaScript代码】
~~~
// 以异步加载方式加载Json文件
// 加载完成后向回调函数传入解析完成的网格
Device.prototype.LoadJSONFileAsync = function (fileName, callback) {
var jsonObject = {};
var xmlhttp = new XMLHttpRequest();
xmlhttp.open("GET", fileName, true);
var that = this;
xmlhttp.onreadystatechange = function () {
if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
jsonObject = JSON.parse(xmlhttp.responseText);
callback(that.CreateMeshesFromJSON(jsonObject));
}
};
xmlhttp.send(null);
};
Device.prototype.CreateMeshesFromJSON = function (jsonObject) {
var meshes = [];
for (var meshIndex = 0; meshIndex < jsonObject.meshes.length; meshIndex++) {
var verticesArray = jsonObject.meshes[meshIndex].vertices;
// 面片
var indicesArray = jsonObject.meshes[meshIndex].indices;
var uvCount = jsonObject.meshes[meshIndex].uvCount;
var verticesStep = 1;
// 取决于纹理坐标的数量,我们动态的选择6步进、8步进以及10步进值
switch (uvCount) {
case 0:
verticesStep = 6;
break;
case 1:
verticesStep = 8;
break;
case 2:
verticesStep = 10;
break;
}
// 我们感兴趣的顶点信息数量
var verticesCount = verticesArray.length / verticesStep;
// 面片的数量是索引数组长度除以3(一个面片有三个顶点索引)
var facesCount = indicesArray.length / 3;
var mesh = new SoftEngine.Mesh(jsonObject.meshes[meshIndex].name, verticesCount, facesCount);
// 首先填充我们网格的顶点数组
for (var index = 0; index < verticesCount; index++) {
var x = verticesArray[index * verticesStep];
var y = verticesArray[index * verticesStep + 1];
var z = verticesArray[index * verticesStep + 2];
mesh.Vertices[index] = new BABYLON.Vector3(x, y, z);
}
// 然后填充面片数组
for (var index = 0; index < facesCount; index++) {
var a = indicesArray[index * 3];
var b = indicesArray[index * 3 + 1];
var c = indicesArray[index * 3 + 2];
mesh.Faces[index] = {
A: a,
B: b,
C: c
};
}
// 获取在Blender中设置的位置坐标
var position = jsonObject.meshes[meshIndex].position;
mesh.Position = new BABYLON.Vector3(position[0], position[1], position[2]);
meshes.push(mesh);
}
return meshes;
};
~~~
你可能会问,为什么我们要设置6、8、10的步进值?这是因为Babylon增加了更多的细节,我们在使用的时候直接把这些细节过滤掉。
这种逻辑是特定于我们的文件格式的,如果要加载其他(如Three.js)导出器的文件,你只需要实现另一种文件格式的规范读取点、面和网格。
**注意**:要想能够载入我们的.babylon文件,对于TypeScript/JavaScript开发者而言,在IIS中,需要在web.config中定义一个新的MIME类型"application/babylon",扩展名为".babylon"。否则将出现404.3错误。
~~~
<system.webServer>
<staticContent>
<mimeMap fileExtension=".babylon" mimeType="application/babylon" />
</staticContent>
</system.webServer>
~~~
对于C#开发者来说,你需要更改文件属性,将其包含在解决方案中,编译方式为“内容”,并在复制输出目录中选择“始终复制”。
![文件属性](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e9899e2c6.jpg "文件属性")
否则,该文件将不会被发现。
最后,我们需要更新我们的主要功能,手动调用LoadJSONFileAsync函数。如果我们加载多个网格动画,则需要在绘制时旋转每一个网格:
【译者注:C#代码】
~~~
private Device device;
Mesh[] meshes;
Camera mera = new Camera();
private async void Page_Loaded(object sender, RoutedEventArgs e)
{
// 在这里设置后台缓冲区的分辨率
WriteableBitmap bmp = new WriteableBitmap(640, 480);
// 设置我们的XAML图像源
frontBuffer.Source = bmp;
device = new Device(bmp);
meshes = await device.LoadJSONFileAsync("monkey.babylon");
mera.Position = new Vector3(0, 0, 10.0f);
mera.Target = Vector3.Zero;
// 注册XAML渲染循环
CompositionTarget.Rendering += CompositionTarget_Rendering;
}
// 渲染循环处理
void CompositionTarget_Rendering(object sender, object e)
{
device.Clear(0, 0, 0, 255);
foreach (var mesh in meshes)
{
// 每一帧都稍微转动一下立方体
mesh.Rotation = new Vector3(mesh.Rotation.X + 0.01f, mesh.Rotation.Y + 0.01f, mesh.Rotation.Z);
}
// 做各种矩阵运算
device.Render(mera, meshes);
// 刷新后台缓冲区到前台缓冲区
device.Present();
}
~~~
【译者注:TypeScript代码】
~~~
///<reference path="SoftEngine.ts"/>
var canvas: HTMLCanvasElement;
var device: SoftEngine.Device;
var meshes: SoftEngine.Mesh[] = [];
var mera: SoftEngine.Camera;
document.addEventListener("DOMContentLoaded", init, false);
function init() {
canvas = <HTMLCanvasElement> document.getElementById("frontBuffer");
mera = new SoftEngine.Camera();
device = new SoftEngine.Device(canvas);
mera.Position = new BABYLON.Vector3(0, 0, 10);
mera.Target = new BABYLON.Vector3(0, 0, 0);
device.LoadJSONFileAsync("monkey.babylon", loadJSONCompleted)
}
function loadJSONCompleted(meshesLoaded: SoftEngine.Mesh[]) {
meshes = meshesLoaded;
// 调用Html5渲染循环
requestAnimationFrame(drawingLoop);
}
// 渲染循环处理
function drawingLoop() {
device.clear();
for (var i = 0; i < meshes.length; i++) {
// 每帧都稍微转动一下立方体
meshes[i].Rotation.x += 0.01;
meshes[i].Rotation.y += 0.01;
}
// 做各种矩阵运算
device.render(mera, meshes);
// 刷新后台缓冲区到前台缓冲区
device.present();
// 递归调用Html5渲染循环
requestAnimationFrame(drawingLoop);
}
~~~
【译者注:JavaScript代码】
~~~
var canvas;
var device;
var meshes = [];
var mera;
document.addEventListener("DOMContentLoaded", init, false);
function init() {
canvas = document.getElementById("frontBuffer");
mera = new SoftEngine.Camera();
device = new SoftEngine.Device(canvas);
mera.Position = new BABYLON.Vector3(0, 0, 10);
mera.Target = new BABYLON.Vector3(0, 0, 0);
device.LoadJSONFileAsync("monkey.babylon", loadJSONCompleted);
}
function loadJSONCompleted(meshesLoaded) {
meshes = meshesLoaded;
// 调用Html5渲染循环
requestAnimationFrame(drawingLoop);
}
// 渲染循环处理
function drawingLoop() {
device.clear();
for (var i = 0; i < meshes.length; i++) {
// 每帧都稍微转动一下立方体
meshes[i].Rotation.x += 0.01;
meshes[i].Rotation.y += 0.01;
}
// 做各种矩阵运算
device.render(mera, meshes);
// 刷新后台缓冲区到前台缓冲区
device.present();
// 递归调用Html5渲染循环
requestAnimationFrame(drawingLoop);
}
~~~
你现在应该有一个3D引擎,它可以加载一个由Blender导出的网格文件并且以线框模式渲染了还有动画!虽然我不知道你现在的感觉,但我还是很高兴能够到达这个阶段。 :)
如果没有,下载源代码:
C#:[SoftEngineCSharpPart3.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart3.zip)
TypeScript:[SoftEngineTSPart3.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineTSPart3.zip)
JavaScript:[SoftEngineJSPart3.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineJSPart3.zip) 或只需右键点击 -> 查看框架的源代码
那么,接下来会发生些什么呢?好了,我们需要填充三角形。这就是所谓的光栅化。我们也将使用深度缓冲区用来实现正确的渲染效果。在接下来的教程中,你将会了解如何获得这样的效果:
![光栅化与深度缓冲](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e989b1286.jpg "光栅化与深度缓冲")
二,绘制线段和三角形来获得线框渲染效果
最后更新于:2022-04-01 10:02:39
译者前言:
本文译自[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/),文章中如果有我的额外说明,我会加上【译者注:】。
正文开始:
现在我们已经通过前面的教程[编写相机、网格和设备对象的核心逻辑](http://blog.csdn.net/teajs/article/details/49989681)建立了3D引擎的核心,我们可以对渲染工作做一些增强了。下一步我们再连接点来绘制一些线条来组成一个线框渲染效果。
本章教程是以下系列的一部分:
[1 – 编写相机、网格和设备对象的核心逻辑](http://blog.csdn.net/teajs/article/details/49989681)
2 – 绘制线段和三角形来获得线框渲染效果(本文)
[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 – 使用平面着色和高氏着色处理光 ](http://blogs.msdn.com/b/davrous/archive/2013/07/03/tutorial-part-5-learning-how-to-write-a-3d-software-engine-in-c-ts-or-js-flat-amp-gouraud-shading.aspx)
[6 – 应用纹理、背面剔除以及一些WebGL相关](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)
在本章教程中,你将学习如何绘制线条、什么是面(Face)以及用Bresenham算法得到一些三角形。
可喜的是,最后你就能知道如何写出非常酷的东西了。
[点我运行](http://david.blob.core.windows.net/softengine3d/part2sample3/index.html)
大赞!我们的3D旋转立方体真正展示在了我们的屏幕上!
首先使用基本算法画出两个点之间的线
让我们来先写一个简单的算法来绘制2个顶点之间的线,我们将用以下逻辑:
- 如果2点之间的距离小于2个像素,什么也不做
- 否则,我们计算两点之间的中心点 (point0坐标 + (point1坐标 - point0坐标) / 2)
- 我们在屏幕上将这个点绘制出来
- 我们使用递归的方式在point0&中心点之间以及中心点与point1之间绘制点
下面是示例代码:
【译者注:C#代码】
~~~
public void DrawLine(Vector2 point0, Vector2 point1)
{
var dist = (point1 - point0).Length();
// 如果两点间的距离小于2,什么都不做
if (dist < 2)
return;
// 查找两点间的中心点
Vector2 middlePoint = point0 + (point1 - point0)/2;
// 绘制这个点到屏幕上
DrawPoint(middlePoint);
// 我们使用递归的方式在point0&中心点之间以及中心点与point1之间绘制点
DrawLine(point0, middlePoint);
DrawLine(middlePoint, point1);
}
~~~
【译者注:TypeScript代码】
~~~
public drawLine(point0: BABYLON.Vector2, point1: BABYLON.Vector2): void {
var dist = point1.subtract(point0).length();
// 如果两点间的距离小于2,什么都不做
if(dist < 2)
return;
// 查找两点间的中心点
var middlePoint = point0.add((point1.subtract(point0)).scale(0.5));
// 绘制这个点到屏幕上
this.drawPoint(middlePoint);
// 我们使用递归的方式在point0&中心点之间以及中心点与point1之间绘制点
this.drawLine(point0, middlePoint);
this.drawLine(middlePoint, point1);
}
~~~
【译者注:JavaScript代码】
~~~
Device.prototype.drawLine = function (point0, point1) {
var dist = point1.subtract(point0).length();
// 如果两点间的距离小于2,什么都不做
if (dist < 2) {
return;
}
// 查找两点间的中心点
var middlePoint = point0.add((point1.subtract(point0)).scale(0.5));
// 绘制这个点到屏幕上
this.drawPoint(middlePoint);
// 我们使用递归的方式在point0&中心点之间以及中心点与point1之间绘制点
this.drawLine(point0, middlePoint);
this.drawLine(middlePoint, point1);
};
~~~
你需要更新渲染循环处理函数来使用这个新的代码片段:
【译者注:C#代码】
~~~
for (var i = 0; i < mesh.Vertices.Length - 1; i++)
{
var point0 = Project(mesh.Vertices[i], transformMatrix);
var point1 = Project(mesh.Vertices[i + 1], transformMatrix);
DrawLine(point0, point1);
}
~~~
【译者注:TypeScript代码】
~~~
for (var i = 0; i < cMesh.Vertices.length -1; i++){
var point0 = this.project(cMesh.Vertices[i], transformMatrix);
var point1 = this.project(cMesh.Vertices[i + 1], transformMatrix);
this.drawLine(point0, point1);
}
~~~
【译者注:JavaScript代码】
~~~
for (var i = 0; i < cMesh.Vertices.length -1; i++){
var point0 = this.project(cMesh.Vertices[i], transformMatrix);
var point1 = this.project(cMesh.Vertices[i + 1], transformMatrix);
this.drawLine(point0, point1);
}
~~~
你现在应该得到这样的效果:
[点击运行](http://david.blob.core.windows.net/softengine3d/part2sample1/index.html)
我知道这看起来很奇怪,但这是预期的行为。它能帮助你了解如何显示3D网格。为了有一个更好的渲染效果,需要了解另一个概念。
**显示三角形的面**
现在,我们知道如何绘制线条,我们需要一个更好的方式来使他们显示网格。最简单的2D几何图形是三角形。我们使用三维的思想使用这些三角形绘制成我们所需要的网格。那么我们需要将立方体的每一面都分成2个三角形。我们先“手工”做到这一点,以后可以使用3D建模软件来帮我们自动做到这一步,这就是下一章节的内容了。
要绘制三角形,你需要有3个点(points)/顶点(vertices)。一个简单的面只包含三个值,这些值是索引下标,通过这些下标可以取得顶点数组中的某一个顶点,然后进行渲染。
要理解这个概念,让我们再看看Blender中的立方体盒子。
![顶点与索引](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e988e9db5.jpg "顶点与索引")
我们在此途中使用0,1,2,3来显示4个顶点。要绘制立方体的上面,我们要画2个三角形。
第一个,面片0,绘制路径为 顶点0(-1, 1, 1) 到 顶点1(1,1,1) 到 顶点2(-1, -1, 1) 然后再到 顶点0(-1, 1, 1)。
第二个,面片1,绘制路径为 顶点1(1, 1, 1) 到 顶点2(-1, -1, 1) 到 顶点3(1, -1 , 1) 然后再到 顶点1(1, 1, 1)。
等效的代码是这样的:
~~~
var mesh = new SoftEngine.Mesh("Square", 4, 2);
meshes.Add(mesh);
mesh.Vertices[0] = new Vector3(-1, 1, 1);
mesh.Vertices[1] = new Vector3(1, 1, 1);
mesh.Vertices[2] = new Vector3(-1, -1, 1);
mesh.Vertices[3] = new Vector3(1, -1, 1);
mesh.Faces[0] = new Face { A = 0, B = 1, C = 2 };
mesh.Faces[1] = new Face { A = 1, B = 2, C = 3 };
~~~
如果你想绘制完整的立方体,需要找到10个剩下的面片(Face),才能够组成12个面片(Face)来绘制立方体的6个不同的面(Sides)。
现在让我们来为面片(Face)对象做定义,这是一个非常简单的对象,因为内部仅仅只是3个索引下标。也请一并更新新的网格代码:
【译者注:C#代码】
~~~
namespace SoftEngine
{
public struct Face
{
public int A;
public int B;
public int C;
}
public class Mesh
{
public string Name { get; set; }
public Vector3[] 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 Vector3[verticesCount];
Faces = new Face[facesCount];
Name = name;
}
}
}
~~~
【译者注:TypeScript代码】
~~~
///<reference path="babylon.math.ts"/>
module SoftEngine {
export interface Face {
A: number;
B: number;
C: number;
}
export class Mesh {
Position: BABYLON.Vector3;
Rotation: BABYLON.Vector3;
Vertices: BABYLON.Vector3[];
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);
}
}
}
~~~
【译者注:JavaScript代码】
~~~
var SoftEngine;
(function (SoftEngine) {
var Mesh = (function () {
function Mesh(name, verticesCount, facesCount) {
this.name = name;
this.Vertices = new Array(verticesCount);
this.Faces = new Array(facesCount);
this.Rotation = new BABYLONTS.Vector3(0, 0, 0);
this.Position = new BABYLONTS.Vector3(0, 0, 0);
}
return Mesh;
})();
SoftEngine.Mesh = Mesh;
})(SoftEngine || (SoftEngine = {}));
~~~
现在我们需要更新设备(Device)对象的 渲染(Render() 函数/方法)遍历所有定义的面片,并绘制相关三角形。
【译者注:C#代码】
~~~
foreach (var face in mesh.Faces)
{
var vertexA = mesh.Vertices[face.A];
var vertexB = mesh.Vertices[face.B];
var vertexC = mesh.Vertices[face.C];
var pixelA = Project(vertexA, transformMatrix);
var pixelB = Project(vertexB, transformMatrix);
var pixelC = Project(vertexC, transformMatrix);
DrawLine(pixelA, pixelB);
DrawLine(pixelB, pixelC);
DrawLine(pixelC, pixelA);
}
~~~
【译者注:TypeScript/JavaScript代码】
~~~
for (var indexFaces = 0; indexFaces < cMesh.Faces.length; indexFaces++)
{
var currentFace = cMesh.Faces[indexFaces];
var vertexA = cMesh.Vertices[currentFace.A];
var vertexB = cMesh.Vertices[currentFace.B];
var vertexC = cMesh.Vertices[currentFace.C];
var pixelA = this.project(vertexA, transformMatrix);
var pixelB = this.project(vertexB, transformMatrix);
var pixelC = this.project(vertexC, transformMatrix);
this.drawLine(pixelA, pixelB);
this.drawLine(pixelB, pixelC);
this.drawLine(pixelC, pixelA);
}
~~~
最后,我们需要声明与我们的立方体的12个面片(Face)并进行关联以保证最新代码工作达到预期。
这里是新的声明:
【译者注:C#代码】
~~~
var mesh = new SoftEngine.Mesh("Cube", 8, 12);
meshes.Add(mesh);
mesh.Vertices[0] = new Vector3(-1, 1, 1);
mesh.Vertices[1] = new Vector3(1, 1, 1);
mesh.Vertices[2] = new Vector3(-1, -1, 1);
mesh.Vertices[3] = new Vector3(1, -1, 1);
mesh.Vertices[4] = new Vector3(-1, 1, -1);
mesh.Vertices[5] = new Vector3(1, 1, -1);
mesh.Vertices[6] = new Vector3(1, -1, -1);
mesh.Vertices[7] = new Vector3(-1, -1, -1);
mesh.Faces[0] = new Face { A = 0, B = 1, C = 2 };
mesh.Faces[1] = new Face { A = 1, B = 2, C = 3 };
mesh.Faces[2] = new Face { A = 1, B = 3, C = 6 };
mesh.Faces[3] = new Face { A = 1, B = 5, C = 6 };
mesh.Faces[4] = new Face { A = 0, B = 1, C = 4 };
mesh.Faces[5] = new Face { A = 1, B = 4, C = 5 };
mesh.Faces[6] = new Face { A = 2, B = 3, C = 7 };
mesh.Faces[7] = new Face { A = 3, B = 6, C = 7 };
mesh.Faces[8] = new Face { A = 0, B = 2, C = 7 };
mesh.Faces[9] = new Face { A = 0, B = 4, C = 7 };
mesh.Faces[10] = new Face { A = 4, B = 5, C = 6 };
mesh.Faces[11] = new Face { A = 4, B = 6, C = 7 };
~~~
【译者注:TypeScript/JavaScript代码】
~~~
var mesh = new SoftEngine.Mesh("Cube", 8, 12);
meshes.push(mesh);
mesh.Vertices[0] = new BABYLON.Vector3(-1, 1, 1);
mesh.Vertices[1] = new BABYLON.Vector3(1, 1, 1);
mesh.Vertices[2] = new BABYLON.Vector3(-1, -1, 1);
mesh.Vertices[3] = new BABYLON.Vector3(1, -1, 1);
mesh.Vertices[4] = new BABYLON.Vector3(-1, 1, -1);
mesh.Vertices[5] = new BABYLON.Vector3(1, 1, -1);
mesh.Vertices[6] = new BABYLON.Vector3(1, -1, -1);
mesh.Vertices[7] = new BABYLON.Vector3(-1, -1, -1);
mesh.Faces[0] = { A:0, B:1, C:2 };
mesh.Faces[1] = { A:1, B:2, C:3 };
mesh.Faces[2] = { A:1, B:3, C:6 };
mesh.Faces[3] = { A:1, B:5, C:6 };
mesh.Faces[4] = { A:0, B:1, C:4 };
mesh.Faces[5] = { A:1, B:4, C:5 };
mesh.Faces[6] = { A:2, B:3, C:7 };
mesh.Faces[7] = { A:3, B:6, C:7 };
mesh.Faces[8] = { A:0, B:2, C:7 };
mesh.Faces[9] = { A:0, B:4, C:7 };
mesh.Faces[10] = { A:4, B:5, C:6 };
mesh.Faces[11] = { A:4, B:6, C:7 };
~~~
你现在应该得到一个旋转的美丽立方体:
[点击运行](http://david.blob.core.windows.net/softengine3d/part2sample2/index.html)
恭喜! :)
使用Bresenham算法绘制增强的线条
[Bresenham算法](http://en.wikipedia.org/wiki/Bresenham's_line_algorithm)绘制线条不仅速度快,而且效果比我们的递归版本更好。这个算法非常棒,你可以在维基百科上找到它的词条。
下面是该算法的3种语言实现:
【译者注:C#代码】
~~~
public void DrawBline(Vector2 point0, Vector2 point1)
{
int x0 = (int)point0.X;
int y0 = (int)point0.Y;
int x1 = (int)point1.X;
int y1 = (int)point1.Y;
var dx = Math.Abs(x1 - x0);
var dy = Math.Abs(y1 - y0);
var sx = (x0 < x1) ? 1 : -1;
var sy = (y0 < y1) ? 1 : -1;
var err = dx - dy;
while (true) {
DrawPoint(new Vector2(x0, y0));
if ((x0 == x1) && (y0 == y1)) break;
var e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
}
~~~
【译者注:TypeScript代码】
~~~
public drawBline(point0: BABYLON.Vector2, point1: BABYLON.Vector2): void {
var x0 = point0.x >> 0;
var y0 = point0.y >> 0;
var x1 = point1.x >> 0;
var y1 = point1.y >> 0;
var dx = Math.abs(x1 - x0);
var dy = Math.abs(y1 - y0);
var sx = (x0 < x1) ? 1 : -1;
var sy = (y0 < y1) ? 1 : -1;
var err = dx - dy;
while (true) {
this.drawPoint(new BABYLON.Vector2(x0, y0));
if ((x0 == x1) && (y0 == y1)) break;
var e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
}
~~~
【译者注:JavaScript代码】
~~~
Device.prototype.drawBline = function (point0, point1) {
var x0 = point0.x >> 0;
var y0 = point0.y >> 0;
var x1 = point1.x >> 0;
var y1 = point1.y >> 0;
var dx = Math.abs(x1 - x0);
var dy = Math.abs(y1 - y0);
var sx = (x0 < x1) ? 1 : -1;
var sy = (y0 < y1) ? 1 : -1;
var err = dx - dy;
while(true) {
this.drawPoint(new BABYLON.Vector2(x0, y0));
if((x0 == x1) && (y0 == y1)) break;
var e2 = 2 * err;
if(e2 > -dy) { err -= dy; x0 += sx; }
if(e2 < dx) { err += dx; y0 += sy; }
}
};
~~~
在 Render函数中,使用DrawBline替换掉DrawLine函数调用。
[运行代码](http://david.blob.core.windows.net/softengine3d/part2sample3/index.html)
如果你注意观察,应该可以发现Bresenham算法比我们自己实现的波动要小很多。
同样的,你可以下载源代码:
C#:[SoftEngineCSharpPart2.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart2.zip)
TypeScript:[SoftEngineTSPart2.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineTSPart2.zip)
JavaScript:[SoftEngineJSPart2.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineJSPart2.zip) 或只需右键点击 -> 查看框架的源代码
下一章节,你将学习如何从Blender这个免费的建模工具中导出一些Json文件格式的网格,然后加载Json文件并用我们的线框引擎去显示它。实际上,我们已经拥有一切必备条件可以显示下面这样的复杂网格了:
![复杂网格](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e9890dffa.jpg "复杂网格")
一,编写相机、网格和设备对象的核心逻辑
最后更新于:2022-04-01 10:02:36
译者前言:
本文译自[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/),文章中如果有我的额外说明,我会加上【译者注:】。
正文开始:
![效果预览](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e987d9ebe.jpg "效果预览")
我非常乐意通过一系列教程和大家分享如何建立所谓的”3D软件渲染引擎“。
”软件渲染引擎“意味着我们将只使用CPU建立一个3D引擎,完全不使用GPU。(请不要忘记毁灭你的80386?)
我将与你分享C#、TypeScript以及JavaScript三种版本的代码。在这三者中,你应该可以找到自己喜欢的语言,或者至少比较接近你所喜欢的语言。
这样做是为了让你更容易从语法中抽身,把重点放在概念和实现上以及你最喜欢的编程环境中。
代码在文章末尾可以下载到。
那么,为什么要建立一个3D软件渲染引擎呢?嗯……这只是因为它确实有助于理解现代化的3D实现以及GPU处理器原理。
事实上,我非常感谢[David Catuhe](http://blogs.msdn.com/b/eternalcoding/)在微软内部研讨会中提供的3D基础知识。他已经掌握3D知识非常之久,矩阵运算就像是硬编码一样刻在了他的脑子里。我在年轻的时候,就一直梦想着能写这样的引擎,但是这对我来说太复杂了。但是最后,你会发现这并不复杂。你只是需要一个人帮你理解基本原理以及最简单的实现方式。
通过本系列教程,你将学习到如何在2D屏幕中绘制一些3D顶点(X,Y,Z)、如何绘制每个点之间的线段、如何填充一些三角形、处理灯光、材质等。
这第一篇教程就先只告诉你如何显示8个顶点组成一个立方体以及如何在3D虚拟世界中进行移动。
本章教程是以下系列的一部分:
1 – 编写相机、网格和设备对象的核心逻辑(本文)
[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 – 使用平面着色和高氏着色处理光 ](http://blogs.msdn.com/b/davrous/archive/2013/07/03/tutorial-part-5-learning-how-to-write-a-3d-software-engine-in-c-ts-or-js-flat-amp-gouraud-shading.aspx)
[6 – 应用纹理、背面剔除以及一些WebGL相关](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)
如果按照完整的系列教程学习,你就会**知道如何建立自己的3D软件渲染引擎**!引擎将会描绘一些线框,然后光栅化,再进行高氏着色,最后应用纹理。
[![绘制线框](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e987f01ed.jpg "绘制线框")](http://david.blob.core.windows.net/html5/SoftEngineProgression/wireframe/index.html)[![光栅化](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e988112af.jpg "光栅化")](http://david.blob.core.windows.net/html5/SoftEngineProgression/rasterization/index.html)[![高氏着色](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e9882a18b.jpg "高氏着色")](http://david.blob.core.windows.net/html5/SoftEngineProgression/gouraudshading/index.html)[![应用纹理](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e9883b64d.jpg "应用纹理")](http://david.blob.core.windows.net/html5/SoftEngineProgression/textured/index.html)
点击图片查看阶段演示,在本系列教程中我们将讨论如何从线框到最终的纹理。
点击[运行代码](http://david.blob.core.windows.net/softengine3d/part1/index.html)后,你将看到学习本章节将会实现8个点的效果。
**免责声明**:
出于教学目的,所以我要建立一个这样的3D软件渲染引擎,而不是使用GPU来渲染。
当然,如果你需要建立一个游戏流体3D动画,你需要的是DirectX或OpenGL/WebGL之类的。
但是,一但你明白如何建立一个3D软件渲染引擎,那么更”复杂“的引擎将更容易理解。
再进一步,你绝对应该看看由David Catuhe建立的[BabylonJS WebGL引擎](http://www.babylonjs.com/)。
更多详情及教程在这里:[Babylon.js,使用Html5和WebGL的一个完整JavaScript框架来构建游戏。](http://www.microsoftvirtualacademy.com/training-courses/introduction-to-webgl-3d-with-html5-and-babylon-js)
**查看MVA视频培训版本**:我们已经与David Catuhe做了一个免费的8个单元的课程,让你学习到基本3D知识、WebGL和[Babylon.js](http://www.babylonjs.com/)。
第一单元是包含本系列教程的一个40分钟的视频版本:[介绍Html5 WebGL 3D和Babylon.js](http://www.microsoftvirtualacademy.com/training-courses/introduction-to-webgl-3d-with-html5-and-babylon-js)
[![视频教学](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e9884f52b.jpg "视频教学")](https://mva.microsoft.com/en-US/training-courses/introduction-to-webgl-3d-with-html5-and-babylon-js-8421)
**阅读前提条件**
我用了很长一段时间一直在思考如何些这些教程。现在我终于决定不再解释每个必须了解的原理。
在网络上有很多不错的资源,比我能更好的解释这些关键原理。
但我也花了一段时间为大家挑选了一些页面,请根据最适合自己的进行阅读:【译者注:某些链接已无效,在此已删除】
[- 世界矩阵、视图矩阵和投影矩阵揭秘](http://web.archive.org/web/20131222170415/http:/robertokoci.com/world-view-projection-matrix-unveiled/)
[- 教程3:矩阵](http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices/) 将为您简要介绍一下模型矩阵、视图矩阵与投影矩阵
[- 变换 (Direct3D 9)](https://msdn.microsoft.com/en-us/library/windows/desktop/bb206269(v=vs.85).aspx)
[- 3D简要介绍](http://inear.se/talk/a_brief_introduction_to_3d.pptx):一个优秀的PPT幻灯片!请至少读到27页,在此之后的内容也谈到OpenGL或DirectX链接到GPU的技术。
[- OpenGL变换](http://www.songho.ca/opengl/gl_transform.html)
![变换过程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98872493.jpg "变换过程")
或许你已经知道了一些三角形的概念,这与API无关(OpenGL或DirectX),我们以后将看到。
通过阅读这些文章,你真正需要了解的是,有这样一连串的变换:
- 我们先围绕一个三维物体本身
- 对同一个对象移入虚拟的3D世界中通过矩阵操作进行平移、缩放或旋转
- 在3D世界中摄像机朝向这个三维物体
- 这个流程之后最终的结果将会投射在一个二维空间,也就是你的屏幕上
![坐标系](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e98884b15.jpg "坐标系")
![坐标系](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e9889669a.jpg "坐标系")
这一切都是通过矩阵神奇的运算累计变换完成的。在教程示例运行之前,你真的应该稍微了解这些概念。就算你不读这些文章就明白了这一切也应该去扫一眼,因为你日后在写3D软件渲染引擎时很可能会回去看。不过这是完全正常的,不用担心 !;) 因为最好的3D学习方式是通过试错。
我们不会花时间说矩阵是如何工作的,好消息是,你也不需要真正了解矩阵。我们简单的把它看成一个黑盒子,然后做正确的操作就行了。我不是矩阵的高手,但是我可以设法自己编写3D软件渲染引擎。所以,你这样做也可以取得成功。
然后,我们将使用为我们工作的库:
对于C#开发人员来说,我们可以用SharpDX,是一个DirectX的托管包装库。
对于JavaScript开发人员来说,我们可以使用由David Catuhe编写的Babylon.math.js库。
同时,我用TypeScript重写了Babylon.math.js库。
所需要的软件
我们可以编写C#语言开发的WinRT/XAML Windows Store Apps应用程序,或使用TypeScript/JavaScript开发的Html5应用程序。
那么,如果你想要使用C#进行开发,你需要安装:
1 - Windows 8及以上版本的操作系统
2 - Visual Studio Express for Windows Store Apps([点此下载](http://msdn.microsoft.com/en-US/windows/apps/br211386))或以上版本的Visual Studio IDE。
如果你选择使用TypeScript编写,你需要从这里[安装](http://www.typescriptlang.org/#Download)此语言。
你会发现这个插件是Visual Studio 2012版本的,但还有其他的选择:[Sublime Text, Vi, Emacs:TypeScript支持](http://msopentech.com/blog/2012/10/01/sublime-text-vi-emacs-typescript-enabled)!
【译者注:此处省略100个英文字母,给TypeScript打广告太明显,偏离了本章主题】
如果你选择JavaScript,你只需要安装你喜欢的IDE和Html5兼容的浏览器。:)
请使用你喜欢的语言创建一个名为“SoftEngine”的项目。如果选择的语言是C#,请使用NuGet添加“SharpDX core assembly”到你的解决方案中:
![SharpDX](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e988a88a2.jpg "SharpDX")
如果是TypeScript,请下载[Babylon.math.ts](http://david.blob.core.windows.net/softengine3d/babylon.math.ts)。如果是JavaScrip,请下载[Babylon.math.js](http://david.blob.core.windows.net/softengine3d/babylon.math.js)。并进行引用。
**后台缓冲区 & 渲染循环**
一个3D引擎,我们绘制完整的场景以保持每秒60帧(FPS)为最佳。这样可以保证动画的流畅。
为了进行渲染工作,我们需要后台缓冲区。它可以被看作是一个映射屏幕大小区域的二维数组。数组中的每个元素都被映射为屏幕上的某一个像素。
在XAMLWindows Store Apps中,我们将使用一个byte[]数组,它将成为我们的动态后台缓冲区。
在我们的动画循环中(Tick),每一帧要进行渲染时,后台缓冲区都将影响到作为XAML图像源头的前台缓冲区的WriteableBitmap。
在渲染循环中,XAML渲染引擎调用我们时,帧就会被产生。注册事件代码为:
~~~
CompositionTarget.Rendering += CompositionTarget_Rendering;
~~~
在Html5中我们将使用 <canvas /> 元素。canvas元素已经具有一个后台缓冲区数组的关联。你可以通过getImageData()和setImageData()函数来访问它。
动画循环将有 [requestAnimationFrame()](http://msdn.microsoft.com/library/ie/hh920765(v=vs.85).aspx) 函数来处理。它是一个类似 setTimeout(function(){}, 1000 / 60); 的定时器实现。不同的是,只有当浏览器主动调用时才会执行,所以并不需要指定间隔时间。
**注意**:在”硬件缩放“的情况下,后台缓冲区和前台缓冲区的大小可以不同。使用”硬件缩放“可以得到更好的性能和更差的效果【译者注:我是故意把这里翻译成这样的,很喜感不是么……哈哈。】。更多关于这个话题的内容请看[这里](http://blogs.msdn.com/b/eternalcoding/archive/2012/03/22/unleash-the-power-of-html-5-canvas-for-gaming-part-1.aspx?Redirected=true)。
**摄像机 & 网格 对象**
让我们开始编码吧!首先,我们需要定义一些有关于摄像机和网格的细节。网格这个名称用来代指三维物体。
我们的摄像机有2个属性:它在3D世界的位置以及它所看向的方位。两者都是Vector3类型。在C#中用 SharpDX.Vector3,在TypeScript & JavaScript中使用BABYLON.Vector3。
我们的网格有顶点数组 (一些三维点),这将用来构建我们的三维物体,它在3D世界中的位置和它旋转的状态。
继续,我们需要下面的代码:
【译者注:C#代码】
~~~
// Camera.cs & Mesh.cs
using SharpDX;
namespace SoftEngine
{
public class Camera
{
public Vector3 Position { get; set; }
public Vector3 Target { get; set; }
}
public class Mesh
{
public string Name { get; set; }
public Vector3[] Vertices { get; private set; }
public Vector3 Position { get; set; }
public Vector3 Rotation { get; set; }
public Mesh(string name, int verticesCount)
{
Vertices = new Vector3[verticesCount];
Name = name;
}
}
}
~~~
【译者注:TypeScript代码】
~~~
//<reference path="babylon.math.ts"/>
module SoftEngine {
export class Camera {
Position: BABYLON.Vector3;
Target: BABYLON.Vector3;
constructor() {
this.Position = BABYLON.Vector3.Zero();
this.Target = BABYLON.Vector3.Zero();
}
}
export class Mesh {
Position: BABYLON.Vector3;
Rotation: BABYLON.Vector3;
Vertices: BABYLON.Vector3[];
constructor(public name: string, verticesCount: number) {
this.Vertices = new Array(verticesCount);
this.Rotation = BABYLON.Vector3.Zero();
this.Position = BABYLON.Vector3.Zero();
}
}
}
~~~
【译者注:JavaScript代码】
~~~
var SoftEngine;
(function (SoftEngine) {
var Camera = (function () {
function Camera() {
this.Position = BABYLON.Vector3.Zero();
this.Target = BABYLON.Vector3.Zero();
}
return Camera;
})();
SoftEngine.Camera = Camera;
var Mesh = (function () {
function Mesh(name, verticesCount) {
this.name = name;
this.Vertices = new Array(verticesCount);
this.Rotation = BABYLON.Vector3.Zero();
this.Position = BABYLON.Vector3.Zero();
}
return Mesh;
})();
SoftEngine.Mesh = Mesh;
})(SoftEngine || (SoftEngine = {}));
~~~
举例来说,如果你想使用我们的网格对象来描述一个立方体,你需要创建8个顶点(vetices)来关联到8个点(points)。下面是在Blender中显示的立方体坐标。
![立方体坐标点](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e988c04d1.jpg "立方体坐标点")
用的是左手坐标系。请别忘了,当你创建一个网格,坐标系开始点再网格的中心。因此,X=0, Y=0, Z=0是立方体的中心点。
这个立方体可以通过这样的代码来创建:
~~~
var mesh = new Mesh("Cube", 8);
mesh.Vertices[0] = new Vector3(-1, 1, 1);
mesh.Vertices[1] = new Vector3(1, 1, 1);
mesh.Vertices[2] = new Vector3(-1, -1, 1);
mesh.Vertices[3] = new Vector3(-1, -1, -1);
mesh.Vertices[4] = new Vector3(-1, 1, -1);
mesh.Vertices[5] = new Vector3(1, 1, -1);
mesh.Vertices[6] = new Vector3(1, -1, 1);
mesh.Vertices[7] = new Vector3(1, -1, -1);
~~~
**最重要的部分:设备对象**
现在,我们有了基本对象,我们知道如何构建一个三维网格。但是我们还缺少一个最重要的部分:设备对象。这是我们的3D引擎核心部分。
在引擎渲染的函数内,我们将建立投影矩阵,并根据我们预先定义过摄像机获得视图矩阵。然后我们遍历每个网格提供基于目前的旋转和平移值来构建世界矩阵。
最后,得到这三个矩阵后,我们就可以这样得到最终的变换矩阵:
~~~
var transformMatrix = worldMatrix * viewMatrix * projectionMatrix;
~~~
你绝对需要通过阅读前面的“阅读前提条件”来理解这个概念。否则,你可能会简单的Copy/Paste代码,而无须了解有关神奇的实现。这对于之后的学习并没有太大的影响,但是理解它你能更好的进行编码。
使用该变换矩阵,我们将项目中每个网格的每个顶点从X, Y, Z坐标转换到2D世界中的X, Y坐标,最终在屏幕上绘制。
我们增加一小段逻辑,只通过 PutPixel(方法/函数)进行绘制显示。
这里有各种版本的设备对象。我增加了些注释,以帮助您更多的理解它。【译者注:还不是要一个一个翻译!】
**注**:微软Windows使用BGRA颜色空间(蓝色,绿色,红色,阿尔法),而Html5的画布使用的是[RGBA](http://en.wikipedia.org/wiki/RGBA_color_space)颜色空间(红,绿,蓝,阿尔法)。
这就是你就发现为什么C#和Html5代码有一些小差别的原因。
【译者注:C#代码】
~~~
using Windows.UI.Xaml.Media.Imaging;
using System.Runtime.InteropServices.WindowsRuntime;
using SharpDX;
namespace SoftEngine
{
public class Device
{
private byte[] backBuffer;
private WriteableBitmap bmp;
public Device(WriteableBitmap bmp)
{
this.bmp = bmp;
// 后台缓冲区大小值是要绘制的像素
// 屏幕(width*height) * 4 (R,G,B & Alpha值)
backBuffer = new byte[bmp.PixelWidth * bmp.PixelHeight * 4];
}
// 清除后台缓冲区为指定颜色
public void Clear(byte r, byte g, byte b, byte a)
{
for (var index = 0; index < backBuffer.Length; index += 4)
{
// Windows使用BGRA,而不是Html5中使用的RGBA
backBuffer[index] = b;
backBuffer[index + 1] = g;
backBuffer[index + 2] = r;
backBuffer[index + 3] = a;
}
}
// 当一切准备就绪时,我们就可以
// 刷新后台缓冲区到前台缓冲区
public void Present()
{
using (var stream = bmp.PixelBuffer.AsStream())
{
// 将我们的byte[]后台缓冲区写入到WriteableBitmap流
stream.Write(backBuffer, 0, backBuffer.Length);
}
// 请求将整个位图重绘
bmp.Invalidate();
}
// 调用此方法把一个像素绘制到指定的X, Y坐标上
public void PutPixel(int x, int y, Color4 color)
{
// 我们的后台缓冲区是一维数组
// 这里我们简单计算,将X和Y对应到此一维数组中
var index = (x + y * bmp.PixelWidth) * 4;
backBuffer[index] = (byte)(color.Blue * 255);
backBuffer[index + 1] = (byte)(color.Green * 255);
backBuffer[index + 2] = (byte)(color.Red * 255);
backBuffer[index + 3] = (byte)(color.Alpha * 255);
}
// 将三维坐标和变换矩阵转换成二维坐标
public Vector2 Project(Vector3 coord, Matrix transMat)
{
// 进行坐标变换
var point = Vector3.TransformCoordinate(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.X * bmp.PixelWidth + bmp.PixelWidth / 2.0f;
var y = -point.Y * bmp.PixelHeight + bmp.PixelHeight / 2.0f;
return (new Vector2(x, y));
}
// 如果二维坐标在可视范围内则绘制
public void DrawPoint(Vector2 point)
{
// 判断是否在屏幕内
if (point.X >= 0 && point.Y >= 0 && point.X < bmp.PixelWidth && point.Y < bmp.PixelHeight)
{
// 绘制一个黄色点
PutPixel((int)point.X, (int)point.Y, new Color4(1.0f, 1.0f, 0.0f, 1.0f));
}
}
// 主循环体,每一帧,引擎都要计算顶点投射
public void Render(Camera camera, params Mesh[] meshes)
{
// 要理解这个部分,请阅读“阅读前提条件”
var viewMatrix = Matrix.LookAtLH(camera.Position, camera.Target, Vector3.UnitY);
var projectionMatrix = Matrix.PerspectiveFovRH(0.78f,
(float)bmp.PixelWidth / bmp.PixelHeight,
0.01f, 1.0f);
foreach (Mesh mesh in meshes)
{
// 请注意,在平移前要先旋转
var worldMatrix = Matrix.RotationYawPitchRoll(mesh.Rotation.Y,
mesh.Rotation.X, mesh.Rotation.Z) *
Matrix.Translation(mesh.Position);
var transformMatrix = worldMatrix * viewMatrix * projectionMatrix;
foreach (var vertex in mesh.Vertices)
{
// 首先,我们将三维空间转换为二维空间
var point = Project(vertex, transformMatrix);
// 然后我们就可以在屏幕画出点
DrawPoint(point);
}
}
}
}
}
~~~
【译者注: TypeScript代码】
~~~
///<reference path="babylon.math.ts"/>
module SoftEngine {
export class Device {
// 后台缓冲区大小值是要绘制的像素
// 屏幕(width*height) * 4 (R,G,B & Alpha值)
private backbuffer: ImageData;
private workingCanvas: HTMLCanvasElement;
private workingContext: CanvasRenderingContext2D;
private workingWidth: number;
private workingHeight: number;
// 等于backbuffer.data
private backbufferdata;
constructor(canvas: HTMLCanvasElement) {
this.workingCanvas = canvas;
this.workingWidth = canvas.width;
this.workingHeight = canvas.height;
this.workingContext = this.workingCanvas.getContext("2d");
}
// 清除后台缓冲区为指定颜色
public clear(): void {
// 默认清除为黑色
this.workingContext.clearRect(0, 0, this.workingWidth, this.workingHeight);
// 一旦用黑色像素清除我们要找回相关图像数据,以清楚后台缓冲区
this.backbuffer = this.workingContext.getImageData(0, 0, this.workingWidth, this.workingHeight);
}
// 当一切就绪后将后台缓冲区刷新到前台缓冲区
public present(): void {
this.workingContext.putImageData(this.backbuffer, 0, 0);
}
// 调用此方法把一个像素绘制到指定的X, Y坐标上
public putPixel(x: number, y: number, color: BABYLON.Color4): void {
this.backbufferdata = this.backbuffer.data;
// 我们的后台缓冲区是一维数组
// 这里我们简单计算,将X和Y对应到此一维数组中
var index: number = ((x >> 0) + (y >> 0) * this.workingWidth) * 4;
// 在Html5 canvas中使用RGBA颜色空间
this.backbufferdata[index] = color.r * 255;
this.backbufferdata[index + 1] = color.g * 255;
this.backbufferdata[index + 2] = color.b * 255;
this.backbufferdata[index + 3] = color.a * 255;
}
// 将三维坐标和变换矩阵转换成二维坐标
public project(coord: BABYLON.Vector3, transMat: BABYLON.Matrix): BABYLON.Vector2 {
// 进行坐标变换
var point = BABYLON.Vector3.TransformCoordinates(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.x * this.workingWidth + this.workingWidth / 2.0 >> 0;
var y = -point.y * this.workingHeight + this.workingHeight / 2.0 >> 0;
return (new BABYLON.Vector2(x, y));
}
// 如果二维坐标在可视范围内则绘制
public drawPoint(point: BABYLON.Vector2): void {
// 判断是否在屏幕内
if (point.x >= 0 && point.y >= 0 && point.x < this.workingWidth
&& point.y < this.workingHeight) {
// 绘制一个黄色点
this.putPixel(point.x, point.y, new BABYLON.Color4(1, 1, 0, 1));
}
}
// 主循环体,每一帧,引擎都要计算顶点投射
public render(camera: Camera, meshes: Mesh[]): void {
// 要理解这个部分,请阅读“阅读前提条件”
var viewMatrix = BABYLON.Matrix.LookAtLH(camera.Position, camera.Target, BABYLON.Vector3.Up());
var projectionMatrix = BABYLON.Matrix.PerspectiveFovLH(0.78,
this.workingWidth / this.workingHeight, 0.01, 1.0);
for (var index = 0; index < meshes.length; index++) {
// 缓存当前网格对象
var cMesh = meshes[index];
// 请注意,在平移前要先旋转
var worldMatrix = BABYLON.Matrix.RotationYawPitchRoll(
cMesh.Rotation.y, cMesh.Rotation.x, cMesh.Rotation.z)
.multiply(BABYLON.Matrix.Translation(
cMesh.Position.x, cMesh.Position.y, cMesh.Position.z));
var transformMatrix = worldMatrix.multiply(viewMatrix).multiply(projectionMatrix);
for (var indexVertices = 0; indexVertices < cMesh.Vertices.length; indexVertices++) {
// 首先,我们将三维空间转换为二维空间
var projectedPoint = this.project(cMesh.Vertices[indexVertices], transformMatrix);
// 然后我们就可以在屏幕画出点
this.drawPoint(projectedPoint);
}
}
}
}
}
~~~
【译者注:JavaScript代码】
~~~
var SoftEngine;
(function (SoftEngine) {
var Device = (function () {
function Device(canvas) {
// 后台缓冲区大小值是要绘制的像素
// 屏幕(width*height) * 4 (R,G,B & Alpha值)
this.workingCanvas = canvas;
this.workingWidth = canvas.width;
this.workingHeight = canvas.height;
this.workingContext = this.workingCanvas.getContext("2d");
}
// 清除后台缓冲区为指定颜色
Device.prototype.clear = function () {
// 默认清除为黑色
this.workingContext.clearRect(0, 0, this.workingWidth, this.workingHeight);
// 一旦用黑色像素清除我们要找回相关图像数据,以清楚后台缓冲区
this.backbuffer = this.workingContext.getImageData(0, 0, this.workingWidth, this.workingHeight);
};
// 当一切就绪后将后台缓冲区刷新到前台缓冲区
Device.prototype.present = function () {
this.workingContext.putImageData(this.backbuffer, 0, 0);
};
// 调用此方法把一个像素绘制到指定的X, Y坐标上
Device.prototype.putPixel = function (x, y, color) {
this.backbufferdata = this.backbuffer.data;
// 我们的后台缓冲区是一维数组
// 这里我们简单计算,将X和Y对应到此一维数组中
var index = ((x >> 0) + (y >> 0) * this.workingWidth) * 4;
// 在Html5 canvas中使用RGBA颜色空间
this.backbufferdata[index] = color.r * 255;
this.backbufferdata[index + 1] = color.g * 255;
this.backbufferdata[index + 2] = color.b * 255;
this.backbufferdata[index + 3] = color.a * 255;
};
// 将三维坐标和变换矩阵转换成二维坐标
Device.prototype.project = function (coord, transMat) {
// 进行坐标变换
var point = BABYLON.Vector3.TransformCoordinates(coord, transMat);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point.x * this.workingWidth + this.workingWidth / 2.0 >> 0;
var y = -point.y * this.workingHeight + this.workingHeight / 2.0 >> 0;
return (new BABYLON.Vector2(x, y));
};
// 如果二维坐标在可视范围内则绘制
Device.prototype.drawPoint = function (point) {
// 判断是否在屏幕内
if (point.x >= 0 && point.y >= 0 && point.x < this.workingWidth
&& point.y < this.workingHeight) {
// 绘制一个黄色点
this.putPixel(point.x, point.y, new BABYLON.Color4(1, 1, 0, 1));
}
};
// 主循环体,每一帧,引擎都要计算顶点投射
Device.prototype.render = function (camera, meshes) {
// 要理解这个部分,请阅读“阅读前提条件”
var viewMatrix = BABYLON.Matrix.LookAtLH(camera.Position, camera.Target, BABYLON.Vector3.Up());
var projectionMatrix = BABYLON.Matrix.PerspectiveFovLH(0.78,
this.workingWidth / this.workingHeight, 0.01, 1.0);
for (var index = 0; index < meshes.length; index++) {
// 缓存当前网格对象
var cMesh = meshes[index];
// 请注意,在平移前要先旋转
var worldMatrix = BABYLON.Matrix.RotationYawPitchRoll(
cMesh.Rotation.y, cMesh.Rotation.x, cMesh.Rotation.z)
.multiply(BABYLON.Matrix.Translation(
cMesh.Position.x, cMesh.Position.y, cMesh.Position.z));
var transformMatrix = worldMatrix.multiply(viewMatrix).multiply(projectionMatrix);
for (var indexVertices = 0; indexVertices < cMesh.Vertices.length; indexVertices++) {
// 首先,我们将三维空间转换为二维空间
var projectedPoint = this.project(cMesh.Vertices[indexVertices], transformMatrix);
// 然后我们就可以在屏幕画出点
this.drawPoint(projectedPoint);
}
}
};
return Device;
})();
SoftEngine.Device = Device;
})(SoftEngine || (SoftEngine = {}));
~~~
**结合到一起**
最后我们需要建立一个网格(我们的立方体),创建一个摄像机,并面向我们的网格,然后实例化设备对象。
一旦这样做,我们将运行动画/渲染循环。在理想的情况下,这个循环将每隔16毫秒(60FPS)执行一次。在每个循环周期,做了这样几件事:
1 - 清空屏幕并且将所有像素变黑(*Clear()* function)。
2 - 对网格更新位置和旋转值。
3 - 计算矩阵,渲染到后台缓冲区( * Render()* function)。
4 - 将后台缓冲区数据刷新到前台缓冲区以显示( * Present()* function)。
【译者注:C#代码】
~~~
private Device device;
Mesh mesh = new Mesh("Cube", 8);
Camera mera = new Camera();
private void Page_Loaded(object sender, RoutedEventArgs e)
{
// 在这里设置后台缓冲区的分辨率
WriteableBitmap bmp = new WriteableBitmap(640, 480);
device = new Device(bmp);
// 设置我们的XAML图像源
frontBuffer.Source = bmp;
mesh.Vertices[0] = new Vector3(-1, 1, 1);
mesh.Vertices[1] = new Vector3(1, 1, 1);
mesh.Vertices[2] = new Vector3(-1, -1, 1);
mesh.Vertices[3] = new Vector3(-1, -1, -1);
mesh.Vertices[4] = new Vector3(-1, 1, -1);
mesh.Vertices[5] = new Vector3(1, 1, -1);
mesh.Vertices[6] = new Vector3(1, -1, 1);
mesh.Vertices[7] = new Vector3(1, -1, -1);
mera.Position = new Vector3(0, 0, 10.0f);
mera.Target = Vector3.Zero;
// 注册XAML渲染循环
CompositionTarget.Rendering += CompositionTarget_Rendering;
}
// 渲染循环处理
void CompositionTarget_Rendering(object sender, object e)
{
device.Clear(0, 0, 0, 255);
// 每一帧都稍微转动一下立方体
mesh.Rotation = new Vector3(mesh.Rotation.X + 0.01f, mesh.Rotation.Y + 0.01f, mesh.Rotation.Z);
// 做各种矩阵运算
device.Render(mera, mesh);
// 刷新后台缓冲区到前台缓冲区
device.Present();
}
~~~
【译者注:TypeScript代码】
~~~
///<reference path="SoftEngine.ts"/>
var canvas: HTMLCanvasElement;
var device: SoftEngine.Device;
var mesh: SoftEngine.Mesh;
var meshes: SoftEngine.Mesh[] = [];
var mera: SoftEngine.Camera;
document.addEventListener("DOMContentLoaded", init, false);
function init()
{
canvas = < HTMLCanvasElement > document.getElementById("frontBuffer");
mesh = new SoftEngine.Mesh("Cube", 8);
meshes.push(mesh);
mera = new SoftEngine.Camera();
device = new SoftEngine.Device(canvas);
mesh.Vertices[0] = new BABYLON.Vector3(-1, 1, 1);
mesh.Vertices[1] = new BABYLON.Vector3(1, 1, 1);
mesh.Vertices[2] = new BABYLON.Vector3(-1, -1, 1);
mesh.Vertices[3] = new BABYLON.Vector3(-1, -1, -1);
mesh.Vertices[4] = new BABYLON.Vector3(-1, 1, -1);
mesh.Vertices[5] = new BABYLON.Vector3(1, 1, -1);
mesh.Vertices[6] = new BABYLON.Vector3(1, -1, 1);
mesh.Vertices[7] = new BABYLON.Vector3(1, -1, -1);
mera.Position = new BABYLON.Vector3(0, 0, 10);
mera.Target = new BABYLON.Vector3(0, 0, 0);
// 调用Html5渲染循环
requestAnimationFrame(drawingLoop);
}
// 渲染循环处理
function drawingLoop()
{
device.clear();
// 每帧都稍微转动一下立方体
mesh.Rotation.x += 0.01;
mesh.Rotation.y += 0.01;
// 做各种矩阵运算
device.render(mera, meshes);
// 刷新后台缓冲区到前台缓冲区
device.present();
// 递归调用Html5渲染循环
requestAnimationFrame(drawingLoop);
}
~~~
【译者注:JavaScript代码】
~~~
var canvas;
var device;
var mesh;
var meshes = [];
var mera;
document.addEventListener("DOMContentLoaded", init, false);
function init() {
canvas = document.getElementById("frontBuffer");
mesh = new SoftEngine.Mesh("Cube", 8);
meshes.push(mesh);
mera = new SoftEngine.Camera();
device = new SoftEngine.Device(canvas);
mesh.Vertices[0] = new BABYLON.Vector3(-1, 1, 1);
mesh.Vertices[1] = new BABYLON.Vector3(1, 1, 1);
mesh.Vertices[2] = new BABYLON.Vector3(-1, -1, 1);
mesh.Vertices[3] = new BABYLON.Vector3(-1, -1, -1);
mesh.Vertices[4] = new BABYLON.Vector3(-1, 1, -1);
mesh.Vertices[5] = new BABYLON.Vector3(1, 1, -1);
mesh.Vertices[6] = new BABYLON.Vector3(1, -1, 1);
mesh.Vertices[7] = new BABYLON.Vector3(1, -1, -1);
mera.Position = new BABYLON.Vector3(0, 0, 10);
mera.Target = new BABYLON.Vector3(0, 0, 0);
// 调用Html5渲染循环
requestAnimationFrame(drawingLoop);
}
// 渲染循环处理
function drawingLoop() {
device.clear();
// 每帧都稍微转动一下立方体
mesh.Rotation.x += 0.01;
mesh.Rotation.y += 0.01;
// 做各种矩阵运算
device.render(mera, meshes);
// 刷新后台缓冲区到前台缓冲区
device.present();
// 递归调用Html5渲染循环
requestAnimationFrame(drawingLoop);
}
~~~
如果你已经正确的遵循这第一个教程的话,你应该已经得到这样的效果:
[点我运行](http://david.blob.core.windows.net/softengine3d/part1/index.html)
如果没有,下载源代码:
C#:[SoftEngineCSharpPart1.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineCSharpPart1.zip)
TypeScript:[SoftEngineTSPart1.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineTSPart1.zip)
JavaScript:[SoftEngineJSPart1.zip](http://david.blob.core.windows.net/softengine3d/SoftEngineJSPart1.zip) 或只需右键点击 -> 查看框架的源代码
简单的检查代码并试图找到是什么地方除了差错。 :)
下一章节,我们将学习面和三角形的概念来绘制每个顶点之间的线段。
![下一章节预览](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-22_56f0e988d78ad.jpg "下一章节预览")
前言
最后更新于:2022-04-01 10:02:34
> 原文出处:[3D软件渲染器入门](http://blog.csdn.net/column/details/soft-3d-engine.html)
作者:[teajs](http://blog.csdn.net/teajs)
**本系列文章经作者授权在看云整理发布,未经作者允许,请勿转载!**
# 3D软件渲染器入门
> 本系列中我们将使用C#、JavaScript、TypeScript三种语言进行编写,并配合Html5做在线演示。为了更好的了解现代3D图形学基础,我们使用CPU来完成3D渲染,让你对图形的认识更加深刻。