// rt.c — a tiny path tracer that renders to the terminal in 24-bit color. // // cc -O2 -o rt rt.c -lm && ./rt // // Each terminal cell is split into two pixels with the upper-half block '▀': // the glyph's foreground paints the top pixel, the background the bottom one, // so an 80x24 terminal becomes a 160-wide... no — an 80x48 framebuffer. // // Monte-Carlo path tracing: diffuse + perfect-mirror materials, an emissive // sphere as the only light, cosine-weighted hemisphere sampling, a handful of // bounces. The camera orbits the scene; each frame accumulates a few samples. #include #include #include #include #include #include #include // ---------------------------------------------------------------- vector math typedef struct { double x, y, z; } V; static V v(double x, double y, double z) { return (V){x, y, z}; } static V add(V a, V b) { return v(a.x+b.x, a.y+b.y, a.z+b.z); } static V sub(V a, V b) { return v(a.x-b.x, a.y-b.y, a.z-b.z); } static V mul(V a, double s) { return v(a.x*s, a.y*s, a.z*s); } static V had(V a, V b) { return v(a.x*b.x, a.y*b.y, a.z*b.z); } // hadamard static double dot(V a, V b) { return a.x*b.x + a.y*b.y + a.z*b.z; } static V cross(V a, V b) { return v(a.y*b.z-a.z*b.y, a.z*b.x-a.x*b.z, a.x*b.y-a.y*b.x); } static double len(V a) { return sqrt(dot(a, a)); } static V norm(V a) { return mul(a, 1.0 / len(a)); } // ------------------------------------------------------------------------- rng // xorshift64 — deterministic per-thread state, fine for Monte-Carlo noise. static uint64_t rng_state = 0x853c49e6748fea9bULL; static double rnd(void) { uint64_t x = rng_state; x ^= x << 13; x ^= x >> 7; x ^= x << 17; rng_state = x; return (x >> 11) * (1.0 / 9007199254740992.0); // [0,1) } // ----------------------------------------------------------------------- scene typedef enum { DIFFUSE, MIRROR, GLASS } Material; typedef struct { V center; double radius; V albedo; // surface colour V emission; // > 0 for lights Material mat; } Sphere; static const Sphere scene[] = { // a big sphere used as the ground plane (grid is applied procedurally) {{0, -1000, 0}, 1000, {0.02, 0.02, 0.04}, {0,0,0}, DIFFUSE}, // chrome spheres — they exist mostly to reflect the neon sky and grid {{-2.6, 1.0, 0}, 1.0, {0.92, 0.55, 0.95}, {0,0,0}, MIRROR }, {{0.0, 1.3, 0}, 1.3, {0.96, 0.99, 1.00}, {0,0,0}, GLASS }, {{2.6, 1.0, 0}, 1.0, {0.55, 0.95, 0.95}, {0,0,0}, MIRROR }, }; static const int N_SPHERES = sizeof(scene) / sizeof(scene[0]); // Returns index of nearest hit sphere, or -1; writes distance t. static int hit(V o, V d, double *t_out) { double best = 1e30; int idx = -1; for (int i = 0; i < N_SPHERES; i++) { V oc = sub(o, scene[i].center); double b = dot(oc, d); double c = dot(oc, oc) - scene[i].radius * scene[i].radius; double disc = b*b - c; if (disc < 0) continue; double s = sqrt(disc); double t = -b - s; if (t < 1e-4) t = -b + s; if (t > 1e-4 && t < best) { best = t; idx = i; } } *t_out = best; return idx; } // Cosine-weighted direction in the hemisphere around normal n. static V cosine_hemisphere(V n) { double r1 = 2.0 * M_PI * rnd(); double r2 = rnd(), r2s = sqrt(r2); V w = n; V a = fabs(w.x) > 0.1 ? v(0,1,0) : v(1,0,0); V u = norm(cross(a, w)); V vv = cross(w, u); return norm(add(add(mul(u, cos(r1)*r2s), mul(vv, sin(r1)*r2s)), mul(w, sqrt(1-r2)))); } // The synthwave sky: indigo aloft, a fat banded sun sinking toward a magenta // horizon. This is the scene's only light source, so it has to be bright. static V sky(V d) { d = norm(d); double up = d.y < 0 ? 0 : d.y > 1 ? 1 : d.y; // clamp to [0,1] // three-stop vertical gradient: indigo aloft -> violet -> hot magenta horizon V top = v(0.04, 0.01, 0.12), mid = v(0.45, 0.05, 0.55), hor = v(1.1, 0.25, 0.65); V grad = up < 0.5 ? add(mul(hor, 1-up*2), mul(mid, up*2)) : add(mul(mid, 1-(up-0.5)*2), mul(top, (up-0.5)*2)); // the sun: a disc around a low horizon direction, colour shifting yellow->magenta V sd = norm(v(0.0, 0.18, -1.0)); double c = dot(d, sd); if (c > 0.94) { double s = (c - 0.94) / 0.06; // 0..1 across the disc V suncol = add(mul(v(1.5,1.1,0.2), s), mul(v(1.4,0.2,0.5), 1-s)); // horizontal scanline gaps carved into the lower half of the sun double band = d.y - sd.y; if (band < 0 && fmod(fabs(band)*42.0, 2.0) < 0.9) suncol = mul(suncol, 0.15); return add(grad, mul(suncol, 2.2)); } return mul(grad, 1.3); } // Trace one ray; return incoming radiance. static V trace(V o, V d, int depth) { if (depth > 12) return v(0,0,0); // glass needs the headroom double t; int i = hit(o, d, &t); if (i < 0) return sky(d); // missed everything const Sphere *s = &scene[i]; V p = add(o, mul(d, t)); V nrm = norm(sub(p, s->center)); if (dot(nrm, d) > 0) nrm = mul(nrm, -1); // face the ray // The ground (sphere 0) gets a glowing neon grid baked into its emission. if (i == 0) { double gx = fabs(fmod(fabs(p.x) + 0.06, 2.0) - 0.06); double gz = fabs(fmod(fabs(p.z) + 0.06, 2.0) - 0.06); if (gx < 0.045 || gz < 0.045) { double fade = 70.0 / (70.0 + dot(sub(p, o), sub(p, o))); // dim with distance return mul(v(0.1, 1.0, 1.3), 2.4 * fade); // cyan grid glow } } if (s->emission.x > 0 || s->emission.y > 0 || s->emission.z > 0) return s->emission; // russian roulette on the brightest albedo channel past a few bounces double pcont = fmax(s->albedo.x, fmax(s->albedo.y, s->albedo.z)); if (depth > 3) { if (rnd() > pcont) return v(0,0,0); } else pcont = 1.0; V alb = mul(s->albedo, 1.0/pcont); if (s->mat == MIRROR) { V r = sub(d, mul(nrm, 2*dot(nrm, d))); return had(alb, trace(p, norm(r), depth+1)); } if (s->mat == GLASS) { // dielectric: refract + Fresnel V gn = norm(sub(p, s->center)); // outward geometric normal V nl = dot(gn, d) < 0 ? gn : mul(gn, -1); // side the ray came from int into = dot(gn, nl) > 0; // entering the glass? double nc = 1.0, nt = 1.5; // air -> crown glass double nnt = into ? nc/nt : nt/nc; double ddn = dot(d, nl); double cos2t = 1 - nnt*nnt*(1 - ddn*ddn); V refl = norm(sub(d, mul(gn, 2*dot(gn, d)))); if (cos2t < 0) // total internal reflection return had(alb, trace(p, refl, depth+1)); V tdir = norm(sub(mul(d, nnt), mul(gn, (into ? 1.0 : -1.0) * (ddn*nnt + sqrt(cos2t))))); double a = nt-nc, b = nt+nc, R0 = a*a/(b*b); // Schlick base double c = 1 - (into ? -ddn : dot(tdir, gn)); double Re = R0 + (1-R0)*c*c*c*c*c, P = 0.25 + 0.5*Re; // reflect probability return rnd() < P ? had(alb, mul(trace(p, refl, depth+1), Re / P)) : had(alb, mul(trace(p, tdir, depth+1), (1-Re) / (1-P))); } V dir = cosine_hemisphere(nrm); return had(alb, trace(p, dir, depth+1)); } // ----------------------------------------------------------------- tonemapping static int tone(double c) { // linear -> sRGB-ish byte if (c < 0) c = 0; c = pow(c / (c + 1.0), 1.0/2.2); // Reinhard + gamma int x = (int)(c * 255 + 0.5); return x < 0 ? 0 : x > 255 ? 255 : x; } // Write the framebuffer as a binary PPM so the image can be inspected directly // (this is how the renderer gets "playtested" — dump a frame, then look at it). static void write_ppm(const char *path, V *fb, int W, int H) { FILE *fp = fopen(path, "wb"); fprintf(fp, "P6\n%d %d\n255\n", W, H); for (int i = 0; i < W*H; i++) { unsigned char px[3] = { tone(fb[i].x), tone(fb[i].y), tone(fb[i].z) }; fwrite(px, 1, 3, fp); } fclose(fp); } int main(int argc, char **argv) { int W = 80, H = 48; // framebuffer pixels int SPP = 24; // samples per pixel int FRAMES = 240; const char *snapshot = NULL; // if set, render 1 frame to PPM const char *gifpre = NULL; // if set, dump every frame as PPM if (argc > 1) SPP = atoi(argv[1]); if (argc > 2) FRAMES = atoi(argv[2]); if (argc > 3 && !strcmp(argv[3], "--shot")) { // --shot W H FILE: hi-res still W = atoi(argv[4]); H = atoi(argv[5]); snapshot = argv[6]; FRAMES = 1; } if (argc > 3 && !strcmp(argv[3], "--gif")) { // --gif W H PREFIX: dump frames W = atoi(argv[4]); H = atoi(argv[5]); gifpre = argv[6]; } // The orbit completes one full turn over FRAMES, so a dumped sequence loops. double period = (snapshot || !gifpre) ? 160.0 : FRAMES; V *fb = malloc(sizeof(V) * W * H); char *out = malloc(W * H * 48 + 4096); // ANSI scratch buffer double aspect = (double)W / H; printf("\033[2J\033[?25l"); // clear, hide cursor fflush(stdout); for (int f = 0; f < FRAMES; f++) { double ang = f * (2*M_PI / period); V eye = v(sin(ang)*9, 3.2, cos(ang)*9); V look = v(0, 1.0, 0); V fwd = norm(sub(look, eye)); V right = norm(cross(fwd, v(0,1,0))); V up = cross(right, fwd); double fov = 1.4; for (int y = 0; y < H; y++) { for (int x = 0; x < W; x++) { V acc = v(0,0,0); for (int sp = 0; sp < SPP; sp++) { double px = ( (x + rnd()) / W * 2 - 1) * aspect * fov; double py = (1 - (y + rnd()) / H * 2) * fov; V dir = norm(add(fwd, add(mul(right, px), mul(up, py)))); acc = add(acc, trace(eye, dir, 0)); } fb[y*W + x] = mul(acc, 1.0 / SPP); } } if (snapshot) { write_ppm(snapshot, fb, W, H); break; } if (gifpre) { // dump frameNNN.ppm and move on char path[512]; snprintf(path, sizeof path, "%s%03d.ppm", gifpre, f); write_ppm(path, fb, W, H); fprintf(stderr, "\rframe %d/%d", f+1, FRAMES); continue; } // Pack two stacked pixels per cell with the upper-half block. char *o = out; o += sprintf(o, "\033[H"); // cursor home for (int y = 0; y < H; y += 2) { for (int x = 0; x < W; x++) { V top = fb[y*W + x], bot = fb[(y+1)*W + x]; o += sprintf(o, "\033[38;2;%d;%d;%dm\033[48;2;%d;%d;%dm▀", tone(top.x), tone(top.y), tone(top.z), tone(bot.x), tone(bot.y), tone(bot.z)); } o += sprintf(o, "\033[0m\n"); } fwrite(out, 1, o - out, stdout); fflush(stdout); } printf("\033[0m\033[?25h\n"); // restore cursor free(fb); free(out); return 0; }