ashen-aetna

Ashen Aetna

— Rustily stumbling around on an ash-covered volcano

(A tutorial on/in/about/with 3D graphics, Rust, Vulkan, ash)

Positioning the boxes, rotation and scaling

We have several boxes in our scene. We can put them in different places. But can we also have them in different sizes? And possibly rotated?

It would be good if they could still share their vertex data.

Therefore: This will become part of the instance data.

That seems logical: There we are also storing the position (offset) of the whole model.

How can we include scaling and rotation?

Well, both are linear transformations. And together with the translation they fit into one 4x4 matrix. (To be fair, here we could get away with fewer entries than the full matrix. We don’t try.)

What do we do with this matrix (let’s call it )? We multiply every vertex position (remember: A position is a vector with four components) of our model by this matrix when drawing. That is, we draw the vertex at , not at .

If encodes a translation by some vector , for example, we will draw the point that was described as being at the origin when we created the model at instead. translates the coordinates from a model-local system (origin = centre of the model, or maybe: the point in the middle below its “feet”) to a world coordinate system (a global coordinate system in which we describe the positions off all objects, origin = some special point in space relative to which positions shall be given).

This matrix is usually called “model matrix”. How to figure out its components? Think about what the vectors etc. should be transformed to, and write that as columns of the matrix (cf. chapters 14 and following).

So instead of the position_offset we should pass a whole 4x4 matrix into our vertex shader, and then apply this matrix to the position vector in the shader. There is a suitable variable type in GLSL: mat4

#version 450

layout (location=0) in vec3 position;
layout (location=1) in mat4 model_matrix;
layout (location=2) in vec3 colour;

layout (location=0) out vec4 colourdata_for_the_fragmentshader;

void main() {
    gl_Position = model_matrix*vec4(position,1.0);
    colourdata_for_the_fragmentshader=vec4(colour,1.0);
}

We have learned that the next thing to adapt are the vertex attribute descriptions. For the position offset of [f32; 3], it was

            vk::VertexInputAttributeDescription {
                binding: 1,
                location: 1,
                offset: 0,
                format: vk::Format::R32G32B32_SFLOAT,
            },

and we certainly have to change the format. But if we look at the list of formats, we have to notice that there is not really a suitable [f32; 16] variant (which would be what we’d need for our 4x4 matrix).

What do we do? Well, we can just use several locations and transmit the matrix column by column. (If it’s

, we send [1,5,9,13] first, then [2,6,10,14] and so on.)

        let vertex_attrib_descs = [
            vk::VertexInputAttributeDescription {
                binding: 0,
                location: 0,
                offset: 0,
                format: vk::Format::R32G32B32_SFLOAT,
            },
            vk::VertexInputAttributeDescription {
                binding: 1,
                location: 1,
                offset: 0,
                format: vk::Format::R32G32B32A32_SFLOAT,
            },
            vk::VertexInputAttributeDescription {
                binding: 1,
                location: 2,
                offset: 16,
                format: vk::Format::R32G32B32A32_SFLOAT,
            },
            vk::VertexInputAttributeDescription {
                binding: 1,
                location: 3,
                offset: 32,
                format: vk::Format::R32G32B32A32_SFLOAT,
            },
            vk::VertexInputAttributeDescription {
                binding: 1,
                location: 4,
                offset: 48,
                format: vk::Format::R32G32B32A32_SFLOAT,
            },
            vk::VertexInputAttributeDescription {
                binding: 1,
                location: 5,
                offset: 64,
                format: vk::Format::R32G32B32_SFLOAT,
            },
        ];
        let vertex_binding_descs = [
            vk::VertexInputBindingDescription {
                binding: 0,
                stride: 12,
                input_rate: vk::VertexInputRate::VERTEX,
            },
            vk::VertexInputBindingDescription {
                binding: 1,
                stride: 76,
                input_rate: vk::VertexInputRate::INSTANCE,
            },
        ];

(Locations 1,2,3,4 are the matrix.)

We have to correct the shader:

#version 450

layout (location=0) in vec3 position;
layout (location=1) in mat4 model_matrix;
layout (location=5) in vec3 colour;

layout (location=0) out vec4 colourdata_for_the_fragmentshader;

void main() {
    gl_Position = model_matrix*vec4(position,1.0);
    colourdata_for_the_fragmentshader=vec4(colour,1.0);
}

