crust / Part 7 - Components and assets

Rusty boat: attribution https://www.pexels.com/photo/a-rusty-boat-on-the-seashore-9816335/

In this article we will implement the ability to load and store 3D models and resources for our scene. We will represent these resources in an abstracted way, avoiding the use of any vendor specific code such as OpenGL. This allows us to write our scene code in an agnostic fashion and describe 3D models and structures in a portable way.

Our engine implementation will be responsible for translating these abstractions into appropriate native resources - in our case OpenGL resources - though this could be other vendor technologies such as Vulkan.


3D refresher

For us to load and render 3D models we need to be familiar with a few basic concepts about how 3D data is represented. I wrote some material in my A Simple Triangle project which might be useful to browse: A Simple Triangle - Load a 3D model.

While a fair amount of that article talks about the C/C++ implementation, I do also discuss topics such as vertices, indices and how the .obj 3D model file format is structured.

We will be taking a similar approach to loading 3D data in our Rust application so I am not going to rehash the same information here. Feel free to read my A Simple Triangle series and other resources to get a grounding on the 3D data structures we will be using.

GLM

We will use the Rust port of the GLM library to give us a range of commonly needed math utilities. The Rust port is based on the C based https://github.com/g-truc/glm version. Most of what we need is in the Rust port, but there are a few gaps where we will have to add implementations ourselves (such as quaternion support).

Core components

We will start off with a set of components that are common across our 3D implementation. I struggled to think of a great module name to group these components in, so landed on the imaginitive module name of components

Create a new components directory next to the core directory in crust-main, along with a mod.rs file. Register the components module in lib.rs with pub mod components;.

Vertex

First up we’ll model a vertex which is a simple building block component representing a point in 3D space and includes the coordinates of how a texture should map onto it. Add components/vertex.rs:

use glm::{Vec2, Vec3};

pub struct Vertex {
    pub position: Vec3,
    pub texture_coord: Vec2,
}

Pretty straight forward, we hold a position as a glm::Vec3 which is just three 32 bit floats representing the x, y, z axis coordinates.

We also hold the texture coordinate as a glm::Vec2 which is two 32 bit floats representing the u, v coordinates of a texture that is applied to the vertex.

Mesh data

A mesh data component holds the raw set of vertices and indices that describe the geometric shape of a 3D object. It will be used as a transport object to pass the raw 3D data into the engine where it will be converted to a native resource. Add components/mesh_data.rs:

use crate::components::vertex::Vertex;
use std::vec::Vec;

pub struct MeshData {
    pub vertices: Vec<Vertex>,
    pub indices: Vec<u32>,
}

Texture data

A texture data component holds the SDL2 surface representation of a loaded image file. It will be used as a transport object to pass loaded image data into the engine where it will be converted to a native resource. Add components/texture_data.rs:

pub struct TextureData<'a> {
    pub width: u32,
    pub height: u32,
    surface: sdl2::surface::Surface<'a>,
}

impl<'a> TextureData<'a> {
    pub fn new(surface: sdl2::surface::Surface) -> TextureData {
        let width = surface.width();
        let height = surface.height();

        TextureData {
            surface: surface,
            width: width,
            height: height,
        }
    }

    pub fn surface(&mut self) -> &sdl2::surface::Surface {
        &self.surface
    }
}

Notice that we are using a Rust lifetime qualifier - the <'a> syntax - to declare the required lifetime of an owner of a texture data object to help Rust’s borrow checker in calculating at compile time how long an instance of this type is permitted to live or be borrowed for.

If you look at the code underneath sdl2::surface::Surface you will see that it requires a lifetime qualifier to be used which is why we need to declare one when holding an instance of a Surface:

pub struct Surface<'a> {
    context: Rc<SurfaceContext<'a>>,
}

I found that Rust lifetimes were one of the most difficult concepts to get my head around when learning Rust - you can read more here: https://doc.rust-lang.org/rust-by-example/scope/lifetime.html. Lifetimes are an important part of what makes Rust code more memory safe than languages such as C or C++ because it tries really hard to prevent you from writing code that could leak.

Most of the time we don’t need to worry about them because Rust can usually infer the appropriate lifetimes on our behalf at compile time, but sometimes there are scenarios - such as this one - where we are forced to deal with them explicitly ourselves.

Note: We still have to be careful near the fringes when we use C/C++ interop as Rust won’t know about foreign object lifetimes so we could still end up with leaks in those cases.

Matrix identity

