截止到目前,我们已经熟悉了 WebGL 的开发步骤:
- 初始化阶段
- 创建所有着色器程序。
- 寻找全部 attribute 参数位置。
- 寻找全部 uniforms 参数位置。
- 创建缓冲区,并向缓冲区上传顶点数据。
- 创建纹理,并上传纹理数据。
- 首次渲染阶段
- 为 uniforms 变量赋值。
- 处理 attribute 变量
- 使用 gl.bindBuffer 重新绑定模型的 attribute 变量。
- 使用 gl.enableVertexAttribArray 启用 attribute 变量。
- 使用 gl.vertexAttribPointer设置 attribute变量从缓冲区中读取数据的方式。
- 使用 gl.bufferData 将数据传送到缓冲区中。
- 使用 gl.drawArrays 执行绘制。
- 后续渲染阶段
- 对发生变化的 uniforms 变量重新赋值。
- 每个模型的 attribute 变量。
- 使用 gl.bindBuffer 重新绑定模型的 attribute 变量。
- 使用 gl.bufferData 重新向缓冲区上传模型的 attribute 数据。
- 使用 gl.drawArrays 执行绘制。
这就是 WebGL 的基本绘制流程,但是这些只是在绘制单个模型时的步骤。思考一下,如果我们有多个模型,会碰到哪些问题?如何进行优化?
这里提到了模型的概念,3D 中的模型是由顶点
vertex
组成,顶点之间连成三角形,多个三角形就能够组成复杂的立体模型。简单模型诸如立方体、球体等,复杂模型诸如汽车、茶壶等。类比到现实世界中,模型可以理解为现实生活中看得见摸得着的物体。
创建模型类
每个模型都有对应的顶点数据,包含顶点位置、颜色、法向量、纹理坐标等,我们将这些数据用一个顶点缓冲对象来表示,每个属性对应一个 attribute
变量。除了顶点数据,还需要有众多 uniforms
变量,uniforms 变量存储和顶点无关的属性,比如模型变换矩阵
、模型视图投影矩阵MVP
,(后续我们用 MVP
指代模型视图投影矩阵),法向量矩阵,光照等。既然模型有这么多共同的属性,那么我们把模型抽象出来。
定义一个模型类,模型类自身属性有模型矩阵u_ModelMatrix
,MVP 矩阵u_Matrix
,以及所有的 uniforms 变量,顶点缓冲数据。
//模型类
function Model(bufferInfo, uniforms ){
this.uniforms = uniforms || {};
this.u_Matrix = matrix.identity();
this.bufferInfo = bufferInfo || {};
// 偏移
this.translation = [0, 0, 0];
// 旋转角度
this.rotation = [0, 0, 0];
// 缩放
this.scalation = [1, 1, 1];
}
matrix.identity 方法生成一个单位矩阵。
设置顶点对象
提供一个为模型提供顶点数据的方法,顶点数据用一个对象表示,对象的属性用着色器中属性名称来 表示,对应顶点属性。一个完整的 bufferInfo
包含如下内容:
bufferInfo = {
attributes:{
a_Positions: {
buffer: buffer,
type: gl.FLOAT,
normalize: false,
numsPerElement: 4,
},
a_Colors:{
buffer:buffer,
type: gl.UNSIGNED_BYTE,
normalize: true,
numsPerElement: 4
},
a_Normals:{
buffer:buffer,
type: gl.FLOAT,
normalize: false,
numsPerElement: 3
},
a_Texcoords:{
buffer:buffer,
type: gl.FLOAT,
normalize: false,
numsPerElement: 2
}
},
indices:[],
elementsCount: 30
}
indices
代表顶点的索引数组, elementsCount
表示顶点的个数。buffer 代表 WebGL 创建的 buffer 对象,里面存储着对应的顶点数据。
顶点数据对象除了可以在初始化时为 model 设置以外,还需要为 model 提供一个单独设置方法:
Model.prototype.setBufferInfo = function(bufferInfo){
this.bufferInfo = bufferInfo || {};
}
我们最初得到的顶点模型数据一般是这种格式的:
let vertexObject = {
positions: [],
normals: [],
texcoords: [],
indices: [],
colors: []
}
这和我们上面设置的字段格式都不同,所以我们要添加一个适配器转换一下。
设置模型状态
我们需要一些方法能够随时对模型对象的信息进行修改,比如位移,旋转角度,缩放比例等,最后还需要增加一个 preRender 预渲染方法,在绘制之前更新矩阵。
设置模型位移。
位移的设置包含同时对三个分量设置以及对每个分量单独设置:
- translate:对模型设置 X 轴、Y 轴、Z 轴方向的偏移。
- translateX:对模型设置 X 轴偏移。
- translateY:对模型设置 Y 轴偏移。
- translateZ:对模型设置 Z 轴偏移。
Model.prototype.translate = function(tx, ty, tz){
this.translateX(tx);
this.translateY(ty);
this.translateZ(tz);
}
Model.prototype.translateX = function(tx){
this.translation[0] = tx || 0;
}
Model.prototype.translateY = function(ty){
this.translation[1] = ty || 0;
}
Model.prototype.translateZ = function(tz){
this.translation[2] = tz || 0;
}
设置模型缩放比例。
缩放比例的设置包含同时对三个分量设置以及对每个分量单独设置:
- scale:对模型设置 X 轴、Y 轴、Z 轴上的缩放比例。
- scaleX:对模型设置 X 轴缩放比例。
- scaleY:对模型设置 Y 轴缩放比例。
- scaleZ:对模型设置 Z 轴缩放比例。
Model.prototype.scale = function(sx, sy, sz){
this.scaleX(sx);
this.scaleY(sy);
this.scaleZ(sz);
}
Model.prototype.scaleX = function(sx){
this.scalation[0] = sx || 1;
}
Model.prototype.scaleY = function(sy){
this.scalation[1] = sy || 1;
}
Model.prototype.scaleZ = function(sz){
this.scalation[2] = sz || 1;
}
设置模型旋转角度。
模型旋转角度的设置包含同时对三个分量设置以及对每个分量单独设置:
- rotate:对模型设置 X轴、Y轴、Z 轴上的旋转角度。
- rotateX:对模型设置 X 轴旋转角度。
- rotateY:对模型设置 Y 轴旋转角度。
- rotateZ:对模型设置 Z 轴旋转角度。
Model.prototype.rotate = function(rx, ry, rz){
this.rotateX(rx);
this.rotateY(ry);
this.rotateZ(rz);
}
Model.prototype.rotateX = function(rx){
this.rotation[0] = rx || 0;
}
Model.prototype.rotateY = function(ry){
this.rotation[1] = ry || 0;
}
Model.prototype.rotateZ = function(rz){
this.rotation[2] = rz || 0;
}
预渲染。
在将模型矩阵以及模型的 MVP 矩阵传递给 GPU 之前,我们对模型矩阵以及 MVP 矩阵重新计算。
- rotate:对模型设置 X 轴、Y 轴、Z 轴上的旋转角度。
- rotateX:对模型设置 X 轴旋转角度。
- rotateY:对模型设置 Y 轴旋转角度。
- rotateZ:对模型设置 Z 轴旋转角度。
Model.prototype.preRender = function( viewMatrix, projectionMatrix){
let modelMatrix = matrix.identity();
if (this.translation) {
modelMatrix = matrix.translate(
modelMatrix,
this.translation[0],
this.translation[1],
this.translation[2]
);
}
if (this.rotation) {
if (this.rotation[0] !== undefined)
modelMatrix = matrix.rotateX(modelMatrix, degToRadians(this.rotation[0]));
if (this.rotation[1] !== undefined)
modelMatrix = matrix.rotateY(modelMatrix, degToRadians(this.rotation[1]));
if (this.rotation[2] !== undefined)
modelMatrix = matrix.rotateZ(modelMatrix, degToRadians(this.rotation[2]));
}
if (this.scalation) {
modelMatrix = matrix.scale(
modelMatrix,
this.scalation[0],
this.scalation[1],
this.scalation[2]
);
}
this.u_ModelMatrix = modelMatrix;
//重新计算 MVP 矩阵
this.u_Matrix = matrix.multiply(viewMatrix, this.u_ModelMatrix);
this.u_Matrix = matrix.multiply(projectionMatrix, this.u_Matrix);
}
封装顶点数据的操作
最为重要的是顶点数据,它们是模型的基本组成元素,顶点数据一般包含如下几个属性:
- 颜色信息
- 位置信息
- 法向量信息
- 索引信息
- 纹理坐标
bufferInfo = {
colors: [],
positions: [],
normals: [],
indices: [],
texcoords: []
}
我们有了这些顶点信息,还需要通过 attribute 变量传递给 GPU,所以,我们还需要找到对应的 attribute 变量。
在着色器中命名 attribute 变量时,我们通常使用 a_
开头,后面跟着顶点属性名称,按照这种规范命名也方便我们在 JavaScript 中对变量进行赋值。
attribute vec4 a_Positions;
attribute vec3 a_Normals;
attribute vec2 a_Texcoords;
attribute vec4 a_Colors;
那么我们查找变量时,可以这样查找:
let attributesCount = gl.getProgramParameter(program, param);
当 pname 为 gl.ACTIVE_ATTRIBUTES时,返回program绑定的顶点着色器中 attribute 变量的数量 attributesCount。
有了变量数量,我们就可以对变量进行遍历了。
for(let i = 0; i< attributesCount; i++){
let attributeInfo = gl.getActiveAttrib(program, i);
}
attributeInfo 对象包含 attribute 的变量名称 name,有了name
我们就能够用 JavaScript 查找该 attribute 变量了:
let attributeIndex = gl.getAttribLocation(program, attributeInfo.name);
接着是熟悉的对变量的启用、读取缓冲区方式的设置了,我们将这些操作封装到一个方法中。
function createAttributeSetter(attributeIndex){
return function(bufferInfo){
gl.bindBuffer(gl.ARRAY_BUFFER, bufferInfo.buffer);
gl.enableVertexAttribArray(attributeIndex);
gl.vertexAttribPointer(
attributeIndex,
bufferInfo.numsPerElement || bufferInfo.size,
bufferInfo.type || gl.FLOAT,
bufferInfo.normalize || false,
bufferInfo.stride || 0,
bufferInfo.offset || 0
);
}
}
定义一个 attribute 变量设置对象,对每个 attribute 绑定上面实现的设置方法createAttributeSetter
。
let attributeSetter = {};
for(let i = 0; i< attributesCount; i++){
let attributeInfo = gl.getActiveAttrib(program, i);
let attributeIndex = gl.getAttribLocation(program, attributeInfo.name);
attributeSetter[attributeInfo.name] = createAttributeSetter(attributeIndex);
}
return attributeSetter;
以上是对着色器的各个attribute变量初始化操作,那么当我们需要对这些变量赋值时,就可以调用attribute 变量对应的 setter 函数对 attribute 进行设置了。
封装 uniforms 变量操作。
那么,除了 attribute 变量,程序中还充斥着很多 uniforms 变量,uniforms 变量是与顶点无关的,即不管执行多少遍顶点操作, uniforms 变量始终保持不变。
像 attribute 变量一样,我们仍然需要先找到所有 uniforms 变量:
let uniformsCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
之后,遍历所有 uniforms 变量,根据 uniforms 变量名称,生成 uniforms 赋值函数。
let uniformsSetters = {};
for(let i = 0; i< uniformsCount; i++){
let uniformInfo = gl.getActiveUniform(program, i);
if (!uniformInfo) {
break;
}
let name = uniformInfo.name;
if (name.substr(-3) === '[0]') {
name = name.substr(0, name.length - 3);
}
var setter = createUniformSetter(program, uniformInfo);
uniformSetters[name] = setter;
}
uniforms 赋值函数比较繁琐一些,只因 uniforms 变量类型比较多,我们需要针对 uniforms 变量类型,编写对应的赋值函数。
let enums = {
FLOAT_VEC2: {
value: 0x8B50,
setter: function(location, v){
gl.uniform2fv(location, v);
}
},
FLOAT_VEC3: {
value: 0x8B51,
setter: function(location, v){
gl.uniform3fv(location, v);
}
}
FLOAT_VEC4: {
value: 0x8B52,
setter: function(location, v){
gl.uniform3fv(location, v);
}
},
INT_VEC2: {
value: 0x8B53,
setter: function(location, v){
gl.uniform2iv(location, v);
}
},
INT_VEC3: {
value: 0x8B54,
setter: function(location, v){
gl.uniform3iv(location, v);
}
},
INT_VEC4: {
value: 0x8B55,
setter: function(location, v){
gl.uniform4iv(location, v);
}
},
BOOL: {
value: 0x8B56,
setter: function(location, v){
gl.uniform1iv(location, v);
}
},
BOOL_VEC2: {
value: 0x8B57,
setter: function(location, v){
gl.uniform2iv(location, v);
}
},
BOOL_VEC3: {
value: 0x8B58,
setter: function(location, v){
gl.uniform3iv(location, v);
}
},
BOOL_VEC4: {
value: 0x8B59,
setter: function(location, v){
gl.uniform4iv(location, v);
}
},
FLOAT_MAT2: {
value: 0x8B5A,
setter: function(location, v){
gl.uniformMatrix2fv(location, false, v);
}
},
FLOAT_MAT3: {
value: 0x8B5B,
setter: function(location, v){
gl.uniformMatrix3fv(location, false, v);
}
},
FLOAT_MAT4: {
value: 0x8B5C,
setter: function(location, v){
gl.uniformMatrix4fv(location, false, v);
}
},
SAMPLER_2D: {
value: 0x8B5E,
setter: function(location, texture){
gl.uniform1i(location, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
}
},
SAMPLER_CUBE: {
value: 0x8B60,
setter: function(location, texture){
gl.uniform1i(location, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture);
}
},
INT: {
value: 0x1404,
setter: function(location, v){
gl.uniform1i(location, v);
}
},
FLOAT: {
value: 0x1406,
setter: function(location, v){
gl.uniform1f(location, v);
}
}
};
enums 是所有的变量类型,但没有包含普通数组,所以我们还需要通过 uniformInfo.size 属性判断该 uniform 变量是否是数组,uniform 变量的 size 大于 1 并且该变量名称的最后三个字符是[0]
,说明该 uniform 变量是数组类型,大家可以尝试一下。
有两点需要大家注意:
1、如果 uniform 或者 attribute 变量只是在着色器中进行了定义,但没有被使用,那么它将被编译器抛弃,我们通过gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS)
这种方式获取不到该变量。
2、uniform 和 attribute 变量的数量并不是可以无限定义的,而是有一定上限,不同平台数量不同,一般 windows 平台 256 个,mac 和 linux 平台一般为 1024 个,如果定义数量超过这个上限,着色器程序会报编译错误。
function createUniformSetter(gl, program, uniformInfo) {
let uniformLocation = gl.getUniformLocation(program, uniformInfo.name);
let type = uniformInfo.type;
let isArray = uniformInfo.size > 1 && uniformInfo.name.substr(-3) === '[0]';
if(isArray && type == enums.INT.value){
return function(v) {
gl.uniform1iv(location, v);
};
}
if(isArray && type == enums.FLOAT.value){
return function(v) {
gl.uniform1fv(location, v);
};
}
return function createSetter(v){
return enums[getKeyFromType(type)].setter(location, v)
}
}
以上就是 uniforms 变量的初始化过程,相对繁琐,但比较简单,容易理解。
绘制多个物体
既然有了模型类、uniforms 和 attribute 变量的赋值函数,接下来我们就可以创建一个模型列表和一个渲染列表,模型列表中存放所有模型对象,渲染列表中存放着待渲染的对象。
// 渲染列表
let renderList = new List();
// 模型列表
let modelList = new list();
// 列表类
function List(list){
this.list = list || [];
this.uuid = list.length;
}
// 添加对象
List.prototype.add = function(object){
object.uuid = this.uuid;
this.list.push(object);
this.uuid++;
}
// 删除对象
List.prototype.remove = function(object){
this.list.splice(object.uuid, 1);
}
// 查找对象
List.prototype.get = function(index){
return this.list[index];
}
// 遍历列表
List.prototype.forEach = function(callback){
return this.list.forEach(callback);
}
模型列表和渲染列表的区别在于,渲染列表只存储和渲染相关的数据,比如着色器程序,模型的顶点缓冲数据,uniforms 数据等。
一个完整的模型对象有如下内容:
let modelObject={
// 偏移状态
translation:[0, 0, 0],
// 缩放状态
scalation:[1, 1, 3],
// 旋转状态
rotation:[30, 60, 100],
bufferInfo:{
// 顶点属性
attributes:{
// 顶点坐标
a_Position: {
buffer: [],
type: gl.FLOAT,
normalize: false,
numsPerElement: 4
},
...
},
// 顶点索引
indices: [],
// 顶点数量
elementsCount: 30
},
uniforms: {
// MVP 矩阵
u_Matrix: ...,
// 模型矩阵
u_ModelMatrix: ...,
// 法向量矩阵
u_NormalMatrix: ...,
// 全局光照
u_LightColor: ...,
...
}
}
而一个渲染对象通常包含对应模型的几个属性:
let renderObject = {
// 模型
bufferInfo: modelObject.bufferInfo,
program: program,
uniforms: modelObject.uniforms,
}
添加一个新模型时,我们只需要初始化模型对象,添加到 objectList 中,同时往 renderList 中添加渲染对象。
let cube = createCube(5, 5, 5);
let cubeModel = new Model(cube);
objectList.add(cubeModel);
let renderObject= {
program: program,
model: cubeModel,
primitive: 'TRIANGLES',
renderType: 'drawArrays'
}
renderList.add(renderObject);
每次渲染时,首先遍历 objectList 中的模型对象,计算模型的 uniforms 变量,比如代表模型状态的 MVP 矩阵,模型矩阵,法向量矩阵等,以及顶点数据 bufferInfo,然后遍历 renderList 中的渲染对象,设置对应的 bufferInfo 和 uniforms 变量 ,执行绘制即可。
objectList.forEach(function(modelObject){
// 计算相关 uniforms 属性。
modelObject.preRender();
})
renderList.forEach(function(renderObject){
let bufferInfo = renderObject.model.bufferInfo;
let uniforms = renderObject.model.uniforms;
let program = renderObject.program;
// 往顶点缓冲区传递数据
setBufferInfos(gl, program, bufferInfo);
// 设置 uniforms 变量。
setUniforms(gl, program, uniforms);
// 绘制
if (renderObject.renderType === 'drawElements') {
if (bufferInfo.indices) {
gl.drawElements(object.primitive, bufferInfo.indices.length, gl.UNSIGNED_SHORT, 0);
return;
} else {
console.warn('model buffer does not support indices to draw');
return;
}
} else {
gl.drawArrays(gl[object.primitive], 0, bufferInfo.elementsCount);
}
})
演示
接下来我们用上面的代码演示一下绘制多个模型的场景,利用之前写好的立方体和球体生成函数,我们生成 200 个模型,随机分配颜色,请注意由于目前强制要求一个模型的顶点必须包含颜色
、坐标
、纹理坐标
、法向量
的,所以我们的模型生成函数必须要有能力生成这些属性。
let cube = createCube(2, 2, 2);
// 将带索引的立方体顶点数据转化成无索引的顶点数据
cube = transformIndicesToUnIndices(cube);
// 为顶点数据添加颜色信息
createColorForVertex(cube);
let sphere = createSphere(1, 10, 10);
// 将带索引的球体顶点数据转化成无索引的顶点数据
sphere = transformIndicesToUnIndices(sphere);
// 为顶点数据添加颜色信息
createColorForVertex(sphere);
根据上面的顶点数据生成模型缓冲对象:
// 生成立方体的顶点缓冲对象
let cubeBufferInfo = createBufferInfoFromObject(gl, cube);
// 生成球体的顶点缓冲对象
let sphereBufferInfo = createBufferInfoFromObject(gl, sphere);
创建模型列表和渲染列表,这里我们选择创建 100 个模型
let modelList = new List();
let renderList = new List();
for (var i = 0; i < 100; ++i) {
var object = new Model();
if (i % 2 == 0) {
object.setBufferInfo(bufferInfo);
} else {
object.setBufferInfo(sphereBufferInfo);
}
// 设置模型的位置
object.translate(rand(-10, 10), rand(-10, 10), rand(-10, 10));
// 设置模型的旋转角度
object.rotate(rand(0, 90));
// 预渲染
object.preRender(viewMatrix, projectionMatrix);
// 设置模型的 uniforms 属性。
object.setUniforms({
u_ModelMatrix: object.u_ModelMatrix,
u_Matrix: object.u_Matrix,
u_ColorFactor: new Float32Array([rand(0.5, 0.75), rand(0.5, 0.75), rand(0.25, 0.5)])
})
objectList.add(object);
// 根据模型对象创建渲染对象,并将渲染对象添加到渲染列表中
renderList.add({
programInfo: program,
model: object,
primitive: gl.TRIANGLES,
renderType: 'drawArrays'
});
}
有了模型列表和渲染列表,接下来我们就可以执行渲染操作了,渲染操作是遍历渲染列表,重新设置模型的 bufferInfo 和 uniforms 属性,然后执行绘制。
function render() {
if (!playing) {
requestAnimationFrame(render);
return;
}
// 重新设置模型的状态
objectList.forEach(function (object) {
object.rotateX(object.rotation[0] + rand(0.2, 0.5));
object.rotateY(object.rotation[1] + rand(0.2, 0.5));
object.rotateZ(object.rotation[1] + rand(0.2, 0.5));
object.preRender(viewMatrix, projectionMatrix);
object.setUniforms({
u_ModelMatrix: object.u_ModelMatrix,
u_Matrix: object.u_Matrix,
})
})
// 执行渲染
let lastProgram;
let lastBufferInfo;
renderList.forEach(function (object) {
let programInfo = object.programInfo;
let bufferInfo = object.model.bufferInfo;
let uniforms = object.model.uniforms;
let bindBuffers = false;
if (programInfo !== lastProgram) {
lastProgram = programInfo;
gl.useProgram(programInfo.program);
bindBuffers = true;
}
if (bindBuffers || bufferInfo !== lastBufferInfo) {
lastBufferInfo = bufferInfo;
setBufferInfos(gl, programInfo, bufferInfo);
}
setUniforms(programInfo, uniforms);
// 绘制
if (object.renderType === 'drawElements') {
if (bufferInfo.indices) {
gl.drawElements(object.primitive, bufferInfo.indices.length, gl.UNSIGNED_SHORT, 0);
return;
} else {
console.warn('model buffer does not support indices to draw');
return;
}
} else {
gl.drawArrays(gl[object.primitive], 0, bufferInfo.elementsCount);
}
});
requestAnimationFrame(render);
}
上面这些就是重构后的调用代码,是不是很简洁了很多?我们看下效果:
回顾
本节将之前的代码进行重构优化,大家可以看到一些前面用到的、没有用到的函数,比如 uniforms
属性赋值函数,虽然种类很多,但是很容易就能够见名知意。之前代码有用到 gl.uniform1f
给变量赋值单个 float
类型的数字,其他类似的函数也是为了给 uniform 变量赋值,只是赋值类型不同。
通过对重用代码进行封装,我们能够以很少的代码绘制多个模型,并且不用再去编写繁琐的buffer
和 uniform
的赋值代码,我们把精力放在编写模型的状态逻辑上,这大大地提高了我们的开发效率。
下一节我们开始学习光照效果,光照效果涉及到一些物理学知识,大家先别急着看代码,先理解下物理知识,然后多做实践,相信大家很快就能掌握。