miniRT Β· handbook
🧭 explorer
42 common core Β· defense handbook

miniRT / the ray tracer, explained

A complete field guide to this codebase: how a .rt file becomes pixels, what every file does, the math behind every intersection, and the answers to the questions an evaluator will actually ask. For the clickable version of the code β€” call graph, guided trails, annotations β€” open the interactive explorer πŸ§­.

Never worked with vectors, angles or light before? Start with Level zero β€” the story of one pixel (no formulas until the program needs them), then Foundations β€” math from zero, which builds every formula in this project from nothing, with numbers you can check by hand.

00 Β· orientationBig picture

miniRT is a CPU ray tracer. For every pixel of an 800Γ—600 window it shoots a ray from the camera through that pixel into the scene, finds the nearest object the ray hits, and computes a color there from ambient light, diffuse (Lambert) light and shadows β€” plus specular highlights, multiple colored lights and a procedural checkerboard in the bonus. The image is drawn once into an MLX image buffer and re-rendered only when the camera moves.

scene.rt srcs/parserread line-by-line β†’ tokenized β†’ dispatched by identifier β†’ validated hard (any error: Error\n + exit 1)
β”‚β–Ό
t_scene includes/miniRT.hambient + camera + light list + object list (tagged unions)
β”‚β–Ό
render loop srcs/rendercamera basis once per frame Β· 4 anti-aliasing rays per pixel via get_ray(u,v)
β”‚β–Ό
intersection srcs/intersectnearest hit over the object list β†’ t_hit (point, ray-facing normal, material)
β”‚β–Ό
shading srcs/shadingambient + Ξ£ lights (diffuse [+ specular]) gated by shadow rays, clamped to [0,1]
β”‚β–Ό
pixels β†’ X11 window srcs/windowmlx_put_pixel writes the image buffer; one mlx_put_image_to_window per frame

The one data structure to know: t_scene

includes/miniRT.h:184

typedef struct s_scene {
    t_ambient  ambient;    /* ratio + color (exactly one "A") */
    t_camera   camera;     /* pos + dir + fov (exactly one "C") */
    t_light    *lights;    /* linked list: 1 in mandatory, N in bonus */
    t_object   *objects;   /* linked list of tagged unions */
    int        has_ambient, has_camera, has_light;
} t_scene;

t_object (miniRT.h:153) is a tagged union: an enum type + an anonymous union { sphere; plane; cylinder; cone; triangle; } + a t_material + next. One malloc per object, no nested allocations. Everything hangs off t_app (miniRT.h:208), a stack variable in main that owns the MLX state and the scene.

The two binaries & the linking trick

Volunteer this in defense make builds miniRT, make bonus builds miniRT_bonus. There is no #ifdef BONUS anywhere. Both binaries share COMMON_SRCS; the behavioral difference comes purely from linking variant files that export the same symbols.
SymbolMandatory fileBonus fileDifference
dispatchparser/dispatch.cparser/dispatch_bonus.cbonus adds co (cone) and tr (triangle)
check_countparser/parse_opts.cparser/parse_opts_bonus.cstrict != count vs < (allows optional material tokens)
parse_materialparser/parse_opts.c (no-op stub)parser/parse_opts_bonus.cparses per-object ks / shininess / … / checkerboard
parse_lightparser/parse_light.cparser/parse_light_bonus.csingle light (duplicate rejected) vs multi-light list
hit_objectintersect/hit_object.cintersect/hit_object_bonus.cbonus dispatches to cone/triangle too
shadeshading/shade.cshading/shade_bonus.cbonus adds specular, per-light color, checkerboard

So the shared object parser (parse_objects.c) behaves strictly in the mandatory binary and extensibly in the bonus binary just because a different check_count/parse_material got linked in. This is the most elegant design decision in the project.

Constants that matter

includes/miniRT.h:22-26

ConstantValueRole
EPSILON1e-6minimum accepted ray parameter t; rejects self-hits and grazing hits
WIDTH Γ— HEIGHT800 Γ— 600fixed window size
MAX_DEPTH8vestigial β€” ray_color takes a depth but never recurses (no reflection/refraction). Be honest about this if asked.
SHADOW_BIAS1e-4offset for shadow-ray origins (anti-acne, Β§07)

01 Β· orientationFile map

Every source file and what lives inside it. Files tagged BONUS are linked only into miniRT_bonus.

srcs/ entry point
main.carg check β†’ parse_scene β†’ mlx_setup β†’ render β†’ hook registration β†’ mlx_loop. Also home of error_exit (writes Error\n + message to stderr, exit 1).
srcs/math/ 4 files
vec3.cconstructors + add / sub / scale / negate β€” all by value, no allocation
vec3_bis.clen, norm (zero-length guard β†’ returns zero vector, not NaN), near_zero
vec3_ops.cdot, cross, reflect (bonus specular only), ray_at = O + tD
quad.cshared quadratic solver: quad_solve (discriminant), root1/root2, quad_nearest
srcs/parser/ 16 files
parse_texture_bonus.cBONUShome-made PGM (P5) loader for bump maps; material_token_count peels a trailing .pgm token (Β§B1)
parse_scene.centry: .rt check, open, hand-rolled read_line (1-byte reads, 1 MiB cap), line loop, final A/C/L presence check
dispatch.cidentifier β†’ parser table (A C L sp pl cy), # comment skip, unknown-identifier error
dispatch_bonus.cBONUSsame + co and tr
parse_ambient.cA ratio r,g,b β€” ratio ∈ [0,1], duplicate rejected
parse_camera.cC pos dir fov β€” FOV strictly (0,180), duplicate rejected
parse_light.csingle L; sets attenuation kc=1, kl=0, kq=0
parse_light_bonus.cBONUSmulti-light list append; keeps light color. ⚠ forgets kc/kl/kq β€” see Β§12
parse_objects.csp pl cy + new_object/append_object; diameter Γ· 2; cylinder cap centers precomputed
parse_objects_bonus.cBONUSco (apex/axis/radius/height) and tr (normal precomputed, degenerate rejected)
parse_opts.cmandatory check_count (strict) + parse_material no-op stub
parse_opts_bonus.cBONUSlenient count + real material tail parser (ks…checker_size)
material.cdefault_material: ka .1, kd .9, ks .3, shininess 32, checker off
parse_num.cft_strtod: sign + integer + fraction accumulation (no scientific notation)
parse_num2.cvalid_num strict grammar + next_component (exactly-3-values enforcer)
parse_utils.cparse_double/ratio/color/vec3/normal β€” range checks live here
tok_utils.csplit_line whitespace splitter, free_tokens, token_count
free_scene.cwalks and frees both linked lists (called from the close handler)
srcs/intersect/ 9 files
intersect.cintersect_scene: nearest-hit loop over the object list
sphere.csphere quadratic + set_face_normal (inside/outside flip)
plane.cdenominator test, two-sided normal from the sign of dot(dir, n)
cylinder.chit_body (tries both roots), cap_plane/hit_cap (disk tests), min-of-three combiner
cylinder_utils.cthe perpendicular-projection quadratic, height clamp, body normal, cap-hit fill
cone_bonus.cBONUScone body root selection + base cap
cone_utils_bonus.cBONUScone_quad (kΒ² slope terms), height/apex clamp, gradient normal
triangle_bonus.cBONUSMΓΆller–Trumbore with early barycentric exits
hit_object.c / _bonus.ctype dispatch β€” the entire mandatory/bonus split on this side
srcs/render/ 4 files
preview.chalf-resolution movement preview + edge-aware upsampling (Β§P1)
camera.corthonormal axes (up-hint trick), FOV β†’ viewport basis, get_ray
render.cpixel loops, 2Γ—2 supersampling, ray_color, sky-gradient background
translation.cget_cam_right + move_camera (WASD)
srcs/shading/ 10 files
bump_bonus.cBONUSsphere UV β†’ height gradient β†’ tangent-frame normal tilt (Β§B1)
shade.cmandatory combine: ambient + Ξ£ (shadowed ? 0 : diffuse), clamp
shade_bonus.cBONUS+ checker, + specular, contribution Γ— light color per light
ambient.cobject βŠ— (A_color Γ— ratio) β€” the fixed formula
diffuse.cLambert: max(0, NΒ·L) Γ— brightness Γ— attenuation
specular_bonus.cBONUSPhong: white Γ— ks Γ— brightness Γ— (RΒ·V)^shininess
shadow.cbiased shadow ray + strictly-between-point-and-light test
attenuation.c1 / (kc + klΒ·d + kqΒ·dΒ²), clamped ≀ 1 (neutral in mandatory)
checker_bonus.cBONUS3D solid checker: floor-cell parity β†’ color inversion; material-copy pointer trick
color.c[0,1] color ops: scale, add, multiply, clamp, lerp
srcs/window/ 3 files
mlx_init.cmlx_setup: init β†’ window β†’ image β†’ data addr, each NULL-checked; starts locked
mlx_hooks.cmouse (scroll = FOV zoom), keys (ESC/SPACE/WASD), close (full teardown), expose (re-blit)
mlx_utils.cmlx_put_pixel: ARGB packing + direct buffer write
everything else support
includes/miniRT.hall structs, constants, prototypes (mandatory)
includes/miniRT_bonus.hbonus-only prototypes; includes miniRT.h
libft/own libc subset (split, strlen, memset, isspace…) β€” vendored, own Makefile
mlx_linux/vendored MiniLibX for X11 β€” the allowed graphics library
scenes/mandatory demo scenes; scenes/bonus/ for the bonus binary (Β§10)
Makefiletwo targets sharing COMMON_SRCS; builds mlx + libft first; -Wall -Wextra -Werror

02 Β· orientationLife of a ray

This is the story to tell an evaluator who says β€œexplain how your program works.”

  1. main srcs/main.c:15 β€” checks argc == 2, calls parse_scene, mlx_setup, render, registers the hooks (mouse; KeyPress=2; DestroyNotify=17; Expose=12) and enters mlx_loop.
  2. Parse (Β§03) β€” the .rt file is read line by line, tokenized on whitespace, dispatched by identifier (A C L sp pl cy [+ co tr]), each element validated hard. Any error β†’ Error\n + message on stderr, exit 1. Result: a fully populated t_scene.
  3. Render (Β§06) β€” build_camera_basis converts pos/dir/FOV into a viewport 1 unit in front of the camera. For each pixel, 4 sub-pixel rays (2Γ—2 anti-aliasing) are generated by get_ray(u, v) and averaged.
  4. Intersect (Β§05) β€” ray_color β†’ intersect_scene walks the object list, calls the per-shape intersector via hit_object, keeps the hit with the smallest t. A t_hit carries the point, a unit normal that always faces the ray, and a pointer to the object's material.
  5. Shade (Β§07/Β§08) β€” shade = ambient + for each light: if not in_shadow, add diffuse (bonus: + specular, Γ— light color). Clamp to [0,1].
  6. Write pixel β€” mlx_put_pixel packs r,g,b (Γ—255.999) into an int and writes it directly into the image buffer at addr + yΒ·line_len + xΒ·(bpp/8).
  7. After the double loop, one mlx_put_image_to_window blits the frame. Keys/scroll mutate the camera and set a needs_render flag; a mlx_loop_hook re-runs steps 3–7 in 32-row slices, preview-first (Β§P1). ESC or the window cross destroy everything, free_scene, exit(0).

On a miss, the background is a vertical white→blue gradient render.c:19-29 — an aesthetic choice, not black; one line to change.

Z1 Β· level zeroOne pixel's question

The easiest way to understand this project is not to start with the formulas. Start with what the computer is trying to accomplish, and meet each piece of math only at the exact moment the program needs it. That's what this Level-zero part does β€” no formula appears before the program has a problem only that formula can solve.

Forget the whole program. The final job is: fill an 800 Γ— 600 rectangle of pixels with colors. So think about one single pixel. Imagine standing in a room, looking through a window β€” except the window is made of pixels. For every pixel, miniRT imagines a straight line going eye β†’ through this pixel β†’ into the 3D world, and asks:

The question β€œWhat should I see through this tiny spot of the screen?”

Answering it is a fixed ladder of smaller questions:

pixel
  ↓
make a ray through that pixel
  ↓
does the ray hit something? ──── NO β†’ background color
  ↓ YES
which object? (the nearest one)
  ↓
where exactly?
  ↓
which way is the surface facing there?
  ↓
is light reaching that point?
  ↓
how bright, what color?
  ↓
write RGB into the pixel

That's basically the whole project. The real code follows this ladder almost line for line: main parses and creates the window; render generates rays; intersect_scene finds the nearest hit; shade computes the lighting; mlx_put_pixel writes the RGB. Repeat 480 000 times.

What the .rt file actually is

A scene file like this is not the picture:

A 0.2  255,255,255          ← "there is a bit of light everywhere"
C 0,0,0  0,0,1  70          ← "the eye stands here, looks this way"
L 5,5,0  0.8  255,255,255   ← "a lamp hangs here"
sp 0,0,5  4  255,0,0        ← "a red ball floats there"

It is a description of a world. The parser's whole job is to transform that text into C structures β€” scene.camera, scene.lights, scene.objects β€” by reading each line, splitting it into words, and looking at the first word (read_line β†’ split_line β†’ dispatch). Note what this means: no geometry happens during parsing. We're just loading data. All the interesting questions start after.

Z2 Β· level zeroA tiny 2D world

Before any 3D, let's answer one real question the renderer faces, in a world simple enough to draw on graph paper. Two dimensions, everything on a grid:

y
5                    πŸ’‘ light (5,5)
4
3
2        X ← the ray hit something here (2,2)
1
0    πŸ‘ camera
     0   1   2   3   4   5     x

The program needs to know: from X, which way is the lamp? You can answer without knowing anything about "vectors": starting at (2,2), to reach (5,5) you must move 3 right and 3 up, because 5βˆ’2 = 3 and 5βˆ’2 = 3. So:

light βˆ’ hit  =  (5,5) βˆ’ (2,2)  =  (3,3)  =  β€œgo 3 right, 3 up” That is ALL vector subtraction means. It's why the code is full of vec3_sub(light->pos, hit->point) β€” β€œgive me the arrow from the hit to the light.”

Now the same move the program actually cares about β€” three positions of the lamp, same surface:

  • Lamp straight above the surface β†’ the surface catches it fully β†’ bright.
  • Lamp off to the side β†’ the light grazes along the surface β†’ dim.
  • Lamp behind the surface β†’ nothing arrives β†’ dark (not "negative brightness" β€” just zero).

Later, one number will express all three cases at once (the dot product, M2/M8). For now it's enough that the question is geometric: "how squarely does this surface face the lamp?"

3D just adds one number

Everything above works identically with a third coordinate: positions become (x, y, z), subtraction subtracts three numbers instead of two. That's the entire jump to 3D. But keep one warning from the start β€” the same t_vec3 struct is used for two different meanings:

position = WHERE?    direction = WHICH WAY? A sphere center is a position. A ray direction is a direction. Calling both "vectors" is what makes the code confusing at first β€” always ask which one you're looking at.

Z3 Β· level zeroOne pixel, all the way down

This is the showpiece: the whole program executed by hand, with real numbers, for one pixel. Every step names the function that does the same thing in code. The scene is the one from Z1:

A 0.2  255,255,255
C 0,0,0  0,0,1  70
L 5,5,0  0.8  255,255,255
sp 0,0,5  4  255,0,0        ← center (0,0,5), DIAMETER 4 β†’ radius 2, red

Step 0 β€” parse. The camera sits at the origin looking along +z; the red sphere floats 5 units ahead; a white lamp hangs up-right at (5,5,0); ambient is 20% white. Color 255,0,0 is stored as (1, 0, 0). parse_scene β†’ dispatch β†’ parse_sphere

Step 1 β€” the ray. Take the pixel in the exact middle of the screen. The viewport is centered on the view direction, so the middle pixel's ray is simply "straight ahead": origin O = (0,0,0), direction D = (0,0,1) β€” already unit length. build_camera_basis, get_ray

Step 2 β€” does it hit the sphere? Run the quadratic recipe (built from scratch in M6):

oc = O βˆ’ C = (0,0,βˆ’5)
a  = DΒ·D            = 1
b  = 2Β·(ocΒ·D)       = 2Β·(βˆ’5) = βˆ’10
c  = ocΒ·oc βˆ’ rΒ²     = 25 βˆ’ 4 = 21
disc = bΒ² βˆ’ 4ac     = 100 βˆ’ 84 = 16  β†’ positive: the ray crosses twice
t = (10 Β± 4) / 2    β†’ t₁ = 3 (entry),  tβ‚‚ = 7 (exit)

Keep the smallest positive t: t = 3. No other object exists, so the sphere wins the "nearest hit" contest by default. intersect_sphere, quad_solve, quad_nearest, intersect_scene

Step 3 β€” where exactly? Walk the ray: P = O + 3Β·D = (0,0,3). Sanity check: the sphere's near surface is at z = 5 βˆ’ 2 = 3. βœ“ ray_at

Step 4 β€” which way does the surface face? For a sphere, the outward direction at P is "away from the center": P βˆ’ C = (0,0,βˆ’2), normalized: N = (0,0,βˆ’1) β€” pointing back at the camera. Makes sense: we hit the front of the ball. set_face_normal

Step 5 β€” which way is the lamp? Subtraction, exactly like Z2:

to_light = L βˆ’ P = (5,5,0) βˆ’ (0,0,3) = (5, 5, βˆ’3)
length   = √(25 + 25 + 9) = √59 β‰ˆ 7.68
LΜ‚ = (5,5,βˆ’3)/7.68 β‰ˆ (0.651, 0.651, βˆ’0.390)

diffuse.c / shadow.c

Step 6 β€” is anything blocking it? Fire the shadow feeler from P (nudged off the surface by SHADOW_BIAS) toward the lamp. The scene has one object β€” the sphere we're standing on β€” and the feeler leaves it immediately; nothing sits within the 7.68 units to the lamp. Lit. in_shadow β†’ intersect_scene

Step 7 β€” how bright? "How squarely does the surface face the lamp?" β€” the dot product delivers the cosine:

dot(N, LΜ‚) = 0Β·0.651 + 0Β·0.651 + (βˆ’1)Β·(βˆ’0.390) = 0.390
diffuse   = 0.390 Γ— brightness 0.8 = 0.312          (about β…“ strength β€” the lamp
                                                     is well off to the side)

diffuse_light

Step 8 β€” assemble the color. Every term filters through the object's color (1,0,0):

ambient = (1,0,0) βŠ— (white Γ— 0.2)  = (0.200, 0, 0)
diffuse = (1,0,0) Γ— 0.312          = (0.312, 0, 0)
total   =                            (0.512, 0, 0)   ← already within [0,1], clamp changes nothing

shade, ambient_light, color_clamp

