WebGL教程:第14课,镜面高光和载入JSON模型

欢迎来到WebGL教程的第14课。在这节课中,我们将会引入从第7课开始介绍的冯氏反射模型中的最后一个部分:镜面高光——在一个光泽表面上闪光的部分。镜面高光会让你的场景看起来更加真实。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

你会看到一个旋转的“犹他茶壶”,旋转的同时你会看到在茶壶中部偏左的部分和茶壶盖子的帽上会有常亮的高光,另外当茶壶的壶嘴和把手与光源成特定角度时,偶尔也会有高光出现。你可以使用canvas下面的复选框来切换是否开启镜面高光,是否开启光源、切换3个不同的纹理状态,分别是不使用纹理、默认的电镀金属纹理(根据CC协议,感谢Arroway Textures提供),最后为了好玩,我们还加上了一个地球的纹理(感谢European Space Agency/Envisat提供),不过茶壶形状的地球看起来实在是太猎奇了。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

得到了源代码之后,请在编辑器中打开。这次我们从代码顶部开始,然后顺着慢慢往下看。这样的好处是我们可以先看到本科中最有趣的部分,也就是片元着色器。在开始之前,还要说一下本课代码和第13课中的一个区别,那就是我们摒弃了逐顶点光照的代码。说实话,逐顶点光照不能很好处理镜面高光(因为看起来非常斑驳),所以我们不再使用它了。

好了,首先你会看到逐片元光照的片元着色器代码。和通常一样,代码先定义了浮点级别的精确度,声明了varying变量和uniform变量。在uniform变量中,我们将点光源的颜色分成了2个变量来分别储存,一个是漫反射的颜色,另一个是镜面高光的颜色。请注意第17行和第26、27行。

  precision mediump float;

  varying vec2 vTextureCoord;
  varying vec3 vTransformedNormal;
  varying vec4 vPosition;

  uniform float uMaterialShininess;

  uniform bool uShowSpecularHighlights;
  uniform bool uUseLighting;
  uniform bool uUseTextures;

  uniform vec3 uAmbientColor;

  uniform vec3 uPointLightingLocation;
  uniform vec3 uPointLightingSpecularColor;
  uniform vec3 uPointLightingDiffuseColor;

  uniform sampler2D uSampler;

没必要解释太多,你可以在页面中手动设定它们的值。让我们接着往下看着色器部分的主函数,首先要做的事情就是判断光照是否开启,这段代码和之前的一样。

  void main(void) {
    vec3 lightWeighting;
    if (!uUseLighting) {
      lightWeighting = vec3(1.0, 1.0, 1.0);
    } else {

下面我们开始处理光照,好戏就要上演了!

      vec3 lightDirection = normalize(uPointLightingLocation - vPosition.xyz);
      vec3 normal = normalize(vTransformedNormal);

      float specularLightWeighting = 0.0;
      if (uShowSpecularHighlights) {

这儿到底怎么了呢?和往常一样,我们为逐片元光照计算出光照方向;然后将片元的法线向量归一化,这还是和往常一样——切记,顶点法线在经过线线性插值之后,得到的片元法线的长度会改变,所以我们必须进行归一化处理!但是这次,我们会比较多地使用归一化之前的线性插值的结果,所以我们将其储存在一个本地变量中。然后,我们定义了一个变量用来储存由镜面高光所带来的额外的亮度。如果镜面高光没有开启,显然它的值应当是0;反之,我们就要计算它了。

那么,究竟是什么决定了镜面高光的亮度呢?你也许还记得,在第7课中我们解释冯氏反射模型的时候曾经说过,镜面高光其实就是光线像照到镜子上一样,照到物体表面然后反射出来的部分。

镜面反射(Specular):这就像镜子一样,反射光将按照和入射角相同的角度反射出来。这种情况下,你看到的物体反射出来的光的亮度,取决于你的眼睛和光反射的方向是否在同一直线上;也就是说,反射光的亮度不仅与光线的入射角有关,还与你的视线和物体表面之间的角度有关。镜面反射通常会造成物体表面上的“闪烁”和“高光”现象,镜面反射的强度也与物体的材质有关,无光泽的木材很少会有镜面反射发生,而高光泽的金属则会有大量镜面反射。

计算镜面反射亮度的方程式如下:

  • (Rm . V)α

其中Rm是发生镜面反射时反射光的方向的单位向量,V是观察方向的单位向量,α是一个描述光泽度的常量,常量的值越大,光泽度越高。你也会还记得,两个向量的点积就是它们之间夹角的余弦值。这样的话,如果光线直接反射到观察者的眼睛中,那根据方程式计算出的结果是1(也就是说Rm和V是平行向量,夹角为0,而0的余弦是1),然后随着光线与视线方向的夹角慢慢变大,镜面反射的亮度也会慢慢地进行一个渐变。当施加α次幂之后,实际上会“压缩“镜面高光的效果,在某个点上,当两个向量平行时,计算结果仍然是1,但该点周围的亮度会迅速下降。你可以试着在Demo页面中调整光泽度常量的值,调的大一些,比如512,你就会明白了。

好了,有了以上的准备,我们首先要做的就是计算出观察方向V和反射光方向Rm。让我们先来看看看V,因为它很简单。在第10课中提到过,我们的场景是基于eye space构造的。也就是说,我们的相机位于原点(0,0,0),看向Z轴的负半轴,X轴的坐标越往右越大,Y轴的坐标越往上越大。从原点到空间中的任何一点的方向,就等于该点的坐标值所表示的向量。那么同样的,反过来,从任何一点看向原点的观察方向,就是该点坐标值的负值。我们对顶点坐标进行线性插值,得到了片元的坐标,储存在vPosition变量中,然后对其取负值,然后归一化使其长度为1,这样我们就得到了观察方向的单位向量V。

	        vec3 eyeDirection = normalize(-vPosition.xyz);

让我们再来看看Rm。这里原本应该是很麻烦的,但幸好我们有一个GLSL函数reflect,这就方便多了。这个函数是这样定义的:

reflect (I, N)
其中I是入射向量,N是物体表面朝向,函数返回值是反射方向。

入射向量就是光线照在物体表面上的入射方向,也就是从片元到光线的方向的反方向,而片元到光线的方向我们已经有了,储存在lightDirection中。N,也就是物体表面朝向,实际上就是法线,我们也已经计算出了。这样,我们就可以很轻松的计算出反射方向。

	        vec3 reflectionDirection = reflect(-lightDirection, normal);

好了,万事俱备,最后一步非常简单。

	        specularLightWeighting = pow(max(dot(reflectionDirection, eyeDirection), 0.0), uMaterialShininess);
      }

以上就是我们如何计算镜面反射贡献的亮度,然后我们再来看看漫发射贡献的亮度。我们还是用之前的逻辑(这里我们用本地变量来储存归一化的法线向量)。

      float diffuseLightWeighting = max(dot(normal, lightDirection), 0.0);

最后,我们将镜面反射的亮度、漫反射的亮度和环境光的颜色结合起来,计算出片元的总亮度。计算方法是我们之前使用的方法的扩展。

      lightWeighting = uAmbientColor
        + uPointLightingSpecularColor * specularLightWeighting
        + uPointLightingDiffuseColor * diffuseLightWeighting;
    }

完成了以上工作,我们就可以使用与第13课中完全相同的代码来计算,基于当前纹理,每个片元的颜色。

    vec4 fragmentColor;
    if (uUseTextures) {
      fragmentColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
    } else {
      fragmentColor = vec4(1.0, 1.0, 1.0, 1.0);
    }
    gl_FragColor = vec4(fragmentColor.rgb * lightWeighting, fragmentColor.a);
  }

到这里,片元着色器就工作完毕。

再往下看看,你会发现下一个与第13课不同的地方,位于initShaders函数中,这个函数又回到了更早之前的老样子,非常简单。其中只创建了一个program,然后理所当然的,为新增加的镜面高光初始化了一两个新的uniform地址。再往下一点,initTextures函数载入了地球纹理和电镀金属纹理,而不是什么月球和木质的板条箱了。再往下,在setMatrixUniforms函数中,和initShaders一样,又回到的原来的样子,只涉及到一个program,而不是多个。再往下,我们就到了本节课中另一个比较有趣的地方了。

我们不再使用initBuffers函数来创建包含不同的顶点属性用于定义茶壶外观的WebGL数组对象了,我们有了两个新朋友,handleLoadedTeapot和loadTeapot函数。代码的模式看起来很像第10课中载入世界的代码,但是还是值得再重新看一遍的。让我们先来看看loadTeapot函数(虽然在代码中它是其中第二个函数)。

  function loadTeapot() {
    var request = new XMLHttpRequest();
    request.open("GET", "Teapot.json");
    request.onreadystatechange = function() {
      if (request.readyState == 4) {
        handleLoadedTeapot(JSON.parse(request.responseText));
      }
    }
    request.send();
  }

代码的总体结构看起来和第10课很像,我们创建了一个新的XMLHttpRequest对象,然后用它来载入Teapot.json文件。因为是异步加载,所以我们还加入了一个回调函数,它会在载入文件过程中的不同阶段被触发,然后当readyState等于4时,也就是说文件被完全载入了,我们做一下相应的处理。

接下来发生的事情非常有意思。我们载入的文件是JSON格式的,基本上也就是说,它是已经用Javascript写好的,打开这个文件看看你就明白我说的意思了。文件描述了一个Javascript对象,其中的列表储存了组成茶壶所需的顶点位置、法线、纹理坐标和顶点索引。我们当然可以将这些代码直接写到index.html里面,但是当你构建更复杂的模型时,尤其是由不同的独立模型的物体组成的,你最好还是把它们都放到一个单独的文件中。

至于具体使用什么样的格式来储存模型,这是个很值得探讨的问题。你可以在任何你喜欢的软件中建模,这些软件可以将模型导出为不同的格式,比如3DsMax中的.obj文件。在未来,也许一些软件能够将模型导出为在Javascript中可以直接使用的格式,就好像我们这节课中用来储存茶壶模型的JSON文件。那下面,你应当将本教程仅仅作为一个示例,展示了如何载入一个预先设计好的JSON模型文件,而不是一个最好的典范。

  var teapotVertexPositionBuffer;
  var teapotVertexNormalBuffer;
  var teapotVertexTextureCoordBuffer;
  var teapotVertexIndexBuffer;
  function handleLoadedTeapot(teapotData) {
    teapotVertexNormalBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexNormalBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexNormals), gl.STATIC_DRAW);
    teapotVertexNormalBuffer.itemSize = 3;
    teapotVertexNormalBuffer.numItems = teapotData.vertexNormals.length / 3;

    teapotVertexTextureCoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexTextureCoordBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexTextureCoords), gl.STATIC_DRAW);
    teapotVertexTextureCoordBuffer.itemSize = 2;
    teapotVertexTextureCoordBuffer.numItems = teapotData.vertexTextureCoords.length / 2;

    teapotVertexPositionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexPositionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexPositions), gl.STATIC_DRAW);
    teapotVertexPositionBuffer.itemSize = 3;
    teapotVertexPositionBuffer.numItems = teapotData.vertexPositions.length / 3;

    teapotVertexIndexBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, teapotVertexIndexBuffer);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(teapotData.indices), gl.STATIC_DRAW);
    teapotVertexIndexBuffer.itemSize = 1;
    teapotVertexIndexBuffer.numItems = teapotData.indices.length;

    document.getElementById("loadingtext").textContent = "";
  }

这些代码里实在是没有什么好强调的东西。就是从载入的JSON对象中提取不同的列表,然后放到WebGL的数组对象中,然后推送到显卡端。在这之后,我们清空了HTML中的div标签,和第10课一样,它用来告诉用户模型正在载入中。

好了,模型载入完毕了,还有什么要说的嘛?好吧,还有drawScene函数。在确认模型已经载入之后,我们要在一个合适的角度来绘制茶壶,这里没什么新东西。看一下代码,确认你知道其中发生了什么(如果有任何不明白的地方,请留下评论),不过我想你应该找不出什么让你吃惊的地方。

这之后,animate函数里也有一些琐碎的改变,用来让茶壶旋转而不是什么月亮和箱子;webGLStart函数调用了loadTeapot而不是initBuffers。最后,当载入模型时,HTML里的div标签中会显示“Loading world…”,还有相应的CSS样式。当然,还新增加了一个文本域用来输入新的镜面高光的相关参数的。

好了,本节课到这里就结束了。你学会了如何实现镜面高光,如何载入一个储存在JSON文件中预先制作好的模型。下节课我们将会学习一个稍微有点高端的东西,这是一种不同的、更加有趣的使用纹理的方式——高光贴图。

WebGL教程:第13课,逐片元光照和多重program

欢迎来到WebGL教程的第13课。在本节课中,我们将会讲解逐片元光照(Per-fragment Lighting),比起我们之前一直使用的逐顶点光照(Per-vertex Lighting),前者对于显卡是来说是一项更加困难的工作,但是同时也会生成更加真实的效果;同时我们还会看一下如何通过选择使用WebGL program对象来切换代码中用到的着色器。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

你会看到一个球体和一个立方体在围绕轨道旋转;在纹理载入阶段,它们是白色的,当纹理载入后,你会看到月球和一个木质的板条箱。这个场景和第12课中的非常相似,但是我们让轨道上的两个物体的距离更近了,这样你可以更加清晰的看到它们的样子。和之前一样,两个物体都是被位于它们中间的点光源所照亮。如果你想要改变光源的位置和颜色等,可以使用canvas下面的文本输入框。你还可以使用复选框开启和关闭光源,切换逐片元光照和逐顶点光照,以及选择是否使用纹理。

请试着在逐片元光照和逐顶点光照之间切换。你应该能够很容易的看出它们之间的区别,尤其是在板条箱上。当启用逐片元光照时,板条箱的中间明显比其他部分更明亮;而在月球上,效果不是那么明显,只是在边缘区域,光照的淡出效果更加平滑,锯齿也更少。当关闭纹理之后,你会更容易地观察到这些效果。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

让我们先讲一下,耗费更多的显卡资源去实现逐片元光照为什么是值得的。你也许还记得左边这张在第7课出现过的图片。你已经知道,物体表面的光亮程度是由法线和入射光线之间的夹角决定的。到目前为止,我们的光照效果都是在顶点着色器中计算的,其中用到了每个顶点的法线和光照方向。这中间有一个称之为光照量的参数,也就是物体表面反射了多少光。我们把这个参数以varying变量的形式,从顶点着色器传递到片元着色器,用来调整片元的颜色,反映出相应的光照程度。这个光照量的参数,和其他varying变量一样,对于顶点之间的片元,都会被WebGL进行线性插值。所以,在左边的图中,B点将会相当明亮,因为B点的光线几乎是平行于法线的;而A点则会稍微暗一些,因为光线的入射角更大一些。在A点和B点之间的点,将会有一个从明到暗的渐变。这个效果看起来非常好。

让我们把光源的位置往上提提,就像右图中的那样。

A点和C点都会比较暗,因为光线的入射角更大。假设我们仍然使用逐顶点光照,那么B点的明亮程度应该是A点和C点的平均值,所以B点也同样会比较暗。但是,这很明显是错误的!在B点,光线几乎是平行于法线的,所以它应该是其中最明亮的一个点。所以在计算顶点之间的片元光照时,我们必须逐个片元、逐个片元的单独进行计算。

每个片元都要计算各自的光照效果,意味着我们需要它们各自的位置(用于计算光照方向)和各自的法线;我们可以把这些值都从顶点着色器传递到片元着色器。同样它们也会被线性插值,所以位置值会是一条顶点间的直线,而法线值将会平滑地改变。那条直线正是我们想要的;而A点和C点的法线是相同的,所以这两点之间所有片元的法线也是相同的,这也很完美。

这样就解释了为什么在我们的页面上,启用逐片元光照后立方体看起来更加真实。另外还有一个好处,那就是对于用平面图形逼近组成的弯曲表面,例如球体,它也能给出很好的光照效果。因为如果两个顶点的法线不同,那么对于这两个顶点之间的片元来说,线性插值后的法线也会平滑的改变,这样就实现了曲面效果。在我们的这种思考方式中,逐片元光照实际上是冯氏着色法(Phong shading)的一种形式,下面的这幅图很好的解释了这种效果,我就不用再多费口舌了。

你同样也可以在Demo中观察到这种效果。当你关闭逐片元光照,启用逐顶点光照时,你会发现阴影的边缘(也就是点光源失效,完全被环境光接管的部分)看起来有很多锯齿。这是因为球体是由很多三角形组成的,你可以仔细观察它们的边缘。当你开启逐片元光照时,你会发现边缘的这种过度非常平滑,更像是真正的球体上的效果。

