Creative Tinkering

WebGL Engine From Scratch Part 3

(–part 2–)

Contents

Introduction

Now that we have a good base with 3D math and 3D object representations, more visual and interesting aspects can be implemented.

Depth Sort

We have 2 triangles in our scene but the right one is always visible even if it should be behind the left one. This is because we have not enabled depth sorting when drawing these triangles. Without depth sorting the visibility of overlapping geometry depends merely on the order in which they are drawn: the right triangle is always drawn after/over the left.

Depth sorting can be enabled simply by calling gl.enable( gl.DEPTH_TEST ); Before drawing something. We are going to add this feature into the engine.GLProgram class. This way there is flexibility to define depth sorting behavior for each shader program.

engine.GLProgram = class GLProgram{
    constructor(){    
        ...
        this.useDepth = true;
        this.depthFunc = gl.LEQUAL;
    }
    
    ...
    
    Use(){
        gl.useProgram(this.program);
        engine.activeProgram = this;
        if(this.useDepth == true){
            gl.enable(gl.DEPTH_TEST);
            gl.depthFunc(this.depthFunc);
        }
    }
}

Because depth sorting uses the depth buffer associated with our color buffer, we must also clear the depth buffer at the start of drawing each frame. Otherwise there will be data leftover from previous frames to break depth sorting logic.

Camera.Render( scene ){
    engine.activeCamera = this;
    gl.viewport( 0,0, engine.canvas.width, engine.canvas.height );
    gl.clearColor(
        scene.backgroundColor[0],
        scene.backgroundColor[1],
        scene.backgroundColor[2],
        scene.backgroundColor[3]
    );
    gl.clear( gl.COLOR_BUFFER_BIT || gl.DEPTH_BUFFER_BIT );
    scene.Draw();
}

So if we run our test now, the triangles will look “right” when one goes behind the other while rotating, regardless of the drawing order.

Procedural Geometry

It is good to have some functions to generate meshes for various situations in our engine. This also enables us to quickly test rendering more complicated geometry than single triangles, but without having to manually write out required vertex data.

A flat plane mesh is arguably the most simple geometry after the triangle. We are also going to make a feature to set the number of segments(subdivisions) along its width and height, because it will become handy in the future for creating terrain meshes or other effects.

New namespace engine.geometry = {}; organizes the future functions neatly.

We just need to repeat this triangle pattern if the number of segments is higher than 1.

v01 +---------+ v11
    |       / |
    |     /   |
    |   /     |
    | /       |
v00 +---------+ v10

3 by 3 segmented plane mesh would have triangles arranged like this:

+-----+-----+-----+
|   / |   / |   / |
| /   | /   | /   |
+-----+-----+-----+
|   / |   / |   / |
| /   | /   | /   |
+-----+-----+-----+
|   / |   / |   / |
| /   | /   | /   |
+-----+-----+-----+

The algorithm to achieve this is quite simple

for segments in x direction
    for segments in y direction
        add a quad offset by x and y

In practice it is a bit more involved however:

engine.geometry.Plane = function( width, height, resWidth, resHeight ){
    var vertices = [];
    var sx = width / resWidth; // offset size in x direction
    var sy = height / resHeight; // offset size in y direction
    var v00 = [0,0,0];  var v01 = [0,0,0];
    var v10 = [0,0,0];  var v11 = [0,0,0];

    /*
        note the start of loop on negative half resolution.
        this positions the vertices so that the origin of the object
        remains at the center of the plane. 
    */    
    for(var x = -resWidth/2; x < resWidth/2; x++){
        for(var y = -resHeight/2; y < resHeight/2; y++){
            
            /* 
                applying calculations to "fit" 
                vertices into the given width
                and height taking into account
                the number of segments
            */
            
            v00 = [    x*sx,    y*sy, 0 ];
            v01 = [    x*sx, y*sy+sy, 0 ];
            v10 = [ x*sx+sx,    y*sy, 0 ];
            v11 = [ x*sx+sx, y*sy+sy, 0 ];
            
            // add the calculated vertex positions to the array
            // in counter clockwise winding order.
            vertices = vertices.concat(
                v00, v11, v01,
                v00, v10, v11
            );
        }
    }

    return {
        "position":{data:new Float32Array( vertices )}
    }
}

