从零开始手撸WebGL3D引擎3:基本渲染流程介绍

663 阅读5分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

写在前面

虽然一个渲染框架可能很复杂,但是如果我们一开始就把事情想的很复杂往往难以下手。另一方面,现在有很多开源引擎,你直接去看也会感觉难以入手。因为复杂性是由于需求而不断增加的,甚至有的复杂性是因为历史原因。本系列文章的初衷,是记录一个可用的,简单的WebGL渲染框架的发展过程,在这个过程中加深对WebGL和图形技术的理解,并且通过实践让对知识的理解不再浮于表面。也许mini3d.js以后会变的很复杂,但是现在他是简单的,是易于理解的,可作为一个参考对象。如果我等到把mini3d.js做的功能很多很复杂,再回头来看,一方面记忆已经模糊,另一方面设计可能已经几易其稿而丢失了设计修改的抉择过程。而现在,唯一要考虑的是能不能坚持下去。

里程碑1:旋转彩色立方体

第一个里程碑的目标,是一个可以用鼠标拖动旋转的彩色立方体。 color cube

该立方体每个面有一个颜色(并不是很多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,需要清除屏幕。