好了,以上就是理论部分,现在让我们看一下代码吧。着色器部分的代码在页面的顶部,我们先来看一下它们。因为在这节课中我们既要用到逐顶点光照又要用到逐片元光照,这是由页面上的复选框决定的,所以每个光照技术都需要有各自的顶点着色器和片元着色器(将两者都写到一起也是可能的,但是这会导致代码很难读)。我们稍后再讲切换不同光照技术的方法,现在你只要记住在页面中我们是通过定义脚本中的id标签来区别二者的。首先是用于逐顶点光照的一对着色器,它们和第7课中的代码完全一样,所以我只列出脚本标签,注意看其中id。

<script id="per-vertex-lighting-vs" type="x-shader/x-vertex">

然后是逐片元光照的片元着色器。

<script id="per-fragment-lighting-fs" type="x-shader/x-fragment">
  precision mediump float;

  varying vec2 vTextureCoord;
  varying vec3 vTransformedNormal;
  varying vec4 vPosition;

  uniform bool uUseLighting;
  uniform bool uUseTextures;

  uniform vec3 uAmbientColor;

  uniform vec3 uPointLightingLocation;
  uniform vec3 uPointLightingColor;

  uniform sampler2D uSampler;

  void main(void) {
    vec3 lightWeighting;
    if (!uUseLighting) {
      lightWeighting = vec3(1.0, 1.0, 1.0);
    } else {
      vec3 lightDirection = normalize(uPointLightingLocation - vPosition.xyz);

      float directionalLightWeighting = max(dot(normalize(vTransformedNormal), lightDirection), 0.0);
      lightWeighting = uAmbientColor + uPointLightingColor * directionalLightWeighting;
    }

    vec4 fragmentColor;
    if (uUseTextures) {
      fragmentColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
    } else {
      fragmentColor = vec4(1.0, 1.0, 1.0, 1.0);
    }
    gl_FragColor = vec4(fragmentColor.rgb * lightWeighting, fragmentColor.a);
  }
</script>

你会发现其中的代码和我们之前一直在顶点着色器中写的代码非常相似。实际上,根本也是做的完全一样的工作,计算出光线方向,然后与法线结合在一起,计算出光照亮。区别在于,参与运算的这些值是从varying变量中引入的,而不是顶点属性;并且最后的光照亮会立即与纹理材质结合,而不是先输出再稍后处理。值得注意的是,我们必须将线性插值之后的法线进行归一化处理。归一化,就是将一个向量的长度调整为1。这是因为在两个长度为1的向量间进行插值,仅仅是确保插值后的向量指向正确的方向,而不会确保长度依然为1。所以我们必须对其进行归一化处理。

因为所有的重担都由片元着色器承担了,所以在逐片元光照中,顶点着色器的代码相对比较简单。

<script id="per-fragment-lighting-vs" type="x-shader/x-vertex">
  attribute vec3 aVertexPosition;
  attribute vec3 aVertexNormal;
  attribute vec2 aTextureCoord;

  uniform mat4 uMVMatrix;
  uniform mat4 uPMatrix;
  uniform mat3 uNMatrix;

  varying vec2 vTextureCoord;
  varying vec3 vTransformedNormal;
  varying vec4 vPosition;

  void main(void) {
    vPosition = uMVMatrix * vec4(aVertexPosition, 1.0);
    gl_Position = uPMatrix * vPosition;
    vTextureCoord = aTextureCoord;
    vTransformedNormal = uNMatrix * aVertexNormal;
  }
</script>

在应用了模型视图矩阵并和法线矩阵相乘之后,我们依然需要计算出顶点的位置。但是我们只需要把它们储存到varying变量中就可以了,稍后在片元着色器中会用到。

以上就是着色器中的全部代码了!剩下的代码和之前的课程中的非常类似,除了一个地方。到目前为止,我们在一个WebGL页面中只使用了1个顶点着色器和1个片元着色器。现在你也许还记得在第1课中我们提到过,WebGL program对象是用来把着色器代码传送到显卡端的,一个program对象只能同时包含1个顶点着色器和1个片元着色器。这也就是说,我们需要定义两个program对象,然后根据是否开启逐片元光照的复选框来切换它们。

方法很简单,initShaders函数做出了如下变化:

  var currentProgram;
  var perVertexProgram;
  var perFragmentProgram;
  function initShaders() {
    perVertexProgram = createProgram("per-vertex-lighting-fs", "per-vertex-lighting-vs");
    perFragmentProgram = createProgram("per-fragment-lighting-fs", "per-fragment-lighting-vs");
  }

我们定义了两个独立的全局变量,一个用来储存逐顶点光照,另一个用来储存逐片元光照;另外还有一个currentProgram变量用来表示当前正在使用的光照。createProgram函数和我们之前用的那个是一样的,只不过被参数化了,我就不重复解释了。

然后我们在drawScene函数一开始就切换到相应的program。

  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);

    var perFragmentLighting = document.getElementById("per-fragment").checked;
    if (perFragmentLighting) {
      currentProgram = perFragmentProgram;
    } else {
      currentProgram = perVertexProgram;
    }
    gl.useProgram(currentProgram);

我们必须一开始就做这项工作是因为,当我们在编写绘制的代码时(比如设置uniform变量或者将顶点数组绑定到attribute上),我们需要确定当前program是哪一个,否则我们就可能使用了错误的program。

    var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(currentProgram.useLightingUniform, lighting);

这样,你应该能看出来,每次我们调用drawScene函数使用了并且仅使用了一个program;在不同的调用中可能会用到不同的program。你也许会问,是否可以在drawScene函数内部的不同地方使用不同的program?那么场景中不同的部分就会用不同的着色器绘制,比如说也许你的场景中有一部分需要用逐片元光照,另一部分需要用逐顶点光照。答案是肯定的!尽管在本课中不需要这么做,但是,它是完美可行的并且实际上非常有用!

好了,本节课结束了。你学会了如何使用多个program来切换着色器,以及如何使用逐像素光照。下节课我们将会讲解第7课剩下的最后一种光照形式——镜面高光!

WebGL教程:第12课,点光源

欢迎来到WebGL教程的第12课,这是第二节不是基于NeHe的OpenGL教程的WebGL课程。在这节课中,我们将介绍点光源,这节课很简单,但是却很重要,而且会引出将来一些有趣的内容。点光源,顾名思义,指的就是来自一个场景内特殊的点的光源,这与我们一直使用的平行光不一样。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

你将会看到一个绕轨道旋转的球体和立方体,在加载纹理的时候,他们会是白色的,当加载完成后,你们会看到一个月亮和一个木条箱。他们两个都是由位于他们之间的一个点光源照亮的。如果你希望改变光源的位置和颜色,你可以使用canvas下方的文本输入框。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

首先,让我们来解释一下用点光源做些什么;点光源与平行光源之间的不同在于点光源中的光线来自场景中的一个点。仔细想想你就能搞清楚,这意味着,对于场景中的每一个点,每束光线射入的角度都是不同的。所以,处理它的最好方法就是计算每个顶点朝着光线所在位置的方向,接着进行我们对平行光线所做的同样的运算。这就是我们所要做的。

(在这里你们可能会想,计算顶点之间的点的光的方向 – 也就是计算每个片元上光的方向,会比仅仅计算每个顶点上的光的方向来的更好。没错,这样想是正确的。这样的计算方法对于显卡来说,比较困难,但是,它给出的效果会更好。在下一节课,我将会详细说明)

现在,既然我们知道了我们需要做的是什么,我们可以再回到示例页面看一下,你会发现在场景中,射出光源的那个点上没有一个实际的物体。如果你希望有一个看上去发出光源的物体(例如在太阳系中的太阳),你就需要分别定义光源和物体。根据我们前几课所学,绘制一个新物体现在对我们是来说十分简单了。所以在教程中,我仅仅会介绍点光源是如何作用的。在以上的说明中你一定可以看出整个过程十分简单;和第十一课中所述内容的最大不同在于需要绘制一个立方体,并且让立方体和球体一起沿着轨道转动。

和以前一样我们从源代码底部向上看起,找出本课代码与第十一课代码中的不同。第一个不同在于HTML的 body标签中,这里用来输入光源方向的字段已经改为光源位置。这个改动十分简单,所以就不再解释了。接着让我们来看一下webGLStart函数。这里的改动依然很简单,这节课上,我们不会用到鼠标控制,所以我们也不需要这部分代码;并且,之前称作initTexture的函数现在被更名为initTextures,这是因为函数需要同时加载一个立方体和一个球体。很无聊的理由吧……

往上一点,tick函数现在有了一个新的调用,让物体能够动起来,这样场景就会随着时间推移不断更新。

  function tick() {
    requestAnimFrame(tick);
    drawScene();
    animate();
  }

再往上就是animate函数,这个函数将会更新两个全局变量,用来描述立方体和球体在以每秒50度的速度围绕轨道旋转时,他们围绕轨道走了多远。

  var lastTime = 0;
  function animate() {
    var timeNow = new Date().getTime();
    if (lastTime != 0) {
      var elapsed = timeNow - lastTime;

      moonAngle += 0.05 * elapsed;
      cubeAngle += 0.05 * elapsed;
    }
    lastTime = timeNow;
  }

再往上是drawScene函数,这里你会发现一些有趣的变化。函数由样板化的代码开始,清除我们的画布并且设置透视,接着是与11课相同的代码,检查光线复选框是否被勾选,并且将环境光的颜色传递给显卡:

  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);

    var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(shaderProgram.useLightingUniform, lighting);
    if (lighting) {
      gl.uniform3f(
        shaderProgram.ambientColorUniform,
        parseFloat(document.getElementById("ambientR").value),
        parseFloat(document.getElementById("ambientG").value),
        parseFloat(document.getElementById("ambientB").value)
      );

然后,我们通过uniform变量将点光源的位置推送给显卡。这里的代码和我们在11课中用来推送光源方向的代码是完全一样的,不同之处在于我们去掉了一些东西,而不是加入了一些新的东西。在第11课中,当我们把光源方向发送给显卡时,我们需要将它转化为一个单位矢量 (也就是说,将它的长度缩放为1)并且将它的方向倒转。但对于点光源来说,我们不需要这样做,我们只需要直接将光源坐标传递给显卡就行。

      gl.uniform3f(
        shaderProgram.pointLightingLocationUniform,
        parseFloat(document.getElementById("lightPositionX").value),
        parseFloat(document.getElementById("lightPositionY").value),
        parseFloat(document.getElementById("lightPositionZ").value)
      );

接着,我们针对点光源的颜色做相同的操作,drawScene中的光源代码大概就是这样了。

      gl.uniform3f(
        shaderProgram.pointLightingColorUniform,
        parseFloat(document.getElementById("pointR").value),
        parseFloat(document.getElementById("pointG").value),
        parseFloat(document.getElementById("pointB").value)
      );
    }

下一步,我们将在合适的位置绘制球体和立方体。

    mat4.identity(mvMatrix);

    mat4.translate(mvMatrix, [0, 0, -20]);

    mvPushMatrix();
    mat4.rotate(mvMatrix, degToRad(moonAngle), [0, 1, 0]);
    mat4.translate(mvMatrix, [5, 0, 0]);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, moonTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, moonVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, moonVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexNormalBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, moonVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, moonVertexIndexBuffer);
    setMatrixUniforms();
    gl.drawElements(gl.TRIANGLES, moonVertexIndexBuffer.numItems, gl.UNSIGNED_SHORT, 0);
    mvPopMatrix();

    mvPushMatrix();
    mat4.rotate(mvMatrix, degToRad(cubeAngle), [0, 1, 0]);
    mat4.translate(mvMatrix, [5, 0, 0]);
    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, cubeVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, cubeVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, cubeVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, crateTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cubeVertexIndexBuffer);
    setMatrixUniforms();
    gl.drawElements(gl.TRIANGLES, cubeVertexIndexBuffer.numItems, gl.UNSIGNED_SHORT, 0);
    mvPopMatrix();
  }

关于drawScene就这么多了。接着往上看,我们会看到initBuffers函数,其中增加了立方体和球体的数组对象的代码,这些代码都很标准。再往上,在initTextures函数中,我们载入了两个纹理,而不是一个。

接着往上,我们将会遇到最后一个,也是最重要的一个变化。找到顶点着色器的部分,你会发现这里有一些小的变动。我们从上往下看,注意发生变动的代码位于第35行和第36行。

  attribute vec3 aVertexPosition;
  attribute vec3 aVertexNormal;
  attribute vec2 aTextureCoord;

  uniform mat4 uMVMatrix;
  uniform mat4 uPMatrix;
  uniform mat3 uNMatrix;

  uniform vec3 uAmbientColor;

  uniform vec3 uPointLightingLocation;
  uniform vec3 uPointLightingColor;

这样我们就设置好了光源位置和颜色的uniform变量,用来替换原先的光照方向和颜色。接下来:

  uniform bool uUseLighting;

  varying vec2 vTextureCoord;
  varying vec3 vLightWeighting;

  void main(void) {
    vec4 mvPosition = uMVMatrix * vec4(aVertexPosition, 1.0);
    gl_Position = uPMatrix * mvPosition;

我们在这里所做的是将原先的代码分成两个部分。到目前为止,在我们对顶点着色器的所有的应用中,我们都是一次性把模型视图矩阵和投影矩阵配置在顶点位置中,就像下面这里:

    // Code from lesson 11
    gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);

但是在这里,我们将会储存一个中间值,这个顶点位置的值已经应用了当前模型视图矩阵,但是还未根据透视进行调整。在下面的代码中我们用到了这个值。

    vTextureCoord = aTextureCoord;

    if (!uUseLighting) {
      vLightWeighting = vec3(1.0, 1.0, 1.0);
    } else {
      vec3 lightDirection = normalize(uPointLightingLocation - mvPosition.xyz);
 

光源位置是通过世界空间坐标表现的,顶点坐标也是一样,模型视图矩阵乘以顶点坐标以后,也将会通过世界空间坐标表现。我们需要利用这些坐标算出点光源射入当前顶点的方向,还需要算出一点到另一点的方向,我们要做的仅仅是把他们相除。除过之后,我们需要做的是使方向向量归一化,就像我们之前对平行光的矢量所做的一样,把它的长度调整为1。 设置完成后,将所有的东西组合起来进行一次运算,这和我们对平行光源所做的是完全一样的,只有几处变量名称的变化。

      vec3 transformedNormal = uNMatrix * aVertexNormal;
      float directionalLightWeighting = max(dot(transformedNormal, lightDirection), 0.0);
      vLightWeighting = uAmbientColor + uPointLightingColor * directionalLightWeighting;

好了,现在你学会了如何在着色器中编写代码来创建一个点光源。

这节课讲完了,下节课上,我们还是会讲光线,通过逐片元光照而不是逐顶点光照,让场景看起来更加真实。

 

WebGL教程:第11课,球体、旋转矩阵和鼠标事件

欢迎来到WebGL教程的第11课。本节课是第一个不是基于NeHe的OpenGL教程改编的课时。这节课里,我们将会演示在平行光照下的一个球体,并为其贴上纹理贴图,观察者可以使用鼠标来旋转球体。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

一开始当纹理还没有完整载入的时候你会看到一个白色的球体,载入完成后你将看到月球,并有来自右上方的光照。用鼠标拖拽球体,球体将会转动,光照效果依然存在。如果你想要改变光线的参数,和第7课一样,可以使用canvas下方的文本输入框。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

和往常一样,我们还是从代码的底部开始我们的学习,一步一步讲解那些发生变化的代码。在body标签之前的HTML代码,与第七课相比并没有发生变化,所以我们直接来看一下webGLStart函数。

  function webGLStart() {
    canvas = document.getElementById("lesson11-canvas");
    initGL(canvas);
    initShaders();
    initBuffers();
    initTexture();

    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.enable(gl.DEPTH_TEST);

    canvas.onmousedown = handleMouseDown;
    document.onmouseup = handleMouseUp;
    document.onmousemove = handleMouseMove;

    tick();
  }

这三行新增加的代码允许我们探测鼠标事件,当用户拖拽球体的时候可以相应的旋转它。很明显,我们只在3D canvas内部读取MouseDown事件(如果你在页面的其他部分进行了拖拽,将不会对球体产生影响,比如说点击文本框)。不那么明显的是,我们却在整个页面而不仅仅是canvas中,监听MouseUp和MouseMove事件;这样我们就可以完整地读取用户拖拽,即使鼠标被释放在或被移动到canvas之外,只要拖拽行为发生在canvas里面就可以了。这种解决方法让我们避免了成为那些愚蠢的交互页面中的一员——当你想要旋转物体的时候,在场景内按下鼠标,然后由于拖拽移动鼠标的缘故,在场景外释放了鼠标,但是你却发现当你把鼠标移动回场景内的时候,MouseUp事件却没有生效,笨蛋电脑依然认为你还是在拖拽当中,逼着你在场景内的某个位置单击鼠标才行。

继续往上看代码,我们来到了tick函数,在本节课中它只是简单的安排下一帧调用drawScene函数,因为它已经不需要处理键盘输入了(因为本课中我们没有键盘输入),也不需要运动场景(因为场景中物体的运动只对用户输入发生反应,没有独立的动画场景)

下一个比较重要的变动位于drawScene函数中。开始,我们还是用样板化的代码清空了canvas并设置了透视,然后和第7课一样用相同的代码设置了光照。

  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);

    var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(shaderProgram.useLightingUniform, lighting);
    if (lighting) {
      gl.uniform3f(
        shaderProgram.ambientColorUniform,
        parseFloat(document.getElementById("ambientR").value),
        parseFloat(document.getElementById("ambientG").value),
        parseFloat(document.getElementById("ambientB").value)
      );

      var lightingDirection = [
        parseFloat(document.getElementById("lightDirectionX").value),
        parseFloat(document.getElementById("lightDirectionY").value),
        parseFloat(document.getElementById("lightDirectionZ").value)
      ];
      var adjustedLD = vec3.create();
      vec3.normalize(lightingDirection, adjustedLD);
      vec3.scale(adjustedLD, -1);
      gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD);

      gl.uniform3f(
        shaderProgram.directionalColorUniform,
        parseFloat(document.getElementById("directionalR").value),
        parseFloat(document.getElementById("directionalG").value),
        parseFloat(document.getElementById("directionalB").value)
      );
    }
 

