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
231 lines
9.3 KiB
C++
231 lines
9.3 KiB
C++
// 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 <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<float>(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<float>(k),
|
|
0.0f,
|
|
-static_cast<float>(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<int>(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<int>(pixel.x),
|
|
static_cast<int>(pixel.y),
|
|
static_cast<int>(pixel.z));
|
|
}
|
|
}
|
|
return 0;
|
|
}
|