前面所有案例中的 3D 模型采用的都是直接给出顶点坐标值((如立方体)或基于数学式用程序生成坐标值(如球体)的方式,这在一些简单的游戏场景中已经足够了。但如果建立一些更复杂、逼真的游戏场景,则物体需要的几何形状可能会很复杂而且不能直接用学公式来描述,如赛车游戏中的车、空战游戏中的战斗机等。
在这种情况下,一般首先用3D建模工具(如3ds Max、Maya、Blender等)建立物体模型,然后导出为特定格式的模型文件并在应用程序中加载渲染。常用的 3D 模型文件格式有obj、3ds、fbx等,这次主要介绍obj模型文件的加载。
obj模型文件概述
obj文件是一种最简单的3D模型文件,其本质上就是文本文件,只是具有固定的格式而已。在obj文件中顶点坐标、三角形面、纹理坐标等信息以固定格式的文本字符串表示,下面给出了一个obj文本的片段。
# Max2Obj Version 4.0 Mar 10th, 2001
……
#
v 4.531507 5.223516 -0.079789
v 4.800519 5.223516 -0.07978
# 2 vertices
vt 4.531507 5.223516 -0.079789
vt 4.800519 5.223516 -0.07978
# 2 texture vertices
vn 4.531507 5.223516 -0.079789
vn 4.481378 5.432536 -0.084661
# 2 vertices normals
g (null)
f 1 8 9
f 9 2 1
# 2 face
g
从上述obj文件片段中可以看出,其内容是以行为基本单位进行组织的,每种以不前缀开头的行有不同的含义,具体情况如下所示。
- 以"#"号开头的行为注释,在程序加载的过程中可以忽略它。
- 以"v"开头的行作用于存放顶点坐标,其后面的3跟数值分别表示一个顶点的x,y,z坐标。
- 以"vt"开头的行作用于存放顶点纹理坐标,其后面的3个数值分别代码纹理坐标的S,T,P分量。
- 以"vn开头的行用于存放顶点法向量,其后面的3个数值分别代表一个顶点的法向量在x轴,y轴,z轴上的分量。
- 以"g"开头的行表示一组的开始,后面的字符串为此组的名称。所谓组是指由顶点组成的一些面的集合。只包含"g"的行代表一组的结束,以于"g"开头的行形成对应。
- 以"f"开头的行表示组中的一个面,如果是三角形则后面有三组用空格分隔的数据,代表三角形的3个顶点。每组数据的包含3个数值,用"/"分隔,依次代表顶点坐标数据索引、顶点纹理坐标索引、顶点向量数据索引。
在obj文件中一般顶点坐标与面的数据是必须选择的,而法向量与纹理数据是可选的。
加载obj文件
1.先介绍下渲染物体的函数。
//加载的用于绘制的3D物体
function ObjObject(gl,vertexDataIn programIn){ //GL上下文//顶点坐标数组//着色器程序对象
//接收顶点数据
this.vertexData=vertexDataIn;
//得到顶点数量
this.vcount=this.vertexData.length/3;
//创建顶点数据缓冲
this.vertexBuffer=gl.createBuffer();
//将顶点数据送入缓冲
gl.bindBuffer(gl.ARRAY_BUFFER,this.vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(this.vertexData),gl.STATIC_DRAW);
//加载着色器程序
this.program=programIn;
this.drawSelf=function(ms){
//送入总矩阵
var uMVPMatrixHandle=gl.getUniformLocation(this.program, "uMVPMatrix");
gl.uniformMatrix4fv(uMVPMatrixHandle,false,new Float32Array(ms.getFinalMatrix()));
//送入变换矩阵
var uMMatrixHandle=gl.getUniformLocation(this.program, "uMMatrix");
gl.uniformMatrix4fv(uMMatrixHandle,false,new Float32Array(ms.currMatrix));
//送入摄像机位置
var uCameraHandle=gl.getUniformLocation(this.program, "uCamera");
gl.uniform3fv(uCameraHandle,new Float32Array([ms.cx,ms.cy,ms.cz]));
//启用顶点数据
gl.enableVertexAttribArray(gl.getAttribLocation(this.program, "aPosition"));
//将顶点数据送入渲染管线
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.vertexAttribPointer(gl.getAttribLocation(this.program, "aPosition"), 3, gl.FLOAT, false, 0, 0);
//用顶点法绘制物体
gl.drawArrays(gl.TRIANGLES, 0, this.vcount);
}
}
2.开发完绘制加载物体的代码后,接下来将详细介绍读取obj文件请求并对接收的数据进行处理的LoadBall.js.
//请求读取obj文件的方法
function loadObjFile(url){
var req = new XMLHttpRequest();//创建对服务器的请求
req.onreadystatechange = function () { processLoadObj(req) };//重写请求的onreadystatechange事件
req.open("GET", url, true);//设置请求的类型和obj文件的路径
req.responseType = "text";//设置回应的类型
req.send(null);
}
function createObj(objDataIn){//用提取的数据创建绘制对象的方法
if(shaderProgArray[0]){//如果着色器已经进行编译
ooTri=new ObjObject(gl,objDataIn.vertices,shaderProgArray[0]); //创建绘制用的物体
}else{//如果着色器还未进行编译
setTimeout(function(){createObj(objDataIn);},10); //10毫秒之后再次进行调用
}
}
function processLoadObj(req){//重写后的onreadystatechange事件
if (req.readyState == 4){ //当接受到服务器传来的数据后
var objStr = req.responseText;//得到obj文件的文本数据
var dataTemp=fromObjStrToObjectData(objStr);//对数据进行处理和提取
createObj(dataTemp);//用提取的数据创建绘制对象
}
}
3.对接收到的obj数据进行解析处理,提取出顶点坐标数组。
function fromObjStrToObjectData(objStr) {// 原始顶点坐标列表--直接从obj文件中加载
var alv = new Array().fill(0);// 结果顶点坐标列表--按面组织好
var alvResult = [];
var aln = [];// 计算出的法向量坐标
var alnResult = [];//面索引的列表
var alFaceIndex = [];
var setOfNormal = new SetOfNormal();
var lines = objStr.split("\n");
for (var lineIndex in lines) {
var line = lines[lineIndex].replace(/[ \t]+/g, " ").replace(/\s\s*$/, "");
if (line[0] == "#") {
continue;
}
var array = line.split(" ");
if (array[0] == "v") {
alv.push(parseFloat(array[1]));
alv.push(parseFloat(array[2]));
alv.push(parseFloat(array[3]));
} else if (array[0] == "f") {
var index = new Array(3); //三个顶点索引值的数组
if (array.length != 4) {
alert("array.length != 4");
continue;
}
var tempArray = array[1].split("/");
index[0] = tempArray[0] - 1;
vx0 = alv[index[0] * 3 + 0];
vy0 = alv[index[0] * 3 + 1];
vz0 = alv[index[0] * 3 + 2];
alvResult.push(vx0);
alvResult.push(vy0);
alvResult.push(vz0);
alFaceIndex.push(index[0]);
var tempArray = array[2].split("/");
index[1] = tempArray[0] - 1;
vx1 = alv[index[1] * 3 + 0];
vy1 = alv[index[1] * 3 + 1];
vz1 = alv[index[1] * 3 + 2];
alvResult.push(vx1);
alvResult.push(vy1);
alvResult.push(vz1);
alFaceIndex.push(index[1]);
var tempArray = array[3].split("/");
index[2] = tempArray[0] - 1;
vx2 = alv[index[2] * 3 + 0];
vy2 = alv[index[2] * 3 + 1];
vz2 = alv[index[2] * 3 + 2];
alvResult.push(vx2);
alvResult.push(vy2);
alvResult.push(vz2);
alFaceIndex.push(index[2]);//记录此面的顶点索引
var vxa = vx1 - vx0;
var vya = vy1 - vy0;
var vza = vz1 - vz0;
var vxb = vx2 - vx0;
var vyb = vy2 - vy0;
var vzb = vz2 - vz0;
var vNormal = vectorNormal(getCrossProduct(vxa, vya, vza, vxb, vyb, vzb));
setOfNormal.add(index[0], vNormal);
setOfNormal.add(index[1], vNormal);
setOfNormal.add(index[2], vNormal);
}
}
for (i = 0; i < setOfNormal.array.length; i++) {
var avernormal = new Array(0, 0, 0);
if (setOfNormal.array[i] != null) {
for (j = 0; j < setOfNormal.array[i].length; j++) {
avernormal[0] += (setOfNormal.array[i][j]).nx;
avernormal[1] += (setOfNormal.array[i][j]).ny;
avernormal[2] += (setOfNormal.array[i][j]).nz;
}
avernormal = vectorNormal(avernormal);
aln.push(avernormal.nx, avernormal.ny, avernormal.nz);
}
}
for (i = 0; i < alFaceIndex.length; i++) {
alnResult.push(aln[alFaceIndex[i] * 3], aln[alFaceIndex[i] * 3 + 1], aln[alFaceIndex[i] * 3 + 2]);
}
return new ObjectData(alvResult.length / 3, alvResult, alnResult);
}
再编写下着色器代码
#version 300 es
uniform mat4 uMVPMatrix; //总变换矩阵
in vec3 aPosition; //从渲染管线接收的顶点位置
out vec3 vPosition; //用于传递给片元着色器的顶点位置
void main() {
gl_Position = uMVPMatrix * vec4(aPosition,1); //根据总变换矩阵计算此次绘制此顶点的位置
vPosition=aPosition;//将顶点位置传递给片元着色器
}
#version 300 es
precision mediump float;//给出默认浮点精度
in vec3 vPosition; //从顶点着色器接收的顶点位置
out vec4 fragColor;//最终片元颜色
void main() {
vec4 bColor=vec4(0.678,0.231,0.129,1.0);//条纹的颜色(深红色)
vec4 mColor=vec4(0.763,0.657,0.614,1.0);//间隔区域的颜色(淡红色)
float y=vPosition.y;//提取顶点的y坐标值
y=mod((y+100.0)*4.0,4.0);//折算出区间值
if(y>1.8) {//当区间值大于指定值时
fragColor = bColor;//设置片元颜色为条纹的颜色
} else {//当区间值不大于指定值时
fragColor = mColor;//设置片元颜色为间隔区域的颜色
}}
运行下,效果如下:
效果不太好看,那我们加入光照和法线的效果。
修改顶点着色器
#version 300 es
uniform mat4 uMVPMatrix; //总变换矩阵
uniform mat4 uMMatrix; //变换矩阵
uniform vec3 uLightLocation; //光源位置
uniform vec3 uCamera; //摄像机位置
in vec3 aPosition; //顶点位置
in vec3 aNormal; //顶点法向量
out vec4 finalLight;//最终光
//定位光光照计算的方法
void pointLight( //定位光光照计算的方法
in vec3 normal, //法向量
inout vec4 ambient, //环境光最终强度
inout vec4 diffuse, //散射光最终强度
inout vec4 specular, //镜面光最终强度
in vec3 lightLocation, //光源位置
in vec4 lightAmbient, //环境光强度
in vec4 lightDiffuse, //散射光强度
in vec4 lightSpecular //镜面光强度
){
ambient=lightAmbient; //直接得出环境光的最终强度
vec3 normalTarget=aPosition+normal; //计算变换后的法向量
vec3 newNormal=(uMMatrix*vec4(normalTarget,1)).xyz-(uMMatrix*vec4(aPosition,1)).xyz;
newNormal=normalize(newNormal); //对法向量规格化
//计算从表面点到摄像机的向量
vec3 eye= normalize(uCamera-(uMMatrix*vec4(aPosition,1)).xyz);
//计算从表面点到光源位置的向量vp
vec3 vp= normalize(lightLocation-(uMMatrix*vec4(aPosition,1)).xyz);
vp=normalize(vp);//格式化vp
vec3 halfVector=normalize(vp+eye); //求视线与光线的半向量
float shininess=5.0; //粗糙度,越小越光滑
float nDotViewPosition=max(0.0,dot(newNormal,vp)); //求法向量与vp的点积与0的最大值
diffuse=lightDiffuse*nDotViewPosition; //计算散射光的最终强度
float nDotViewHalfVector=dot(newNormal,halfVector); //法线与半向量的点积
float powerFactor=max(0.0,pow(nDotViewHalfVector,shininess)); //镜面反射光强度因子
specular=lightSpecular*powerFactor; //计算镜面光的最终强度
}
void main()
{
gl_Position = uMVPMatrix * vec4(aPosition,1); //根据总变换矩阵计算此次绘制此顶点位置
vec4 tempColor=vec4(1.0,1.0,1.0,1.0);
vec4 ambientTemp, diffuseTemp, specularTemp; //存放环境光、散射光、镜面反射光的临时变量
pointLight(normalize(aNormal),ambientTemp,diffuseTemp,specularTemp,uLightLocation,vec4(0.1,0.1,0.1,1.0),vec4(0.7,0.7,0.7,1.0),vec4(0.3,0.3,0.3,1.0));
finalLight=(ambientTemp+diffuseTemp+specularTemp);
}
修改面片着色器
precision mediump float;
in vec4 finalLight;//最终光
out vec4 fragColor;//输出到的片元颜色
void main(){
//将计算出的颜色给此片元
vec3 Color=vec3(0.9,0.9,0.9);
fragColor = vec4(finalLight.xyz*Color,1.0);//给此片元颜色值
}
添加法线
图1 面片法向量效果图
在绘制光滑曲面的时候,一个顶点的 法线取相邻的面法线的平均值 顶点法向量效果图
图2
加载纹理贴图
在现实世界中的物体表面并不一定是纯色的,可能是花纹的,如白瓷的手绘茶壶。
绘制函数添加纹理代码。
//接收顶点纹理坐标数据
this.vertexTexCoor=vertexTexCoorIn;
//创建顶点纹理坐标缓冲
this.vertexTexCoorBuffer=gl.createBuffer();
//将顶点纹理坐标数据送入缓冲
gl.bindBuffer(gl.ARRAY_BUFFER,this.vertexTexCoorBuffer);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(this.vertexTexCoor),gl.STATIC_DRAW);
对应解析obj文件增加接受vt的代码。 运行效果如下:
同理,上面的法线是通过我们计算获取的,在导出obj是可生产法线数据,可通过解析代替计算。**
双面光照效果
开启背面裁剪效果
gl.enable(gl.CULL_FACE);
关闭背面裁剪效果
gl.disable(gl.CULL_FACE);
对应的webgl的gl_FrontFacing变量可提供面片是正面还是背面,可因此对背面进行而外处理,实现内外不同的颜色亮度的效果。