2

从本篇起,我们将正式进入webgl的3D世界

本篇涵盖的内容包括:

  1. webgl它在干啥?
  2. 如何画一个正方体?
  3. 如何成为一个“有深度”的正方体
  4. 正方体要离家出走了!

webgl它在干啥?

首先我们需要知道webgl的世界其实是一个x[-1,1],y[-1,1],z[-1,1]的小世界,所有在这个长度2的世界中的物体,才能被显示。如图所示:

image

黄色区域是webgl[-1,1]区域,上方的球和下方的长方体都完全在这个区域内可以完全被显示,而中间的立方体这是部分显示

从正面看就会是
image

段棒子和球完全显示,常棒子超出区域被截掉

简单说来,这个过程中webgl把三纬空间xyz[-1,1]压扁成xy[1,-1]再根据实际画布的尺寸,绘制到canvas上,这个过程就是光栅化。不过如果三纬空间xyz[-1,1]中的物体如图所示,而canvas是2:1的尺寸的话实际得到的图片会是这样的:

image

那么为啥我平常用的canvas不会变形?请耐心看到文末你会有答案的!

如何画一个正方体?

本篇我们并不会在作色器上大动手脚,所以按照老方法,做一遍就可以了,不懂得可以看看《前端图形学从入门到放弃》第一篇。我们对作色器的唯一改造就是,开放了标示深度的z值:

<!-- 一个顶点着色器提供裁剪空间坐标值 -->
<script id="vertex-shader-2d" type="notjs">
    attribute vec3 a_position;
    uniform mat4 u_matrix;
    // 这个是后续空间变化需要的,这一步     void main() {
      vec4 position = u_matrix * vec4(a_position,1.0);
      gl_Position = vec4(position.xyz,1.0);
    }
</script>
<!-- 一个片断着色器提供颜色值 -->
<script id="fragment-shader-2d" type="notjs">
    precision mediump float;
    void main() {
      // 输出颜色固定即可,我选的是(1.0,0.1,.45),rgb都除以255       gl_FragColor = vec4(vec3(1.0,0.1,.45), 1.0); //     }
   
</script>

我们当然可以使用gl.drawArrays方法继续绘制,但对于数量更多的图形,这个方法其实不够高效,想象一下有如图ABCD四个点组成了两个三角形:

image

对于gl.drawArrays我们需要向buffer中传入ABC后再传入BCD绘制两个三角形,但buffer就会重复存储点BC,目前还只有位置坐标,如果是复杂的场景一个点上除了位置信息外,还会包含颜色,法线等一些列数据,这就相当浪费存储空间了。所以我们采用gl.drawElements方法。

顶点数据还是如前,往buffer中丢,只不过每个点只传入一次,例如我们需要绘制一个以[0,0,0]为中心,边长是0.4的正方形,他的的八个顶点组成的数据是:

var data = new Float32Array([
         0.2, 0.2, 0.2,
        -0.2, 0.2, 0.2,
        -0.2, -0.2, 0.2,
         0.2, -0.2, 0.2,
         0.2, 0.2, -0.2,
        -0.2, 0.2, -0.2,
        -0.2, -0.2, -0.2,
         0.2, -0.2, -0.2,
]); 

这时我们还需要一个索引数组来标识,用哪些点围城图形。

//顶点索引数组 var indexes = new Uint8Array([
        //右侧四个点         0, 1, 2, 3,
        //左侧四个顶点         4, 5, 6, 7,
        //左右对应关系         0, 4,
        1, 5,
        2, 6,
        3, 7
]); 

然后把这两个数据丢给buffer:

 var indexesBuffer = gl.createBuffer();
 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexesBuffer);
 gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indexes, gl.STATIC_DRAW);
 var vBuffer = gl.createBuffer();
 // 顶点需要是 ARRAY_BUFFER  gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
 gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); 

最后就是绘制:

