月度归档:2014年12月

拒绝流氓!百度安全卫士BaiduProtect.exe的彻底卸载删除方法

最近电脑三天两头被流氓软件骚扰,上周末从公司走的时候忘关机了,结果周一来到公司就发现电脑被安装了“百度安全卫士”,我记得我两个月前曾经卸载过它,没想到它还真顽固!

首先我到控制面板里选择了卸载删除,然后查看进程,居然“BaiduProtect.exe”还在,而且无法杀掉,哎,无奈重启。重启后发现“BaiduProtect.exe”还是依然存在,依然无法杀掉,而且还多了个小兄弟叫“bddownloader.exe”,我了个去,这不是要把“百度安全卫士”这只流氓再请回来的节奏嘛,哎。。。

查看“BaiduProtect.exe”的位置(C:\\Program Files\Common Files\Baidu\BaiduProtect1.*),居然和“那个百度安全卫士”不是一个软件,它叫“百度安全组件”,而且在控制面板未提供任何卸载程序!无奈在文件夹内把它卸载了。。。10年前杀毒软件收费,现在不光免费了,而且还强制登门“保护”,愿意只有一个,争夺用户,窃取隐私,搞他们的bigdata!

修复“briefly unavailable for scheduled maintenance. check back in a minute”报错

刚才更新wordpress的时候,不小心关了浏览器,再打开就出现了“briefly unavailable for scheduled maintenance. check back in a minute”这个错误,查了一下原来是wordpress在更新的时候会在程序根目录生成一个“.maintenance”文件,打开文件一看,原来是定义了一个时间戳变量,我觉得这个应该是wordpress用来锁住整个网站,为了防止更新中途中断,而用一个时间戳来判断是否更新超时,果然等了10分钟吧,再次刷新博客就自动可以运行了,进入后台提示继续更新,很不错的功能~

HTML4默认CSS样式标准

一下内容摘自w3官网,只是鼓励使用,当然不是强制标准,仅供参考

html, address,
blockquote,
body, dd, div,
dl, dt, fieldset, form,
frame, frameset,
h1, h2, h3, h4,
h5, h6, noframes,
ol, p, ul, center,
dir, hr, menu, pre   { display: block; unicode-bidi: embed }
li              { display: list-item }
head            { display: none }
table           { display: table }
tr              { display: table-row }
thead           { display: table-header-group }
tbody           { display: table-row-group }
tfoot           { display: table-footer-group }
col             { display: table-column }
colgroup        { display: table-column-group }
td, th          { display: table-cell }
caption         { display: table-caption }
th              { font-weight: bolder; text-align: center }
caption         { text-align: center }
body            { margin: 8px }
h1              { font-size: 2em; margin: .67em 0 }
h2              { font-size: 1.5em; margin: .75em 0 }
h3              { font-size: 1.17em; margin: .83em 0 }
h4, p,
blockquote, ul,
fieldset, form,
ol, dl, dir,
menu            { margin: 1.12em 0 }
h5              { font-size: .83em; margin: 1.5em 0 }
h6              { font-size: .75em; margin: 1.67em 0 }
h1, h2, h3, h4,
h5, h6, b,
strong          { font-weight: bolder }
blockquote      { margin-left: 40px; margin-right: 40px }
i, cite, em,
var, address    { font-style: italic }
pre, tt, code,
kbd, samp       { font-family: monospace }
pre             { white-space: pre }
button, textarea,
input, select   { display: inline-block }
big             { font-size: 1.17em }
small, sub, sup { font-size: .83em }
sub             { vertical-align: sub }
sup             { vertical-align: super }
table           { border-spacing: 2px; }
thead, tbody,
tfoot           { vertical-align: middle }
td, th, tr      { vertical-align: inherit }
s, strike, del  { text-decoration: line-through }
hr              { border: 1px inset }
ol, ul, dir,
menu, dd        { margin-left: 40px }
ol              { list-style-type: decimal }
ol ul, ul ol,
ul ul, ol ol    { margin-top: 0; margin-bottom: 0 }
u, ins          { text-decoration: underline }
br:before       { content: "\A"; white-space: pre-line }
center          { text-align: center }
:link, :visited { text-decoration: underline }
:focus          { outline: thin dotted invert }

