Writing Your Simplified OpenGL in Rust - Part 2 (Wire Rendering)

    So, in the continuation of the previous article, I am writing the 2nd part, where we will try to get to writing a wire render. I remind you that the goal of this series of articles is to write a very simplified analogue of OpenGL in Rust. The “Short course in computer graphics” from haqreu is used as the basis , in my articles I no longer focus on the graphics as such, but on the implementation features with Rust: emerging problems and their solutions, personal impressions, useful resources for Rust students. The resulting program itself has no special value, the benefit of this case is in the study of a new promising PL and the basics of three-dimensional graphics. Finally, this lesson is pretty exciting.

    I also remind you that since I am not a professional neither in Rust nor in 3D-graphics, but I study these things right in the course of writing the article, there may be gross errors and omissions, which, however, I am happy to correct if I have them indicate in the comments.


    The machine that we get at the end of the article

    Put the line in order


    Well, let's start by rewriting our nightmarish handmade function line to a normal implementation of the Bresenham algorithm from the haqreu article . Firstly, it is faster, secondly more canonical , thirdly we can compare Rust code with C ++ code .

        pub fn line(&mut self, mut x0: i32, mut y0: i32, mut x1: i32, mut y1: i32, color: u32) {
            let mut steep = false;
            if (x0-x1).abs() < (y0-y1).abs() {
                mem::swap(&mut x0, &mut y0);
                mem::swap(&mut x1, &mut y1);
                steep = true;
            }
            if x0>x1 {
                mem::swap(&mut x0, &mut x1);
                mem::swap(&mut y0, &mut y1);
            }
            let dx = x1-x0;
            let dy = y1-y0;
            let derror2 = dy.abs()*2;
            let mut error2 = 0;
            let mut y = y0;
            for x in x0..x1+1 {
                if steep {
                    self.set(y, x, color);
                } else {
                    self.set(x, y, color);
                }
                error2 += derror2;
                if error2 > dx {
                    y += if y1>y0 { 1 } else { -1 };
                    error2 -= dx*2;
                }
            }
        }
    

    As you can see, the differences are minimal, and the number of lines relative to the original remains unchanged. There were no particular difficulties at this stage.

    Do the test


    After the implementation of the line was over, I decided not to delete the code that served me so well in testing, which drew 3 of our test lines:

        let mut canvas = canvas::Canvas::new(100, 100);
        canvas.line(13, 20, 80, 40, WHITE);
        canvas.line(20, 13, 40, 80, RED);
        canvas.line(80, 40, 13, 20, BLUE);
    

    I don’t know what kind of experience the author of the original article has, but it turns out that these 3 calls cover pretty much the whole range of errors that can be made when implementing the line. And which, of course, I allowed.

    Removing the code into an unused function will cause Rust to give a warning every time it compiles (the compiler swears at every unused function, or variable). Of course, warning can also be suppressed by giving the function a name starting with the bottom dash _test_line() , but it somehow smells bad. And to store potentially useful but now unnecessary code in comments in general, in my opinion, is a bad programming tone. A much smarter solution is to create a test! So, for information, we turn to the corresponding articleabout the testing functionality in Rust to make your first test in this language.

    This is done in an elementary way. Just write a #[test] line above the signature of the function. This turns her into a test. Rust does not display warning functions as unused for such functions, and the launch cargo test causes Cargo to display statistics on the run of all such functions in the project:

         Running target/debug/rust_project-2d87cd565073580b
    running 1 test
    test test_line ... ok
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured
    

    Interestingly, it also displays warning'i for all unused functions and variables based on the fact that the input point of the project is functions marked as a test. In the future, this helps determine the test coverage of project functions. It is clear that so far our test does not really test anything, because the window with the drawing results just appears and immediately disappears. In a good way, there should be a mock object that replaces our Canvas, which allows you to check the sequence of function calls set(x, y, color); for compliance with the given one. Then it will be an automatic unit test. In the meantime, we just played around with the corresponding compiler functionality. Here is a snapshot of the repository after these changes.

    Vectors and reading files


    Well, it's time to start implementing wire rendering. The first obstacle along this path is that we will need to read the model file (which is stored in the Wavefront .obj file format ). haqreu in his article provides a ready-made parser for his students, which uses the classes of 2-dimensional and 3-dimensional vectors, also presented by haqreu . Since its implementation is in C ++, we will need to rewrite all this in Rust. We start, naturally, with vectors. Here is a code snippet of the original vector (two-dimensional version):

    template  struct Vec2 {
    	union {
    		struct {t u, v;};
    		struct {t x, y;};
    		t raw[2];
    	};
    	Vec2() : u(0), v(0) {}
    	Vec2(t _u, t _v) : u(_u),v(_v) {}
    	inline Vec2 operator +(const Vec2 &V) const { return Vec2(u+V.u, v+V.v); }
    	inline Vec2 operator -(const Vec2 &V) const { return Vec2(u-V.u, v-V.v); }
    	inline Vec2 operator *(float f)          const { return Vec2(u*f, v*f); }
    	template  friend std::ostream& operator<<(std::ostream& s, Vec2& v);
    };
    

    In the implementation of vectors in C ++, templates are used. In Rust, their analogue is Generics, about which you can read the corresponding article , as well as see examples of their use on rustbyexample.com . In general, this site is a very useful resource when learning Rust. For every possibility of the language there is an example of use with detailed comments and the ability to edit and run examples directly in the browser window (the code is executed on a remote server).

    When I tried to make a constructor that takes no arguments and creates a zero vector (0, 0), I ran into another problem. As I understand it, a rasta type system cannot be created, because we will not be able to initialize the structure with default values ​​due to the lack of implicit type casting. Such functionality can be implemented through traits , but for this you will have to write a lot of code or use the standard trait std::num::Zero , which is unstable. I didn't like both options, so I decided it was easier to write new(0, 0) in code.

    A showdown with generalized types, types and operator overloading took several hours. When I realized that to implement an analog of the original classes of vectorsI still need to delve into how to do operator overloading (which is itself arranged using traits) for the generalized type, I decided to go from another side. It seems that in C ++ it is done with a few lines of code and, in Rust, it is sometimes implemented at times more complex and long code. Perhaps this is due to the fact that I'm trying to literally translate C ++ - code into Rust, instead of comprehending the algorithm and writing its analogue in a language with a significantly different ideology. In general, I settled on making my own vector with only those capabilities that, as far as I can tell, I will definitely need to store information from the model file according to my own judgments about this. The result was such a simple class, which is quite enough at the current stage of the task:

    pub struct Vector3D {
        pub x: f32,
        pub y: f32,
        pub z: f32,
    }
    impl Vector3D {
        pub fn new(x: f32, y: f32, z: f32) -> Vector3D {
            Vector3D {
                x: x,
                y: y,
                z: z,
            }
        }
    }
    impl fmt::Display for Vector3D {
        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
            write!(f, "({},{},{})", self.x, self.y, self.z)
        }
    }
    

    Now you can take up the parser, but we have not yet studied working with files in Rust. Then StackOverflow came to the rescue, where there was an answer with an easy-to-understand code example . Based on it, the following code was obtained:

    pub struct Model {
        pub vertices: Vec,
        pub faces : Vec<[i32; 3]>,
    }
    impl Model {
        pub fn new(file_path: &str) -> Model {
            let path = Path::new(file_path);
            let file = BufReader::new(File::open(&path).unwrap());
            let mut vertices = Vec::new();
            let mut faces = Vec::new();
            for line in file.lines() {
                let line = line.unwrap();
                if line.starts_with("v ") {
                    let words: Vec<&str> = line.split_whitespace().collect();
                    vertices.push(Vector3D::new(words[1].parse().unwrap(), 
                                                words[2].parse().unwrap(),
                                                words[3].parse().unwrap()));
                    debug!("readed vertex: {}", vertices.last().unwrap());
                } else if line.starts_with("f ") {
                    let mut face: [i32; 3] = [-1, -1, -1];
                    let words: Vec<&str> = line.split_whitespace().collect();
                    for i in 0..3 {
                        face[i] = words[i+1].split("/").next().unwrap().parse().unwrap();
                        face[i] -= 1;
                        debug!("face[{}] = {}", i, face[i]);
                    }
                    faces.push(face);
                }
            }
            Model {
                vertices: vertices,
                faces: faces,
            }
        }
    }
    

    There were no particular difficulties with him. Just reading the file and processing the lines. Unless only the search for information on how to make this or that thing in the rast is complicated by the fact that the language changes rapidly on the Internet a lot of information for older versions of Rust <1.0. (thanks stepik777 for constructively correcting ) Sometimes you find some answers, try them, but they don’t work, because it turns out that this method was renamed, deleted, etc. I came across this with an example of a method from_str() .

    At first I made a mistake in this code, forgetting to write a line faces.push(face); and for a long time I could not understand why my render did not even enter the cycle that runs through all faces. Only after I found out what the problem was by typing, did I find an interesting line in the output of the compiler warning: variable does not need to be mutable, #[warn(unused_mut)] on by default regarding the line of declaring the variable face. And I did not notice this warning because I had a bunch of warnings regarding unused variables, so I forgot to look at them. After that, I commented out all the unused variables, so now any warning is striking. In Rust, compiler warnings are very helpful in finding bugs and should not be neglected.

    It is also worth noting that the code looks quite simple and understandable, unlike the original in C ++. Around the same, it could be written in some Python or Java. It is also interesting how productive it is compared to the original. I plan to take performance measurements when the entire render from start to finish is ready.

    Wire render


    Finally, here it is a wire render. Most of the work was done in the previous steps, so the code is simple:

    fn main() {
        env_logger::init().unwrap();
        info!("starting up");
        let model = Model::new("african_head.obj");
        let mut canvas = canvas::Canvas::new(WIDTH, HEIGHT);
        debug!("drawing wireframe");
        for face in model.faces {
            debug!("processing face:");
            debug!("({}, {}, {})", face[0], face[1], face[2]);
            for j in 0..3 {
                let v0 = &model.vertices[face[j] as usize];
                let v1 = &model.vertices[face[(j+1)%3] as usize];
                let x0 = ((v0.x+1.)*WIDTH as f32/2.) as i32;
                let y0 = ((v0.y+1.)*HEIGHT as f32/2.) as i32;
                let x1 = ((v1.x+1.)*WIDTH as f32/2.) as i32;
                let y1 = ((v1.y+1.)*HEIGHT as f32/2.) as i32;
                debug!("drawing line ({}, {}) - ({}, {})", x0, y0, x1, y1); 
                canvas.line(x0, y0, x1, y1, WHITE);
            }
        }
        info!("waiting for ESC");
        canvas.wait_for_esc();
    }
    

    Apart from minor differences in syntax, it differs from C ++ mainly in a large number of type conversions. Well, logging, which I poked everywhere when I was looking for errors. Here is the picture we get in the end ( snapshot of the code in the repository ):



    This is already pretty good, but firstly, if I feed my program in its current form the model of the machine that I plan to draw, it simply will not show it. Secondly, all these beauties are drawn terribly long (I started the program and you can go drink coffee). The first problem is due to the fact that in the model of the typewriter the vertices are recorded on completely different scales. The code above is tailored to the scale of the head model. To make it universal, you still have to work with it. I don’t know the second problem because of what, but if you think about it, there are only 2 options: either an inefficient algorithm is used, or an ineffective implementation of this algorithm is written on this particular technology stack. In any case, the question arises which specific piece of the algorithm (implementation) is inefficient.

    In general, as you already understood, I decided to start with the issue of speed.

    Measure performance


    Since I still had plans to compare the performance of the original project and my implementation in Rust, I decided to just do it early. However, the working principle of the original and my implementation are significantly different. The original draws in a temporary buffer and only at the end writes the TGA file, while my application executes SDL rendering commands right in the process of processing the triangles.

    The solution is simple - redo our Canvas so that the method of drawing a point set(x, y, color) only stores data in an internal array, and direct drawing using the SDL tools was already performed at the end of the program, after all the calculations were completed. By this we kill 3 birds with one stone:
    1. We get the opportunity to compare the speed of implementations before rendering / saving to a file, that is, where they essentially do identical things.
    2. We receive preparations for the future for double buffering .
    3. We separate our calculations from drawing, which allows us to evaluate the overhead imposed by SDL calls.

    Rewriting Canvas quickly, I saw that the calculation of the lines itself was very fast. But rendering using SDL was done at a turtle speed. There is scope for optimization. It turned out that the point-drawing feature in Rust-SDL2 was by no means as fast as I expected. We managed to solve the problem by saving the entire image to a texture and then outputting this texture with this code:

        pub fn show(&mut self) {
            let mut texture = self.renderer.create_texture_streaming(PixelFormatEnum::RGB24, 
                                           (self.xsize as u32, self.ysize as u32)).unwrap();
            texture.with_lock(None, |buffer: &mut [u8], pitch: usize| {
                for y in (0..self.ysize) {
                    for x in (0..self.xsize) {
                        let offset = y*pitch + x*3;
                        let color = self.canvas[x][self.ysize - y - 1];
                        buffer[offset + 0] = (color >> (8*2)) as u8;
                        buffer[offset + 1] = (color >> (8*1)) as u8;
                        buffer[offset + 2] = color as u8;
                    }
                }
            }).unwrap();
            self.renderer.clear();
            self.renderer.copy(&texture, None, Some(Rect::new_unwrap(0, 0, 
                                                    self.xsize as u32, self.ysize as u32)));
            self.renderer.present();
        }
    

    In general, there was nothing new in rewriting Canvas from the point of view of programming in Rust, so there is nothing much to talk about. The code at this point is in the corresponding snapshot of the repository . After these changes, the program began to fly. Drawing took a split second. There is already an interest in measuring performance has disappeared. Since the program execution took very little time, a simple measurement error due to random processes in the OS could increase this time by 2 times or vice versa reduce it. In order to somehow fight this, I enclosed the main body of the program (reading the .obj file and calculating the two-dimensional projection) in a cycle that was run 100 times. Now it was possible to measure something. I did the same with the C ++ implementation from haqreu .

    Actually, here are the numbers of the Rust implementation:

    cepreu@cepreu-P5K:~/Загрузки/rust-3d-renderer-70de52d8e8c82854c460a41d1b8d8decb0c2e5c1$ time ./rust_project 
    real	0m0.769s
    user	0m0.630s
    sys	0m0.137s
    

    And here are the implementation numbers in C ++:
    cepreu@cepreu-P5K:~/Загрузки/tinyrenderer-f6fecb7ad493264ecd15e230411bfb1cca539a12$ time ./a.out 
    real	0m1.492s
    user	0m1.483s
    sys	0m0.008s
    

    I ran each program 10 times, and then I chose the best time (real). I brought it to you. In my implementation, I made modifications to cut out all the references to SDL, so that external calls did not affect the resulting time. Actually, you can see in the snapshot of the repository .

    Here are the modifications I made to the C ++ implementation:

    int main(int argc, char** argv) {
        for (int cycle=0; cycle<100; cycle++){
            if (2==argc) {
                model = new Model(argv[1]);
            } else {
                model = new Model("obj/african_head.obj");
            }
            TGAImage image(width, height, TGAImage::RGB);
            for (int i=0; infaces(); i++) {
                std::vector face = model->face(i);
                for (int j=0; j<3; j++) {
                    Vec3f v0 = model->vert(face[j]);
                    Vec3f v1 = model->vert(face[(j+1)%3]);
                    int x0 = (v0.x+1.)*width/2.;
                    int y0 = (v0.y+1.)*height/2.;
                    int x1 = (v1.x+1.)*width/2.;
                    int y1 = (v1.y+1.)*height/2.;
                    line(x0, y0, x1, y1, image, white);
                }
            }
            delete model;
        }
        //image.flip_vertically(); // i want to have the origin at the left bottom corner of the image
        //image.write_tga_file("output.tga");
        return 0;
    }
    

    Well, I also deleted the debugging print in model.cpp. In general, of course, the result surprised me. It seemed to me that the Rust compiler didn’t have to be as well optimized as gcc yet, and because of my ignorance, I probably piled up non-optimal code ... I somehow don’t even understand why this code turned out to be faster. Or is Rust so super fast. Or in C ++, something is not optimal. In general, those wishing to discuss this - welcome to comment.

    Summary


    Finally, through a simple adjustment of the coefficients (see the repository picture ), I got a picture with a machine that optimally occupies the window space. You observed it at the beginning of the article.

    Some impressions:
    • Writing in Rust is getting easier. The early days were an ongoing struggle with the compiler. Now I just sit down and write code, occasionally peeping on the Internet how to make this or that thing. In general, for the most part, the language is already perceived as familiar. As you can see, this did not take much time.
    • Still pleased warning'a rasta. That in other languages ​​only a very advanced IDE (like IntelliJ IDEA in Java) tells you, the compiler itself says in Rust. Helps maintain a good style, protects from mistakes.
    • The fact that Rust was faster is a shock. Apparently the compiler is nowhere near as raw as I thought.


    Final - 3rd part of the cycle: We write our simplified OpenGL in Rust - part 3 (rasterizer)

    Also popular now: