📜 ⬆️ ⬇️

We write our simplified OpenGL on Rust - part 3 (rasterizer)

I continue my cycle of articles about the simplified analogue of OpenGL on Rust, in which 2 articles have already been published:
  1. We write our simplified OpenGL on Rust - part 1 (draw a line)
  2. We write our simplified OpenGL on Rust - part 2 (wire rendering)

I remind you that the basis of my series of articles is “A Short Course in Computer Graphics” from haqreu . In previous articles, I did not go very fast. In fact, for one article of the course I got 2 articles. This is due to the fact that in my articles I focus mainly on the nuances of working with Rust, and when you just learn a new language, you come across a lot of new nuances for you, rather than when you have been programming on it for some time. I think further Rust will throw less rakes, and I will align the ratio of my articles to the articles of the original course.

In the meantime, I traditionally caution that since I am not a professional in Rust or in 3D graphics, but I study these things right in the course of writing this article, there may be a lot of nonsense in it. If you notice this, write a comment - I will correct the error. And of course there will be a lot of personal impressions in the article, with which you may turn out to disagree. Constructive criticism is welcome.


What we get on the basis of this article

Draw a model with triangles


Everything was just like that and there is nothing to describe. The code can be found in the corresponding snapshot of the repository .
')
But the picture that I got.


Flat toning


