8.0 KiB
ASCII 3D Renderer — Technical Documentation
Overview
This is a software 3D renderer that outputs to a terminal. It takes text input, turns each character into a 3D shape, lights it with a multi-light Blinn-Phong model, and draws the result using ASCII characters (or colored output via ANSI escape sequences). Everything runs in a single-threaded loop at 60 FPS.
There are no GPU calls, no windowing system, and no third-party rendering libraries. The only external dependency beyond libc is libm for math functions (powf, fmaxf, sqrtf, etc.).
How the rendering works
The pipeline, roughly, goes like this:
- Look up the glyph bitmap for each character (from
font.c) - Extrude the 2D bitmap into a 3D volume by adding depth along the Z-axis
- Step through the volume at regular intervals (
VOXEL_STEP = 0.15) and, at each point, check whether we're "inside" geometry - Calculate surface normals by looking at which neighboring cells are empty — a filled voxel next to an empty one means there's a surface edge there, and the normal points toward the gap
- Apply rotation matrices (one per axis) based on the current angle
- Project the 3D points into 2D screen coordinates using perspective projection (field of view, near/far planes)
- For each projected point, run it through the lighting model to get a brightness value
- Map that brightness to a character from the current shade palette
- Write it to the framebuffer (a 2D array of chars), respecting the Z-buffer so closer surfaces win
- Dump the entire framebuffer to stdout in one pass
When anti-aliasing is enabled, steps 3–8 are repeated with jittered sample offsets (2×2 pattern by default), and the results are averaged.
Project structure
src/
main.c — CLI parsing, signal handling, main loop
renderer.c — the core rendering engine: projection, voxel traversal,
framebuffer management, screen output
lighting.c — Blinn-Phong lighting: diffuse, specular, multi-light
accumulation, color math
font.c — 5×7 bitmap font data for A-Z and 0-9, stored as
bitflag arrays
tui.c — terminal input handling: puts the TTY into raw mode
for non-blocking reads, processes keypresses
timing.c — frame rate limiter using CLOCK_MONOTONIC + nanosleep
vec3.c — basic 3D vector math (dot, cross, normalize, rotate)
include/
config.h — all the tuneable constants in one place: viewport size,
camera FOV, lighting params, shade palettes, etc.
(+ headers for each .c file)
Module details
font.c — Bitmap font
Each character is a 5-wide, 7-tall bitmap stored as 7 bytes. Each byte represents one row — bits 4 down to 0 tell you which pixels are filled. For example, the letter "A" is:
0x0E → .###.
0x11 → #...#
0x11 → #...#
0x1F → #####
0x11 → #...#
0x11 → #...#
0x11 → #...#
Only A–Z (case-insensitive) and 0–9 are supported. Anything else gets skipped.
renderer.c — 3D engine
This is the biggest file and does most of the heavy lifting.
Glyph extrusion: Each 5×7 bitmap gets depth. The renderer walks through the glyph volume at small Z increments. At each (x, y, z) point, if the corresponding 2D pixel is "on" in the bitmap, that point is considered solid geometry.
Normal estimation: For shading to work, you need surface normals. Since we're working with blocky voxel-like shapes, the normals are estimated by checking adjacent cells. If a filled cell has an empty neighbor to the left, the normal has a leftward component. Same for all six directions. The result is normalized to get a unit vector. This gives you smooth-ish shading on what are otherwise cube-shaped surfaces.
Projection: Standard perspective projection. A 3D point gets divided by its Z distance (scaled by field-of-view), then offset to the center of the screen. Points behind the near plane or past the far plane are clipped.
Z-buffering: The renderer keeps a depth buffer (initialized to a large value). When drawing a point, it only updates the framebuffer if the new point is closer than what was already there. This keeps front surfaces in front.
Render modes:
- Shaded — full lighting + ASCII shade mapping (the default)
- Solid — everything gets drawn with
@, no lighting - Wireframe — only draw points that sit on the boundary between filled and empty cells
- Points — draw all filled voxels with
.
lighting.c — Blinn-Phong model
The lighting system supports up to 3 lights (configurable via MAX_LIGHTS). By default it sets up a classic 3-point rig:
- Key light — the main light, slightly above and to the right, warm white
- Fill light — dimmer, coming from the left, slightly blue-tinted, softens shadows
- Rim light — behind and below the subject, adds edge definition
Each light can be directional (parallel rays, like the sun) or point (with inverse-square falloff).
For each surface point, the shader calculates:
- Diffuse — Lambert's cosine law: brightness depends on the angle between the surface normal and the light direction
- Specular — Blinn-Phong half-vector method: creates shiny highlights where the surface reflects light toward the camera
The final brightness is the sum of ambient + all light contributions, clamped to [0, 1]. In monochrome mode this maps to an index into the shade character palette. In color mode, the full RGB result is computed per-channel and output using ANSI escape sequences.
Material properties (ambient/diffuse/specular colors, shininess) are set to sensible defaults — there's no way to change them at runtime yet.
tui.c — Terminal input
On startup, the terminal gets switched to non-canonical mode with echo disabled (via termios). This means keypresses are available immediately without waiting for Enter, and typed characters don't show up on screen.
The original terminal settings are saved and restored on exit — this is important because if the program crashes without restoring them, you'd end up with a terminal that doesn't echo your typing or respond to Ctrl+C properly. Signal handlers for SIGINT and SIGTERM are set up specifically to make sure cleanup happens even on interrupt.
timing.c — Frame limiter
Pretty straightforward. After each frame, it checks how much time has passed since the frame started using clock_gettime(CLOCK_MONOTONIC). If there's time remaining in the frame budget (1/60th of a second), it sleeps for the difference with nanosleep. This keeps the animation speed consistent regardless of how fast the machine is.
Build system
The Makefile has three build profiles:
| Target | Flags | Purpose |
|---|---|---|
release |
-O3 -march=native -flto -DNDEBUG |
Production — fast as possible |
debug |
-O0 -g3 -fsanitize=address,undefined |
Development — catches memory bugs and UB |
profile |
-O2 -g -pg |
Profiling with gprof |
Dependency tracking is handled with -MMD -MP, so incremental builds work correctly.
The install target copies the binary to /usr/local/bin (or wherever PREFIX points).
Configuration
Everything tuneable lives in include/config.h. Some notable values:
| Constant | Default | What it controls |
|---|---|---|
SCREEN_WIDTH / SCREEN_HEIGHT |
120 × 45 | Framebuffer size in characters |
CAMERA_DISTANCE |
30.0 | How far back the virtual camera sits |
FIELD_OF_VIEW |
50.0 | Perspective FOV in degrees |
EXTRUSION_DEPTH |
4.0 | How thick the 3D letters are |
VOXEL_STEP |
0.15 | Sampling resolution along Z when building geometry |
AA_SAMPLES |
2 | Anti-aliasing grid (2 = 2×2 = 4 samples per pixel) |
AMBIENT_INTENSITY |
0.15 | Base light level in shadows |
DIFFUSE_INTENSITY |
0.70 | Strength of the diffuse lighting term |
SPECULAR_INTENSITY |
0.40 | Strength of specular highlights |
SPECULAR_POWER |
32.0 | Shininess exponent (higher = tighter highlights) |
TARGET_FPS |
60 | Frame rate cap |