相信很多人是以创建逼真酷炫的三维效果为目标而学习webGL的吧,首先我就是😂。我掌握了足够的webGL技巧后,正准备大展身手时,遇到了一种尴尬的情况:还是做不出想要的东西😭。为啥呢,因为没有3D模型可供操作啊,纯粹用代码构建复杂的3D模型完全不可想象。
必须使用3dMax,maya,以及开源的blender等建模软件进行构建。既然已经入了webGL的坑了,那也只能硬着头皮继续学习3D建模,断断续续学了一个多月的blender教程,总算入门了。
这节主要学习如何导入模型文件,然后用代码应用效果,操作模型。首先展示下我的大作,喷火战斗机的3D模型:webGL 喷火战斗机
内容大纲
模型文件
着色器
光照
模型变换
事件处理
模型文件 blender导出的模型文件plane.obj, 同时还包括材质文件plane.mtl。模型包括2800多个顶点,2200多个面,共200多k的体积,内容比较大,所以只能将文件加载入html文件比较方便。
怎么加载呢?一般会使用ajax获取,但我这里有更方便的办法。那就是将模型文件内容预编译直出到html中,这样不但提高了加载性能,开发也更方便。具体可参考我之前的文章:前端快速开发模版
这里使用我之前的开发模版, 将模型(obj、mtl)文件以字符串的形式写入text/template模版中,同时将GLSL语言写的着色器也预编译到html中。到时用gulp的命令构建页面,所有内容就会自动生成到页面中,html部分的代码如下所示:
{% extends '../layout/layout.html' %} {% block title %}spitfire fighter{% endblock %} {% block js %} <script src ="./lib/webgl.js" > </script > <script src ="./lib/objParse.js" > </script > <script src ="./lib/matrix.js" > </script > <script src ="./js/index.js" > </script > {% endblock %} {% block content %} <div class ="content" > <p > 上下左右方向键 调整视角,W/S/A/D键 旋转模型, +/-键 放大缩小</p > <canvas id ="canvas" width ="800" height ="600" > </canvas > </div > <script type ="text/template" id ="tplObj" > {% include '../model/plane.obj' %} </script > <script type ="text/template" id ="tplMtl" > {% include '../model/plane.mtl' %} </script > <script type ="x-shader/x-vertex" id ="vs" > {% include '../glsl/vs.glsl' %} </script > <script type ="x-shader/x-fragment" id ="fs" > {% include '../glsl/fs.glsl' %} </script > {% endblock %}
obj文件 obj文件包含的是模型的顶点法线索引等信息。这里以最简单的立方体为例。
v 几何体顶点
vt 贴图坐标点
vn 顶点法线
f 面:顶点索引 / 纹理坐标索引 / 法线索引
usemtl 使用的材质名称
# Blender v2.79 (sub 0) OBJ File: '' # www.blender.org mtllib cube.mtl o Cube v -0.442946 -1.000000 -1.000000 v -0.442946 -1.000000 1.000000 v -2.442946 -1.000000 1.000000 v -2.442945 -1.000000 -1.000000 v -0.442945 1.000000 -0.999999 v -0.442946 1.000000 1.000001 v -2.442946 1.000000 1.000000 v -2.442945 1.000000 -1.000000 vn 0.0000 -1.0000 0.0000 vn 0.0000 1.0000 0.0000 vn 1.0000 0.0000 0.0000 vn -0.0000 -0.0000 1.0000 vn -1.0000 -0.0000 -0.0000 vn 0.0000 0.0000 -1.0000 usemtl Material s off f 1//1 2//1 3//1 4//1 f 5//2 8//2 7//2 6//2 f 1//3 5//3 6//3 2//3 f 2//4 6//4 7//4 3//4 f 3//5 7//5 8//5 4//5 f 5//6 1//6 4//6 8//6
mtl文件 mtl文件包含的是模型的材质信息
Ka 环境色 rgb
Kd 漫反射色,材质颜色 rgb
Ks 高光色,材质高光颜色 rgb
Ns 反射高光度 指定材质的反射指数
Ni 折射值 指定材质表面的光密度
d 透明度
# Blender MTL File: 'None' # Material Count: 1 newmtl Material Ns 96.078431 Ka 1.000000 1.000000 1.000000 Kd 0.640000 0.640000 0.640000 Ks 0.500000 0.500000 0.500000 Ke 0.000000 0.000000 0.000000 Ni 1.000000 d 1.000000 illum 2
知道了obj和mtl文件的格式,我们需要做的就是读取它们,逐行分析,这里使用的objParse读取解析,想知道内部原理,可以查看源代码,这里不详述。
提取出需要的信息后,就可将模型信息写入缓冲区,然后渲染出来。
var canvas = document .getElementById('canvas' ), gl = get3DContext(canvas, true ), objElem = document .getElementById('tplObj' ), mtlElem = document .getElementById('tplMtl' ); function main ( ) { var program = gl.program; program.a_Position = gl.getAttribLocation(gl.program, 'a_Position' ); var vertexBuffer = createEmptyArrayBuffer(gl, program.a_Position, 3 , gl.FLOAT); var objDoc = new OBJDoc('plane' ,objElem.text,mtlElem.text); if (!objDoc.parse(1 , false )){return ;} var drawingInfo = objDoc.getDrawingInfo(); gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, drawingInfo.vertices, gl.STATIC_DRAW); }
着色器 顶点着色器 顶点着色器比较简单,和之前的区别比较大的是,把计算颜色光照部分移到了片元着色器,这样可以实现逐片元光照,效果会更加逼真和自然。
attribute vec4 a_Position; attribute vec4 a_Color; attribute vec4 a_Scolor; attribute vec4 a_Normal; uniform mat4 u_MvpMatrix; uniform mat4 u_ModelMatrix; uniform mat4 u_NormalMatrix; varying vec4 v_Color; varying vec3 v_Normal; varying vec3 v_Position; void main () { gl_Position = u_MvpMatrix * a_Position; v_Position = vec3(u_ModelMatrix * a_Position); v_Normal = normalize(vec3(u_NormalMatrix * a_Normal)); v_Color = a_Color; }
光照 光照相关的计算主要在片元着色器中,首先科普一下光照的相关信息。
物体呈现出颜色亮度就是表面的反射光导致,计算反射光公式如下: <表面的反射光颜色> = <漫反射光颜色> + <环境反射光颜色> + <镜面反射光颜色> 1. 其中漫反射公式如下: <漫反射光颜色> = <入射光颜色> * <表面基底色> * <光线入射角度> 光线入射角度可以由光线方向和表面的法线进行点积求得: <光线入射角度> = <光线方向> * <法线方向> 最后的漫反射公式如下: <漫反射光颜色> = <入射光颜色> * <表面基底色> * (<光线方向> * <法线方向>) 2. 环境反射光颜色根据如下公式得到: <环境反射光颜色> = <入射光颜色> * <表面基底色> 3. 镜面(高光)反射光颜色公式,这里使用的是冯氏反射原理 <镜面反射光颜色> = <高光颜色> * <镜面反射亮度权重> 其中镜面反射亮度权重又如下 <镜面反射亮度权重> = (<观察方向的单位向量> * <入射光反射方向>) ^ 光泽度
片元着色器 着色器代码就是对上面公式内容的演绎
precision mediump float ;uniform vec3 u_LightPosition;uniform vec3 u_diffuseColor;uniform vec3 u_AmbientColor;uniform vec3 u_specularColor;uniform float u_MaterialShininess;varying vec3 v_Normal;varying vec3 v_Position;varying vec4 v_Color;void main() { vec3 normal = normalize (v_Normal); vec3 lightDirection = normalize (u_LightPosition - v_Position); float nDotL = max (dot (lightDirection, normal), 0.0 ); vec3 diffuse = u_diffuseColor * nDotL * v_Color.rgb; vec3 ambient = u_AmbientColor * v_Color.rgb; vec3 eyeDirection = normalize (-v_Position); vec3 reflectionDirection = reflect (-lightDirection, normal); float specularLightWeighting = pow (max (dot (reflectionDirection, eyeDirection), 0.0 ), u_MaterialShininess); vec3 specular = lightColor.rgb * specularLightWeighting ; gl_FragColor = vec4 (ambient + diffuse + specular, v_Color.a); }
模型变换 这里先设置光照相关的初始条件,然后是mvp矩阵变换和法向量矩阵相关的计算,具体知识点可参考之前的文章WebGL学习(2) - 3D场景
要注意的是逆转置矩阵,主要用于计算模型变换之后的法向量,有了变换后的法向量才能正确计算光照。
求逆转置矩阵步骤 1.求原模型矩阵的逆矩阵 2.将逆矩阵转置 <变换后法向量> = <逆转置矩阵> * <变换前法向量>
给着色器变量赋值然后绘制出模型,最后调用requestAnimationFrame不断执行动画。矩阵的旋转部分可结合下面的keydown事件进行查看。
function main ( ) { gl.uniform3f(u_LightPosition, 0.0 , 2.0 , 12.0 ); gl.uniform3f(u_diffuseColor, 1.0 , 1.0 , 1.0 ); gl.uniform3f(u_AmbientColor, 0.5 , 0.5 , 0.5 ); gl.uniform1f(u_MaterialShininess, 30.0 ); var modelMatrix = new Matrix4(); var mvpMatrix = new Matrix4(); var normalMatrix = new Matrix4(); var n = drawingInfo.indices.length; (function animate ( ) { if (notMan) { angleY += 0.5 ; } modelMatrix.setRotate(angleY % 360 , 0 , 1 , 0 ); modelMatrix.rotate(angleX % 360 , 1 , 0 , 0 ); var eyeY = viewLEN * Math .sin((viewAngleY * Math .PI) / 180 ), len = viewLEN * Math .cos((viewAngleY * Math .PI) / 180 ), eyeX = len * Math .sin((viewAngleX * Math .PI) / 180 ), eyeZ = len * Math .cos((viewAngleX * Math .PI) / 180 ); mvpMatrix.setPerspective(30 , canvas.width / canvas.height, 1 , 300 ); mvpMatrix.lookAt( eyeX, eyeY, eyeZ, 0 , 0 , 0 , 0 , viewAngleY > 90 || viewAngleY < -90 ? -1 : 1 , 0 ); mvpMatrix.multiply(modelMatrix); normalMatrix.setInverseOf(modelMatrix); normalMatrix.transpose(); gl.uniformMatrix4fv(u_ModelMatrix, false , modelMatrix.elements); gl.uniformMatrix4fv(u_MvpMatrix, false , mvpMatrix.elements); gl.uniformMatrix4fv(u_NormalMatrix, false , normalMatrix.elements); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_SHORT, 0 ); requestAnimationFrame(animate); })(); }
事件处理 +/- 键实现放大/缩小场景的功能;WSAD键实现模型的旋转,也就是实现绕x轴和y轴旋转;上下左右方向键实现的是视点的旋转。矩阵变换的相关实现参考上面代码的动画部分。
模型旋转和视点旋转看着很相似,其实又有不同的。视点的旋转是整个场景比如光照模型等都是跟着变化的,如果以场景做参照物,它就相当于人改变观察位置观看物体。而模型旋转呢,它只旋转模型自身,外部的光照和场景都是不变的,以场景做参照物,相当于人在同一位置观看模型在运动。从demo的光照可以看出两种方式的区别。
document .addEventListener( "keydown" , function (e ) { if ([37 , 38 , 39 , 65 , 58 , 83 , 87 , 40 ].indexOf(e.keyCode) > -1 ) notMan = false ; switch (e.keyCode) { case 38 : viewAngleY -= 2 ; if (viewAngleY < -270 ) viewAngleY += 360 ; break ; case 40 : viewAngleY += 2 ; if (viewAngleY > 270 ) viewAngleY -= 360 ; break ; case 37 : viewAngleX += 2 ; break ; case 39 : viewAngleX -= 2 ; break ; case 87 : angleX -= 2 ; break ; case 83 : angleX += 2 ; break ; case 65 : angleY += 2 ; break ; case 68 : angleY -= 2 ; break ; case 187 : if (viewLEN > 6 ) viewLEN--; break ; case 189 : if (viewLEN < 30 ) viewLEN++; break ; default : break ; } }, false );
总结 最后,个人感觉建立3D模型还是挺费时间,需要花心机慢慢调整,才能做出比较完美的模型。