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 π§.
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.
Error\n + exit 1)get_ray(u,v)t_hit (point, ray-facing normal, material)mlx_put_pixel writes the image buffer; one mlx_put_image_to_window per frameThe 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
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.| Symbol | Mandatory file | Bonus file | Difference |
|---|---|---|---|
dispatch | parser/dispatch.c | parser/dispatch_bonus.c | bonus adds co (cone) and tr (triangle) |
check_count | parser/parse_opts.c | parser/parse_opts_bonus.c | strict != count vs < (allows optional material tokens) |
parse_material | parser/parse_opts.c (no-op stub) | parser/parse_opts_bonus.c | parses per-object ks / shininess / β¦ / checkerboard |
parse_light | parser/parse_light.c | parser/parse_light_bonus.c | single light (duplicate rejected) vs multi-light list |
hit_object | intersect/hit_object.c | intersect/hit_object_bonus.c | bonus dispatches to cone/triangle too |
shade | shading/shade.c | shading/shade_bonus.c | bonus 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
| Constant | Value | Role |
|---|---|---|
EPSILON | 1e-6 | minimum accepted ray parameter t; rejects self-hits and grazing hits |
WIDTH Γ HEIGHT | 800 Γ 600 | fixed window size |
MAX_DEPTH | 8 | vestigial β ray_color takes a depth but never recurses (no reflection/refraction). Be honest about this if asked. |
SHADOW_BIAS | 1e-4 | offset 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 allocationvec3_bis.clen, norm (zero-length guard β returns zero vector, not NaN), near_zerovec3_ops.cdot, cross, reflect (bonus specular only), ray_at = O + tDquad.cshared quadratic solver: quad_solve (discriminant), root1/root2, quad_nearestsrcs/parser/ 15 files
parse_scene.centry: .rt check, open, hand-rolled read_line (1-byte reads, 1 MiB cap), line loop, final A/C/L presence checkdispatch.cidentifier β parser table (A C L sp pl cy), # comment skip, unknown-identifier errordispatch_bonus.cBONUSsame + co and trparse_ambient.cA ratio r,g,b β ratio β [0,1], duplicate rejectedparse_camera.cC pos dir fov β FOV strictly (0,180), duplicate rejectedparse_light.csingle L; sets attenuation kc=1, kl=0, kq=0parse_light_bonus.cBONUSmulti-light list append; keeps light color. β forgets kc/kl/kq β see Β§12parse_objects.csp pl cy + new_object/append_object; diameter Γ· 2; cylinder cap centers precomputedparse_objects_bonus.cBONUSco (apex/axis/radius/height) and tr (normal precomputed, degenerate rejected)parse_opts.cmandatory check_count (strict) + parse_material no-op stubparse_opts_bonus.cBONUSlenient count + real material tail parser (ksβ¦checker_size)material.cdefault_material: ka .1, kd .9, ks .3, shininess 32, checker offparse_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 heretok_utils.csplit_line whitespace splitter, free_tokens, token_countfree_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 listsphere.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 combinercylinder_utils.cthe perpendicular-projection quadratic, height clamp, body normal, cap-hit fillcone_bonus.cBONUScone body root selection + base capcone_utils_bonus.cBONUScone_quad (kΒ² slope terms), height/apex clamp, gradient normaltriangle_bonus.cBONUSMΓΆllerβTrumbore with early barycentric exitshit_object.c / _bonus.ctype dispatch β the entire mandatory/bonus split on this sidesrcs/render/ 3 files
camera.corthonormal axes (up-hint trick), FOV β viewport basis, get_rayrender.cpixel loops, 2Γ2 supersampling, ray_color, sky-gradient backgroundtranslation.cget_cam_right + move_camera (WASD)srcs/shading/ 9 files
shade.cmandatory combine: ambient + Ξ£ (shadowed ? 0 : diffuse), clampshade_bonus.cBONUS+ checker, + specular, contribution Γ light color per lightambient.cobject β (A_color Γ ratio) β the fixed formuladiffuse.cLambert: max(0, NΒ·L) Γ brightness Γ attenuationspecular_bonus.cBONUSPhong: white Γ ks Γ brightness Γ (RΒ·V)^shininessshadow.cbiased shadow ray + strictly-between-point-and-light testattenuation.c1 / (kc + klΒ·d + kqΒ·dΒ²), clamped β€ 1 (neutral in mandatory)checker_bonus.cBONUS3D solid checker: floor-cell parity β color inversion; material-copy pointer trickcolor.c[0,1] color ops: scale, add, multiply, clamp, lerpsrcs/window/ 3 files
mlx_init.cmlx_setup: init β window β image β data addr, each NULL-checked; starts lockedmlx_hooks.cmouse (scroll = FOV zoom), keys (ESC/SPACE/WASD), close (full teardown), expose (re-blit)mlx_utils.cmlx_put_pixel: ARGB packing + direct buffer writeeverything else support
includes/miniRT.hall structs, constants, prototypes (mandatory)includes/miniRT_bonus.hbonus-only prototypes; includes miniRT.hlibft/own libc subset (split, strlen, memset, isspaceβ¦) β vendored, own Makefilemlx_linux/vendored MiniLibX for X11 β the allowed graphics libraryscenes/mandatory demo scenes; scenes/bonus/ for the bonus binary (Β§10)Makefiletwo targets sharing COMMON_SRCS; builds mlx + libft first; -Wall -Wextra -Werror02 Β· orientationLife of a ray
This is the story to tell an evaluator who says βexplain how your program works.β
mainsrcs/main.c:15 β checksargc == 2, callsparse_scene,mlx_setup,render, registers the hooks (mouse; KeyPress=2; DestroyNotify=17; Expose=12) and entersmlx_loop.- Parse (Β§03) β the
.rtfile 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 populatedt_scene. - Render (Β§06) β
build_camera_basisconverts 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 byget_ray(u, v)and averaged. - Intersect (Β§05) β
ray_colorβintersect_scenewalks the object list, calls the per-shape intersector viahit_object, keeps the hit with the smallest t. At_hitcarries the point, a unit normal that always faces the ray, and a pointer to the object's material. - Shade (Β§07/Β§08) β
shade= ambient + for each light: if notin_shadow, add diffuse (bonus: + specular, Γ light color). Clamp to [0,1]. - Write pixel β
mlx_put_pixelpacks r,g,b (Γ255.999) into an int and writes it directly into the image buffer ataddr + yΒ·line_len + xΒ·(bpp/8). - After the double loop, one
mlx_put_image_to_windowblits the frame. Keys/scroll mutate the camera and re-run steps 3β7; 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.
03 Β· subsystemParser
srcs/parser/ β entry: parse_scene(file, scene), parse_scene.c:83
End-to-end flow
- Extension check parse_scene.c:51-61, 87-88 β needs
len >= 4and the last 3 chars== ".rt"(shortest valid name isa.rt; case-sensitive). open(file, O_RDONLY):89-91 β failure β error. A directory namedfoo.rtopens fine, but the firstread()returns β1 (EISDIR), caught at :37-38 as βread errorβ.ft_memset(scene, 0, β¦):92 β zeroes thehas_*flags and NULLs the lists.- Line loop
read_all:63-81 βread_lineβsplit_lineβdispatchβ free the line and tokens immediately. - 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β theret > 0part means a bare\nreturns 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.
Tokenization & dispatch
split_linetok_utils.c:52-76 β two-pass whitespace splitter. Whitespace isft_isspace= space + ASCII 9β13, so CRLF files parse fine (the\ris stripped).dispatchdispatch.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
valid_numparse_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".ft_strtodparse_num.c:61-74 β sign + integer part + fraction part. Deliberately no scientific notation.- Range checks β
parse_doubleparse_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)vrejects 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 withvec3_norm. So0.5,0.5,0is accepted and canonicalized rather than rejected β deliberate tolerance for rounding (e.g.0.577,0.577,0.577); say so up front.
Element parsers
| Id | File | Rules |
|---|---|---|
A | parse_ambient.c:15-26 | 3 tokens; ratio β [0,1]; duplicate rejected via has_ambient |
C | parse_camera.c:15-27 | 4 tokens; FOV strictly β (0,180); duplicate rejected |
L (mand.) | parse_light.c | duplicate rejected; sets attenuation kc=1, kl=0, kq=0 (:31-33) β always exactly 1.0 |
L (bonus) | parse_light_bonus.c | appends to the list β multiple lights; keeps the color. β does NOT set kc/kl/kq β Β§12 bug #1 |
sp pl cy | parse_objects.c:42-92 | new_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. |
co | parse_objects_bonus.c:15-33 | co apex axis radius height color β a radius, not a diameter (own documented format) |
tr | parse_objects_bonus.c | tr 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_scenefree_scene.c:15-36 walks both lists; called from the window-close path. - On a parse error mid-file:
error_exitmain.c:33-39 writesError\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β.
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
| Function | File | Notes |
|---|---|---|
vec3, add, sub, scale, negate | vec3.c | negate is used everywhere to flip normals toward the ray |
vec3_len | vec3_bis.c:15 | β(xΒ²+yΒ²+zΒ²) |
vec3_norm | vec3_bis.c:20 | guards Γ·0: len < 1e-12 β returns the zero vector (detectable, not NaN) |
vec3_near_zero | vec3_bis.c:30 | all comps < 1e-8; rejects zero orientations & degenerate triangles |
vec3_dot | vec3_ops.c:16 | the workhorse: quadratics, NΒ·L, projections, face tests |
vec3_cross | vec3_ops.c:22 | camera basis, triangle normal, MΓΆllerβTrumbore |
vec3_reflect | vec3_ops.c:31 | v β 2(vΒ·n)n; only used by bonus specular |
ray_at | vec3_ops.c:37 | origin + tΒ·dir |
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_solvequad.c:20-27 βdisc = bΒ² β 4ac; negative β miss (return 0); else cachesqrt_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_nearestquad.c:46-55 β try root1, accept ift β₯ EPSILON; else root2; else miss. One call handles outside rays, inside rays, and objects behind the camera.
05 Β· subsystemIntersections
srcs/intersect/
Dispatch & nearest hit
hit_objecthit_object.c:15-24 β if/else onobj->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_sceneintersect.c:15-37 β walks the list,closeststarts at 1e15, keeps hits withtmp.t < closest, copies the winningt_hitout 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 < EPSILONrejected (: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 fromdenom's sign (no extra dot product βdenomisdot(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
(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.
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.
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; onemlx_put_image_to_windowat 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 (why keypress re-renders visibly lag). - 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 fromlower_left. ray_color:34-43 β returns black ifdepth <= 0but 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:18-27), speed 0.5, then full re-render. - Mouse scroll (buttons 4/5): FOV β5Β°, clamped to [1,179], re-render.
- SPACE toggles
app->is_locked(movement/zoom ignored while locked). The camera starts locked (is_locked = 1inmlx_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.
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 :30-41 reuses the same
intersect_scene:
if (intersect_scene(shadow_ray, scene, &tmp))
return (tmp.t < light_dist - SHADOW_BIAS);
Only a hit strictly between point and light blocks; correct because
intersect_scene returns the nearest hit, and 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 forgets to set these β Β§12 bug #1.
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:18-29 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 */
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);
Four differences vs mandatory: checkerboard first, 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.
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.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 (:28) β the camera starts LOCKED;
press SPACE before demoing movement!
mlx_put_pixel
mlx_utils.c:18-29
argb = ((int)(r*255.999) << 16) | ((int)(g*255.999) << 8) | (int)(b*255.999);
dst = mlx->addr + y * line_len + x * (bpp / 8);
*(unsigned int *)dst = argb;
line_len= padded stride in bytes per row β the X server may align rows, so hardcodingWIDTH*4could shear the image. Likewisebpp/8vs a literal 4.- Γ255.999 maps 1.0 β 255 without ever producing 256.
- Why an image buffer and not
mlx_pixel_put?mlx_pixel_putis 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.
Events
main.c:24-28 + mlx_hooks.c β the magic numbers are X11 protocol event codes
| Hook | Event | Mask | Handler |
|---|---|---|---|
mlx_hook(win, 2, 1L<<0, β¦) | KeyPress | KeyPressMask | keys |
mlx_hook(win, 17, 0, β¦) | DestroyNotify | none needed | red cross |
mlx_hook(win, 12, 1L<<15, β¦) | Expose | ExposureMask | re-blit |
mlx_mouse_hook | ButtonPress | β | scroll zoom |
key_handlermlx_hooks.c:42-68 β ESC βclose_handler; SPACE togglesis_locked(prints π/π); when locked everything else is ignored; WASD move at speed 0.5 + re-render.close_handlermlx_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.
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.
scenes/ β run with ./miniRT
| Scene | Shows / when to use |
|---|---|
full_mandatory.rt | plane + sphere + cylinder + light β the main demo |
sphere.rt | decimal values, tilted cylinder (axis 0,0,1) β float parsing |
cylinder_inside.rt | camera at the cylinder's center β inside-surface normals |
cylinder_side.rt / cylinder_top.rt | body from the side; caps from above |
floor_shadow.rt / min_shadow.rt | hard shadow; min_shadow has ambient 0.05 (near-black shadow β the ambient-formula proof) |
two_planes.rt | planes visible from the correct side (two-sided planes) |
wall.rt | vertical plane behind a sphere |
ten_objs.rt | several objects β closest-hit correctness |
scenes/bonus/ β run with ./miniRT_bonus
| Scene | Shows |
|---|---|
showcase.rt | one-stop bonus demo: 2 colored lights, cone, triangle, checkerboard plane with the full material tail |
cone_triangle.rt | cone apex-down + flat and vertical triangles |
specular_test.rt / material_test.rt | spheres with ks .9/.5/0 and shininess 256/8/32 β the Phong highlight varying |
atelier.rt / nocturne.rt | art 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
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-runningmakedoes 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 -uon both binaries minus MLX/libc internals leaves onlyopen close read write malloc free exitand math (sqrt tan, +floor powin 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 homemadeft_strtod, not libcatof.
The soutenance commit (228f5f3) β βwhat did you fix recently?β
mlx_destroy_displayon close β X connection leak fixed.- The Expose handler β the window survives minimize; the image is re-put if the first draw beats the window mapping.
- FOV made horizontal, as the subject defines (camera.c).
- A cylinder bug where a ray whose only hit was the bottom cap was lost
(
bestnot updated) β fixed in cylinder.c. read_lineread a byte before checkingread's return β an uninitialized read when handed a directory β fixed.- 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
parse_light_bonus.c:22-30 mallocs each light and sets pos/brightness/color/next but
never sets kc, kl, kq (the mandatory parser sets
them at parse_light.c:31-33). diffuse_light β attenuation()
(attenuation.c:21) reads all three on every shading sample β read of uninitialized heap
memory in every miniRT_bonus frame. In practice fresh pages are often zero β
1/0 = inf β clamped to 1.0, so it looks fine β but
valgrind ./miniRT_bonus scene.rt prints βConditional jump β¦ depends on uninitialised
valueβ, and running valgrind is a standard evaluator move.
Fix = copy the 3 init lines from parse_light.c into parse_light_bonus.c.
- MAX_DEPTH /
depthis dead scaffolding β no recursion (no reflection/refraction). Say it plainly: βhook for a bounce loop; rays terminate at first hit.β - ka, kd, reflectivity, transparency, ior, roughness are parsed/stored but never used in shading; only ks/shininess/checker fields are consumed.
- Error paths don't free β but everything stays reachable (Β§03); verify with valgrind and quote the numbers.
- Performance: 4 rays/pixel, full re-render per keypress, linear object list. A 10k-object scene is slow β that's why the 1 MiB parse cap exists.
mlx_put_pixelassumes 32 bpp little-endian (*(unsigned int *)dst;endianstored but unused) β always true on evaluation Linux/X11.- Cone
a β 0(ray parallel to the slant) has no explicit guard like the cylinder's: roots become Β±inf and are rejected by theprojrange check β implicit but harmless; know it. - Lines > 4095 chars are split by
read_line; the continuation chunk fails as βunknown identifierβ β clean failure, no overflow. - 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.
- Plane t = 0 rejected by EPSILON β a camera exactly on a plane doesn't see it; by design.
- The checkerboard token is a truncating cast (0.9 β 0) and
checker_size β€ 0is accepted by the parser (but neutralized inapply_checker) β defensible as βdocumented format: 0/1 and a positive size.β - README vs reality: the header defines KEY_UP/DOWN/LEFT/RIGHT (miniRT.h:34-37)
and the README advertises arrow keys, but
key_handleronly 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. error_exitafter parsing (e.g.mlx_new_windowfailure) exits withoutfree_sceneβ OS-reclaimed and still-reachable, but know it exists. Alsomlx_get_data_addr's return is not NULL-checked (mlx_init.c:26).- 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.β
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?
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?
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?
vec3_near_zero check in parse_normal (parse_utils.c:78).A non-normalized orientation like 1,1,1?
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?
How do you read lines without get_next_line?
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?
free_scene in the close handler.Where is the bonus code? How does the shared parser accept extra tokens only in the bonus?
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?
Do you support comments and blank lines?
# 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?
Why compare t against EPSILON instead of 0?
Why compute a = dot(dir,dir) if the direction is normalized (a = 1)?
Why must ray directions be normalized at all?
What happens when a ray is parallel to a plane?
Derive the cylinder intersection.
How do you know whether you hit the wall or a cap?
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?
What if the camera is inside a sphere or cylinder?
scenes/cylinder_inside.rt.Cone: why can the ray hit βabove the apexβ?
Why does the cone normal contain a kΒ² term?
Explain MΓΆllerβTrumbore in one sentence.
Where is the triangle's normal computed?
How is the closest object chosen?
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?
Do you handle the camera looking straight up?
Explain your lighting model / Phong.
What is shadow acne and how do you avoid it?
Why do you clamp colors?
color_clamp at the end of shade guarantees each channel maps to 0-255.Why multiply object color by light color?
What happens if brightness > 1?
Where is the anti-aliasing?
Why is the void blue instead of black?
background() (render.c:19-29) β one line to make it
black.Why does the mandatory part ignore the light's color?
MLX, events & build
Why write into an image instead of mlx_pixel_put?
What is line_len β why not WIDTH*4?
What are 2, 17, 12 and the masks in mlx_hook?
ESC vs the red cross?
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?
Error\nmlx_init failed to stderr, exit 1 β
verified, no segfault.Why doesn't the window go blank when I minimize or cover it?
Any relink?
make does nothing (βNothing to be doneβ).14 Β· defenseThe final 24 hours
- Hour 0-1 β read Β§00 + Β§02 twice. Draw the pipeline from memory once.
- 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_componentout loud. - 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.
- Hour 6-8 β camera + render. Recompute half_w for FOV = 70 on paper; explain the y-flip and the 4-ray averaging.
- 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.
- 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. - Hour 11-12 β decide about Β§12 bug #1 (fix it or prepare to own it).
- 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 with1,2,, RGB 256, FOV 180, duplicate C, missing L. ./miniRT_bonus scenes/bonus/showcase.rt(+ the others) for the bonus tour.- Press SPACE to unlock the camera before demoing WASD/scroll; demo WASD only β arrow keys are not handled.
valgrind --leak-check=fulloutput pre-captured for a good and a bad run (and settle Β§12 bug #1 first β valgrind on the bonus flags it today).
15 Β· defenseGlossary
| Term | Meaning here |
|---|---|
| Ray | P(t) = O + tΒ·D β origin O, unit direction D; t is the world-space distance along it. |
| Discriminant | bΒ² β 4ac of a quadratic; its sign says whether the ray's line misses (β), grazes (0), or crosses (+) the surface. |
| Normal | Unit vector perpendicular to the surface at the hit point; in this code it always faces against the incoming ray. |
| front_face | 1 if the ray hit the surface from outside, 0 from inside (normal was flipped). |
| Lambert / diffuse | Matte reflection: intensity β cos ΞΈ = max(0, NΒ·L). View-independent. |
| Phong specular | Glossy highlight: (RΒ·V)^shininess with R = reflection of the light direction about N. View-dependent. |
| Ambient | Constant fill light approximating indirect bounce; here object β (A_color Γ ratio). |
| Shadow acne | Speckle caused by a shadow ray re-hitting its own surface at t β 0; cured by bias + EPSILON. |
| Attenuation | Distance 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βTrumbore | Rayβtriangle test solving the barycentric system directly with scalar triple products. |
| Supersampling | Anti-aliasing by averaging several rays per pixel (here 2Γ2). |
| Tagged union | A struct holding an enum tag + a union of shape structs β one type for all objects. |
| FOV | Horizontal field of view; viewport half-width = tan(fov/2) at unit distance. |
| Solid texture | A pattern computed from the 3D hit point itself (our checkerboard), no UV unwrapping. |
| MiniLibX | 42's tiny X11 wrapper: window, image buffer, event hooks. The only allowed graphics lib. |