//LINE_LOOP模式绘制右侧四个点 gl.drawElements(gl.LINE_LOOP, 4, gl.UNSIGNED_BYTE, 0);
//LINE_LOOP模式从第五个点开始绘制左侧四个点 gl.drawElements(gl.LINE_LOOP, 4, gl.UNSIGNED_BYTE, 4);
//LINES模式绘制连线左右点连线 gl.drawElements(gl.LINES, 8, gl.UNSIGNED_BYTE, 8); 

这样运行一遍会得到如下结果:

image

奇怪?为什么只有一个正方形?说好的3D?

这是因为我们现在还是正交视图,后面的点完全被前面的点挡住了,所以我们需要移动这个立方体。让后面的点被看到

所以,接下来我们创建6个滑块分别控制正方形向x,y,z移动和绕x,y,z旋转。你怎么实现都可以:

<div id="control">
    <div class="item item-x">
      <span>x:</span>
      <input type="range" id="item-x" value="0" min="-1" max="1" step="0.01" >
    </div>
    <div class="item item-y">
      <span>y:</span>
      <input type="range" id="item-y" value="0" min="-1" max="1" step="0.01" >
    </div>
    <div class="item">
      <span>z:</span>
      <input type="range" id="item-z" value="0" min="-1" max="1" step="0.01" >
    </div>
    <div class="item item-x">
      <span>x轴角度:</span>
      <input type="range" id="item-r-x" value="0" min="-3.14" max="3.14" step="0.001" >
    </div>
    <div class="item item-y">
      <span>y轴角度:</span>
      <input type="range" id="item-r-y" value="0" min="-3.14" max="3.14" step="0.001" >
    </div>
    <div class="item">
      <span>z轴角度:</span>
      <input type="range" id="item-r-z" value="0" min="-3.14" max="3.14" step="0.001" >
    </div>
  </div>

而通过这些数据我们可以组成一个矩阵对立方体的点进行变换。只是从《前端图形学从入门到放弃》002第二篇2D(支持齐次变换的3维矩阵)变成了3D(支持齐次变换的4维矩阵)。

他们分别是一个平移矩阵和3个旋转矩阵:

var m4 = {  // 用来存放三位变换方法的对象   transform: function (x, y, z) {
    return [
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      x, y, z, 1,
    ];
  },
  rotateX: function (deg) {
    var c = Math.cos(deg);
    var s = Math.sin(deg);
    return [
      1, 0, 0, 0,
      0, c, s, 0,
      0, -s, c, 0,
      0, 0, 0, 1,
    ]
  },
  rotateY: function (deg) {
    var c = Math.cos(deg);
    var s = Math.sin(deg);
    return [
      c, 0, -s, 0,
      0, 1, 0, 0,
      s, 0, c, 0,
      0, 0, 0, 1,
    ]
  },
  rotateZ: function (deg) {
    var c = Math.cos(deg);
    var s = Math.sin(deg);
    return [
      c, s, 0, 0,
      -s, c, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, 1,
    ]
  },
} 

当然我们还需要一个矩阵的乘法方法multiply,以及可以支持多个矩阵连乘的multiplys方法:

