DEV Community

loading...

WebGL 3D Engine from Scratch Part 3: Mesh Transformations

ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
・10 min read

Once rudimentary 3d was done I wasn't really sure where to go next but I think one place is to start structuring the code as things are going to get a bit messy laid out in one giant render function. Specifically I want to think about how to think about a 3d object mesh in an intuitive way that makes it easy to use.

The mesh class

To be honest I'm not really a fan of setting up classes for things as it's just a lot of boilerplate around an object literal but in this case I think it might make sense. It should be pretty self-explanatory, the only interesting thing is, for convenience, I'm doing the typed array conversion and I call the indices "triangles" because from the mesh point of view that's what it is.

export class Mesh {
    #positions;
    #colors;
    #normals;
    #uvs;
    #triangles;

    constructor(mesh){
        this.positions = mesh.positions;
        this.colors = mesh.colors;
        this.normals = mesh.normals;
        this.uvs = mesh.uvs;
        this.triangles = mesh.triangles;
    }

    set positions(val){
        this.#positions = new Float32Array(val);
    }
    get positions(){
        return this.#positions;
    }
    set colors(val) {
        this.#colors = new Float32Array(val);
    }
    get colors(){
        return this.#colors;
    }
    set normals(val) {
        this.#normals = new Float32Array(val);
    }
    get normals(){
        return this.#normals;
    }
    set uvs(val) {
        this.#uvs = new Float32Array(val);
    }
    get uvs(){
        return this.#uvs;
    }
    set triangles(val) {
        this.#triangles = new Uint16Array(val);
    }
    get triangles(){
        return this.#triangles;
    }
}

Enter fullscreen mode Exit fullscreen mode

Updating the renderer

Now in the renderer we want to split up some of the drawing functions. We'll follow a new connectedCallback:

async connectedCallback() {
    this.createShadowDom();
    this.cacheDom();
    this.attachEvents();
    await this.bootGpu();
    this.createMeshes();
    this.setupUniforms();
    this.render();
}
Enter fullscreen mode Exit fullscreen mode

bootGpu does slightly less now.

async bootGpu() {
    this.context = this.dom.canvas.getContext("webgl");
    this.program = this.context.createProgram();

    const vertexShader = compileShader(this.context, `
            uniform mat4 uProjectionMatrix;
            attribute vec3 aVertexPosition;
            attribute vec3 aVertexColor;
            float angle = -3.1415962 / 4.0;
            mat4 rotationY = mat4(
                cos(angle), 0, sin(angle), 0,
                0, 1, 0, 0,
                -sin(angle), 0, cos(angle), 0,
                0, 0, 0, 1
            );
            vec4 translateZ = vec4(0.0, 0.0, 2.0, 0.0);
            varying mediump vec4 vColor;
            void main(){
                gl_Position = uProjectionMatrix * (translateZ + rotationY * vec4(aVertexPosition, 1.0));
                vColor = vec4(aVertexColor, 1.0);
            }
        `, this.context.VERTEX_SHADER);
    const fragmentShader = compileShader(this.context, `
        varying lowp vec4 vColor;
        void main() {
            gl_FragColor = vColor;
        }
    `, this.context.FRAGMENT_SHADER);
    this.program = compileProgram(this.context, vertexShader, fragmentShader)
    this.context.enable(this.context.CULL_FACE);
    this.context.cullFace(this.context.BACK);
    this.context.useProgram(this.program);
}
Enter fullscreen mode Exit fullscreen mode

It compiles the shader program (for now we only have one, but this could be refactored later) and sets it to be in use.

Next, we look at createMeshes:

createMeshes(){
    this.meshes = {
        cube: new Mesh({
            positions: [
                //Front
                -0.5, -0.5, -0.5,
                0.5, -0.5, -0.5,
                0.5, 0.5, -0.5,
                -0.5, 0.5, -0.5,
                //Back
                0.5, -0.5, 0.5,
                -0.5, -0.5, 0.5,
                -0.5, 0.5, 0.5,
                0.5, 0.5, 0.5
            ],
            colors: [
                1.0, 0.0, 0.0,
                1.0, 0.0, 0.0,
                1.0, 0.0, 0.0,
                1.0, 0.0, 0.0,
                0.0, 1.0, 0.0,
                0.0, 1.0, 0.0,
                0.0, 1.0, 0.0,
                0.0, 1.0, 0.0
            ],
            triangles: [
                0, 1, 2, //front
                0, 2, 3,
                1, 4, 7, //right
                1, 7, 2,
                4, 5, 6, //back
                4, 6, 7,
                5, 0, 3, //left
                5, 3, 6,
                3, 2, 7, //top
                3, 7, 6,
                0, 4, 1, //bottom
                0, 5, 4
            ]
        })
    };
}
Enter fullscreen mode Exit fullscreen mode