/* Begin bidirectionality settings (do not change) */
BDO[DIR="ltr"]  { direction: ltr; unicode-bidi: bidi-override }
BDO[DIR="rtl"]  { direction: rtl; unicode-bidi: bidi-override }

*[DIR="ltr"]    { direction: ltr; unicode-bidi: embed }
*[DIR="rtl"]    { direction: rtl; unicode-bidi: embed }

@media print {
  h1            { page-break-before: always }
  h1, h2, h3,
  h4, h5, h6    { page-break-after: avoid }
  ul, ol, dl    { page-break-before: avoid }
}

WebGL教程:第16课,渲染到纹理

欢迎来到系列教程的第16课!在这一课中,我们将介绍一种非常有用的绘制技术: 将3D场景渲染到一张纹理中。通过这一技术,我们可以在绘制过程中利用上一次绘制的结果来创造一些特殊的效果。同时,这也是一种常用的绘制技巧,除了在一个场景中绘制另一个场景(本课将详细解释这种应用途径)外,渲染到纹理也是做选取(鼠标在3D场景中点选物体)、引用、反射等3D特效的基础技术。

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

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

在本课的演示程序中,你会看到一个综合了多种光照效果(包含笔记本屏幕上的高光)的白色笔记本模型。除了笔记本模型,你会发现一些更加有趣的东西,在笔记本的屏幕上显示了另外一个3D场景,对,没错,那是13课中我们看过的月球和木箱。这个演示程序的思路很清楚,它将13课中的3D场景渲染到了一张2D纹理上,然后将这张2D纹理映射到了笔记本模型的屏幕上。

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

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

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

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

你可以使用任何你所熟悉的文本编辑器来查看课程源码(index.html)。本课内容的文件组织与前几课有较大的不同,下面让我们按由下向上的顺序来查看代码内容。首先是webGLStart,发生变化的代码位于第789行。

  function webGLStart() {
    var canvas = document.getElementById("lesson16-canvas");
    initGL(canvas);
    initTextureFramebuffer();
    initShaders();
    initBuffers();
    initTextures();
    loadLaptop();

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

    tick();
  }

这段代码看起来并没有什么特别的地方,初始化WebGL,加载Shader,创建用于绘制的顶点Buffer,加载用于月亮和木箱的纹理,及像14课中加载茶壶模型一样,加载JSON格式的笔记本模型文件。在这中间,唯一所不同的,就是我们额外为纹理创建了一个framebuffer。在继续解释代码内容之前,让我们首先来了解一下什么是frame buffer。

当你使用WebGL API渲染3D内容时,显卡需要一块缓冲区来存储渲染的最终结果。通过WebGL 接口,你实际上可以控制这块存储区域的结构类型。首先,你至少需要一块区域来存储每个像素渲染后的颜色结果,同时,你往往还需要一个depth buffer(深度缓冲区)来处理视线遮挡,这样,近处的像素将会遮挡住较远处的像素,depth buffer同样需要一部分存储空间。除此之外,有时我们也会用到一些其它种类的buffer,比如stencil buffer(模板缓冲区),在接下来的系列教程中,我们会讲到它。

Framebuffer就是用来存储渲染结果的这一类缓冲区的一种集合。如果你没有指定,默认会存在一个”Default” frame buffer,也就是到本课之前,我们一直在使用的frame buffer,它代表最终会被显示在网页中的缓冲区域。除此之外,你可以创建你自己的frame buffer,并指定WebGL将渲染结果输送到你所创建的frame buffer中。在本课中,我们创建了一个自己的frame buffer,并指定它使用一张纹理作为存储像素颜色的缓冲区。同时,我们也需要分配一个深度缓冲区来做深度遮挡计算。

