So, in the continuation of the previous
article I am writing the 2nd part, where we will try to get to write a wire render. I remind you that the purpose of this series of articles is to write a strongly simplified analogue of OpenGL on Rust. The
“ Haqreu Computer Graphics Short Course” is used as a basis, in my own articles I focus no more on graphics as such, but on implementation features using Rust: problems that arise and their solutions, personal impressions, useful resources for Rust learners. The resulting program itself has no special value, the benefits of this case are in studying the new prospective PL and the fundamentals of three-dimensional graphics. Finally, this lesson is quite exciting.
I also remind you that since I am not a professional in Rust or in 3D graphics, but I study these things directly as I write this article, there may be blunders and omissions, which I, however, am happy to correct, if I’m into them will indicate in the comments.
The machine that we get at the end of the articlePut the line in order
Well, let's start by rewriting our dreadful handmade-line function to the normal implementation of the Bresenham algorithm from
haqreu article. Firstly, it is faster, secondly more
canonical , thirdly we can compare the code on Rust with the
code in C ++ .
')
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. No special difficulties at this stage arose.
Do the test
After the line was finished, I decided not to delete the code that had served me so well in testing, which was drawing 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 experience the author of the original article has, but it turns out that these 3 challenges quite well cover almost the whole range of errors that can be made in the implementation of the line. And which I, of course, allowed.
Moving the code to an unused function will cause Rust to issue a warning at each compilation (the compiler curses each 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 store potentially useful but now unnecessary code in the comments in general, in my opinion, a bad tone of programming. A much more reasonable solution is to create a test! So, for information, refer to the relevant
article about testing functionality in Rust to make your first test in this language.
This is done elementary. It is enough to write
#[test]
line above the function signature. This turns her into a test. For such functions, Rust does not display warnings as unused, and the launch of the
cargo test
causes Cargo to show us 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
What is interesting, it also displays warning messages for all unused functions and variables, assuming that the input point of the project is functions marked as a test. In the long term, this helps to determine the coverage of project functions with tests. It is clear that while our test doesn’t really test anything, because the window with the results of drawing just appears and immediately disappears. In an amicable way, there should be a mock-object replacing our Canvas, which allows you to check the sequence of
set(x, y, color);
function calls
set(x, y, color);
for compliance with a given. Then it will be an automatic unit test. For now, we just played around with the appropriate compiler functionality. Here is a
snapshot of the repository after these changes.
Vectors and file reading
Well, it's time to start the implementation of wire rendering. The first obstacle on this path is we need to read the model file (which is stored in the
“Wavefront .obj file” format).
haqreu in its article provides a ready-made parser for its students, which, when working, uses classes of 2-dimensional and 3-dimensional vectors, also represented by
haqreu . Since its implementation is in C ++, we will need to rewrite all this to Rust. We begin naturally with vectors. Here is an excerpt of the original vector code (two-dimensional version):
template <class t> struct Vec2 { union { struct {tu, v;}; struct {tx, y;}; t raw[2]; }; Vec2() : u(0), v(0) {} Vec2(t _u, t _v) : u(_u),v(_v) {} inline Vec2<t> operator +(const Vec2<t> &V) const { return Vec2<t>(u+Vu, v+Vv); } inline Vec2<t> operator -(const Vec2<t> &V) const { return Vec2<t>(uV.u, vV.v); } inline Vec2<t> operator *(float f) const { return Vec2<t>(u*f, v*f); } template <class > friend std::ostream& operator<<(std::ostream& s, Vec2<t>& v); };
In the implementation of vectors in C ++, templates are used. In Rust, their analogue is generic types (Generics), about which you can read the relevant
article , as well as see
examples of their use on
rustbyexample.com . In general, this site is a very useful resource in the study of Rust. For each language opportunity 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, but creates a zero vector (0, 0), I ran into another problem. As I understand it, the rasta type system cannot be created this way, because we will not be able to initialize the structure with default values ​​due to the lack of implicit type conversion. Such functionality can be implemented through
types (Traits) , but for this you have to write a lot of code or use the standard type
std::num::Zero
, which is unstable. I did not like both options, so I decided that it was easier to write
new(0, 0)
in the code.
Disassembly with generalized types, types and operator overload took several hours. When I realized that to implement an analogue of the
original classes of vectors, I would need to understand more about how to do operator overloading (which itself is constructed using types) for a generalized type, I decided to go from the other side. It seems that in C ++ it is done with a few lines of code and, in Rust, it is sometimes implemented in times more complicated and long code. Perhaps this is due to the fact that I am trying to literally translate C ++ code into Rust, instead of comprehending the algorithm and writing its counterpart in a language with a substantially different ideology. In general, I stopped at making my vector with only those capabilities that, as far as I can tell, I will need to store information from the model file according to my own judgments about it. The result is 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. Here StackOverflow came to the rescue, where there was an answer with a simple to understand
code example . Based on it, the following code was obtained:
pub struct Model { pub vertices: Vec<Vector3D>, 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. Is that only the search for information, how to do this or that thing in the plant is complicated by the fact that the
language is rapidly changing on the Internet a lot of information for older versions of Rust <1.0. (thanks to
stepik777 for
correcting constructively ) Sometimes you find some answers, try them, but they don’t work, because this method is renamed, deleted, etc. I
from_str()
this using the example of
from_str()
.
At first I made a mistake in this code, having forgotten to write the line
faces.push(face);
and for a long time could not understand why my render does not even enter a cycle running through all the faces. Only after I figured out what the problem was, I found an interesting line in the compiler's output.
warning: variable does not need to be mutable, #[warn(unused_mut)] on by default
regarding the line of declaring the variable face. I didn’t notice this warning because I had a bunch of warnings about unused variables, so I scored to view them. After that, I commented out all the unused variables, so now any warning will be striking. In Rust, compiler warnings are very useful in finding errors and should not be neglected.
It is also worth noting that the code looks quite simple and understandable, unlike the original in C ++. Approximately it could also be written in some Python or Java. It is also interesting how it is productive in comparison with the original. I plan to make measurements of performance, when the whole render from start to finish will be ready.
Wire Render
Finally, here it is wire rendering. Most of the work was done in the previous stages, 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 by a large number of type conversions. Well, logging, which I poked everywhere, when I was looking for errors. Here, what picture we get as a result (
snapshot code in the repository ):

This is pretty good, but firstly, if I feed my model 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 and drink coffee). The first problem is due to the fact that in the model of the machine the vertices are recorded on a completely different scale. The code above is adjusted to the scale of the head model. To make it universal, you still need to work with it. I don’t know the second problem because of what, but if you think about it, the option is only 2: either an inefficient algorithm is used, or an inefficient implementation of this algorithm is written on this particular technology stack. In any case, there will be another question, which particular piece of the algorithm (implementation) is inefficient.
In general, as you already understood, I decided to start with the question of speed.
Measuring performance
Since I still had plans to compare the performance of the
original project and my implementation at Rust, I decided to just do it early. However, the principle of the original and my implementation are significantly different. The original draws in the temporary buffer and only at the end writes the TGA file, while my application executes the commands to draw the SDL directly during the processing of the triangles.
The solution is simple - to remake our Canvas, so that the method of drawing the
set(x, y, color)
point only saves the data to the internal array, and the SDL directly draws at the end of the program, after all the calculations have been done. We kill 3 birds with this:
- We get the opportunity to compare the speed of implementations to render / save to a file, i.e. where they essentially do identical things.
- We get blanks for the future for double buffering .
- We separate our calculations from drawing, which allows us to estimate the overhead imposed by SDL calls.
Quickly rewriting Canvas, I saw that the calculation of the lines itself was very fast. But drawing with SDL was done with snail speed. There is room for optimization. It turned out that the point drawing function in Rust-SDL2 was by no means as fast as I expected. The problem was solved 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, rewriting Canvas did not create anything new from the point of view of programming in Rust, so there’s nothing to talk about. The code at this stage 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 how to measure performance disappeared. Since the execution of the program took very little time, a simple measurement error due to random processes in the OS could increase this time by 2 times or, on the contrary, reduce it. In order to somehow fight this, I concluded the main body of the program (reading an .obj-file and calculating a two-dimensional projection) in a cycle that was performed 100 times. Now it was possible to measure something. He did the same with the
haqreu C ++ implementation.
Actually, here are the Rust-implementation numbers:
cepreu@cepreu-P5K:~//rust-3d-renderer-70de52d8e8c82854c460a41d1b8d8decb0c2e5c1$ time ./rust_project real 0m0.769s user 0m0.630s sys 0m0.137s
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 of the programs 10 times, and then chose the best time (real). I brought him to you. I made modifications to my implementation in order to cut out all references to the SDL so that external references did not affect the resulting time. Actually you can see in the
snapshot of the repository .
Here are the modifications I made in 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; i<model->nfaces(); i++) { std::vector<int> 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; }
Well, also deleted the debug print in model.cpp. In general, of course, the result surprised me. It seemed to me that the Rust compiler still should not be as well optimized as gcc, and I unknowingly probably built up a suboptimal code ... I somehow don’t even understand why my code was faster. Or is it Rust so super fast. Or in C ++, the implementation is not optimal. In general, those who wish to discuss this - welcome to the comments.
Results
Finally, by uncomplicated adjustment of the coefficients (see the
repository snapshot ), I got a picture of the machine that optimally occupies the space of the window. You observed it at the beginning of the article.
Some impressions:
- Writing on Rust is becoming easier. The first days were incessant struggle with the compiler. Now I just sit down and write the code, from time to time looking on the Internet how to do this or that thing. In general, for the most part the language is already perceived familiar. As you can see, it didn't take long.
- Still delight warning'i rasta. The fact that in other languages ​​only a very advanced IDE (such as IntelliJ IDEA in Java) is prompted in Rust says the compiler itself. Helps maintain good style, saves from mistakes.
- The fact that Rust was faster - shock. Apparently the compiler is nowhere near as raw as I thought.
Final - 3rd part of the cycle:
We write our simplified OpenGL on Rust - part 3 (rasterizer)