var m4 = {
// .......   multiply: function (a, b) {
    var a00 = a[0 * 4 + 0];
    var a01 = a[0 * 4 + 1];
    var a02 = a[0 * 4 + 2];
    var a03 = a[0 * 4 + 3];
    var a10 = a[1 * 4 + 0];
    var a11 = a[1 * 4 + 1];
    var a12 = a[1 * 4 + 2];
    var a13 = a[1 * 4 + 3];
    var a20 = a[2 * 4 + 0];
    var a21 = a[2 * 4 + 1];
    var a22 = a[2 * 4 + 2];
    var a23 = a[2 * 4 + 3];
    var a30 = a[3 * 4 + 0];
    var a31 = a[3 * 4 + 1];
    var a32 = a[3 * 4 + 2];
    var a33 = a[3 * 4 + 3];
    var b00 = b[0 * 4 + 0];
    var b01 = b[0 * 4 + 1];
    var b02 = b[0 * 4 + 2];
    var b03 = b[0 * 4 + 3];
    var b10 = b[1 * 4 + 0];
    var b11 = b[1 * 4 + 1];
    var b12 = b[1 * 4 + 2];
    var b13 = b[1 * 4 + 3];
    var b20 = b[2 * 4 + 0];
    var b21 = b[2 * 4 + 1];
    var b22 = b[2 * 4 + 2];
    var b23 = b[2 * 4 + 3];
    var b30 = b[3 * 4 + 0];
    var b31 = b[3 * 4 + 1];
    var b32 = b[3 * 4 + 2];
    var b33 = b[3 * 4 + 3];
    return [
      b00 * a00 + b01 * a10 + b02 * a20 + b03 * a30,
      b00 * a01 + b01 * a11 + b02 * a21 + b03 * a31,
      b00 * a02 + b01 * a12 + b02 * a22 + b03 * a32,
      b00 * a03 + b01 * a13 + b02 * a23 + b03 * a33,
      b10 * a00 + b11 * a10 + b12 * a20 + b13 * a30,
      b10 * a01 + b11 * a11 + b12 * a21 + b13 * a31,
      b10 * a02 + b11 * a12 + b12 * a22 + b13 * a32,
      b10 * a03 + b11 * a13 + b12 * a23 + b13 * a33,
      b20 * a00 + b21 * a10 + b22 * a20 + b23 * a30,
      b20 * a01 + b21 * a11 + b22 * a21 + b23 * a31,
      b20 * a02 + b21 * a12 + b22 * a22 + b23 * a32,
      b20 * a03 + b21 * a13 + b22 * a23 + b23 * a33,
      b30 * a00 + b31 * a10 + b32 * a20 + b33 * a30,
      b30 * a01 + b31 * a11 + b32 * a21 + b33 * a31,
      b30 * a02 + b31 * a12 + b32 * a22 + b33 * a32,
      b30 * a03 + b31 * a13 + b32 * a23 + b33 * a33,
    ];
  },
  multiplys: function (list) {
    var that = this;
    return list.reduce(function (a, b) {
      return that.multiply(a, b);
    })
  },
// ....... } 

要完成对立方体的控制,我们需要把渲染写入一个循环中:

 var xNode = document.querySelector('#item-x');
      var yNode = document.querySelector('#item-y');
      var zNode = document.querySelector('#item-z');
      var rXNode = document.querySelector('#item-r-x');
      var rYNode = document.querySelector('#item-r-y');
      var rZNode = document.querySelector('#item-r-z');
      // 。。。。。       loop();
      function loop() {
        var mx = m4.rotateX(rXNode.value); 
        var my = m4.rotateY(rYNode.value);
        var mz = m4.rotateZ(rZNode.value);
        var mTransform = m4.transform(xNode.value,yNode.value,zNode.value);
        var matrix = m4.multiplys([mTransform,mz,my,mx]);
        gl.uniformMatrix4fv(u_matrix, false, matrix);
        // 把矩阵传入顶点作色器         gl.clearColor(0.75, 0.85, 0.8, 1.0);
        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
        //LINE_LOOP模式绘制右侧四个点         gl.drawElements(gl.LINE_LOOP, 4, gl.UNSIGNED_BYTE, 0);
        //LINE_LOOP模式从第五个点开始绘制左侧四个点         gl.drawElements(gl.LINE_LOOP, 4, gl.UNSIGNED_BYTE, 4);
        //LINES模式绘制连线左右点连线         gl.drawElements(gl.LINES, 8, gl.UNSIGNED_BYTE, 8);
        requestAnimationFrame(loop)
      } 

这样一来我们就能看到立方体的屁股了:

demo

如何成为一个“有深度”的正方体?

虽然我们实现了3D效果,但这种正交效果并不会有日常我们见到的透视中的近大远小。

image

我们要如何实现呢?

我们先来想想近大远小是什么,近大远小不就是近处的看起来大一点远处的看起来小一点么。那么最直接的近大远小就是根据z值大小去缩放点的位置。