下面,让我们来看一下创建frame buffer的具体代码。在本课的实例中,initTextureFramebuffer函数完成了这个工作,如果你需要查找这个函数,它大概在文件中从上向下浏览的三分之一处。

  var rttFramebuffer;
  var rttTexture;

  function initTextureFramebuffer() {

在函数之前,我们定义了两个全局变量,rttFramebuffer用来存储将被渲染到笔记本屏幕的frame buffer,而rttTexture用来存储容纳笔记本屏幕渲染结果的纹理(rttTexture在我们绘制整个笔记本场景时会使用到)。下面,继续看这个函数接下来的部分。

    rttFramebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, rttFramebuffer);
    rttFramebuffer.width = 512;
    rttFramebuffer.height = 512;

第一步是创建一个frame buffer,然后的操作流程很标准,就像纹理和vertex attribute buffer一样,我们将frame buffer指定为WebGL的当前frame buffer。当前frame buffer的含义就是其后的对frame buffer的操作都将作用在当前frame buffer上。此外,我们将笔记本屏幕的绘制尺寸储存在了我们所创建的frame buffer中,注意,这两个尺寸属性并不是frame buffer的固有属性,我只是利用了javascript动态为对象添加属性的技巧,因为在后续对frame buffe的使用中,我们需要这个信息。我们选择绘制一个512×512 的frame buffer,WebGL的只能创建2的整数幂次大小的纹理,而对于我们的应用环境,256×256则会使得画面出现模糊,而更高的1024×1024则不会对提高画面精细度有太大的帮助。

接下来,我们创建一个纹理对象,并初始化相应的参数。

    rttTexture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, rttTexture);
    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.texImage2D来为纹理初始化数据,但在这里我们会发现一些与之前不同的地方。

      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, rttFramebuffer.width, rttFramebuffer.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);

通常,当我们想为texture加载一张图片时,我们会使用gl.texImage2D来为texture指定对应的Image,但在这里,我们并没有图片可用来加载,所以我们调用了gl.texImage2D 的另外一个版本,告诉WebGL我们并没有Image可供加载,我们仅仅需要创建一个指定大小的空数据的纹理对象。严格来说,这里的最后一个参数是用来指定传入纹理的像素列表,但在这里,我们不需要纹理对象加载任何数据,所以在这里,我们传入null。(早期的Minefield 需要使用者传入一个指定大小的空数组来初始化纹理,但这个问题目前似乎已经被修复掉了)

现在我们拥有了一张用来存储颜色结果的纹理,接下来,我们需要创建一张depth buffer用来记录深度信息。

    var renderbuffer = gl.createRenderbuffer();
    gl.bindRenderbuffer(gl.RENDERBUFFER, renderbuffer);
    gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, rttFramebuffer.width, rttFramebuffer.height);

这里我们创建了一个render buffer对象,render buffer表示用来存储与frame buffer相关的广义用途的缓冲区,你可以用它来作为深度缓冲,或者模板缓冲,或者两者兼之。像其它缓冲区对象一样,我们绑定render buffer,将它作为WebGL的当前render buffer,并调用gl.renderbufferStorage来通知WebGL当前render buffer用作深度缓冲区,每个像素的深度数据大小16位,同时我们还指定了render buffer的尺寸。

接下来:

    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, rttTexture, 0);
    gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, renderbuffer);

我们将创建的纹理和render buffer都绑定到当前的frame buffer上(不要忘记,在frame buffer被创建后,我们就一直将它作为WebGL的当前frame buffer)。这里,我们告诉WebGL当前frame buffer使用rttTexture作为颜色缓冲区(gl.COLOR_ATTACHMENT0),使用我们创建的render buffer作为深度缓冲区(gl.DEPTH_ATTACHMENT)。