然后,我们移动到正确的位置,开始绘制月球。

    mat4.identity(mvMatrix);

    mat4.translate(mvMatrix, [0, 0, -6]);

下面,出现了一行比较奇怪的代码。我现在先不详细解释,大概说一下就是我们把月球的当前旋转状态储存在一个矩阵之中,这个矩阵从单位矩阵开始(表示我们没有做任何旋转),然后当用户进行鼠标操作时,矩阵也会发生对应于这个操作的相应变化。所以,在我们绘制月球之前,需要将旋转矩阵应用于当前的模型视图矩阵,我们使用了okMat4Mul函数。

    mat4.multiply(mvMatrix, moonRotationMatrix);

完成之后,剩下的工作就是绘制月球了。这些代码都相当的标准——我们设置了纹理,然后告诉WebGL用建立好的数组对象来绘制一串三角形,这些代码我们在前几课用过很多次了。

    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, moonTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, moonVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, moonVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexNormalBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, moonVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, moonVertexIndexBuffer);
    setMatrixUniforms();
    gl.drawElements(gl.TRIANGLES, moonVertexIndexBuffer.numItems, gl.UNSIGNED_SHORT, 0);
  }
 

那么,我们是如何建立顶点位置、法线、纹理坐标和顶点索引,并给它们赋值,来绘制一个球体呢?秘密就在于下面这个函数中:initBuffers。

函数开始先定义了对象数组的全局变量,然后指定了经度带和纬度带的数量,以及球体的半径。如果你打算在你自己的WebGL页面中使用这些代码,你应当将经度带、纬度带和弧度都参数化,并且将数组对象储存在其他地方而不是全局变量中。这里我没有这么做只是为了演示起来方便简单,我可不想因此影响了你的良好的面对对象以及函数化的编程理念。

  var moonVertexPositionBuffer;
  var moonVertexNormalBuffer;
  var moonVertexTextureCoordBuffer;
  var moonVertexIndexBuffer;
  function initBuffers() {
    var latitudeBands = 30;
    var longitudeBands = 30;
    var radius = 2;

那么,究竟什么是经度带和纬度带呢?为了绘制一系列三角形来逼近球体,我们必须将球体分割开来。有很多聪明的技巧来实现这种分割,我们将使用其中最简单的方法,仅仅需要高中几何知识。这是因为(a)它的效果相当完美(b)我懂,并且理解起来不会让人头痛。这种方法是基于一个Khronos网站上的Demo的,原本是由WebKit团队开发的。它的原理如下:

让我们先从这些专业术语的定义开始。纬线就是在一个球体上,告诉你距离南极或北极有多远的线。在球体表面上丈量南极到北极的距离,是一个常量。如果你按照纬线将一个球体从上到下依次切开,那么在顶部和底部你会得到镜片形状的切片,然后慢慢的在中间得到光盘形状的切片。如果你很难视觉化的想象出来,请参考在制作蔬菜沙拉时切西红柿。只不过切的时候要保证每个切片表面从顶部到底部的距离都是相同的,显然中间部分的切片的厚度要比两端的大。

而经线是另外一种线,它们将球体分割成弓形。如果你按照经线切开一个球体,那么切出来的部分好像切橙子一样。

现在,为了绘制球体,想象一下我们在球体上画满了经线和纬线。我们要做的就是计算出这些经线和纬线的交点,用这些交点作为顶点位置。这样我们就可以把由两条相邻的经线和纬线所组成的四边形分割成两个三角形,然后绘制它们。左边的图片应该很清楚地表达出我们的目的。

下一个问题是,我们如何才能计算出这些经线和纬线的交点呢?让我们假设球体的半径是1,然后在X轴和Y轴平面上垂直切开球体,让原点处于球体中心位置。显然,切片的形状是一个正圆,一条条纬线平行穿过此圆。在图中,你会发现全部一共有10个纬线带,而我们正在指向从上往下数第3个纬线带。连结坐标轴原点和纬线与圆的交点,设Y轴与该连接线的夹角为θ。那么利用简单的三角学知识,我们可以算出这条纬线与圆的交点的X坐标是sin(θ),Y坐标是cos(θ)。

下面,让我们来概括一下如何计算出每条纬线上相应的点。因为每两条相邻纬线之间的球面距离都是相等的,我们可以根据θ的值来计算出每条纬线。每个半圆的弧度是π,所以θ的取值应该是从0、π/10、2π/10、 3π/10……一直到10π/10。这样我们就可以确保我们用纬线平均的将球体分割开来。

在每个确定的纬线上的点,不管他们的经度如何,都有相同的Y坐标。根据我们上面用方程求出的纬线与圆的交点的Y坐标,我们可以推出,在这个用10条纬线平均分割且半径为1的球体上,第n条纬线的Y坐标是cos(nπ / 10)。

这样我们就解决了Y坐标的问题。那X坐标和Z坐标如何确定呢?我们可以看出来,在Z坐标为0、Y坐标为cos(nπ / 10)的位置,X坐标是sin(nπ / 10)。让我们换一种方式来切割球体,就像左边的那幅图,我们在第N条纬线上,水平将球体切开。我们可以看到圆的半径是sin(nπ / 10),让我们设其为k。如果我们用经线将这个圆平均分割一下,假设是10条经线,我们同样设X轴和经线与圆的交点之间的夹角为φ,又有整个圆的弧度为2π,那么φ的取值应该是0、2π/10、4π/10……我们再利用简单的三角学知识计算一下,可以得出X坐标为kcos(φ),Z坐标为ksin(φ)。

总结一下,对于一个半径为r的球体,有m个纬线带和n个经线带,我们把从0到π的区间平均分成m等份就可以得到θ的取值范围,把0到2π的区间平均分成n等份就可以得到φ的取值范围,从而计算出坐标x,y,z的值。

  • x = r sinθ cosφ
  • y = r cosθ
  • z = r sinθ sinφ

以上就是我们如何计算出顶点的过程。那现在看看我们还需要哪些值,包括法线和纹理坐标。好吧,其实计算法线是相当容易的。你只要记住,法线就是一个直勾勾指向表面外部的一个长度为1的向量。对于一个半径为1的球体来说,法线就是从球体中心指向到表面的向量,而这个值我们已经在计算顶点的过程中计算出来了!事实上,计算顶点位置和法线向量的顺序应当是,在按照上面的公式运算中,在与半径相乘之前,先把结果储存一下,作为法线向量,然后再与半径相乘得到顶点位置。

纹理坐标,其实更简单。我们希望纹理贴图的提供者,提供给我们的是一张矩形图片。多说一句,WebGL(不是Javascript)会被其他形状的贴图搞晕。这样,我们就可以放心的假定,这张纹理图片的顶部和底部拉伸肯定是遵循墨卡托投影法则(Mercator Projection)的。这样就是说,我们可以从左到右按照经线平均分割纹理图片得出坐标u,从上到下按照纬线平均分割纹理图片得到坐标v。

好了,这就是全部的工作原理。对于Javascript来说,理解并运算以上原理是如此难以置信得简单方便!我们只需要循环遍历所有的纬线切片,在循环内我们再遍历所有的经线切片,之后我们就可以计算出法线、纹理坐标和顶点位置。唯一需要注意的是,在循环结束的条件中,循环变量必须大于经线或纬线的数量。所以这里我们必须使用小于等于而不是小于。也就是说,比如有30条经线,在每条纬线上就会产生31个顶点。因为根据三角函数的循环,最后一个顶点和第一个顶点的位置其实是相同的,这样的一个重叠让我们把所有东西都连接到了一起。

    var vertexPositionData = [];
    var normalData = [];
    var textureCoordData = [];
    for (var latNumber = 0; latNumber <= latitudeBands; latNumber++) {
      var theta = latNumber * Math.PI / latitudeBands;
      var sinTheta = Math.sin(theta);
      var cosTheta = Math.cos(theta);

      for (var longNumber = 0; longNumber <= longitudeBands; longNumber++) {
        var phi = longNumber * 2 * Math.PI / longitudeBands;
        var sinPhi = Math.sin(phi);
        var cosPhi = Math.cos(phi);

        var x = cosPhi * sinTheta;
        var y = cosTheta;
        var z = sinPhi * sinTheta;
        var u = 1 - (longNumber / longitudeBands);
        var v = 1 - (latNumber / latitudeBands);

        normalData.push(x);
        normalData.push(y);
        normalData.push(z);
        textureCoordData.push(u);
        textureCoordData.push(v);
        vertexPositionData.push(radius * x);
        vertexPositionData.push(radius * y);
        vertexPositionData.push(radius * z);
      }
    }

现在我们已经处理完顶点了,还需要把它们缝合到一起。我们生成一个顶点索引列表,其中包括了上面六个值的序列,将每个四边形分成一对三角形。下面是代码:

    var indexData = [];
    for (var latNumber = 0; latNumber < latitudeBands; latNumber++) {
      for (var longNumber = 0; longNumber < longitudeBands; longNumber++) {
        var first = (latNumber * (longitudeBands + 1)) + longNumber;
        var second = first + longitudeBands + 1;
        indexData.push(first);
        indexData.push(second);
        indexData.push(first + 1);

        indexData.push(second);
        indexData.push(second + 1);
        indexData.push(first + 1);
      }
    }

这些代码实在是太容易理解了。我们通过循环遍历了所有顶点,对于每个顶点,我们将其索引值储存在first变量中,然后向前数longitudeBands + 1个顶点,找到和它配对的下一个纬线带,储存在second变量中(之所以+1是因为我们额外增加的那一个顶点会重叠)。这样我们就生成了两个三角形,如图所示。

好了,以上就是本节课的难点(至少解释起来很难)。让我们继续看看其他变化的代码。

在initBuffers函数上方有3个用于处理鼠标事件的函数。我们必须仔细的考察一下它们。让我们先想想我们的目的是什么。我们想让观察者可以使用拖拽来旋转月球。一个幼稚的想法就是,我们建立三个变量用来表示X、Y、Z轴的旋转。当用户拖拽鼠标的时候我们可以相应的调整变量值。如果用户上下拖拽鼠标,我们就调整X轴的旋转变量;如果用户左右拖拽鼠标,我们就调整Y轴的旋转变量。这么做的问题在于,当你围绕不同的轴旋转物体的时候,你实际上做的一系列不同的旋转,而这一系列不同旋转的应用顺序很重要。比如说,用户先让月球围绕Y轴旋转了90°,然后又向下拖拽鼠标。这时,如果我们按照原来说好的再围绕X轴做旋转,观察者就会发现实际上月球正在围绕Z轴旋转。因为第一次的旋转,同时也旋转了轴线。这对于观察者来说会变得很奇怪。当观察者先把物体围绕X轴旋转10°,再围绕Y轴旋转23°,再怎样怎样时,这种问题变的更糟糕。我们可以耍下小聪明——“给出当前的旋转装袋,如果用户向下拖拽鼠标,那就同时改变所有三个旋转变量”。其实,一共更简单的处理方式是,用某种方法记录下观察者施加给月球的每一个旋转,然后在我们绘制的时候重新展示出来。表面上看,这种方法似乎需要耗费大量资源。但是不要忘了,我们已经找到一种完美的方法来保持追踪几何体的一系列变换,并且只用一个操作就可以应用这些变换,那就是——矩阵!

我们维护一个用来储存当前月球旋转状态的矩阵,叫做moonRotationMatrix。当用户拖拽鼠标的时候,我们会捕获到一系列的鼠标事件,每捕获到一个鼠标事件,我们都会根据用户拖拽鼠标的量,计算出绕着当前X轴和Y轴旋转多少度。我们用一个矩阵来表示这两个旋转,然后左乘moonRotationMatrix——之所以使用左乘,理由和我们上节课设置相机一样,我们需要做一个逆操作,旋转是基于eye space的,而不是模型空间。

有了以上的说明,下面的代码就变得清晰起来。

  var mouseDown = false;
  var lastMouseX = null;
  var lastMouseY = null;

  var moonRotationMatrix = mat4.create();
  mat4.identity(moonRotationMatrix);

  function handleMouseDown(event) {
    mouseDown = true;
    lastMouseX = event.clientX;
    lastMouseY = event.clientY;
  }

  function handleMouseUp(event) {
    mouseDown = false;
  }

  function handleMouseMove(event) {
    if (!mouseDown) {
      return;
    }
    var newX = event.clientX;
    var newY = event.clientY;

    var deltaX = newX - lastMouseX;
    var newRotationMatrix = mat4.create();
    mat4.identity(newRotationMatrix);
    mat4.rotate(newRotationMatrix, degToRad(deltaX / 10), [0, 1, 0]);

    var deltaY = newY - lastMouseY;
    mat4.rotate(newRotationMatrix, degToRad(deltaY / 10), [1, 0, 0]);

    mat4.multiply(newRotationMatrix, moonRotationMatrix, moonRotationMatrix);

    lastMouseX = newX
    lastMouseY = newY;
  }

以上就是本节课的另一个重点。代码中剩下的变化都很简单,比如载入新的纹理和新的变量名称。

好了!这节课结束了,你学会了如何使用一种简单但是有效的算法来绘制球体;如何捕捉鼠标事件,让用户可以通过拖拽来和你的3D物体交互;如何使用矩阵来表示场景中物体的当前旋转状态。

下一节课中,我们将会讲解一种新的光源类型:点光源,它是一种来自于场景内部某处并且向外辐射的光源,类似于一个光秃秃的灯泡。

 

 

WebGL中文教程:第10课,载入世界,以及相机简介

 欢迎来到WebGL教程的第10课,这节课是基于NeHe的OpenGL教程的第10节改编的。在这节课中,我们将会从一个文件中载入3D场景(这样我们就可以通过切换文件来轻松扩展Demo),然后会写一些简单的代码让我们可以在场景中移动,用Doom自带的WAD文件格式,实现一个类似于Doom的小游戏!

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里。你会看到大量的不同颜色的星星,不断盘旋。

你会发现你自己身处一个房间之中,墙上贴满了Lionel Brits的照片,他撰写了原始的OpenGL教程,也就是我们一直基于其改编的Nehe的OpenGL教程(/致敬)。使用方向键或WASD键,你可以在房间里来回走动,还可以走出房间;使用Page Up和Page Down键,你可以抬头低头。特需要注意的是,为了增强真实性,你的视角也会在移动的时候一上一下,类似于慢跑时的点头。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

要获得上面实例的代码,请点击这里下载我们为您准备好的压缩包。这里不建议通过查看源代码的方式获得实例,因为下载下来的world.txt的编码可能会发生变化,导致场景不可用。另外,因为要载入本地资源,所以请使用Chrome浏览器的读者为Chrome增加如下命令行:“–allow-file-access-from-files”;请使用Firefox的读者打开about:config配置页面,找到“security.fileuri.strict_origin_policy”项,并将其设置为“false”。除此之外,你也可以安装一个web服务器,然后通过服务器加载电脑上的资源。

和前面几课一样,最简单的讲解方式就是从代码底部开始。所以让我们先从body标签里的HTML代码开始吧!自从第一课开始,这部分代码第一次有了些有意思的东西!


<body onload="webGLStart();">
<a href="http://learningwebgl.com/blog/?p=1067">&lt;&lt; Back to Lesson 10</a><br />

  <canvas id="lesson10-canvas" style="border: none;" width="500" height="500"></canvas>

  <div id="loadingtext">Loading world...</div>

  <br/>
使用方向键或WASD移动,使用Page Up/Page Down键来上看下看。
<br/>
<br/>
<a href="http://learningwebgl.com/blog/?p=1067">&lt;&lt; Back to Lesson 10</a>

</body>

我们使用了DIV标签来充当占位符,在载入世界时显示Loading。如果客户端与服务器端的连接缓慢,客户端就会看到正在载入的提示信息。当然,这条信息必须要显示在canvas之上而不是之下,这是由一段CSS代码来控制的,它就位于head标签的尾部。

<style type="text/css">
    #loadingtext {
        position:absolute;
        top:250px;
        left:150px;
        font-size:2em;
        color: white;
    }
