Skip to content

Latest commit

 

History

History
650 lines (435 loc) · 21.9 KB

File metadata and controls

650 lines (435 loc) · 21.9 KB

Ergosphere Tutorial

Building a terminal-based black hole renderer from scratch — learning Rust, raymarching, and black hole physics along the way.

Part 1: Vectors and Rays

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.

The Vec3 struct

#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec3 {
    x: f64,
    y: f64,
    z: f64,
}

Key Rust concepts here:

  • struct groups 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, assigning a = b would move b, making it unusable. This is Rust's ownership system.
    • Clone — explicit duplication via .clone()
    • Debug — enables {:?} printing
    • PartialEq — enables == comparison
  • f64 — 64-bit floating point (double precision). Enough accuracy for our physics.

Essential vector operations

Length (magnitude) — distance from the origin:

$$|\vec{v}| = \sqrt{x^2 + y^2 + z^2}$$

This is the 3D Pythagorean theorem.

Normalization — scaling a vector to length 1 while preserving direction:

$$\hat{v} = \frac{\vec{v}}{|\vec{v}|}$$

Critical for ray directions — we want direction without magnitude.

Dot product — measures how much two vectors point in the same direction:

$$\vec{a} \cdot \vec{b} = a_x b_x + a_y b_y + a_z b_z$$

Returns a scalar:

  • $+1$ → same direction
  • $0$ → perpendicular
  • $-1$ → opposite directions

Operator overloading

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.


Part 2: Camera and Ray Generation

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.

Perspective projection

We map each cell to normalized device coordinates (NDC) ranging from $-1$ to $+1$:

$$\text{ndc}_x = \frac{2(\text{col} + 0.5)}{\text{width}} - 1$$

$$\text{ndc}_y = 1 - \frac{2(\text{row} + 0.5)}{\text{height}}$$

The $+0.5$ centers the ray in the middle of each cell. The y-axis is flipped because terminal rows count downward but our y-axis points up.

Field of view and focal length

FOV (field of view) controls how wide the camera sees. The focal length is derived from it:

$$f = \frac{1}{\tan(\text{FOV} / 2)}$$

A wider FOV means a shorter focal length, like a fisheye lens.

Terminal aspect ratio correction

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:

$$\text{pixel_aspect} = \frac{\text{cols}}{\text{rows} \times \text{char_height_ratio}}$$

where $\text{char_height_ratio} \approx 2.0$ for typical monospace fonts. We apply this as a single correction to the x coordinate:

Vec3::new(ndc_x * pixel_aspect, ndc_y, -focal_length).normalize()

Part 3: Schwarzschild Geodesics

A black hole warps spacetime. Light passing nearby doesn't travel straight — it curves toward the singularity. This is gravitational lensing.

The Schwarzschild metric

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 $G = c = 1$ and set the black hole mass $M = 1$. This fixes all the physics:

Quantity Formula Value
Event horizon $r_s = 2M$ 2.0
Photon sphere $r = 3M$ 3.0
ISCO $r = 6M$ 6.0
Critical impact parameter $b_c = 3\sqrt{3}M$ ≈ 5.196

The geodesic equation

Photons follow null geodesics — paths through curved spacetime where the spacetime interval $ds^2 = 0$. From the Schwarzschild effective potential:

$$V(r) = \left(1 - \frac{r_s}{r}\right) \frac{L^2}{r^2}$$

we can derive the exact 3D acceleration for a photon at position $\vec{x}$ with velocity $\vec{v}$:

$$\vec{a} = -\frac{3M \cdot h^2}{r^5} \cdot \vec{x}$$

where $h = |\vec{x} \times \vec{v}|$ is the specific angular momentum and $r = |\vec{x}|$.

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 and photon sphere

The event horizon at $r_s = 2M$ is the point of no return — inside it, all paths through spacetime lead to the singularity.

The photon sphere at $r = 3M$ is where photons can orbit the black hole, but unstably — any perturbation sends them either spiraling in or escaping. This creates the bright photon ring visible in black hole images.

The shadow of the black hole has an apparent radius of $b_c = 3\sqrt{3}M \approx 5.2M$ — larger than the event horizon because photons with impact parameter $b < b_c$ get captured even without crossing the horizon.

The raymarching algorithm

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)

ASCII brightness ramp

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.


Part 4: The Accretion Disk

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.

Disk geometry

The disk lies in the XZ plane ($y = 0$) as a flat torus between an inner and outer radius. The inner edge corresponds to the innermost stable circular orbit (ISCO) — the closest orbit where matter can circle without spiraling in. For a non-spinning black hole:

$$r_\text{ISCO} = 3 , r_s$$

Detecting disk crossings

During raymarching, we check if the ray crosses the disk plane by watching for a sign change in the y-coordinate between steps:

$$\text{crossed} = (\text{prev_y} \times \text{pos_y}) < 0$$

We also check proximity: $|y| &lt; \text{thickness}$, to catch rays traveling nearly parallel to the plane.

