教你用webgl快速创建一个小世界

Candida99E 4年前
   <p>Webgl的魅力在于可以创造一个自己的3D世界,但相比较canvas2D来说,除了物体的移动旋转变换完全依赖矩阵增加了复杂度,就连生成一个物体都变得很复杂。</p>    <p>什么?!为什么不用Threejs?Threejs等库确实可以很大程度的提高开发效率,而且各方面封装的非常棒,但是不推荐初学者直接依赖Threejs,最好是把webgl各方面都学会,再去拥抱Three等相关库。</p>    <p>上篇矩阵入门中介绍了矩阵的基本知识,让大家了解到了基本的仿射变换矩阵,可以对物体进行移动旋转等变化,而这篇文章将教大家快速生成一个物体,并且结合变换矩阵在物体在你的世界里动起来。</p>    <p>注:本文适合稍微有点webgl基础的人同学,至少知道shader,知道如何画一个物体在webgl画布中</p>    <h2>为什么说webgl生成物体麻烦</h2>    <p>我们先稍微对比下基本图形的创建代码</p>    <p>矩形:</p>    <p>canvas2D</p>    <pre>  <code class="language-javascript">ctx1.rect(50, 50, 100, 100);  ctx1.fill();     </code></pre>    <p>webgl(shader和webgl环境代码忽略)</p>    <pre>  <code class="language-javascript">var aPo = [      -0.5, -0.5, 0,      0.5, -0.5, 0,      0.5, 0.5, 0,      -0.5, 0.5, 0  ];     var aIndex = [0, 1, 2, 0, 2, 3];     webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer());  webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW);  webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0);     webgl.vertexAttrib3f(aColor, 0, 0, 0);     webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, webgl.createBuffer());  webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(aIndex), webgl.STATIC_DRAW);     webgl.drawElements(webgl.TRIANGLES, 6, webgl.UNSIGNED_SHORT, 0);     </code></pre>    <p>完整代码地址: <a href="/misc/goto?guid=4959741853606376887" rel="nofollow,noindex">https://vorshen.github.io/simple-3d-text-universe/rect.html</a></p>    <p>结果:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e06d773503616938bab9ad7a1b515162.png"></p>    <p>圆:</p>    <p>canvas2D</p>    <pre>  <code class="language-javascript">ctx1.arc(100, 100, 50, 0, Math.PI * 2, false);  ctx1.fill();     </code></pre>    <p>webgl</p>    <pre>  <code class="language-javascript">var angle;  var x, y;  var aPo = [0, 0, 0];  var aIndex = [];  var s = 1;  for(var i = 1; i <= 36; i++) {      angle = Math.PI * 2 * (i / 36);      x = Math.cos(angle) * 0.5;      y = Math.sin(angle) * 0.5;         aPo.push(x, y, 0);         aIndex.push(0, s, s+1);         s++;  }     aIndex[aIndex.length - 1] = 1; // hack一下     webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer());  webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW);  webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0);     webgl.vertexAttrib3f(aColor, 0, 0, 0);     webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, webgl.createBuffer());  webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(aIndex), webgl.STATIC_DRAW);     webgl.drawElements(webgl.TRIANGLES, aIndex.length, webgl.UNSIGNED_SHORT, 0);     </code></pre>    <p>完整代码地址: <a href="/misc/goto?guid=4959741853699495306" rel="nofollow,noindex">https://vorshen.github.io/simple-3d-text-universe/circle.html</a></p>    <p>结果:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/ed79a83d52f8adedd4d6561d4bb60c35.png"></p>    <p>总结:我们抛开shader中的代码和webgl初始化环境的代码,发现webgl比canvas2D就是麻烦很多啊。光是两种基本图形就多了这么多行代码,抓其根本多的原因就是因为 <strong>我们需要顶点信息</strong> 。简单如矩形我们可以直接写出它的顶点,但是复杂一点的圆,我们还得用数学方式去生成,明显阻碍了人类文明的进步。</p>    <p>相比较数学方式生成,如果我们能直接获得顶点信息那应该是最好的,有没有快捷的方式获取顶点信息呢?</p>    <p>有,使用建模软件生成obj文件。</p>    <p>Obj文件简单来说就是包含一个3D模型信息的文件,这里信息包含:顶点、纹理、法线以及该3D模型中纹理所使用的贴图</p>    <p>下面这个是一个obj文件的地址:</p>    <p><a href="/misc/goto?guid=4959741853792503354" rel="nofollow,noindex">https://vorshen.github.io/simple-3d-text-universe/assets/a1.obj</a></p>    <h2>简单分析一下这个obj文件</h2>    <p style="text-align:center"><img src="https://simg.open-open.com/show/354b0b3b5d4aff57c6e10a7e36b4c8ef.png"></p>    <p>前两行看到#符号就知道这个是注释了,该obj文件是用blender导出的。Blender是一款很好用的建模软件,最主要的它是免费的!</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/2a2dd49459f54da32171d54edf847675.png"></p>    <p>Mtllib(material library)指的是该obj文件所使用的材质库文件(.mtl)</p>    <p>单纯的obj生成的模型是白模的,它只含有纹理坐标的信息,但没有贴图,有纹理坐标也没用</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/048f1c52c227630430e2c22edd3e87b0.png"></p>    <p>V 顶点vertex</p>    <p>Vt 贴图坐标点</p>    <p>Vn 顶点法线</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/3923285f3294bea0e82fbacff933bc0e.png"></p>    <p>Usemtl 使用材质库文件中具体哪一个材质</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/d1dd4eb67cb8ff7c24bf56d7c022faf8.png"></p>    <p>F是面,后面分别对应 顶点索引 / 纹理坐标索引 / 法线索引</p>    <p>这里大部分也都是我们非常常用的属性了,还有一些其他的,这里就不多说,可以google搜一下,很多介绍很详细的文章。</p>    <p>如果有了obj文件,那我们的工作也就是将obj文件导入,然后读取内容并且按行解析就可以了。</p>    <p>先放出最后的结果,一个模拟银河系的3D文字效果。</p>    <p>在线地址查看: <a href="/misc/goto?guid=4959741853872665065" rel="nofollow,noindex">https://vorshen.github.io/simple-3d-text-universe/index.html</a></p>    <p>在这里顺便说一下,2D文字是可以通过分析获得3D文字模型数据的,将文字写到canvas上之后读取像素,获取路径。我们这里没有采用该方法,因为虽然这样理论上任何2D文字都能转3D,还能做出类似input输入文字,3D展示的效果。但是本文是教大家快速搭建一个小世界,所以我们还是采用blender去建模。</p>    <h2>具体实现</h2>    <h3>1、首先建模生成obj文件</h3>    <p>这里我们使用blender生成文字</p>    <p><img src="https://simg.open-open.com/show/80b3a1e75f6bfea2e42fac85e555b127.gif"></p>    <h3>2、读取分析obj文件</h3>    <pre>  <code class="language-javascript">var regex = { // 这里正则只去匹配了我们obj文件中用到数据      vertex_pattern: /^v\s+([\d|\.|\+|\-|e|E]+)\s+([\d|\.|\+|\-|e|E]+)\s+([\d|\.|\+|\-|e|E]+)/, // 顶点      normal_pattern: /^vn\s+([\d|\.|\+|\-|e|E]+)\s+([\d|\.|\+|\-|e|E]+)\s+([\d|\.|\+|\-|e|E]+)/, // 法线      uv_pattern: /^vt\s+([\d|\.|\+|\-|e|E]+)\s+([\d|\.|\+|\-|e|E]+)/, // 纹理坐标      face_vertex_uv_normal: /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/, // 面信息      material_library_pattern: /^mtllib\s+([\d|\w|\.]+)/, // 依赖哪一个mtl文件      material_use_pattern: /^usemtl\s+([\S]+)/  };     function loadFile(src, cb) {      var xhr = new XMLHttpRequest();         xhr.open('get', src, false);         xhr.onreadystatechange = function() {          if(xhr.readyState === 4) {                 cb(xhr.responseText);          }      };         xhr.send();  }     function handleLine(str) {      var result = [];      result = str.split('\n');         for(var i = 0; i < result.length; i++) {          if(/^#/.test(result[i]) || !result[i]) { // 注释部分过滤掉              result.splice(i, 1);                 i--;          }      }         return result;  }     function handleWord(str, obj) {      var firstChar = str.charAt(0);      var secondChar;      var result;         if(firstChar === 'v') {             secondChar = str.charAt(1);             if(secondChar === ' ' && (result = regex.vertex_pattern.exec(str)) !== null) {              obj.position.push(+result[1], +result[2], +result[3]); // 加入到3D对象顶点数组          } else if(secondChar === 'n' && (result = regex.normal_pattern.exec(str)) !== null) {              obj.normalArr.push(+result[1], +result[2], +result[3]); // 加入到3D对象法线数组          } else if(secondChar === 't' && (result = regex.uv_pattern.exec(str)) !== null) {              obj.uvArr.push(+result[1], +result[2]); // 加入到3D对象纹理坐标数组          }         } else if(firstChar === 'f') {          if((result = regex.face_vertex_uv_normal.exec(str)) !== null) {              obj.addFace(result); // 将顶点、发现、纹理坐标数组变成面          }      } else if((result = regex.material_library_pattern.exec(str)) !== null) {          obj.loadMtl(result[1]); // 加载mtl文件      } else if((result = regex.material_use_pattern.exec(str)) !== null) {          obj.loadImg(result[1]); // 加载图片      }  }     </code></pre>    <p>代码核心的地方都进行了注释,注意这里的正则只去匹配我们obj文件中含有的字段,其他信息没有去匹配,如果有对obj文件所有可能含有的信息完成匹配的同学可以去看下Threejs中objLoad部分源码</p>    <h3>3、将obj中数据真正的运用3D对象中去</h3>    <pre>  <code class="language-javascript">Text3d.prototype.addFace = function(data) {      this.addIndex(+data[1], +data[4], +data[7], +data[10]);      this.addUv(+data[2], +data[5], +data[8], +data[11]);      this.addNormal(+data[3], +data[6], +data[9], +data[12]);  };     Text3d.prototype.addIndex = function(a, b, c, d) {      if(!d) {          this.index.push(a, b, c);      } else {          this.index.push(a, b, c, a, c, d);      }  };     Text3d.prototype.addNormal = function(a, b, c, d) {      if(!d) {          this.normal.push(              3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,              3 * this.normalArr[b], 3 * this.normalArr[b] + 1, 3 * this.normalArr[b] + 2,              3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2          );      } else {          this.normal.push(              3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,              3 * this.normalArr[b], 3 * this.normalArr[b] + 1, 3 * this.normalArr[b] + 2,              3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2,              3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,              3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2,              3 * this.normalArr[d], 3 * this.normalArr[d] + 1, 3 * this.normalArr[d] + 2          );      }  };     Text3d.prototype.addUv = function(a, b, c, d) {      if(!d) {          this.uv.push(2 * this.uvArr[a], 2 * this.uvArr[a] + 1);          this.uv.push(2 * this.uvArr[b], 2 * this.uvArr[b] + 1);          this.uv.push(2 * this.uvArr[c], 2 * this.uvArr[c] + 1);      } else {          this.uv.push(2 * this.uvArr[a], 2 * this.uvArr[a] + 1);          this.uv.push(2 * this.uvArr[b], 2 * this.uvArr[b] + 1);          this.uv.push(2 * this.uvArr[c], 2 * this.uvArr[c] + 1);          this.uv.push(2 * this.uvArr[d], 2 * this.uvArr[d] + 1);      }  };     </code></pre>    <p>这里我们考虑到兼容obj文件中f(ace)行中4个值的情况,导出obj文件中可以强行选择只有三角面,不过我们在代码中兼容一下比较稳妥</p>    <h3>4、旋转平移等变换</h3>    <p>物体全部导入进去,剩下来的任务就是进行变换了,首先我们分析一下有哪些动画效果</p>    <p>因为我们模拟的是一个宇宙,3D文字就像是星球一样,有公转和自转;还有就是我们导入的obj文件都是基于(0,0,0)点的,所以我们 <strong>还需要把它们进行平移操作</strong></p>    <p>先上核心代码~</p>    <pre>  <code class="language-javascript">......  this.angle += this.rotate; // 自转的角度     var s = Math.sin(this.angle);  var c = Math.cos(this.angle);     // 公转相关数据  var gs = Math.sin(globalTime * this.revolution); // globalTime是全局的时间  var gc = Math.cos(globalTime * this.revolution);        webgl.uniformMatrix4fv(      this.program.uMMatrix, false, mat4.multiply([              gc,0,-gs,0,              0,1,0,0,              gs,0,gc,0,              0,0,0,1          ], mat4.multiply(              [                  1,0,0,0,                  0,1,0,0,                  0,0,1,0,                  this.x,this.y,this.z,1 // x,y,z是偏移的位置              ],[                  c,0,-s,0,                  0,1,0,0,                  s,0,c,0,                  0,0,0,1              ]          )      )  );     </code></pre>    <p>一眼望去uMMatrix(模型矩阵)里面有三个矩阵,为什么有三个呢,它们的顺序有什么要求么?</p>    <p>因为矩阵不满足交换率,所以我们矩阵的平移和旋转的顺序十分重要,先平移再旋转和先旋转再平移有如下的差异</p>    <p>先旋转后平移:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/9f8edbfdf8e7fdcc853616890d187e10.png"></p>    <p>先平移后旋转:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/1e92dd10ee241b981301f6b406504f32.png"></p>    <p>从图中明显看出来 <strong>先旋转后平移是自转</strong> ,而 <strong>先平移后旋转是公转</strong></p>    <p>所以我们矩阵的顺序一定是 公转 * 平移 * 自转 * 顶点信息(右乘)</p>    <p>具体矩阵为何这样写可见上一篇矩阵入门文章</p>    <p>这样一个3D文字的8大行星就形成啦</p>    <h3>4、装饰星星</h3>    <p>光秃秃的几个文字肯定不够,所以我们还需要一点点缀,就用几个点当作星星,非常简单</p>    <p>注意 <strong>默认渲染webgl.POINTS是方形的</strong> ,所以我们得在fragment shader中加工处理一下</p>    <pre>  <code class="language-javascript">precisionhighpfloat;     void main() {      float dist = distance(gl_PointCoord, vec2(0.5, 0.5)); // 计算距离      if(dist < 0.5) {          gl_FragColor = vec4(0.9, 0.9, 0.8, pow((1.0 - dist * 2.0), 3.0));      } else {          discard; // 丢弃      }  }     </code></pre>    <h2>结语</h2>    <p>需要关注的是这里我用了另外一对shader,此时就涉及到了关于是用多个program shader还是在同一个shader中使用if statements,这两者性能如何,有什么区别</p>    <p>这里将放在下一篇webgl相关优化中去说</p>    <p>本文就到这里啦,有问题和建议的小伙伴欢迎留言一起讨论~!</p>    <p> </p>    <p>来自:http://www.alloyteam.com/2017/03/teach-you-to-use-webgl-to-quickly-create-a-small-world/</p>    <p> </p>