Drawing a 3D-Looking Rotating Triangle

So far, we've only really considered drawing triangles in a 2D-kind of way. Even though, in the last chapter we have been starting to rotate our triangle in virtual 3D space, in the end we used an orthongonal projection to draw it onto the screen.

Don't get me wrong, there is nothing intrinsically bad with orthogonal projections and they can be quite useful for creating isometric looking worlds.

First of all, we will need to add a handle for the perspective transformation matrix uniform:

Not only does glam help us calculate a perspective transform, it will also take the aspect ratio of our window into account, so our triangle doesn't get all squishy. For this we need to add information about the viewport, viewport_size: (i32, i32), to our Renderer:

pub struct Renderer {
    gl: gl::Gl,
    viewport_size: (i32, i32),
    program: gl::types::GLuint,
    vao: gl::types::GLuint,
    vbo: gl::types::GLuint,
    rotation: gl::types::GLint,
    perspective: gl::types::GLint,
}

We can then use this transformation matrix in our shader:

const VERTEX_SHADER_SOURCE: &CStr = c"
#version 410 core

uniform mat3 rotation;
uniform mat4 perspective;

const mat4 translation = mat4(1.0, 0.0, 0.0, 0.0,
                              0.0, 1.0, 0.0, 0.0,
                              0.0, 0.0, 1.0, 0.0,
                              0.0, 0.0, -1.5, 1.0);

in vec3 position;
in vec3 color;

out vec3 v_color;

void main() {
    gl_Position = perspective * translation * vec4(rotation * position, 1.0);
    v_color = color;
}
";

In our Renderer::new function, first lets get our handle to the OpenGL uniform:

            let perspective =
                gl.GetUniformLocation(program, c"perspective".as_ptr() as *const _);

And in the end of Renderer::new we have to set our viewport and our perspective handler for our Renderer object.

We will initialize the viewport with (0, 0), as at the point of building the renderer, the window has not been created yet, so we don't actually know what size the window has. Soon, we will add the code that sets viewport_size to the correct value as soon as the window has been created:

            Self { gl, viewport_size: (0, 0), program, vao, vbo, rotation, perspective }

Now, here's the hard part: We need to write down our perspective transformation matrix. To be honest, I first tried to derive the matrix from first principles, but failed. So I have taken the formula from the Learn WebGL tutorial by Wayne Brown, which also includes a very nice derivation of the transform, as well as interactive examples and an explanation of the difference between the createFrustum and createPerspective functions of WebGL.

