Skip to content

ISmillex/software-rendered-game

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Software Rendered Game

A 3D game engine written in C that renders everything on the CPU. There are no GPU graphics APIs involved — no OpenGL, no Vulkan, no Metal. Every pixel that appears on screen is computed by C code and written into a flat array of integers. SDL2 is used only to create a window and copy that array to the screen once per frame. It does nothing else.

Screenshot

How It Works

The renderer operates on a framebuffer: a one-dimensional array of pixels laid out in row-major order, paired with a depth buffer of the same size. These two arrays are the only destination for all rendering output. Everything visible on screen was written to them by the engine's own code.

From Objects to Pixels

The scene is made up of objects, each referencing a model (loaded from OBJ files at startup) and a texture (loaded from BMP or PNG files). Each frame, the engine walks through every object, transforms its triangles from their local coordinate space into screen coordinates using standard matrix math (model transform, then the camera's combined view-projection matrix), and produces a list of chunks.

A chunk is a single triangle ready to be drawn, along with the information needed to draw it — its screen-space vertices, a depth value for sorting, and either a solid color or a texture with UV coordinates. Chunks are the fundamental unit of rendering work in this engine. They serve the same role as draw calls in a GPU pipeline. Different chunk types map to different rasterizer functions, so adding a new rendering style means adding a new chunk type and writing its rasterizer.

After all chunks are generated, they are sorted front-to-back by depth. This ordering matters because the rasterizers check the depth buffer before writing each pixel. If a closer surface has already been drawn at a given pixel, the rasterizer skips the current one. Sorting front-to-back makes this early rejection happen as often as possible, which saves work.

Parallel Rendering

The screen is divided into vertical strips, one per CPU core. Each strip covers a contiguous range of columns. After sorting, each chunk is assigned to whichever strips its bounding box overlaps. A triangle that spans two strips goes into both buckets.

A pool of worker threads, created once at startup, renders these strips in parallel. Each thread draws only the pixels within its own column range. Because the strips do not overlap, no thread ever writes to another thread's region of the framebuffer. This eliminates the need for locks or atomic operations on the pixel data. Synchronization is handled by a single pthread barrier: the main thread fills the strip buckets, releases the barrier, the workers render, and the barrier fires again when all workers are done.

Vertical strips were chosen over horizontal ones because of memory layout. The framebuffer is stored row by row, so pixels in the same row are adjacent in memory. A vertical strip accesses sequential addresses within each row, which is cache-friendly. Horizontal strips would jump across rows, scattering memory access.

Memory

There is no dynamic memory allocation during rendering. A 4 MB arena is allocated once at startup. Each frame, the arena's offset is reset to zero, and all per-frame data — the chunk array, temporary vertex buffers — is bump-allocated from it. This is a simple scheme: allocating means advancing a pointer, and freeing means resetting that pointer to the start. There are no individual frees, no fragmentation, and no calls to malloc or free in the hot path.

Long-lived data — models, textures, the framebuffer itself, the depth buffer, the glyph cache, strip bucket arrays — is allocated once at startup and never freed until shutdown.

Text and Overlays

Text rendering uses a glyph cache. At startup, every printable ASCII character is rasterized from a bitmap font into an atlas — a single image containing all glyphs at known positions. Drawing a character means copying a rectangle from the atlas to the framebuffer. There is no per-frame font processing.

The engine has several debug overlays drawn on top of the 3D scene after rendering but before presenting to SDL. F1 shows the current state of all game flags. F3 shows frame rate, camera position, and chunk count. F4 shows how many chunks each strip processed. The tilde key opens a console where commands can be typed.

Console and Flags

The debug console works through a command registry. Each command is a name paired with a callback function. Game flags — fog, fly mode, wireframe rendering, depth buffer visualization, third-person camera — are registered as console commands at startup. Typing "fog" or "fly" in the console invokes the corresponding callback. The console maintains a ring buffer of messages and handles its own text input, cursor movement, and scrolling.

When the console is open, keyboard input is routed to it instead of to the camera and game controls.

Physics

The engine includes a physics simulation that runs between input handling and rendering. Gravity pulls the player and objects downward at 20 units per second squared. The player can jump with the spacebar when standing on the ground or on top of a solid object. Toggling fly mode or noclip off in mid-air causes the player to fall naturally.

Pressing Q throws stones — small textured spheres that launch from the camera in the look direction at 18 units per second. Holding Q fires continuously at roughly 6.6 stones per second. Stones are affected by gravity, bounce off the floor, walls, crates, and other objects with velocity reflection and configurable restitution. They come to rest when their energy drops below a threshold, and expire after 8 seconds to free their scene object slot for reuse.

Four balls are placed around the scene as physics targets. They start at rest but react when hit by stones or pushed by the player. Sphere-versus-sphere collision reflects the stone's velocity along the collision normal and transfers a fraction of its momentum to the ball. The player can also kick balls and stones by walking into them.

Physics bodies are stored in a separate array from scene objects, connected by index. This keeps velocity, restitution, and lifetime data out of the rendering path. Scene object slots are recycled when stones expire — new projectiles reuse dead slots instead of consuming fresh ones, keeping the 256-object limit from being exhausted.

Sphere meshes for stones and balls are generated procedurally by the asset generator as OBJ files, at two resolutions: 96 triangles for stones and 384 for balls.

Scene

The default scene is a walled room with a tiled floor, scattered crates, and four balls. The floor is an 8x8 grid of quads with repeating tile UVs for visible grout lines at distance. The walls are simple boxes scaled to enclose the room, with brick-pattern UVs that tile proportionally to their dimensions so the texture is not stretched. The crates sit on the floor and bob up and down on sine waves at slightly different speeds. The balls are procedurally generated spheres with a red-orange texture. All geometry in the scene has axis-aligned bounding boxes for collision — the player cannot walk through walls or crates.

Camera Modes

The engine supports two camera modes. First-person is the default: the camera is positioned at the player's eye height and looks in the direction of the mouse. Third-person mode is toggled via the thirdperson console command. In third-person, the camera pulls back behind and above the player at a fixed distance and height, looking down at the player's position. The player model becomes visible in third-person and is hidden in first-person. Movement controls are the same in both modes.

Player Model

The player is represented by a model from the penger-obj collection, included as a git submodule. Four model variants are loaded at startup: penger, cyber, real-penger, and suitger. Each has its own OBJ mesh and PNG texture. The model console command switches between them at runtime. The player model tracks the camera's position and yaw each frame so it always appears at the player's feet facing the direction of movement. Each variant has a per-model scale factor so they all appear roughly the same size regardless of their original dimensions.

The OBJ parser supports both triangle and quad faces. Quads are split into two triangles during loading. PNG textures are loaded using stb_image with vertical flipping to match the OBJ UV convention where V=0 is at the bottom of the image.

Input

SDL events are polled once per frame into an InputState struct that tracks which keys are currently held and which were pressed this frame (edge-triggered). The rest of the engine reads from this struct and never touches SDL's event system directly.

Building and Running

The build system is nob, a header-only library for writing build recipes in C by Tsoding. There is no Makefile or CMake.

cc -o nob nob.c
./nob
./build/game

For a debug build with AddressSanitizer and UndefinedBehaviorSanitizer:

./nob debug

To generate procedural assets:

./nob assets

The only external dependency is SDL2. On macOS, install it through Homebrew. The engine also links against pthreads and the standard math library. Player models are included as a git submodule — run git submodule update --init after cloning.

Controls

  • WASD to move, mouse to look
  • Space to jump
  • Q to throw stones (hold for continuous fire)
  • F1 shows game flags, F3 shows debug info, F4 shows strip statistics
  • Tilde (~) opens the debug console
  • thirdperson — toggle first-person / third-person camera
  • model <name> — change player model (penger, cyber, real-penger, suitger)
  • fog, fly, noclip, wireframe, zbuffer, gravity — toggle game flags
  • Escape to quit

Acknowledgements

The original idea for this project comes from the Software Rendering series by Tsoding Daily.

Player models are from the penger-obj collection by Max Kawula.

License

This project is licensed under the MIT License.

About

A 3D game engine in C with a software-rendered pipeline — no GPU, every pixel computed on the CPU.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors