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.
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).
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;
.
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.
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>,
}
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.
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 writing1.0
.
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!
We will add a structure that can model the orientation of an object in 3D space, described by the attributes:
pitch
: Rotation on the x
axis - looking up or downyaw
: Rotation on the y
axis - turning left or rightroll
: Rotation on the z
axis - tilting left or rightThis 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.
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:
mesh_id
: Id of the 3D geometry data resource to rendertexture_id
: Id of the texture resource to apply when rendering the modelshader_id
: Id of the shader program to use when rendering the modelposition
: The x, y, z
coordinates of the 3D model in the worldscale
: The relative size of the model scaled from its original dimensionsorientation
: The orientation of the modelWe 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.
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:
fov
for short) of 66 degrees - a larger field of view will see more of the scene but will gradually produce more of a fish eye effect the larger it getswidth
and height
of the current display are also used to figure out the correct aspect ratio - for this reason it is important that if anything changes the width
or height
of our display - for example the window being resized - we should recreate our perspective camera too or it will start to look incorrect0.01
and the far is 100.0
- any geometry closer or further away from these bounds will not be drawnup
direction so it can have a reference axis to understand its orientation - we are using the positive y
axis as the up
directionposition
in 3D space as well - we can manipulate the camera position to move the user around inside a 3D scenetarget
tells the camera where is should be pointing - we can manipulate the target to cause our camera to change the direction it is pointing atThe 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.
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 avulkan
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.
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.
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.
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