The order in which the vertices are appended is very specific. Triangles need to be defined in a constant winding direction. So either clockwise or counter-clockwise. The winding determines in which direction the resulting triangle is “facing”. Winding is used to cull back faces when rendering so that only one side of a triangle is drawn. This can optimize rendering to not draw triangles on the “far side” of some object. Triangle winding should not be confused with triangle normal. The normal data exists independently from winding.

Counter-clockwise triangle winding.
The numbers represent vertex order when defining
such triangles. 

              2 +-----+ 1            + 2
                |   /              / |
                | /              /   |
              0 +            0 +-----+ 1

The function returns a JS object that can be plugged right into engine.Mesh( attributes ); constructor. At the moment we have only generated the position attribute, but we will also need UV  and normal attribute data in the future.

It is also convenient to add few more parameters to the plane:

engine.geometry.Plane = function(
        width, 
        height, 
        resWidth, 
        resHeight, 
        offset, 
        direction 
){
        var offset = offset || 0;
        var dir = direction || "+Z"; /* direction of the plane normal */
        var vertices = [];
        var sx = width / resWidth;
        var sy = height / resHeight;
        var v00 = [0,0,0];
        var v01 = [0,0,0];
        var v10 = [0,0,0];
        var v11 = [0,0,0];
        for(var x = -resWidth/2; x < resWidth/2; x++){
            for(var y = -resHeight/2; y < resHeight/2; y++){
                switch(dir){
                    case "+X":
                        v00 = [ offset,    y*sy, x*sx    ];
                        v01 = [ offset, y*sy+sy, x*sx    ];
                        v10 = [ offset,    y*sy, x*sx+sx ];
                        v11 = [ offset, y*sy+sy, x*sx+sx ];
                        vertices = vertices.concat(
                            v00, v11, v01, /* triangle 0 */ 
                            v00, v10, v11  /* triangle 1 */
                        );
                        break;
                    case "-X":
                        v00 = [ -offset,    y*sy, x*sx    ];
                        v01 = [ -offset, y*sy+sy, x*sx    ];
                        v10 = [ -offset,    y*sy, x*sx+sx ];
                        v11 = [ -offset, y*sy+sy, x*sx+sx ];
                        vertices = vertices.concat(
                            v11, v00, v01, /* triangle 0 */ 
                            v10, v00, v11  /* triangle 1 */
                        );
                        break;
                    
                    case "+Y":
                        v00 = [    x*sx, offset,    y*sy ];
                        v01 = [    x*sx, offset, y*sy+sy ];
                        v10 = [ x*sx+sx, offset,    y*sy ];
                        v11 = [ x*sx+sx, offset, y*sy+sy ];
                        vertices = vertices.concat(
                            v00, v11, v01, /* triangle 0 */ 
                            v00, v10, v11  /* triangle 1 */
                        );
                        break;
                    case "-Y":
                        v00 = [    x*sx, -offset,    y*sy ];
                        v01 = [    x*sx, -offset, y*sy+sy ];
                        v10 = [ x*sx+sx, -offset,    y*sy ];
                        v11 = [ x*sx+sx, -offset, y*sy+sy ];
                        vertices = vertices.concat(
                            v11, v00, v01, /* triangle 0 */ 
                            v10, v00, v11  /* triangle 1 */
                        );
                        break;
                    
                    case "+Z":
                        v00 = [    x*sx,    y*sy, offset ];
                        v01 = [    x*sx, y*sy+sy, offset ];
                        v10 = [ x*sx+sx,    y*sy, offset ];
                        v11 = [ x*sx+sx, y*sy+sy, offset ];
                        vertices = vertices.concat(
                            v00, v11, v01, /* triangle 0 */ 
                            v00, v10, v11  /* triangle 1 */
                        );
                        break;
                    case "-Z":
                        v00 = [    x*sx,    y*sy, -offset ];
                        v01 = [    x*sx, y*sy+sy, -offset ];
                        v10 = [ x*sx+sx,    y*sy, -offset ];
                        v11 = [ x*sx+sx, y*sy+sy, -offset ];
                        vertices = vertices.concat(
                            v11, v00, v01, /* triangle 0 */ 
                            v10, v00, v11  /* triangle 1 */
                        );
                        break;
                }
            }
        }

        return {
            "position" : { data : new Float32Array( vertices ) }
        };
    };

Now the plane can be generated to “face” any positive or negative coordinate axis direction, with an offset parameter to offset it along that axis. All this enables us to construct a Box mesh geometry with less effort, because we can just define it as 6 planes facing in each required direction.