When a crossing is detected at cylindrical radius $r_\text{disk} = \sqrt{x^2 + z^2}$, we check if it falls within the disk:

$$r_\text{inner} \leq r_\text{disk} \leq r_\text{outer}$$

Intrinsic flux — Page & Thorne (1974)

The brightness of the disk isn't a simple function of radius. Page & Thorne derived the exact formula for a Schwarzschild black hole:

$$F_s = \frac{3M\dot{M}}{8\pi(r^* - 3){r^_}^{5/2}} \left[ \sqrt{r^_} - \sqrt{6} + \frac{\sqrt{3}}{2} \ln\left(\frac{(\sqrt{r^_}+\sqrt{3})(\sqrt{6}-\sqrt{3})}{(\sqrt{6}+\sqrt{3})(\sqrt{r^_}-\sqrt{3})}\right) \right]$$

where $r^* = r/M$ and $\dot{M}$ is the accretion rate.

This peaks at $r \approx 9.55M$ — not at the inner edge! The formula balances two competing effects:

  • Closer to the hole → deeper gravity well → more energy available
  • Closer to the ISCO → less orbital energy to extract

Redshift factor — Luminet (1979)

The redshift factor $(1+z)$ combines gravitational and Doppler effects:

$$1 + z = \frac{1 + \sqrt{M/r^3} \cdot b \cdot \sin\theta_0 \cdot \sin\alpha}{\sqrt{1 - 3M/r}}$$

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 $(x, 0, z)$ is $(-z, 0, x)$ (tangent to the circular orbit), and we project it onto the camera direction.

Observed flux

The observed flux combines intrinsic emission with the redshift:

$$F_\text{obs} = \frac{F_\text{intrinsic}}{(1+z)^4}$$

The $(1+z)^4$ factor accounts for:

  • $(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.

Color mapping

We tint the disk color based on the redshift:

  • $(1+z) &lt; 1$ → blueshift (approaching) → cool cyan/white
  • $(1+z) &gt; 1$ → redshift (receding) → warm orange/red

The intensity from the observed flux controls overall brightness.

ANSI truecolor output

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.


Part 5: Look-At Camera

The original camera assumed all rays point along $-z$. When the camera is offset (e.g. above the disk at $y = 2$), the black hole drifts off-center. We need a look-at camera that always aims at the singularity.

Camera basis vectors

We build three perpendicular axes from the camera position:

$$\vec{f} = \text{normalize}(\text{target} - \text{camera})$$

$$\vec{r} = \text{normalize}(\vec{f} \times \vec{u}_\text{world})$$

$$\vec{u} = \vec{r} \times \vec{f}$$

where $\vec{u}_\text{world} = (0, 1, 0)$ is the world "up" direction.

This requires the cross product, which gives a vector perpendicular to two inputs:

$$\vec{a} \times \vec{b} = (a_y b_z - a_z b_y, ;; a_z b_x - a_x b_z, ;; a_x b_y - a_y b_x)$$

The ray direction for each cell then becomes:

$$\vec{d} = \text{normalize}(\vec{f} \cdot f_\text{len} + \vec{r} \cdot x_\text{ndc} \cdot \text{aspect} + \vec{u} \cdot y_\text{ndc})$$

This ensures the center of the screen always points directly at the black hole, regardless of camera position.


Part 6: Animation and Controls

Going from static frames to real-time interactive rendering requires three things: raw terminal mode, a frame loop, and input polling.

Raw terminal mode

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.

The ? operator

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),
};

Spherical coordinates for orbiting

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:

$$x = r \sin\phi \sin\theta$$ $$y = r \cos\phi$$ $$z = r \sin\phi \cos\theta$$

At $\phi = 0$, the camera is directly above; at $\phi = \pi/2$, it's level with the disk. We clamp $\phi$ away from the poles ($0.1$ to $\pi - 0.1$) to avoid a degenerate cross product with the world up vector.

The frame loop

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.

Controls

Key Action
/ Orbit camera horizontally ($\theta \pm 0.1$)
/ Orbit camera vertically ($\phi \pm 0.05$)
+ / - Zoom in/out (distance $\pm 0.5$)
r Reset to default view
q / Esc Quit

Part 7: Rust Modules

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.

The mod keyword

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.

Visibility with pub

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.

Importing with use and crate

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;

Module structure

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.


Part 8: WebAssembly

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.

The lib.rs / main.rs split

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.

crate-type: cdylib + rlib

[lib]
crate-type = ["cdylib", "rlib"]
  • rlib — Rust library format. Needed for cargo test and for the CLI binary.
  • cdylib — C-compatible dynamic library. Required for wasm-bindgen to produce a .wasm file. Without this, wasm-pack build would fail.

Conditional compilation

# 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.

wasm-bindgen

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.

Rendering to Canvas

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.

ASCII mode in the browser

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.

The animation loop

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 feature flags

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 getrandom problem

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.


Running the project

# 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

CLI controls

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.

Browser controls

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.


What's next

  • Performancerayon for 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