The Rust glm port didn’t seem to offer a utility method to construct an identity matrix - or I somehow completely missed it. The identity matrix is needed in our 3D math code when computing things like transformations. We will create a basic utility that offers an identity method which produces the 4x4 identity matrix. Add components/matrix.rs:

use glm::Mat4;

#[inline]
pub fn identity() -> Mat4 {
    glm::mat4(1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.)
}

This produces the desired 4x4 matrix:

[ 1, 0, 0, 0 ]
[ 0, 1, 0, 0 ]
[ 0, 0, 1, 0 ]
[ 0, 0, 0, 1 ]

Note that we are using the #[inline] annotation to unpack this code directly into each place it is called from to avoid a method call. This is a small performance tweak - I honestly haven’t profiled it to see if it really matters though …

Note: In Rust you can write numeric values in a shorthand way if there are no fractional components, for example in the code above we write 1. which is the same as writing 1.0.

Quaternion

Ok, this one is a bit complex - in our 3D code we are going to use quaternions to model the orientation and rotation of objects. This is actually a bit different to the approach I took in A Simple Triangle but it is a much better way to do it, though quaternions themselves are a complicated mathematical topic in their own right.

Part of the C based glm library does include a range of quaternion types but the Rust port didn’t include them. Some links about quaternions:

To have quaternion support in crust, I ported the GLM C++ implementation of the 4x4 quaternion code into Rust myself. I honestly do not understand the pure math that is involved here, but to prove the port was accurate I wrote a C++ test harness application which created and used the C++ based GLM quaternion library, then verified that my Rust implementation emitted the same outputs.

Porting the C++ library into Rust took quite a while and I can’t say it was trivial to do - but I am happy that I got it working so I could use quaternions in crust!

Add components/quaternion.rs:

use glm::{Mat4, Vec3};
use std::ops;

pub struct Quaternion {
    pub axis: Vec3,
    pub rotation: f32,
}

impl Quaternion {
    pub fn new(axis: &Vec3, angle: f32) -> Self {
        let radians = angle.to_radians();
        let half_radians = radians * 0.5;

        Quaternion {
            axis: *axis * half_radians.sin(),
            rotation: half_radians.cos(),
        }
    }

    pub fn dot(&self, other: &Quaternion) -> f32 {
        let temp = Quaternion {
            axis: self.axis * other.axis,
            rotation: self.rotation * other.rotation,
        };

        (temp.axis.x + temp.axis.y) + (temp.axis.z + temp.rotation)
    }

    pub fn normalize(&self) -> Self {
        let x = self.axis.x;
        let y = self.axis.y;
        let z = self.axis.z;
        let rotation = self.rotation;
        let magnitude = (rotation * rotation + x * x + y * y + z * z).sqrt();

        Quaternion {
            axis: glm::vec3(x / magnitude, y / magnitude, z / magnitude),
            rotation: rotation / magnitude,
        }
    }

    pub fn to_matrix(&self) -> Mat4 {
        let axis = self.axis;
        let rotation = self.rotation;
        let xx = axis.x * axis.x;
        let yy = axis.y * axis.y;
        let zz = axis.z * axis.z;
        let xz = axis.x * axis.z;
        let xy = axis.x * axis.y;
        let yz = axis.y * axis.z;
        let rx = rotation * axis.x;
        let ry = rotation * axis.y;
        let rz = rotation * axis.z;

        glm::mat4(
            1. - 2. * (yy + zz),
            2. * (xy + rz),
            2. * (xz - ry),
            0.,
            2. * (xy - rz),
            1. - 2. * (xx + zz),
            2. * (yz + rx),
            0.,
            2. * (xz + ry),
            2. * (yz - rx),
            1. - 2. * (xx + yy),
            0.,
            0.,
            0.,
            0.,
            1.,
        )
    }
}

// Addition
fn add(a: &Quaternion, b: &Quaternion) -> Quaternion {
    Quaternion {
        axis: a.axis + b.axis,
        rotation: a.rotation + b.rotation,
    }
}

// &Quaternion + &Quaternion
impl ops::Add<&Quaternion> for &Quaternion {
    type Output = Quaternion;

    fn add(self, other: &Quaternion) -> Self::Output {
        add(&self, other)
    }
}

// &Quaternion + Quaternion
impl ops::Add<Quaternion> for &Quaternion {
    type Output = Quaternion;

    fn add(self, other: Quaternion) -> Self::Output {
        add(&self, &other)
    }
}

// Quaternion + &Quaternion
impl ops::Add<&Quaternion> for Quaternion {
    type Output = Quaternion;