</style>
 

好了,这就是HTML代码的部分。下面我们来看看Javascript部分。

首先第一个小改动位于已经成为我们的标准的webGLStart函数中,除了通常的部署初始化的代码之外,还调用了一个新的函数用于从服务器载入世界。

  function webGLStart() {
    var canvas = document.getElementById("lesson10-canvas");
    initGL(canvas);
    initShaders();
    initTexture();
    loadWorld();

    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.enable(gl.DEPTH_TEST);

    document.onkeydown = handleKeyDown;
    document.onkeyup = handleKeyUp;

    tick();
  }

让我们往上走走,loadWorld函数就在drawScene函数的上面,大概在整个代码的四分之三处。它长的是这个样子滴:

  function loadWorld() {
    var request = new XMLHttpRequest();
    request.open("GET", "world.txt");
    request.onreadystatechange = function() {
      if (request.readyState == 4) {
        handleLoadedWorld(request.responseText);
      }
    }
    request.send();
  }

代码的样式看起来非常眼熟,很像之前我们用来载入纹理的那段代码。我们建立了一个XMLHttpRequest对象,让它来负责载入,然后告诉他使用HTTP GET请求来获取与当前网页文件相同服务器、相同目录下的名为world.txt的文件。我们还指定了一个回调函数,在不同的载入阶段,回调函数会被更新。当XMLHttpRequest报告说它手下的readyState已经等于4啦,也就是文件已经被完整的载入了,回调函数就会调用handleLoadedWorld。最后,我们告诉XMLHttpRequest使用send方法来正式开始获取文件。

接着,让我们来看看handleLoadedWorld函数,它就在loadWorld函数上面。

  var worldVertexPositionBuffer = null;
  var worldVertexTextureCoordBuffer = null;
  function handleLoadedWorld(data) {

这个函数的工作就是解析载入文件的内容,并且使用这些内容来建立两个我们之前课程中已经不厌其烦的对象数组——顶点位置数组和纹理坐标数组。载入文件中的内容是被当做字符串来传入的,参数的名字是data。开始的一小段代码解析了这些文件内容。在这节课的示例中我们使用的文件格式非常简单,它包含一个三角形的列表,每一个都由3个顶点来指定;文件中每行都是一个顶点,每个顶点包含5个值:位置坐标X、位置坐标Y、位置坐标Z、纹理坐标S和纹理坐标T。文件中还包括注释(以“//”起头的那些行)和空白行,这些在处理的时候都会被忽略。文件在第一行还给出了三角形的总数,尽管我们实际上并不会用到这个数字。

怎么样,是不是觉得这个文件格式完美无瑕?好吧,实际上它漏洞百出!它忽略了很多我们在真实场景应用中需要的信息,比如法线、不同物体的不同纹理。在真实的应用中,我们应该使用另外一种格式,或者直接使用JSON。我依然在教程中使用这种简单的文件格式,是因为原本NeHe的OpenGL教程中使用了它,并且它真的很容易理解,处理起来也很简单。好吧,说了这么多,因为真实应用中不会使用这种格式,所以我不会详细讲解下面的解析代码的细节。

    var lines = data.split("n");
    var vertexCount = 0;
    var vertexPositions = [];
    var vertexTextureCoords = [];
    for (var i in lines) {
      var vals = lines[i].replace(/^s+/, "").split(/s+/);
      if (vals.length == 5 && vals[0] != "//") {
        // It is a line describing a vertex; get X, Y and Z first
        vertexPositions.push(parseFloat(vals[0]));
        vertexPositions.push(parseFloat(vals[1]));
        vertexPositions.push(parseFloat(vals[2]));

        // And then the texture coords
        vertexTextureCoords.push(parseFloat(vals[3]));
        vertexTextureCoords.push(parseFloat(vals[4]));

        vertexCount += 1;
      }
    }

最后说一句,以上代码所做的实际上是将读取由空格分隔的5个属性值,然后用它们组成顶点位置数组和纹理坐标数组;另外还在vertexCount变量中记录了顶点的数量。

下面的代码现在看起来应该非常熟悉

    worldVertexPositionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexPositionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexPositions), gl.STATIC_DRAW);
    worldVertexPositionBuffer.itemSize = 3;
    worldVertexPositionBuffer.numItems = vertexCount;

    worldVertexTextureCoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexTextureCoordBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexTextureCoords), gl.STATIC_DRAW);
    worldVertexTextureCoordBuffer.itemSize = 2;
    worldVertexTextureCoordBuffer.numItems = vertexCount;

我们建立了从文件中载入的顶点位置数组和纹理坐标数组。最终,当这些准备工作都已经就绪时,我们清空了原本显示“Loading World…”的DIV标签中的内容。

    document.getElementById("loadingtext").textContent = "";
}
 

以上就是从文件中载入世界所需要的全部代码。在我们继续研究其他代码之前,让我们先休息一下,来看看这个world.txt中的一些有趣的东西。首先是前三个顶点,用于描述场景中的第一个三角形,它看起来是这样的:

// Floor 1
-3.0  0.0 -3.0 0.0 6.0
-3.0  0.0  3.0 0.0 0.0
 3.0  0.0  3.0 6.0 0.0

你还记得,这5个值分别是X、Y、Z、S、T,其中S和T是纹理坐标。你可以看到纹理坐标是介于0到6之间的。但是我之前说过纹理坐标的范围是位于0到1的区间中的。这是怎么回事呢?答案就是,当你求纹理中的一个点时,S和T的坐标值将自动对1取模,所以5.5实际上与纹理中0.5的那个点是相同的点。这就意味着,实际上纹理会自动平铺,不断重复,直到填满整个三角形。这显然十分有用,尤其是当你想用一个很小的纹理填充一个很大的物体时——比如用一块砖的纹理覆盖整个一面墙。

好了,让我们继续看看下一段比较有趣代码——drawScene函数。首先,函数检查了一下当我们完成载入世界之后,相应的对象数组是否被正确建立;如果没有,将会有一个应急处理:

  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    if (worldVertexTextureCoordBuffer == null || worldVertexPositionBuffer == null) {
      return;
    }

如果对象数组确认无误,下一步就是我们通常所做的,建立投影矩阵和模型视图矩阵。

    mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);

    mat4.identity(mvMatrix);

接下来我们要着手处理相机(camera)了,也就是允许视角按照我们想要的方式来移动穿越场景。首先你要记住的事,和其他很多特性一样,WebGL并不直接支持相机,但是要模拟一个也不是什么困难的事情。如果我们有了相机,在这个简单的示例中,我们想要的是,把它放置于一个指定的X、Y、Z坐标上,并且可以围绕X轴做倾斜用于观察上下(pitch),可以围绕Y轴做转动用于左转向右转向(yaw)。因为我们不能改变相机的位置——实际上它永远都处于坐标(0,0,0)并且看向Z轴的负半轴,所以我们想要做的是告诉WebGL去调整我们绘制的场景,这个用X、Y、Z坐标指定的空间我们称之为world space;将其调整到一个新的基于相机的位置和旋转的参照系中去,我们称之为eye space。

一个简单的例子也许有助于你理解。让我们设想一下一个简单的场景,其中有一个立方体,在world space中它的中心坐标是(1,2,3)。我们想要模拟一个处于(0,0,7)坐标、既没有pitch也没有yaw、看向Z轴负半轴的相机。为了实现这个效果,我们把立方体中心坐标从world space的坐标转换为eye space的坐标。这么理解或许会简单一些,也许也没简单多少。

我们大概比较清楚的就是又要用到矩阵了,我们会维护一个称之为相机矩阵(camera matrix)的东西,用来表示相机的位置和旋转。但是在这节课的示例中,我们简单一些,我们只要使用既有的模型视图矩阵就可以了。

从上面的实例中可以明显的推理出,我们模拟相机的方式就是移动场景,相机不动,而让场景朝我们想要移动方向的相反方向“退回”,然后再根据通常使用的相关坐标系,绘制出场景的方法。假设我们把自己想象成相机,我们想要移动到某个指定位置,然后做指定的旋转。“退回”的意思就是,我们做一个相反的旋转,然后再做一个相反的移动。

从数学的角度讲,比如我们模拟一个位置在(x,y,z)的相机,然后做ψ角度yaw旋转,再做一个θ角度的pitch旋转;那我们就需要先做一个围绕X轴的-θ角度旋转,再做一个围绕Y轴的-ψ角度旋转,然后移动到(-x,-y,-z)。完成后,我们把所有要绘制的东西的状态用world space中的坐标都储存到模型视图矩阵中,最后在顶点着色器中通过矩阵的乘法运算,会神奇般地将其转换为eye space中的坐标。

当然,还有其他的方法来放置相机,我们将会在以后的课程中讨论。现在,我们先来看看上述做法的代码:

    mat4.rotate(mvMatrix, degToRad(-pitch), [1, 0, 0]);
    mat4.rotate(mvMatrix, degToRad(-yaw), [0, 1, 0]);
    mat4.translate(mvMatrix, [-xPos, -yPos, -zPos]);

完成后,我们要做的就是根据之前我们载入的对象数组中所描述的,绘制出整个场景。下面的代码看起来和之前几课非常相似。

    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, mudTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, worldVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, worldVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

    setMatrixUniforms();
    gl.drawArrays(gl.TRIANGLES, 0, worldVertexPositionBuffer.numItems);
  }

好了,到此我们就解决了本课中新增加的那一批代码。最后我们来看看控制移动,包括“慢跑”是上下点头的那一部分代码。和之前课程中的一样,这节课页面中的键盘动作也是设计成给所有人一个相同的运动速率,不管他们的电脑性能如何。电脑好的人仅仅是能获得更好的fps帧率,而不是更快的移动速度!

这些工作都是由handleKeys函数来完成的,我们根据用户当前正按下的键来计算出一个速率——也就是位置改变的速率——一个pitch改变的速率和一个yaw改变的速率。如果没有键被按下,那么这些值都会被设置为0;如果相应的键被按下,那么它们会被设为一个固定的值,单位是每毫秒。你可以在页面的三分之二处找到这些代码。

  var pitch = 0;
  var pitchRate = 0;

  var yaw = 0;
  var yawRate = 0;

  var xPos = 0;
  var yPos = 0.4;
  var zPos = 0;

  var speed = 0;

  function handleKeys() {
    if (currentlyPressedKeys[33]) {
      // Page Up
      pitchRate = 0.1;
    } else if (currentlyPressedKeys[34]) {
      // Page Down
      pitchRate = -0.1;
    } else {
      pitchRate = 0;
    }

    if (currentlyPressedKeys[37] || currentlyPressedKeys[65]) {
      // Left cursor key or A
      yawRate = 0.1;
    } else if (currentlyPressedKeys[39] || currentlyPressedKeys[68]) {
      // Right cursor key or D
      yawRate = -0.1;
    } else {
      yawRate = 0;
    }

    if (currentlyPressedKeys[38] || currentlyPressedKeys[87]) {
      // Up cursor key or W
      speed = 0.003;
    } else if (currentlyPressedKeys[40] || currentlyPressedKeys[83]) {
      // Down cursor key
      speed = -0.003;
    } else {
      speed = 0;
    }

  }

就拿上面的代码为例,如果左方向键被按下,那么yawRate就被设置为0.1°/ms,也就是100°/s。换句话说,如果我们向左旋转,旋转一周的时间需要3.6秒。

和前面的课程一样,这些改变的速率都会在animate函数中被调用,以设置xPos和zPos。同时, 在animate函数中也会设置yaw和pitch.yPos,但是逻辑上有些不同。让我们看一下代码,它就在drawScene函数的下面,接近底部,下面是开头的几行:

  var lastTime = 0;
// Used to make us "jog" up and down as we move forward.
  var joggingAngle = 0;
  function animate() {
    var timeNow = new Date().getTime();
    if (lastTime != 0) {
      var elapsed = timeNow - lastTime;
 

其中大部分都是正常的代码,用于计算出距离上次animate函数被调用的毫秒数。joggingAngle是个很有意思的变量。我们用来获得点头效果的方法是,在移动是,让Y坐标跟随头部高度中心位置的正弦波移动。joggingAngle这个角度变量,就是用于传递给正弦函数计算当前位置的。

让我们看看这部分的代码,同时代码中也调整了x和z来实现移动:

      if (speed != 0) {
        xPos -= Math.sin(degToRad(yaw)) * speed * elapsed;
        zPos -= Math.cos(degToRad(yaw)) * speed * elapsed;

        joggingAngle += elapsed * 0.6;  // 0.6 "fiddle factor" -- makes it feel more realistic :-)
        yPos = Math.sin(degToRad(joggingAngle)) / 20 + 0.4
      }

显然,位置的改变和所谓的慢跑时的点头效果都应该发生在我们移动的时候,所以如果speed为非零值,就会根据三角函数和当前速度来调整xPos和zPos(因为Javascript中的三角函数使用的是弧度制,所以我们借助degToRad函数将角度值转换为弧度值)。接下来,joggingAngle继续参与运算,计算出当前的yPos。所有我们使用到的数字都乘以距离上次animate函数被调用的毫秒数,尤其是对于speed,它已经被设置为以毫秒为单位了,这非常完美。

然后,我们需要根据yaw和pitch各自相应的改变速率调整它们的值,这个运算在我们站在原地不移动的时候也会进行。

      yaw += yawRate * elapsed;
      pitch += pitchRate * elapsed;

最后,我们需要记录当前时间,以便我们可以在下次animate函数被调用时计算出中间的时间间隔。

    }
    lastTime = timeNow;
  }


好了,本节课到此为止!你学会了一种简单的从txt文件中载入场景的方法,和一种简单的应用相机的方法。

下节课中我们将会演示如何显示一个球体,然后使用鼠标事件旋转它,然后还会为你讲解如何使用旋转矩阵去避免一个讨厌的问题——万向节死锁(gimbal-lock)。

WebGL中文教程:第9课,优化代码结构实现多物体运动

 欢迎来到WebGL教程的第9课,这节课的内容是基于NeHe的OpenGL教程的第9节改编的。这节课中,我们将使用Javascript对象来实现3D场景中的多个独立的运动物体。我们还会涉及到如何更改加载纹理的颜色以及如何混合纹理。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里。你会看到大量的不同颜色的星星,不断盘旋。

你可以点击画布下方的复选框来开启或关闭“闪光”效果(我们一会就会讲解到)。你还可以使用方向键来使星星围绕着X轴运动,并使用Page Up键和Page Down键来进行缩放。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

要讲清楚这节课与上节课不同的地方,最好的办法就是从代码底部开始,从webGLStart函数开始,下面就是本节课中的webGLStart函数:

  function webGLStart() {
    var canvas = document.getElementById("lesson09-canvas");
    initGL(canvas);
    initShaders();
    initTexture();
    initBuffers();
    initWorldObjects();

    gl.clearColor(0.0, 0.0, 0.0, 1.0);

    document.onkeydown = handleKeyDown;
    document.onkeyup = handleKeyUp;

    tick();
  }

发生变化的代码位于第397行。我们新调用了一个initWorldObjects函数。这个函数创建了Javascript对象,我们很快就要讲到。但这之前我们还需要说一下一个非常细微的变化,那就是之前我们的webGLStart函数中都有下面一行代码来开启深度检测:

    gl.enable(gl.DEPTH_TEST);

但是在这一课的例子中我们移除了这行代码。你也许还记得,在上一课中,混合和深度检测这两位小朋友总是玩不到一起去。在这节课中我们将全时使用混合,而深度检测默认是关闭的,所以移除这行代码可以满足我们的需求。