到此为止,frame buffer的内容初始化工作就完成了,WebGL已经知道我们所创建的frame buffer该绘制到什么地方去;所以,在这之后,我们将当前的texture、renderbuffer和framebuffer重置为默认状态。

    gl.bindTexture(gl.TEXTURE_2D, null);
    gl.bindRenderbuffer(gl.RENDERBUFFER, null);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  }

现在我们有了一个自定义的frame buffer,那如何去使用它呢? 让我们跳到drawScene函数来看一下,这个函数在文件的底部。在函数的最开始处,在正常的视口设置等初始化工作之前,你应该看到了一些新东西。

  var laptopAngle = 0;

  function drawScene() {
    gl.bindFramebuffer(gl.FRAMEBUFFER, rttFramebuffer);
    drawSceneOnLaptopScreen();

    gl.bindFramebuffer(gl.FRAMEBUFFER, null);

    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);

函数的第一行,非常容易理解,我们将当前frame buffer从默认frame buffer切换到之前在initTextureFramebuffer函数中所创建的frame buffer,这样,后续的绘制工作将被导向我们所设置的纹理和深度缓冲,而不再是最终的网页。之后,我们调用drawSceneOnLaptopScreen函数,将需要显示在笔记本屏幕上的3D场景绘制到指定的当前frame buffer中,绘制结束后,当前frame buffer被切换后默认状态。在继续接下来的学习之前,我认为读者有必要了解一下drawSceneOnLaptopScreen这个函数的内容,我不会在这里列出它们,因为所涉及的代码非常简单,基本是第13课中drawscene函数的简化版。这是因为我们之前的绘制代码并没有设置特定的frame buffer,而仅仅是将所有内容绘制到当前frame buffer上,这里与13课中唯一的不同就是我们去掉了可移动光源等在本课中并不需要的场景元素。

所以,当drawScene函数开头的三行代码执行后,我们就拥有了一张存储着第13课场景绘制结果的纹理。绘制代码的剩余部分就是正常绘制出笔记本模型,并将之前存储着场景绘制结果的纹理映射到笔记本模型的屏幕上。首先是初始化model-view矩阵,并将笔记本旋转一定的角度,角度由laptopAngle给出(像之前的课程一样,laptopAngle每帧都会在animate函数中更新来实现笔记本模型的持续旋转效果)。

    mat4.identity(mvMatrix);

    mvPushMatrix();

    mat4.translate(mvMatrix, [0, -0.4, -2.2]);
    mat4.rotate(mvMatrix, degToRad(laptopAngle), [0, 1, 0]);
    mat4.rotate(mvMatrix, degToRad(-90), [1, 0, 0]);

之后,在下面的代码中,我们将场景中光源的颜色和位置等信息传给显卡。

    gl.uniform1i(shaderProgram.showSpecularHighlightsUniform, true);
    gl.uniform3f(shaderProgram.pointLightingLocationUniform, -1, 2, -1);

    gl.uniform3f(shaderProgram.ambientLightingColorUniform, 0.2, 0.2, 0.2);
    gl.uniform3f(shaderProgram.pointLightingDiffuseColorUniform, 0.8, 0.8, 0.8);
    gl.uniform3f(shaderProgram.pointLightingSpecularColorUniform, 0.8, 0.8, 0.8);

接下来,我们需要将笔记本的光照材质信息传入显卡,这里有一些之前没有出现过的新内容,但它们与渲染到纹理技术本身并没有关系。你也许还记得,在第7课中,当我们描述Phong光照算法的时候,我提到了材质信息对应不同的光照类型有不同的颜色信息:ambient color(环境反射光),diffuse colour(漫反射光),specular colour(镜面反射光/高光)。在之前的课程中,我们始终假设所有的材质颜色都是纯白色,如果有纹理,我们则取纹理颜色作为所有材质属性的颜色,但在本课中,这种简化后的光照方式将不再试用,原因你将在稍后的文章中看到。在这里,我们需要为材质的每一种属性信息设置单独的数据,同时,我们还引入了一种新的材质属性:emissive colour(自发光)。但是不用担心,对于笔记本模型来说,这个设置过程非常简单,因为笔记本本身是白色的。

    // The laptop body is quite shiny and has no texture.  It reflects lots of specular light
    gl.uniform3f(shaderProgram.materialAmbientColorUniform, 1.0, 1.0, 1.0);
    gl.uniform3f(shaderProgram.materialDiffuseColorUniform, 1.0, 1.0, 1.0);
    gl.uniform3f(shaderProgram.materialSpecularColorUniform, 1.5, 1.5, 1.5);
    gl.uniform1f(shaderProgram.materialShininessUniform, 5);
    gl.uniform3f(shaderProgram.materialEmissiveColorUniform, 0.0, 0.0, 0.0);
    gl.uniform1i(shaderProgram.useTexturesUniform, false);