engine.geometry.Box = function(width, height, depth, resWidth, resHeight, resDepth){
        /* 
            engine.geometry.Plane() can be utilised 
            to generate the individual box faces
        */
        var negativeX = engine.geometry.Plane( depth, height, resDepth, resHeight,  width/2, "-X" );
        var positiveX = engine.geometry.Plane( depth, height, resDepth, resHeight,  width/2, "+X" );
        var negativeZ = engine.geometry.Plane( width, height, resWidth, resHeight,  depth/2, "-Z" );
        var positiveZ = engine.geometry.Plane( width, height, resWidth, resHeight,  depth/2, "+Z" );
        var negativeY = engine.geometry.Plane( width,  depth, resWidth, resDepth,  height/2, "-Y" );
        var positiveY = engine.geometry.Plane( width,  depth, resWidth, resDepth,  height/2, "+Y" );
        var verts = [];
        
        for(var i = 0; i < negativeX.position.data.length; i++){
            verts.push(negativeX.position.data[i]);
        }

        for(var i = 0; i < positiveX.position.data.length; i++){
            verts.push(positiveX.position.data[i]);
        }

        for(var i = 0; i < negativeY.position.data.length; i++){
            verts.push(negativeY.position.data[i]);
        }

        for(var i = 0; i < positiveY.position.data.length; i++){
            verts.push(positiveY.position.data[i]);
        }

        for(var i = 0; i < negativeZ.position.data.length; i++){
            verts.push(negativeZ.position.data[i]);
        }

        for(var i = 0; i < positiveZ.position.data.length; i++){
            verts.push(positiveZ.position.data[i]);
        }
        
        var result = {
            "position":{data:new Float32Array(verts)}
        }

        return result;
    };

So now that we have these new meshes, lets try them out. Instantiate a new mesh in test.html with the geometry generator function:

...

var planeMesh = new engine.Mesh(engine.geometry.Plane(1,1,4,4));
planeMesh.Init();

...

obj.mesh = planeMesh;

Although we cannot see the segments, they are there. To make them visible we could implement a method for drawing wireframe overlay on meshes.

Wireframe Drawing

When we take a look at our meshes’ Draw method, there is a call to gl.drawArrays( gl.TRIANGLES, 0, this.vertexCount ); You might think that replacing gl.TRIANGLES with gl.LINES would draw the wireframe. The problem is that gl.LINES works by drawing a line between a pair of vertices. Our triangles have 3 vertices which would lead to only one pair of vertices to draw a line across and the second line would connect the 3rd vertex to the 1st vertex of the next triangle. This is why we must generate an array of vertex pairs out of our triangle vertices. Fortunately it is very straightforward.

For every triangle in our mesh, we will have to create pairs of vertices to draw the line between. And put it into a WebGL buffer to draw it when needed.

    // engine.Mesh{
    ...
    GenerateWireframeVertices(){
        var verts = [];
        /*
            a bit fiddly, because the vertex data 
            is a flat float array
        */
        for(var i = 0; i < this.vertexCount; i+=3){
            var v0 = [
                this.attributes.position.data[i*3+0],
                this.attributes.position.data[i*3+1],
                this.attributes.position.data[i*3+2]
            ];
            var v1 = [
                this.attributes.position.data[i*3+3],
                this.attributes.position.data[i*3+4],
                this.attributes.position.data[i*3+5]
            ];
            var v2 = [
                this.attributes.position.data[i*3+6],
                this.attributes.position.data[i*3+7],
                this.attributes.position.data[i*3+8]
            ];
            verts.push( 
                v0[0],v0[1],v0[2],
                v1[0],v1[1],v1[2],
                v1[0],v1[1],v1[2],
                v2[0],v2[1],v2[2],
                v2[0],v2[1],v2[2],
                v0[0],v0[1],v0[2],
            );
        }
        this.wireframeVertices = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, this.wireframeVertices);
        gl.bufferData( gl.ARRAY_BUFFER, new Float32Array( verts ), gl.STATIC_DRAW );
        gl.bindBuffer(gl.ARRAY_BUFFER, null);
    }
}

Generating the vertex data for wireframe representation of the mesh is only the first step though. To make things more convenient we could implement a shader program library for the engine, which contains common default shader programs. This way we would not need to write out new shaders for common tasks, such as wireframe drawing.

engine.programLib = {}

