本文已参与「新人创作礼」活动,一起开启掘金创作之路。
写在前面
虽然一个渲染框架可能很复杂,但是如果我们一开始就把事情想的很复杂往往难以下手。另一方面,现在有很多开源引擎,你直接去看也会感觉难以入手。因为复杂性是由于需求而不断增加的,甚至有的复杂性是因为历史原因。本系列文章的初衷,是记录一个可用的,简单的WebGL渲染框架的发展过程,在这个过程中加深对WebGL和图形技术的理解,并且通过实践让对知识的理解不再浮于表面。也许mini3d.js以后会变的很复杂,但是现在他是简单的,是易于理解的,可作为一个参考对象。如果我等到把mini3d.js做的功能很多很复杂,再回头来看,一方面记忆已经模糊,另一方面设计可能已经几易其稿而丢失了设计修改的抉择过程。而现在,唯一要考虑的是能不能坚持下去。
里程碑1:旋转彩色立方体
第一个里程碑的目标,是一个可以用鼠标拖动旋转的彩色立方体。
该立方体每个面有一个颜色(并不是很多OpenGL教程里面那种渐变色的立方体),鼠标拖动可上下左右旋转,并且我还顺便测试了一下万向节死锁的效果。
初始化WebGL上下文
在搭建项目框架时,讲到应用开始要调用mini3d.init('webgl')
。这个init方法获取了WebGL的上下文对象WebGLRendringContext,并进行一些初始化操作。代码位于src/gl.js
中。mini3d.js采用了ES6的模块系统。gl.js导出了3个对象:export { init, gl, canvas };
其中gl是我们获得的WebGL上下文对象,canvas是运行WebGL的canvas对象,而init则是初始化方法。
这个文件比较短就都贴出来:
let gl = null;
let canvas = null;
function init(canvasId){
if(canvasId != null){
canvas = document.getElementById(canvasId);
if(canvas === undefined){
console.error("cannot find a canvas named:"+canvasId);
return;
}
} else {
canvas = document.createElement("canvas");
document.body.appendChild(canvas);
}
canvas.width = Math.floor(canvas.clientWidth * window.devicePixelRatio);
canvas.height = Math.floor(canvas.clientHeight * window.devicePixelRatio);
let names = ["webgl", "experimental-webgl", "webkit-3d", "moz-webgl"];
let context = null;
for(let i=0; i<names.length; ++i){
try{
context = canvas.getContext(names[i]);
} catch(e){}
if(context){
break;
}
}
gl = context;
gl.viewport(0, 0, canvas.width, canvas.height);
};
export { init, gl, canvas };
init方法的参数为canvas的id,如果不填写,则会在document上创建一个canvas对象,否则会根据id获取到canvas对象。之后会计算出canvas的像素为单位的宽和高。然后使用canvas.getContext获取WebGL上下文,并保存到gl对象中。最后调用gl.viewport设置视口。其实这儿没什么说的,都是常规化的操作。
基本渲染流程介绍
由于是第一个demo,所以先大概介绍一下渲染流程。这之后会逐个讲述mini3d.js封装的各个对象。 demo的入口在examples/src/main.js中,所有的内容都在example()方法内。
创建Shader并映射到顶点属性
首先,我们创建了一个mini3d.Shader对象:
let shader = new mini3d.Shader();
if(!shader.create(VSHADER_SOURCE, FSHADER_SOURCE)){
console.log("Failed to initialize shaders");
return;
}
其中VSHADER_SOURCE和FSHADER_SOURCE是预定义好的Shader代码,暂时还是直接用字符串表示,后面会从文件载入。mini3d.Shader是对WebGL shader&program的封装,后面会详细说明。 由于我们使用shader渲染模型时,要知道模型的顶点属性和shader中的attribute的对应关系,所以下面调用:
shader.mapAttributeSemantic(mini3d.VertexSemantic.POSITION, 'a_Position');
shader.mapAttributeSemantic(mini3d.VertexSemantic.COLOR, 'a_Color');
将a_Position映射到模型的POSITION属性上,a_Color映射到COLOR属性,后面会详细说明属性和VertexSemantic。
使用shader
shader.use();
使用哪个shader其实是一个WebGL的状态,由于本例只有一个shader不需要切换,所以在初始化之后直接use他。
创建Mesh
createMesh()方法创建了一个立方体模型,在mini3d中用Mesh对象表示。模型由顶点组成,而每个顶点包含了很多数据,比如在这个例子里面需要位置和颜色属性。顶点由哪些数据组成,这些数据的长度以及在顶点数据buffer中的位置,由VertexFormat定义。min3d.Mesh创建时必须传入一个创建好的VertexFormat。
let format = new mini3d.VertexFormat();
format.addAttrib(mini3d.VertexSemantic.POSITION, 3);
format.addAttrib(mini3d.VertexSemantic.COLOR, 3);
let mesh = new mini3d.Mesh(format);
这个例子里面定义了两个属性,属性的类别使用VertexSemantic中的预定义常量,其实mini3d.js支持自定义属性,只要不和VertexSemantic中定义的常量冲突。
然后向mesh传入顶点的各个属性数据,并设置三角形索引,然后执行upload将数据上传到显存。
mesh.setVertexData(mini3d.VertexSemantic.POSITION, position_data);
mesh.setVertexData(mini3d.VertexSemantic.COLOR, color_data);
mesh.setTriangles(triangels);
mesh.upload();
创建矩阵
为什么需要矩阵,其实不是WebGL需要,而是我们为了在Shader里面变换顶点,将顶点从模型空间转换到裁剪空间,而需要使用矩阵。我们需要最终在Shader中使用MVP矩阵。在这个例子中,由于立方体是旋转的,所以需要保存立方体在世界空间下的旋转,因此创建了一个modelMatrix。由于camera不动,所以合并view和projection,创建了一个viewProjMatrix。然后就是最终合成的mvpMatrix。
设置鼠标操作事件
setupInput中设置了鼠标操作事件,将鼠标的移动量转化为欧拉角的增量,然后改变欧拉角,并且重新绘制
设置全局状态
这个例子需要设置的全局状态只有clear color和depth,以及打开DEPTH_TEST。
绘制立方体
function draw(mesh, shader){
//rotate order: x-y-z
modelMatrix.setRotate(rotZ, 0, 0, 1); //rot around z-axis
modelMatrix.rotate(rotY, 0.0, 1.0, 0.0); //rot around y-axis
modelMatrix.rotate(rotX, 1.0, 0.0, 0.0); //rot around x-axis
mvpMatrix.set(viewProjMatrix);
mvpMatrix.multiply(modelMatrix);
shader.setUniform('u_mvpMatrix', mvpMatrix.elements);
let gl = mini3d.gl;
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
mesh.render(shader);
}
基于旋转后的欧拉角,重新创建了modelMatrix,然后重新合并出mvpMatrix。将矩阵传入shader,然后clear屏幕,对mesh调用render。 这个例子中,不是每帧都绘制,只会在鼠标拖到时刷新。可以看到本例中不变的是mesh, shader和viewProjMatrix。每次绘制需要重新计算modelMatrix以及mvpMatrix,需要清除屏幕。