这里我们添加一个控制点来缩放透视程度:

<div class="item">
     <span>z透视参数:</span>
     <input type="range" id="item-z-factor" value="0" min="0" max="2" step="0.001" >
 </div>
// .......
<script>
//........ var zFactorNode = document.querySelector("#item-z-factor")
// ........ </script>

然后我们把这个变换写成一个矩阵的形式:

var zFactorMatrix = [
          1,0,0,0,
          0,1,0,0,
          0,0,1,zFactorNode.value,
          0,0,0,1
];
var matrix = m4.multiplys([zFactorMatrix,mTransform,mz,my,mx]); 

这样通过调节z透视参数,我们的立方体就有了纵深感:

demo

需要注意的是,xyz经过zFactorMatrix处理后没有变化:

out_x = in_x+0+0+0;out__y = in_y_+0+0+0_;out_z = in_z_+0+0+0_;

但w会变成

out_w = in_w_+1_

给 gl_Position 的 x,y,z,w 值自动除以 w,所以所有点的x,y才会受到z的控制。

但实际开发中,我们不会使用_zFactor来控制深度_。而是用透视矩阵:

image

不过在此之前我们先来解决,物体超出xyz[-1,1]空间的问题。

正方体要离家出走了!

例如我把立方体变成一个边长20,中心在(0,0,70)的立方体:

var data = new Float32Array([
        10, 10, 80,
        -10, 10, 80,
        -10, -10, 80,
        10, -10, 80,
        10, 10, 60,
        -10, 10, 60,
        -10, -10, 60,
        10, -10, 60,
]); 

它立刻消失在屏幕上。

所以我们现在要做的是指定一个区域(假设是以_(0,0,70)为中心,变成是50的空间,图中黑色区域_)将其矩阵变换到空间[-1,1]之中。

image

第一步我们自然是要把这个空间移动到_(0,0,0),第二部就是把这个空间缩小到[-1,1]_

这里我们在m4对象中再创建一个方法_orthographic,它接受6个参数(t, b, l, r, n, f)分别所需要呈现空间的y,x,z的最小和最大值,对于以_(0,0,70)为中心,变成是50的空间,这6个参数是(_25,-25,25,-25,45,95_):

var m4 = {
// ......   orthographic: function (t, b, l, r, n, f) {
    //先把空间移动到0,0,0     var trans = [
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      -(r + l) / 2, -(t + b) / 2, -(n + f) / 2, 1,
    ]
    //在把空间缩放到[-1,1]     var scale = [
      2 / (r - l), 0, 0, 0,
      0, 2 / (t - b), 0, 0,
      0, 0, 2 / (n - f), 0,
      0, 0, 0, 1,
    ]
    return this.multiply(scale, trans);
  },
//...... } 

大家可以用原始空间的顶点带入矩阵计算看看是不是都被移动到了[-1,1]的八个顶点上。

这样我们在loop中再创建一个矩阵:

var Ortho = m4.orthographic(25,-25,25,-25,45,95); 

而此时传入作色器的matrix矩阵将变成:

var matrix = m4.multiplys([Ortho, mTransform, mx, my, mz]); 

而运行后通过调整参数我们又得到立方体,这样我们就实现了将空间中任意区域的几何体画到canvas上了:

image

虽然如此但我们的透视又消失了回到了正交的状态,而且旋转的时候,物体好像并不是绕着自己的中心在转?这又是怎么回事?由于篇幅有限,这些事我们留到下回再说吧!

下回我们将解决:

  1. 如何加入透视?
  2. 如何让物体绕着自己旋转
  3. 如何让相机移动与旋转
  4. 如何盯住物体

参考资料:

WebGL 三维透视投影
Marschner, S., & Shirley, P. (2018). _Fundamentals of computer graphics_. Place of publication not identified: A K Peters/CRC Press.


这是上帝的杰作
2.2k 声望164 粉丝

//loading...