Note: if you followed along from last time the winding order of the bottom face was incorrect. It's correct above.

This is nice because now all of the data for the mesh is in one place. You could easily imagine loading this from JSON or something. We could add other meshes to the object too and just loop through them.

setupUniforms is identical. We're just setting up the projection matrix:

setupUniforms(){
        const projectionMatrix = new Float32Array(getProjectionMatrix(this.#height, this.#width, 90, 0.01, 100).flat());
        const projectionLocation = this.context.getUniformLocation(this.program, "uProjectionMatrix");
        this.context.uniformMatrix4fv(projectionLocation, false, projectionMatrix);
    }
Enter fullscreen mode Exit fullscreen mode

render got an update:

render() {
    this.context.clear(this.context.COLOR_BUFFER_BIT | this.context.DEPTH_BUFFER_BIT);
    for (const mesh of Object.values(this.meshes)){
        this.bindMesh(mesh);
        this.context.drawElements(this.context.TRIANGLES, mesh.triangles.length, this.context.UNSIGNED_SHORT, 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

We first clear and then we iterate through the meshes, call bind and then draw it. bindMesh it where all the previous setup calls to bind buffers happens.

bindMesh(mesh){
    this.bindPositions(mesh.positions);
    this.bindColors(mesh.colors);
    this.bindIndices(mesh.triangles);;
}
Enter fullscreen mode Exit fullscreen mode

I won't go over the rest of the code since it's pretty much identical, the only difference is we take in the values from the mesh instead of hardcode them. I've also renamed them bind because that's really what's happening, we're binding the mesh values to buffers on the GPU and associating them to attributes.

Everything should work as it did before, but it should be easier to deal with. Let's try swapping out our cube for a quad pyramid:

export const quadPyramid = {
    positions: [
        0.0, 0.5, 0.0,
        -0.5, -0.5, -0.5,
        0.5, -0.5, -0.5,
        0.5, -0.5, 0.5,
        -0.5, -0.5, 0.5 
    ],
    colors: [
        1.0, 0, 0,
        0, 0, 1,
        0, 0, 1,
        0, 0, 1,
        0, 0, 1
    ],
    triangles: [
        0, 1, 2,
        0, 2, 3,
        0, 3, 4,
        0, 4, 1
    ]
}
Enter fullscreen mode Exit fullscreen mode

download (1)

Mesh-level transforms

We can also do transforms on the mesh itself. I'm not saying this is a great idea because the GPU can do this a lot faster but if you wanted to we can add that and this can be used to at least demonstrate that things are working correctly.

In the mesh we can add the method translate:

translate(x = 0, y = 0, z = 0){
    for(let i = 0; i < this.#positions.length; i++){
        switch(i % 3){
            case 0:
                this.#positions[i] += x;
                break;
            case 1:
                this.#positions[i] += y;
                break;
            case 2:
                this.#positions[i] += z;
                break;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This will move it by updating the coordinates (Again in practice you want to keep the baseline coordinates relatively centered and use matrix multiplication to move it around rather change them).

We can use this to place 2 meshes on the same canvas with only a few changes:

createMeshes(){
    const tcube = new Mesh(cube);
    tcube.translate(0.75, 0, 0);
    const tpyramid = new Mesh(quadPyramid);
    tpyramid.translate(-0.75, 0, 0);
    this.meshes = {
        pyramid: tpyramid,
        cube: tcube
    };
}
Enter fullscreen mode Exit fullscreen mode

download (1)

And now we can easily draw multiple meshes!

Translate, Scale and Rotate

Let's do this is real way. What we want is to store the values with the mesh and then when we draw it we can pass in a matrix that represents these fields and let the GPU do the work in the vertex shader without us modifying the underlying mesh. Let's add some properties to the mesh to represent translation, scale and rotation:

#translation = new Float32Array([0, 0, 0]);
#scale = new Float32Array([1, 1, 1]);
#rotation = new Float32Array([0, 0, 0]);
setTranslation({ x, y, z }){
    if (x){
        this.#translation[0] = x;
    }
    if (y) {
        this.#translation[1] = y;
    }
    if (z) {
        this.#translation[2] = z;
    }
}
getTranslation(){
    return this.#translation;
}
setScale({ x, y, z }) {
    if (x) {
        this.#scale[0] = x;
    }
    if (y) {
        this.#scale[1] = y;
    }
    if (z) {
        this.#scale[2] = z;
    }
}
getScale() {
    return this.#scale;
}
setRotation({ x, y, z }) {
    if (x) {
        this.#rotation[0] = x;
    }
    if (y) {
        this.#rotation[1] = y;
    }
    if (z) {
        this.#rotation[2] = z;
    }
}
getRotation(){
    return this.#rotation;
}
Enter fullscreen mode Exit fullscreen mode

I've tried to make these ergonomic so you can just pass in the component you want to update it. The underlying data type is a Float32Array because that's how WebGL will expect it when binding.

Next we can create a method bindUniforms to bind these values per mesh:

bindMesh(mesh){
    this.bindPositions(mesh.positions);
    this.bindColors(mesh.colors);
    this.bindIndices(mesh.triangles);
    this.bindUniforms(mesh.getTranslation(), mesh.getScale(), mesh.getRotation());
}
bindUniforms(translation, scale, rotation){
    const translationLocation = this.context.getUniformLocation(this.program, "uTranslation");
    this.context.uniform3fv(translationLocation, translation);
    const scaleLocation = this.context.getUniformLocation(this.program, "uScale");
    this.context.uniform3fv(scaleLocation, scale);
    const rotationLocation = this.context.getUniformLocation(this.program, "uRotation");
    this.context.uniform3fv(rotationLocation, rotation);
}
Enter fullscreen mode Exit fullscreen mode

And in the vertex shader we can add support for them:

uniform mat4 uProjectionMatrix;
uniform vec3 uTranslation;
uniform vec3 uScale;
uniform vec3 uRotation;

attribute vec3 aVertexPosition;
attribute vec3 aVertexColor;
varying mediump vec4 vColor;
void main(){
    mat4 translation = mat4(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        uTranslation[0], uTranslation[1], uTranslation[2], 1
    );
    mat4 scale = mat4(
        uScale[0], 0, 0, 0,
        0, uScale[1], 0, 0,
        0, 0, uScale[2], 0,
        0, 0, 0, 1
    );
    mat4 rotationX = mat4(
        1, 0, 0, 0,
        0, cos(uRotation[0]), -sin(uRotation[0]), 0,
        0, sin(uRotation[0]), cos(uRotation[0]), 0,
        0, 0, 0, 1
    );
    mat4 rotationY = mat4(
        cos(uRotation[1]), 0, sin(uRotation[1]), 0,
        0, 1, 0, 0,
        -sin(uRotation[1]), 0, cos(uRotation[1]), 0,
        0, 0, 0, 1
    );
    mat4 rotationZ = mat4(
        cos(uRotation[2]), -sin(uRotation[2]), 0, 0,
        sin(uRotation[2]), cos(uRotation[2]), 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1
    );
    mat4 modelMatrix = translation * scale * rotationX * rotationY * rotationZ;
    gl_Position = uProjectionMatrix * modelMatrix * vec4(aVertexPosition, 1.0);
    vColor = vec4(aVertexColor, 1.0);
}
Enter fullscreen mode Exit fullscreen mode

We create our translate, rotation and scale matrices. In fact, I completely messed these up because glsl uses column major ordering. Oops, watch out for that (I don't full understand why the rotation matrices work but translate will not unless defined properly). These matrices for translation, rotation and scaling are textbook standard by-the-way. I won't go into how they work because there's plenty of resources that can do them justice, just know that if you need them you can look them up.

createMeshes(){
    const tcube = new Mesh(cube);
    tcube.setRotation({ x: Math.PI / 4,  y: Math.PI / 4 });
    tcube.setTranslation({ z: 2, x: 0.75 });
    const tpyramid = new Mesh(quadPyramid);
    tpyramid.setTranslation({ z: 2, x: -0.75 });
    this.meshes = {
        pyramid: tpyramid,
        cube: tcube
    };
}
Enter fullscreen mode Exit fullscreen mode

We can now easily rotate and translate at will! But remember, we need to push the shapes back a bit so the camera isn't sitting inside them so without that translate-z you wouldn't see anything!

download (2)

Creating the Model Matrix

This is fine and all but it's not terribly efficent. Consider all the matrix operation we're doing. If this mesh had 10,000 vertices we'd be doing this work 10,000 times! In this case it's actually better to do a bit on the CPU to precalculate before running a shader against it.

mat4 modelMatrix = translation * scale * rotationX * rotationY * rotationZ;
Enter fullscreen mode Exit fullscreen mode

This line is important. I didn't explain it but we can basically multiply all the matrices together into a single pre-computed matrix. This is also important because ordering matters. If I translate then rotate that's different than rotating then translating (if that seems odd think about rotating the cube 45 degrees and then translating 2 units right versus translating 2 units right then rotating 45 degrees around the origin). In fact we might do many operations repeatedly in different orders so it doesn't make sense to fix it (though for simplicity I'm still going to fix it but moving it off the GPU into the data structure will make future improvements easier).

Well lets start by defining our matrix ops:

//vector.js
export function getRotationXMatrix(theta) {
    return [
        [1, 0, 0, 0],
        [0, Math.cos(theta), -Math.sin(theta), 0],
        [0, Math.sin(theta), Math.cos(theta), 0],
        [0, 0, 0, 1]
    ];
}

export function getRotationYMatrix(theta) {
    return [
        [Math.cos(theta), 0, Math.sin(theta), 0],
        [0, 1, 0, 0],
        [-Math.sin(theta), 0, Math.cos(theta), 0],
        [0, 0, 0, 1]
    ];
}

export function getRotationZMatrix(theta) {
    return [
        [Math.cos(theta), -Math.sin(theta), 0, 0],
        [Math.sin(theta), Math.cos(theta), 0, 0],
        [0, 0, 1, 0],
        [0, 0, 0, 1]
    ];
}

export function getTranslationMatrix(x, y, z) {
    return [
        [1, 0, 0, x],
        [0, 1, 0, y],
        [0, 0, 1, z],
        [0, 0, 0, 1]
    ];
}

export function getScaleMatrix(x, y, z){
    return [
        [x, 0, 0, 0],
        [0, y, 0, 0],
        [0, 0, z, 0],
        [0, 0, 0, 1]
    ];
}

export function multiplyMatrix(a, b) {
    const matrix = [
        new Array(4),
        new Array(4),
        new Array(4),
        new Array(4)
    ];
    for (let c = 0; c < 4; c++) {
        for (let r = 0; r < 4; r++) {
            matrix[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c] + a[r][3] * b[3][c];
        }
    }

    return matrix;
}

export function getIdentityMatrix() {
    return [
        [1, 0, 0, 0],
        [0, 1, 0, 0],
        [0, 0, 1, 0],
        [0, 0, 0, 1]
    ];
}
export function transpose(matrix){
    return [
        [matrix[0][0], matrix[1][0], matrix[2][0], matrix[3][0]],
        [matrix[0][1], matrix[1][1], matrix[2][1], matrix[3][1]],
        [matrix[0][2], matrix[1][2], matrix[2][2], matrix[3][2]],
        [matrix[0][3], matrix[1][3], matrix[2][3], matrix[3][3]]
    ];
}
Enter fullscreen mode Exit fullscreen mode

It's a lot of code but should be straight-forward. In the mesh.js file we'll change #rotation, #scale and #translation into plain arrays and then we'll add a new method:

getModelMatrix(){
    return new Float32Array(transpose(multiplyMatrix(
        getRotationXMatrix(this.#translation[0]),
            multiplyMatrix(
                getRotationYMatrix(this.#rotation[1]),
                multiplyMatrix(
                    getRotationZMatrix(this.#rotation[2]),
                    multiplyMatrix(
                        getScaleMatrix(this.#scale[0], this.#scale[1], this.#scale[2]),
                        multiplyMatrix(
                            getTranslationMatrix(this.#translation[0], this.#translation[1], this.#translation[2]),
                            getIdentityMatrix()
                        )
                    )
                )
            )
        )).flat());
}
Enter fullscreen mode Exit fullscreen mode

Gross, but we need to do it. You need to transpose it too for the column major reasons above. It can probably cleaned up with a more sophisticated matrix class but I'm just going to leave it for now. Let's update the uniforms so we just have the one:

bindMesh(mesh){
    this.bindPositions(mesh.positions);
    this.bindColors(mesh.colors);
    this.bindIndices(mesh.triangles);
    this.bindUniforms(mesh.getModelMatrix());
}
bindUniforms(modelMatrix){
    const modelMatrixLocation = this.context.getUniformLocation(this.program, "uModelMatrix");
    this.context.uniformMatrix4fv(modelMatrixLocation, false, modelMatrix);
}
Enter fullscreen mode Exit fullscreen mode

Watch out for that tricky transpose parameter that snuck in! Matrices take 3 parameters. And the vertex shader can be simple again:

uniform mat4 uProjectionMatrix;
uniform mat4 uModelMatrix;

attribute vec3 aVertexPosition;
attribute vec3 aVertexColor;
varying mediump vec4 vColor;
void main(){
    gl_Position = uProjectionMatrix * modelMatrix * vec4(aVertexPosition, 1.0);
    vColor = vec4(aVertexColor, 1.0);
}
Enter fullscreen mode Exit fullscreen mode

Nice, simple transformations and efficient!

We're really butting up against codepen limitations now. Perhaps next time we'll have enough for a git repo.

Discussion (0)