    fn add(self, other: &Quaternion) -> Self::Output {
        add(&self, &other)
    }
}

// Quaternion + Quaternion
impl ops::Add<Quaternion> for Quaternion {
    type Output = Quaternion;

    fn add(self, other: Quaternion) -> Self::Output {
        add(&self, &other)
    }
}

// Subtraction
fn subtract(a: &Quaternion, b: &Quaternion) -> Quaternion {
    Quaternion {
        axis: a.axis - b.axis,
        rotation: a.rotation - b.rotation,
    }
}

// &Quaternion - &Quaternion
impl ops::Sub<&Quaternion> for &Quaternion {
    type Output = Quaternion;

    fn sub(self, other: &Quaternion) -> Self::Output {
        subtract(&self, other)
    }
}

// &Quaternion - Quaternion
impl ops::Sub<Quaternion> for &Quaternion {
    type Output = Quaternion;

    fn sub(self, other: Quaternion) -> Self::Output {
        subtract(&self, &other)
    }
}

// Quaternion - &Quaternion
impl ops::Sub<&Quaternion> for Quaternion {
    type Output = Quaternion;

    fn sub(self, other: &Quaternion) -> Self::Output {
        subtract(&self, &other)
    }
}

// Quaternion - Quaternion
impl ops::Sub<Quaternion> for Quaternion {
    type Output = Quaternion;

    fn sub(self, other: Quaternion) -> Self::Output {
        subtract(&self, &other)
    }
}

// Multiplication
fn multiply(a: &Quaternion, b: &Quaternion) -> Quaternion {
    let axis_a = &a.axis;
    let rotation_a = a.rotation;
    let axis_b = &b.axis;
    let rotation_b = b.rotation;

    Quaternion {
        axis: glm::vec3(
            rotation_a * axis_b.x + axis_a.x * rotation_b + axis_a.y * axis_b.z - axis_a.z * axis_b.y,
            rotation_a * axis_b.y - axis_a.x * axis_b.z + axis_a.y * rotation_b + axis_a.z * axis_b.x,
            rotation_a * axis_b.z + axis_a.x * axis_b.y - axis_a.y * axis_b.x + axis_a.z * rotation_b,
        ),
        rotation: rotation_a * rotation_b - axis_a.x * axis_b.x - axis_a.y * axis_b.y - axis_a.z * axis_b.z,
    }
}

// &Quaternion * &Quaternion
impl ops::Mul<&Quaternion> for &Quaternion {
    type Output = Quaternion;

    fn mul(self, other: &Quaternion) -> Self::Output {
        multiply(&self, other)
    }
}

// &Quaternion * Quaternion
impl ops::Mul<Quaternion> for &Quaternion {
    type Output = Quaternion;

    fn mul(self, other: Quaternion) -> Self::Output {
        multiply(&self, &other)
    }
}

// Quaternion * &Quaternion
impl ops::Mul<&Quaternion> for Quaternion {
    type Output = Quaternion;

    fn mul(self, other: &Quaternion) -> Self::Output {
        multiply(&self, other)
    }
}

// Quaternion * Quaternion
impl ops::Mul<Quaternion> for Quaternion {
    type Output = Quaternion;

    fn mul(self, other: Quaternion) -> Self::Output {
        multiply(&self, &other)
    }
}

Apart from the set of expected math methods such as dot, normalize etc, you may observe that there are a heap of ops::* methods. This is how you add the ability for data types in Rust to overload operators such as -, +, *, /.

Lets say we had two quaternion objects - a and b and want to be able to use the + operator to add them together:

let result = a + b;

Rust has no way to implicitly know how to add two objects together - we have to implement the addition code ourselves. The syntax to do this is:

impl ops::Add<RIGHT> for LEFT

where RIGHT is the data type of the right hand operand and LEFT is the data type for the left hand operand. Looking at our example:

let result = a + b;

// If the type of `a` was f32 and the type of `b` was String you would implement the Add operator like:

impl ops::Add<String> for f32

So, for the Quaternion class to allow two quaternion objects to be added we would need to implement:

impl ops::Add<Quaternion> for Quaternion

Great, however there is a wrinkle - if one or both of the operands are a reference to a quaternion - &Quaternion - we need to explicitly implement those scenarios too as a referenced type is considered a completely different type to a non referenced type - &Quaternion is discrete from Quaternion.

So if we had &a + b then we need:

impl ops::Add<Quaternion> for &Quaternion