下一步,如果笔记本模型的顶点数据已经加载完毕,那就将它绘制出来,这部分代码读者应该已经非常熟悉了,特别是在阅读过14课之后(实际上,大部分是从第14课的内容中复制过来的)。

    if (laptopVertexPositionBuffer) {
      gl.bindBuffer(gl.ARRAY_BUFFER, laptopVertexPositionBuffer);
      gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, laptopVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

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

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

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

上面的代码已经将笔记本模型绘制在了网页上,下面,我们需要将纹理映射到笔记本的屏幕上。首先依然是需要设置光照参数,在这里,我们设置了不同的emissive colour。

    gl.uniform3f(shaderProgram.materialAmbientColorUniform, 0.0, 0.0, 0.0);
    gl.uniform3f(shaderProgram.materialDiffuseColorUniform, 0.0, 0.0, 0.0);
    gl.uniform3f(shaderProgram.materialSpecularColorUniform, 0.5, 0.5, 0.5);
    gl.uniform1f(shaderProgram.materialShininessUniform, 20);
    gl.uniform3f(shaderProgram.materialEmissiveColorUniform, 1.5, 1.5, 1.5);
    gl.uniform1i(shaderProgram.useTexturesUniform, true);

下面,我们需要设置用于采样的纹理,也就是之前存储着第一次绘制结果的纹理对象。

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

最后,绘制笔记本屏幕。

    setMatrixUniforms();
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, laptopScreenVertexPositionBuffer.numItems);

    mvPopMatrix();
  }

整个过程是不是有些由繁入简的感觉呢? :) 上面就是实现渲染到纹理,并将纹理用作其它绘制的全部代码。

在本文的最后,我们给出了一个用于新材质类型光照的fragment shader,在理解之前课程的基础上,新shader的内容应该非常容易理解。这里唯一的新内容就是增加了emissive colour,而shader对它的处理仅仅的将它简单的加在最终的像素颜色上:

  precision mediump float;

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

  uniform vec3 uMaterialAmbientColor;
  uniform vec3 uMaterialDiffuseColor;
  uniform vec3 uMaterialSpecularColor;
  uniform float uMaterialShininess;
  uniform vec3 uMaterialEmissiveColor;

  uniform bool uShowSpecularHighlights;
  uniform bool uUseTextures;

  uniform vec3 uAmbientLightingColor;

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

  uniform sampler2D uSampler;

  void main(void) {
    vec3 ambientLightWeighting = uAmbientLightingColor;

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

    vec3 specularLightWeighting = vec3(0.0, 0.0, 0.0);
    if (uShowSpecularHighlights) {
      vec3 eyeDirection = normalize(-vPosition.xyz);
      vec3 reflectionDirection = reflect(-lightDirection, normal);

      float specularLightBrightness = pow(max(dot(reflectionDirection, eyeDirection), 0.0), uMaterialShininess);
      specularLightWeighting = uPointLightingSpecularColor * specularLightBrightness;
    }

    float diffuseLightBrightness = max(dot(normal, lightDirection), 0.0);
    vec3 diffuseLightWeighting = uPointLightingDiffuseColor * diffuseLightBrightness;

    vec3 materialAmbientColor = uMaterialAmbientColor;
    vec3 materialDiffuseColor = uMaterialDiffuseColor;
    vec3 materialSpecularColor = uMaterialSpecularColor;
    vec3 materialEmissiveColor = uMaterialEmissiveColor;
    float alpha = 1.0;
    if (uUseTextures) {
      vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
      materialAmbientColor = materialAmbientColor * textureColor.rgb;
      materialDiffuseColor = materialDiffuseColor * textureColor.rgb;
      materialEmissiveColor = materialEmissiveColor * textureColor.rgb;
      alpha = textureColor.a;
    }
    gl_FragColor = vec4(
      materialAmbientColor * ambientLightWeighting
      + materialDiffuseColor * diffuseLightWeighting
      + materialSpecularColor * specularLightWeighting
      + materialEmissiveColor,
      alpha
    );
  }