Note that we are still keeping the mat4 as location 1 here. (Actually, it’s locations 1, 2, 3 and 4, but they will be automatically combined.) What we had to adapt was the location for colour: It cannot be location 2, it has to use the next “free” location (compare with the VertexInputAttributeDescriptions).

Next place to change: our InstanceData struct:

#[repr(C)]
struct InstanceData {
    modelmatrix: [f32; 16],
    colour: [f32; 3],
}

as well as all places where it is created:

        cube.insert_visibly(InstanceData {
            modelmatrix: [
                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, 0.0, 1.0,
            ],
            colour: [1.0, 0.0, 0.0],
        });
        cube.insert_visibly(InstanceData {
            modelmatrix: [
                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.25, 0.0, 1.0,
            ],
            colour: [0.6, 0.5, 0.0],
        });
        cube.insert_visibly(InstanceData {
            modelmatrix: [
                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.5, 0.0, 1.0,
            ],
            colour: [0.0, 0.5, 0.0],
        });

These changes are enough to have the program running again, with the same output.

(If the output has become a black window by these changes, I recommend checking the offsets and strides in the vertex attribute and binding descriptions first.)

I don’t really like

            modelmatrix: [
                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, 0.0, 1.0,
            ],

very much. Maybe we can make it a bit more readable by using [[f32; 4]; 4] instead of [f32; 16]:

#[repr(C)]
struct InstanceData {
    modelmatrix: [[f32; 4]; 4],
    colour: [f32; 3],
}

and

        cube.insert_visibly(InstanceData {
            modelmatrix: [
                [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, 0.0, 1.0],
            ],
            colour: [1.0, 0.0, 0.0],
        });
        cube.insert_visibly(InstanceData {
            modelmatrix: [
                [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.25, 0.0, 1.0],
            ],
            colour: [0.6, 0.5, 0.0],
        });
        cube.insert_visibly(InstanceData {
            modelmatrix: [
                [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.5, 0.0, 1.0],
            ],
            colour: [0.0, 0.5, 0.0],
        });

These are the only changes required: How those f32s are stored in memory is unaffected; our vertex shader (or vertex buffer) won’t notice any difference.

However, this way is still confusing. The lines in this code are actually columns of the matrices, which should be easy to froget. Also, we can’t really use these matrices for common operations like matrix*matrix or matrix*vector etc.

We should dedicated matrix and vector types in our code. We could start implementing them ourselves — but it seems more reasonable to include a specialized crate for linear algebra; my choice is nalgebra. New line for Cargo.toml: nalgebra = "0.18.0", and new line for our code: use nalgebra as na;

Creating the matrix then can take different forms:

        let matrix1 = na::Matrix4::from_column_slice(&[
            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.5, 0.0, 1.0,
        ]);
        let matrix2 = na::Matrix4::from_row_slice(&[
            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
        ]);
        let matrix3 = na::Matrix4::<f32>::new(
            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
        );
        let matrix4 = na::Matrix4::from_columns(&[
            na::Vector4::new(1.0, 0.0, 0.0, 0.0),
            na::Vector4::new(0.0, 1.0, 0.0, 0.0),
            na::Vector4::new(0.0, 0.0, 1.0, 0.0),
            na::Vector4::new(0.0, 0.5, 0.0, 1.0),
        ]);
        let matrix5 = na::Matrix4::from_rows(&[
            na::RowVector4::new(1.0, 0.0, 0.0, 0.0),
            na::RowVector4::new(0.0, 1.0, 0.0, 0.5),
            na::RowVector4::new(0.0, 0.0, 1.0, 0.0),
            na::RowVector4::new(0.0, 0.0, 0.0, 1.0),
        ]);
        let matrix6 = na::Matrix4::<f32>::new(
            1.0, 0.0, 0.0, 0.0, //
            0.0, 1.0, 0.0, 0.5, //
            0.0, 0.0, 1.0, 0.0, //
            0.0, 0.0, 0.0, 1.0,
        );
        println!("{}", matrix1);
        println!("{}", matrix2);
        println!("{}", matrix3);
        println!("{}", matrix4);
        println!("{}", matrix5);
        println!("{}", matrix6);

All of these matrices are the same (and the same as the last matrix before). I recommend println! over dbg! for inspecting it.