All the combinations of operands need to be accommodated. Then we need to do all that for each of the operands we want to support (addition, subtraction, multiplication, division). It’s not hard to see how quickly this kind of implementation can balloon out but it is what it is!

Orientation

We will add a structure that can model the orientation of an object in 3D space, described by the attributes:

This article has a good diagram to show pitch, yaw and roll under the Euler angles section.

Modelling an Orientation structure allows us to spin objects around, or use the direction of the orientation to figure out how to move objects along relative trajectories. We can also use it to help create a camera for our scene. We will make use of our Quaternion here. Add components/orientation.rs:

use crate::components::quaternion::Quaternion;
use glm::{Mat4, Vec3, Vec4};

pub struct Orientation {
    pitch: f32,
    yaw: f32,
    roll: f32,
    x_axis: Vec3,
    y_axis: Vec3,
    z_axis: Vec3,
    direction_axis: Vec4,
}

impl Orientation {
    pub fn new(pitch: f32, yaw: f32, roll: f32) -> Self {
        Orientation {
            pitch: pitch,
            yaw: yaw,
            roll: roll,
            x_axis: glm::vec3(1., 0., 0.),
            y_axis: glm::vec3(0., 1., 0.),
            z_axis: glm::vec3(0., 0., 1.),
            direction_axis: glm::vec4(0., 0., 1., 0.),
        }
    }

    pub fn add_pitch(&mut self, pitch: f32) {
        self.pitch = wrap_angle(self.pitch + pitch);
    }

    pub fn add_yaw(&mut self, yaw: f32) {
        self.yaw = wrap_angle(self.yaw + yaw);
    }

    pub fn add_roll(&mut self, roll: f32) {
        self.roll = wrap_angle(self.roll + roll);
    }

    pub fn to_matrix(&self) -> Mat4 {
        let quat_pitch = Quaternion::new(&self.x_axis, self.pitch);
        let quat_yaw = Quaternion::new(&self.y_axis, self.yaw);
        let quat_roll = Quaternion::new(&self.z_axis, self.roll);
        let orientation = quat_roll * quat_yaw * quat_pitch;

        orientation.to_matrix()
    }

    pub fn direction(&self) -> Vec3 {
        let direction = self.to_matrix() * self.direction_axis;

        glm::normalize(glm::vec3(direction.x, direction.y, direction.z))
    }
}

fn wrap_angle(input: f32) -> f32 {
    (input % 360. + 360.) % 360.
}

We can manipulate an instance of an Orientation by calling its add_pitch, add_yaw and add_roll methods, then asking it about its current direction or getting the orientation as a 4x4 matrix through the to_matrix method. We also include a wrap_angle utility method to keep an angle (measured in degrees) within the 360 degree range.

Model

Next up we will create a structure that can represent a 3D model in our world. Our scenes will create instances of these models to describe what the user should see. A model will have the following attributes:

We should be able to change some of these properties at runtime to cause an instance of a model to spin around, grow or shrink or move in space. Add components/model.rs:

use crate::components::{matrix, orientation::Orientation};
use glm::{Mat4, Vec3};

pub struct Model {
    pub mesh_id: String,
    pub texture_id: String,
    pub shader_id: String,
    pub position: Vec3,
    pub scale: Vec3,
    orientation: Orientation,
    identity: Mat4,
}

impl Model {
    pub fn new(mesh_id: &str, texture_id: &str, shader_id: &str, position: Vec3, scale: Vec3) -> Self {
        Model {
            mesh_id: mesh_id.to_owned(),
            texture_id: texture_id.to_owned(),
            shader_id: shader_id.to_owned(),
            position: position,
            scale: scale,
            orientation: Orientation::new(0., 0., 0.),
            identity: matrix::identity(),
        }
    }

    pub fn orientation(&mut self) -> &mut Orientation {
        &mut self.orientation
    }

    pub fn transform(&self, projection: &Mat4) -> Mat4 {
        *projection
            * glm::ext::translate(&self.identity, self.position)
            * self.orientation.to_matrix()
            * glm::ext::scale(&self.identity, self.scale)
    }
}

The orientation method actually returns a mutable reference to the model orientation object so the caller can directly change its values.

The transform method returns a resolved matrix describing the combination of all its 3D attributes into the supplied projection view. This is used by a camera who knows what projection its view should have and needs to know how a model should look within that projection.

Perspective Camera

Next we’ll add a perspective camera that understands how to project its view based on the dimensions of the display and attributes such as the field of view. Without a camera, we wouldn’t be able to visualise our 3D models with the illusion of depth (perspective). There are other types of cameras such as orthogonal or isometric and if you want to render 2D graphics or 3D graphics in a special way you’d use those kinds of cameras.