engine.Init = function(){

    ...

    // simple program for wireframe drawing
    engine.programLib.wireframe = new engine.GLProgram(
        new engine.GLShader(`
            precision lowp float;
            attribute vec3 position;
            uniform mat4 modelMatrix;
            uniform mat4 viewMatrix;
            uniform mat4 projMatrix;
            void main(){
                gl_Position = projMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
            }
        `, gl.VERTEX_SHADER),
        new engine.GLShader(`
            precision lowp float;
            void main(){
                gl_FragColor = vec4(0.0,0.0,0.0,1.0);
            }
        `, gl.FRAGMENT_SHADER)
    );

    // shaders should be instantiated and compiled before
    // update loop
    engine.Update()

}

Now we can modify our Mesh class to actually draw the wireframe vertices with the program from the program library.

engine.Mesh = class Mesh{
    constructor(){
        ...
        this.drawMode = gl.TRIANGLES;
        this.drawWireframe = false; // must be turned on manually
        this.wireframeVertices = null;
    }

    Draw(){
        ...
        gl.drawArrays( this.drawMode, 0, this.vertexCount );
        if(this.drawWireframe == true && this.drawMode!= gl.LINES){
            engine.programLib.wireframe.Use();
            engine.activeProgram.SetUniform("viewMatrix", engine.activeCamera.viewMatrix.data, "m4");
            engine.activeProgram.SetUniform("projMatrix", engine.activeCamera.projMatrix.data, "m4");
            engine.activeProgram.SetUniform("modelMatrix", engine.activeObject.localToWorld.data, "m4");
            gl.lineWidth(5);
            // generate wireframe vertices if needed
            if(this.wireframeVertices == null){
                this.GenerateWireframeVertices();
            }

            gl.bindBuffer(gl.ARRAY_BUFFER, this.wireframeVertices);
            loc = engine.activeProgram.GetAttribLocation("position");
            gl.enableVertexAttribArray(loc);
            gl.vertexAttribPointer( loc, 3, gl.FLOAT, false, 0, 0 );
            gl.drawArrays( gl.LINES, 0, this.vertexCount * 2 );
        }
    }
}

I have also modified the handling of the modelmatrix to make it easier to switch shaderprograms during drawing an object.

engine.activeObject = null;

engine.Obj = class Obj{
    Draw(){
        engine.activeObject = this;
        this.program.SetUniform("modelMatrix", engine.activeObject.localToWorld.data, "m4");
        ...
    }
}

Finally we can test this out by enabling wireframe drawing on one of the instantiated meshes.

test.html:

engine.Init();

var vs = new engine.GLShader(
    `
        precision lowp float;
        attribute vec3 position;
        uniform mat4 modelMatrix;
        uniform mat4 viewMatrix;
        uniform mat4 projMatrix;
        varying vec3 color;
        void main(){
            color = position*0.5 + 0.5;
            gl_Position = projMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );
        }
    `,
    gl.VERTEX_SHADER
);
var fs = new engine.GLShader(
    `
        precision lowp float;
        varying vec3 color;
        void main(){
            gl_FragColor = vec4( color, 1.0 );
        }
    `,
    gl.FRAGMENT_SHADER
);
var prog = new engine.GLProgram( vs, fs );

var boxMesh = new engine.Mesh( engine.geometry.Box( 1,1,1, 4,4,4 ) );
boxMesh.drawWireframe = true;
boxMesh.Init();

var planeMesh = new engine.Mesh( engine.geometry.Plane( 1,1,4,4 ) );
planeMesh.Init();
var obj = new engine.Obj();
obj.mesh = planeMesh;
obj.program = prog;
obj.localScale.Set(0.5, 0.5, 0.5);
obj.onupdate = function(){
    this.localPosition.x = Math.sin(engine.time)*0.1;
    this.localPosition.z = Math.cos(engine.time)*0.1;
    this.matrixNeedsUpdate = true;
};
engine.scene.Add(obj);

var obj2 = new engine.Obj();
obj2.mesh = boxMesh;
obj2.program = prog;
obj2.onupdate = function(){
    this.localRotation.SetEuler( engine.time,engine.time,0 );
    this.matrixNeedsUpdate = true;
};
obj2.SetParent( obj );
obj2.localPosition.x = 1;
obj2.matrixNeedsUpdate = true;
engine.scene.Add(obj2);

console.log(engine);

Result



Source files:

https://github.com/artkalev/tut_webglengine/tree/master/part3

Leave a Reply

Your email address will not be published. Required fields are marked *