到此为止,本课的所有内容就结束了。在这一课中,我们学习到了如何将一个场景渲染到一张纹理中,并在另一个场景中使用这张纹理,同时,我们也深入了解了材质属性的细节和它们的工作原理。在下一课中,我们将给出一个渲染到纹理的实际应用:3D场景中的鼠标点选,这个功能可以让用户通过鼠标点击与3D场景中物体进行直接交互。

WebGL教程:第15课,高光贴图

欢迎来到系列教程的第15课!在这一课中,我们将介绍高光贴图技术。就像普通纹理用来指定物体表面的颜色一样,高光贴图可以用来指定物体表面每一处细节的光照反射程度,因此可以大大增强物体的真实感。本课中并没有太多新增的代码, 只会在前几课基础上进行简单的改动,但从意义上来讲,本课将引入一种全新的概念。

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

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

在本课的演示程序中,你应当会看到一个旋转的地球,地球表面有从太阳反射出的光斑。如果你仔细观察,你会发现高光反射只存在于地球表面的海洋部分,而对于陆地部分,正像你预想的那样,不会反射太阳的光照。

如果你尝试取消“开启高光贴图”选项(此选项位于canvas的下方)。你会发现高光反射就会出现在陆地上,这种效果非常不真实,看起来像是一个超大的聚光灯正在对着地球照射。这就是高光贴图的作用, 它可以允许你精确的控制物体表面的反光细节。

重新打开“开启高光贴图”选项,并降低光源的漫反射强度, 比如将漫反射强度设为(0.5,0.5,0.5), 之后, 再关掉高光贴图, 你会发现地球表面的颜色纹理已经几乎看不到了,但是高光却没有变化,依然同时出现在海洋和陆地区域。

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

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

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

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

在解释代码细节之前,让我们先来了解一下背景知识。到目前为止,我们一直将纹理用作一种蒙在物体表面的图像。我们指定一张图像,并为每个顶点设定纹理坐标(纹理坐标标明顶点应该对应到纹理图像中的哪个位置),然后在fragment shader中, 从sampler中取出对应位置的纹理颜色, 并将它作为当前像素的颜色输出。

高光贴图则将我们上面对纹理的认识扩展了一下。一张纹理有R,G, B, A四个通道,在shader中, 每个通道都是一个float类型的标量值, 但并没有限定我们一定要将纹理数据当做颜色来对待。上一课中,我们了了解到,材质的反光度(shinness)也是一个float类型标量值,所以我们可以通过纹理来将物体表面的反光度属性赋给每一个对应的像素,就像我们使用纹理为像素赋颜色一样。

这就是我们在本课中使用的技巧,我们将两张纹理同时传给用于绘制地球的fragment shader,一张用来指定颜色,如下图:

另一张低分辨率的用来指定反光度:

上图只是一张普通的GIF图片, 是我在PAINT.NET中通过修改颜色纹理生成的。在本课中,我们假设RGB通道的反光度都是相同的, 同时, 我们需要选择一种颜色作为完全不反光的标志(按照我们的设计,如果需要得到反光度32,则纹理中会存储深灰色(32,32,32)),这里我们指定纯白色为完全不反光。