For our project we’ll start with a perspective camera though if we wanted to write a 2D GUI system we’d need other kinds of cameras too. Add components/perspective_camera.rs:

use crate::core::display_size::DisplaySize;
use glm::{Mat4, Vec3};

pub struct PerspectiveCamera {
    projection: Mat4,
    up: Vec3,
    position: Vec3,
    target: Vec3,
}

impl PerspectiveCamera {
    pub fn new(display_size: &DisplaySize) -> Self {
        PerspectiveCamera {
            projection: glm::ext::perspective(
                66.0f32.to_radians(),
                (display_size.width as f32) / (display_size.height as f32),
                0.01,
                100.0,
            ),
            up: glm::vec3(0., 1., 0.),
            position: glm::vec3(0., 0., 0.),
            target: glm::vec3(0., 0., 0.),
        }
    }

    pub fn configure(&mut self, position: Vec3, direction: Vec3) {
        self.position = position;
        self.target = position - direction;
    }

    pub fn projection_view(&self) -> Mat4 {
        self.projection * glm::ext::look_at(self.position, self.target, self.up)
    }
}

We are leaning on some of the glm library functionality to calculate the perspective projection matrix using:

The configure method provides a way for both the position and direction of the camera to be set in the same operation. The projection_view method will return a matrix which describes how to project geometry such that it would be seen correctly by the camera - we would apply this projection view to the transform matrix of each model during the rendering pipeline to produce the full MVP (Model View Projection) matrix that the shader program can then use.

Asset loading

Now that we have a set of components that represent the basic resource types such as meshes and textures we need some utilities to load asset files and parse them into those components. Before writing these utilities let’s add some 3D models and texture files to our project.

For this project we will actually use the exact same assets that were used in A Simple Triangle. Create the following directory structure:

+ root
    + crust-main
        + assets
            + models
            + shaders
                + opengl
            + textures

Download the following files into assets/models:

Download the following files into assets/shaders/opengl:

Note: We are putting an opengl directory as in the future if we wanted to add Vulkan support we would put a vulkan directory next to it.

Download the following files into assets/textures:

The assets are stored in our crust-main directory but when we build our project we need to include them in the output of the build as well. We will make a small detour back into our crust-build project to add the appropriate code to collect these assets.

Windows

Edit crust-build/windows.rs and update the create_output method:

fn create_output(context: &Context, sdl2_libs_dir: &PathBuf, sdl2_image_libs_dir: &PathBuf) -> FailableUnit {
    logs::out(log_tag!(), "Creating product ...");

    outputs::clean(context)?;

    outputs::collect(
        context,
        vec![
            context.rust_build_dir.join(context.variant.id()).join("crust.exe"),
            sdl2_libs_dir.join("SDL2.dll"),
            sdl2_image_libs_dir.join("SDL2_image.dll"),
            sdl2_image_libs_dir.join("libpng16-16.dll"),
            sdl2_image_libs_dir.join("zlib1.dll"),
        ],
    )?;

    match context.variant {
        Variant::Debug => {
            logs::out(log_tag!(), "Debug build - symlinking assets ...");
            io::create_symlink(&context.assets_dir, &outputs::output_dir(context).join("assets"), &outputs::output_dir(context))?;
        }

        Variant::Release => {
            logs::out(log_tag!(), "Release build - collecting assets ...");
            outputs::collect(context, vec![context.assets_dir.clone()])?;
        }
    }

    Ok(())
}

We will collect the assets files differently depending on the build variant - you will need to add core::variant::Variant and core::io to the using block.

For debug builds we will simply put a symlink to the assets directory in our output - this helps our build speed while developing by avoiding repeated duplication of asset files.

For release builds we actually will duplicate the assets into the output directory - this would allow you to put a copy of everything inside the out/release directory anywhere you liked and have a working standalone crust application.

Macos Console

Update the create_output method in crust-build/macos_console.rs:

