WebGL基础教程:第三部分

2019-01-12 23:06:43 浏览数 (1)

欢迎回到第三部分,也是我们的迷你WebGL教程系列的最后一部分。在此课程中,我们会会介绍光照和添加2D对象到场景中。新的内容很多,我们还是直接开始吧。

光照

光照可能是3D应用中最技术化和最难理解的部分了。牢固地掌握光照知识绝对是非常基本的。

 光照是如何工作的?

在我们介绍不同类型的光照,和编码技术之前,一件重要的事情是,理解真实世界中的光照是如何形成的。 每个光源 (比如:一个灯泡,太阳,等等) 产生了称为光子的粒子。这些光子在对象周围弹跳,直到它们最终进入我们的眼睛。 我们的眼睛将光子转化为一个可视的"图像"。这就是我们能够看到东西的原理。光是可加的,意思是一个颜色更多的对象要比没有颜色 (黑色) 的对象显得更亮一些。 黑色是绝对的没有颜色,而白色包含了所有的颜色。这是在处理非常亮或"过饱和"光照时需要注意的一个重要区别。

亮度只不是是具有多个状态的一个原则。比如,反射可以有多个不同的层次。像镜子一样的一个对象可以是完全反射的,而其它对象的表面则少一些光泽。 透明度决定了对象如何弯曲和折射光线;一个对象可以是完全透明的,也可以是完全不透明,或中间的任意状态。

这个知识清单还可以继续列下去,但我想你已经意识到光照不是那么简单了。

如果你想在一个小场景中对真实光照进行仿真,很有可能一个小时只能渲染4帧,这还是高性能电脑的情况。 为了克服这个问题,程序员们使用了一些技巧和技术来仿真半真实的光照,以实现更合理的帧率。 你必须在真实感和速度之间进行妥协。让我们看一看部分这样的技术。

在我开始详细介绍不同的技术时,我要先小小地声明一下。 对于不同的光照技术,它们精确名称是有争议的,比如"光线跟踪"或"光照映射"技术,不同的人会给出不同的解释来。 所以,在我们卷入这种招人恨的争议中之前,我要说的是,我只是用了我所学过的名称;有些人可能并不会同意我用的名词。 无论如何,重要的是知道不同的技术具体是什么。不再啰嗦,我们开始吧。

你必须在真实感和速度之间进行权衡。

光线跟踪

光线跟踪是更具真实感的一种光照技术,但它也是更耗时的一种。光线跟踪模仿了真实光;它从光源处发射"光子"或"光线",并让它们四处弹跳。 在大多数光线跟踪实现中,光线来自于"摄像机",并延相反方向弹向场景。这个技术通常用于电影,或可以提前渲染的场合。 这并不是说,你不能在实时应用中使用光线跟踪,但这样做会迫使你调整场景中的其它东西。 比如,你可能必须要减少光线必须"弹跳"的次数,或你可以确保没有对象有反射或折射表面。 如果你的应用中光源和对象较少,光线跟踪也是一个可行选项。

如果你有一个实时应用,你可能会提前编译场景内的部分内容。

如果应用中的光源不会到处移动,或一次只在小区域内移动,则你可以有一种非常高级的光线跟踪算法来预编译光照,并在移动光源附近重新计算一个小区域。 比如,如果你在做一个游戏应用,其中的光源是不动的,你可以预编译整个游戏世界,并实现所需的光照和效果。 然后,当你的角色移动时,你可以只在它附近添加一个阴影。这会得到非常高质量的效果,而只需要最小的处理量。

光线投射

光线投射与光线跟踪非常相似,只不过"光子"不再弹跳或与不同材料进行交互。 在一个典型的应用中,你基本上是一个黑暗的场景开始的,然后你会从光源发射一些光线。光线所到之处会被点亮,而其它区域仍然保持黑暗。 这个技术比光线跟踪快很多,但仍然给你一个真实的阴影效果。但光线投射的问题在于它的严格限制;当需要添加光线反射效果时,你并没有太多办法可想。 通常,你不得不在光线投射和光线追踪之间进行妥协,在速度和视觉效果之间进行平衡。