好了,需要预先解释的都已经说清楚了,下面我们来看代码。本课代码与14课内容只有非常微小的改动,而最主要的部分在fragment shader中,所以在这里我们主要看一下shader部分的内容:

第一个不同是shader多了两个新的uniform常量用来标明是否使用颜色和高光纹理 (第17、18行):

  precision mediump float;

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

  uniform bool uUseColorMap;
  uniform bool uUseSpecularMap;
  uniform bool uUseLighting;

接下来,是两个sampler,用于代表两张纹理。这里将颜色纹理重命名为uColorMapSampler,同时,增加了一个sampler用来表示高光贴图(第27、28行):

  uniform vec3 uAmbientColor;

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

  uniform sampler2D uColorMapSampler;
  uniform sampler2D uSpecularMapSampler;

下面,是常规的切换是否进行光照的代码,并计算法线和光照方向:

  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);

最后,就是实际处理高光贴图的代码。首先,我们定义了一个变量用来表示specular光照的权重;如果最终不应进行高光反射,则这个变量将被赋为0。

      float specularLightWeighting = 0.0;

接下来,开始处理材质的反光度部分。在这里,如果用户选择不使用高光贴图,我们就会默认将反光度初始化为32,否则,我们会从高光贴图中取出对应的反光数值,就像从颜色纹理中取出颜色一样。因为我们假设RGB通道的反光度相同(这就是为什么本课中的高光贴图看起来是一张灰度图),因此在这里我们只取高光贴图纹理的R通道数据,实际上,你从任何一个通道读取,结果都是一样的。

      float shininess = 32.0;
      if (uUseSpecularMap) {
        shininess = texture2D(uSpecularMapSampler, vec2(vTextureCoord.s, vTextureCoord.t)).r * 255.0;
      }

现在,你需要根据取出的反光度来判断是否需要进行反光,还记得吗?前文中我们提到将纯白色作为不进行反光的标志,因此在这里,我们将只对小于255的反光度进行实际的高光计算。

      if (shininess < 255.0) {
	

下面的代码和上一课中的高光计算基本相同,唯一的区别就是这里的反光度是从高光贴图中取出的,而不像之前那样从shader外部传入。

        vec3 eyeDirection = normalize(-vPosition.xyz);
        vec3 reflectionDirection = reflect(-lightDirection, normal);

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

最后,我们将所有类型的光照元素组合到一起,并使用这个结果作为物体颜色的权重。对于物体颜色,当用户选择使用颜色纹理,也就是uUseColorMap为true的时候,物体颜色是从颜色纹理中取出的,而当不使用颜色纹理时,我们默认认为物体颜色为纯白色。

      float diffuseLightWeighting = max(dot(normal, lightDirection), 0.0);
      lightWeighting = uAmbientColor
        + uPointLightingSpecularColor * specularLightWeighting
        + uPointLightingDiffuseColor * diffuseLightWeighting;
    }

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

当你阅读到这里,你应该已经理解了实现高光贴图所需要的全部知识。除此之外,代码中还有一些其它的改动,但这些细小变化并不值得去细细研究。initShaders函数中添加了用于传输新uniform常量的代码,initTextures则需要加载两张新的纹理,而前一课中加载teapot的代码则被替换为11课中的initBuffers函数,drawScene的内容由绘制茶壶变为了绘制一个球形,同时用户交互UI和animate函数也有对应本课的改动。

That’s it!这就是本课的全部内容。在这一课中,你了解了怎样通过纹理来提供材质反光度所需要的数据,同时,其实你也可以通过纹理来提供其它的表面细节信息,比如,人们经常会用纹理来提供物体表面的法线信息,这样,可以在保持较低顶点数量的前提下,为模型增加一些表面细节,在未来课程中,对这一部分内容我们会特别提到。

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物体交互;如何使用矩阵来表示场景中物体的当前旋转状态。

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