下面一个重大的变化位于animate函数中。之前我们一直使用这个函数来更新全局变量以便实现场景中的动画效果——例如,在绘制立方体之前旋转角度的全局变量。这节课中我们还是要做类似的事情,但不是直接更新变量,而是遍历场景中的物体,让它们自己运动。

  var lastTime = 0;
  function animate() {
    var timeNow = new Date().getTime();
    if (lastTime != 0) {
      var elapsed = timeNow - lastTime;

      for (var i in stars) {
        stars[i].animate(elapsed);
      }
    }
    lastTime = timeNow;

  }

让我们继续,下面来看看drawScene函数。我不打算直接标出发生变化的所有代码,而是一点一点的来慢慢看。首先:

  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);
 

以上是我们进行场景基本设置的代码,它从第一节课开始就没有变过。

    gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
    gl.enable(gl.BLEND);

接着,我们开启了混合。我们和上节课一样,使用同样的混合方法。你应该还记得,这项技术允许物体互相“透过”彼此。有用的是,这也就意味着,在绘制时,物体的黑色部分会被当成是透明的。如果想不明白其中的原理,请参考上一节课中我们对混合的讲解。也就是说,当我们绘制场景中的星星的时候,黑色的部分会看起来是透明的;进一步说,星星中不那么明亮的部分,看起来就会更透明一些。我们使用下面这个纹理来绘制星星

这恰好给了我们想要的效果。

接下来的代码是:

    mat4.identity(mvMatrix);
    mat4.translate(mvMatrix, [0.0, 0.0, zoom]);
    mat4.rotate(mvMatrix, degToRad(tilt), [1.0, 0.0, 0.0]);

这里我们移动到了场景中央,并进行了合适的缩放。我们还让场景绕着X轴倾斜,在这里依然使用tilt这个全局变量来接收来自于键盘的输入,控制倾斜角度。好了,现在差不多就要开始绘制场景了。我们先检测一下“闪光”复选框是否被选中。

    var twinkle = document.getElementById("twinkle").checked;

然后,就和我们刚才让星星自己运动一样,通过循环迭代来绘制每一颗星星。我们传入当前场景的倾斜角度(tilt)和闪光值(twinkle)。另外还有一个当前的“旋转(spin)”值,用于让星星在围绕场景中心公转的同时,还围绕自己的中心进行自转。

    for (var i in stars) {
      stars[i].draw(tilt, spin, twinkle);
      spin += 0.1;
    }

  }
 

好了,以上就是drawScene函数。我们可以清晰的看到,星星自主地被绘制出来和运动起来。接着往上看就是创建星星的代码:

  var stars = [];
  function initWorldObjects() {
    var numStars = 50;

    for (var i=0; i < numStars; i++) {
      stars.push(new Star((i / numStars) * 5.0, i / numStars));
    }
  }

通过一个简单的循环,我们创建了50颗星星(你也可以增加或减少星星的数量)。每颗星星被赋予的第一个参数用于指定距离场景中心的初始距离,第二个参数用于指定围绕场景中心公转的速度,这两个参数都是根据位置计算出来的。

下面,我们要看一看如何定义星星的类。如果你对Javascript并不熟悉,那这些代码看起来会有些奇怪。(如果你已经非常了解Javascript,那请略过这一部分关于建立对象模型的讲解吧!)

Javascript对象模型与其它语言有很大的不同。你可以将js对象理解为一个字典容器(哈希表,关联数组),对象成员作为数据放在对象容器中,由对象名称索引。所以,在Javascript中,当你像其它语言一样使用foo.bar形式调用成员时,实际上,你是在访问foo这个字典容器中的以bar为索引关键字的数据,即foo[“bar”]。

对于每个Javascript函数,都有一个特殊的私有变量“this”, 表示当前函数的“拥有者”。对于全局函数,this指向网页中的“windows”对象。如果你在引用this的前方加new关键字,那它的意义就不再是指向当前函数的拥有者,而是创建一个新的对象。所以,如果你有一个函数,在函数体内部将this.foo设置为1,this.bar设置为一个函数,并确保在调用此函数前使用new关键字, 那它的功能基本上等同于带class定义的类构造函数。

接下来,我们需要注意,如果一个函数在调用时指明了调用者(例如foo.bar()),那此函数在调用过程中就绑定在了它的调用者身上,正像大多数开发者希望的那样,通过这种方式我们可以让函数调用的内容只作用于它的调用者。

最后,对于每个函数,都有一个特殊的属性:prototype。prototype是一个字典容器,它内部存储了当使用new关键字创建此对象的副本时,所需要创建的成员变量。这是一种良好的定义Javascript“类”对象通用成员的方式——比如,类方法。

好了,让我们来看看定义Star对象的代码。

  function Star(startingDistance, rotationSpeed) {
    this.angle = 0;
    this.dist = startingDistance;
    this.rotationSpeed = rotationSpeed;

    // Set the colors to a starting value.
    this.randomiseColors();
  }

在这个构造函数中,我们先初始化了星星,使其角度为0,然后又调用了一个方法。接下来我们要把几个方法绑定到Star函数相关的prototype上,让所有新创建的Star对象都有相同的方法。首先要绑定的方法是draw。

  Star.prototype.draw = function(tilt, spin, twinkle) {
    mvPushMatrix();
 

draw方法被定义为将我们传入的参数带入到drawScene主函数中。我们一开始先把当前模型视图矩阵压入栈中,以此避免之前课程中提到过的副作用。

    // Move to the star's position
    mat4.rotate(mvMatrix, degToRad(this.angle), [0.0, 1.0, 0.0]);
    mat4.translate(mvMatrix, [this.dist, 0.0, 0.0]);

然后,我们根据星星的角度值绕着Y轴旋转一下,然后根据星星的距离值移出场景中心。这让我们到达了绘制星星的正确位置。

    // Rotate back so that the star is facing the viewer
    mat4.rotate(mvMatrix, degToRad(-this.angle), [0.0, 1.0, 0.0]);
    mat4.rotate(mvMatrix, degToRad(-tilt), [1.0, 0.0, 0.0]);

这几行代码用于确保星星始终面对观察者,尤其是当接受键盘输入来控制场景的倾斜角度的时候。星星都是使用一个绘制在正方形中的2D纹理来贴图的,我们竖着看的时候是正常的;但是当场景倾斜的时候,那我们就只能看到它们的一侧了。所以,我们在放置星星的时候还需要还原这个旋转。当你做“逆旋转”的时候,你需要逆着一开始执行的顺序倒着回去。所以我们先还原了旋转,然后再做倾斜(这是在drawScene函数中完成的)。
下面就要开始绘制星星了。

    if (twinkle) {
      // Draw a non-rotating star in the alternate "twinkling" color
      gl.uniform3f(shaderProgram.colorUniform, this.twinkleR, this.twinkleG, this.twinkleB);
      drawStar();
    }

    // All stars spin around the Z axis at the same rate
    mat4.rotate(mvMatrix, degToRad(spin), [0.0, 0.0, 1.0]);

    // Draw the star in its main color
    gl.uniform3f(shaderProgram.colorUniform, this.r, this.g, this.b);
    drawStar();
 

让我们先忽略关于执行“闪光”效果的代码。星星先是根据传入的spin参数围绕Z轴进行自转。然后将星星的颜色通过uniform变量传送到显卡端,然后调用了一个全局函数drawStar(我们稍后讲解)。

现在来看看“闪光”的部分吧?好吧,每颗星星都有两个颜色,一个是正常的颜色,一个是闪光的颜色。在绘制闪光的星星前,我们先绘制了一个正常颜色下的星星。也就是说两个星星会混合在一起,实现了闪光;进一步说,第一次绘制的星星是不动的,而第二次绘制的星星是自转的,两者结合在一起,就给出了我们想要的闪光的效果。

好了,星星绘制完毕,我们要让模型视图矩阵出栈了。

    mvPopMatrix();
  };

接下来我们要绑定到prototype上的方法是用来让星星运动的。

  var effectiveFPMS = 60 / 1000;
  Star.prototype.animate = function(elapsedTime) {

在之前的课程中,与其让场景更快的更新状态,我选择了更稳健的方式,确保电脑较快的人能得到更平滑的动画效果,而电脑较慢的人也不至于那么糟糕。在本节课的示例中,我觉得让星星围绕场景中心公转、并最终收敛在场景中心的角度的变化速率是NeHe精心计算出来的,所以为了不把它搞乱套,我决定将每秒的帧率定在60fps,然后配合使用elapsedTime变量(你应该还记得,这个变量用来表示animate函数两次调用之间的时间间隔)来控制每次动画效果在“tick”函数中的运动的量。elapsedTime变量的单位是毫秒,所以每毫秒的帧率应当是60/1000。我们把它放到一个animate函数之外的全局变量中,从而保证我们每次绘制星星的时候它不会被重新计算。

现在我们可以根据这个数字调整星星的角度了,也就是星星在围绕场景中心的轨道上可以走多远。

    this.angle += this.rotationSpeed * effectiveFPMS * elapsedTime;

然后,我们可以调整星星到场景中心的距离,当星星最终到达场景中心的时候,将它移出场景之外,并且重置它的颜色。

    // Decrease the distance, resetting the star to the outside of
    // the spiral if it's at the center.
    this.dist -= 0.01 * effectiveFPMS * elapsedTime;
    if (this.dist < 0.0) {
      this.dist += 5.0;
      this.randomiseColors();
    }

  };

最后一段组成Star对象prototype的代码,是被构造函数调用,以及刚刚在动画中随机生成闪光和非闪光颜色的:

  Star.prototype.randomiseColors = function() {
    // Give the star a random color for normal
    // circumstances...
    this.r = Math.random();
    this.g = Math.random();
    this.b = Math.random();

    // When the star is twinkling, we draw it twice, once
    // in the color below (not spinning) and then once in the
    // main color defined above.
    this.twinkleR = Math.random();
    this.twinkleG = Math.random();
    this.twinkleB = Math.random();
  };

这样我们就完成了Star对象的prototype,其中我们建立了一个星星的对象,并且填充了方法来完成绘制和动画。现在,就在这些函数上面你可以看到绘制星星的代码(相当无趣):用第一节课中我们熟知的方法绘制一个正方形,其中需要正确地使用纹理坐标数组、顶点位置数组……

  function drawStar() {
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, starTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, starVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, starVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, starVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, starVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

    setMatrixUniforms();
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, starVertexPositionBuffer.numItems);
}

再往上你一点,你会看到initBuffers函数,也就是用于建立顶点位置数组和纹理坐标数组的;以及用于处理键盘输入的代码handleKeys函数,当你按下Page Up和Page Down键进行缩放,按下方向键进行倾斜;最后是initTexture和handleLoadedTexture函数,其中加载了新的纹理。这些都很简单,所以我不会再对你进行无聊的说教。

让我们直接看一下着色器部分的代码吧!你会看到这节课中的变化。所有与光照有关的代码都被我们移除出了顶点着色器,现在它的代码和第5课完全一致。而片元着色器则发生了一些有趣的变化。

  precision mediump float;

  varying vec2 vTextureCoord;

  uniform sampler2D uSampler;

  uniform vec3 uColor;

  void main(void) {
    vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
    gl_FragColor = textureColor * vec4(uColor, 1.0);
  }
 

但不是那么有趣。我们所做的就是提取颜色的uniform变量,它是由Star对象的draw方法传送过来的,然后使用它来为纹理着色。因为纹理是单色的,所以星星会显示出我们需要的合适的颜色。

以上就是本课的全部内容!在这节课中,你学会了如何建立Javascript对象来表示场景中的物体,然后赋予它们方法,允许它们独立地绘制和运动。

下节课中,我们将会展示如何从一个单一文件中载入场景,然后看一看如何操纵相机来穿越场景,配合这些制作出一个小的DOOM游戏!

 

WebGL中文教程:第8课,深度缓存、透明度和混合

欢迎来到WebGL教程的第八节课,这节课的内容基于NeHe OpenGL教程的第8节改编的。在这节课上,我们将会介绍混合,并且稍微介绍一下这个相当有用的深度缓存是如何工作的。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

你将会看到一个半透明并且缓慢旋转的立方体,看上去是用有色玻璃制作的。你还可以像上节课中一样调节光照。

你可以点击画布下边的复选框以开启或者关闭混合模式和透明效果。你还可以调节alpha 的参数(这个我们将在稍后解释),当然,还有光线明暗。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

深度缓存(Depth Buffer)

当你命令WebGL绘制物体的时候,必须要经过某些必要的步骤。按等级高低排序:

  1. 在所有的顶点上运行顶点着色器以绘制出物体所在的位置。
  2. 线性地在顶点之间进行插值运算,这样做是告诉顶点哪些片元(这个时候,你可以把片元当做像素对待)需要上色。
  3. 对于每个片元来说,运行片元着色器以绘制出它的颜色。
  4. 把它写入帧缓冲。

最终,帧缓冲就是屏幕上显示出来的内容。但是当你需要绘制两个物体时,又会是怎样的呢?比如,当你需要绘制两个大小一样的正方形,一个中心位于(0,0,-5),另一个中心位于(0,0,-10),又会是怎样的呢?你肯定不希望第二个正方形覆盖在第一个正方形上,因为它距离更远,应当被挡住的。

WebGL正是运用深度缓存来处理这样的情况的。当片元着色器处理完片元以及RGBA颜色值,并把它们写入帧缓冲时,也会将与Z值相关的深度值储存在里面,但是这个值并不完全与Z值相同。(这不奇怪,因为深度缓存经常也被叫做Z缓存。)

为什么我说“相关的”呢?是这样的,WebGL总是将所有的Z值按从0到1顺序排列,0为最近,1为最远。其实这些事情在drawScene函数一开始,当我们调用透视并创建投影矩阵时,就背着我们发生了。现在,你所需要知道的就是,Z-buffer的值越大,物体距离就越远,这一点与我们通常的坐标系统是相反的。

好了,这些就是深度缓存。你应该还记得我们在第一课中初始化WebGL的代码,有这样一行。

    gl.enable(gl.DEPTH_TEST);

这行代码是给WebGL系统一个说明,告诉系统在把一段新的片元写入帧缓冲时,系统需要做些什么,基本上的意思是“注意深度缓存”。它会和另一个WebGL设置深度函数一起协同发生作用。实际上这个函数默认拥有一个合适的值,如果我们将它设置为默认值,它会是这样的:

    gl.depthFunc(gl.LESS);

这个函数的意思是:如果片元的Z值小于当前值,将使用新的值,而不是原有的值。这个深度检测,和其他实现他的代码一起,会给我们一个合理的透视行为——近处的物体会掩盖远处的物体。(你还可以尝试着将深度函数中的参数设为其他不同的值,尽管我想这些值并不太常用。)

混合(Blending)

混合是上述这一过程的另一个替代方案。通过深度检测,我们使用深度函数来判断是否用新的片元替换现有片元。当我们使用混合时,我们使用一个混合函数,把现有片元和新片元的颜色组合到一起,创建一个全新的片元,接着将它写入缓冲区内。

让我们来看一下代码。大部分代码是和第七课中的代码相同的,最重要的部分几乎都在drawScene的一小段代码中。首先,我们先来看一下混合复选框是否被选取。

    var blending = document.getElementById("blending").checked;

如果被选取,我们将设置一个函数用来组合这两个片元。

    if (blending) {
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE);

这些参数定义了混合是如何完成的。这需要一定技巧,但是并不困难。首先,我们需要定义两个术语:源片元(source fragment)和目标片元(destination fragment)。其中源片元是我们正在绘制的片元,而目标片元是已经存在于帧缓冲之中的。gl.blendFunc函数中的第一个参数定义了source factor,而第二个参数,定义了,这些factor是在混合函数中使用的一些数字。在这节课的例子中,我们指定用源片元的alpha值作为source factor,而destination factor则是一个常量1。当然,也有其他可能性,如果将源色指定为SRC_COLOR,你最后得到的source factor会是单独的红、绿、蓝和alpha值,其中每个都与原始的RGBA的分量相等。

现在,让我们来假设一下如果WebGL正在试图用计算出一个片元的颜色,这个片元有一个既存的目标片元和一个即将被加入的源片元,目标片元的RGBA值为 (Rd, Gd, Bd, Ad),源片元的值为(Rs, Gs, Bs, As)。

另外,假设RGBA的source factor是(Sr, Sg, Sb, Sa),而destination factor是(Dr, Dg, Db, Da)。

对于每个颜色分量,WebGL将会进行以下运算。

  • Rresult = Rs * Sr + Rd * Dr
  • Gresult = Gs * Sg + Gd * Dg
  • Bresult = Bs * Sb + Bd * Db
  • Aresult = As * Sa + Ad * Da