fn create_output(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    let output_dir = outputs::output_dir(context);

    logs::out(log_tag!(), "Creating product ...");

    outputs::clean(context)?;
    outputs::collect(context, vec![context.rust_build_dir.join(context.variant.id()).join("crust")])?;

    match context.variant {
        Variant::Debug => {
            logs::out(log_tag!(), "Debug build - symlinking assets ...");
            io::create_symlink(&context.assets_dir, &PathBuf::from("assets"), &output_dir)?;

            logs::out(log_tag!(), "Debug build - symlinking frameworks ...");
            io::create_symlink(&frameworks_dir, &PathBuf::from("Frameworks"), &output_dir)?;
        }

        Variant::Release => {
            logs::out(log_tag!(), "Release build - copying assets ...");
            outputs::collect(context, vec![context.assets_dir.clone()])?;

            logs::out(log_tag!(), "Release build - copying frameworks ...");
            outputs::collect(context, vec![frameworks_dir.clone()])?;
        }
    }

    scripts::run(&Script::new("install_name_tool -add_rpath @loader_path/Frameworks crust").working_dir(&output_dir))
}

Note the addition of the assets symlink for debug variant and the collection of the assets directory in the release variant - resulting in a similar outcome to the Windows target.

Load .obj files

To load .obj files we’ll use the tobj crate. We will implement a basic loading mechanism by only parsing the first 3D mesh in a given .obj file - meaning that each unique mesh in our project would be in its own .obj file.

We could actually model an entire 3D scene in an .obj file and load everything in, but I’ll leave that as an exercise for your own project if that’s something that interests you.

We will also ignore any material data stored in the .obj files and instead manually tell each mesh what textures to apply via our Model structure.

From this point on we will return to making code changes in the crust-main project. Create core/io.rs:

use crate::{
    components::{mesh_data::MeshData, texture_data::TextureData, vertex::Vertex},
    core::failable::Failable,
};
use sdl2::{pixels::PixelFormatEnum, rwops::RWops, surface::Surface};
use std::{
    collections::HashMap,
    io::{BufReader, Read},
    path::Path,
    vec::Vec,
};

pub fn load_text_file(path: &str) -> Failable<String> {
    let mut stream = RWops::from_file(Path::new(path), "r")?;
    let mut content = String::new();

    stream.read_to_string(&mut content)?;

    Ok(content)
}

pub fn load_obj_file(path: &str) -> Failable<MeshData> {
    let data = load_text_file(path)?;
    let mut input = BufReader::new(data.as_bytes());
    let (models, _) = tobj::load_obj_buf(&mut input, true, |_| Ok((Vec::new(), HashMap::new())))?;
    let mut vertices: Vec<Vertex> = vec![];
    let mut indices: Vec<u32> = vec![];

    for model in &models {
        let mesh = &model.mesh;

        for index in &mesh.indices {
            vertices.push(Vertex {
                position: glm::vec3(
                    mesh.positions[(3 * index + 0) as usize],
                    mesh.positions[(3 * index + 1) as usize],
                    mesh.positions[(3 * index + 2) as usize],
                ),
                texture_coord: glm::vec2(
                    mesh.texcoords[(2 * index + 0) as usize],
                    -1. - mesh.texcoords[(2 * index + 1) as usize],
                ),
            });

            indices.push((vertices.len() - 1) as u32);
        }
    }

    Ok(MeshData {
        vertices: vertices,
        indices: indices,
    })
}

We have a load_text_file method because .obj files are actually text based. We can use this method later to load other text based assets such as shader program files.

Important: When running our application on various targets such as mobile phones, we should use the SDL2 RWops mechanism for file access which are cross platform compatible, whereas regular Rust based file access methods are not.

The load_obj_file method uses the tobj library to parse the text of a loaded .obj file. We then iterate the 3d geometry data to build up a set of vertices and indices, including texture coordinates on the way. Ultimately we end up producing a MeshData object which has the correctly configured 3D data in it. I explored this topic in depth in this article from A Simple Triangle so have a read of that as we are doing pretty much the same thing here but in Rust.

Load .png files

We can load image asset resources - for this project in the .png format - by using the SDL2 Image library that we have already set up in our project. It offers a range of methods to load image files and convert them into surfaces. A surface contains the actual image data that can then be transformed by our engine into texture resources. Edit core/io.rs, adding the following method:

pub fn load_png(path: &str) -> Failable<TextureData> {
    let surface: Surface = sdl2::image::LoadSurface::from_file(Path::new(path))?;
    Ok(TextureData::new(surface.convert_format(PixelFormatEnum::RGBA32)?))
}

I also covered OpenGL textures in this article in A Simple Triangle if you’d like to learn more.

Summary

We now have all the components in place to load up asset resources and represent their positions and orientations in 3D space. In the next article we will implement the OpenGL engine aspect and finally start rendering our 3D models in our main scene.

The code for this article can be found here.

Continue to Part 8: OpenGL engine.

End of part 7