这两种技术的主要问题在于WebGL并不会让你访问到除当前顶点外的其它顶点。

这意味着你要么在CPU (相对于图形卡) 上处理一切,要么用第二个着色器来计算所有光照,然后将信息存于一个假纹理上。 然后,你需要将纹理解压缩为光照信息,并映射到顶点上。 所以,基本上,WebGL当前的版本不是很适合于这个任务。但我并不是说无法做到,我只是说WebGL帮不了你。

Shadow Mapping

如果你的应用中光照和对象很少,光线追踪是一个可行选项。

在WebGL中,光线投射的一个更好的替代品是阴影映射。它可以得到和光线投射一样的效果,但用到的是一种不同的技术。 阴影映射不会解决你的所有问题,但WebGL对它是半优化了的。你可以将其理解为一种诡计,但阴影映射确实被用于真实的PC和终端应用中了。

你会问,那么它到底是什么呢?

你必须理解WebGL是如何渲染场景的,然后才能回答这个问题。WebGL将所有的顶点传入顶点着色器,在应用了变换之后,它会计算出每个顶点的最终坐标。 然后,为了节约时间,WebGL丢掉了被挡在其它对象之后的那些顶点,且只画最重要的对象。就像光线投射一样,它只不过是将光线投射到可见对象上。 所以,我们将场景的"摄像机"设置为光源的坐标,并让它的朝向光线前进的方向。 然后,WebGL自动删除不在光线照耀下的那些顶点。于是,我们可以将这个数据存起来,使得我们在渲染场景时知道哪些顶点上是有光照的。

这个技术在纸面上听起来不错,但是它有一些缺点:

  • WebGL不允许你访问深度缓存;你需要在片元着色器中采用创造性的方法来保存这个数据。
  • 即使你保存了所有的数据,在渲染场景时,你仍然需要在它们进入顶点数组之前将它们映射到顶点上。这需要额外的CPU时间。

所有这些技术需要大量的WebGL技巧。但我这里展示的是一种非常基本的技术,它可以产生一种散射的光照,使得你的对象更有个性。 我不会称其为真实感光照,但它确实让你的对象更有意思。这个技术用到了对象的法向量矩阵,以计算相对于对象表面的光线夹角。 这是非常快而高效的,不需要什么WebGL技巧。让我们开始吧。

添加光照

让我们先修改着色器,增加光照功能。我们需要增加一个boolean变量,用来确定一个对象是否应该有光照。 然后,我们需要实际的法向量,并将变换到与模型对齐。最后,我们要用一个变量,将最后的结果传递给片元着色器。下面是我们的新的顶点着色器:

代码语言:javascript复制
<script id="VertexShader" type="x-shader/x-vertex">  
attribute highp vec3 VertexPosition;
attribute highp vec2 TextureCoord;
attribute highp vec3 NormalVertex;
uniform highp mat4 TransformationMatrix;
uniform highp mat4 PerspectiveMatrix;
uniform highp mxat4 NormalTransformation;
uniform bool UseLights;
varying highp vec2 vTextureCoord;
varying highp vec3 vLightLevel;
void main(void) {
 gl_Position = PerspectiveMatrix * TransformationMatrix * vec4(VertexPosition, 1.0);
 vTextureCoord = TextureCoord;
 if (UseLights) {
 highp vec3 LightColor = vec3(0.15, 0.15, 0.15);
 highp vec3 LightDirection = vec3(0.5, 0.5, 4);
 highp vec4 Normal = NormalTransformation * vec4(VertexNormal, 1.0);
 highp float FinalDirection = max(dot(Normal.xyz, LightDirection), 0.0);
 vLightLevel = (FinalDirection * LightColor);
 } else {    
 vLightLevel = vec3(1.0, 1.0, 1.0);
 }
}
</script>

如果我不用这些光照,则我们只不过将一个空白顶点传递到片元着色器,从而颜色将保持不变。当光照打开时,我们用点乘函数来计算光线方向与对象表面法向之间的夹角,并且让结果乘以光线的颜色,作为一种覆盖在对象上的掩膜。

Oleg Alexandrov画的曲面法向量。