A small “trick” you can see in this code example are the (empty) comments at the ends of the lines in the definition of matrix6 tricking rustfmt into leaving these lines alone and keeping them in a readable “looks just like a regular matrix” form. (It doesn’t work any longer if the matrix entries become so long that rustfmt wants to put them in separate lines anyway.)

It’s worth noting that nalgebra comes with a bunch of additional types that carry information like “this vector has unit length” or “this transformation is a rotation (and a rotation only)” and so on.

— If we want to construct the same matrix as before in a more self-explanatory way:

        let matrix7 = na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.5, 0.0));

We can also turn these matrices in the right form to use with our shaders:

        let matrix_for_vulkan: [[f32; 4]; 4] = matrix7.into();

(We could also rely on .to_slice methods or adjust the type of modelmatrix in InstanceData to na::Matrix4. We do neither.)

The creation of boxes could look like this:

        cube.insert_visibly(InstanceData {
            modelmatrix: na::Matrix4::identity().into(),
            colour: [1.0, 0.0, 0.0],
        });
        cube.insert_visibly(InstanceData {
            modelmatrix: na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.25, 0.0)).into(),
            colour: [0.6, 0.5, 0.0],
        });
        cube.insert_visibly(InstanceData {
            modelmatrix: (na::Matrix4::from_scaled_axis(na::Vector3::new(
                0.0,
                0.0,
                std::f32::consts::FRAC_PI_3,
            )) * na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.5, 0.0)))
            .into(),
            colour: [0.0, 0.5, 0.0],
        });

Two comments on the transformation for the last cube, “Rotation*Translation”: Firstly, the rotation is given as “rotation about the z-axis” (the direction of the vector in this function), the angle (here, π/3) is encoded in the length of the given vector. Secondly, “Rotation*Translation” means: translate first, then rotate (consider: R*T*v = R*(T*v)). And (try it!): the order does matter (for operations like rotation and translation, or more generally for the multiplication of matrices, except in very special cases).

In our definition of the cube

impl Model<[f32; 3], InstanceData> {
    fn cube() -> Model<[f32; 3], InstanceData> {
        let lbf = [-0.1, 0.1, 0.0]; //lbf: left-bottom-front
        let lbb = [-0.1, 0.1, 0.1];
        let ltf = [-0.1, -0.1, 0.0];
        let ltb = [-0.1, -0.1, 0.1];
        let rbf = [0.1, 0.1, 0.0];
        let rbb = [0.1, 0.1, 0.1];
        let rtf = [0.1, -0.1, 0.0];
        let rtb = [0.1, -0.1, 0.1];
        Model {
            vertexdata: vec![
                lbf, lbb, rbb, lbf, rbb, rbf, //bottom
                ltf, rtb, ltb, ltf, rtf, rtb, //top
                lbf, rtf, ltf, lbf, rbf, rtf, //front
                lbb, ltb, rtb, lbb, rtb, rbb, //back
                lbf, ltf, lbb, lbb, ltf, ltb, //left
                rbf, rbb, rtf, rbb, rtb, rtf, //right
            ],
            handle_to_index: std::collections::HashMap::new(),
            handles: Vec::new(),
            instances: Vec::new(),
            first_invisible: 0,
            next_handle: 0,
            vertexbuffer: None,
            instancebuffer: None,
        }
    }
}

we use coordinates 0.1. Let us change these to 1.0 — that seems more canonical — and rather scale the models we are using. That is something we can do with our new InstanceData.

        cube.insert_visibly(InstanceData {
            modelmatrix: na::Matrix4::new_scaling(0.1).into(),
            colour: [1.0, 0.0, 0.0],
        });
        cube.insert_visibly(InstanceData {
            modelmatrix: (na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.25, 0.0))
                * na::Matrix4::new_scaling(0.1))
            .into(),
            colour: [0.6, 0.5, 0.0],
        });
        cube.insert_visibly(InstanceData {
            modelmatrix: (na::Matrix4::from_scaled_axis(na::Vector3::new(
                0.0,
                0.0,
                std::f32::consts::FRAC_PI_3,
            )) * na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.5, 0.0))
                * na::Matrix4::new_scaling(0.1))
            .into(),
            colour: [0.0, 0.5, 0.0],
        });

(First scale, then translate. If we translated first, also the translation vector would be scaled.)

Continue