Now, assuming that viewport_size is correctly set, we can calculate the aspect ratio of our window.

        // from http://learnwebgl.brown37.net/08_projections/projections_perspective.html
        let aspect_ratio = self.viewport_size.0 as f32 / self.viewport_size.1 as f32;
        let field_of_view: f32 = 1.0;
        let near: f32 = 1.0;
        let far: f32 = 10.0;
        let top: f32 = near * (field_of_view / 2.0).tan();
        let bottom: f32 = -top;
        let right: f32 = top * aspect_ratio;
        let left: f32 = -right;
        let perspective: [f32; 16]  = [ 2.0*near/(right-left),  0.0,  0.0,  -near*(right+left)/(right-left),
                                        0.0,  2.0*near/(top-bottom),  0.0,  -near*(top+bottom)/(top-bottom),
                                        0.0,  0.0,  -(far+near)/(far-near),  2.0*far*near/(near-far),

And we need to let OpenGL know about our perspective transform by setting the uniform for our perspective matrix in the shader to the contents of our pespective matrix:

            self.gl.UniformMatrix4fv(self.perspective, 1, 1, perspective.as_ptr());

In our resize method, we can finally set the viewport_size of the Renderer. This function will be called at least once, when the window is created. So we can be sure, that our viewport_size always contains the actual size of our window.

    fn resize(&mut self, width: i32, height: i32) {

Running the code will now render a much more convincing 3-dimensional triangle:

Using a Math Library

One important lessen of doing everything yourself, is knowing when to stop doing it yourself. ;) So now that we understand what we're doing, we can stop doing it and use a library!

We will use the crate glam. glam is a pure-rust 3D math library that offers to generate rotation and perspetive matrices for us. It can do a lot more with matrices and also with quaternions, should you wish to use them later for managing rotations.

We need to add glam to the [dependencies] section in our Cargo.toml:

glam = "0.29.2"

With that we can calculate our rotation and perspective matrices using the glam methods glam::Mat4::rotation_y and glam::Mat4::perspective_rh_gl, respectively.

The rh in glam::Mat4::perspective_rh_gl means that we are generating a perspective transform for a right-handed coordinate system (as OpenGL does) and gl means that the near and far planes are mapped into \(z \in [-1,1]\), as OpenGL expects. (DirectX, in contrast, expects a left-handed coordinate system and \(z \in [0,1]\).) In our formula we have used above, all these information were already implicitely used in the derivation of the formula in the WebGL tutorial.

        let rotation = glam::Mat3::from_rotation_y(phi);

        let aspect_ratio = self.viewport_size.0 as f32 / self.viewport_size.1 as f32;
        let perspective = glam::Mat4::perspective_rh_gl(1.0, aspect_ratio, 0.5, 10.0);

Now, since we are using glam matrices instead of our own hard-coded arrays, we'll need to change a bit how we pass the pointer to the internal data to OpenGL. Luckily for us, glam matrices internally are still just a continuous strip of f32 values in memory, we can just convert the matrices into the pointers we need:

            self.gl.UniformMatrix3fv(self.rotation, 1, 0, (&rotation as *const _) as *const _);
            self.gl.UniformMatrix4fv(self.perspective, 1, 0, (&perspective as *const _) as *const _);

Running the code now reveals … nothing! Nothing has changed, as expected, but we got rid of 12 lines of complicated code by incorporating a library:

Full code

As always, here comes the full code of everything we've done in all the chapters before and this chapter (though some things might just reference previous chapters):

Cargo.toml

[package]
name = "rotating_triangle_3d_glam"
edition = "2021"

[dependencies]
glam = "0.29.2"
glwindow = "0.1"

[build-dependencies]
gl_generator = "0.14"

build.rs

Unchanged from Chapter 2's build.rs.

src/main.rs

use std::error::Error;
use std::ffi::{CStr, CString};
use std::time::Instant;
use glwindow::AppControl;
use glwindow::event::{WindowEvent, KeyEvent};
use glwindow::keyboard::{Key, NamedKey::Escape};

pub mod gl {
    #![allow(clippy::all)]
    include!(concat!(env!("OUT_DIR"), "/gl_bindings.rs"));
    pub use Gles2 as Gl;
}

pub struct State {
    begin: Instant,
}

pub struct Renderer {
    gl: gl::Gl,
    viewport_size: (i32, i32),
    program: gl::types::GLuint,
    vao: gl::types::GLuint,
    vbo: gl::types::GLuint,
    rotation: gl::types::GLint,
    perspective: gl::types::GLint,
}

static VERTEX_DATA: [f32; 18] = [
    -0.5, -0.5,  0.0,     0.8,  0.8,  0.0,
     0.0,  0.5,  0.0,     0.0,  0.8,  0.8,
     0.5, -0.5,  0.0,     0.8,  0.0,  0.8,
];

const VERTEX_SHADER_SOURCE: &CStr = c"
#version 410 core

uniform mat3 rotation;
uniform mat4 perspective;

const mat4 translation = mat4(1.0, 0.0, 0.0, 0.0,
                              0.0, 1.0, 0.0, 0.0,
                              0.0, 0.0, 1.0, 0.0,
                              0.0, 0.0, -1.5, 1.0);

in vec3 position;
in vec3 color;

out vec3 v_color;

void main() {
    gl_Position = perspective * translation * vec4(rotation * position, 1.0);
    v_color = color;
}
";

const FRAGMENT_SHADER_SOURCE: &CStr = c"
#version 410 core

out vec4 color;

in vec3 v_color;

void main() {
    color = vec4(v_color, 1.0);
}
";

impl glwindow::AppRenderer for Renderer {
    type AppState = State;

    fn new<D: glwindow::GlDisplay>(gl_display: &D) -> Self {
        unsafe {
            let gl = gl::Gl::load_with(|symbol| {
                let symbol = CString::new(symbol).unwrap();
                gl_display.get_proc_address(symbol.as_c_str()).cast()
            });

            let vertex_shader = gl.CreateShader(gl::VERTEX_SHADER);
            gl.ShaderSource(vertex_shader, 1, [VERTEX_SHADER_SOURCE.as_ptr()].as_ptr(), std::ptr::null());
            gl.CompileShader(vertex_shader);

            let fragment_shader = gl.CreateShader(gl::FRAGMENT_SHADER);
            gl.ShaderSource(fragment_shader, 1, [FRAGMENT_SHADER_SOURCE.as_ptr()].as_ptr(), std::ptr::null());
            gl.CompileShader(fragment_shader);

            let program = gl.CreateProgram();

            gl.AttachShader(program, vertex_shader);
            gl.AttachShader(program, fragment_shader);

            gl.LinkProgram(program);

            gl.UseProgram(program);

            gl.DeleteShader(vertex_shader);
            gl.DeleteShader(fragment_shader);

            let mut vao = std::mem::zeroed();
            gl.GenVertexArrays(1, &mut vao);
            gl.BindVertexArray(vao);

            let mut vbo = std::mem::zeroed();
            gl.GenBuffers(1, &mut vbo);
            gl.BindBuffer(gl::ARRAY_BUFFER, vbo);
            gl.BufferData(
                gl::ARRAY_BUFFER,
                (VERTEX_DATA.len() * std::mem::size_of::<f32>()) as gl::types::GLsizeiptr,
                VERTEX_DATA.as_ptr() as *const _,
                gl::STATIC_DRAW,
            );

            let pos_attrib =
                gl.GetAttribLocation(program, c"position".as_ptr() as *const _);
            gl.VertexAttribPointer(
                pos_attrib as gl::types::GLuint,
                3,
                gl::FLOAT,
                0,
                6 * std::mem::size_of::<f32>() as gl::types::GLsizei,
                std::ptr::null(),
            );
            gl.EnableVertexAttribArray(pos_attrib as gl::types::GLuint);

            let color_attrib =
                gl.GetAttribLocation(program, c"color".as_ptr() as *const _);
            gl.VertexAttribPointer(
                color_attrib as gl::types::GLuint,
                3,
                gl::FLOAT,
                0,
                6 * std::mem::size_of::<f32>() as gl::types::GLsizei,
                (3 * std::mem::size_of::<f32>()) as *const () as *const _,
            );
            gl.EnableVertexAttribArray(color_attrib as gl::types::GLuint);

            let rotation =
                gl.GetUniformLocation(program, c"rotation".as_ptr() as *const _);
            let perspective =
                gl.GetUniformLocation(program, c"perspective".as_ptr() as *const _);

            Self { gl, viewport_size: (0, 0), program, vao, vbo, rotation, perspective }
        }
    }

    fn draw(&self, state: &mut State) {
        let time = Instant::now().duration_since(state.begin).as_millis() % 5000;
        let phi = (time as f32) / 5000.0 * 2.0 * std::f32::consts::PI;

        let rotation = glam::Mat3::from_rotation_y(phi);

        let aspect_ratio = self.viewport_size.0 as f32 / self.viewport_size.1 as f32;
        let perspective = glam::Mat4::perspective_rh_gl(1.0, aspect_ratio, 0.5, 10.0);

        unsafe {
            self.gl.UseProgram(self.program);
            self.gl.UniformMatrix3fv(self.rotation, 1, 0, (&rotation as *const _) as *const _);
            self.gl.UniformMatrix4fv(self.perspective, 1, 0, (&perspective as *const _) as *const _);

            self.gl.BindVertexArray(self.vao);
            self.gl.BindBuffer(gl::ARRAY_BUFFER, self.vbo);

            self.gl.ClearColor(0.1, 0.1, 0.1, 0.9);
            self.gl.Clear(gl::COLOR_BUFFER_BIT);
            self.gl.DrawArrays(gl::TRIANGLES, 0, 3);
        }
    }

    fn resize(&mut self, width: i32, height: i32) {
        self.viewport_size = (width, height);
        unsafe {
            self.gl.Viewport(0, 0, width, height);
        }
    }
}

impl Drop for Renderer {
    fn drop(&mut self) {
        unsafe {
            self.gl.DeleteProgram(self.program);
            self.gl.DeleteBuffers(1, &self.vbo);
            self.gl.DeleteVertexArrays(1, &self.vao);
        }
    }
}

fn handle_event(_app_state: &mut State, event: WindowEvent)
                -> Result<AppControl, Box<dyn Error>> {
    let mut exit = false;
    match event {
        WindowEvent::CloseRequested => {
            exit = true;
        }
        WindowEvent::KeyboardInput { event: KeyEvent { logical_key: Key::Named(Escape), .. }, .. } => {
            exit = true;
        }
        _ => (),
    }

    Ok(if exit { AppControl::Exit } else { AppControl::Continue })
}

fn main() -> Result<(), Box<dyn Error>> {
    let app_state = State{
        begin: Instant::now(),
    };
    glwindow::Window::<_,_,Renderer>::new()
        .run(app_state, handle_event as glwindow::HandleFn<_>)
}