为了简单起见,这里我们仅会对红色分量进行运算。

  • Rresult = Rs * As + Rd

通常来说,这并不是创建透明物体的最理想方法。但当开启光照时,它确是解决问题的一个好方法。还有一件需要强调的事情是,混合并不是透明!相对于其他技术,它仅仅是一种能够实现透明效果的技巧。我在学习Nehe时,我花了好大功夫才弄懂这些,所以,请原谅我在这里过于强调这一点。

好啦,继续我们的课程。

      gl.enable(gl.BLEND);

这一段很简单,和其他许多WebGL中的特性一样,混合是默认关闭的。所以,在这里我们必须启用它。

      gl.disable(gl.DEPTH_TEST);

这里有一个很有趣的现象,那就是在开启混合的同时,我们必须要关闭深度检测。如果我们不这么做,混合效果将会出现在某些地方,但是在另一些地方却又不会出现。例如,当我们在绘制一个立方体时,假设我们正巧先绘制立方体的背面,这样,立方体的背面就会被写入帧缓冲之中;接着,当正面完成时,正面就会出现在背面之前,并带有混合效果,这也是我们需要的效果。但是,相反如果我们先绘制正面,然后绘制背面,这时背面将会在我们运行混合函数之前,在深度检测中被丢弃,显然这不是我们希望得到的效果。

眼尖的读者应该会在上面的混合函数中注意到这一点,混合很依赖绘制物体的顺序,这一点是在我们前几节课中都没有遇到过的。我们将在后面详细介绍,让我们先来讲完最后一点代码。

      gl.uniform1f(shaderProgram.alphaUniform, parseFloat(document.getElementById("alpha").value));

现在,我们正从页面中的文本框里加载一个alpha值,并且将它传递给着色器。这是因为我们作为纹理使用的图像没有自带alpha通道(它只有RGB通道,所以每个像素来说alpha值都固定为1)。所以,alpha值最好是可以调节的,这样我们就能看出它是如何影响图像的。

drawScene函数中的其余部分是用于当混合被关闭时,进行一般运算的。

    } else {
      gl.disable(gl.BLEND);
      gl.enable(gl.DEPTH_TEST);
    }

在片元着色器中还有一些小的改动,那就是处理纹理时将使用到alpha值,参见第18行代码。

  precision mediump float;

  varying vec2 vTextureCoord;
  varying vec3 vLightWeighting;

  uniform float uAlpha;

  uniform sampler2D uSampler;

  void main(void) {
     vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
     gl_FragColor = vec4(textureColor.rgb * vLightWeighting, textureColor.a * uAlpha);
  }

有变化的代码就这么多了!

好了,我们现在来讲一讲绘制顺序。示例中的透明效果十分出色——看上去很像彩色玻璃吧。 但现在我们再来看看它,这次我们改变一下光线的方向,让光线从Z轴正方向射入,也就是把文本框中的“-”号都去掉。恩,看上去很棒,但是,那种可以乱真的彩色玻璃效果却消失了。

为什么呢?这是因为在原来的光照处理中,立方体朝后的那一面,也就是背对光源的那一面一般都是昏暗的。也就是说那一面的R、G、B的值都十分小。所以,在进行运算时:

  • Rresult = Rs * Ra + Rd

那一面看上去就不会那么明显。换句话说,我们的光照处理让后侧可见度较低。如果我们改变光照效果,让前侧可见度较低的话,那我们的透明效果看起来就不那么好了。

但是,我们要如何才能得到一个“合适”的透明效果呢?OpenGL FAQ告诉我们,需要使用SRC_ALPHA 作为source factor,用ONE_MINUS_SRC_ALPHA作为destination factor。但是还有一个问题,源片元和目标片元的运算方式不同,所以,我们将会十分依赖绘制物体的顺序。这也引出了我认为是OpenGL或WebGL中的一个不光彩的地方;好再让我们来看一下OpenGL的FAQ:

当使用深度缓存时,你必须十分注意呈现图元的顺序。按照从后到前的顺序,完全不透明的图元应当首先被呈现,接着是部分透明的图元;如果你没有按照此顺序呈现图元,深度检测会屏蔽那些原本借助于透明图元才可以显示的图元。

深度缓存只能保证最近的面。而透明物体,实际上需要的是保存多层面数据,才能重现正确的效果。所以需要先画不透明的物体,然后再画透明的物体;同时,透明物体需要先按由远到近排好顺序才行。 如果你先画了透明的,后画不透明的,显然是不对的。因为即使不透明的更近,也不能通过深度检测屏蔽掉不透明的,所以必须先画不透明的。透明的物体,也需要由远到近来画。这样一层层blend上去,才是正确的结果。如果先blend了近的,那再blend远的时候,实际结果是错的。

如果在开启depth test的情况下,先画一个透明物体,那如果它后面有一个不透明物体,而且又是后画的话,depth test会使得后面的物体画不上去。

大概就是这样了。使用混合来做出透明效果是十分有技巧、并且十分繁琐的,但是只要你能像控制光照一样,控制场景中的其他大部分对象,我们就能很容易地实现我们需要的正确效果。仅仅能够正确地绘制物体还不够,为了让物体看上去更好看,我们还必须用特定的顺序去绘制它们。

值得一提的是,混合是一项非常有用的技术,还可以用来实现许多其他的效果,下一节课你将会看到。那么,这一节课所讲的内容就到此为止了。这节课上我们讲了深度缓存,还有如何通过混合来做出透明效果。

如果你有任何问题、评论或者修正,都请告诉我!特别是这一课,当我第一次学习时,我感觉这是NeHe的教程中最难理解的一部分。

下节课我们将改进代码结构,不再使用麻烦的全局变量,并实现多个不同物体在场景中的运动。

 

WebGL中文教程:第7课,平行光和环境光

 欢迎来到WebGL教程的第七课,本节课的内容是根据NeHe的OpenGL教程的第七节改编的。在这节课中我们将为你的WebGL页面添加简单的光照。说实话,这要比在OpenGL中更复杂一些,希望本教程能让你轻松理解其中的原理。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

你可以用canvas下面的复选框来选择开启或者关闭光照,这样你就能看出效果上的变化。你还可以改变平行光和环境光的颜色(稍后我会详细讲解),另外还可以设置平行光的方向。在开始之前,请先试着玩一下这个Demo,例如更改平行光的RGB值,改为大于1的某个值(不过如果超过5的话,你会发现损失了一定的纹理细节)。另外,和上节课一样,你依然可以用鼠标控制盒子的旋转快慢,用PageUp和PageDown键来进行缩放。这次我们使用了效果最好的纹理过滤,所以去掉了F键的功能。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

在我们详细讲解如何在WebGL中添加和设置光照之前,我需要先宣布一个坏消息!那就是,WebGL完全没有提供任何对光照系统的内置支持!不像OpenGL,它的实现版本最少也允许你指定8道光源,并且可以替你处理这些光源,而WebGL却完全撒手不管,都推给了你自己解决。但是——这是一个转折度很深的“但是”——如果解释清楚了,其实光照是一个相当简单的概念。如果你能很好的理解我们之前讲过的着色器的知识,那学习光照应该也没什么问题——只有你像新手一样从编写简单的光照代码开始,才会让你更容易理解当你成为高手后必须去面对的那些代码。毕竟,在OpenGL中光照是描绘真实场景时最基本的元素,然而它却并不能处理阴影,比如说,对于弯曲表面它产生的效果非常粗糙。所以稍微复杂一些的场景中的光照都需要手工编写代码。

好了,让我们先来想一下我们想从光照中得到什么效果。我们的目的是在场景内模拟出几道光源。这些光源本身不需要是可见的,但它们发出的光必须能够作用于3D物体的表面,让物体面对光源的那一侧显得明亮,同时背对光源的部分变得阴暗。换句话说,我们想要指定一系列的光源,然后让这些光源可以作用于我们的3D场景内的任何一个部分。现在,我想你应该已经知道WebGL是通过向着色器中填充一些东西来实现这些效果的。更进一步的说,我们这节课中要做的就是向顶点着色器写入代码来处理光照。我们将要计算出光照对每个顶点的影响,并以此来调整顶点的颜色。虽然现在我们只准备计算一道光源,但计算多道光源也不复杂,只是重复这一过程然后把结果累加在一起。

还需要说明的是,我们只是基于每个顶点来处理光照效果,所以顶点间像素的光照效果都是由线性插值来实现的。也就是说,物体顶点间的部分都会被当成平面来处理,而碰巧的是,我们画的是一个立方体,这正是我们想要的!对于弯曲的表面,如果你想为每个像素都独立计算出光照效果,你可以使用一种称之为“逐像素光照(逐片元光照)”(per-pixel lighting或per-fragment lighting)的技术,它能实现更好的效果。我们将在以后的课程中学习这项技术。而现在我们做的逻辑上被称为“逐顶点光照”(per-vertex lighting)。

好了,下一步,如果我们要向顶点着色器写入代码来处理一道光源对顶点颜色的影响,那我们应该怎么做呢?让我们先从冯氏反射模型(Phong Reflection Model)开始。首先你要理解下面几点:

  • 虽然在真实世界里不存在光照类型的概念,但在图形学中我们却将光的类型按照光与物体表面的作用来进行了区分:
  1. 一种是从特定方向射入并只会照亮面对入射方向的物体,我们称之为平行光(directional light)。
  2. 另一种光是来自所有方向并且会照亮所有物体,不管这些物体的朝向如何,我们称之为环境光(ambient light)。当然在真实世界里,这只是平行光照到其他物体上,比如空气、灰尘等等,然后反射出来的散射而已。但是在这里,我们需要把它单独作为一个光照模型列出来。
  • 当光照到物体表面,会发生两种情况:
  1. 漫反射(Diffuse):无论光的入射角度如何,都会向所有方向发生反射。反射光的亮度只和光线的入射角度有关,与观察角度无关。光线越平行于物体表面,则反射光越弱,表面越暗;光线越垂直于表面,反射光越强,表面越亮。漫反射是我们通常想到一个物体受到光照时需要首先想到的。
  2. 镜面反射(Specular):这就像镜子一样,反射光将按照和入射角相同的角度反射出来。这种情况下,你看到的物体反射出来的光的亮度,取决于你的眼睛和光反射的方向是否在同一直线上;也就是说,反射光的亮度不仅与光线的入射角有关,还与你的视线和物体表面之间的角度有关。镜面反射通常会造成物体表面上的“闪烁”和“高光”现象,镜面反射的强度也与物体的材质有关,无光泽的木材很少会有镜面反射发生,而高光泽的金属则会有大量镜面反射。

冯氏反射模型引申了这个四步走的光照系统,首先所有的光线都有以下两个属性:

  1. 发生漫反射光的RBG值。
  2. 发生镜面反射光的RGB值。

其次所有材质都有以下四个属性

  1. 反射的环境光RGB值
  2. 反射的漫反射光RGB值
  3. 反射的镜面反射光RGB值
  4. 物体的反光度,它决定了镜面反射的细节

对于场景中的每一点,它的颜色都是由照射光的颜色、材质本身的颜色和光照效果混合起来的。所以,根据冯氏反射模型,为了解决场景中的光照,每条光线我们都需要知道两个属性,每个物体表面上的点都需要4个属性。环境光应当是自然的,而不是特定的光线,但我们依然需要找到一种方法来储存整个场景中的环境光;有时可以用最简单的方法,就是为每个光源设置一个环境等级,然后把它们都放到一个单一项中。

好了,我们有了以上的预备知识,我们就能计算出环境光、平行光和镜面反射光照在任何一个点上的颜色,然后再把它们组合到一起,就得到了最后的颜色值。下面这幅图清晰的解释了我们的工作原理。而我们所有的着色器需要做的就是分别计算出在环境光、漫反射光和镜面反射光下每个顶点的红、绿、蓝的颜色,然后组成RGB值,再组合到一起,最后输入结果。

在这节课中,我们先搞的简单一些。我们只会考虑漫反射和环境光,而忽略镜面反射。我们将会继续使用上一节课中绘制的贴图的立方体,并且用纹理的颜色来计算漫反射和环境光。最后,我们只会考虑一种最简单的漫反射光,那就是平行光。下面我用图表来解释一下。

从一个方向上来的光可以分为两种。一种是简单的平行光,来自于同一个方向的平行光束穿越整个场景。另一种是点光源,来源于场景内的一个点发出的光线,也就是说每个地方的光线角度都不一样。

对于简单的平行光来说,当光线打到物体表面的顶点上(图中的A点和B点),入射角永远都是相同的。想一下太阳光,光线都是平行的。

相反,对于点光源,A点和B点的入射角是不同的。A点差不多是45°,而B点则接近0°,也就是说B点的入射光线垂直于物体表面。

这也就意味着对于点光源,我们需要为每个顶点都计算出各自不同的光线入射角度;然而对于平行光,我们只需要使用一个固定的角度。这就使得点光源变得有一些复杂,所以这节课中我们只会处理平行光。在以后的课程中我们再来学习点光源,不过即使你自己研究一下应该也是很容易搞清楚的。

这样我们就把问题精炼了。我们知道我们场景中的所有光线都会来自于一个固定的方向,而且这个方向对于每个顶点来说都是不会改变的。也就是说我们可以把它放到uniform变量中,然后提供给着色器来调用。我们同样知道每个顶点上的光照效果取决于光线的入射角度,所以我们需要找到一个可以代表物体表面朝向的东西。对于3D几何体,最好的办法就是指定顶点所在表面的法线向量,这个向量允许我们用3组数字表示出物体表面的朝向。(在二维世界中我们可以同样使用切线来达到这一目的,但是在三维世界中,切线的垂线是指向两个方向的,所以我们要用两个向量来表示它,而表示法线我们使用用一个向量就可以了。)

除了法线之外,在像着色器写入代码之前我们还需要最后一样东西。我们指定了顶点平面的法线向量,还有用来表示光照方向的向量,我们还需要计算出物体表面漫反射了多少光。这与这两个向量之间角度的余弦值成正比。当法线向量与光照方向向量的夹角是0度的时候(也就是说,光线完全照射到物体表面,光线方向90°垂直于物体表面),我们可以看做物体反射了所有的光;当夹角为90度的时候,没有任何光线被反射;当夹角处于0到90度之间时,应当符合余弦曲线。(如果当角度大于90度是,根据我们的理论会得出一个负值的反射光,这显然是很扯淡的,所以对此我们设其为余弦值或0,两者无论哪个都比负值要大。)

幸运的是,计算这两个向量夹角的余弦值并不是什么复杂的计算,如果它们两者的长度都是1,那我们只要使用这两个向量的点积即可。更幸运的是,点积运算是内置于着色器的,我们只要使用这个叫做dot的函数即可。

哇!还没开始我们就讲了这么一大堆理论,现在列一下我们要做的事情的清单:

  • 为每一个顶点指定一个法线向量
  • 指定光线的方向向量
  • 在顶点着色器中计算法线向量和光线方向向量的点积,然后计算出相应的颜色值,同时加入环境光的分量。

现在让我们来看看代码吧。我们将从下至上的来讲解。首先,显然最下面的HTML部分的代码发生了变化,因为我们增加了一些新的输入框。但是我不想在这部分在多费口舌。让我们往上瞧瞧,来看看Javascript部分。先来看看initBuffers函数,在建立顶点位置数组和纹理坐标数组的代码中间,你会看到我们建立法线向量数组的代码。现在你应该对这种形式的代码非常熟悉了吧。

    cubeVertexNormalBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
    var vertexNormals = [
      // Front face
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,

      // Back face
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,

      // Top face
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,

      // Bottom face
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,

      // Right face
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,

      // Left face
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
    ];
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexNormals), gl.STATIC_DRAW);
    cubeVertexNormalBuffer.itemSize = 3;
    cubeVertexNormalBuffer.numItems = 24;

真是够简单的!代码的下一处变化位于drawScene函数中,我们把法线向量数组绑定到相应的着色器的属性中。

    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, cubeVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);

在这之后,还在drawScene函数中,我们移除了上一课中切换纹理过滤方式的代码,这里我们只使用一种纹理过滤方式。

    gl.bindTexture(gl.TEXTURE_2D, crateTexture);

接下来有点麻烦。首先我们需要先检测一下“lighting”复选框是否被选中,并通过设置一个uniform变量来告诉着色器。

    var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(shaderProgram.useLightingUniform, lighting);

