https://marioslab.io/posts/rendering-like-its-1996/babys-first-pixel/ Mario's Lab Mastodon Twitter Github RSS Rendering like it's 1996 - Baby's first pixel December 02, 2022 [quake] There's absolutely no chance we'll get to this level of quality. In 1996, I was a teen without a gaming console. While my friends enjoyed their Crash Bandicoots, Tekens, and Turoks, I had a beige 486 DX 2 with a turbo button, 16Mb of RAM, a 256Mb hard disk, and a 2x CD-ROM drive running DOS. And then I got a copy of Quake. Did it run great? No. But it did run! And to my young eyes, it was the most beautiful thing I've ever seen on my computer screen. Ok, the most beautiful brown thing. 3D accelerator graphics cards were in their infancy. Most DOS PC games around that time would render their glorious pixels via the CPU to a dedicated area in RAM, e.g. starting at segment address 0xa000. The (pretty dumb) graphics card would then read and display the contents of that memory area on your bulky CRT. This is known as software rendering or software rasterization. I did dabble in some graphics programming back then. I even managed to create a Wolfenstein style first person shooter in QBasic with some assembly before the end of the century. [rtm] Actually not a ray casting engine, but a polygonal 3D engine with terrible affine texture mapping. But I never really dove into the depths of contemporary graphics technology. And while my subsequent professional career featured plenty of graphics programming, it was mostly the GPU accelerated kind, not the "worry about each cycle in your inner loops" software rasterizer kind of type. (Non-)Goals I want to explore the ins and outs of software rasterization, starting from first principles, i.e. getting a pixel on screen. From there, I want to delve into topics like simple demo effects, primitive rasterization, ray casting, voxel terrain, maybe even Quake-style 3D rendering, and whatever else comes to mind. Each blog post on a topic will lay out the theory the way I understand it in hopefully simple terms, discuss a naive practical implementation, and finally investigate ways to optimize the implementation until it is reasonably fast. The end product(s) should work on Windows, Linux, macOS, and common browsers. Ideally, a little software rasterizer library and demos will fall out at the end, that can serve both as an example implementation of common techniques, or as the basis for other demos or games with DOS game aesthetics. You'll be able to follow along both here, and by playing with the code on GitHub. For each blog post, there will be one tagged commit in the main branch you can check out. In addition to the render-y bits, I'll also demonstrate how I set up a cross-platform C project and show you how I structure, build, and debug C code in such a project. I love seeing and learning from other people's workflows. Maybe that's true for you too. What I do not want to do is dabble in things like assembly or SIMD optimizations. While that can be fun too, it is unlikely to be necessary on today's hardware, given that I'll target common DOS resolutions like 320x240, or 640x480. I might however inspect and discuss the compiler's assembly output to identify areas that can be improved performance wise in the higher level code. Tools of the trade The weapon of choice will be C99 for aesthetic and practical reasons. I want all the code produced throughout this series to compile anywhere. It should also be easy to re-use the code in other languages through an FFI. C99 is a good choice for both objectives. In terms of ompilers, I'll be using Clang with MinGW headers and standard libraries on Windows, Clang through Xcode on macOS, and GCC on Linux. Why Clang on Windows? Because Visual Studio is a multi-gigabyte download, and setting up builds for it is a terrible experience. Clang also generates better code. I'll use CMake as the meta build tool, not because I love it, but because my favorite C/C++ IDE CLion has first class support for it. Other development environments understand CMake as well these days, including Visual Studio if that's your kink. For actually executing the builds, I'll use Ninja, which is wicked fast, especially compared to MSBuild and consorts. The pixels we'll generate need to be thrown up on the display somehow. On Windows, Linux, and macOS we'll use MiniFB. In a few lines of code, we can open a window, process keyboard and mouse input, and give it a bunch of pixels to draw to the window. It can even upscale our low resolution output if needed. Since MiniFB does not have browser support, I've written a web backend myself and submitted it as a pull request to the upstream repo. In the meantime, we'll use my MiniFB fork, which has web support baked in. To get the code running in the browser, we'll use Emscripten to compile the C code to WASM and a small .js file, which loads the .wasm file and exposes our C functions to JavaScript. In terms of IDE, you are free to use whatever you want. You'll most likely want something that can ingest CMake builds. For this series, I choose VS Code, not because I love it, but because it's free. The project contains a bunch of VS Code specific settings that make working on the project super simple for all supported platforms. Getting the source code and tools That's a lot of tools! I've tried to make it as simple for you to follow along as possible. Here's what you need to install: * Visual Studio Code + Make sure code can be called on the command line! Open VS Code, press CTRL+SHIFT+P (or CMD+SHIFT+P on macOS), type Shell Command: Install 'code' command in PATH and hit enter. * Windows: + Git for Windows. Make sure its available on the command line via the system PATH. * Linux: + Git, GCC, GDB, Python, CMake, Curl, libx11-dev, libxkbcommon-dev, and libgl1-mesa-dev. On Ubuntu sudo apt install build-essential git gdb python3.11 cmake curl libx11-dev libxkbcommon-dev libgl1-mesa-dev * macOS: + Xcode. Make sure to also install the command line tools Once you've installed the above, clone the repository (on Windows, use Git Bash, which comes with Git for Windows): git clone https://github.com/badlogic/r96 cd r96 Next, checkout the tag for the blog post you want to follow along with, execute the tools/download-tools.sh script: git checkout 01-babys-first-pixel ./tools/download-tools.sh The download-tools.sh script will download all remaining tools that are needed, like CMake, Ninja, Clang for Windows, Python, a small static file server, Emscripten, and Visual Studio Code extensions needed for C/C++ development. See the README.md for details. Note: we may add new tools in future blog posts. After checking out a tag for a blog post, make sure to run tools/download-tools.sh again. The r96 project These are the goals for the project scaffold: * Make building and debugging for the desktop and the web trivial. * Allow adding new demo apps that work without code modification on both the desktop and in the browser * Make creating re-usable code easy. Let's see how I tried to achieve the above. Open your clone of the r96 Git repository in VS Code and have a look what's inside. Note: The first time you open the project in VS Code, you'll be asked to select a CMake configure preset. Note: the first time you open a source file in VS Code, you will be asked if you want to install clangd. Click Yes. File structure [r96-files] Let's start in the root folder. The .gitignore, LICENSE, and README.md files are self-explanatory. The CMakeLists.txt and CMakePresets.json define our build. We'll look into these in a later section. The .clang-format file stores the formatting settings used to format the code via, you guessed it, clang-format. The VS Code C/C++ extension uses the settings in that file whenever you format a C/C++ source file. The file can also be used to format the entire code base from the command line. The src/ folder contains our code. Re-usable code goes into src/r96/. Demo apps go into the root of the src/ folder. There are two demo apps so far called 00_basic_window.c and 01_drawing_a_pixel.c. Any demo apps we write in subsequent blog posts will also go into src/ and start with a sequential number, so we immediately see in which order they were written. The src/web/ folder may be weird, even scary to seasoned C veterans. But we need it to run our demo apps on the web. A small price to pay. It contains one .html file per demo app. The purpose of that file is to: * Load the .js and .wasm files generated by Emscripten for the demo app executable target * Provide the demo app with a HTML5 canvas element to draw to * Kick off the demo apps execution by calling its main() function For any demo app we write in the future, we'll add a source file to the src/ folder, and a corresponding .html file to the src/web/ folder. The src/web/index.html file is just a plain listing linking to all the .html files of our demo apps. The src/web/r96.css file is a CSS style sheet used to make the elements in the demo app .html files a little prettier. The .vscode/ folder contains settings and launch configurations so working on the project is a nice experience in VS Code. Finally, the tools/ folder contains scripts to download the necessary tools as well as configuration files for a few of those tools. When executing the tools/download-tools.sh script, some of the tools actually get installed in the tools/ folder so they don't clog up your system. The folder also contains scripts and batch files used by the launch configurations to do their work. The details of the .vscode and tools folder are all gory and duct tape-y. You can have a look if you must. For the remained of the series, their content doesn't matter much. Just know that they are setup in a way to make our lives easy. Building The first time you open the project in VS Code, you're asked to select a configure preset. [preset] A configure preset defines for what platform the code should be build and with what compiler and build flags that should happen. The presets are defined in CMakePresets.json. For each platform the r96 project supports, there is a corresponding debug and release configure preset. To start, we'll select Desktop debug. You can also select the configure preset in the status bar at the bottom of VS Code. [change-pre] To build the project for the selected platform and build type (debug or release), click the Build button in the status bar. [build] Alternatively, you can open the VS Code command palette (CTRL+SHIFT+P or CMD+SHIFT+P on macOS), type CMake: Build, and hit enter. In both cases, the CMake Tools extension, which was installed as part of tools/download-tools.sh, will configure the CMake build if necessary, then incrementally build the libraries and executables defined in CMakeLists.txt. The resulting build output consisting of executables and assets can be found in build/-. E.g. for Desktop debug, the build output will be located in build/windows-debug, build/ linux-debug, or build/macos-debug depending on what operating system you are on. For Web release the output will be in build/web-output, and so on. Note: To learn more about how to use VS Code CMake integration, check out the documentation. You can of course also build the project on the command line: # Configure a Windows debug build and execute the build cmake --preset windows-debug cmake --build build/windows-debug # Configure a web release build and execute the build cmake --preset web-release cmake --build build/web-release Debugging The launch.json file in the .vscode/ folder defines launch configurations for each platform. Click the launch button in the status bar to select the launch configuration and start a debugging session. [launch] After clicking this status bar entry, you'll be asked to select a launch configuration: [launch-con] When you first start a debugging session, you'll be asked to select a launch target, aka the executable you want to launch: [launch-tar] You can also change the launch target in the status bar: [launch-tar] After selecting the launch target, the code is incrementally rebuild, and the debugging session starts. Instead of going through the status bar, you can also start a new debugging sessions by pressing `F5`. This will launch a session for the currently selected launch configuration, configure preset, and launch target. Important: the launch configuration MUST match the preset you selected: * Desktop debug target: select the Desktop debug or Desktop release preset. * Web debug target: select the Web debug or Web release preset. Debugging a desktop build is the standard experience you are used to. Set breakpoints and watches, interrupt the program at any time, and so on. Debugging the C code compiled to WASM directly in VS Code is not possible. When you start a web debugging session, the respective launch configuration starts a local static file server (downloaded via tools/download-tools.sh) and opens the .html file corresponding to the selected launch target in a browser tab. When you are done "debugging" a web build, close the browser tab, and close the debugging session in VS Code by clicking the "Stop" button in the debugger controls. If you feel adventurous: it is possible to debug the C and JavaScript code in Chrome.. We'll look into that below. Dissecting the CMakeLists.txt and CMakePresets.json files To understand how the build is setup, we need to understand the CMakeLists.txt and CMakePresets.json files. We've already had a brief look at the CMakePresets.json file above. It defines a configure preset for each operating system and build type combination. Let's have a look at one of the presets, specifically, the one used for Windows debug builds. { "name": "windows-debug", "displayName": "Desktop debug", "description": "", "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "CMAKE_MAKE_PROGRAM": "${sourceDir}/tools/desktop/ninja/ninja" }, "toolchainFile": "${sourceDir}/tools/desktop/toolchain-clang-mingw.cmake", "condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Windows" } }, The important bits are: * generator: we tell CMake to generate a Ninja build. * binaryDir: specifies the output directory for the build. In this case it maps to build/windows-debug through variable substitution. * CMAKE_BUILD_TYPE: tells CMake we want the binaries to include debugging information. * CMAKE_MAKE_PROGRAM: tells CMake were to find the Ninja executable. The tools/download-tools.sh script downloaded the executable to tools/desktop/ninja/ * toolchainFile: where to find the compiler and linker. On Windows, we use Clang with MinGW headers and standard libraries., which the tools/download-tools.sh script downloads to tools/desktop/ clang. The toolchain file references the compiler and linker in that location and sets up a few other CMake cache variables. * condition: tells CMake to only enable this configure preset if we're running on Windows. The other configure presets are pretty similar and only differ in what operating system they should be available on, as well as the toolchain being used. On macOS and Linux, the default toolchain is used (GCC or Xcode's Clang). For the web, the Emscripten toolchain is used through a toolchain file that ships with Emscripten. We could work without this presets file, but that would mean we'd have to specify all these parameters manually every time we configure a CMake build. With the presets, this becomes cmake --preset . Much nicer! The CMakeLists.txt file defines the actual build itself, i.e. which source files make up which libraries and executables, and what compiler flags to use. Definitions of libraries and executables are called targets in CMake. Let's go through it section by section. We start out with this: cmake_minimum_required(VERSION 3.21.1) project(r96) set(CMAKE_C_STANDARD 99) set(CMAKE_C_STANDARD_REQUIRED TRUE) set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE) We define the minimum CMake version and project name, and enable (and require) C99 support. The final line makes CMake generate a compile_commands.json file in the build folder. This is also known as a compilation database and used by many IDEs to understand a CMake build. In our case, the file is used by the clangd VS Code extension to provide us with code completion and other niceities. include(FetchContent) FetchContent_Declare(minifb GIT_REPOSITORY https://github.com/badlogic/minifb GIT_TAG dos-pr-master) set(MINIFB_BUILD_EXAMPLES CACHE INTERNAL FALSE) FetchContent_MakeAvailable(minifb) Next we pull in MiniFB via CMake's FetchContent mechanism. CMake veterans may sneer at this and rather use a Git submodule. But I like it that way, thank you very much. This magic incantation will clone my MiniFB fork with web support, disable the MiniFB example targets, and finally make the remaining MiniFB library target available to the targets defined in our own CMakeLists.txt. Nice. add_compile_options(-Wall -Wextra -Wpedantic -Wno-implicit-fallthrough) This section sets the "pedantic warnings are errors" compiler flags. We want the code to be reasonably clean and fail if a warning is generated. add_library(r96 "src/r96/r96.c") Next we add a library target called r96. It's compiled from the r96/ r96.c source file. Any re-usable code we write during the course of this blog post series will go in there. Any of our demo app executable targets can then depend on the r96 library target to pull in its code. add_executable(r96_00_basic_window "src/00_basic_window.c") add_executable(r96_01_drawing_a_pixel "src/01_drawing_a_pixel.c") We define two executable targets for the demos of this blog post. add_custom_target(r96_web_assets COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/src/web $ ) We define a custom target that copies all the .html files from src/ web/ to the output folder. This target is needed for web builds. get_property(targets DIRECTORY "${_dir}" PROPERTY BUILDSYSTEM_TARGETS) list(REMOVE_ITEM targets minifb r96 r96_assets r96_web_assets) foreach(target IN LISTS targets) target_link_libraries(${target} LINK_PUBLIC minifb r96) if(EMSCRIPTEN) add_dependencies(${target} r96_web_assets) target_link_options(${target} PRIVATE "-sSTRICT=1" "-sENVIRONMENT=web" "-sLLD_REPORT_UNDEFINED" "-sMODULARIZE=1" "-sALLOW_MEMORY_GROWTH=1" "-sALLOW_TABLE_GROWTH" "-sMALLOC=emmalloc" "-sEXPORT_ALL=1" "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\",\"_main\"]" "-sASYNCIFY" "--no-entry" "-sEXPORT_NAME=${target}" ) endif() endforeach() And then stuff gets crazy! The first two lines compiles a list of all demo executable targets. We then iterate through those executable targets and link the r96 library target to each of them. If we build for the web, we also add the custom r96_web_assets target to the executable as a dependency, so the .html files get copied over to the output folder. Finally, we add a few Emscripten specific linker options. These are settings I arrived at after working with WASM for the last 2 years. They are all Emscripten specific and do things like allowing heap memory to grow. You can check out all the options in Emscripten's settings.js file. There's a lot. The purpose of this evil incantation is to reduce the amount of CMake spaghetti needed when adding a new demo. All we need to do is add a single add_executable_target() line, specifiyng the demo name and source files its composed of. The evil incantation will then link the new demo up with all the necessary bits automatically. Nice! The first demo app: 00_basic_window Before we can get our hands dirty with programmatically creating the most beautiful pixels in the world, we need to understand how MiniFB works and how a demo app is structured in terms of code. With no further ado, here's src/00_basic_window.c: #include #include int main(void) { const int res_x = 320, res_y = 240; struct mfb_window *window = mfb_open("00_basic_window", res_x, res_y); uint32_t *pixels = (uint32_t *) malloc(sizeof(uint32_t) * res_x * res_y); do { mfb_update_ex(window, pixels, res_x, res_y); } while (mfb_wait_sync(window)); return 0; } This is a minimal MiniFB app that opens a window with a drawing area of 320x240 pixels (line 6). It then allocates a buffer of 320x240 unit32_t elements (line 7). Each uint32_t element encodes the color of a pixel. Next, we keep drawing the contents of the buffer to the window via mfb_update_ex() (line 9) until mfb_wait_sync() returns false (line 10), e.g. because the user pressed the ESC key to quit the app. It can't get any simpler. MiniFB has a super minimal API. You can learn more about it here. Note: The mfb_update_ex() function also returns a status code which can be used to decide if the app should be exited. We're not using this above for brevity's sake. Running the demo app on the desktop To compile and run (or debug) our little demo app on the desktop, select the Desktop debug configure preset in the VS Code status bar, select the r96_00_basic_window target as the launch target, and the Desktop debug target launch configuration. Press F5 and you'll get this: [desktop] r96_00_basic_window on the desktop. Most impressive. You can make changes to the code and just hit F5 again to incrementally rebuild and restart the demo app. You can also set breakpoints, inspect variables and call stacks, and so on. Now how do we run the same demo app in the browser? Running the demo app on the web Select the Web debug configure preset, and the Web debug target launch configuraton and press F5. You'll see this: [browser] r96_00_basic_window running in the browser. The launch configuration starts a static file server (tools/web/ static-server) which serves the files in build/web-debug/ on port 8123. The static server will also automatically open a browser tab with the URL corresponding to the demo's .html file. When you're done being amazed by this, close the browser tab, and click the Stop button in the debugger controls in VS Code. If you want to be amazed again, just press F5 How the web version works It's kind of magic. Here's how the 00_basic_window.html file for the 00_basic_window.c demo app looks like:

Basic window

Ignoring the boring HTML boilerplate, we see that the r96_00_basic_window.js file is loaded via a