这是可行的,因为法向量已经与对象表面垂直,而点乘函数得到一个由光线与法向量的夹角相关的数。如果法向量和光线几乎是平行的,则点乘函数返回一个正数,表示光线是正对着表面的。 当法向量和光线垂直时,曲面与光线平行,点乘函数返回零。光线与法向量之间的角度大于90度时会得到负数,但我们会用"max zero"函数将这些情况过滤掉。

现在,让我给出如下的片元着色器:

代码语言:javascript复制
<script id="FragmentShader" type="x-shader/x-fragment">  
varying highp vec2 vTextureCoord;
varying highp vec3 vLightLevel;
uniform sampler2D uSampler;
void main(void) {
 highp vec4 texelColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
 gl_FragColor = vec4(texelColor.rgb * vLightLevel, texelColor.a);
}     
</script>

这个着色器与上篇文章非常相同。唯一的差别在于我们将纹理的颜色乘上了光线层次。这个亮度或暗度将对象的不同部分区分开,从而表现出深度信息。

着色器就是这些了,现在我们回到WebGL.js文件,并修改其中的两个类。

更新我们的框架

我们先从GLObject类开始。我们需要加一个变量来表示法向量数组。这里是GLObject类的定义的最开始的一部分代码:

代码语言:javascript复制
function GLObject(VertexArr, TriangleArr, TextureArr, ImageSrc, NormalsArr) {
    this.Pos = { X : 0, Y : 0, Z : 0};
    this.Scale = { X : 1.0, Y : 1.0, Z : 1.0};
    this.Rotation = { X : 0, Y : 0, Z : 0};
    this.Vertices = VertexArr;
 
    //Array to hold the normals data
    this.Normals = NormalsArr;
 
    //The Rest of GLObject continues here

这个代码的意思很明显。现在,我们回到HTML文件,并为我们的对象添加法向量数组。

Ready()函数中,我们已经加载了3D模型,我们还需要增加表示法向量数组的参数。 一个空数组表示模型并不包含任何法向量数据,于是我们不得不在没有光照的情况下绘制对象。当此数组包含数据时,我们要将其传递给GLObject对象。

我们还需要更新WebGL类。我们需要在加载完着色器后立刻将变量链接到着色器。让我们添加法向量顶点;你的代码现在应该像下面这样了:

代码语言:javascript复制
//Link Vertex Position Attribute from Shader
this.VertexPosition = this.GL.getAttribLocation(this.ShaderProgram, "VertexPosition");
this.GL.enableVertexAttribArray(this.VertexPosition);
//Link Texture Coordinate Attribute from Shader
this.VertexTexture = this.GL.getAttribLocation(this.ShaderProgram, "TextureCoord");
this.GL.enableVertexAttribArray(this.VertexTexture);
//This is the new Normals array attribute
this.VertexNormal = this.GL.getAttribLocation(this.ShaderProgram, "VertexNormal");
this.GL.enableVertexAttribArray(this.VertexNormal);

接下来,让我们更新PrepareModel()函数,并增加代码来在合适的时候缓存法向量数据。新的代码置于底部Model.Ready语句之前:

代码语言:javascript复制
if (false !== Model.Normals) {
 Buffer = this.GL.createBuffer();
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer); 
 this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.Normals), this.GL.STATIC_DRAW);
 Model.Normals = Buffer;
}
Model.Ready = true;

最后,同样重要的一件事是,更新实际的Draw函数,来并入所有这些修改。因为有不少的修改,我打算对整个函数逐一的浏览一遍。

代码语言:javascript复制
this.Draw = function(Model) {
 if(Model.Image.ReadyState == true && Model.Ready == false) {
 this.PrepareModel(Model);
 }
 if (Model.Ready) {
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Vertices); 
 this.GL.vertexAttribPointer(this.VertexPosition, 3, this.GL.FLOAT, false, 0, 0); 
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.TextureMap);
 this.GL.vertexAttribPointer(this.VertexTexture, 2, this.GL.FLOAT, false, 0, 0);

到这里为止,还和以前一样。然后是法向量部分:

