How this happened?
Nearly two days before new year I enrolled to a course "3D Computer graphics programming" by Gustavo Pezzi. It's basically from zero to hero course type, and you're a hero. Creating software renderer was on back of my head since a long time (like decades, really), so I picked it up. It was also great excuse to take a break from my other projects, which are endless and are more music related.
I have some great books about graphics programming, but most of them are teaching latest graphics api's, without fundamentals or are now outdated. Don't get me wrong theory will always be valid, but you have to know what to pick or discard. There's no one to guide you through and there is more effort involved in figuring out and applying all those concepts.
For instance I really like "Computer Graphics: Principles and Practice in C" (2nd Edition) by James D. Foley, Andries Van Dam, Steven K. Feiner, John F. Hughes. It's last edition, which uses C language. That's why I didn't bother getting newer editions in which you are thrown into WPF nonsense, when you basically just need simple buffer and draw pixel function. Second problem with this book is that it is using premade and rather archaic api / framework, which isn't usable out of the box on modern machines, api looks weird, it uses old C dialect and alot of material is interesting only from historical point of view.
There's no good introductory material for basics in one place, some kind of bridge, which could prepare you for what's awaits under the hood of modern graphics api's, how they do their things and tells what kind of problems you might face, when you start using them. It isn't well described anywhere in approachable form. I didn't see anything like this in any school / university I attended or a book. Also there is this dreaded math involved. To be honest I am not very comfortable with math and I probably never be, thanks to my school teachers and whole polish education system. I've tried to avoid math up to the point I realized that things I want to do base on math, so I need it badly to make progress and use it more confidently.
The main goal of course was to create simple 3D software renderer from scratch, with nearly no dependencies, in C language (C99 and up), using only cpu and some fundamental math concepts behind it. At the end you will be more aware what modern rendering graphics api's and graphics cards have to do to render triangles on screen. It's like programming in 80's, but with alot of cheating. By cheating I mean that now you have on your disposal alot of cpu power, floating point numbers and piles of memory, 32bpp color depth in a frame buffer conveniently laid out (have you tried to put pixel with several bitplanes on Amiga or Atari ST?) and nice IDE's. But even having those resources you will very quickly get more respect for 'programmers of old'. That's because their software renderers run faster than yours on much more limited hardware. It's especially visible, when triangle filling kicks in. But's that fine, we are doing something new after all and learning. At last for me it was really great, because as a programmer I transitioned right into modern graphics api's OpenGL / DirectX / Vulkan/ GNM and other graphics api's, skipping software rendering period (not as a gamer of course :)). It was like skipping something important. Most of modern graphics api's have little differences, use different coordinate systems, which can be left or right handed, can use different vector / matrix column ordering and you have to know what actually those differences mean to actually fix problems across multiple api's for example. Randomly flipping signs and rearranging matrices here or there will get you nowhere.
Here are some videos from results of this journey. In general I took a little different approach than proposed, I didn't modify one source version in place, I didn't use makefiles / Visual C++ solutions directly. I've created and improved framework during course, coded along, added features when needed, sometime returning back and readjusting earlier excercises.
Advantage was that I could create new programs faster for each chapter and better document whole process. Each chapter is an evolution of a previous one and tries to improve things and/or fix problems introduced earlier. Enabling C99 support was not well described for latest Visual C++, but it was no real big problem. Currently everything works on x64 target, but adding Linux or other platforms will be trivial by supplying some platform specific functions and switching toolchains. To manage project I choose CMake which is loathed by nearly everyone, but it works. It generates Visual Studio 2019 / 2020 solutions, makefiles, whatever and deploys data assets to folders in which program executables reside.
During course I also used my new found skill, which is detecting heap memory corruption and leaks. It is similar to what Win32 api leak/heap corruption detector is doing (adding guards to allocated memory regions), but it can run everywhere. Originally I wrote it for my Atari projects, TOS machines doesn't have any memory protection, so memory corruptions are frequent and can be fatal (killing operating system is normal practice there no matter if you do it accidentally or on purpose ;-) ). It can be enabled by simple define, even in release builds. In short, it was really handy for detecting issues during development.
Project itself used SDL2, but I reworked it for SDL3. upng library was used for loading textures from PNG files with some simple macros for dynamic arrays. And that's it. SDL is only used for window management and blitting your frame buffer to a screen. You have blank canvas and you are plotting pixels one by one like in old times. Is there something I didn't like or think is missing? I think that if there was applied fixed point math, this course would be much better, also c99 use might be better too, but other than that everything was great. I think Gustavo is really great teacher. One I wish I had, but maybe twenty or thirty years ago. :) So, right now I can tell everyone that "I wrote 3D software renderer once" :)...
3D Software renderer stages of development
1) 2D point projection. We render first pixel, first rectangle and transform them in space with fake perspective correction.
2) Vertices rendering with fake perspective projection. We create data for cube, define it’s vertices, draw them and connect the dots with line drawing algorithm (DDA, which is not best in this universe).
3) Loading meshes from Wavefront OBJ files. We parse and load Wavefront OBJ files imported from modeling program and try to display it. Transformations are made with simple functions, not matrices.
4) Back face culling. First optimization, we reject triangles facing away from our virtual camera to reduce triangle count and drawing time. Some triangles aren’t rejected on Suzane monkey head still, looks better on simple cube.
5) Triangle rasterization. We fill rectangles with top-bottom triangle method. We introduce switchable drawing styles. And sort triangles by depth using triangles vertices average depth, which is not ideal.
6) Matrix operations. Earlier operations are exchanged to matrix ones (scaling,rotoation, translation), so we reduce number of operations per frame.
7) Projection matrix. Projection matrix is used for perspective projection instead of function.
8) Light and shading. Simple light source is introduced from viewer direction and mesh surface color is influenced by angle in which light bounces from surface. Surface away from light source are darker.
9) Texture mapping with perspective correction. Introduction of texture mapping with and without perspective correction (hi! PS1!). We need to load colour data from textures before from PNG files.
10) Z-Buffer. Introduction of depth buffer, so we doesn’t have to sort triangles by depth and render them from back to front. Also depth checking is now per pixel, so we need to adjust previous rendering functions, so they can look up Z-buffer and perform depth test.
11) Camera concept and movement. Introduction of virtual camera, view transformations and input handling, so we can navigate around the scene, implementation of lookAt matrix.
12) Frustrum clipping TBA. We clip triangles against all frustrum planes.
12) Multiple object loading TBA. Handling of more than one mesh.
Possible future ideas and improvements to render better, more and faster:
- finish geometry clipping (I’ve got some issues with it atm),
- implement triangle clipping in clip space
- try and use faster line drawing algorithms,
- load arbitrary number of objects / meshes and multiple textures,
- use SIMD,
- use multithreading,
- object culling in two phases (broad and narrow)
- introducing materials and rendering objects in batches to not switch drawing contexts (sounds familiar? ;) ),
- adding shadows,
- more light types,
- implement Gouraud shading for that 90s look
- better lighting system (per object, multiple lights),
- add translucency
- add animated meshes
- add font rendering
- use scene format to set up more complicated scenes,
- make fixed point math version
- port it to 16-bit/32-bit computer / console.. or 8-bit one… or ZX Spectrum :)… All of them… ;) …