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.
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/ 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 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/ 4 files
preview.chalf-resolution movement preview + edge-aware upsampling (Β§P1)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/ 10 files
bump_bonus.cBONUSsphere UV β height gradient β tangent-frame normal tilt (Β§B1)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 set aneeds_renderflag; amlx_loop_hookre-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:
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:
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:
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
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).
Pressing W
Every ray starts at the camera. Press W and the code does, almost literally in English:
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:
| Tool | The 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" |
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:
| Operation | Computed as | What 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:
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.
(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):
The magic is what that number means. There is a second, equivalent definition:
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:
(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 asks | How the dot product answers it |
|---|---|
| How bright is this surface point? diffuse.c:20 | dot(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:17 | dot(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:21 | dot(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.c | dot(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. |
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. Thenup = cross(right, dir)makes the third perpendicular. Edge case: ifdiris 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 withvec3_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:
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:
fov * M_PI / 180.0 in camera.c β nothing deeper than a unit change, like cm β inch.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":
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).
M6 Β· foundationsFinding the hit point β geometry becomes algebra
Here is the core trick of all ray tracing, in three steps. It never varies:
- Write the shape as a membership test β an equation that is true exactly for points on the surface.
- Replace the point in that equation with the ray formula
origin + tΒ·dir. - 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):
Substitute the ray P = O + tΒ·D, write oc = O β C (arrow from center
to camera), expand, and group by powers of t:
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:
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:
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):
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):
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. β
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:
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.
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).
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:
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):
- 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.
- 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. - 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. - The ray = origin at the eye, direction = normalize(point β eye).
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.)
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
- The camera gives me three things: position, direction, FOV.
- 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). - The render loop visits every pixel; each pixel becomes fractions
(u, v)of that viewport, four sub-pixel samples each (R3). get_rayturns a (u, v) into a ray: aim from the eye through that spot (R4).ray_colorasks the intersection system for the nearest hit; a miss paints the background (R5).- A hit is shaded: ambient + (if the lamp is visible) diffuse β shadow tested with a second ray (Β§07).
- 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 ratio | t 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, bias | the 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Β°.
- 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). - How wide?
half_w = tan(90Β°/2) = tan(45Β°) = 1β the M4 triangle with adjacent = 1. Full width 2. - 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.) - Which way is "right"?
right = norm(cross(dir, world_up)). For dir = (0,0,β1):right = (1,0,0), and thenup = cross(right, dir) = (0,1,0). - 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.
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).
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/600 | meaning |
|---|---|---|
| 0 (top row of the image) | 1.0 | top of the viewport β |
| 300 (middle) | 0.5 | middle of the viewport β |
| 599 (bottom row) | β 0.002 | bottom 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).
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:
| Variable | Plain English |
|---|---|
closest = 1e15 | βthe best (nearest) distance found so farβ β starts at effectively infinity, so anything real beats it |
tmp | a 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
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.
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 bonusshade()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
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
- 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 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 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), atMOVE_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 setsneeds_renderand 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 = 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. (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.
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 point | Status | Where |
|---|---|---|
| 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:
- Where am I on the map? Convert the hit's normal to sphere UV β
longitude from
atan2(n.z, n.x), latitude fromasin(n.y)(the classic globe unwrap; the M4 functions in reverse) β a texel (x, y). - 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).
- 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) andbitangent = cross(N, tangent). - 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.
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_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.
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.
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.β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
| Hook | Event | Mask | Handler |
|---|---|---|---|
mlx_hook(win, 2, 1L<<0, β¦) | KeyPress | KeyPressMask | keys (records held) |
mlx_hook(win, 3, 1L<<1, β¦) | KeyRelease | KeyReleaseMask | release held key |
mlx_hook(win, 10, 1L<<21, β¦) | FocusOut | FocusChangeMask | drop all held keys (no stuck movement after alt-tab) |
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 atMOVE_SPEED Γ dt(held keys, time-based) and setneeds_renderβ the actual repaint happens in the loop hook, sliced and preview-first (Β§P1).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.
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.
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.
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
| Piece | Location |
|---|---|
-O2 (halved every number to start with) | Makefile:5 |
| flag + loop-hook logic | loop_hook, srcs/render/render_loop.c:52 (flag set in press_move_key, srcs/window/keys.c; hook registered in main.c) |
| 32-row slicing | render_span, srcs/render/render.c:77 Β· SLICE_ROWS, miniRT.h:27 |
| preview + edge-aware upsample | srcs/render/preview.c (upsample_span :83, spread test vs EDGE_SPREAD) β the upsampling itself runs in slices too |
| front/back swap | present_frame, srcs/window/mlx_utils.c:28 |
| state | t_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 forLonly (multiple lights). - Colors are
R,G,Bas 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):
| Pos | Field | Default | Meaning |
|---|---|---|---|
| +1 | ks | 0.3 | specular strength [0..1] (0 = matte) |
| +2 | shininess | 32 | highlight tightness, β₯ 1 (8 = broad blob, 256 = tight glint) |
| +3 | reflectivity | 0 | (parsed, reserved β not used by shading) |
| +4 | transparency | 0 | (parsed, reserved) |
| +5 | ior | 1.0 | (parsed, reserved, β₯ 1) |
| +6 | checkerboard | 0 | 1 = checker pattern on this object |
| +7 | checker_size | 1.0 | edge 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:
cytakes its center (body spans Β±h/2),cotakes 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, axis0,-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.85is 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
| 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 |
bump_sphere.rt | bump mapping: a cratered sphere (craters.pgm) next to an identical smooth one, same light (Β§B1) |
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
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-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
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.
- 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; the consumed fields are ks, shininess, the checker pair, and the bump map. reflectivity/transparency/ior remain forward-looking storage.
- Error paths now clean up β
error_exitcallscleanup_all()(frees the scene, closes the fd) before exiting, so even mid-parse failures are valgrind-clean. Verify once and quote the numbers. - 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.
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.β
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
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.
β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.
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 direction | Main challenge |
|---|---|
| along X | does the computed right/up rotate correctly? |
| along Z | the normal camera-basis case |
| along Y | the special case: dir parallel to the default world_up |
| arbitrary | does 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.
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}) thent4_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) thent5_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 ismax(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
| Sheet bonus (1 pt each) | Status | Demo |
|---|---|---|
| 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.
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?
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?
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?
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.
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?
How does bump mapping work if the geometry never changes?
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?
How does the parser accept the .pgm token only in the bonus?
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?
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β).What actually happens when I type make vs make bonus?
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?
#ifdef is ever needed.Why is the window responsive even while rendering?
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?
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?
Why don't I ever see a half-drawn frame?
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
- 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.
- 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. Then walk R1βR5 β the render path line by line, exactly as you'd narrate it in the defense.
- 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. - 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=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. |