Since the vector and scalar product operations are used for rendering the light, we will need to expand our good old Vector3D class with new operators. After reading a little about operator overloading in Rust and reviewing the list of available overloads , I didn’t bother to write the following code:

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, } } pub fn norm(self) -> f32 { return (self.x*self.x+self.y*self.y+self.z*self.z).sqrt(); } pub fn normalized(self, l: f32) -> Vector3D { return self*(l/self.norm()); } } impl fmt::Display for Vector3D { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({},{},{})", self.x, self.y, self.z) } } impl Add for Vector3D { type Output = Vector3D; fn add(self, other: Vector3D) -> Vector3D { Vector3D { x: self.x + other.x, y: self.y + other.y, z: self.z + other.z} } } impl Sub for Vector3D { type Output = Vector3D; fn sub(self, other: Vector3D) -> Vector3D { Vector3D { x: self.x - other.x, y: self.y - other.y, z: self.z - other.z} } } impl Mul for Vector3D { type Output = f32; fn mul(self, other: Vector3D) -> f32 { return self.x*other.x + self.y*other.y + self.z*other.z; } } impl Mul<f32> for Vector3D { type Output = Vector3D; fn mul(self, other: f32) -> Vector3D { Vector3D { x: self.x * other, y: self.y * other, z: self.z * other} } } impl BitXor for Vector3D { type Output = Vector3D; fn bitxor(self, v: Vector3D) -> Vector3D { Vector3D { x: self.y*vz-self.z*vy, y: self.z*vx-self.x*vz, z: self.x*vy-self.y*vx} } } 

Verdict: operator overloading in Rust is done elementary.
However, there was a difficulty with copying and moving. It turns out in Rust all types can be either relocatable or replicable. If the type is relocatable, then calling a method that accepts a variable of this type will make it inaccessible for all subsequent calls in the code, since the ownership of the variable will transfer to the called function. That is, such code will cause an error:

 let x = Vector3D::new(1.0, 1.0, 1.0); let y = x*2.0; do_something_else(x); // error! 

The ownership of the variable x was transferred to the multiplication function, the variable was moved to the local variable of the function and deleted after exiting the function, because we did not return it back. Actually this type of error occurred to me in the function normalized()
normalized()
, because there, as you can see, self both to the right and left of the multiplication operator. That is, we are trying to move it 2 times in a row. By default, all user structures in Rust are relocatable.
There are two solutions: either to make the variable replicable by default, or to make our operator implementations accept the reference, not the value. I chose the 2nd option. To implement it, it is enough to write before declaring the structure #[derive(Copy, Clone)] . This tells the compiler that our structure is copied and it can be copied by simple byte duplication. Now in calls like the one above, a copy of the data will be sent to our operators, and the original will remain available after the call. It looks difficult, but due to this additional complication the compiler does not allow me to write code with memory errors (for example, Use After Free ).

By the way, it turns out that there are no optional parameters in Rust and they cannot even be emulated in the usual way - by writing a function with the same name but a different set of arguments. This can be circumvented in part using types . But the method is not suitable for all cases and, in my opinion, is somewhat overly verbose. In general, types in Rast leave the impression of some unnecessary verbosity. I don’t know if it could have been done differently, without adding overhead to the runtime and without violating protection against memory errors, but the current implementation causes a keen desire to use characters as little as possible.

Further the code of drawing of model with lighting was written without special adventures.

That's what he drew us


And here is the corresponding snapshot of the repository .

Z-buffer


When I started programming Z-buffer, I realized that I still needed it so that my Vector3D class could be either with integer coordinates or with real coordinates. Rolling up his sleeves, he took up his rewriting using generalized types and types. Here the heat also has gone. Did I already say that types in Rust have complex syntax? See for yourself the code:

 use std::fmt; use std::ops::Add; use std::ops::Sub; use std::ops::Mul; use std::ops::BitXor; use num::traits::NumCast; #[derive(Copy, Clone)] pub struct Vector3D<T> { pub x: T, pub y: T, pub z: T, } impl<T> Vector3D<T> { pub fn new(x: T, y: T, z: T) -> Vector3D<T> { Vector3D { x: x, y: y, z: z, } } } impl<T: NumCast> Vector3D<T> { pub fn to<V: NumCast>(self) -> Vector3D<V> { Vector3D { x: NumCast::from(self.x).unwrap(), y: NumCast::from(self.y).unwrap(), z: NumCast::from(self.z).unwrap(), } } } impl Vector3D<f32> { pub fn norm(self) -> f32 { return (self.x*self.x+self.y*self.y+self.z*self.z).sqrt(); } pub fn normalized(self, l: f32) -> Vector3D<f32> { return self*(l/self.norm()); } } impl<T: fmt::Display> fmt::Display for Vector3D<T> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({},{},{})", self.x, self.y, self.z) } } impl<T: Add<Output = T>> Add for Vector3D<T> { type Output = Vector3D<T>; fn add(self, other: Vector3D<T>) -> Vector3D<T> { Vector3D { x: self.x + other.x, y: self.y + other.y, z: self.z + other.z} } } impl<T: Sub<Output = T>> Sub for Vector3D<T> { type Output = Vector3D<T>; fn sub(self, other: Vector3D<T>) -> Vector3D<T> { Vector3D { x: self.x - other.x, y: self.y - other.y, z: self.z - other.z} } } impl<T: Mul<Output = T> + Add<Output = T>> Mul for Vector3D<T> { type Output = T; fn mul(self, other: Vector3D<T>) -> T { return self.x*other.x + self.y*other.y + self.z*other.z; } } impl<T: Mul<Output = T> + Copy> Mul<T> for Vector3D<T> { type Output = Vector3D<T>; fn mul(self, other: T) -> Vector3D<T> { Vector3D { x: self.x * other, y: self.y * other, z: self.z * other} } } impl<T: Mul<Output = T> + Sub<Output = T> + Copy> BitXor for Vector3D<T> { type Output = Vector3D<T>; fn bitxor(self, v: Vector3D<T>) -> Vector3D<T> { Vector3D { x: self.y*vz-self.z*vy, y: self.z*vx-self.x*vz, z: self.x*vy-self.y*vx} } } 

Briefly explain what is here and why. After the colon for T, bindings are written: what types (traits) should T realize. For example, in the case of BitXor operation, T, as you see, is obliged to implement multiplication, subtraction and copying. With the first two it is clear, in the function code we multiply and subtract, it is logical that this should be valid with T. Why copy, you ask? The case in the situation already described above is that we cannot reuse a variable that has been moved to another function. Therefore, we need to either rewrite the arithmetic of x: self.y*vz-self.z*vy, y: self.z*vx-self.x*vz, z: self.x*vy-self.y*vx using links or make sure that T is copyable. All elementary types in Rust allow copying. In general, we don’t expect anyone to try to put lists or files in x, y, z or something else complicated, so we’ll like the copy option. “Stop, and what is this even for <Output = T> ?”, The attentive reader will ask. And it will be right, because without this record the code will not work. The fact is that Rust does not guarantee that the result of addition or multiplication will be of the same type as the operands. Therefore, we additionally specify here that we need T, which implements multiplication in such a way that the result of multiplication is also of type T. Is it difficult? I warned you.

Special mention deserves the method to (), which allows you to convert a vector of one type to another. For example, real to integer. As you can see here, any type that implements NumCast can be converted to any type that implements NumCast. All primitive types in Rust implement it, so that we are completely painless (not counting the time spent in order to learn about this type) got the type conversion for our vector.
The remaining modifications were not too complicated. As a result, I received a code that you can see in the corresponding snapshot of the repository .

And what a picture he draws


TGA-canvas


Since for texturing we need to be able to read TGA files, since it is in them that the textures are stored in the models of interest to us, then it is time to return to what I missed at the very beginning of the cycle - reading TGA files. And since we are still learning to read them, then why not also make an alternative implementation of Canvas, which writes the result in the TGA. Naturally for this you need to make the Canvas abstract, and then prepare 2 of its implementation: SdlCanvas and TgaCanvas. In Java, I would have made a base class from which I inherited 2 others. In Rust, this functionality is implemented using impurities. See for yourself the code. Here is the Canvas admixture itself:

 pub trait Canvas { fn canvas(&mut self) -> &mut Vec<Vec<u32>>; fn zbuffer(&mut self) -> &mut Vec<Vec<i32>>; fn xsize(&self) -> usize; fn ysize(&self) -> usize; fn new(x: usize, y: usize) -> Self; fn show(&mut self); fn wait_for_enter(&mut self); fn set(&mut self, x: i32, y: i32, color: u32) { if x < 0 || y < 0 { return; } if x >= self.xsize() as i32 || y >= self.ysize() as i32{ return; } self.canvas()[x as usize][y as usize] = color; } fn triangle(&mut self, mut p0: Vector3D<i32>, mut p1: Vector3D<i32>, mut p2: Vector3D<i32>, color: u32) { //... } } 

Here is the SdlCanvas:

 pub struct SdlCanvas { sdl_context: Sdl, renderer: Renderer, canvas: Vec<Vec<u32>>, zbuffer: Vec<Vec<i32>>, xsize: usize, ysize: usize, } impl Canvas for SdlCanvas { fn new(x: usize, y: usize) -> SdlCanvas { //... SdlCanvas { sdl_context: sdl_context, renderer: renderer, canvas: vec![vec![0;y];x], zbuffer: vec![vec![std::i32::MIN; y]; x], xsize: x, ysize: y, } } fn show(&mut self) { let mut texture = self.renderer.create_texture_streaming(PixelFormatEnum::RGB24, (self.xsize as u32, self.ysize as u32)).unwrap(); // ... self.renderer.present(); } fn wait_for_enter(&mut self) { //... } fn canvas(&mut self) -> &mut Vec<Vec<u32>>{ &mut self.canvas } fn zbuffer(&mut self) -> &mut Vec<Vec<i32>>{ &mut self.zbuffer } fn xsize(&self) -> usize{ self.xsize } fn ysize(&self) -> usize{ self.ysize } } 

In order not to complicate the understanding of the essence, I deleted a part of the code that has no direct relation to the impurities and their implementation, replacing it with // ... Those interested can see the full code in the corresponding snapshot of the repository . As you can see, this all looks a bit unusual, but in fact it is very similar to our usual inheritance and interfaces. By code size is not even much different. The only moment impurities do not allow to require the presence of any variables in the structures. So we had to create getters for some of the variables we need in a universal implementation and in SdlCanvas, in turn, to implement them. The corresponding article Rust by Example greatly helped in the implementation of the above.
Now actually to read the pictures. The first difficulty was to read the header of the TGA file. In the original haqreu code, this is done in a fairly simple, elegant code:

 #pragma pack(push,1) struct TGA_Header { char idlength; char colormaptype; // ... short width; short height; char bitsperpixel; char imagedescriptor; }; #pragma pack(pop) // ... TGA_Header header; in.read((char *)&header, sizeof(header)); 

However, it is also clear that this code is rather low-level. We simply read an array of bytes from a file and put it at the address of our structure, which is previously declared to correspond to how all this data is placed in the file header. Before that, we wrote mostly at a fairly high level. Well, it is time to check how it behaves when applied as a low-level language. In general, after all the research, we got this code:

 const HEADERSIZE: usize = 18; // 18 = sizeof(TgaHeader) #[repr(C, packed)] struct TgaHeader { idlength: i8, colormaptype: i8, // ... width: i16, height: i16, bitsperpixel: i8, imagedescriptor: i8, } // ... let mut file = File::open(&path).unwrap(); let mut header_bytes: [u8; HEADERSIZE] = [0; HEADERSIZE]; file.read(&mut header_bytes); let header = unsafe { mem::transmute::<[u8; HEADERSIZE], TgaHeader>(header_bytes) }; 

The preprocessor directive in front of the structure sets its storage method. Usually the structure fields in Rust (and in C ++, by the way, too) are aligned according to the architecture. For example, on my computer, a structure containing i8 and i16 would take 4 bytes, not 3. Because i8 would be aligned to occupy one two-byte cell. In C ++, this works similarly. This was described in detail by k06a in his article on this topic . In order for the data in the structure to follow byte by byte, without gaps, we asked #[repr(C, packed)] . Thus, our structure is now located in memory as it was located in the harsh, ancient times when the TGA was invented. In addition, here we use unsafe code. It is understandable, the interpretation in the area in memory as a kind of structure without any checks completely breaks the idea of ​​static typing. Fortunately, this code will most often work. (but not always, there are still all sorts of nuances with byte order ) And of course you also noticed that I set the size of the buffer as a constant. What about sizeof, you ask? Well, it is in Rust, but it is not calculated as a constant expression , but is considered at runtime. The size of the array should be known at the compilation stage. These are the pies.

Next is the most interesting. When I tried to learn how to read simple TGB-files like RGBA (4 bytes per pixel) without RLE compression, there was some kind of mystique. My program processed a simple image in general, producing such a mess:



Here is a snapshot of the code at this stage. If you are interested, you can try to find a mistake in it yourself before continuing further reading the article. It is somewhere in the file reading function:

  fn read(path: &str) -> TgaCanvas{ let path = Path::new(path); let mut file = BufReader::new(File::open(&path).unwrap()); let mut header_bytes: [u8; HEADERSIZE] = [0; HEADERSIZE]; file.read(&mut header_bytes); let header = unsafe { mem::transmute::<[u8; HEADERSIZE], TgaHeader>(header_bytes) }; let xsize = header.width as usize; let ysize = header.height as usize; debug!("read header: width = {}, height = {}", xsize, ysize); let bytespp = header.bitsperpixel>>3; debug!("bytes per pixel - {}", bytespp); let mut canvas = vec![vec![0;ysize];xsize]; for iy in 0..ysize{ for ix in 0..xsize{ if bytespp == 1 { let mut bytes: [u8; 1] = [0; 1]; file.read(&mut bytes); let intensity = bytes[0] as u32; canvas[ix][iy] = intensity + intensity*256 + intensity*256*256; } else if bytespp == 3 { let mut bytes: [u8; 3] = [0; 3]; file.read(&mut bytes); canvas[ix][iy] = bytes[2] as u32 + bytes[1] as u32*256 + bytes[0] as u32*256*256; } else if bytespp == 4 { let mut bytes: [u8; 4] = [0; 4]; file.read(&mut bytes); if ix == 0 { debug!("{} {} {} {}", bytes[0], bytes[1], bytes[2], bytes[3]); } canvas[ix][iy] = bytes[2] as u32 + ((bytes[1] as u32) << (8*1)) + ((bytes[0] as u32) << (8*2)); //debug!("{}", canvas[ix][iy]); } } debug!("{}", canvas[0][iy]); } TgaCanvas { canvas: canvas, zbuffer: vec![vec![std::i32::MIN; ysize]; xsize], xsize: xsize, ysize: ysize, } } 

What really BufReader::new(File::open(&path).unwrap()); that if you replace BufReader::new(File::open(&path).unwrap()); on just File::open(&path).unwrap(); , the bug was not shown. I even thought that this is a bug in BufReader, because in theory it should only provide buffering, without interfering with the byte stream.

What was the mistake
It turns out this is a bit unexpected behavior of the function read in the standard library. read () does not guarantee that buffer.len() bytes will be read, although this is often the case. But not always. In BufReader, the buffer ends and it returns as many bytes as it is left in it and, meanwhile, starts filling the buffer in the background again. If I got the point right. As a result, at some point, I skipped a few bytes and then the image was damaged. This behavior on the part of read () is documented, but, in my opinion, violates the principle of least surprise . Although only I can not read all the documentation for the methods used ...

Next issue with TGA-files was closed quickly. The code with the final version of TgaCanvas, as always, can be seen in the snapshot of the repository .

Textures


The trouble came from no waiting. It turns out that in Rust it is simply impossible to concatenate strings and return the result as str (this is a primitive type - a string). Str simply does not have a concatenation method. The String has it, but you cannot convert the String to str later. UPDATE: Everything under the spoiler is not true. Saved here just for history. Thanks to Googolplex for explaining my mistake to me in my comment .
Old text that I wrote without understanding
The corresponding as_str () method is unstable. Which means that you cannot use it in stable releases of Rust. Actually there is a reasonable solution - to transfer file names as String, but not str. Something like new(file_path: &String) -> Model , but the problem here is that then you have to write in all calls not Model::new("african_head.obj"); , and Model::new("african_head.obj".to_string()); . I was offended by the language for this perversion and decided, in turn, also to pervert. Here is my concatenation code:

 let texture_path_string = file_path.rsplitn(2, '.').last().unwrap().to_string() + "_diffuse.tga"; let texture_path_str = texture_path_string.split("*").next().unwrap(); 

First, the actual concatenation using the methods of the class String, and then comes the conversion of String to str. How? It turns out the split () method of the String returns an iterator to the str collection. Here is such a nonsense. Normally, for some reason, str cannot be obtained from String, but if you strongly pervert ... In general, the language is still damp, since you have to use such a creepy hack to simply convert String to str. A contest for the cutest hack to convert String to str is announced. Write your options in the comments. (note: you cannot use unstable feature or unsafe code).

Then everything is quite trivial. The result is a code that you can see in the repository slice . It displays just the very same picture that you could observe in the introduction to the article.

Afterword


I think this is the last article of the cycle. Initially, I set myself two goals: to study Rust and figure out how modern 3D graphics work. With 3D graphics, I have not finished yet, but Rust is no longer for me an unfamiliar language that was about one and a half months ago. Most of the recent "discoveries" concerning the language are just small nuances. I haven’t found anything fundamentally new to me for several weeks. So, in fact, I have nothing to write about in articles. I also plan to add perspective distortions for the renderer, camera movements, maybe Guro's tinting and draw the typewriter from the 1st article, so those interested can still continue to monitor my progress in the corresponding repository . Thanks to everyone who reads. And special thanks to those who give useful comments.

Source: https://habr.com/ru/post/262235/


All Articles