Step 9 β€” become memory. 0.512 Γ— 255.999 β‰ˆ 131, so the packed pixel is RGB(131, 0, 0) β€” a dark-ish red, written at addr + yΒ·line_len + xΒ·(bpp/8). One integer in memory. One pixel of the image. mlx_put_pixel

Own it Redo steps 5–8 with the lamp moved to (0,0,0) β€” straight between camera and ball. to_light = (0,0,βˆ’3), LΜ‚ = (0,0,βˆ’1), dot(N,LΜ‚) = 1 β†’ full diffuse 0.8 β†’ total (1.0, 0, 0) β†’ RGB(255,0,0). The front of the ball glows fully red. If you can do that unaided, you understand the mandatory part's entire render path.

Z4 Β· level zeroFour rays per pixel, and why WASD re-renders everything

The staircase problem

Screens are made of square pixels; the world has curved and diagonal edges. If each pixel asks its question only once β€” through its center β€” every pixel is answered "entirely sphere" or "entirely sky", and a diagonal edge becomes a staircase (that artifact is called aliasing):

real edge:        what square pixels make of it:
      /                  β–ˆβ–ˆ
     /                 β–ˆβ–ˆ
    /                β–ˆβ–ˆ
   /               β–ˆβ–ˆ

The fix in this project: ask four times per pixel, at four spots inside the pixel's little square (a 2Γ—2 grid β€” "supersampling"), and average the four answers. A pixel straddling the sphere's edge might get two answers "red sphere" and two "sky blue" β€” its final color is the 50/50 blend, and the staircase melts into a smooth ramp. Important: the screen is still 800 Γ— 600; the four samples are questions, not pixels (render_pixel, render.c:58-76).

800 Γ— 600 pixels Γ— 4 samples = 1 920 000 rays per frame This is why ray tracing gets expensive fast β€” and why the next paragraph matters.

Pressing W

Every ray starts at the camera. Press W and the code does, almost literally in English:

new position = position + MOVE_SPEED Γ— looking-direction camera.pos = vec3_add(camera.pos, vec3_scale(camera.dir, MOVE_SPEED)) β€” POSITION + MOVEMENT = NEW POSITION. Scaling makes "a movement of exactly one step that way"; addition applies it. (move_camera, translation.c)

Two things follow. First, the invisible viewport isn't an object stored in the world β€” it is calculated from the camera, so it moves with it and must be rebuilt (build_camera_basis). Second, every one of the 480 000 pixels now shoots from a different place β€” the old image cannot be "shifted a few pixels"; the whole frame must be recomputed. That cost used to freeze the window on every keypress β€” Β§P1 tells the story of how it was hidden (a cheap preview while moving, sliced rendering so input never waits, and a full-quality pass once you stop).

Strafing with A/D needs a "right" direction the camera never stored β€” so the code manufactures one with the cross product (M3) and does the same position-plus-movement. And note WASD only translates: position changes, the looking direction doesn't. (Rotation would be the opposite β€” same position, new direction.)

Z5 Β· level zeroThe vocabulary β€” what each piece of math is FOR

Memorize these jobs, not the formulas. Every formula in the project belongs to one of these one-line answers:

ToolThe geometric question it answers
(x, y, z)either "where?" or "which way?" β€” always know which
subtraction A βˆ’ B"give me the arrow from B to A"
length"how long is this arrow?"
normalize"keep the direction, make the length exactly 1"
dot product"how much do these two directions agree?"
cross product"give me a direction perpendicular to these two"
P = O + tΒ·D"start at O, travel t along D"
smallest positive t"the closest visible surface"
quadratic equation"a line can cross a curved surface twice β€” where?"
discriminant"miss, graze, or two crossings?"
tan(fov/2)"how wide is the camera's imaginary screen?"
dot(N, L)"how squarely does this surface face the lamp?"
a second ray toward the light"can this point see the lamp?"
EPSILON / SHADOW_BIAS"don't let a surface collide with itself over rounding dust"
The most important conceptual change When you open the code and meet vec3_dot(…), vec3_norm(…), quad_solve(…), tan(…) β€” don't ask "what mathematical operation is this?" Ask first: "what real geometric question is the program answering here?" Then find it in the table above. The math turns from a wall of symbols into parts of one machine.

What you're really learning (and the order to learn it)

Everything in miniRT belongs to four families that compose: A β€” C architecture (parser, structs, lists, MLX, events); B β€” basic 3D math (vectors, length, normalize, dot, cross, tan); C β€” geometry (rays, t, shape equations, normals, closest hit); D β€” graphics (camera, lighting, shadows, RGB, anti-aliasing, image buffer). None is the result by itself; the dependency chain runs straight through them:

coordinates β†’ positions & directions β†’ vector ops β†’ length + normalize
    β†’ camera/viewport β†’ pixel β†’ ray β†’ O + tΒ·D β†’ shape equations β†’ t
    β†’ closest t β†’ hit point β†’ normal β†’ to-light β†’ dot β†’ brightness
    β†’ shadow ray β†’ RGB β†’ 4-sample average β†’ MLX pixel β†’ IMAGE

That chain is the recommended learning order. In this handbook it maps to: this Level-zero part β†’ Foundations M1–M9 (each tool in depth, with hand-checkable numbers) β†’ the subsystem chapters Β§03–§09 (the real code) β†’ Β§13 (the Q&A drill). Leave the bonus shapes (cone, MΓΆller–Trumbore) and specular for last β€” until sphere + plane + camera + diffuse feel obvious, they only add noise.

M1 Β· foundationsVectors from zero

Everything in this project happens in a 3D world. To describe that world we only need one tool: three numbers written together, like (2, 1, 5). That triple is called a vector, and in the code it is the struct t_vec3 with fields x, y, z. The trick is that the same triple is used for two different jobs, and you must always know which job it is doing:

  • A position ("where"): (2, 1, 5) can mean the point that is 2 steps right, 1 step up, 5 steps forward from the origin. The sphere's center, the camera's position, a hit point β€” these are positions.
  • A direction ("which way, how fast"): the same (2, 1, 5) can mean an arrow pointing that way. A ray's direction, a surface normal, "toward the light" β€” these are directions. A direction has no home; only orientation and length.

The four basic operations

All vector math in srcs/math/vec3.c is done component by component β€” there is nothing more to it:

OperationComputed asWhat it means with arrows
vec3_add(a, b)(a.x+b.x, a.y+b.y, a.z+b.z)walk along arrow a, then along arrow b β€” result is the total journey
vec3_sub(a, b)(a.xβˆ’b.x, …)the arrow FROM b TO a. This one matters everywhere: light_pos βˆ’ hit_point = "the direction from the hit point toward the light"
vec3_scale(v, t)(v.xΒ·t, v.yΒ·t, v.zΒ·t)same direction, t times longer (t=2 doubles it, t=0.5 halves it)
vec3_negate(v)(βˆ’v.x, βˆ’v.y, βˆ’v.z)the same arrow flipped 180Β° β€” used to flip normals to face the camera

Length, and why we "normalize"

How long is the arrow (3, 4, 0)? In 2D you'd use the Pythagorean theorem (aΒ² + bΒ² = cΒ² for right triangles). It works the same in 3D β€” just add the third square:

len(v) = √(xΒ² + yΒ² + zΒ²) (3, 4, 0) β†’ √(9 + 16 + 0) = √25 = 5. That's vec3_len, vec3_bis.c:15.

A vector whose length is exactly 1 is called a unit vector. Normalizing means rescaling a vector to length 1 without changing its direction: divide every component by the length. (3, 4, 0) normalized is (0.6, 0.8, 0) β€” same direction, length 1. That's vec3_norm vec3_bis.c:20.

Why bother? Two reasons, and they explain half the code: (1) if a ray's direction has length 1, then "travel t along the ray" moves you exactly t units of real distance β€” so t values become comparable distances (which hit is closer? is the blocker nearer than the light?); (2) if two vectors both have length 1, their dot product (next section) is directly the cosine of the angle between them β€” no correction factors. This is why the code normalizes ray directions, normals, and to-light vectors everywhere.
Check yourself Normalize (0, 0, 7). Length = √49 = 7, so the answer is (0, 0, 1). Normalize (1, 1, 0): length = √2 β‰ˆ 1.414 β†’ (0.707, 0.707, 0). Note the components of a unit vector are never bigger than 1 β€” that's why the parser rejects orientation components outside [βˆ’1, 1].

M2 Β· foundationsThe dot product β€” the single most used tool

The dot product takes two vectors and produces one plain number. Computing it is trivial (vec3_dot, vec3_ops.c:16):

aΒ·b = a.xΒ·b.x + a.yΒ·b.y + a.zΒ·b.z

The magic is what that number means. There is a second, equivalent definition:

aΒ·b = len(a) Β· len(b) Β· cos(ΞΈ) ΞΈ is the angle between the two arrows. If both are unit vectors, the lengths are 1 and the dot product IS cos(ΞΈ).

So the dot product answers the question: "how much do these two arrows agree in direction?" You don't need to compute any angle β€” three multiplications and two additions give you the cosine for free. Everything is in the sign and size:

Check yourself (0,1,0)Β·(0,1,0) = 1 β†’ same direction, cos 0Β° = 1. (0,1,0)Β·(1,0,0) = 0 β†’ perpendicular. (0,1,0)Β·(0,βˆ’1,0) = βˆ’1 β†’ exactly opposite. (0,1,0)Β·(0.707,0.707,0) = 0.707 β†’ 45Β° apart.

Every place this project uses it

Question the code asksHow the dot product answers it
How bright is this surface point? diffuse.c:20dot(N, L) β€” N is the surface normal, L points at the light. Facing the light β†’ 1 (full brightness), sideways β†’ 0, facing away β†’ negative (clamped to 0). This is the whole diffuse lighting model.
Did the ray hit the surface from inside or outside? sphere.c:17dot(ray.dir, outward_normal) < 0 β†’ ray and normal point against each other β†’ we see the outside.
Is the ray parallel to this plane? plane.c:21dot(dir, n) β‰ˆ 0 β†’ direction perpendicular to the normal β†’ gliding along the plane, never crossing it.
How far along the cylinder's axis is this point? cylinder_utils.cdot(P βˆ’ C, axis) β€” the projection: the "shadow" that the arrow Pβˆ’C casts onto the axis. Used to clamp hits between the caps.
All the quadratic coefficients (M6)dot products of the ray direction and origin offset with themselves and each other.
Projection β€” one more picture If A is a unit vector, then dot(v, A) is how far v extends in the A direction β€” imagine the sun directly overhead and A lying on the ground: it's the length of v's shadow on A's line. Negative means "behind". This one idea unlocks the whole cylinder and cone code (M7).

M3 Β· foundationsThe cross product β€” making perpendiculars

The cross product a Γ— b takes two vectors and produces a new vector (not a number) with two famous properties (vec3_cross, vec3_ops.c:22):

  • It is perpendicular to both a and b β€” it sticks straight out of the plane the two arrows lie in. Which of the two possible "straight out" sides? The right-hand rule: point your right hand's fingers along a, curl them toward b β€” your thumb shows a Γ— b. (Swap the order and the result flips sign.)
  • Its length equals the area of the parallelogram the two arrows span. If a and b are parallel, that area is 0 β€” the cross product is the zero vector.

Where the project needs perpendiculars

  • The camera basis camera.c:21-31: given only "the camera looks this way" (dir), we need a "right" and an "up" to span the screen. right = norm(cross(dir, world_up)) β€” guaranteed perpendicular to the view direction. Then up = cross(right, dir) makes the third perpendicular. Edge case: if dir is straight up, cross(dir, world_up) is between parallel vectors = zero vector = can't normalize β†’ the code switches to a different helper vector first. Now that guard makes sense.
  • The triangle's normal parse_objects_bonus.c:52: two triangle edges lie in the triangle's plane, so cross(v1βˆ’v0, v2βˆ’v0) sticks straight out of the triangle β€” that IS the normal. And if the three points are on one line (a degenerate "triangle"), the edges are parallel, the cross product is ~zero, and the parser rejects it with vec3_near_zero.
  • MΓΆller–Trumbore (the ray/triangle test, M7) is built from cross and dot products chained together.

M4 Β· foundationsAngles: cos and tan, the only two we need

This project uses exactly two ideas from trigonometry, both defined on a right triangle (a triangle with one 90Β° corner). Name the sides relative to the angle ΞΈ you're looking at: the hypotenuse (the long slanted side), the adjacent side (touching ΞΈ) and the opposite side (across from ΞΈ). Then:

cos(ΞΈ) = adjacent / hypotenuse    tan(ΞΈ) = opposite / adjacent cos: "how much of the slanted length survives when flattened onto the adjacent direction" β€” 1 at 0Β°, 0 at 90Β°. tan: "rise per unit of run" β€” a slope.

Where cos appears: brightness

We never call cos() in the code β€” the dot product of unit vectors (M2) is the cosine. Lambert's law (M8) says brightness ∝ cos of the angle between the surface normal and the light direction, and dot(N, L) delivers exactly that.

Where tan appears: the field of view

The camera (M9) imagines a screen floating exactly 1 unit in front of the eye. The FOV (field of view) is the angle of the wedge you can see. Split that wedge in half and you get a right triangle: adjacent = 1 (distance to the screen), opposite = half the screen's width. So:

