commit f8b7ff475c1550a06ca9846056c8302ce1ba382f Author: Ole-Morten Duesund Date: Thu May 28 13:47:50 2026 +0200 Initial commit: business card raytracer with explanation Preserves Andrew Kensler's original card.cc verbatim and adds: - CMakeLists.txt building both the original and a de-obfuscated variant (card_explained.cc) that produces a visually identical render - A heavily annotated rewrite explaining the vector ops, ray-sphere intersection, soft shadows, depth of field, and reflection recursion - Rendered sample output (docs/aek.png) embedded in the README - CLAUDE.md establishing the "never modify card.cc" rule for future work diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db24cfc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +*.ppm diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bcb907f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# card-raytracer — project notes for Claude + +This repo is a small study of Andrew Kensler's "business card raytracer". +The point of the project is **preservation + explanation**, not extension. + +## The sacred rule + +**`card.cc` must never be modified.** It is a historical artifact, ~1337 +characters of obfuscated C++ that fits on a real business card. Any cleanup, +formatting, comment, or "fix" goes into `card_explained.cc` instead. + +If a compiler warning or modern-C++ issue appears against `card.cc`, the +answer is always a CMake flag adjustment (e.g. silencing warnings), never +an edit to the source. + +## Layout + +| File | Role | +|---|---| +| `card.cc` | Original, golfed, untouched. Read-only by convention. | +| `card_explained.cc` | De-obfuscated rewrite with full names and comments. Same visual output. | +| `CMakeLists.txt` | Builds both binaries; provides `image` and `image_explained` render targets. | +| `docs/aek.png` | Published render used by the README. Regenerate after meaningful changes. | +| `README.md` | Human-facing description with the embedded render. | + +## Build & render + +```sh +cmake -S . -B build +cmake --build build # builds both binaries +cmake --build build --target image # → build/aek.ppm +cmake --build build --target image_explained # → build/aek_explained.ppm +``` + +Convert PPM → PNG (for README, etc.) with `pnmtopng build/aek.ppm > docs/aek.png`. + +## Conventions + +- `card_explained.cc` is built with `-Wall -Wextra` and should stay + warning-clean. `card.cc` is built with `-w` because we can't edit it. +- C++14, no external dependencies beyond `libm`. +- The de-obfuscated version preserves the **same `rand()` call sequence** as + the original so the rendered image is visually identical. Don't reorder + the `randf()` calls inside `shade()` or the per-sample loop in `main()` + without re-checking the output. + +## Remote + +Hosted on Forgejo: `ssh://git@kode.naiv.no:2222/olemd/card-raytracer.git`. +Per global instructions, use the `forgejo` skill (invoke via `/forgejo`) and +the `fj` CLI for any repository interactions (PRs, issues, releases, etc.). +Never use `gh` for this repo. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3e4d41b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.16) +project(card_raytracer CXX) + +# The original card.cc is sacred and must NOT be modified. We compile it +# verbatim. Because the file is golfed C++ from the early 2010s, we relax +# a few things so a modern compiler accepts it untouched: +# - C++14 (default constructor v(){} is fine; pow(float,int) overload exists) +# - -w silences the unavoidable warnings (implicit float->int conversions +# in the final printf, narrowing, unused result of operator new, etc.) +# We can't fix them — we'd have to edit card.cc, which we won't. +set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_executable(card card.cc) +add_executable(card_explained card_explained.cc) + +# Per-compiler flags. The original is golfed and triggers warnings we can't +# fix without editing it (forbidden). The de-obfuscated version keeps full +# warnings on so it stays honest. +if(MSVC) + target_compile_options(card PRIVATE /w /O2) + target_compile_options(card_explained PRIVATE /W4 /O2) +else() + target_compile_options(card PRIVATE -w -O3) + target_compile_options(card_explained PRIVATE -Wall -Wextra -O3) +endif() + +# Convenience targets: render an image from each binary. +# Run with: cmake --build build --target image +add_custom_target(image + COMMAND card > aek.ppm + DEPENDS card + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + COMMENT "Rendering aek.ppm from the original card (512x512 @ 64 spp)" +) + +add_custom_target(image_explained + COMMAND card_explained > aek_explained.ppm + DEPENDS card_explained + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + COMMENT "Rendering aek_explained.ppm from the de-obfuscated card" +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c538a22 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# card-raytracer + +A study of [Andrew Kensler's "business card raytracer"][aek] — a complete +recursive path-tracer with soft shadows, depth of field, anti-aliasing, +reflections, a checker floor, and a sky gradient, all crammed into ~1337 +characters of obfuscated C++ small enough to print on the back of a +business card. + +The original code is preserved verbatim as [`card.cc`](card.cc). A +de-obfuscated, heavily-annotated rewrite lives in +[`card_explained.cc`](card_explained.cc). Both produce the same image: + +![Rendered output: the letters "aek" as reflective spheres on a red and white checker floor under a blue-purple sky, with soft shadows and depth-of-field blur](docs/aek.png) + +The spheres spell **a · e · k**, Andrew Kensler's initials, encoded as a +9-column bitmap in the `G[]` array of the original source. + +## Quick start + +Requires a C++14 compiler, CMake ≥ 3.16, and (optionally) `netpbm` for +PPM → PNG conversion. + +```sh +cmake -S . -B build +cmake --build build + +# Render with the original (PPM, 786 KiB, ~6 s on a modern laptop) +./build/card > aek.ppm + +# Or via the convenience target: +cmake --build build --target image # → build/aek.ppm +cmake --build build --target image_explained # → build/aek_explained.ppm + +# View it (PPM is supported by most image viewers; or convert): +pnmtopng aek.ppm > aek.png +``` + +## What's interesting about it + +The whole renderer is built on **operator overloading**: a single `struct v` +provides `+` (add), `*` (scale), `%` (dot product), `^` (cross product), and +unary `!` (normalise). Once those are in place, the entire rendering +pipeline — sphere intersections, reflection vectors, Phong shading, +checker-pattern tile lookup, camera basis construction — collapses into +just a handful of expressions. + +Other tricks worth admiring: + +- **Soft shadows for free**: the light position is jittered each call, so + averaging 64 samples per pixel produces a real penumbra without any + area-light formalism. +- **Depth of field for free**: the camera origin is also jittered across + a 99-unit "lens" each sample, so points off the focal plane go blurry. +- **Anti-aliasing for free**: sub-pixel jitter on the ray direction. + +The same 64-sample loop pays for all three at once. + +A line-by-line walkthrough of the algorithm is the long-form +[comments inside `card_explained.cc`](card_explained.cc). + +## Layout + +``` +. +├── card.cc # original, untouched — do not edit +├── card_explained.cc # de-obfuscated, documented rewrite +├── CMakeLists.txt +├── docs/ +│ └── aek.png # published render +├── README.md +└── CLAUDE.md # working notes for AI assistance +``` + +## Credits + +- Original raytracer: [Andrew Kensler][aek-home] ("aek"). +- Excellent walkthrough that inspired this study: + Fabien Sanglard, [_Decyphering The Business Card Raytracer_][aek]. + +## Licence + +The original `card.cc` is reproduced as-is from publicly published material +for educational purposes; credit and copyright belong to Andrew Kensler. +The supporting files in this repository (CMake, README, de-obfuscated +rewrite) are placed in the public domain by the repository author. + +[aek]: https://fabiensanglard.net/rayTracing_back_of_business_card/ +[aek-home]: https://www.cs.utah.edu/~aek/ diff --git a/card.cc b/card.cc new file mode 100644 index 0000000..38417e4 --- /dev/null +++ b/card.cc @@ -0,0 +1,35 @@ +#include // card > aek.ppm +#include +#include +typedef int i;typedef float f;struct v{ +f x,y,z;v operator+(v r){return v(x+r.x +,y+r.y,z+r.z);}v operator*(f r){return +v(x*r,y*r,z*r);}f operator%(v r){return +x*r.x+y*r.y+z*r.z;}v(){}v operator^(v r +){return v(y*r.z-z*r.y,z*r.x-x*r.z,x*r. +y-y*r.x);}v(f a,f b,f c){x=a;y=b;z=c;}v +operator!(){return*this*(1/sqrt(*this%* +this));}};i G[]={247570,280596,280600, +249748,18578,18577,231184,16,16};f R(){ +return(f)rand()/RAND_MAX;}i T(v o,v d,f +&t,v&n){t=1e9;i m=0;f p=-o.z/d.z;if(.01 +0 +){f s=-b-sqrt(q);if(s.01)t=s,n=!( +p+d*t),m=2;}}return m;}v S(v o,v d){f t +;v n;i m=T(o,d,t,n);if(!m)return v(.7, +.6,1)*pow(1-d.z,4);v h=o+d*t,l=!(v(9+R( +),9+R(),16)+h*-1),r=d+n*(n%d*-2);f b=l% +n;if(b<0||T(h,l,t,n))b=0;f p=pow(l%r*(b +>0),99);if(m&1){h=h*.2;return((i)(ceil( +h.x)+ceil(h.y))&1?v(3,1,1):v(3,3,3))*(b +*.2+.1);}return v(p,p,p)+S(h,r)*.5;}i +main(){printf("P6 512 512 255 ");v g=!v +(-6,-16,0),a=!(v(0,0,1)^g)*.002,b=!(g^a +)*.002,c=(a+b)*-256+g;for(i y=512;y--;) +for(i x=512;x--;){v p(13,13,13);for(i r +=64;r--;){v t=a*(R()-.5)*99+b*(R()-.5)* +99;p=S(v(17,16,8)+t,!(t*-1+(a*(R()+x)+b +*(y+R())+c)*16))*3.5+p;}printf("%c%c%c" +,(i)p.x,(i)p.y,(i)p.z);}} diff --git a/card_explained.cc b/card_explained.cc new file mode 100644 index 0000000..9bc2ece --- /dev/null +++ b/card_explained.cc @@ -0,0 +1,231 @@ +// card_explained.cc +// +// A de-obfuscated, heavily-annotated rewrite of Andrew Kensler's "business +// card raytracer" (card.cc). The behaviour and visual output match the +// original; only the names, formatting and comments differ. The original +// file (card.cc) is kept untouched as a historical artifact — this file +// exists so a human (or a future you) can read what's going on. +// +// Build via the project's CMake setup, or standalone: +// c++ -O3 -o card_explained card_explained.cc +// Render: +// ./card_explained > aek.ppm +// +// Reference: https://fabiensanglard.net/rayTracing_back_of_business_card/ + +#include +#include +#include + +// --------------------------------------------------------------------------- +// 3D vector — doubles as an RGB colour throughout the renderer. +// --------------------------------------------------------------------------- +struct Vec3 { + float x, y, z; + + // Default-constructed Vec3 is *uninitialised*. Matches the original; + // every Vec3 created this way is assigned before it is read. + Vec3() {} + Vec3(float a, float b, float c) : x(a), y(b), z(c) {} + + Vec3 operator+(const Vec3& r) const { return Vec3(x + r.x, y + r.y, z + r.z); } + Vec3 operator*(float s) const { return Vec3(x * s, y * s, z * s); } + + float dot(const Vec3& r) const { return x * r.x + y * r.y + z * r.z; } + + Vec3 cross(const Vec3& r) const { + return Vec3(y * r.z - z * r.y, + z * r.x - x * r.z, + x * r.y - y * r.x); + } + + Vec3 normalised() const { + return *this * (1.0f / std::sqrt(this->dot(*this))); + } +}; + +// --------------------------------------------------------------------------- +// Scene: nine bitmap columns spelling "aek" as unit-radius spheres. +// +// Each int is one column (j ∈ [0,8]). Each set bit (k ∈ [0,18]) places a +// sphere of radius 1 at world position (k, 0, j + 4). +// --------------------------------------------------------------------------- +static const int kLetterColumns[9] = { + 247570, 280596, 280600, 249748, 18578, 18577, 231184, 16, 16, +}; + +// Uniform random float in [0, 1]. +static float randf() { return static_cast(std::rand()) / RAND_MAX; } + +enum HitKind { + HIT_SKY = 0, + HIT_FLOOR = 1, + HIT_SPHERE = 2, +}; + +// --------------------------------------------------------------------------- +// Cast a single ray against the scene. +// +// origin / direction — the ray (direction assumed normalised) +// out_t — written with the distance to the nearest hit +// out_normal — written with the surface normal at that hit +// +// Returns one of HitKind: which surface (if any) was hit. +// --------------------------------------------------------------------------- +static int trace(Vec3 origin, Vec3 direction, float& out_t, Vec3& out_normal) { + out_t = 1e9f; + int kind = HIT_SKY; + + // (a) Floor plane at z = 0. + // + // Solve origin.z + t * direction.z = 0 ⇒ t = -origin.z / direction.z. + // The 0.01 guard is the standard "shadow acne" epsilon: it prevents + // a ray from re-hitting the surface it just left. + float t_floor = -origin.z / direction.z; + if (t_floor > 0.01f) { + out_t = t_floor; + out_normal = Vec3(0, 0, 1); + kind = HIT_FLOOR; + } + + // (b) Spheres encoded in kLetterColumns. + for (int k = 18; k >= 0; --k) { + for (int j = 8; j >= 0; --j) { + if ((kLetterColumns[j] & (1 << k)) == 0) continue; + + // Vector from sphere centre to ray origin. + // centre = (k, 0, j + 4) → p = origin - centre + Vec3 p = origin + Vec3(-static_cast(k), + 0.0f, + -static_cast(j + 4)); + + // Ray-sphere intersection. Since direction is unit-length the + // quadratic's "a" coefficient is 1, so it disappears: + // t² + 2 b t + c = 0, with b = p·d, c = p·p - r² + float b = p.dot(direction); + float c = p.dot(p) - 1.0f; // r² = 1 + float discriminant = b * b - c; + + if (discriminant > 0.0f) { + float t_hit = -b - std::sqrt(discriminant); // nearer root + if (t_hit > 0.01f && t_hit < out_t) { + out_t = t_hit; + out_normal = (p + direction * t_hit).normalised(); + kind = HIT_SPHERE; + } + } + } + } + return kind; +} + +// --------------------------------------------------------------------------- +// Shade a ray: trace it, then compute a colour. Recurses on sphere hits +// (mirror reflection). Recursion bottoms out naturally when a reflected +// ray escapes to the sky. +// --------------------------------------------------------------------------- +static Vec3 shade(Vec3 origin, Vec3 direction) { + float t; + Vec3 normal; + int kind = trace(origin, direction, t, normal); + + // --- Sky -------------------------------------------------------------- + // Blue-purple gradient that gets darker looking up, brighter at the + // horizon. (1 - direction.z) is small when the ray points upward. + if (kind == HIT_SKY) { + return Vec3(0.7f, 0.6f, 1.0f) * std::pow(1.0f - direction.z, 4.0f); + } + + Vec3 hit_point = origin + direction * t; + + // Light position is jittered each call → averaging many samples gives + // soft shadows (penumbrae) without any explicit area-light maths. + Vec3 to_light = (Vec3(9.0f + randf(), 9.0f + randf(), 16.0f) + + hit_point * -1.0f).normalised(); + + // Reflection direction: R = D - 2 (N·D) N + // Written as D + N * (N·D * -2) to match the original. + Vec3 reflect_dir = direction + normal * (normal.dot(direction) * -2.0f); + + // Lambertian term, zeroed if the surface faces away from the light or + // if a shadow-ray reports occlusion. + float diffuse = to_light.dot(normal); + { + float shadow_t; Vec3 shadow_n; + if (diffuse < 0.0f || trace(hit_point, to_light, shadow_t, shadow_n)) { + diffuse = 0.0f; + } + } + + // Phong-style specular. Multiplying by (diffuse > 0) collapses the + // highlight to zero in shadow without an extra branch. + float specular = std::pow(to_light.dot(reflect_dir) * (diffuse > 0.0f ? 1.0f : 0.0f), + 99.0f); + + // --- Floor ------------------------------------------------------------ + if (kind == HIT_FLOOR) { + // hit_point * 0.2 makes each tile 5 world-units wide. + Vec3 tile_coords = hit_point * 0.2f; + bool red_tile = (static_cast(std::ceil(tile_coords.x) + + std::ceil(tile_coords.y)) & 1) != 0; + Vec3 base_colour = red_tile ? Vec3(3, 1, 1) : Vec3(3, 3, 3); + // 0.2 * diffuse + 0.1 ambient = "always at least dimly lit". + return base_colour * (diffuse * 0.2f + 0.1f); + } + + // --- Sphere (mirror) -------------------------------------------------- + // Specular highlight plus half the reflected scene. No explicit recursion + // limit: a reflected ray must eventually miss everything and hit the + // sky, which terminates immediately. + return Vec3(specular, specular, specular) + shade(hit_point, reflect_dir) * 0.5f; +} + +// --------------------------------------------------------------------------- +// Build the camera, shoot 64 rays per pixel, write a binary PPM to stdout. +// --------------------------------------------------------------------------- +int main() { + std::printf("P6 512 512 255 "); + + // Camera basis (orthonormal). The .002 scale on `right` and `up` is the + // pixel size in world units → it determines the field of view. + Vec3 forward = Vec3(-6.0f, -16.0f, 0.0f).normalised(); + Vec3 right = Vec3(0.0f, 0.0f, 1.0f).cross(forward).normalised() * 0.002f; + Vec3 up = forward.cross(right).normalised() * 0.002f; + + // Offset from the camera's optical axis to the top-left pixel. + Vec3 top_left = (right + up) * -256.0f + forward; + + for (int y = 512; y-- > 0; ) { + for (int x = 512; x-- > 0; ) { + // Pre-biased accumulator: 13 each channel adds a slight overall + // brightness so the truncation to uchar at the end lands in + // [0, 255] without an explicit divide-by-samples step. + Vec3 pixel(13.0f, 13.0f, 13.0f); + + for (int s = 64; s-- > 0; ) { + // Random offset on a 99-unit "lens" → depth of field. + Vec3 lens_offset = + right * ((randf() - 0.5f) * 99.0f) + + up * ((randf() - 0.5f) * 99.0f); + + // Sub-pixel jitter → anti-aliasing. + Vec3 pixel_direction = + (lens_offset * -1.0f + + (right * (randf() + x) + + up * (randf() + y) + + top_left) * 16.0f).normalised(); + + Vec3 ray_origin = Vec3(17.0f, 16.0f, 8.0f) + lens_offset; + pixel = shade(ray_origin, pixel_direction) * 3.5f + pixel; + } + + // Write one RGB triple. Values are truncated, not clamped — + // the constants above are tuned so this is safe. + std::printf("%c%c%c", + static_cast(pixel.x), + static_cast(pixel.y), + static_cast(pixel.z)); + } + } + return 0; +} diff --git a/docs/aek.png b/docs/aek.png new file mode 100644 index 0000000..7c83b1a Binary files /dev/null and b/docs/aek.png differ