然后,如果lighting复选框被选中,我们读出用户在输入框中键入的环境光的红、绿、蓝的颜色值,并传递给着色器。

    if (lighting) {
      gl.uniform3f(
        shaderProgram.ambientColorUniform,
        parseFloat(document.getElementById("ambientR").value),
        parseFloat(document.getElementById("ambientG").value),
        parseFloat(document.getElementById("ambientB").value)
      );

然后我们要传递光线方向给着色器:

      var lightingDirection = [
        parseFloat(document.getElementById("lightDirectionX").value),
        parseFloat(document.getElementById("lightDirectionY").value),
        parseFloat(document.getElementById("lightDirectionZ").value)
      ];
      var adjustedLD = vec3.create();
      vec3.normalize(lightingDirection, adjustedLD);
      vec3.scale(adjustedLD, -1);
      gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD);

你会发现在传递给着色器之前,我们对光线方向向量做出了一些调整。我们使用了okVec3,和okMat4一样,这都是Oak3D对于数学概念的封装。然后,我们先执行了lightingDirection.normalize,将其长度调整为1。你应该还记得两个长度为1的向量之间的夹角的余弦值等于它们的点积,所以法线向量和光线方向向量的长度都应该为1。我们之前定义的法线向量已经将长度设置为1了,但是光线方向是由用户来自定义的,而且对于用户来说让他们自己去将光线方向向量调整为1然后再输入恐怕是不太现实的,所以我们这里需要做一个转换。然后我们将光线方向向量乘以一个标量-1,用于调转向量的方向。这是因为我们要求用户指定的是光线射出的方向,而我们之前讨论的算法中需要的是光线射入的方向。完成后,我们用gl.uniform3fv函数将它传递给着色器,它将一个vec3函数处理过的含有3个元素的Float32Array放入到一个uniform变量中。

接下来的代码就要简单多了,只是将平行光的颜色分量传送到相应的着色器uniform变量中。

      gl.uniform3f(
        shaderProgram.directionalColorUniform,
        parseFloat(document.getElementById("directionalR").value),
        parseFloat(document.getElementById("directionalG").value),
        parseFloat(document.getElementById("directionalB").value)
      );
    }

这就是全部drawScene函数的变化的代码。移动到处理键盘输入的代码部分,在这里我们移除了使用F键切换纹理过滤方式的代码。接下来比较有趣的变动位于setMatrixUniforms函数中,提到这个函数你应该会想起将模型视图矩阵和投影矩阵拷贝并传递给着色器的uniform变量。在这儿我们增加了4行代码用于传递一个新的基于模型视图的矩阵:

    var normalMatrix = mat3.create();
    mat4.toInverseMat3(mvMatrix, normalMatrix);
    mat3.transpose(normalMatrix);
    gl.uniformMatrix3fv(shaderProgram.nMatrixUniform, false, normalMatrix);
 

和你猜的一样,这个名叫normalMatrix的矩阵就是用来转换法线向量的。我们用规则的模型视图矩阵来转换顶点位置,但是我们不能用同样的方式来转换法线向量。这是因为法线向量会随着我们的平移和旋转发生变化。比如说,如果我们忽略旋转并且假设做了一个(0,0,-5)的平移,那么法线向量(0,0,1)就会变成(0,0,-4),这不仅长度不对而且根本就指向了错误的方向!我们或许可以解决这个问题。你应该注意到了在顶点着色器中,当我们要把一个含有3个元素的顶点位置数组乘以一个4×4模型视图矩阵的时候,为了使两者相匹配,我们扩充了顶点位置数组,在它的末尾加了一个1。这个1不仅仅是用来填充数组,还可以使平移、投影等空间变换应用于矩阵变换之上。所以这里也许我们可以为法线向量的末尾加上一个0而不是1,这样就可以忽略掉那些变换。目前来看,这样我们可以很完美的解决问题。但是不幸的是,当模型视图矩阵包含不同的空间变换时,尤其是缩放和扭曲时,它将不再有用。比如说,如果模型视图矩阵将我们要绘制的物体放到两倍大小,那法线向量也会被拉伸,即使我们在末尾加了0。这会导致严重的光照错误。所以,为了避免养成坏习惯,我们还是不能作弊,要走正道啊!

让法线向量永远指向正确方向的正规解决方法是,使用模型视图矩阵的左上角3×3矩阵的逆转置矩阵,这样可以去掉矩阵中非正交的因素。形象的说, 就是只保留旋转,而不能对向量做缩放和移动,否则会这会改变向量的方向,这并不是变换矩阵想要做的。

不管怎么样,当我们计算完毕这个矩阵,就可以像其他矩阵一样传递给着色器了。

向上移动一下光标,在载入纹理的代码部分也发生了一些细微的变化,我们只按照mipmap的方式载入了一次纹理,而不是上节课中的3次。另外在initShaders函数中,我们初始化了vertexNormalAttribute属性以供drawScene函数调用。然后类似的,我们处理了所有新引入的uniform变量。这些都不值得细说,让我们赶紧直接跳到着色器部分吧!

  precision mediump float;

  varying vec2 vTextureCoord;
  varying vec3 vLightWeighting;

  uniform sampler2D uSampler;

  void main(void) {
     vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
     gl_FragColor = vec4(textureColor.rgb * vLightWeighting, textureColor.a);
  }

你会发现,我们和第六课中一样从纹理中提取了颜色信息,但是在返回的时候我们使用一个叫做vLightWeighting的Varying变量调整了它的R、G、B值。vLightWeighting是一个含有3个元素的向量,用来储存经过顶点着色器计算过的光照的红、绿、蓝的颜色值。

那顶点着色器是如何计算的呢?让我们看一下顶点着色器的代码。

  attribute vec3 aVertexPosition;
  attribute vec3 aVertexNormal;
  attribute vec2 aTextureCoord;

  uniform mat4 uMVMatrix;
  uniform mat4 uPMatrix;
  uniform mat3 uNMatrix;

  uniform vec3 uAmbientColor;

  uniform vec3 uLightingDirection;
  uniform vec3 uDirectionalColor;

  uniform bool uUseLighting;

  varying vec2 vTextureCoord;
  varying vec3 vLightWeighting;

  void main(void) {
    gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
    vTextureCoord = aTextureCoord;

    if (!uUseLighting) {
      vLightWeighting = vec3(1.0, 1.0, 1.0);
    } else {
      vec3 transformedNormal = uNMatrix * aVertexNormal;
      float directionalLightWeighting = max(dot(transformedNormal, uLightingDirection), 0.0);
      vLightWeighting = uAmbientColor + uDirectionalColor * directionalLightWeighting;
    }
  }

新的属性aVertexNormal当然是用来储存我们在initBuffers函数中指定并且在drawScene函数中传递给着色器的顶点法线。uNMatrix就是我们的法线矩阵。uUseLighting是用来指定是否开启光照的uniform变量。uAmbientColor、uDirectionalColor和uLightingDirection显然都是用于储存用户在网页上输入的各种值的。

在数学之光的照耀下,我们遍历了整个代码,实际上应当是很容易理解的。着色器主要输出的就是varying变量vLightWeighting,用于在片元着色器中调整图像的颜色。如果光照被关闭,我们就使用默认值(1,1,1),意思是说物体颜色将不会被改变。如果光照开启,我们使用法线矩阵计算出法线方向,然后计算它与光线方向向量的点积,用于表示反射了多少光(当然,最小值是0,就像我们之前说的)。最终我们用平行光的颜色分量乘以这个反射了多少光的量得出最终的光亮程度,然后加上环境光的颜色。而运算结果正是片元着色器需要的。到此,我们完成了光照!

在本节课中,你已经对于理解在像WebGL这种图形系统中光线是如何工作的有了扎实的基础,并且知道了如何设置两种简单的光——环境光和平行光。

如果你有任何问题、评论或者修正,都请留下评论告诉我!

下一节课,我们将来研究一下混合,它将用于绘制部分透明的物体。

WebGL中文教程:第6课,键盘输入和纹理过滤

 欢迎来到WebGL教程的第六课,这节课的内容是基于NeHe OpenGL教程的第七节改编的。在这节课上,我们将会介绍如何在WeblGL页面上实现键盘输入。我们将用键盘输入来控制贴上纹理贴图的立方体的旋转方向和旋转速度;并且,我们还可以改变纹理过滤的方式,你可以选择加载速度快但图像质量低,或者加载速度慢但图像质量高的表现形式。在NeHe的OpenGL教程的第七节中,不光介绍了这些,还包括光线;因为光线在WebGL中比OpenGL有更多的作用,所以我在这节课上先不讲,以后拿出来单独讲。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

加载完成后,你可以使用Page Up 和 Page Down键来进行缩放;你还可以用方向键来改变立方体的旋转方向(按下的时间越长,旋转的速度越快)。你还可以点击F键在三个不同的纹理过滤之间切换。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

这节课和之前最大的不同在于,我们需要把重心转到键盘上,我们先来看一看代码,这样的话,比较容易理解我们的课程。在示例代码中,你会发现我们定义了如下的全局变量:

  var xRot = 0;
  var xSpeed = 0;

  var yRot = 0;
  var ySpeed = 0;

  var z = -5.0;

  var filter = 0;

在第五课上,我们已经对xRot和yRot很熟悉了,他们代表立方体沿X轴和Y轴的旋转。xSpeed和ySpeed就很明显了,我们将允许用户通过方向键来改变立方体的旋转速度,而对xRot和yRot变量的改变速率就需要储存在xSpeed和ySpeed中。z是立方体的z轴,就是它离浏览者的距离,是通过Page Up 和Page Down控制的。最后,filter是一个从0到2之间的整数,它指明了我们在立方体上覆盖的纹理的过滤方式,这些过滤方式决定了纹理图像的图形质量如何。

我们来看一下驱动纹理过滤的代码。第一处的改变在于加载纹理的代码,这段代码位于示例代码自上到下三分之一处。这一部分代码和以前有很大不同,因此,我就不标红任何东西了。但是,你们应该还是十分熟悉这样的代码形式。

  function handleLoadedTexture(textures) {
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

    gl.bindTexture(gl.TEXTURE_2D, textures[0]);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[0].image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

    gl.bindTexture(gl.TEXTURE_2D, textures[1]);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[1].image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);

    gl.bindTexture(gl.TEXTURE_2D, textures[2]);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[2].image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
    gl.generateMipmap(gl.TEXTURE_2D);

    gl.bindTexture(gl.TEXTURE_2D, null);
  }

  var crateTextures = Array();

  function initTexture() {
    var crateImage = new Image();

    for (var i=0; i < 3; i++) {
      var texture = gl.createTexture();
      texture.image = crateImage;
      crateTextures.push(texture);
    }

    crateImage.onload = function() {
      handleLoadedTexture(crateTextures)
    }
    crateImage.src = "crate.gif";
  }

我们首先来看一下函数initTexture 和全局变量crateTextures。我们很明显可以看出,虽然代码已经发生变化,但是代码中根本的不同在于我们创建了3个WebGL纹理对象数组,而不是一个;并且,当图像被加载时,我们通过回调函数将这个数组传递到handleLoadedTexture。当然,我们需要加载一个新的图像,crate.gif而不是nehe.gif。

handleLoadedTexture 也并没有发生任何复杂的变化。之前,我们只是在初始化了一个带有图像数据的WebGL纹理物体,并且设置了两个参数gl.TEXTURE_MAG_FILTER 和gl.TEXTURE_MIN_FILTER ,他们都被设置为 gl.NEAREST。现在,我们要初始化数组中的3个带有相同图像的纹理,但是各自的参数是不同的,并且最后一个纹理还附加了一些其他的代码。以下就是这些纹理过滤方式具体是在哪些方面不同的。

Nearest(最近点采样过滤)

第一个纹理的gl.TEXTURE_MAG_FILTER 和gl.TEXTURE_MIN_FILTER参数都被设置为gl.NEAREST。这个和我们原来的设置是一样的,也就是说,当纹理被按比例放大或者缩小时,WebGL会在原始图像上寻找最近的点来决定指定点的颜色。在没有缩放的情况下,纹理看其来还是不错的;缩小后,图像看起来还过得去;但是纹理图像被放大时,看起来会有很多“马赛克”,因为这种算法实际上只是简单地放大了原始图像的像素,并没有做其他任何优化。

Linear(线性过滤)

对于第二个纹理来说,gl.TEXTURE_MAG_FILTER 和gl.TEXTURE_MIN_FILTER参数都被设置为gl.LINEAR。这里,我们在放大缩小时还是使用同样的过滤方式。但是,线性算法能够在纹理被放大时更好的表现物体;基本可以说,它对原始纹理图像上的像素进行了线性插值。说的再大概一下,在一个白的和一个黑的像素之间的像素会被输出为灰色。这样的话,我们看到的画面效果就更加平滑,但是必然原本锐利的边缘部分会看起来有点模糊。(公平的说,放大图像时,图像看起来都不会那么完美,因为你无法看到原始图像中本来就没有的细节。)

Mipmaps(多级渐进纹理过滤)

对于第三个纹理来说,gl.TEXTURE_MAG_FILTER参数被设置为gl.LINEAR,而gl.TEXTURE_MIN_FILTER被设置为gl.LINEAR_MIPMAP_NEAREST。这个是三种纹理过滤方式中最复杂的一个。

Linear过滤在图像被放大时,给出了比较好的效果,但是在图像被缩小时,它的表现不如Nearest过滤。事实上,两种过滤方法都会产生难看的锯齿效果。要想看一看效果是什么样的,请重新加载示例图像,这样它使用的就是Nearest(或者,点击刷新键,你就能看到它原来的状态);然后,按住Page Up键一会,来缩小图像。当立方体产生改变时,在某些点上,你会发现图像产生了“扭曲”,垂直的线条看起来时有时无。当你观察到这种现象时,稍微放大或缩小一下图像,观察一下扭曲的程度,接着按F键切换到Linear过滤,把立方体拉前推后,你会发现产生的扭曲效果和之前一样。再按一下F键,使用Mipmaps过滤,再次放大和缩小图像,你会发现,扭曲效果被消除了,至少是减少了。

当立方体离我们比较远时,比如在宽度和高度是Canvas画布的十分之一时,请让它在这个位置旋转然后切换纹理过滤方式。当使用Nearest过滤或者Linear过滤时,你会发现,在某些地方组成木质纹理的暗色线条十分清晰,然而在另外一些地方,这些线条缺消失了,图像看起来污渍斑斑的。这种情况在使用Nearest过滤时,十分严重,在使用Linear 过滤时也好不到哪儿去。只有在使用Mipmaps过滤时,才看的过去。

Nearest过滤和Linear过滤发生问题的原因是,当纹理被缩小为原来十分之一的尺寸时,纹理过滤将在原始图像的每十个像素中,使用一个来拼凑成缩小的纹理图像。实例中纹理是一个木质颗粒的图案,纹理的整体是浅棕色的,并且在垂直方向上有一些暗色的细线条。假设每个颗粒是10个像素大小,或者换句话说,水平方向上每十个像素就有一个暗棕色像素。当纹理图像被缩小为原图十分之一时,任何一个像素都会有十分之一的几率变成暗棕色,而十分之九的几率是光亮的。换句话说,只有十分之一的暗色的线条看起来会和原始尺寸的纹理图像一样清晰,其他的则都被隐藏了。这样就造成了看起来斑斑点点的效果,并且当纹理图像被放大缩小时,会产生扭曲。

我们需要做的是,在我们需要将纹理图像缩放为原来十分之一时候和场合,缩小后的图像中的每个像素的颜色,都根据一个10×10像素的像素块的平均值来计算。但是这种处理方式在真实使用时会耗费大量计算开销,因此,我们就有了Mipmap过滤。

Mipmaps过滤通过为纹理图像生成许多被称为mip level的子图像的方法解决了这个问题。这些图像分别是原图尺寸大小、四分之一大小、十六分之一大小……直到1×1像素大小。所有这些子图像的集合被称为mipmap。每一个mip level都是上一级大一点的mip level的平均值,这样,就很容易为当前缩放规格找出合适的图像版本:这一算法依赖于gl.TEXTURE_MIN_FILTER的值, 它所做的就是根据当前缩放规格,找到最合适的mip level,然后运用Linear过滤获得适合的像素。

这样就解释的很清楚了。另外,我们给第三个纹理加上了一行代码

    gl.generateMipmap(gl.TEXTURE_2D);

用来告诉WebGL为当前活动纹理生成对应的一系列MipMap层。

显然,对于Mipmaps我已经讲了非常多了,应当很清楚了,如果还有什么不理解的地方,请留下评论联系我。

让我们再次回到代码中。目前,我们已经看过了全局变量,并且了解了纹理是如何被加载和设置的。现在,我们来看一下全局变量和纹理在实际场景绘制中的应用。

drawScene函数位于示例代码的三分之一处,这里只有三处变化。第一处是我们在绘制立方体时,我们使用的是全局变量z,而不是一个固定的点:

    mat4.translate(mvMatrix, [0.0, 0.0, z]);

第二个变化是我们移除了第五课的一行代码,我们不让立方体围绕Z轴转动了,而是只围绕X轴和Y轴转动:

    mat4.rotate(mvMatrix, degToRad(xRot), [1, 0, 0]);
    mat4.rotate(mvMatrix, degToRad(yRot), [0, 1, 0]);

最后,我们要开始绘制立方体了,我们需要指明我们使用的那三个纹理。

    gl.bindTexture(gl.TEXTURE_2D, crateTextures[filter]);

这些就是drawScene函数中的所有变化。另外在animate中也有一些小的变化,除了改变xRot和yRot的恒定速率,我们还加上了xSpeed 和ySpeed的相关变量:

      xRot += (xSpeed * elapsed) / 1000.0;
      yRot += (ySpeed * elapsed) / 1000.0;

这就是图形相关代码中发生的所有变化,但是不包括那些控制用户按键和更新全局变量的代码。下来我们要讲的就是这些。

出现的第一个相关的变化就在下面,在webGLStart函数中,我们加入了第418行和第419行这两行新的代码。

  function webGLStart() {
    var canvas = document.getElementById("lesson06-canvas");
    initGL(canvas);
    initShaders();
    initBuffers();
    initTexture();

    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.enable(gl.DEPTH_TEST);

    document.onkeydown = handleKeyDown;
    document.onkeyup = handleKeyUp;

    tick();
  }

我们很明显可以看出,我们在这里告诉JavaScript,当一个按键被按下时,我们希望名为handleKeyDown的函数被调用,当按键被释放时,函数handleKeyUp被调用。

接着我们看一下这些函数。他们位于示例代码的中间位置,就在我们先前看过的全局变量的下面。他们长这样:

  var currentlyPressedKeys = {};

  function handleKeyDown(event) {
    currentlyPressedKeys[event.keyCode] = true;

    if (String.fromCharCode(event.keyCode) == "F") {
      filter += 1;
      if (filter == 3) {
        filter = 0;
      }
    }
  }

  function handleKeyUp(event) {
    currentlyPressedKeys[event.keyCode] = false;
  }

在这里我们维护了一个字典,可能你也知道它叫做哈希表(hashtable)或关联数组(associative array)。这个字典用来保存按键代码——键盘上的按键在JavaScript中的数字标示符——能告诉我们按键是否正被用户按下。如果你并不熟悉JavaScript 是如何运作,你会发现,任何对象都能够作为一个单独的字典来使用; 我们用来初始化currentlyPressedKey的语法看起来像一个Python字典,实际上它仅仅是一个空的基础对象类型的实例。

除了这个之外,我们在处理按键被按下的事件中还增加了另外一些东西,这些东西是为当F键被按下时所准备的。在这段代码中,每次按下F键时,filter全局变量的值都会在0,1,2 这三个数字之间循环改变。

这里很值得花些时间讲一讲为什么我们用两种不同的方法处理不同的按键。在电脑游戏中,或者在其他类似的3D系统中,按键能以以下两种形式工作:

一种是立即做出反应。比如发射激光枪!按键后,按某种固定速率自动重复,比如每秒发射两次。

另一种是根据按键时间长短期发生作用。例如,按方向键向前走,直到松手之后,才会停下。

对于第二种按键方式来说,当你按住一个键时,你可能还会想按其他的键,这样你就能比如说向前跑、转弯,或者在移动中射击。这样的话,和通常处理文字时的按键解读方式不同,这是一种完全不同的按键解读方法。例如,你在一个文字处理器中按住A键,会出现一长串的A,但是当你按住A的同时时按了B,B会出现,但是一长串A也会停止。如果同样的事情发生在游戏中,你在跑步或者转弯时,就会受阻。这很显然是非常让人厌恶的。

所以,我们这里只需要将F键按照第一种按键方式处理。字典将在处理第二种按键方式的代码中使用,它会不断记录过程中同时按下的所有按键,而不只是最后一个被按下的键。

这个字典实际上会被各种不同的函数调用,比如handleKeys。在这之前,请先跳到代码末尾处,我们会看到tick函数也调用了它,就像drawScene和animate一样:

  function tick() {
    requestAnimFrame(tick);
    handleKeys();
    drawScene();
    animate();
  }

代码是这个样子滴:

  function handleKeys() {
    if (currentlyPressedKeys[33]) {
      // Page Up
      z -= 0.05;
    }
    if (currentlyPressedKeys[34]) {
      // Page Down
      z += 0.05;
    }
    if (currentlyPressedKeys[37]) {
      // Left cursor key
      ySpeed -= 1;
    }
    if (currentlyPressedKeys[39]) {
      // Right cursor key
      ySpeed += 1;
    }
    if (currentlyPressedKeys[38]) {
      // Up cursor key
      xSpeed -= 1;
    }
    if (currentlyPressedKeys[40]) {
      // Down cursor key
      xSpeed += 1;
    }
  }

这段代码虽然长,但是相对简单,它所做的就是检查是否按键被按下,并且在适当的时候更新全局变量。最重要的,当方向键“上”和“右”同时被按下,他会立即更新xSpeed和ySpeed,从而得到我们需要的效果。

好了,以上就是本节课的全部内容。在本节课中,你学到了各种不同的纹理过滤方式在处理缩放时的不同,并且还学会了如何在3D动画中读取用户的键盘输入。

如果你有任何的问题、评论或者修正,都请留下评论。在下节课中,我们将会开始加入光线。

WebGL中文教程:第5课,引入纹理贴图

 欢迎来到Lesson 5,本节课是基于NeHe的OpenGL教程的第六课改编的。这节课,我们将会给3D物体加入纹理贴图——也就是说我们会载入一个独立的图片文件来覆盖3D物体。这对于增加3D场景的细节非常有用,你不必绘制非常非常复杂的单个物体。比如说在一个迷宫游戏中有一栋石头墙,你不必为每个石块都制作单独的模型,而只需要将整栋墙体都做成一个模型,然后用一张石头的图片来覆盖到墙上就可以了。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;你也可以点击这里,下载我们为您打包好的压缩包。

简单来说,纹理贴图的原理就是用特殊的方法来设置3D物体中某个点的颜色。你应该会记得在第二课中,我们讲过颜色是由片元着色器指定的,所以我们需要载入图片然后将它输送到片元着色器。另外,片元着色器也需要知道当前片元应当使用纹理的哪一部分,所以我们也需要把纹理的使用位置信息, 也就是纹理坐标, 传给片元着色器。

让我们先从载入纹理的代码开始看起,首先是从页面一被载入就立即执行的webGLStart函数,第336行就是新增加的代码:

  function webGLStart() {
    var canvas = document.getElementById("lesson05-canvas");
    initGL(canvas);
    initShaders();
    initBuffers();
    initTexture();

    gl.clearColor(0.0, 0.0, 0.0, 1.0);

于是让我们看看initTexture函数,大约在页面代码的三分之一的位置,这是一个全新的函数:

  var neheTexture;
  function initTexture() {
    neheTexture = gl.createTexture();
    neheTexture.image = new Image();
    neheTexture.image.onload = function() {
      handleLoadedTexture(neheTexture)
    }

    neheTexture.image.src = "nehe.gif";
  }

我们建立了一个全局变量用来储存纹理,显然在真正的应用中会有很多纹理,所以不应该使用全局变量,但在这里我们是为了讲解更加清楚。我们使用gl.createTexture来建立了一个纹理对象,并赋值给全局变量;然后我们建立了一个Javasript图片对象(Javascript Image Object),并把它放到我们给纹理对象添加的一个新属性中,这里我们又用到了Javasript的那个优点,就是可以给任何对象添加任何属性——纹理对象本身默认并不含有一个图片属性,但是显然我们需要这样一个属性,所以我们就手动为它增加这样一个属性。下一步就应该将图片文件载入到图片对象中了,但是在这之前我们加入了一个回调函数,它在图片被完全载入后将被调用,所以我们最好先设置好。最后,我们设置好图片的src属性。完成后,图片将被异步加载——也就是说,设置图片src属性的代码将会立即执行并返回,而一个后台线程将会从服务器上将图片载入。

  function handleLoadedTexture(texture) {
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texture.image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.bindTexture(gl.TEXTURE_2D, null);
  }

首先我们必须告诉WebGL我们的纹理是“当前纹理”。和之前我们见过的bindBuffer一样,需要用bindTexture将纹理与WebGL上下文绑定,设置其为当前纹理,而不能直接作为参数使用某个纹理。

然后,我们告诉WebGL所有被载入纹理的图片都需要做一个垂直翻转。为什么要进行垂直翻转呢?这都是坐标闯的祸。对于我们的纹理坐标,我们使用的坐标系和你平常在数学课上用到的是相同的,即在垂直坐标轴上,越往上坐标值越大;这与我们一直用来指定顶点位置的X、Y、Z轴的坐标系统也是一致的。但是,在其他大多数计算机图形系统中情况正好相反,在垂直坐标轴上,越往下坐标值反而越大,就比如我们用来储存纹理的GIF格式图片。这种垂直坐标轴上的差异意味着在WebGL的透视中,我们使用的GIF图片实际上已经被翻转过了,所以我们需要翻转回来,或许可以称之为“逆翻转”。

下一步我们就要使用texImage2D方法,将刚刚被载入还冒着热气的新出炉的图片上传到显卡端的纹理空间中。函数的参数按顺序分别是,图片类型、细节层次(我们以后的课程中会详细讲解)、图片各通道的大小(也就是用于储存R、G、B值的数据类型)、最后是图片本身。

紧接着的下面两行的代码是用于指定纹理的特殊缩放参数的。第一行代码告诉WebGL当纹理被填充到一个相对于图片尺寸较大的屏幕空间时应当怎么做,换句话说,就是告诉WebGL如何放大纹理。同样,第二行代码则告诉WebGL如何缩小纹理。有很多种缩放方式供你选择,而NEAREST是其中最不酷的一种,它用来指定说无论如何都只使用原始图片,也就是说原始图片什么样,纹理就什么样,所以当你近距离观看时,将会看到非常斑驳的纹理。然而,这也有它的好处,那就是执行速度非常快,即使在非常慢的电脑上。在下一节课中,我们会使用到其他的缩放方式,到时你将体会到它们在效果和性能上的不同。

完成之后,我们将当前纹理设置为null,严格来讲这不是必须的,但这样的一个清理工作却是一个好的编程习惯。

以上就是载入纹理所需的所有代码。下面,我们把注意力转移到initBuffers上。首先显而易见的是,我们把在第四课中用到的所有与锥形有关的代码都移除掉了;另外,我们用一个新的数组——纹理坐标数组——替换了立方体的顶点颜色数组。

    cubeVertexTextureCoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexTextureCoordBuffer);
    var textureCoords = [
      // Front face
      0.0, 0.0,
      1.0, 0.0,
      1.0, 1.0,
      0.0, 1.0,

      // Back face
      1.0, 0.0,
      1.0, 1.0,
      0.0, 1.0,
      0.0, 0.0,

      // Top face
      0.0, 1.0,
      0.0, 0.0,
      1.0, 0.0,
      1.0, 1.0,

      // Bottom face
      1.0, 1.0,
      0.0, 1.0,
      0.0, 0.0,
      1.0, 0.0,

      // Right face
      1.0, 0.0,
      1.0, 1.0,
      0.0, 1.0,
      0.0, 0.0,

      // Left face
      0.0, 0.0,
      1.0, 0.0,
      1.0, 1.0,
      0.0, 1.0,
    ];
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoords), gl.STATIC_DRAW);
    cubeVertexTextureCoordBuffer.itemSize = 2;
    cubeVertexTextureCoordBuffer.numItems = 24;

现在再来看这样的代码,你应该感到非常轻松了。我们所做的就是在每个数组对象中增加了一个新的顶点属性,每个顶点上这个属性有两个值。纹理坐标指定的是在笛卡尔x,y坐标系中顶点位于纹理中的位置。为了实现纹理坐标,我们把纹理的宽和高都看成1.0,这样(0,0)就是左下角,(1,1)就是右上角。而从这样的假想转换到纹理图片的真实分辨率的工作,都是由WebGL来完成的。

以上就是initBuffers函数中的变化,下面我们来看看drawScene函数。不言自明,函数最大的变化就是加入了使用纹理的代码。在这之前我们先来处理一下那些琐碎的小变动,例如我们移除了和锥形有关的代码,另外立方体旋转的方向也发生了变化。想必你已经可以轻松理解这些代码了,所以我也不再花时间详细讲解了。

  var xRot = 0;
  var yRot = 0;
  var zRot = 0;
  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);

    mat4.identity(mvMatrix);

    mat4.translate(mvMatrix, [0.0, 0.0, -5.0]);

    mat4.rotate(mvMatrix, degToRad(xRot), [1, 0, 0]);
    mat4.rotate(mvMatrix, degToRad(yRot), [0, 1, 0]);
    mat4.rotate(mvMatrix, degToRad(zRot), [0, 0, 1]);

    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, cubeVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

另外在animate函数中也有相应的修改用来配合xRot、yRot和zRot,这我也不会多讲。

好了,终于是时候来看看纹理相关的代码了。在initBuffers函数中我们建立了包含纹理坐标的数组对象,现在我们需要将它绑定到合适的属性中,以便着色器可以调用它。

    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, cubeVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

那么,现在WebGL已经知道各个顶点该使用纹理的哪个点了,下面我们需要告诉WebGL使用我们之前载入的纹理来绘制立方体。

    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, neheTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cubeVertexIndexBuffer);
    setMatrixUniforms();
    gl.drawElements(gl.TRIANGLES, cubeVertexIndexBuffer.numItems, gl.UNSIGNED_SHORT, 0);

这段代码有点复杂。WebGL可以在调用像gl.drawElements这种函数中处理最多32个纹理对象,从TEXTURE0到TEXTURE31,这些纹理被一一标记。前两行代码所做的就是将我们刚刚载入的纹理指定为0号纹理,在第三行代码中我们将0这个值推送到着色器的uniform变量中(和其他我们用于处理矩阵的uniform变量一样,都是从initShaders函数中的着色器program对象中提取出来的),告诉着色器我们要使用0号纹理。

不管怎样,现在我们已经可以说是蓄势待发了。我们用相同的代码来绘制一堆三角形最后组成一个立方体。

最后剩下一件事,就是要解释一下着色器的变化。让我们先来看看顶点着色器。

  attribute vec3 aVertexPosition;
  attribute vec2 aTextureCoord;

  uniform mat4 uMVMatrix;
  uniform mat4 uPMatrix;

  varying vec2 vTextureCoord;

  void main(void) {
    gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
    vTextureCoord = aTextureCoord;
  }

这和我们在第二课中给顶点着色器填充一些与颜色相关的东西非常相似,我们所做的就是将纹理坐标设置为顶点属性,然后以Varying变量的形式直接从顶点着色器中传出。

当每个顶点都设置完毕后,WebGL都会将顶点与顶点之间的片元(基本上可以理解为像素)进行线性插值,就像第二课中处理颜色一样。所以,在纹理坐标为(1,0)和(0,0)中间的片元会得到一个(0.5,0)的纹理坐标,在纹理坐标为(0,0)和(1,1)之间的片元会得到一个(0.5,0.5)的纹理坐标。然后在片元着色器中:

  precision mediump float;
  varying vec2 vTextureCoord;
 uniform sampler2D uSampler;
  void main(void) {
    gl_FragColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
  }

我们获取到了线性插值后的纹理坐标,并且以sampler类型的形式储存在变量中,它在着色器中代表纹理。在drawScene函数中,我们的纹理与gl.TEXTURE0绑定在一起,而uniform变量uSampler的值是0,所以这个sampler变量代表的正是我们的纹理。着色器所做的就是调用texture2D,并根据坐标从纹理中获得相对应的颜色。另外,纹理坐标通常使用s和t来表示,而不是x和y;不过别担心,着色器语言支持别名,所以我们可以依然可以方便的使用vTextureCoord.x和 vTextureCoord.y。

在片元获得颜色之后,我们就成功地在屏幕上绘制出了一个带纹理贴图的物体。

好了,在这节课中你学到了如何在WebGL中为3D物体增加纹理,如何载入图片并作为纹理来使用它,如何为物体赋予纹理坐标,以及在着色器中使用纹理和纹理坐标。

如果你有任何的问题、评论或是纠正,都请留言给我。

在下一课中,我们将会用Javascript为你的3D场景增加基本的键盘输入,以便制作出可以与人互动的网页。我们将会允许观看者改变立方体的旋转、缩放以及调整WebGL缩放纹理的方式。