half_width = tan(fov / 2) camera.c:42. Bigger angle β†’ wider screen β†’ more world squeezed into the same 800 pixels β†’ zoomed out.
Degrees vs radians Humans measure angles in degrees (a full turn = 360Β°); the C math library expects radians (a full turn = 2Ο€ β‰ˆ 6.28, i.e. the distance you'd walk around a circle of radius 1). Conversion: multiply by Ο€/180. That's the mysterious fov * M_PI / 180.0 in camera.c β€” nothing deeper than a unit change, like cm β†’ inch.
Check yourself FOV 90Β°: half of it is 45Β°, tan(45Β°) = 1 β†’ the screen is 2 units wide at distance 1. FOV 60Β° β†’ tan(30Β°) β‰ˆ 0.577 β†’ screen β‰ˆ 1.15 wide (more telephoto). FOV 170Β° β†’ tan(85Β°) β‰ˆ 11.4 β†’ enormous screen (fisheye distortion).

M5 Β· foundationsRays & the number t

A ray is just "a starting point plus a direction": think of a laser pointer. In code it's two vectors β€” origin (a position) and dir (a unit direction). Every point the laser beam passes through can be written with a single number t, "how far along the beam":

P(t) = origin + t Β· dir That's ray_at (vec3_ops.c:37): start at the origin, walk t units in the direction. t = 0 is the origin itself; because dir has length 1, t is measured in real world units.

Three consequences run the whole ray tracer:

  • Negative t is behind the laser pointer. If the math says a surface is at t = βˆ’4, the object is behind the camera β€” not visible, rejected.
  • Smaller positive t = closer. When a ray crosses several objects, the one with the smallest t hides the others. That's the entire job of intersect_scene: try everything, keep the minimum t.
  • t is a distance, so it can be compared with other distances β€” e.g. "is the blocking object nearer than the light?" in the shadow test (M8).
Why "t β‰₯ EPSILON" instead of "t β‰₯ 0" Computers store decimals imperfectly (0.1 + 0.2 gives 0.30000000000000004). When a new ray starts exactly on a surface (a shadow feeler, for example), rounding errors can make the math report "you hit that same surface at t = 0.0000000000000002". Accepting that fake hit makes surfaces shadow themselves β€” a speckled mess called shadow acne. The fix: ignore any hit closer than a tiny threshold, EPSILON = 0.000001 (miniRT.h:22). It's a dead zone right at the ray's start.

M6 Β· foundationsFinding the hit point β€” geometry becomes algebra

Here is the core trick of all ray tracing, in three steps. It never varies:

  1. Write the shape as a membership test β€” an equation that is true exactly for points on the surface.
  2. Replace the point in that equation with the ray formula origin + tΒ·dir.
  3. What remains is an ordinary equation with one unknown, t. Solve for t. Each solution is a spot where the ray touches the surface.

Worked example: the sphere, with real numbers

Membership test: a point P is on a sphere (center C, radius r) exactly when its distance to the center is r. Distances involve square roots, which are ugly β€” so square both sides (the classic trick, used everywhere in this code):

|P βˆ’ C|Β² = rΒ² |v|Β² (length squared) is just dot(v, v) β€” Pythagoras without the square root.

Substitute the ray P = O + tΒ·D, write oc = O βˆ’ C (arrow from center to camera), expand, and group by powers of t:

(DΒ·D)Β·tΒ² + 2(ocΒ·D)Β·t + (ocΒ·oc βˆ’ rΒ²) = 0

Stare at it: DΒ·D, ocΒ·D, ocΒ·oc are all known numbers (dot products of known vectors). So this is the school equation aΒ·tΒ² + bΒ·t + c = 0 β€” a quadratic β€” with a = DΒ·D, b = 2(ocΒ·D), c = ocΒ·oc βˆ’ rΒ². Exactly the three lines at sphere.c:41-43. And the school recipe for solving it:

t = (βˆ’b Β± √(bΒ² βˆ’ 4ac)) / 2a The part under the square root, disc = bΒ² βˆ’ 4ac, is called the discriminant.

Why a quadratic and not something simpler? Because a straight line can cross a sphere at most twice β€” in one side, out the other. Two possible crossings β‡’ an equation with up to two solutions β‡’ degree two. The discriminant just counts them:

Do it by hand once β€” it demystifies everything Camera at O = (0,0,0) looking along D = (0,0,1). Sphere at C = (0,0,5), radius 2.
oc = O βˆ’ C = (0,0,βˆ’5).
a = DΒ·D = 1.   b = 2(ocΒ·D) = 2Β·(βˆ’5) = βˆ’10.   c = ocΒ·oc βˆ’ rΒ² = 25 βˆ’ 4 = 21.
disc = bΒ² βˆ’ 4ac = 100 βˆ’ 84 = 16 β†’ √disc = 4 β†’ two hits.
t₁ = (10 βˆ’ 4)/2 = 3 and tβ‚‚ = (10 + 4)/2 = 7.
Sanity check without any algebra: the sphere's near surface is at z = 5 βˆ’ 2 = 3 and the far surface at z = 5 + 2 = 7. The math found exactly them. βœ“

The two roots have names in this codebase: quad_root1 (the βˆ’βˆš one, the entry, always the smaller t) and quad_root2 (the +√ one, the exit). quad_nearest (quad.c:46) tries the entry first; if it's behind the camera or inside the EPSILON dead zone, it falls back to the exit β€” which is exactly what you want when the camera sits inside the sphere: the entry is behind your head, the exit is the wall you're looking at.

The plane β€” same recipe, easier equation

Membership test: a plane is "all points level with p0, as judged by the normal n". A point P is on it when the arrow from p0 to P is perpendicular to n β€” and perpendicular means dot product zero (M2):

(P βˆ’ p0) Β· n = 0

Substitute P = O + tΒ·D and solve β€” t appears only to the first power (a line crosses a flat plane at most once, so no quadratic needed):

t = ((p0 βˆ’ O) Β· n) / (D Β· n) plane.c:24. If DΒ·n = 0 the ray runs parallel to the plane β€” dividing by ~0 would explode, so the code returns "miss" first (plane.c:22).
By hand Floor: p0 = (0,βˆ’3,0), n = (0,1,0) (pointing up). Camera at the origin, ray sloping down-forward D = (0,βˆ’0.6,0.8).
DΒ·n = βˆ’0.6 (not 0, good).   (p0 βˆ’ O)Β·n = (0,βˆ’3,0)Β·(0,1,0) = βˆ’3.
t = βˆ’3 / βˆ’0.6 = 5 β†’ hit point = O + 5Β·D = (0,βˆ’3,4). Its y is βˆ’3: exactly on the floor. βœ“
The mental model to keep Sphere, plane, cylinder, cone β€” the intersection code all reads the same way: build a few dot products (the equation's coefficients) β†’ solve for t β†’ reject solutions that are behind the ray or out of bounds β†’ the survivor is the hit distance. Once you see that pattern, intersect/ stops being scary.

M7 Β· foundationsThe harder shapes, gently

Cylinder: split every arrow into "along" and "across"

What makes a point P belong to an (infinite) cylinder around an axis? Not its distance to the center β€” its distance to the axis line must equal the radius. To measure that, take the arrow v = P βˆ’ C and split it into two pieces with the projection idea from M2:

along = (v Β· A) Β· A    across = v βˆ’ along A is the axis direction (unit). "along" is v's shadow on the axis; "across" is what's left β€” it points from the axis straight out to P.

The code does this split before solving (cylinder_utils.c:23-26): it removes the along-axis part from both the ray direction and the origin offset. What remains is a circle problem in the flat plane perpendicular to the axis β€” and circles give the same quadratic as the sphere. Then the finite height is a separate, trivial check: the hit's along-axis shadow (Pβˆ’C)Β·A must sit between βˆ’h/2 and +h/2. The two flat caps are just the plane recipe (M6) plus "is the hit within r of the cap's center". Body, top cap, bottom cap are tested independently; the smallest t wins.

The one real subtlety A root can be valid for the infinite tube but land above or below the finite cylinder. So the code can't blindly take the nearest root: it checks the height clamp on the entry root, and if that fails tries the exit root (cylinder.c:18-37). Camera inside the tube works for free: the entry is behind you, the exit is the wall you see.

Cone: a cylinder whose radius grows

Same split, one change: the allowed "across" distance isn't a constant r anymore β€” it grows as you move away from the apex. At distance d along the axis the cone is exactly kΒ·d wide, where k = radius/height (the slope of the slanted wall, a tan β€” M4!). So the membership test is |across|Β² = kΒ²Β·dΒ², and expanding it gives a quadratic whose coefficients carry the extra βˆ’kΒ²(...) terms (cone_utils_bonus.c:33-35).

The double-cone trap Algebra doesn't know the cone stops at its tip: the equation also describes a mirror cone opening the other way above the apex (think of an hourglass). The check 0 ≀ d ≀ height throws away hits on the phantom half. Classic evaluator question.

Triangle: weights instead of distances

A triangle needs a different idea. Any point in the plane of a triangle can be described as a weighted mix of its three corners β€” like mixing three paints. Call the weights wβ‚€, w₁, wβ‚‚ with wβ‚€ + w₁ + wβ‚‚ = 1. The point is inside the triangle exactly when no weight is negative β€” you're not "over-pulling" past any edge. These weights are called barycentric coordinates.

The code (triangle_bonus.c) solves for two of the weights, calls them u and v (the third is 1 βˆ’ u βˆ’ v), together with t, using Cramer's rule β€” a mechanical recipe for small equation systems built from the cross/dot products of M2–M3. It bails out early the moment a weight leaves [0, 1]: no point computing more if you're already outside. The algorithm has a name worth saying in the defense: MΓΆller–Trumbore. Note there's no quadratic here β€” a line crosses a flat triangle at most once.

M8 Β· foundationsLight from zero

Real light leaves lamps, bounces around, and some of it ends in your eye. Simulating that forward is hopeless β€” almost all photons never reach you. So ray tracing runs the movie backwards: from the eye, through each pixel, out into the world β€” "what would a photon arriving along this line have last touched, and how much light was it carrying?" That's why our rays start at the camera.

When a ray lands on a surface, the code adds up three ingredients (shade.c / shade_bonus.c). Each answers one visual question:

1 Β· Diffuse β€” why surfaces facing the light are brighter

Hold a sheet of paper facing the sun: it catches a lot of light. Tilt it: the same beam now smears across a larger area, so each square centimeter gets less. Tilt it 90Β°: the beam slides along it, delivering nothing. The received energy per area follows the cosine of the tilt angle β€” that's Lambert's law, and it's the entire formula:

In code (diffuse.c): N is the surface normal (unit arrow sticking out of the surface β€” every intersector computes it), L = normalize(light_pos βˆ’ hit_point) points at the light, and brightness is max(0, dot(N, L)) times the light's strength. The max(0, Β·) handles surfaces facing away β€” negative cosine just means "the light is behind me", i.e. zero, not negative light.

2 Β· Shadows β€” blocked line of sight

A point is in shadow when something stands between it and the lamp. The test is charmingly literal (shadow.c): fire a second, invisible ray from the hit point toward the light β€” a "feeler". If it hits anything closer than the light itself, the light's contribution is skipped. Two refinements you can now fully explain: the feeler starts a hair off the surface (SHADOW_BIAS) so it doesn't collide with its own starting surface (M5's dead-zone problem), and "closer than the light" is a plain comparison of t against the light's distance β€” meaningful only because directions are normalized (M1).

3 Β· Ambient β€” the cheat that keeps shadows from being black holes

In the real world, light bounces off walls and floors and fills shadows with soft indirect light. Simulating bounces is expensive, so the subject prescribes a flat fake: add a small constant illumination to everything, unconditionally. That's the A line of the scene: a ratio (how strong) and a color. One multiplication, done (ambient.c).

Color: why multiply?

A color here is three numbers in [0, 1] β€” how much red, green, blue. Think of an object's color as a filter: a red ball is red because it reflects most red light and swallows most green and blue. "Reflects a fraction" is multiplication, channel by channel:

seen = object_color Γ— incoming_light  (each channel separately) Red light (1,0,0) on a green ball (0,1,0): (1Β·0, 0Β·1, 0Β·0) = (0,0,0) β€” black. Physically correct and a favorite evaluator demo.

Since several lights and terms get added on top of each other, a channel can exceed 1.0; before painting the pixel it's clamped back to [0,1], because the screen can't display "more than fully bright" (color.c, and 1.2Γ—255 = 306 would corrupt the packed pixel bytes).

Bonus: specular β€” the shiny glint

Glossy surfaces also reflect the lamp itself as a bright spot. Where? Light bounces off a mirror at the same angle it came in β€” the bounce direction R is computed by vec3_reflect. The closer your viewing direction is to R, the more of that mirror bounce you catch: measured by β€” once again β€” a dot product, dot(R, V). Raising it to a power (pow(…, shininess)) sharpens the spot: a high exponent kills every value except those very close to 1, shrinking the highlight to a tight glint. The spot is white, not the object's color, because you're seeing the lamp, not the paint (specular_bonus.c).

M9 Β· foundationsFrom pixels to rays

Last piece: the screen is an 800 Γ— 600 grid of pixels; rays live in the 3D world. How does pixel (412, 187) become a ray? Picture a window floating exactly 1 unit in front of the eye, facing the way the camera looks. Each pixel owns one small rectangle of that window. The ray for a pixel is simply: start at the eye, aim through your rectangle's center.

Building that window from the scene file's three camera values (build_camera_basis, camera.c):

  1. How wide is the window? The FOV triangle from M4: half-width = tan(fov/2). Height follows from the screen shape: half-height = half-width Γ— 600/800, so pixels stay square and spheres stay round.
  2. Which way is "right" and "up" on the window? The cross-product construction from M3: right = cross(dir, world_up), up = cross(right, dir) β€” two arrows perpendicular to the view direction.
  3. Where is a pixel in 3D? Convert pixel (px, py) to fractions u = px/800, v = 1 βˆ’ py/600 (both in [0,1]), then walk from the window's bottom-left corner: point = lower_left + uΒ·horizontal + vΒ·vertical. The "1 βˆ’" flips vertically because image memory counts rows downward from the top while the world's up points… up.
  4. The ray = origin at the eye, direction = normalize(point βˆ’ eye).
Anti-aliasing in one sentence A pixel is a small area, not a point β€” sampling only its center makes edges staircase-y, so the code shoots 4 rays per pixel at 4 spots inside the square and averages the colors (render.c:58-76): edges blend smoothly, at 4Γ— the cost.
You now own every formula Full circle: pixels β†’ rays (M9), rays β†’ hit points via dot-product algebra (M2, M6, M7), hit points β†’ brightness via cosine (M4, M8), brightness β†’ clamped RGB β†’ one int written into the image buffer. Reread Β§04–§08 now β€” the code should read like sentences.

R1 Β· ray deep-diveThe rendering story β€” Person B's script

This project was built by two people, and a defense often splits the same way: Person A owns parsing and the intersection math, Person B owns the camera, the render loop, shading and MLX. This part is Person B's script β€” the rendering half learned as one continuous story instead of memorizing camera.c, render.c and shade.c separately. (Person A's story is Β§03 + Β§05.)

The story, in one breath β€œI take the 3D scene that has already been parsed, decide what the camera sees, generate rays for the pixels, ask the intersection system what each ray hits, turn that hit into a color using lighting and shadows, and finally write the color into the MLX image.”

What do I receive? The parser has already done its work: at rendering time the scene exists as C structures β€” camera, ambient, lights, objects. When the evaluator says "explain your rendering", don't start at "sp 0,0,5…". Say: β€œAt rendering time I already have the scene represented as C structures. My job starts at the camera” β€” and open camera.c.

The 90-second mental story

  1. The camera gives me three things: position, direction, FOV.
  2. From them I build an invisible viewport 1 unit ahead β€” its width comes from tan(fov/2), its "right" and "up" from cross products (R2).
  3. The render loop visits every pixel; each pixel becomes fractions (u, v) of that viewport, four sub-pixel samples each (R3).
  4. get_ray turns a (u, v) into a ray: aim from the eye through that spot (R4).
  5. ray_color asks the intersection system for the nearest hit; a miss paints the background (R5).
  6. A hit is shaded: ambient + (if the lamp is visible) diffuse β€” shadow tested with a second ray (Β§07).
  7. The color is clamped, packed and written into the image buffer; the finished frame is presented in one blit (Β§P1).
Know cold (Person B)Still know from Person A
half_w = tan(fov/2); half_h from aspect ratiot is a real distance along the ray
the u/v mapping and the 1 βˆ’ v flip (R3)a hit returns t, point, normal, material
4 samples per pixel, averaged (1 in preview mode)smallest positive t = visible object
ambient + max(0, NΒ·L) Β· brightness, shadow ray, biasthe sphere quadratic in outline (M6)
0–1 colors, clamp, Γ—255.999 packing, loop-hook rendering (Β§P1)why EPSILON exists

R2 Β· ray deep-diveBuilding the viewport, brick by brick

M9 gave the picture; here we build the actual rectangle number by number, and end on the exact line of code. Running example: camera at pos = (0,0,0), looking along dir = (0,0,βˆ’1), FOV 90Β°.

  1. Where is the rectangle's center? One unit in front of the eye: pos + dir = (0,0,βˆ’1). Note that adding a direction to a position means "step that way once" (M1).
  2. How wide? half_w = tan(90Β°/2) = tan(45Β°) = 1 β€” the M4 triangle with adjacent = 1. Full width 2.
  3. How tall? The screen is 800Γ—600, so half_h = half_w Β· 600/800 = 0.75. (Ignore FOV for height β€” it follows from the aspect ratio, or spheres would stretch.)
  4. Which way is "right"? right = norm(cross(dir, world_up)). For dir = (0,0,βˆ’1): right = (1,0,0), and then up = cross(right, dir) = (0,1,0).
  5. Walk to the corner. From the center, go half a width left and half a height down:
    (0,0,βˆ’1) βˆ’ (1,0,0)Β·1 βˆ’ (0,1,0)Β·0.75 = (βˆ’1, βˆ’0.75, βˆ’1). That point is lower_left β€” the viewport's bottom-left corner, in world coordinates.

Now read the real line (camera.c:40-43) term by term β€” it is exactly the walk above:

b->lower_left = pos          ← start at the eye
              + dir          ← step to the rectangle's center, 1 unit ahead
              βˆ’ rightΒ·half_w ← walk to the LEFT edge
              βˆ’ upΒ·half_h    ← walk DOWN to the bottom

Two more values complete the rectangle (camera.c:38-39): horizontal = right Β· 2Β·half_w = (2,0,0) and vertical = up Β· 2Β·half_h = (0,1.5,0) β€” the full-width and full-height spanning arrows.

Why we never store four corners One corner plus two spanning arrows reach every point of the rectangle: lower_left + uΒ·horizontal + vΒ·vertical with u,v ∈ [0,1]. Check: u = 1, v = 1 gives (βˆ’1,βˆ’0.75,βˆ’1) + (2,0,0) + (0,1.5,0) = (1, 0.75, βˆ’1) β€” the opposite corner. βœ“ The viewport is never "stored" anywhere as an object β€” it is these four vectors (origin, lower_left, horizontal, vertical = the whole t_camera_basis), recomputed from the camera at the start of every frame.

Why "right" isn't just (1,0,0)

It is only (1,0,0) for this particular camera. Tilt the camera and the whole rectangle must tilt with it β€” that's why right is computed, not hardcoded. A pleasing detail: with world_up = (0,1,0), the formula gives cross(dir, world_up) = (βˆ’dir.z, 0, dir.x) β€” the y component is always 0. "Right" is always level, which is why the horizon never rolls. And that vector's length is √(dir.xΒ² + dir.zΒ²), which shrinks to ~0 when the camera looks straight up or down β€” precisely the degenerate case the 0.99 guard in get_camera_axes exists to catch (Β§06).

The one sentence to keep β€œThe viewport is a corner plus two arrows, recomputed from the camera every frame; a pixel is just two fractions of those arrows.”

R3 Β· ray deep-diveget_sample_color, every line

render.c:38-49

static t_color	get_sample_color(t_app *app, t_camera_basis *basis, double px,
		double py)
{
	double	u;
	double	v;
	t_ray	ray;

	u = px / (double)WIDTH;
	v = 1.0 - py / (double)HEIGHT;
	ray = get_ray(basis, u, v);
	return (ray_color(ray, &app->scene, MAX_DEPTH));
}

u = px / WIDTH β€” turns "pixel column 412 of 800" into "51.5% of the way across". Why not hand px to get_ray directly? Because the camera should not know the image is 800 pixels wide. Fractions decouple the two worlds: change WIDTH to 1600 and get_ray doesn't change at all.

v = 1.0 βˆ’ py / HEIGHT β€” same fraction, plus the famous flip. Image memory counts rows downward from the top; the viewport's vertical arrow points up from lower_left. Watch it with real numbers:

py (image row)v = 1 βˆ’ py/600meaning
0 (top row of the image)1.0top of the viewport βœ“
300 (middle)0.5middle of the viewport βœ“
599 (bottom row)β‰ˆ 0.002bottom of the viewport βœ“

Without the 1 βˆ’, every scene would render upside-down.

Where do px, py come from? render_pixel (render.c:51-74) passes them fractional: x + (i + 0.5) / ss β€” sub-pixel offsets for anti-aliasing. ss = 2 βˆ’ app->fast: in normal mode ss = 2 β†’ a 2Γ—2 grid of 4 samples averaged; in preview mode (Β§P1) ss = 1 β†’ a single centered sample. Same function, two speeds.

The last two lines are the handoff: fractions β†’ ray (R4), ray β†’ color (R5). In plain English the whole function reads: β€œturn this sample position into percentages of the viewport, aim a ray through that spot, and ask what color it sees.”

R4 Β· ray deep-diveget_ray, every line

camera.c:53-66

t_ray	get_ray(t_camera_basis *b, double u, double v)
{
	t_ray	ray;
	t_vec3	target;

	target = vec3_add(b->lower_left,
			vec3_add(vec3_scale(b->horizontal, u),
				vec3_scale(b->vertical, v)));
	ray.origin = b->origin;
	ray.dir = vec3_norm(vec3_sub(target, b->origin));
	return (ray);
}

target = lower_left + uΒ·horizontal + vΒ·vertical β€” the R2 corner-plus-arrows walk: start at the bottom-left corner, go u of the way across, v of the way up. With the R2 numbers and the exact middle (u = v = 0.5):

target = (βˆ’1,βˆ’0.75,βˆ’1) + 0.5Β·(2,0,0) + 0.5Β·(0,1.5,0)
       = (βˆ’1,βˆ’0.75,βˆ’1) + (1,0,0) + (0,0.75,0)
       = (0, 0, βˆ’1)          ← the viewport's center: straight ahead βœ“

target is a position on the window β€” not a ray yet.

ray.origin = b->origin β€” every primary ray starts at the eye.

ray.dir = norm(target βˆ’ origin) β€” subtraction gives "the arrow from the eye to the window spot" (M1), and normalization makes its length exactly 1 so that every t downstream is a real world distance (M5). For a corner sample (u = v = 1): target = (1, 0.75, βˆ’1), raw arrow (1, 0.75, βˆ’1) with length √2.5625 β‰ˆ 1.60, normalized β‰ˆ (0.62, 0.47, βˆ’0.62).

The whole function in plain English β€œFind the sample's actual 3D position on the viewport, then make the ray that starts at the camera and points through it.” Nothing else happens here β€” no objects, no colors. Geometry only.

R5 Β· ray deep-diveray_color β†’ intersect_scene β†’ shade

ray_color (render.c:27-36) asks one question β€” what did this ray hit? If intersect_scene says nothing: return the sky-gradient background. If it says something: hand the hit to shade. (The depth guard returns black at 0 but nothing ever decrements it β€” the honest answer is "a hook for reflection that isn't implemented", Β§12.)

t_hit is the baton passed from geometry to lighting. It carries everything shading will need: t (how far), point (where), normal (facing which way, always turned against the incoming ray), mat (color/properties), front_face (outside or inside).

intersect_scene (intersect.c:15-37) β€” the two local variables confuse people, so here they are in plain English:

VariablePlain English
closest = 1e15β€œthe best (nearest) distance found so far” β€” starts at effectively infinity, so anything real beats it
tmpa scratch t_hit each object writes its answer into; copied to the caller's hit only if it wins

The loop walks the object list asking each one: β€œcan you beat the current best?” β€” hit_object(ray, obj, &tmp) && tmp.t < closest. How does it know whether it's testing a sphere or a plane? It doesn't need cleverness: hit_object is an if/else on obj->type, the tag of the tagged union (Β§00). Each shape function answers the same single question: β€œgiven this ray, what is your t?” Visibility falls out for free: no visibility algorithm exists anywhere β€” it emerges from β€œsmallest positive t wins.”

shade (shade.c:17-34) then assembles the color: start with ambient (the always-on fill, Β§M8); for each light, ask in_shadow (a second ray, Β§07) and if the lamp is visible add diffuse (bonus: + specular, tinted by the light's color); clamp to [0,1]. The result rides back up: shade β†’ ray_color β†’ get_sample_color β†’ render_pixel (averaged over the 4 samples) β†’ mlx_put_pixel β€” one integer in the image buffer.

render_pixel(x, y)
  └─ 4 Γ— get_sample_color(px, py)      fractions u,v  + the 1βˆ’v flip
        └─ get_ray(u, v)               corner + uΒ·across + vΒ·up β†’ aim from the eye
        └─ ray_color(ray)              nearest hit, or background
              └─ intersect_scene       "who beats closest?" over the object list
              └─ shade                 ambient + Ξ£ lit lights, clamp
  └─ average β†’ mlx_put_pixel           one integer in memory: the pixel

R6 Β· ray deep-diveThe light system, end to end

M8 built the physics from zero and Β§07/Β§08 walk the formulas; this section tells the story of light as a system β€” from two lines of text in the scene file to the number in a pixel. The first distinction everything else hangs on: the program has two different kinds of light:

1. Ambient light     β†’ always contributes a little light everywhere,
                       cannot be blocked, has no position
2. Point light(s)    β†’ come FROM a position in 3D space,
                       can be blocked, strength depends on surface direction

The mandatory build has exactly one point light; the bonus allows several.

It starts in the .rt file

A  0.2        255,255,255      β†’ ambient: strength 0.2, white
L  -10,10,-10 0.7 255,255,255  β†’ point light: position, brightness, color

parse_ambient stores ratio + color into scene->ambient; parse_light builds the light node β€” position, brightness, color, and the attenuation trio, which mandatory sets to the neutral kc=1, kl=0, kq=0 (more on that below). So the lighting state exists before any rendering starts:

scene
 β”œβ”€β”€ ambient (ratio, color)
 β”œβ”€β”€ camera
 β”œβ”€β”€ lights β†’ [light] β†’ …      one node in mandatory, a list in bonus
 └── objects
The architectural point evaluators like Light is not involved when the camera ray is shot. Intersection only answers β€œwhat does this ray hit, where, facing which way?” β€” it produces a t_hit and never looks at a light. Intersection finds the surface; shading decides what that surface looks like under the lights. That's why shade() is where the light system really begins.

The mandatory recipe

final = ambient_light()             ← the unconditional baseline
for the light:
    if NOT in_shadow():
        final += diffuse_light()    ← direct light, only if it arrives
color_clamp(final)

Ambient first: object color Γ— (ambient color Γ— ratio). A red sphere under 0.2 white ambient starts as dark red β€” never pitch black. This baseline is exactly what a shadowed point keeps.

The two questions β€” keep them separate For each point light, the renderer asks two independent questions, answered by two different functions:
Q1 β€” can the light reach X at all? β†’ the shadow test (in_shadow)
Q2 β€” if it reaches X, how strongly does X face it? β†’ the diffuse strength (diffuse_light)

Q1 β€” the shadow ray, the renderer's second kind of ray

PRIMARY RAY   camera β†’ object      "what do I see?"
SHADOW RAY    object β†’ light       "can this point see the lamp?"

make_shadow_ray starts at the hit point β€” nudged off the surface by SHADOW_BIAS so floating-point dust can't make the surface shadow itself (β€œshadow acne”) β€” and points at the light. Then a lovely architecture point: there is no special shadow geometry β€” the shadow ray is tested with the very same per-shape functions (hit_object) as camera rays. But since PR #18 the loop differs in one smart way: it returns at the first blocker it finds instead of searching for the nearest one, bounded by the light's distance:

X ───── object ───── β˜€      ANY hit BEFORE the light   β†’ shadow, stop looking
X ───── β˜€ ───── object      hit BEHIND the light        β†’ irrelevant, lit

Why that's legitimate: the camera ray needs the nearest hit (that's what you see); the shadow ray only asks β€œis there ANY obstacle?” β€” a yes/no question that the first blocker already answers. Same boolean, image bit-identical (checksum-verified), and it gets faster the more objects and lights a scene has. The distance comparison against the light is meaningful only because ray directions are unit length, so t is a real distance (M5).

Q2 β€” diffuse: the normal earns its keep

Light reaches the surface; how strong is the illumination? This is where the normal β€” carried in every t_hit since intersection β€” becomes crucial: dot(N, L) with L pointing at the light gives the alignment (M2): 1 facing the lamp head-on, 0 sideways, negative = lamp behind the surface β†’ clamped to zero. Without normals, a sphere would render as a flat uniformly-colored disk; the dot product is what makes it look round.

Then two multipliers scale that alignment β€” worked numbers:

alignment dot(N,L) = 0.8      the surface mostly faces the lamp
light brightness   = 0.5      straight from the L line's ratio
attenuation        = 1.0      mandatory: kc=1, kl=0, kq=0 β†’ always exactly 1
                              (the 1/(kc + klΒ·d + kqΒ·dΒ²) machinery exists,
                               but mandatory light does NOT fade with distance)
intensity = 0.8 Γ— 0.5 Γ— 1.0 = 0.4
red object (1,0,0) Γ— 0.4    = (0.4, 0, 0)

Adding it up β€” where shadows come from

lit point:       ambient (0.1,0,0) + diffuse (0.4,0,0) = (0.5,0,0)
shadowed point:  ambient (0.1,0,0) + nothing           = (0.1,0,0)  ← the visible shadow

A shadow is not painted β€” it is simply the absence of the diffuse term. Finally color_clamp caps each channel at 1 (added terms can overshoot), and much later mlx_put_pixel converts [0,1] back to 0–255 bytes.

The whole mandatory pipeline in one picture

.rt file β†’ parse_ambient() + parse_light() β†’ scene holds the lighting state
─────────────────────────────────────────────────────────────────
camera ray β†’ intersect_scene() β†’ t_hit (point + normal + material)
   ↓
shade()
   β”œβ”€ ambient_light()                        the baseline
   └─ for the light:
        in_shadow() ── shadow ray ── intersect_scene()
        if visible: diffuse_light()
                       β”œβ”€ dot(normal, to_light)
                       β”œβ”€ Γ— brightness
                       └─ Γ— attenuation (≑ 1 in mandatory)
   ↓
add contributions β†’ color_clamp() β†’ pixel

What the bonus changes

  • Multiple lights β€” the bonus parser appends to a list instead of rejecting a second L; the bonus shade() loops over all of them, running the two questions per light.
  • Light color actually matters β€” mandatory parses the L color but never uses it; the bonus multiplies each light's (diffuse + specular) by that light's own RGB. Red light on a white object β†’ red-looking illumination.
  • Specular β€” a third question joins the two: β€œdoes the light's mirror-bounce direction point toward the camera?” (specular_light, classic Phong: ks = strength, shininess = how tight the glint is).
bonus:  final = ambient + Ξ£ over visible lights: light_color Γ— (diffuse + specular)
        then clamp
The mental picture to keep INTERSECTION answers β€œwhere did the camera ray touch the world?” β†’ t_hit (point + normal + material) β†’ SHADING answers β€œwhat should this point look like?” through its questions: ambient β€” minimum light everywhere; shadow β€” can the lamp reach here?; diffuse β€” how squarely does the surface face it?; bonus specular β€” does the reflection point at the viewer? Geometry creates the surface, normals say how it faces, and the lights turn that geometric hit into brightness and color.

03 Β· subsystemParser

srcs/parser/ β€” entry: parse_scene(file, scene), parse_scene.c:83

End-to-end flow

  1. Extension check parse_scene.c:51-61, 87-88 β€” needs len >= 4 and the last 3 chars == ".rt" (shortest valid name is a.rt; case-sensitive).
  2. open(file, O_RDONLY) :89-91 β€” failure β†’ error. A directory named foo.rt opens fine, but the first read() returns βˆ’1 (EISDIR), caught at :37-38 as β€œread error”.
  3. ft_memset(scene, 0, …) :92 β€” zeroes the has_* flags and NULLs the lists.
  4. Line loop read_all :63-81 β€” read_line β†’ split_line β†’ dispatch β†’ free the line and tokens immediately.
  5. Post-conditions :95-96 β€” missing any of A/C/L β†’ error. A scene with zero objects is legal (renders only the background).

read_line β€” the hand-rolled GNL

parse_scene.c:23-49

Mallocs a 4096-byte buffer and reads the fd one byte at a time until \n, EOF, or 4095 chars. Return contract:

  • returns the line if i > 0 || ret > 0 β€” the ret > 0 part means a bare \n returns an empty string, not NULL, so blank lines don't terminate parsing (they produce zero tokens and are skipped at :77);
  • returns NULL only at true EOF with nothing read.

The 1 MiB size cap parse_scene.c:16-21 Β· commit ab79a0f: bump_total() counts each byte actually consumed into a line (called after the newline-break, so newlines don't count) and errors past 1 048 576 bytes. History: the old version over-counted and rejected a legitimate 10 000-object scene; the fix counts only bytes really read.

Defense phrasing β€œWe cap cumulative scene content at 1 MiB, counted byte-by-byte, so huge-but-legitimate scenes pass and an infinite-file bomb is rejected cleanly.”

Tokenization & dispatch

  • split_line tok_utils.c:52-76 β€” two-pass whitespace splitter. Whitespace is ft_isspace = space + ASCII 9–13, so CRLF files parse fine (the \r is stripped).
  • dispatch dispatch.c:23-43 / dispatch_bonus.c:40-54 β€” first token β†’ parser. A first token starting with # skips the line (comment support). Unknown identifier β†’ Error\nunknown identifier: <tok>, exit 1. Inline comments after data are not supported (they become extra tokens).

Number parsing β€” three layers

  1. valid_num parse_num2.c:28-44 β€” strict grammar before conversion: optional single sign, β‰₯1 digit, optional . + β‰₯1 digit, end of string. Rejects: "", "+", "1.", ".5", "++5", "1.2.3", "1e5", "0x1F".
  2. ft_strtod parse_num.c:61-74 β€” sign + integer part + fraction part. Deliberately no scientific notation.
  3. Range checks β€” parse_double parse_utils.c:15-28 enforces |v| ≀ 1 000 000; parse_ratio :30-38 enforces [0,1].

Triples go through next_component parse_num2.c:46-71 β€” the trickiest function in the parser:

if (!last && **s == ',')
    *s += 1;
else if (!last || **s != '\0')
    error_exit("expected exactly three comma separated values");

Read it as: not-last β‡’ a comma must follow (consume it); last β‡’ end-of-string must follow. Every malformed shape β€” 1,2 Β· 1,2,3,4 Β· 1,,3 Β· 1,2, β€” falls into the error. The 64-byte local buf with its i < 63 copy guard means a 200-digit component just stops copying, leaves *s on a char that is neither , nor \0, and hits the same clean error β€” no overflow possible.

  • Colors parse_utils.c:40-58 β€” each component must be an integer in [0,255] (v != (int)v rejects 12.5), then is normalized to [0,1] by Γ·255. All downstream color math is linear [0,1]; conversion back to 0–255 happens only at pixel-write time.
  • Orientation vectors parse_utils.c:70-81 β€” each component in [βˆ’1,1], vector must not be ~zero (vec3_near_zero), then re-normalized with vec3_norm. So 0.5,0.5,0 is accepted and canonicalized rather than rejected β€” deliberate tolerance for rounding (e.g. 0.577,0.577,0.577); say so up front.

Element parsers

IdFileRules
Aparse_ambient.c:15-263 tokens; ratio ∈ [0,1]; duplicate rejected via has_ambient
Cparse_camera.c:15-274 tokens; FOV strictly ∈ (0,180); duplicate rejected
L (mand.)parse_light.cduplicate rejected; sets attenuation kc=1, kl=0, kq=0 (:31-33) β†’ always exactly 1.0
L (bonus)parse_light_bonus.cappends to the list β€” multiple lights; keeps the color. ⚠ does NOT set kc/kl/kq β€” Β§12 bug #1
sp pl cyparse_objects.c:42-92new_object malloc β†’ geometry β†’ default_material(color) + parse_material (no-op in mandatory) β†’ tail-append. Sphere & cylinder divide the diameter by 2 (:49, :79), reject ≀ 0. Cylinder precomputes top/bottom_center = center Β± axisΒ·h/2 (:85-88) β€” the .rt center is the mid-height point.
coparse_objects_bonus.c:15-33co apex axis radius height color β€” a radius, not a diameter (own documented format)
trparse_objects_bonus.ctr v0 v1 v2 color β€” normal precomputed at parse time: normalize(cross(v1βˆ’v0, v2βˆ’v0)) (:52-54); degenerate/collinear rejected (:55-56)

Bonus per-object material tail parse_opts_bonus.c:21-44, appended after the color: [ks] [shininess] [refl] [transp] [ior] [checkerboard 0/1] [checker_size]. Defaults from default_material material.c:15-31: ka .1, kd .9, ks .3, shininess 32, refl/transp 0, ior 1, checker off, size 1. Of these, only ks, shininess, checkerboard, checker_size are actually consumed by shading β€” the rest are forward-looking storage (admit it if asked).

Memory management

  • Objects/lights: singly linked lists of individually malloc'd nodes, tail-append.
  • Normal teardown: free_scene free_scene.c:15-36 walks both lists; called from the window-close path.
  • On a parse error mid-file: error_exit main.c:33-39 writes Error\n + message to stderr and exit(1) β€” nothing is freed, but everything already allocated is still referenced from live stack frames, so valgrind reports it as β€œstill reachable”, not β€œdefinitely lost”.
Defense phrasing β€œOn malformed input we exit immediately; no memory is lost β€” 0 bytes definitely lost under valgrind. On the normal path everything is freed by free_scene in the close handler.” Run valgrind on a bad file before the defense so you can quote the numbers.

04 Β· subsystemMath toolkit

srcs/math/ β€” all vec3 functions are by-value: no pointers, no allocation

FunctionFileNotes
vec3, add, sub, scale, negatevec3.cnegate is used everywhere to flip normals toward the ray
vec3_lenvec3_bis.c:15√(x²+y²+z²)
vec3_normvec3_bis.c:20guards Γ·0: len < 1e-12 β†’ returns the zero vector (detectable, not NaN)
vec3_near_zerovec3_bis.c:30all comps < 1e-8; rejects zero orientations & degenerate triangles
vec3_dotvec3_ops.c:16the workhorse: quadratics, NΒ·L, projections, face tests
vec3_crossvec3_ops.c:22camera basis, triangle normal, MΓΆller–Trumbore
vec3_reflectvec3_ops.c:31v βˆ’ 2(vΒ·n)n; only used by bonus specular
ray_atvec3_ops.c:37origin + tΒ·dir
Key invariant Ray directions are normalized (camera.c:73), so t is a true world-space distance β€” nearest-hit comparisons and the shadow max-distance check depend on it.

The shared quadratic solver

math/quad.c Β· t_quad in miniRT.h:169

Sphere, cylinder and cone are all atΒ² + bt + c = 0. The t_quad struct is shared scratch space β€” the extra fields d f m n root exist so helpers stay under Norm limits without recomputing: the cylinder caches its projected vectors in d/f, the cone caches axis dot products in m/n.

  • quad_solve quad.c:20-27 β€” disc = bΒ² βˆ’ 4ac; negative β†’ miss (return 0); else cache sqrt_disc.
  • quad_root1 = (βˆ’b βˆ’ √disc)/2a β€” the near root (entry point).
  • quad_root2 = (βˆ’b + √disc)/2a β€” the far root (exit; the relevant one when the ray starts inside the object, since root1 is then behind the origin).
  • quad_nearest quad.c:46-55 β€” try root1, accept if t β‰₯ EPSILON; else root2; else miss. One call handles outside rays, inside rays, and objects behind the camera.
Why t β‰₯ EPSILON and not t β‰₯ 0 Floating point. A secondary ray starting on a surface would re-hit that same surface at t β‰ˆ 1e-16; requiring t β‰₯ 1e-6 discards those self-hits β€” otherwise the render shows β€œshadow acne” speckle.

05 Β· subsystemIntersections

srcs/intersect/

Dispatch & nearest hit

  • hit_object hit_object.c:15-24 β€” if/else on obj->type β†’ sphere/plane/cylinder. The bonus version hit_object_bonus.c:15-28 adds cone and triangle. This one-file swap is the entire mandatory/bonus split on the intersection side.
  • intersect_scene intersect.c:15-37 β€” walks the list, closest starts at 1e15, keeps hits with tmp.t < closest, copies the winning t_hit out whole. O(n) per ray β€” no BVH; fine at miniRT scale.

Every intersector fills the full t_hit: t, point, unit normal that always faces the ray, mat (a pointer into the object β€” no copy), front_face.

Sphere

sphere.c

Math. P on sphere ⟺ |P βˆ’ C|Β² = rΒ². Substitute P = O + tD, let oc = O βˆ’ C:

(DΒ·D)Β·tΒ² + 2(ocΒ·D)Β·t + (ocΒ·oc βˆ’ rΒ²) = 0

Code mapping sphere.c:34-52: oc at :40, a = dot(dir,dir) :41 (would be 1.0 for unit dirs β€” computed anyway so the formula stays correct for any caller), b = 2Β·dot(oc,dir) :42, c = dot(oc,oc) βˆ’ rΒ² :43, then quad_solve + quad_nearest :44, fill hit :46-50.

Normal flipping β€” set_face_normal sphere.c:17-29: the outward normal is normalize(P βˆ’ C); if dot(ray.dir, outward) < 0 the ray sees the outside β†’ keep it, front_face = 1; otherwise the ray hit from inside (origin inside the sphere, quad_nearest returned root2) β†’ negate, front_face = 0. Shading always receives a normal pointing against the incoming ray (the β€œRay Tracing in One Weekend” convention).

Plane

plane.c

Solve (O + tD βˆ’ p0)Β·n = 0 β†’ t = ((p0 βˆ’ O)Β·n) / (DΒ·n).

  • denom = dot(dir, normal) :21; |denom| < EPSILON β†’ parallel β†’ miss (:22) β€” also protects the division.
  • t < EPSILON rejected (:26).
  • Two-sided (:31-35): denom < 0 β‡’ the ray faces the stored normal β†’ keep it; denom > 0 β‡’ the ray comes from behind β†’ return the negated normal. Same β€œnormal faces the ray” invariant, derived directly from denom's sign (no extra dot product β€” denom is dot(dir, n)).

Cylinder β€” the defense centerpiece

cylinder.c + cylinder_utils.c

Decomposition: infinite tube clipped by height + top disk + bottom disk, tested independently; smallest valid t wins (intersect_cylinder cylinder.c:81-103, min-reduction starting at INFINITY).

(a) Body quadratic β€” cylinder_quadratic cylinder_utils.c:18-34

P is on the infinite tube (center C, unit axis A, radius r) ⟺ the component of P βˆ’ C perpendicular to the axis has length r. Instead of expanding a messy scalar formula, the code projects the axis component out first:

q->d = dir βˆ’ (dirΒ·A)A;      /* direction, axis part removed          */
q->f = oc  βˆ’ (ocΒ·A)A;       /* origin offset likewise (oc = O βˆ’ C)   */
a = dΒ·d;   b = 2Β·(dΒ·f);   c = fΒ·f βˆ’ rΒ²;

i.e. an ordinary circle quadratic in the plane perpendicular to the axis. Edge case at :28: a β‰ˆ 0 β‡’ ray parallel to the axis β‡’ it can never cross the wall β†’ return 0 (the caps still get tested). Also protects the Γ·2a divisions.

(b) Height clamp β€” valid_body_hit cylinder_utils.c:39-49

proj = (P βˆ’ C)Β·A is the signed distance along the axis from the (mid-height) center; valid iff βˆ’h/2 ≀ proj ≀ h/2 and t β‰₯ EPSILON.

(c) Root selection with clipping β€” hit_body cylinder.c:18-37

Crucial subtlety You cannot just take the nearest positive root. The near root may hit the infinite tube outside the height range while the far root is inside (tilted views; camera inside). So root1 is tried and, only if rejected, root2 β€” each validated with the height clamp. Same pattern in the cone.

(d) Caps β€” hit_cap cylinder.c:56-76

Via cap_plane (:42-54): top uses top_center/+axis, bottom uses bottom_center/βˆ’axis; then plane intersection + disk membership |P βˆ’ cap_center|Β² ≀ rΒ² (:71-73 β€” squared, no sqrt). set_cap_hit cylinder_utils.c:84-93 flips the cap normal toward the ray like everything else. The cap centers were precomputed at parse time.

(e) Body normal β€” body_normal cylinder_utils.c:55-64

Project the hit point onto the axis, subtract: normalize(P βˆ’ (C + projΒ·A)) β€” the radial vector at that height, perpendicular to the axis by construction.

(f) Camera inside the cylinder

scenes/cylinder_inside.rt proves it. root1 < 0 β†’ rejected; root2 (the wall you're looking at) accepted; check_body_hit cylinder_utils.c:66-79 sees dot(dir, outward) > 0 β†’ front_face = 0, normal negated β†’ the interior wall shades correctly.

β€œWall or cap β€” how do you know?” You never need a flag: each of the three tests fills its own normal (radial vs Β±axis) and the min-t wins; downstream shading only consumes hit->normal.

Cone (bonus)

cone_bonus.c + cone_utils_bonus.c

Stored as apex + axis (apex→base) + base radius + height; slope k = radius/height = tan(half-angle) cone_utils_bonus.c:27.

Body quadratic β€” cone_quad :20-36: P on cone ⟺ |perp(Pβˆ’apex)|Β² = kΒ²Β·((Pβˆ’apex)Β·A)Β². Same perpendicular-decomposition trick, with m = dirΒ·A, n = ocΒ·A cached in the quad struct:

a = dβŠ₯Β·dβŠ₯ βˆ’ kΒ²mΒ²;
b = 2Β·(dβŠ₯Β·ocβŠ₯ βˆ’ kΒ²mn);
c = ocβŠ₯Β·ocβŠ₯ βˆ’ kΒ²nΒ²;

The βˆ’kΒ²(…) terms are exactly what turns a cylinder into a cone: the allowed radius grows linearly with axial distance.

Height clamp & the double-cone trap β€” check_cone_body :54-74: proj = n + tΒ·m (algebraically (Pβˆ’apex)Β·A, but two dot products cheaper); valid iff 0 ≀ proj ≀ height.

Classic evaluator trap proj < 0 discards the mirror cone above the apex β€” the quadratic describes a double cone and this test removes the phantom half. Know it cold.

Base cap: one disk at apex + axisΒ·height cone_bonus.c:34-35, same plane+disk test; there is no apex-end cap (it's a point).

Body normal β€” cone_body_normal cone_utils_bonus.c:42-52: normalize(perp βˆ’ kΒ²Β·projΒ·axis) where perp is the radial vector. Why the βˆ’kΒ²Β·projΒ·axis term: the surface is the zero set of F(P) = |perp|Β² βˆ’ kΒ²Β·projΒ²; the normal is its gradient βˆ‡F ∝ perp βˆ’ kΒ²Β·projΒ·axis. A purely radial normal would shade like a cylinder.

Triangle (bonus) β€” MΓΆller–Trumbore

triangle_bonus.c

Solve the linear system O + tD = v0 + uΒ·e1 + vΒ·e2 (e1 = v1βˆ’v0, e2 = v2βˆ’v0) by Cramer's rule via scalar triple products; hit iff u β‰₯ 0, v β‰₯ 0, u+v ≀ 1 (barycentric containment). No quadratic.

tri_solve :25-50: h = dir Γ— e2, det = e1Β·h β€” |det| < EPSILON β‡’ ray parallel to the triangle's plane β‡’ miss. The sign of det is kept: > 0 front face, < 0 back face (triangles are two-sided; the precomputed normal is just flipped by det's sign at render time :63-67 β€” no per-ray cross product). u is computed and range-checked before v (early exit), then t = (e2Β·(sΓ—e1))/det, rejected below EPSILON.

06 Β· subsystemCamera & render loop

srcs/render/

Camera basis

camera.c

Pinhole model with the viewport at distance 1 in front of the camera:

  • half_w = tan((fov Β· Ο€/180) / 2) :42 β€” FOV is horizontal, per subject. Trig: opposite/adjacent with adjacent = 1.
  • half_h = half_w Β· (HEIGHT/WIDTH) :43 β€” aspect ratio keeps pixels square, spheres round.
  • Soutenance fix commit 228f5f3 β€” it used to be the other way around (half_h = tan(…), FOV effectively vertical); the fix swapped the two lines.
  • horizontal/vertical = full spanning vectors; lower_left = pos + dir βˆ’ rightΒ·half_w βˆ’ upΒ·half_h :47-50.

Orthonormal axes β€” get_camera_axes camera.c:21-31:

if (fabs(dir.y) < 0.99)  world_up = (0,1,0);  else  world_up = (0,0,1);
right = norm(cross(dir, world_up));
up    = norm(cross(right, dir));

Edge case: a camera looking straight up/down would make cross(dir, (0,1,0)) the zero vector (normalizing = Γ·0) β€” the 0.99 guard swaps the hint to (0,0,1). up is re-derived from right Γ— dir so the basis is exactly orthogonal even when dir isn't perpendicular to the hint.

Ray generation β€” get_ray camera.c:64-75: target = lower_left + uΒ·horizontal + vΒ·vertical; dir = normalize(target βˆ’ origin). (u,v) ∈ [0,1]Β².

Render loop

render.c

  • render :78-93 β€” basis built once per frame; double loop y then x; one mlx_put_image_to_window at the end (:92).
  • 2Γ—2 supersampling β€” render_pixel :58-76: 4 rays per pixel at sub-pixel offsets 0.25 / 0.75 in each axis, averaged (Γ—0.25 at :75). Stratified anti-aliasing; total 800Γ—600Γ—4 = 1.92 M primary rays per frame for a full-quality pass (movement uses a cheaper preview first β€” Β§P1).
  • u/v mapping β€” get_sample_color :45-56: u = px/WIDTH, v = 1 βˆ’ py/HEIGHT β€” the y-flip: image memory row 0 is the top of the window but viewport v grows upward from lower_left.
  • ray_color :34-43 β€” returns black if depth <= 0 but never recurses or decrements β€” MAX_DEPTH is scaffolding for a reflection bounce loop that was never enabled. Honest answer if asked.

Camera movement

translation.c + window/mlx_hooks.c

  • W/S move along Β±dir, A/D strafe along Β±right (recomputed with the same up-hint logic, translation.c), at MOVE_SPEED = 8 units per second β€” movement is now time-based while a key is held (dt from gettimeofday), so the glide speed is independent of render cost. Movement never renders directly β€” it sets needs_render and the loop hook repaints in slices, preview-first (Β§P1).
  • Mouse scroll (buttons 4/5): FOV βˆ“5Β°, clamped to [1,179] β€” same flag mechanism.
  • SPACE toggles app->is_locked (movement/zoom ignored while locked). The camera starts locked (is_locked = 1 in mlx_setup) β€” press SPACE first when demoing movement.
  • Arrow keys are defined in the header but not handled β€” only WASD works (Β§12).

07 Β· subsystemShading β€” mandatory

srcs/shading/

shade shade.c:17-34: final = ambient + Ξ£ lights (in_shadow ? 0 : diffuse), then color_clamp. (void)ray; at :22 β€” no specular in mandatory. The loop is list-shaped but the mandatory parser guarantees exactly one light. (For the light system told as one story β€” from the .rt lines to the pixel β€” see Β§R6.)

Ambient β€” know this formula, it was a graded fix

ambient.c:15-21 Β· commit cc27a56

amb = color_scale(ambient->color, ambient->ratio);   /* A color Γ— ratio  */
return color_multiply(hit->mat->color, amb);         /* βŠ— object color   */

The old version multiplied the object color in twice and dampened by ka = 0.1, making shadows nearly black and ignoring the A color. Numeric sanity check on scenes/min_shadow.rt: lit β‰ˆ 150 vs shadowed β‰ˆ 7 β‰ˆ 0.05 Γ— 150 (ratio 0.05). βœ”

Diffuse β€” Lambert

diffuse.c:15-27

to_light  = norm(light->pos βˆ’ hit->point);
intensity = max(0, dot(N, L)) Β· brightness Β· attenuation(light, point);
return color_scale(hit->mat->color, intensity);

dot(N,L) = cos ΞΈ: full strength when the surface faces the light, zero at grazing or facing away (the max(0, Β·)). Notes: mandatory never multiplies by light->color (subject: unused in mandatory) and the material kd is parsed but never applied β€” brightness is the only diffuse gain.

Shadows

shadow.c

make_shadow_ray :16-28 offsets the origin point + SHADOW_BIASΒ·normal + SHADOW_BIASΒ·to_light (bias 1e-4). Why: without it, the shadow ray re-hits its own surface at t β‰ˆ 1e-15 β†’ every point thinks it's shadowed β†’ β€œshadow acne” speckle. Offsetting along both normal and light direction covers grazing geometry. in_shadow :29-46 walks the object list itself with the same per-shape testers β€” but any-hit, not nearest-hit (PR #18):

while (obj)
{
    tmp.t = light_dist;
    if (hit_object(shadow_ray, obj, &tmp) && tmp.t < light_dist)
        return (1);
    obj = obj->next;
}
return (0);

Only a hit strictly between point and light blocks β€” and the first such hit ends the search, because β€œis there any obstacle?” is a yes/no question. Output is bit-identical to the old nearest-hit version (checksum-verified); t is world-distance because directions are unit length.

Attenuation

attenuation.c:15-25

1 / (kc + klΒ·d + kqΒ·dΒ²), clamped ≀ 1. The mandatory parser sets kc=1, kl=0, kq=0 β†’ always exactly 1.0 (the subject has no falloff; the machinery is present but neutral). The bonus parser once forgot to set these β€” an uninitialized read caught by valgrind, long since fixed (the story is in Β§12).

Color pipeline

color.c + window/mlx_utils.c

All colors are doubles in [0,1] end-to-end (color_scale/add/multiply/clamp/lerp). color_clamp at the end of shade is required because several additive terms (multi-light + specular) can exceed 1.0, and 1.2 Γ— 255 = 306 would bleed into the neighboring channel via the bit-shift packing. mlx_put_pixel mlx_utils.c:15-26 packs (int)(c Γ— 255.999) per channel β€” Γ—255.999 maps 1.0 β†’ 255 without hitting 256 β€” and writes *(unsigned int *)(addr + yΒ·line_len + xΒ·(bpp/8)).

08 Β· subsystemShading β€” bonus additions

shade_bonus.c Β· specular_bonus.c Β· checker_bonus.c

shade, bonus version shade_bonus.c:17-38:

apply_checker(hit, &tmp);                       /* may repoint hit->mat  */
apply_bump(hit);                                /* may tilt the normal β€” Β§B1 */
final = ambient_light(hit, &scene->ambient);
while (light) {
    if (!in_shadow(scene, hit, light))
        final += color_multiply(diffuse + specular, light->color);
    light = light->next;
}
return color_clamp(final);

Five differences vs mandatory: checkerboard first, bump-tilted normals (Β§B1), a real multi-light loop, Phong specular, and each light's contribution tinted by its own color commit b3f6ba6 β€” the color β€œwas always parsed but never used”; a red light on a white sphere now gives β‰ˆ (152, 51, 51). Ambient is deliberately not tinted by point lights.

Phong specular

specular_bonus.c:20-38 β€” true reflect-vector Phong, not Blinn half-vector

R = vec3_reflect(βˆ’to_light, normal);          /* v βˆ’ 2(vΒ·n)n     */
V = norm(βˆ’ray.dir);
spec = pow(max(0, dot(R, V)), shininess) Β· ks Β· brightness;
return white Γ— spec;

The highlight base is white β€” it reflects the light source, not the object (that's why a red ball gets a white glint); it becomes light-tinted by the per-light multiply above. ks = 0.3, shininess = 32 by default, per-object overridable.

Checkerboard β€” 3D solid procedural texture (not UV)

checker_bonus.c

sum = floor(p.x/size) + floor(p.y/size) + floor(p.z/size);
if (sum % 2)  color = 1 βˆ’ color;   /* photographic negative of the cell */

Space is diced into size-cubes; cell parity picks the original color or its inverse. floor() (not cast truncation) keeps cells consistent across negative coordinates; C's % on negatives yields βˆ’1, which is truthy, so alternation continues correctly through the origin. Works on every primitive because it only needs the 3D hit point. Trade-off to defend: a sphere looks β€œcarved from a checkered block,” not latitude/longitude tiles like UV mapping would give.

The pointer trick β€” great β€œexplain this” material apply_checker checker_bonus.c:26-38 copies the material into shade's stack variable tmp, inverts the copy's color if parity is odd, and repoints hit->mat at tmp. Safe only because hit never outlives shade's frame. If checkerboard is off or checker_size ≀ 0, it returns before touching anything β€” that guard (:28) also defuses the parser's missing checker_size validation.

B1 Β· subsystemsThe bonus, feature by feature

The subject lists five bonus points. All five are implemented, all through the linking trick (Β§00) β€” zero #ifdefs, the bonus binary just links different files for the same symbols:

Subject bonus pointStatusWhere
1 Β· Full light management: multiple + colored lightsβœ”parse_light_bonus.c (list append), shade_bonus.c:33 (per-light tint) β€” Β§08
2 Β· Specular reflection (full Phong model)βœ”specular_bonus.c β€” Β§08
3 Β· Checkerboard patternβœ”checker_bonus.c (3D solid texture) β€” Β§08
4 Β· Extra objects: cone (2nd degree) + triangleβœ”cone_bonus.c / cone_utils_bonus.c / triangle_bonus.c β€” Β§05
5 Β· Bump-map textureβœ” (the newest)parse_texture_bonus.c + bump_bonus.c β€” below

Features 1–4 are covered in depth in Β§05 and Β§08. This section documents the fifth in full, because it exists nowhere else in the handbook.

Bump mapping β€” relief without geometry

The idea. A surface looks bumpy because light catches its slopes β€” bright on the side facing the lamp, dark on the far side. But lighting never sees geometry directly: it only sees the normal (M8 β€” brightness is dot(N, L)). So if you tilt the normal per point as if craters were there, the shading produces the relief β€” while the actual sphere stays mathematically perfect. Give-away an evaluator may spot, and the honest answer: the silhouette stays a perfect circle, because intersections are untouched β€” only lighting is fooled.

Scene syntax. Append a .pgm file path as the last token of a sphere line β€” with or without the material tail (scenes/bonus/bump_sphere.rt):

sp -2.6,0,0 4.4 205,175,130 0.35 24 0 0 1 0 1 scenes/bonus/textures/craters.pgm
sp  2.6,0,0 4.4 205,175,130 0.35 24              ← same sphere, no bump: the control

The loader (parse_texture_bonus.c) is written from scratch with only open/read/malloc β€” no forbidden functions, no MinilibX involvement. A PGM "P5" file is the simplest image format that exists: a 3-line text header (P5, width + height, maxval) followed by raw bytes, one byte per pixel, 0 = black = low, 255 = white = high. The loader checks the magic, bounds the dimensions to 1–4096, requires maxval 255, and reads the payload in a loop until complete β€” a missing file, wrong format, or truncated payload each exits cleanly with the fd closed (:62, :70, :94). The map is owned by the material and freed in free_scene (free_scene.c:26).

The parser trick (material_token_count, parse_texture_bonus.c:102-116): if the line's last token ends in .pgm, load it and return count βˆ’ 1 β€” the rest of the material parser then sees the line as if the texture token never existed. And in the mandatory binary nothing loads PGMs at all: its strict check_count sees one token too many and refuses the scene, as the subject requires.

The shading (apply_bump, bump_bonus.c:48-69 β€” called from shade_bonus.c:23, right after the checkerboard) is four small steps per hit:

  1. Where am I on the map? Convert the hit's normal to sphere UV β€” longitude from atan2(n.z, n.x), latitude from asin(n.y) (the classic globe unwrap; the M4 functions in reverse) β†’ a texel (x, y).
  2. Which way does the ground slope here? Central differences: height at (x+1) minus height at (xβˆ’1) gives the east–west slope; same vertically. This is a gradient β€” exactly the M7 cone-normal idea, but sampled from data instead of derived from algebra. The x lookup wraps (longitude is a circle) while y clamps (the poles end, bump_at :15-23).
  3. Tilt in which directions? Slopes need axes to tilt along: a tangent frame β€” tangent = cross(world_up, N) (perpendicular to the normal, M3; fallback (1,0,0) at the poles where the cross degenerates) and bitangent = cross(N, tangent).
  4. Tilt. N' = normalize(N + tangentΒ·grad_uΒ·2.2 + bitangentΒ·grad_vΒ·2.2) β€” the 2.2 is the bump strength. Every later consumer (diffuse, specular, shadow bias) just uses the tilted normal; nothing else changes.
Defense phrasing β€œA bump map is a grayscale height image. At each hit I read how the height changes around the point β€” a gradient β€” and tilt the shading normal by that slope in the surface's tangent directions. Lighting then behaves as if the relief existed. The geometry, intersections and silhouette are untouched, which is exactly the definition of bump mapping as opposed to displacement mapping.”

Demo: ./miniRT_bonus scenes/bonus/bump_sphere.rt β€” a cratered sphere (craters.pgm) next to an identical smooth one under the same light; ripples.pgm is the second sample texture. Bad-input demos: a nonexistent path, a non-P5 file, a truncated file β€” each prints a specific Error message, and valgrind stays clean (no leaks, no open fds).

09 Β· subsystemWindow & MLX

srcs/window/

mlx_setup

mlx_init.c:15-29

Straight-line init, NULL-checked at every step: mlx_init() (:17 β€” failure β†’ Error\nmlx_init failed, exit 1; verified to fail cleanly with no DISPLAY, no segfault), mlx_new_window(800, 600, "miniRT") (:20), mlx_new_image (:23), mlx_get_data_addr (:26 β€” fills bpp, line_len, endian). Ends with app->is_locked = 1 (mlx_init.c:37) β€” the camera starts LOCKED; press SPACE before demoing movement!

mlx_put_pixel

mlx_utils.c:15-26

argb = ((int)(r*255.999) << 16) | ((int)(g*255.999) << 8) | (int)(b*255.999);
dst  = mlx->back_addr + y * line_len + x * (bpp / 8);
*(unsigned int *)dst = argb;
  • Writes go to back_addr β€” the back buffer of the double-buffering scheme (Β§P1); the front image is what the window shows.
  • Γ—255.999 maps 1.0 β†’ 255 without ever producing 256.
  • Why an image buffer and not mlx_pixel_put? mlx_pixel_put is one X11 round-trip per pixel (480 000 per frame); we write RAM and blit once. Also, pixels drawn before the window is mapped are lost β€” an image persists and the Expose handler re-puts it.

line_len β€” the stride, drawn out

When you call mlx_new_image, there's no grid in memory β€” just one long line of bytes. To find pixel (x, y) you must compute where row y starts, and for that you need to know how many bytes one row occupies. That number is line_len, and the concept is called the stride.

Why isn't a row just width Γ— 4 bytes? With 32 bits per pixel, 800 pixels of pixel data do take 800 Γ— 4 = 3200 bytes. But the library is allowed to make each row slightly longer than its data, leaving unused padding bytes at the end, so that every row begins at a nicely aligned memory address (divisible by 8, 16, 64…). Aligned rows make copying to the X server and the hardware faster. So the real layout can be:

row 0:  [3200 bytes of pixels][64 bytes padding]   ← row is 3264 bytes long
row 1:  [3200 bytes of pixels][64 bytes padding]
row 2:  …

line_len is the honest answer to β€œhow far apart are two consecutive rows, in bytes?” β€” 3264 in this sketch, not 3200.

What happens if you use width Γ— 4 anyway? Row 0 lands correctly. Row 1 gets written 64 bytes too early β€” into row 0's padding and a bit before its true start. Row 2 is off by 128 bytes. The error grows with every row, so the picture comes out sheared into a diagonal smear β€” a classic, instantly recognizable bug.

The treacherous part At 800Γ—600 with 4-byte pixels, 3200 is often already a multiple of the alignment β€” so line_len == 3200 and the wrong formula happens to work. Change the width to 801 someday and it silently breaks. That's why the address formula always uses what mlx told you: dst = addr + yΒ·line_len + xΒ·(bpp/8) β€” β€œgo down y rows of line_len bytes each, then right x pixels of 4 bytes each.”
The one-line defense answer β€œmlx_get_data_addr hands back three numbers; line_len is the real byte distance between rows β€” it can include alignment padding, so it isn't necessarily width times four. Using it makes the code correct at any resolution.” An evaluator who asks about those three numbers is checking whether you copied a tutorial or understood the memory layout β€” and mlx_put_pixel does exactly the right thing.

Events

main.c:24-28 + mlx_hooks.c β€” the magic numbers are X11 protocol event codes

HookEventMaskHandler
mlx_hook(win, 2, 1L<<0, …)KeyPressKeyPressMaskkeys (records held)
mlx_hook(win, 3, 1L<<1, …)KeyReleaseKeyReleaseMaskrelease held key
mlx_hook(win, 10, 1L<<21, …)FocusOutFocusChangeMaskdrop all held keys (no stuck movement after alt-tab)
mlx_hook(win, 17, 0, …)DestroyNotifynone neededred cross
mlx_hook(win, 12, 1L<<15, …)ExposeExposureMaskre-blit
mlx_mouse_hookButtonPressβ€”scroll zoom
  • key_handler mlx_hooks.c:42-68 β€” ESC β†’ close_handler; SPACE toggles is_locked (prints πŸ”’/πŸ”“); when locked everything else is ignored; WASD move at MOVE_SPEED Γ— dt (held keys, time-based) and set needs_render β€” the actual repaint happens in the loop hook, sliced and preview-first (Β§P1).
  • close_handler mlx_hooks.c:70-78 β€” both ESC and the red cross funnel here: mlx_destroy_image β†’ mlx_destroy_window β†’ mlx_destroy_display (added in the soutenance commit; kills an X connection leak) β†’ free(mlx.ptr) β†’ free_scene β†’ exit(0).
  • expose_handler :80-84 β€” re-puts the stored image, so the window survives minimize/overlap without re-raytracing, and the first frame isn't lost if the put happens before the window is mapped.

P1 Β· performanceThe optimization story β€” how the camera became fluid

The starting problem

Before the fix, every keypress did this: key_handler β†’ move_camera β†’ render() β€” a full 2–5 second ray trace inside the event handler. While rendering, no events were processed. So holding a key for one second queued ~30 more keypresses, each of which would trigger another full render. The window froze for minutes, and even ESC sat unprocessed in the queue.

The real diagnosis The bug wasn't that rendering was slow β€” it's that everything waited for it.
BEFORE                                AFTER
key event                             key event
  └─ move camera                        └─ move camera
  └─ render EVERYTHING (seconds,        └─ set needs_render = 1   (microseconds)
     30 more keys pile up,
     ESC waits minutes)               event queue empty?
                                        └─ loop_hook: render 32 rows, return
                                        └─ (events checked)
                                        └─ loop_hook: next 32 rows … until done
                                        └─ swap the finished frame onto screen

The five mechanisms, in the order they matter

1 Β· Never render inside an event handler. Handlers now do only two things: update the camera and set a flag (needs_render = 1, press_move_key, keys.c). A mlx_loop_hook β€” a function MiniLibX calls whenever the event queue is empty β€” checks the flag and renders (loop_hook, render_loop.c:52). The consequence is subtle but huge: a burst of 30 key repeats just sets the same flag 30 times, and exactly one render happens once the burst ends. Input can never pile up work anymore. This is the classic GUI pattern: events invalidate, a separate loop paints.

2 Β· Slice the render so it's interruptible. The loop hook doesn't render a whole frame β€” it renders 32 rows (SLICE_ROWS, miniRT.h:27), remembers where it stopped (app->row), and returns (render_span, render.c:77). MiniLibX then checks for events before calling it again. So ESC now waits at most one slice (~50 ms) instead of one frame (~seconds) β€” measured: ESC responds in 0.12 s where the old code took 11.7 s. And if a keypress arrives mid-frame, the flag resets row = 0 and the pass simply restarts with the new camera β€” no stale work completes.

3 Β· Two-speed rendering. While you're moving, perfection is wasted β€” the frame will be replaced in 100 ms anyway. So movement frames trace one ray per 2Γ—2 pixel block into a half-resolution buffer (app->half): ΒΌ the pixels Γ— no anti-aliasing = ~1/16 the work. When events stop arriving, a full-quality pass re-renders automatically. The full path's math is untouched β€” which is why the still image could be proven bit-identical to the old renderer's output.

4 Β· Edge-aware upsampling β€” the reason there's "no transition". A naive half-res preview looks like mosaic squares. Instead, for every output pixel the code looks at the 4 half-res samples around it and asks: do they agree? In flat regions (the sky gradient, the inside of a surface) they do β€” and bilinear interpolation between them is visually indistinguishable from actually tracing the pixel. Where they disagree beyond EDGE_SPREAD (miniRT.h:29) β€” a silhouette, a checker boundary β€” the code fires a real full ray for that exact pixel (upsample_span, preview.c:83). So edges are pixel-perfect and only flat areas are interpolated. Measured: the preview differs from the final image by ~1.3% of pixels, at roughly a third of a ray per pixel. That's the trick β€” the first image after you stop moving is already almost the final one, so the "refine" pass is nearly invisible.

5 Β· Double buffering. All rendering goes into a back image; only when a pass completes does the code mlx_put_image_to_window and swap front/back (present_frame, mlx_utils.c:28). The window therefore never shows a half-drawn frame β€” you never see the slices sweep β€” and when the window is minimized and restored, the expose handler re-puts the last complete frame. That's exactly what the eval sheet's "window content must remain consistent" test checks.

The preview, drawn out β€” mechanisms 3 + 4 in pictures

Preview mode is a cheap, temporary render so the program can respond instead of computing the full 800Γ—600 image at maximum quality on every movement frame. It deliberately calculates fewer real rays, then reconstructs the missing pixels.

Step 1 β€” render a coarser grid. In preview mode render_span strides with step = 1 + app->fast, so it computes every second pixel of every second row β€” X means "actually shoot a ray here", Β· means "not yet":

FULL                      PREVIEW
X X X X X X               X Β· X Β· X Β·
X X X X X X               Β· Β· Β· Β· Β· Β·
X X X X X X               X Β· X Β· X Β·
X X X X X X               Β· Β· Β· Β· Β· Β·

Each computed sample also skips anti-aliasing (1 ray instead of 4) and lands in a half-resolution buffer β€” ΒΌ the pixels Γ— ΒΌ the rays β‰ˆ 1/16 the work.

Step 2 β€” fill the holes by looking at the neighbors. For every missing pixel, upsample_span asks the four nearest computed samples:

A ─────── B        A,B,C,D all similar (blue sky, a flat wall)?
β”‚         β”‚          β†’ this is a SMOOTH area:
β”‚    ?    β”‚            interpolate between them (bilinear)
β”‚         β”‚            β€” visually indistinguishable from a real ray
C ─────── D

Step 3 β€” but what if there's an edge?

blue sky      blue sky      The four samples disagree strongly β†’
                            something important happens here: a
        ?                   silhouette, a checker boundary.
                            Don't guess β€” FIRE A REAL RAY for
red sphere    red sphere    this exact pixel.

The decision is is_edge: if any color channel's spread (max βˆ’ min of the four samples) exceeds EDGE_SPREAD, the pixel gets the real-ray treatment. That's why it's called edge-aware upsampling:

surrounding colors similar   β†’ interpolate (cheap, invisible)
surrounding colors different β†’ probably an edge β†’ real ray (exact)

Why this works. Large smooth areas β€” sky, floors, the inside of a sphere β€” don't need every pixel computed independently: several samples already agree on the answer. Precision only matters where the image changes. So the preview spends its computation exactly there, and nowhere else. Measured result: the reconstructed frame differs from a fully traced one by ~1.3% of pixels, at roughly a third of a ray per pixel.

Step 4 β€” present. When the preview pass finishes, present_frame runs the upsampling and only then blits β€” the window receives a complete reconstructed image, never a half-filled grid.

The main idea to remember Preview mode is not a different ray tracer. It uses the same camera, the same rays, the same intersections and shading. The optimization is one sentence: don't calculate every pixel unless you need to β€” compute a coarse image, interpolate the smooth regions, and spend real rays around edges where guessing would show. That is the conceptual reason preview.c exists.
Refined since (the pr18 pass) The same five mechanisms, sharpened: movement is now held-key and time-based (KeyRelease + FocusOut hooks track a key bitmask; each idle tick moves the camera by MOVE_SPEED Γ— elapsed time via gettimeofday β€” smooth glide, no stuck keys after alt-tab); each pass renders from a camera snapshot (render_camera), so mid-frame input can't tear a frame; quality passes use 2-row slices (quality rows cost ~16Γ— more, so the slice shrinks to keep latency constant); and the edge-aware upsampling itself runs in slices (upsample_span). One more micro-optimization rode along: the shadow test now stops at the first blocker (any-hit) instead of finding the nearest β€” same boolean, bit-identical image, faster with many objects and lights (Β§R6). Walk it all live: the β€œA keypress, from event to pixel” trail in the explorer.

Why this is the only shape a fix can take

Ray tracing cost is roughly pixels Γ— rays-per-pixel Γ— objects Γ— (1 + lights) β€” physics you can't cheat for the final image. That leaves exactly three legal moves:

  • Do less work when nobody can see the difference β†’ motion gets the preview (mechanisms 3 + 4);
  • Never let the work block input β†’ the flag and the slices (mechanisms 1 + 2);
  • Never show unfinished work β†’ the double buffer (mechanism 5).

Every line of the optimization is one of those three. Nothing else exists.

Where it lives, for the defense

PieceLocation
-O2 (halved every number to start with)Makefile:5
flag + loop-hook logicloop_hook, srcs/render/render_loop.c:52 (flag set in press_move_key, srcs/window/keys.c; hook registered in main.c)
32-row slicingrender_span, srcs/render/render.c:77 Β· SLICE_ROWS, miniRT.h:27
preview + edge-aware upsamplesrcs/render/preview.c (upsample_span :83, spread test vs EDGE_SPREAD) β€” the upsampling itself runs in slices too
front/back swappresent_frame, srcs/window/mlx_utils.c:28
statet_app: needs_render, fast, row, half (miniRT.h:205-212)

A later web version scaled the same ideas further β€” slices became worker bands, the loop hook became a scheduler that measures each tier's cost, plus a bounding-sphere accelerator β€” but none of that touched the 42 repo. What's submitted is exactly the five mechanisms above: all within mlx's public API, all norm-clean, and all provably output-identical on still frames.

S1 Β· operationsScene-file reference

The complete .rt format β€” every field, every default, every gotcha β€” verified against the parser on the current (PR-17) branch.

General rules

  • One element per line; identifier first, then fields in strict order, separated by whitespace. Blank lines and #-comment lines are allowed anywhere; elements in any order.
  • Capital-letter identifiers (A C L) are unique β€” declared once. The bonus lifts this for L only (multiple lights).
  • Colors are R,G,B as integers 0–255 (12.5 is rejected); ratios in 0.0–1.0; orientation vectors need each component in [βˆ’1, 1] β€” the parser re-normalizes the vector, but components outside the range are rejected.
  • ⚠️ Sphere and cylinder sizes are DIAMETERS, not radii β€” the parser divides by 2. The cone's size is a RADIUS (its own documented format β€” parse_objects_bonus.c:20 says <radius> and stores it unhalved).
  • Any malformed field β†’ Error + exit(1).

Mandatory elements

A   0.2   255,255,255
β”‚    β”‚         └─ ambient color
β”‚    └─ ambient ratio [0..1]  (how much light objects get with no lamp)
└─ Ambient light β€” exactly one

C   -50,0,20   0,0,1   70
β”‚       β”‚        β”‚      └─ horizontal FOV in degrees, strictly 0 < fov < 180
β”‚       β”‚        └─ viewing direction (normalized; parser re-normalizes)
β”‚       └─ camera position x,y,z
└─ Camera β€” exactly one

L   -40,50,0   0.6   10,0,255
β”‚       β”‚       β”‚        └─ light color (ignored in mandatory, used in bonus)
β”‚       β”‚       └─ brightness ratio [0..1]
β”‚       └─ light position x,y,z
└─ Light β€” one in mandatory, several in bonus

sp   0,0,20   12.6   10,0,255
β”‚       β”‚       β”‚        └─ color
β”‚       β”‚       └─ DIAMETER (radius will be 6.3)
β”‚       └─ center x,y,z
└─ sphere

pl   0,0,-10   0,1,0   0,0,225
β”‚        β”‚       β”‚         └─ color
β”‚        β”‚       └─ normal vector (this one = plane faces up: a floor)
β”‚        └─ any point on the plane
└─ plane (infinite)

cy   50,0,20.6   0,0,1   14.2   21.42   10,0,255
β”‚        β”‚         β”‚      β”‚       β”‚         └─ color
β”‚        β”‚         β”‚      β”‚       └─ height (extends Β±height/2 from center)
β”‚        β”‚         β”‚      └─ DIAMETER
β”‚        β”‚         └─ axis (0,1,0 = standing up; 0,0,1 = lying along z)
β”‚        └─ CENTER of the cylinder (mid-height, not its base)
└─ cylinder, with caps

Bonus-only extensions

All of these are rejected by the mandatory binary (strict token counts, no co/tr in its dispatch table).

co   0,10,10   0,-1,0   6   14   255,100,40
β”‚        β”‚        β”‚     β”‚    β”‚        └─ color
β”‚        β”‚        β”‚     β”‚    └─ height (body spans apex β†’ apex + axisΒ·h)
β”‚        β”‚        β”‚     └─ RADIUS (not a diameter!)
β”‚        β”‚        └─ axis, points from the apex toward the base
β”‚        └─ APEX position (the tip)
└─ cone

tr   5,-5,4   14,-5,4   9,-5,16   200,180,60
β”‚       └────────┴─────────┴─ three vertices    └─ color
└─ triangle (two-sided; degenerate/collinear rejected at parse)

The optional material tail

After any object's color you may append, in this exact order β€” stop wherever you want, but earlier positions are required to reach later ones (parse_opts_bonus.c:21-44):

PosFieldDefaultMeaning
+1ks0.3specular strength [0..1] (0 = matte)
+2shininess32highlight tightness, β‰₯ 1 (8 = broad blob, 256 = tight glint)
+3reflectivity0(parsed, reserved β€” not used by shading)
+4transparency0(parsed, reserved)
+5ior1.0(parsed, reserved, β‰₯ 1)
+6checkerboard01 = checker pattern on this object
+7checker_size1.0edge length of one checker cell, world units
last*.pgmβ€”bump-map height texture (Β§B1). Designed for spheres β€” the UV unwrap is spherical; the parser accepts it on any object, but only spheres get a meaningful mapping.

Decoded examples from this repo's own scenes:

pl 0,-3,0 0,1,0 200,200,200  0.4 64   0 0 1   1 2
                              β”‚   β”‚   └─┴─┴─ refl/transp/ior left neutral
                              β”‚   └─ shininess 64      └─ checker ON, 2-unit cells
                              └─ ks 0.4

sp -2.6,0,0 4.4 205,175,130  0.35 24  0 0 1  0 1  scenes/bonus/textures/craters.pgm
   cratered bump-mapped sphere: mild specular, no checker, PGM height map last

Practical gotchas when writing test scenes

  • Camera direction is a direction, not a target. To look at the origin from position p, use dir = βˆ’p (the parser normalizes): from 14,19,-23 β†’ roughly -0.45,-0.6,0.75.
  • Cylinder and cone position semantics differ: cy takes its center (body spans Β±h/2), co takes its apex (body spans apex β†’ apex + axisΒ·h). A cone "sitting" on a floor at y = βˆ’5 with height 14 opening downward: apex at y = 9, axis 0,-1,0.
  • A plane's normal sign doesn't matter visually (shading is two-sided, Β§05) β€” but point + normal must describe the plane you mean.
  • Ambient and brightness combine. With ambient near 0 shadows go pitch black; near 1 everything washes out. A 0.2 + L 0.7–0.85 is the sweet spot for eval scenes.
  • Any malformed field exits with Error β€” negative diameters, 299 in a color, FOV 190, letters in numbers, 1,2,, duplicate C… that's precisely what the evaluator will type (drill list: Β§13 Parsing).

10 Β· operationsScenes guide

Mandatory format: A ratio r,g,b Β· C pos dir fov Β· L pos brightness r,g,b Β· sp pos diameter color Β· pl point normal color Β· cy center axis diameter height color. Full field-by-field reference, defaults and gotchas: Β§S1.

scenes/ β€” run with ./miniRT

SceneShows / when to use
full_mandatory.rtplane + sphere + cylinder + light β€” the main demo
sphere.rtdecimal values, tilted cylinder (axis 0,0,1) β€” float parsing
cylinder_inside.rtcamera at the cylinder's center β€” inside-surface normals
cylinder_side.rt / cylinder_top.rtbody from the side; caps from above
floor_shadow.rt / min_shadow.rthard shadow; min_shadow has ambient 0.05 (near-black shadow β€” the ambient-formula proof)
two_planes.rtplanes visible from the correct side (two-sided planes)
wall.rtvertical plane behind a sphere
ten_objs.rtseveral objects β€” closest-hit correctness

scenes/bonus/ β€” run with ./miniRT_bonus

SceneShows
showcase.rtone-stop bonus demo: 2 colored lights, cone, triangle, checkerboard plane with the full material tail
bump_sphere.rtbump mapping: a cratered sphere (craters.pgm) next to an identical smooth one, same light (Β§B1)
cone_triangle.rtcone apex-down + flat and vertical triangles
specular_test.rt / material_test.rtspheres with ks .9/.5/0 and shininess 256/8/32 β€” the Phong highlight varying
atelier.rt / nocturne.rtart scenes, multi-light (warm key + cool fill) β€” the best-looking demos

Bonus syntax by example (from showcase.rt)

L  -8,10,-6  0.8  255,80,80                    # colored light (color IS used in bonus)
co 4,-3,3  0,1,0  2.5  6  60,200,120  0.5 32   # cone: apex axis RADIUS height color [ks shin]
tr -1,4,7  -3,1,7  1,1,7  220,180,60           # triangle: three vertices + color
pl 0,-3,0  0,1,0  200,200,200  0.4 64 0 0 1 1 2
#  tail: ks=0.4 shininess=64 refl=0 transp=0 ior=1 checkerboard=1 size=2
sp -2.6,0,0 4.4 205,175,130 scenes/bonus/textures/craters.pgm
#  a .pgm as LAST token = bump map (works with or without the tail) β€” Β§B1

The bonus scenes also demonstrate # comments and blank lines being accepted.

11 Β· operationsBuild & project health

zero warnings no relink norminette clean no forbidden functions

  • Makefile: make β†’ miniRT, make bonus β†’ miniRT_bonus, flags -Wall -Wextra -Werror -ggdb; builds mlx_linux and libft first; links -lmlx -lft -lXext -lX11 -lm. Verified: both targets compile with zero warnings, and re-running make does not relink (β€œNothing to be done”) β€” a standard evaluator check. Objects depend on both headers (Makefile:109), so header edits rebuild correctly.
  • Norminette 3.3.59: fully clean on includes/, srcs/, libft/.
  • Forbidden functions: clean. nm -u on both binaries minus MLX/libc internals leaves only open close read write malloc free exit and math (sqrt tan, + floor pow in bonus, allowed via -lm). The suspicious-looking rest (calloc getenv gethostname shm* strlen strncmp puts) all come from inside MiniLibX (X shared memory, getenv("DISPLAY")) β€” zero hits in project code. Number parsing is a homemade ft_strtod, not libc atof.

The soutenance commit (228f5f3) β€” β€œwhat did you fix recently?”

  1. mlx_destroy_display on close β€” X connection leak fixed.
  2. The Expose handler β€” the window survives minimize; the image is re-put if the first draw beats the window mapping.
  3. FOV made horizontal, as the subject defines (camera.c).
  4. A cylinder bug where a ray whose only hit was the bottom cap was lost (best not updated) β€” fixed in cylinder.c.
  5. read_line read a byte before checking read's return β€” an uninitialized read when handed a directory β€” fixed.
  6. The 1 MB scene cap (then refined by ab79a0f to count only bytes actually read, and c026715 to fit the norm's 25-line limit).

12 Β· operationsKnown weaknesses β€” read before the defense

#1 Β· Fixed β€” and worth telling as a story

The bonus light parser used to malloc each light and set pos/brightness/color/next but never kc, kl, kq β€” yet attenuation() read all three on every shading sample: an uninitialized-heap read in every miniRT_bonus frame. It looked fine (fresh pages are usually zero β†’ 1/0 = inf β†’ clamped to 1.0), but valgrind printed β€œConditional jump … depends on uninitialised value”. Fixed since: parse_light_bonus.c:47-49 now initializes all three. If asked "did you have bugs?", this is a great answer β€” invisible on screen, caught by valgrind, three-line fix.

  1. MAX_DEPTH / depth is dead scaffolding β€” no recursion (no reflection/refraction). Say it plainly: β€œhook for a bounce loop; rays terminate at first hit.”
  2. ka, kd, reflectivity, transparency, ior, roughness are parsed/stored but never used in shading; the consumed fields are ks, shininess, the checker pair, and the bump map. reflectivity/transparency/ior remain forward-looking storage.
  3. Error paths now clean up β€” error_exit calls cleanup_all() (frees the scene, closes the fd) before exiting, so even mid-parse failures are valgrind-clean. Verify once and quote the numbers.
  4. Performance: still a linear object list, no acceleration structure β€” a 10k-object scene renders slowly (hence the 1 MiB parse cap). But input no longer blocks on rendering: see Β§P1 for the flag + slices + preview architecture.
  5. mlx_put_pixel assumes 32 bpp little-endian (*(unsigned int *)dst; endian stored but unused) β€” always true on evaluation Linux/X11.
  6. Cone a β‰ˆ 0 (ray parallel to the slant) has no explicit guard like the cylinder's: roots become Β±inf and are rejected by the proj range check β€” implicit but harmless; know it.
  7. Lines > 4095 chars are split by read_line; the continuation chunk fails as β€œunknown identifier” β€” clean failure, no overflow.
  8. Camera within ~8Β° of vertical (the 0.99 threshold) switches the up-hint β€” can cause a visible roll change between two nearly-vertical cameras; cosmetic.
  9. Plane t = 0 rejected by EPSILON β€” a camera exactly on a plane doesn't see it; by design.
  10. The checkerboard token is a truncating cast (0.9 β†’ 0) and checker_size ≀ 0 is accepted by the parser (but neutralized in apply_checker) β€” defensible as β€œdocumented format: 0/1 and a positive size.”
  11. README vs reality: the header defines KEY_UP/DOWN/LEFT/RIGHT (miniRT.h:34-37) and the README advertises arrow keys, but key_handler only acts on WASD (mlx_hooks.c:62). The README also mentions reflection/transparency β€” parsed-but-unimplemented material fields. Either align the README before the defense or don't demo those.
  12. error_exit after parsing (e.g. mlx_new_window failure) exits without free_scene β€” OS-reclaimed and still-reachable, but know it exists. Also mlx_get_data_addr's return is not NULL-checked (mlx_init.c:26).
  13. The camera starts locked (is_locked = 1, mlx_init.c:28) β€” not a bug, but forget to press SPACE during the demo and movement β€œdoesn't work.”

E1 Β· defenseThe eval sheet's geometry tests, decoded

β€œTranslate this object” β€” the scene file IS the interface

miniRT has no UI command like β€œselect sphere β†’ translate 10 units.” The scene is defined by the .rt file β€” so to translate an object you change its coordinates in the file and run miniRT again; to rotate a cylinder or cone you change its orientation/axis vector and run again. That's not a missing feature β€” it's the design the eval sheet expects you to demonstrate.

sp 0,0,0  10 255,60,60      ← before: red sphere centered at the origin
sp 12,0,0 10 255,60,60      ← "translated": same sphere, new center

The sphere never gained a translate() function. You changed its center, and every piece of existing intersection code works with the new position automatically, because it reads obj->sphere.center β€” concretely, the very first line of the sphere test: oc = vec3_sub(ray.origin, obj->sphere.center). The whole chain:

edit object position           edit cylinder axis
        ↓                              ↓
parser stores new center       parser stores new axis
        ↓                              ↓
intersection uses new center   cylinder intersection uses new axis
        ↓                              ↓
render changes accordingly     cylinder appears rotated
What is really being tested Not a β€œtranslation feature” β€” the evaluator is checking that your geometry is not hardcoded to the origin or to the world axes. Say exactly that.

Making eval scenes fast at the terminal β€” a heredoc writes a scene without opening an editor (nothing ray-tracing-specific about it):

cat > scenes/eval/tr1_before.rt <<'EOF'
A 0.2 255,255,255
C -30,12,0 1,-0.35,0 70
L -25,20,-15 0.75 255,255,255
sp 0,0,0 10 255,60,60
sp 0,0,0 10 60,60,255
EOF

That scene puts a red and a blue sphere at exactly the same place β€” their surfaces coincide, so you won't see β€œtwo spheres.” (Fun precision: with coinciding surfaces the intersection distances tie, and the strict tmp.t < closest comparison in intersect_scene keeps the earlier object in the list β€” the red one.) The β€œafter” scene then moves one sphere, and the render must change accordingly.

A trap in the test's own wording β€œTranslate parallel to the camera's direction, both spheres should be visible” is potentially misleading: move a same-sized sphere exactly along the line of sight and the nearer one completely hides the farther one β€” correctly! For a useful visual assertion, give the translation some sideways separation, or place the camera so both stay distinguishable. The math is right either way; the visibility claim isn't.

β€œCamera along the Y axis” β€” the test that hunts one specific bug

The difficult case is a camera looking straight up or straight down, e.g. dir = (0,βˆ’1,0). The camera basis normally starts from right = cross(dir, world_up) with world_up = (0,1,0). But now look at the two directions: (0,βˆ’1,0) and (0,1,0) are the same line. Ask the cross product β€œwhich way is sideways, given vertical and vertical?” and there is no unique answer β€” left, right, toward you, away from you are all equally perpendicular. So it returns (0,0,0): β€œI cannot determine a sideways direction from parallel inputs.” Zero is not a direction β†’ no right β†’ no horizontal viewport β†’ the camera math breaks. That's exactly what the evaluator is fishing for.

How the code survives it (get_camera_axes, camera.c):

if (fabs(dir.y) < 0.99)
    world_up = vec3(0, 1, 0);
else
    world_up = vec3(0, 0, 1);

Read it as: β€œis the camera NOT looking almost vertically? Then Y is a fine reference. Otherwise Y is useless β€” use Z instead.” For dir = (0,βˆ’1,0): fabs(dir.y) = 1 β†’ reference becomes (0,0,1), perpendicular to the view, and the crosses work again:

forward = ( 0,βˆ’1, 0)      looking straight down
right   = (βˆ’1, 0, 0)      = cross(forward, (0,0,1))
up      = ( 0, 0, 1)      = cross(right, forward)

Geometrically that's the only sane answer: looking straight down Y, the image lies in the X–Z plane β€” world Y can't serve as β€œup on the screen” anymore because Y is now the direction you're looking through the screen. The screen must be spanned by the other two axes.

The reframing that makes it click Despite its name, world_up here is really just a reference direction that is not parallel to the camera direction. The fallback does not change where the camera looks β€” it only changes the helper used to orient the camera's rectangular screen.

The evaluator's camera placements, and what each one actually challenges:

Camera directionMain challenge
along Xdoes the computed right/up rotate correctly?
along Zthe normal camera-basis case
along Ythe special case: dir parallel to the default world_up
arbitrarydoes everything work when no axis is conveniently aligned?

E2 Β· defenseThe control sheet, test by test

This is the official evaluation sheet turned into a runbook: for every test β€” what the evaluator does, the ready-made scene to open (all live in scenes/eval/, one file per test, named after its section), the pass criterion, and the sentence to say while it renders. Each sheet section is all-or-nothing: one failed test = zero points for that section.

The grade-0 gate comes first Before any points exist, two preliminaries can end the defense at 0: (1) make compiles cleanly, without relink, and produces miniRT β€” demo: make re, then make again (β€œNothing to be done”); (2) flawless error handling β€” a misconfigured file or a name not ending in .rt must print Error and exit properly. Have the Β§S1 gotcha battery ready to type: no argument, x.txt, a directory named foo.rt, 1,2,, RGB 299, FOV 190, duplicate C, missing L, letters in numbers. Every one exits with a specific message and, since the cleanup work, valgrind-clean.

Technical elements of the display

  • Window opens with one parameter and stays open β€” ./miniRT scenes/eval/t1_sphere_origin.rt. The window lives until you close it; the render arrives via the loop hook (Β§P1).
  • Hide / overlap / minimize / restore β†’ content stays consistent β€” drag another window across it, minimize and restore. Why it works: frames are drawn into an image buffer and the Expose handler re-puts the last complete frame β€” no re-raytracing, no blank window (Β§09, Β§P1 mechanism 5).
  • Resolution change β†’ content consistent β€” the window is a fixed 800Γ—600 image; changing the screen setup just triggers Expose events, answered from the stored frame.
  • ESC and the red cross both exit properly β€” both funnel into the same close_handler: destroy image β†’ window β†’ display β†’ free scene β†’ exit(0). Offer valgrind as proof.

The basic shapes

  • t1_sphere_origin.rt β€” sphere at {0,0,0}, camera facing it. Say: the sphere looks round because of per-pixel normals (Β§M8) β€” without them it would be a flat disk.
  • t2_plane_z0.rt β€” plane with z value null (point 0,0,0, normal 0,0,1), camera facing it. Planes are two-sided (Β§05), so orientation of the normal can't hide it.
  • t3_cylinder_y.rt β€” cylinder along the y axis, camera facing it. Wall + two caps tested independently; smallest t wins (Β§05).

Translations and rotations

The full decode of what's really being tested is Β§E1 β€” the scene file IS the interface; geometry must not be hardcoded to the origin or the axes.

  • Translation β€” t4_translation_before.rt (red and blue spheres both at {0,0,0}) then t4_translation_after.rt (blue moved 8 units along the camera's direction β€” more than the diameter 6). Staging note baked into the scene: the camera sits slightly above and looks slightly down, so after the parallel translation both spheres remain visible instead of the near one occluding the far one (the trap E1 warns about).
  • Rotation β€” t5_rotation_before.rt (axis 0,1,0) then t5_rotation_after.rt (axis 1,0,0 β€” exactly a 90Β°/Ο€/2 rotation about z). Say: the cylinder has no rotate(); its orientation IS the axis vector, and every intersection reads it fresh.

Multi-objects

  • t6_multi_intersect.rt β€” a sphere and a cylinder overlapping. The seam is clean because visibility is decided per pixel by "smallest positive t wins" (intersect_scene, Β§05) β€” no special intersection-of-objects code exists or is needed.
  • t6_multi_repeat.rt β€” two spheres, two cylinders and a plane: objects are linked-list nodes, so the same type any number of times is free.

Camera position and direction

Same scene, four cameras, all pointed at {0,0,0}:

  • t7_cam_x.rt β€” along X (dir βˆ’1,0,0): proves right/up are computed, not hardcoded.
  • t7_cam_y.rt β€” along Y, straight down: the trap. dir is parallel to the default world-up, the cross product would be zero β€” the 0.99 guard swaps the reference to (0,0,1). Full story: Β§E1.
  • t7_cam_z.rt β€” along Z: the ordinary case.
  • t7_cam_random.rt β€” off every axis and diagonal (camera 14,19,βˆ’23, dir = βˆ’position pre-normalized into [βˆ’1,1]; the parser re-normalizes). Recipe for any such camera: dir = βˆ’pos, scaled until each component fits [βˆ’1,1].

Brightness 1/2 β€” sideways light, and the halo that must move

  • t8_brightness_side.rt β€” sphere at the origin, spot far to the left: one side lit, one dark, smooth falloff between β€” that gradient is max(0, dot(N, L)) in action (Β§07, Β§R6).
  • t8_brightness_translated.rt β€” the SAME sphere moved to (6,2,4), same light. The halo must sit on the moved sphere: it does, because lighting starts from the hit point, which comes from the intersection against the current center β€” nothing about lighting is precomputed at parse time. "Computed after translation, not before."

Brightness 2/2 β€” shadows

  • t9_shadow.rt β€” vertical spot above a sphere over a plane, camera off to the side: sphere, plane and the cast shadow all visible. Say: the shadow is not painted β€” it is the absence of the diffuse term where the shadow ray finds the sphere first (Β§R6); the edges are clean because of SHADOW_BIAS + EPSILON (no acne).
  • The complex scene β€” scenes/full_mandatory.rt (the illustration-V.6-style scene): several objects, all shadows consistent.

Bonus β€” gated, then one point each

The gate Bonuses are looked at only if the mandatory part is flawless start to finish, including twisted-input error handling. Secure the sheet above first; only then open the bonus tour.
Sheet bonus (1 pt each)StatusDemo
Specular reflectionβœ”scenes/bonus/specular_test.rt β€” shininess 256 vs 8 side by side
Color disruption: checkerboardβœ”scenes/bonus/showcase.rt β€” checkered plane
Colored and multi-spot lightsβœ”scenes/bonus/atelier.rt / nocturne.rt β€” warm + cool lights
One other 2nd-degree object (cone)βœ” (+ triangle)scenes/bonus/cone_triangle.rt
Bump map texturesβœ”scenes/bonus/bump_sphere.rt β€” cratered vs smooth, plus a broken .pgm for error handling

All five implemented β€” details, syntax and defense phrasing per feature in Β§B1. Run the bonus tour with ./miniRT_bonus; the mandatory binary must (and does) reject every bonus-only scene element.

Pre-flight, the morning of make re + second make (no relink) Β· norminette Β· every scene in scenes/eval/ opened once (eyes on each render) Β· the Β§S1 error battery typed once Β· valgrind on one good and one bad scene Β· SPACE unlocks the camera before any movement demo.

13 Β· defenseQ&A drill

Tap a question, answer out loud, then reveal. Use the buttons to open or close a whole group.

Parsing

What happens with a duplicate A / C / L?
Rejected via the has_ambient/has_camera flags (parse_ambient.c:17, parse_camera.c:17). Duplicate L: rejected in mandatory (parse_light.c:19), allowed in bonus β€” multiple lights is a bonus feature.
What if I feed you 1,2, β€” or 1,2,3,4 β€” or 1..2 β€” or ++5?
All exit with Error\n: an empty component fails valid_num; an extra component trips the !last || **s != '\0' guard in next_component; 1..2 and ++5 fail the number grammar.
RGB of 256 or 12.5? Negative diameter? FOV 180?
parse_color enforces integer [0,255]; sphere/cylinder reject ≀ 0 diameter/height; FOV must be strictly inside (0,180) (parse_camera.c:24).
Camera orientation 0,0,0?
Rejected β€” vec3_near_zero check in parse_normal (parse_utils.c:78).
A non-normalized orientation like 1,1,1?
Components must each be in [βˆ’1,1]; then the vector is re-normalized with vec3_norm. Justification: the subject says the input is a normalized vector; we canonicalize instead of punishing rounding (e.g. 0.577,0.577,0.577).
No .rt extension? A directory? An unreadable file? Empty file? Missing L?
Extension check (parse_scene.c:87); open failure (:90); a directory passes open but the first read returns EISDIR β†’ β€œread error” (:37); an empty or comment-only file β†’ β€œscene missing mandatory element (A, C, or L)” (:95-96).
How do you read lines without get_next_line?
Our own read_line: 1-byte reads into a 4096 buffer until \n/EOF, with a cumulative 1 MiB content cap (bump_total). Only allowed functions: open/read/malloc/free/write/exit.
Why is exit-on-error OK for memory?
On malformed input we exit(1) immediately; every allocation is still referenced from live stack frames, so valgrind shows 0 bytes definitely lost (β€œstill reachable” only). The normal path frees everything via free_scene in the close handler.
Where is the bonus code? How does the shared parser accept extra tokens only in the bonus?
The linking trick (Β§00): both binaries share the object parser, but the bonus links a lenient check_count and a real parse_material instead of the strict counter and the no-op stub. Same symbols, different files, zero #ifdefs.
Diameter or radius?
sp/cy take a diameter per the subject (Γ·2 at parse_objects.c:49, 79); the bonus cone takes a radius per its own documented format (printed in its error message).
Do you support comments and blank lines?
Yes β€” lines whose first token starts with # are skipped (dispatch.c:27) and blank lines produce zero tokens. Inline comments after data are not supported (extra-token error).

Math & intersections

What does the discriminant mean?
For atΒ² + bt + c = 0, disc = bΒ² βˆ’ 4ac (quad.c:22). Negative β†’ the ray's line never touches the surface β†’ miss. Zero β†’ tangent graze (one root). Positive β†’ the line crosses twice: entry (root1) and exit (root2).
Why compare t against EPSILON instead of 0?
Floating point: a ray spawned on a surface can re-intersect its own surface at t β‰ˆ 1e-16. Requiring t β‰₯ 1e-6 (quad.c, plane.c, cylinder_utils.c, cone, triangle) discards those self-hits β€” without it the render shows shadow-acne speckle.
Why compute a = dot(dir,dir) if the direction is normalized (a = 1)?
Defensive correctness: the formula stays valid for any direction vector; it costs one dot product and protects against a future caller that doesn't normalize.
Why must ray directions be normalized at all?
So t is a metric distance: nearest-hit comparisons across objects, the shadow light-distance check, and attenuation all assume t in world units (normalized at camera.c:73).
What happens when a ray is parallel to a plane?
denom = dot(dir, n) β‰ˆ 0 β†’ the division would blow up; we return a miss when |denom| < EPSILON (plane.c:21-23). Geometrically the ray never crosses the plane (or lies inside it β€” also treated as no visible hit).
Derive the cylinder intersection.
A point is on the tube iff its distance to the axis is r. Remove the axis component from D and from Oβˆ’C (cylinder_utils.c:23-26) β€” what remains is a plain circle quadratic in the perpendicular plane: a = dβŠ₯Β·dβŠ₯, b = 2Β·dβŠ₯Β·fβŠ₯, c = fβŠ₯Β·fβŠ₯ βˆ’ rΒ². Finite height: reject roots whose axial projection leaves [βˆ’h/2, +h/2]. Caps: two disks β€” plane hit + |P βˆ’ cap_center| ≀ r. Take the min of the three.
How do you know whether you hit the wall or a cap?
We never need a flag: body, top and bottom are tested independently, each fills its own normal (radial via body_normal, Β±axis for caps), and intersect_cylinder keeps the smallest t. Shading only consumes hit->normal.
Why does hit_body try both roots instead of quad_nearest?
A root valid on the infinite tube can be invalid on the finite one: the near root may land above/below the height range while the far root lands on the visible wall (tilted views, camera inside). Each root is validated with the height clamp before falling through (cylinder.c:26-35). Same pattern for the cone.
What if the camera is inside a sphere or cylinder?
root1 < 0 is rejected; root2 (the exit surface) is used. The front-face test (dot(dir, outward) < 0) fails, so the normal is negated to point back at the ray (sphere.c:17-29, cylinder_utils.c:73-77) and interior walls shade correctly β€” demo scenes/cylinder_inside.rt.
Cone: why can the ray hit β€œabove the apex”?
The algebraic cone is a double cone; the quadratic doesn't know about the apex. proj = n + tΒ·m < 0 identifies hits on the mirror half and rejects them (cone_utils_bonus.c:61-62).
Why does the cone normal contain a kΒ² term?
The surface is |perp|Β² βˆ’ kΒ²projΒ² = 0; the normal is its gradient ∝ perp βˆ’ kΒ²Β·projΒ·axis (cone_utils_bonus.c:50-51). A pure radial normal would be a cylinder normal and the shading would look flat-walled.
Explain MΓΆller–Trumbore in one sentence.
It solves the 3Γ—3 linear system O + tD = v0 + uΒ·e1 + vΒ·e2 by Cramer's rule using scalar triple products, rejecting as soon as a barycentric coordinate (u, then v, then u+v) leaves [0,1] β€” and the determinant's sign tells front from back face for free (triangle_bonus.c:25-50).
Where is the triangle's normal computed?
Once, at parse time β€” normalize(cross(v1βˆ’v0, v2βˆ’v0)) (parse_objects_bonus.c:52-54); render time only flips it by det's sign. Degenerate triangles are refused at parse (vec3_near_zero, :55).
How is the closest object chosen?
Linear scan of the object list in intersect_scene (intersect.c:25-35), tracking the minimum t. O(objects) per ray β€” no BVH/spatial structure, acceptable at miniRT scale.

Camera, render & shading

How does FOV change the image?
Viewport half-width = tan(fov/2) at unit distance (camera.c:42): a larger FOV β†’ wider viewport β†’ more scene per pixel β†’ zoom-out with perspective distortion at the edges; 179Β° β‰ˆ fisheye, 1Β° β‰ˆ telephoto. It's the horizontal angle; height follows from the aspect ratio.
Do you handle the camera looking straight up?
Yes β€” get_camera_axes switches the up-hint to (0,0,1) when |dir.y| β‰₯ 0.99: with dir parallel to (0,1,0) the cross product would return the zero vector ("no unique sideways"), so a non-parallel reference is substituted. The screen then spans the X–Z plane. Full decode of this eval test: Β§E1.
How do you translate or rotate an object?
The scene file is the interface: edit the object's position (or its axis vector for rotation) and rerun. Intersection code reads obj->sphere.center / obj->cylinder.axis from the structs, so everything recomputes β€” the test really checks that geometry isn't hardcoded to the origin or the world axes (Β§E1).
Explain your lighting model / Phong.
Three additive terms: ambient = object βŠ— (A_color Γ— ratio) (ambient.c:19-20); diffuse = object Γ— max(0, NΒ·L) Γ— brightness (diffuse.c:20-26) β€” Lambert's cosine law; specular (bonus) = white Γ— ks Γ— brightness Γ— (RΒ·V)^shininess (specular_bonus.c:31-37), where R is the light direction mirrored about the normal. Sum per light, gated by the shadow test, clamped at the end.
What is shadow acne and how do you avoid it?
Self-intersection at t β‰ˆ 0 from floating-point error when a shadow ray starts exactly on the surface. Avoided by offsetting the origin by SHADOW_BIAS (1e-4) along normal + light direction (shadow.c:23-25), and by all intersectors rejecting t < EPSILON.
Why do you clamp colors?
Several additive terms (multiple lights + specular) can exceed 1.0; unclamped values overflow the Γ—255 conversion (1.2Γ—255 = 306 bleeds into the wrong channel via the bit-shift packing). color_clamp at the end of shade guarantees each channel maps to 0-255.
Why multiply object color by light color?
Component-wise multiplication models physical absorption: a surface reflects each wavelength proportionally to its albedo, and the incoming light only contains its own spectrum. Red light (1,0,0) on a green object (0,1,0) β†’ black β€” physically right.
What happens if brightness > 1?
The parser rejects it (must be in [0.0, 1.0], parse_light*.c). Even if it entered, the final clamp bounds the pixel.
Where is the anti-aliasing?
render.c:58-76 β€” 2Γ—2 stratified supersampling: 4 rays per pixel at offsets 0.25/0.75, averaged.
Why is the void blue instead of black?
An aesthetic sky gradient in background() (render.c:19-29) β€” one line to make it black.
Why does the mandatory part ignore the light's color?
The subject states the L color field is unused in the mandatory part; the bonus applies it (shade_bonus.c:33).
How does bump mapping work if the geometry never changes?
Lighting only ever consumes the normal (brightness = dot(N,L)). apply_bump reads the height gradient of a grayscale PGM at the hit's sphere-UV and tilts the normal along the surface's tangent directions (bump_bonus.c). Diffuse and specular then behave as if relief existed. Intersections are untouched β€” the honest tell is that the silhouette stays a perfect circle (that's bump vs displacement mapping).
What is a PGM and why that format?
The simplest image format in existence: a "P5" text header (width, height, maxval 255) followed by one raw byte per pixel. It can be parsed with just open/read β€” no external library, no forbidden functions (parse_texture_bonus.c). Header checks + a full-read loop reject missing, malformed, or truncated files with a clean Error and a closed fd.
How does the parser accept the .pgm token only in the bonus?
The bonus material parser calls material_token_count: if the last token ends in ".pgm" it loads the map into the material and returns count βˆ’ 1, so the rest of the line parses as if the token weren't there. The mandatory build never links that code β€” its strict check_count sees one token too many and rejects the scene.

MLX, events & build

Why write into an image instead of mlx_pixel_put?
One X request per pixel = 480 000 round-trips per frame vs one memory write per pixel + one blit. Also, pixels drawn before the window maps are lost, while an image persists (re-put by the Expose handler).
What is line_len β€” why not WIDTH*4?
Bytes per row including padding β€” the X server may align rows; hardcoding WIDTH*4 can shear the image (an off-by-padding error that grows per row β†’ diagonal smear). Treacherous detail: at 800 wide, widthΓ—4 = 3200 is often already aligned, so the wrong formula happens to work β€” until the resolution changes. Same reason for bpp/8 instead of a literal 4. Full walkthrough: Β§09, β€œthe stride, drawn out.”
What are 2, 17, 12 and the masks in mlx_hook?
X11 event types (KeyPress, DestroyNotify, Expose) with their event masks (1L<<0 KeyPressMask, 1L<<15 ExposureMask; DestroyNotify needs mask 0 β€” it's delivered regardless).
ESC vs the red cross?
Both funnel into close_handler (mlx_hooks.c:70): destroy image β†’ window β†’ display β†’ free display ptr β†’ free_scene β†’ exit(0).
What if X isn't running / mlx_init fails?
NULL-checked at mlx_init.c:18; prints Error\nmlx_init failed to stderr, exit 1 β€” verified, no segfault.
Why doesn't the window go blank when I minimize or cover it?
The Expose handler re-blits the stored image without re-raytracing.
Any relink?
No β€” a second make does nothing (β€œNothing to be done”).
What actually happens when I type make vs make bonus?
Both compile the same COMMON_SRCS objects; the target then links them with either MAND_OBJS or BONUS_OBJS β€” different files exporting the same symbols (dispatch, check_count, parse_material, parse_light, hit_object, shade). The shared code calls whichever variant got linked in. 80–90% of the project is shared; only the decision points swap.
Why can't dispatch.c and dispatch_bonus.c both be compiled into one binary?
They define the same global function names β€” linking both would be a duplicate-symbol error. That's not a limitation, it's the design: exactly one implementation of each decision-point symbol may exist per binary, so the Makefile chooses the set at link time and no #ifdef is ever needed.
Why is the window responsive even while rendering?
Nothing renders inside event handlers: keys just update the camera and set needs_render (press_move_key, keys.c). A mlx_loop_hook paints 32 rows per call (render_span, SLICE_ROWS) and returns, so MiniLibX checks events between slices β€” ESC waits at most ~50 ms (measured 0.12 s vs 11.7 s before). See Β§P1.
What happens if I hold a movement key β€” don't renders pile up?
No. A burst of 30 key repeats sets the same flag 30 times; exactly one render starts once the loop hook runs. If a key arrives mid-frame, row resets to 0 and the pass restarts with the new camera β€” stale work is abandoned, never finished.
Does the fast preview change the final image?
No β€” it's two-speed rendering. Movement frames trace 1 ray per 2Γ—2 block (~1/16 the work) and upsample edge-aware (interpolate flat regions, fire real rays where the 4 neighbors disagree beyond EDGE_SPREAD β€” preview.c). Once input stops, a full-quality pass re-renders with untouched math: still frames are bit-identical to the pre-optimization renderer.
Why don't I ever see a half-drawn frame?
Double buffering: slices render into a back image; only a completed pass is mlx_put_image_to_window'd and swapped (present_frame, mlx_utils.c:28). The expose handler re-puts the last complete frame β€” exactly the eval sheet's "window content must remain consistent" check.

14 Β· defenseThe final 24 hours

  1. Hour 0 β€” if vectors/angles/light are new to you, read Level zero (Z1–Z5) first β€” especially the worked pixel in Z3 β€” then Foundations (M1–M9), doing each "check yourself" by hand. Everything else assumes them.
  2. Hour 0-1 β€” read Β§00 + Β§02 twice. Draw the pipeline from memory once.
  3. Hour 1-3 β€” parser. Open parse_scene.c and follow one line of a real scene through read_line β†’ split_line β†’ dispatch β†’ parse_sphere with this guide beside you. Rehearse next_component out loud.
  4. Hour 3-6 β€” intersections. Re-derive on paper: the sphere quadratic, plane t, the cylinder perpendicular decomposition. Then read the code and match each line to your derivation. The cylinder is the most-asked topic β€” overlearn it.
  5. Hour 6-8 β€” camera + render. Recompute half_w for FOV = 70 on paper; explain the y-flip and the 4-ray averaging. Then walk R1–R5 β€” the render path line by line, exactly as you'd narrate it in the defense.
  6. Hour 8-10 β€” shading. Learn the ambient formula (it was a fix β€” evaluators love β€œwhy did this change”). Walk shade_bonus with a two-light scene.
  7. Hour 10-11 β€” run everything: make re && make bonus, every scene in scenes/ and scenes/bonus/, valgrind on a good and a bad scene, norminette.
  8. Hour 11-12 β€” decide about Β§12 bug #1 (fix it or prepare to own it).
  9. Remaining β€” drill Β§13 with a partner or a rubber duck; sleep.

Live-demo checklist

  • ./miniRT scenes/full_mandatory.rt β€” the main demo.
  • ./miniRT scenes/cylinder_inside.rt β€” camera inside a cylinder works.
  • ./miniRT scenes/min_shadow.rt β€” hard shadow + correct ambient in shadow.
  • Bad inputs ready to type: ./miniRT, ./miniRT x.txt, a scene with 1,2,, RGB 256, FOV 180, duplicate C, missing L.
  • The eval scene pack is ready-made: scenes/eval/t1…t9, one file per control test (runbook: Β§E2) β€” plus heredoc skills for anything improvised (Β§E1).
  • ./miniRT_bonus scenes/bonus/showcase.rt (+ the others) for the bonus tour.
  • ./miniRT_bonus scenes/bonus/bump_sphere.rt β€” bump mapping, cratered vs smooth; have a broken .pgm ready to show the clean error handling.
  • Press SPACE to unlock the camera before demoing WASD/scroll; demo WASD only β€” arrow keys are not handled.
  • valgrind --leak-check=full output pre-captured for a good and a bad run (and settle Β§12 bug #1 first β€” valgrind on the bonus flags it today).

15 Β· defenseGlossary

TermMeaning here
RayP(t) = O + tΒ·D β€” origin O, unit direction D; t is the world-space distance along it.
DiscriminantbΒ² βˆ’ 4ac of a quadratic; its sign says whether the ray's line misses (βˆ’), grazes (0), or crosses (+) the surface.
NormalUnit vector perpendicular to the surface at the hit point; in this code it always faces against the incoming ray.
front_face1 if the ray hit the surface from outside, 0 from inside (normal was flipped).
Lambert / diffuseMatte reflection: intensity ∝ cos θ = max(0, N·L). View-independent.
Phong specularGlossy highlight: (RΒ·V)^shininess with R = reflection of the light direction about N. View-dependent.
AmbientConstant fill light approximating indirect bounce; here object βŠ— (A_color Γ— ratio).
Shadow acneSpeckle caused by a shadow ray re-hitting its own surface at t β‰ˆ 0; cured by bias + EPSILON.
AttenuationDistance falloff 1/(kc + klΒ·d + kqΒ·dΒ²); neutral (1.0) in the mandatory part.
Barycentric coords(u, v) weights locating a point inside a triangle; containment ⟺ u β‰₯ 0, v β‰₯ 0, u+v ≀ 1.
MΓΆller–TrumboreRay–triangle test solving the barycentric system directly with scalar triple products.
SupersamplingAnti-aliasing by averaging several rays per pixel (here 2Γ—2).
Tagged unionA struct holding an enum tag + a union of shape structs β€” one type for all objects.
FOVHorizontal field of view; viewport half-width = tan(fov/2) at unit distance.
Solid textureA pattern computed from the 3D hit point itself (our checkerboard), no UV unwrapping.
MiniLibX42's tiny X11 wrapper: window, image buffer, event hooks. The only allowed graphics lib.