代码语言:javascript复制
//Check For Normals
if (false !== Model.Normals) {
 //Connect The normals buffer to the Shader
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Normals);
 this.GL.vertexAttribPointer(this.VertexNormal, 3, this.GL.FLOAT, false, 0, 0);
 //Tell The shader to use lighting
 var UseLights = this.GL.getUniformLocation(this.ShaderProgram, "UseLights");  
 this.GL.uniform1i(UseLights, true);
} else {
 //Even if our object has no normals data we still have to pass something
 //So I pass in the Vertices instead
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Vertices);
 this.GL.vertexAttribPointer(this.VertexNormal, 3, this.GL.FLOAT, false, 0, 0);
 //Tell The shader to use lighting
 var UseLights = this.GL.getUniformLocation(this.ShaderProgram, "UseLights");  
 this.GL.uniform1i(UseLights, false);
}

我们检查模型是否有法向量数据。如果有,则将其链接到缓存,并设置boolean变量。如果没有,则着色器仍然需要某种数据,否则会报错。 所以,我传递了顶点缓存,并将UseLight变量设置为false。你可以用多个着色器来处理这种情况,但我认为我的方案在当前场合下会更简单一些。

代码语言:javascript复制
this.GL.bindBuffer(this.GL.ELEMENT_ARRAY_BUFFER, Model.Triangles);
//Generate The Perspective Matrix
var PerspectiveMatrix = MakePerspective(45, this.AspectRatio, 1, 1000.0);  
var TransformMatrix = Model.GetTransforms();

本函数的这一部分仍然保持不变。

代码语言:javascript复制
varNormalsMatrix =  MatrixTranspose(InverseMatrix(TransformMatrix));

接下来是计算法向变换矩阵。我会马上讨论MatrixTranspose()InverseMatrix()函数。 为了计算法向量数组的变换矩阵,我们需要计算对象的常规变换矩阵的逆矩阵的转置。这个主题后面会介绍。

代码语言:javascript复制
 //Set slot 0 as the active Texture
 this.GL.activeTexture(this.GL.TEXTURE0);
 //Load in the Texture To Memory
 this.GL.bindTexture(this.GL.TEXTURE_2D, Model.Image);
 //Update The Texture Sampler in the fragment shader to use slot 0
 this.GL.uniform1i(this.GL.getUniformLocation(this.ShaderProgram, "uSampler"), 0);
 //Set The Perspective and Transformation Matrices
 var pmatrix = this.GL.getUniformLocation(this.ShaderProgram, "PerspectiveMatrix");  
 this.GL.uniformMatrix4fv(pmatrix, false, new Float32Array(PerspectiveMatrix));
 var tmatrix = this.GL.getUniformLocation(this.ShaderProgram, "TransformationMatrix");  
 this.GL.uniformMatrix4fv(tmatrix, false, new Float32Array(TransformMatrix));  
 var nmatrix = this.GL.getUniformLocation(this.ShaderProgram, "NormalTransformation");  
 this.GL.uniformMatrix4fv(nmatrix, false, new Float32Array(NormalsMatrix));  
 //Draw The Triangles
 this.GL.drawElements(this.GL.TRIANGLES, Model.TriangleCount, this.GL.UNSIGNED_SHORT, 0);
 }
};

你可容易地看到任何WebGL应用来学到更多。

下面是Draw()函数的剩下部分。它几乎和之前一样,只不过添加了链接法向量矩阵到着色器的代码。现在,让我们回到用于计算法向变换矩阵的那两个函数。

InverseMatrix()函数接受一个矩阵作为参数,并返回其逆矩阵。一个矩阵的逆矩阵指的是,乘以原矩阵得到一个单位矩阵。我们用一点基础的代数示例来解释这一点。 数字4的逆是1/4,因为1/4 x 4=1。矩阵里和"1"相当的是单位矩阵。因而,InverseMatrix()函数的返回值与参数的乘积是单位矩阵。下面是此函数:

