Building a terminal-based black hole renderer from scratch — learning Rust, raymarching, and black hole physics along the way.
Every graphics program starts with a vector type. A 3D vector represents either a position (a point in space) or a direction (which way something points). We need both for raymarching.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec3 {
x: f64,
y: f64,
z: f64,
}Key Rust concepts here:
structgroups related data. Similar to a class in other languages, but without inheritance.#[derive(...)]auto-generates trait implementations:Copy— lets the compiler implicitly copy values (like integers). Without it, assigninga = bwould moveb, making it unusable. This is Rust's ownership system.Clone— explicit duplication via.clone()Debug— enables{:?}printingPartialEq— enables==comparison
f64— 64-bit floating point (double precision). Enough accuracy for our physics.
Length (magnitude) — distance from the origin:
This is the 3D Pythagorean theorem.
Normalization — scaling a vector to length 1 while preserving direction:
Critical for ray directions — we want direction without magnitude.
Dot product — measures how much two vectors point in the same direction:
Returns a scalar:
-
$+1$ → same direction -
$0$ → perpendicular -
$-1$ → opposite directions
Rust lets us define +, -, * for custom types by implementing traits from std::ops:
impl Add for Vec3 {
type Output = Vec3;
fn add(self, other: Vec3) -> Vec3 {
Vec3 {
x: self.x + other.x,
y: self.y + other.y,
z: self.z + other.z,
}
}
}This lets us write a + b instead of Vec3::add(a, b), making physics formulas readable. A trait is Rust's version of an interface — it defines methods a type must provide. type Output is an associated type that tells Rust what the operation produces.
The camera sits at a point in 3D space and looks toward the origin. For each terminal cell (col, row), we compute a ray direction that passes "through" that cell, as if the terminal were a window into the scene.
We map each cell to normalized device coordinates (NDC) ranging from
The
FOV (field of view) controls how wide the camera sees. The focal length is derived from it:
A wider FOV means a shorter focal length, like a fisheye lens.
Terminal characters are non-square — roughly twice as tall as they are wide. If we don't correct for this, circles render as tall ovals.
What matters is the pixel aspect ratio of the entire terminal:
where
Vec3::new(ndc_x * pixel_aspect, ndc_y, -focal_length).normalize()A black hole warps spacetime. Light passing nearby doesn't travel straight — it curves toward the singularity. This is gravitational lensing.
The Schwarzschild metric describes spacetime around a non-rotating, uncharged black hole. Karl Schwarzschild discovered this exact solution to Einstein's field equations in 1916, just months after general relativity was published.
We work in natural units where
| Quantity | Formula | Value |
|---|---|---|
| Event horizon | 2.0 | |
| Photon sphere | 3.0 | |
| ISCO | 6.0 | |
| Critical impact parameter | ≈ 5.196 |
Photons follow null geodesics — paths through curved spacetime where the spacetime interval
we can derive the exact 3D acceleration for a photon at position
where
Key properties:
- Directed toward the singularity (attractive)
-
Scales as
$1/r^5$ — falls off much faster than Newtonian$1/r^2$ , concentrating lensing near the black hole -
Proportional to
$h^2$ — radial photons ($h = 0$ ) travel undeflected, which is physically correct -
Produces the correct photon sphere at
$r = 3M$ , where unstable circular orbits exist -
Gives the correct shadow size (
$b_\text{crit} = 3\sqrt{3}M$ )
The event horizon at
The photon sphere at
The shadow of the black hole has an apparent radius of
for each step:
advance ray by STEP_SIZE
compute r = distance to singularity
if r < EVENT_HORIZON_RADIUS:
→ ray absorbed (black pixel)
check for accretion disk crossing
compute h = |pos × dir| (angular momentum)
accel = -3M · h² / r⁵ · pos
dir = normalize(dir + accel · dt)
if ray survives all steps:
→ ray escaped (background)
We map intensity (0.0–1.0) to characters with increasing visual density:
" .:-=+*#%@"
Space is darkest, @ is brightest. The human eye perceives these characters as having different "densities" in a monospace font.
An accretion disk is a flat ring of gas and dust spiraling into the black hole. As matter falls inward, friction and compression heat it to extreme temperatures — millions of degrees in reality, emitting X-rays.
The disk lies in the XZ plane (
During raymarching, we check if the ray crosses the disk plane by watching for a sign change in the y-coordinate between steps:
We also check proximity:
When a crossing is detected at cylindrical radius
The brightness of the disk isn't a simple function of radius. Page & Thorne derived the exact formula for a Schwarzschild black hole:
where
This peaks at
- Closer to the hole → deeper gravity well → more energy available
- Closer to the ISCO → less orbital energy to extract
The redshift factor
where:
-
$\sqrt{M/r^3}$ is the Keplerian angular velocity$\Omega$ -
$b \cdot \sin\theta_0 \cdot \sin\alpha$ captures the Doppler shift from disk rotation -
$1/\sqrt{1 - 3M/r}$ is the gravitational redshift (diverges at$r = 3M$ )
For our 3D raytracer, we compute the Doppler part geometrically: the orbital velocity direction at
The observed flux combines intrinsic emission with the redshift:
The
-
$(1+z)^1$ — photon energy shift -
$(1+z)^1$ — time dilation (fewer photons per second) -
$(1+z)^2$ — solid angle change (relativistic beaming)
This makes the approaching (blueshifted) side dramatically brighter and the receding (redshifted) side much dimmer — one of the most visually striking features of a black hole image.
We tint the disk color based on the redshift:
-
$(1+z) < 1$ → blueshift (approaching) → cool cyan/white -
$(1+z) > 1$ → redshift (receding) → warm orange/red
The intensity from the observed flux controls overall brightness.
Modern terminals support 24-bit RGB color via escape sequences:
\x1b[38;2;R;G;Bm — set foreground color
\x1b[0m — reset to default
We emit a color code before each disk character and reset when transitioning back to non-disk characters, keeping the output clean.
The original camera assumed all rays point along
We build three perpendicular axes from the camera position:
where
This requires the cross product, which gives a vector perpendicular to two inputs:
The ray direction for each cell then becomes:
This ensures the center of the screen always points directly at the black hole, regardless of camera position.
Going from static frames to real-time interactive rendering requires three things: raw terminal mode, a frame loop, and input polling.
Normally, the terminal buffers input until Enter is pressed and echoes characters as you type. Raw mode disables both — we receive each keypress instantly and control exactly what appears on screen. The crossterm crate handles this cross-platform.
terminal::enable_raw_mode()?; // enter raw mode
// ... animation loop ...
terminal::disable_raw_mode()?; // restore on exit (critical!)If we exit without restoring, the user's terminal becomes unusable until they run reset.
Rust's ? operator propagates errors concisely. If an operation returns Err, ? immediately returns that error to the caller:
// These are equivalent:
let size = terminal::size()?;
let size = match terminal::size() {
Ok(val) => val,
Err(e) => return Err(e),
};To orbit the camera, we use spherical coordinates — like latitude/longitude on a globe:
-
$\theta$ — horizontal angle (longitude),$0$ to$2\pi$ -
$\phi$ — vertical angle (co-latitude),$0$ (north pole) to$\pi$ (south pole) -
$r$ — distance from the origin
Conversion to Cartesian:
At
loop:
record start time
get terminal size (handles window resizing)
compute camera position from (theta, phi, distance)
render frame
write frame to stdout at cursor position (0, 0)
poll for input with remaining frame time as timeout
apply any input to camera state
Key insight: we use cursor::MoveTo(0, 0) instead of clearing the screen each frame. Clearing causes visible flicker; overwriting in-place is smooth.
| Key | Action |
|---|---|
← / →
|
Orbit camera horizontally ( |
↑ / ↓
|
Orbit camera vertically ( |
+ / -
|
Zoom in/out (distance |
r |
Reset to default view |
q / Esc
|
Quit |
As the codebase grows, a single file becomes hard to navigate. Rust's module system lets us split code into logical units while maintaining strict visibility control.
In main.rs, we declare submodules:
mod vec3;
mod camera;
mod physics;
mod disk;
mod render;Each mod foo; tells the compiler to look for src/foo.rs and include it as a submodule. Unlike many languages, files aren't automatically part of the build — you must explicitly declare them.
By default, everything in Rust is private. To make items accessible from other modules, mark them pub:
// In vec3.rs — the struct and its fields must be pub
pub struct Vec3 {
pub x: f64,
pub y: f64,
pub z: f64,
}Without pub on the fields, other modules could create Vec3 values but not access .x, .y, .z.
Inside a module, crate:: refers to the project root:
// In physics.rs
use crate::vec3::Vec3;In main.rs, since the modules are direct children, you can use shorter paths:
use camera::spherical_to_cartesian;
use render::render_frame;src/
├── lib.rs — library root, module declarations
├── main.rs — CLI entry point, animation loop, keyboard controls
├── wasm.rs — WASM entry point, BlackHoleRenderer bindings
├── vec3.rs — Vec3 struct, operators, methods
├── camera.rs — ray direction, spherical coordinates
├── physics.rs — Schwarzschild constants, geodesic ray marching
├── disk.rs — accretion disk redshift and flux
└── render.rs — ASCII, ANSI, RGBA, and HTML rendering
Each module has its own #[cfg(test)] mod tests { ... } section — tests live right next to the code they test.
The same physics runs in the browser — no server needed, no JavaScript physics reimplementation. Rust compiles to WebAssembly (WASM), a binary instruction format that runs at near-native speed in all modern browsers.
To support both CLI and WASM, we split the crate into a library and a binary:
src/
├── lib.rs — library root: declares all shared modules
├── main.rs — CLI binary: crossterm terminal loop
├── wasm.rs — WASM entry point: #[wasm_bindgen] bindings
├── vec3.rs — (shared) pure math
├── camera.rs — (shared) pure math
├── physics.rs — (shared) pure math
├── disk.rs — (shared) pure math
└── render.rs — (shared) rendering logic + RGBA/HTML output
In Rust, src/lib.rs and src/main.rs are separate compilation units. The library owns the module tree; the binary imports from it:
// In main.rs (binary) — imports from the library crate
use ergosphere::camera::spherical_to_cartesian;
use ergosphere::render::render_frame;The ergosphere:: prefix is the crate name from Cargo.toml. Within the library's own modules, you'd use crate:: instead.
[lib]
crate-type = ["cdylib", "rlib"]rlib— Rust library format. Needed forcargo testand for the CLI binary.cdylib— C-compatible dynamic library. Required forwasm-bindgento produce a.wasmfile. Without this,wasm-pack buildwould fail.
# Only on native (x86_64, aarch64, etc.)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
crossterm = "0.28"
# Only on WASM
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["HtmlCanvasElement", ...] }In Rust code, #[cfg(target_arch = "wasm32")] guards WASM-only modules:
// In lib.rs — this module is completely ignored on native builds
#[cfg(target_arch = "wasm32")]
pub mod wasm;The compiler doesn't just skip the code at runtime — it doesn't compile it at all. Zero cost on native builds.
The #[wasm_bindgen] attribute generates JavaScript glue code:
#[wasm_bindgen]
pub struct BlackHoleRenderer {
theta: f64,
phi: f64,
distance: f64,
}
#[wasm_bindgen]
impl BlackHoleRenderer {
#[wasm_bindgen(constructor)]
pub fn new() -> BlackHoleRenderer { ... }
pub fn render_to_canvas(&mut self, width: usize, height: usize, timestamp: f64) -> Vec<u8> {
// Returns RGBA pixel buffer
}
}JavaScript sees this as a regular class:
const renderer = new BlackHoleRenderer();
const pixels = renderer.render_to_canvas(160, 90, performance.now());The Vec<u8> return type becomes a Uint8Array in JavaScript — wasm-bindgen handles the memory copy across the WASM boundary.
The RGBA pixel buffer from Rust maps directly to ImageData:
const pixels = renderer.render_to_canvas(WIDTH, HEIGHT, timestamp);
const imageData = new ImageData(
new Uint8ClampedArray(pixels),
WIDTH, HEIGHT
);
ctx.putImageData(imageData, 0, 0);The canvas renders at low resolution (160x90) for performance — each pixel requires a full geodesic ray march. CSS scales it up with image-rendering: pixelated for crisp pixels.
The same rendering pipeline generates HTML instead of ANSI codes:
pub fn render_frame_ascii_html(width: usize, height: usize, camera_pos: Vec3) -> String {
// Generates <span style="color:rgb(...)">@</span> for disk cells
// Plain characters for stars and background
}Set as innerHTML of a <pre> element — the browser equivalent of a terminal.
Browsers use requestAnimationFrame instead of a loop:
function renderFrame(timestamp) {
const pixels = renderer.render_to_canvas(WIDTH, HEIGHT, timestamp);
ctx.putImageData(new ImageData(new Uint8ClampedArray(pixels), WIDTH, HEIGHT), 0, 0);
requestAnimationFrame(renderFrame);
}
requestAnimationFrame(renderFrame);requestAnimationFrame syncs with the display's refresh rate (~60fps) and pauses when the tab is hidden — saving CPU and battery.
web-sys wraps every Web API, but you only pay for what you use:
[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys]
version = "0.3"
features = [
"console", # console.error() for panic messages
"HtmlCanvasElement", # <canvas> element
"CanvasRenderingContext2d", # 2D drawing context
"ImageData", # pixel buffer → canvas
]Each feature adds ~0 bytes if unused (dead code elimination) but enables the corresponding Rust bindings.
The rand crate's default features include os_rng, which pulls in getrandom — a crate that provides OS-level entropy (e.g., /dev/urandom on Linux). This doesn't exist on wasm32-unknown-unknown.
Since we only use SmallRng (a deterministic PRNG seeded from a fixed value), we disable default features:
rand = { version = "0.9", default-features = false, features = ["small_rng", "alloc"] }This is a common pattern: WASM compatibility often means auditing your dependency tree for OS-specific assumptions.
# Run tests (45 tests across all modules)
cargo test
# Launch the interactive CLI renderer
cargo run --release
# Build for WebAssembly (requires wasm-pack)
wasm-pack build --target web --out-dir web/pkg
# Serve the web version locally
python3 -m http.server 8000 --directory web
# Visit http://localhost:8000| Key | Action |
|---|---|
← / → |
Orbit camera horizontally |
↑ / ↓ |
Orbit camera vertically |
+ / - |
Zoom in/out |
r |
Reset to default view |
q / Esc |
Quit |
Note: the accretion disk is axially symmetric, so horizontal orbiting changes the Doppler coloring (approaching/receding sides swap) rather than the silhouette shape. The starfield rotates to provide visual feedback.
| Input | Action |
|---|---|
| Mouse drag | Orbit camera |
| Scroll wheel | Zoom in/out |
| Arrow keys | Orbit camera |
+ / - |
Zoom in/out |
r |
Reset to default view |
The scene auto-rotates by default. User input pauses auto-rotation for 3 seconds, then it resumes.
- Performance —
rayonfor parallel row rendering (CLI), web workers for WASM - Higher-order images — photons that orbit the black hole before hitting the disk
- Kerr black hole — add spin parameter for frame-dragging effects