代码语言:javascript复制
function InverseMatrix(A) {
 var s0 = A[0] * A[5] - A[4] * A[1];
 var s1 = A[0] * A[6] - A[4] * A[2];
 var s2 = A[0] * A[7] - A[4] * A[3];
 var s3 = A[1] * A[6] - A[5] * A[2];
 var s4 = A[1] * A[7] - A[5] * A[3];
 var s5 = A[2] * A[7] - A[6] * A[3];
 var c5 = A[10] * A[15] - A[14] * A[11];
 var c4 = A[9] * A[15] - A[13] * A[11];
 var c3 = A[9] * A[14] - A[13] * A[10];
 var c2 = A[8] * A[15] - A[12] * A[11];
 var c1 = A[8] * A[14] - A[12] * A[10];
 var c0 = A[8] * A[13] - A[12] * A[9];
 var invdet = 1.0 / (s0 * c5 - s1 * c4   s2 * c3   s3 * c2 - s4 * c1   s5 * c0);
 var B = [];
 B[0] = ( A[5] * c5 - A[6] * c4   A[7] * c3) * invdet;
 B[1] = (-A[1] * c5   A[2] * c4 - A[3] * c3) * invdet;
 B[2] = ( A[13] * s5 - A[14] * s4   A[15] * s3) * invdet;
 B[3] = (-A[9] * s5   A[10] * s4 - A[11] * s3) * invdet;
 B[4] = (-A[4] * c5   A[6] * c2 - A[7] * c1) * invdet;
 B[5] = ( A[0] * c5 - A[2] * c2   A[3] * c1) * invdet;
 B[6] = (-A[12] * s5   A[14] * s2 - A[15] * s1) * invdet;
 B[7] = ( A[8] * s5 - A[10] * s2   A[11] * s1) * invdet;
 B[8] = ( A[4] * c4 - A[5] * c2   A[7] * c0) * invdet;
 B[9] = (-A[0] * c4   A[1] * c2 - A[3] * c0) * invdet;
 B[10] = ( A[12] * s4 - A[13] * s2   A[15] * s0) * invdet;
 B[11] = (-A[8] * s4   A[9] * s2 - A[11] * s0) * invdet;
 B[12] = (-A[4] * c3   A[5] * c1 - A[6] * c0) * invdet;
 B[13] = ( A[0] * c3 - A[1] * c1   A[2] * c0) * invdet;
 B[14] = (-A[12] * s3   A[13] * s1 - A[14] * s0) * invdet;
 B[15] = ( A[8] * s3 - A[9] * s1   A[10] * s0) * invdet;
 return B;
}

这个函数相当复杂,不妨偷偷告诉你,我并不完全理解这背后的数学。但我已经为你解释了它的精髓。这个函数并不是我写的;它是Robin Hilliard用ActionScript写的。

下一个函数MatrixTranspose()则简单多了,它只不过返回一个输入矩阵的"转置"的版本。简而言之,它将矩阵沿对角线转了一下。下面是代码:

代码语言:javascript复制
function MatrixTranspose(A) {
 return [
 A[0], A[4], A[8], A[12],
 A[1], A[5], A[9], A[13],
 A[2], A[6], A[10], A[14],
 A[3], A[7], A[11], A[15]
 ];
}

你可看到,经过转置,原来的水平行 (A[0],A[1],A[2]...) 变成了竖直列,原来的竖直列 (A[0],A[4],A[8]...) 变成了水平行。

你可以将这两个函数添加到WebGL.js文件中去,然后,任何包含法向量数据的模型都会有光照效果。你可以修改顶点着色器中的光照方向和颜色来得到不同的效果。

我最后希望介绍的主题是在场景中添加2D内容。在3D场景中添加2D元素有很多好处。 比如,它可用于展示坐标信息,一个缩略图,应用的指令,以及其它信息。这个过程并不是你想象那么直接,所以,我们还是讨论一下吧。

2D?还是2.5D?

HTML不会让你在同一个画布 (canvas) 上使用WebGL API和2D API。

你可能会想,"为何不用HTML5的画布 (canvas) 的内置2D API"?原因在于HTML不让你在同一个画布上同时使用WebGL API和2D API。 一量你将画布 (canvas) 的上下文赋给WebGL之后,你不能再在它上面使用2D API。当你尝试访问2D上下文时,你得到的null。所以,我们怎么解决这个问题呢?我可以给你两个选项:

2.5D

2.5D指的是将2D对象 (没有深度的对象) 添加到3D场景中。在场景中添加文字是2.5D的一个例子。 你可以将文字写到一幅图中,然后将图片用作纹理贴到3D平面上,或者,你可以构造一个文字的3D模型,然后在屏幕上渲染。

这种方法的好处在于,你不需要两个画布 (canvas),而且如果你只用简单的形状,它的绘制效率也会很高。

但是,为了处理文字,要么你为每个句话都准备图片,要么你为每个字建一个3D模型 (我觉得有点夸张了)。

2D

另一种方法是生成第二个画布 (canvas),将它覆盖在3D画布上。我倾向于这种方法,因为它看上去更适于绘制2D内容。 我不会开始造一个新的2D框架,但是我们可以用一个简单例子来显示模型在当前旋转情况下的坐标信息。 让我们在HTML文件中添加第二个画布,就放在WebGL画布的后面。下面是当前画布和新画布的代码:

代码语言:javascript复制
<canvas id="GLCanvas" width="600" height="400" style="position:absolute; top:0px; left:0px;">  
 Your Browser Doesn't Support HTML5's Canvas.  
</canvas>  
<canvas id="2DCanvas" width="600" height="400" style="position:absolute; top:0px; left:0px;">
 Your Browser Doesn't Support HTML5's Canvas.
</canvas>

我还添加了一些行内的CSS代码,以让第二个画布覆盖在第一个上。下一步是用一个变量来获取这个2D画布的上下文。 我将在Ready()函数中实现这一点。你的修改后的代码应该像下面这样:

代码语言:javascript复制
var GL; 
var Building;
var Canvas2D;
function Ready(){
 //Gl Declaration and Load model function Here
 Canvas2D = document.getElementById("2DCanvas").getContext("2d");
 Canvas2D.fillStyle="#000";
}

在顶部,你可看到我添加了2D画布的全局的变量。然后,我在Ready()函数的底部添加了两行。第一行取得2D上下文,第二行设置颜色为黑色。

最后一步是在Update()函数内绘制文本。

代码语言:javascript复制
function Update(){
 Building.Rotation.Y  = 0.3
 //Clear the Canvas from the previous draw
 Canvas2D.clearRect(0, 0, 600, 400);
 //Title Text
 Canvas2D.font="25px sans-serif";
 Canvas2D.fillText("Building" , 20, 30);
 //Object's Properties
 Canvas2D.font="16px sans-serif";
 Canvas2D.fillText("X : "   Building.Pos.X , 20, 55);
 Canvas2D.fillText("Y : "   Building.Pos.Y , 20, 75);
 Canvas2D.fillText("Z : "   Building.Pos.Z , 20, 95);
 Canvas2D.fillText("Rotation : "   Math.floor(Building.Rotation.Y) , 20, 115);
 GL.GL.clear(16384 | 256);
 GL.Draw(Building);
}

我们首先让模型绕Y轴旋转,然后我们清除2D画布之前的内容。接下来,我们设置字体大小,并为每个坐标轴绘制文本。 fillText()方法接受参数:待绘制文本,x坐标,y坐标。

此方法的简洁性显而易见。为了画一些文字而这样做似乎有些小题大做;你尽可以在指定了位置的<div/><p/>元素中写一些文字。 但是,如果你要画一些形状,螺线,或一个健康显示条,等等,此方法很可能是你最好的选择。

最后的思考

在这三个教程中,我们创建了一个非常漂亮,但又比较基础的3D引擎。虽然还比较原始,但它为我们进一步前行打下了坚实的基础。 若继续前行,我建议了解一下其它的框架,比如three.js或gige,从它们那儿可以了解有哪些可行性。此外,WebGL在浏览器中运行,你总是可以通过查看其源码来学到更多。

更多精彩内容,请微信关注”前端达人”公众号!

新年大礼包

关注“前端达人”公众号,回复“新年大礼包”获取英文电子书:

更多精彩内容,请微信关注”前端达人”公众号!

0 人点赞