diff --git a/CLAUDE.md b/CLAUDE.md index fefaca4..49d9cfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ mini_rt/ │ ├── ft_paraboloid.c # Paraboloid intersection │ ├── ft_csg.c # CSG operations (union, intersect, difference) │ ├── ft_mesh.c # OBJ mesh loading -│ ├── ft_material.c # Material properties (refl, trans, spec, ior) +│ ├── ft_material.c # Material properties (refl, trans, spec, ior, PBR) │ ├── ft_texture.c # Texture mapping │ ├── ft_bump.c # Bump/normal mapping │ ├── ft_bvh.c / ft_bvh_2.c # BVH acceleration structure @@ -98,6 +98,8 @@ mini_rt/ │ ├── ft_dof.c # Depth of field │ ├── ft_motion.c # Motion blur │ ├── ft_pathtrace.c # Path tracing / global illumination +│ ├── ft_pbr.c # PBR shading (GGX/Cook-Torrance BRDF, Fresnel) +│ ├── ft_env.c # Environment sky lighting │ ├── ft_precompute.c # Shape precomputation / caching │ ├── ft_parsing.c / ft_parsing_2.c # Scene file (.rt) parser │ ├── ft_check_parsing.c / ft_check_parsing_2.c # Input validation @@ -134,7 +136,7 @@ mini_rt/ ├── libft/ # Custom 42 utility library ├── mlx/ # minilibX (macOS) ├── mlx_linux/ # minilibX (Linux) -├── scenes/ # 19 example .rt scene files +├── scenes/ # 20 example .rt scene files ├── saves/ # Rendered BMP output directory ├── skills/ # Technical documentation ├── .github/workflows/ # CI/CD (GitHub Actions) @@ -166,6 +168,8 @@ ft_aff() main render loop └── Specular highlights ├── Recursive reflection rays (up to MAX_REFLECT_DEPTH=4) ├── Refraction rays (Snell's law with IOR) + ├── PBR shading (GGX/Cook-Torrance BRDF, Fresnel, metallic-roughness) + ├── ACES filmic tone mapping + sRGB gamma ├── Texture / bump map sampling ├── Depth of field (aperture jitter, DOF_SAMPLES=16) └── Motion blur (temporal sampling, MOTION_BLUR_SAMPLES=8) @@ -173,6 +177,8 @@ ft_aff() main render loop Progressive rendering is available: renders at 8x → 4x → 2x → 1x resolution for interactive preview. +Path tracing mode (`--pathtrace N`) features Russian Roulette termination, environment sky lighting, emissive materials, and PBR-aware importance sampling. + ### Supported Shapes (14 types) #### Basic shapes @@ -244,6 +250,9 @@ sp 2,0,-5 1.5 255,255,255 trans:0.9 ior:1.5 # Glass sphere | `refl:` | 0–1 | Reflectivity (recursive, max depth 4) | | `trans:` | 0–1 | Transparency | | `ior:` | >0 | Index of refraction (used with `trans`) | +| `metal:` | 0–1 | Metallic factor (PBR metallic-roughness workflow) | +| `rough:` | 0–1 | Surface roughness (0 = mirror, 1 = matte) | +| `emit:` | R,G,B | Emissive light (HDR values allowed, e.g. `emit:500,400,300`) | ### Memory Management @@ -315,4 +324,29 @@ GitHub Actions pipeline: build validation, unit tests, regression tests, clang-f ### Scene Files -`scenes/` directory contains 19 test scenes covering basic shapes, extended shapes, quadrics, lighting, interior views, DOF, motion blur, and path tracing. +`scenes/` directory contains 22 test scenes covering basic shapes, extended shapes, quadrics, lighting, interior views, DOF, motion blur, path tracing, PBR materials, and emissive objects. + +## Development Workflow + +### Every iteration MUST: + +1. **`make lint`** — clang-format check passes +2. **`make test`** — all unit tests pass (math, camera, intersections, lighting, parsing, PBR) +3. **`make regression`** — all 19 BMP regression tests pass (requires Xvfb on headless systems: `xvfb-run make regression`) + +### When scenes are added or modified: + +4. **Re-render `saves/`** — run each changed scene with `-save --output saves/.bmp` +5. **Regenerate `renders_preview/`** — run `python3 tools/bmp_to_png.py saves/ --output renders_preview/` +6. **Note:** `scenes/cathedral_ruins.rt` renders at 2560×1440 and takes ~1h. Render in background or last. + +### Scene authoring (post-42 scenes only): + +All scenes created after the initial 42 project follow the drone-view guidelines documented in **[skills/camera-and-rendering.md](skills/camera-and-rendering.md)** § "Post-42 Scene Generation Rules": + +- Elevated camera with downward tilt (`rot_x` 0.12–0.18), **never** `rot_z = ±1` +- All objects fully visible and well-separated at first glance +- `E` directive for sky gradient background +- If rows of items, all rows visible from above (no occlusion) + +Original 42 scenes are left unchanged. diff --git a/Makefile b/Makefile index 12f7cab..b91c4e1 100644 --- a/Makefile +++ b/Makefile @@ -84,6 +84,8 @@ SRC_NAME = ft_mini_rt.c \ ft_dof.c \ ft_motion.c \ ft_pathtrace.c \ + ft_pbr.c \ + ft_env.c \ ft_close.c \ ft_save.c \ ft_error.c @@ -166,7 +168,7 @@ SHAPE_SRCS = ft_sphere.c ft_plane.c ft_square.c \ TEST_INTERSECT_SRCS = $(TEST_MATH_SRCS) $(SHAPE_SRCS) \ ft_ray_2.c ft_ray.c ft_light.c ft_precompute.c \ ft_bvh.c ft_bvh_2.c \ - ft_random.c ft_dof.c ft_motion.c ft_pathtrace.c \ + ft_random.c ft_dof.c ft_motion.c ft_pathtrace.c ft_pbr.c ft_env.c \ ft_parsing.c ft_parsing_2.c ft_check_parsing.c \ ft_check_parsing_2.c ft_bzero_struct.c @@ -174,7 +176,7 @@ TEST_PARSING_SRCS = $(TEST_MATH_SRCS) ft_parsing.c ft_parsing_2.c \ ft_check_parsing.c ft_check_parsing_2.c ft_bzero_struct.c \ $(SHAPE_SRCS) ft_precompute.c \ ft_bvh.c ft_bvh_2.c ft_ray.c ft_ray_2.c ft_light.c \ - ft_random.c ft_dof.c ft_motion.c ft_pathtrace.c + ft_random.c ft_dof.c ft_motion.c ft_pathtrace.c ft_pbr.c ft_env.c TEST_DIR = tests @@ -211,6 +213,12 @@ test: $(LIB) $(TEST_DIR)/test_parsing.c $(TEST_DIR)/test_stubs.c \ $(addprefix $(SRC_DIR)/,$(TEST_PARSING_SRCS)) \ $(TEST_LINK) -o run_test_parsing && ./run_test_parsing + @echo "" + @echo "=== Running PBR tests ===" + @$(CC) -Wall -Wextra -Werror -g3 $(MLX_DEF) $(TEST_INC) \ + $(TEST_DIR)/test_pbr.c $(TEST_DIR)/test_stubs.c \ + $(addprefix $(SRC_DIR)/,$(TEST_INTERSECT_SRCS)) \ + $(TEST_LINK) -o run_test_pbr && ./run_test_pbr COV_FLAGS = -Wall -Wextra -g3 --coverage -fprofile-arcs -ftest-coverage @@ -223,6 +231,7 @@ coverage: $(LIB) $(TEST_DIR)/test_intersections.c \ $(TEST_DIR)/test_lighting.c \ $(TEST_DIR)/test_parsing.c \ + $(TEST_DIR)/test_pbr.c \ $(TEST_DIR)/test_stubs.c \ $(addprefix $(SRC_DIR)/,$(TEST_INTERSECT_SRCS)) \ $(TEST_LINK) -o cov_test_all && ./cov_test_all @@ -248,7 +257,7 @@ lint: TEST_BENCH_SRCS = $(TEST_MATH_SRCS) $(SHAPE_SRCS) \ ft_ray_2.c ft_ray.c ft_light.c ft_precompute.c \ ft_bvh.c ft_bvh_2.c ft_bzero_struct.c \ - ft_random.c ft_dof.c ft_motion.c ft_pathtrace.c \ + ft_random.c ft_dof.c ft_motion.c ft_pathtrace.c ft_pbr.c ft_env.c \ ft_parsing.c ft_parsing_2.c ft_check_parsing.c \ ft_check_parsing_2.c @@ -264,7 +273,7 @@ gen_scene: @echo "Built gen_scene tool" testclean: - $(RM) run_test_math run_test_camera run_test_intersections run_test_lighting run_test_parsing run_benchmark + $(RM) run_test_math run_test_camera run_test_intersections run_test_lighting run_test_parsing run_test_pbr run_benchmark $(RM) cov_test_math cov_test_intersections cov_test_lighting cov_test_parsing cov_test_all $(RM) -r coverage $(RM) *.gcno *.gcda src/*.gcno src/*.gcda tests/*.gcno tests/*.gcda diff --git a/includes/mini_rt.h b/includes/mini_rt.h index c2b37a9..b07dcd1 100644 --- a/includes/mini_rt.h +++ b/includes/mini_rt.h @@ -140,6 +140,9 @@ typedef struct s_material double tex_scale_u; double tex_scale_v; double bump_strength; + double metallic; + double roughness; + t_argb emission; t_texture *texture; t_texture *bump_map; t_pt vel; @@ -248,6 +251,7 @@ typedef struct s_window int path_trace_spp; int path_trace_bounces; int motion_blur_samples; + int use_env_sky; } t_window; typedef struct s_thread_data @@ -483,6 +487,11 @@ void ft_ray_motion(double i, double j, t_window *win, t_cam *cam); void ft_pathtrace_pixel(double i, double j, t_window *win, t_cam *cam); +t_argb ft_pbr_shade(t_pt view, t_pt light_dir, t_pt normal, t_material *mat, + t_argb light_col, t_argb albedo); +double ft_fresnel_schlick(double cos_theta, double f0); +t_argb ft_env_sky(t_pt dir); + int ft_save(t_window *win); int ft_file_header(int fd, t_window *rt); int ft_info_header(int fd, t_window *rt); diff --git a/renders_preview/benchmark.png b/renders_preview/benchmark.png new file mode 100644 index 0000000..42877ef Binary files /dev/null and b/renders_preview/benchmark.png differ diff --git a/renders_preview/cathedral_ruins.png b/renders_preview/cathedral_ruins.png new file mode 100644 index 0000000..c24288c Binary files /dev/null and b/renders_preview/cathedral_ruins.png differ diff --git a/renders_preview/cornell_box.png b/renders_preview/cornell_box.png new file mode 100644 index 0000000..1a5db3b Binary files /dev/null and b/renders_preview/cornell_box.png differ diff --git a/renders_preview/cylinder.png b/renders_preview/cylinder.png new file mode 100644 index 0000000..0f54a72 Binary files /dev/null and b/renders_preview/cylinder.png differ diff --git a/renders_preview/dof_test.png b/renders_preview/dof_test.png new file mode 100644 index 0000000..a0321e7 Binary files /dev/null and b/renders_preview/dof_test.png differ diff --git a/renders_preview/example.png b/renders_preview/example.png new file mode 100644 index 0000000..6985252 Binary files /dev/null and b/renders_preview/example.png differ diff --git a/renders_preview/glass_spheres.png b/renders_preview/glass_spheres.png new file mode 100644 index 0000000..f2fdf0b Binary files /dev/null and b/renders_preview/glass_spheres.png differ diff --git a/renders_preview/inside_cy.png b/renders_preview/inside_cy.png new file mode 100644 index 0000000..72f6237 Binary files /dev/null and b/renders_preview/inside_cy.png differ diff --git a/renders_preview/inside_cy_l.png b/renders_preview/inside_cy_l.png new file mode 100644 index 0000000..af4d746 Binary files /dev/null and b/renders_preview/inside_cy_l.png differ diff --git a/renders_preview/inside_sp.png b/renders_preview/inside_sp.png new file mode 100644 index 0000000..cfa880a Binary files /dev/null and b/renders_preview/inside_sp.png differ diff --git a/renders_preview/inside_sp_l.png b/renders_preview/inside_sp_l.png new file mode 100644 index 0000000..b617dbc Binary files /dev/null and b/renders_preview/inside_sp_l.png differ diff --git a/renders_preview/lights.png b/renders_preview/lights.png new file mode 100644 index 0000000..4c762a1 Binary files /dev/null and b/renders_preview/lights.png differ diff --git a/renders_preview/materials.png b/renders_preview/materials.png new file mode 100644 index 0000000..cf5afc7 Binary files /dev/null and b/renders_preview/materials.png differ diff --git a/renders_preview/mixed_shapes.png b/renders_preview/mixed_shapes.png new file mode 100644 index 0000000..81c8fbb Binary files /dev/null and b/renders_preview/mixed_shapes.png differ diff --git a/renders_preview/motion_blur.png b/renders_preview/motion_blur.png new file mode 100644 index 0000000..92fcfb4 Binary files /dev/null and b/renders_preview/motion_blur.png differ diff --git a/renders_preview/new_shapes.png b/renders_preview/new_shapes.png new file mode 100644 index 0000000..9798263 Binary files /dev/null and b/renders_preview/new_shapes.png differ diff --git a/renders_preview/objects.png b/renders_preview/objects.png new file mode 100644 index 0000000..9735138 Binary files /dev/null and b/renders_preview/objects.png differ diff --git a/renders_preview/pbr_showcase.png b/renders_preview/pbr_showcase.png new file mode 100644 index 0000000..dac9cd7 Binary files /dev/null and b/renders_preview/pbr_showcase.png differ diff --git a/renders_preview/quadrics.png b/renders_preview/quadrics.png new file mode 100644 index 0000000..9b04a41 Binary files /dev/null and b/renders_preview/quadrics.png differ diff --git a/renders_preview/reflections.png b/renders_preview/reflections.png new file mode 100644 index 0000000..987b123 Binary files /dev/null and b/renders_preview/reflections.png differ diff --git a/renders_preview/scene_basic.png b/renders_preview/scene_basic.png new file mode 100644 index 0000000..1e09237 Binary files /dev/null and b/renders_preview/scene_basic.png differ diff --git a/renders_preview/scene_new_shapes.png b/renders_preview/scene_new_shapes.png new file mode 100644 index 0000000..8020d61 Binary files /dev/null and b/renders_preview/scene_new_shapes.png differ diff --git a/renders_preview/solar_system.png b/renders_preview/solar_system.png new file mode 100644 index 0000000..70b195c Binary files /dev/null and b/renders_preview/solar_system.png differ diff --git a/renders_preview/spiral.png b/renders_preview/spiral.png new file mode 100644 index 0000000..6f15815 Binary files /dev/null and b/renders_preview/spiral.png differ diff --git a/renders_preview/taurus.png b/renders_preview/taurus.png new file mode 100644 index 0000000..9c983c8 Binary files /dev/null and b/renders_preview/taurus.png differ diff --git a/renders_preview/triangle.png b/renders_preview/triangle.png new file mode 100644 index 0000000..86cc2a3 Binary files /dev/null and b/renders_preview/triangle.png differ diff --git a/saves/benchmark.bmp b/saves/benchmark.bmp new file mode 100755 index 0000000..f9fcadf Binary files /dev/null and b/saves/benchmark.bmp differ diff --git a/saves/dof_test.bmp b/saves/dof_test.bmp new file mode 100755 index 0000000..b3e3e06 Binary files /dev/null and b/saves/dof_test.bmp differ diff --git a/saves/materials.bmp b/saves/materials.bmp new file mode 100755 index 0000000..f0d3cb9 Binary files /dev/null and b/saves/materials.bmp differ diff --git a/saves/motion_blur.bmp b/saves/motion_blur.bmp new file mode 100755 index 0000000..bf57d27 Binary files /dev/null and b/saves/motion_blur.bmp differ diff --git a/saves/new_shapes.bmp b/saves/new_shapes.bmp index e630af8..34dd087 100755 Binary files a/saves/new_shapes.bmp and b/saves/new_shapes.bmp differ diff --git a/saves/pbr_showcase.bmp b/saves/pbr_showcase.bmp new file mode 100755 index 0000000..2476aa9 Binary files /dev/null and b/saves/pbr_showcase.bmp differ diff --git a/saves/quadrics.bmp b/saves/quadrics.bmp index 6d99e03..e7ca775 100755 Binary files a/saves/quadrics.bmp and b/saves/quadrics.bmp differ diff --git a/scenes/benchmark.rt b/scenes/benchmark.rt index c42c3ae..6a2858c 100644 --- a/scenes/benchmark.rt +++ b/scenes/benchmark.rt @@ -1,7 +1,8 @@ R 1920 1080 A 0.1 255,255,255 +E -c 0,20,-60 0,-0.3,1 70 +c 0,30,60 0.12,0,0 70 l -30,40,-20 0.8 255,255,200 l 30,40,-20 0.6 200,200,255 diff --git a/scenes/cathedral_ruins.rt b/scenes/cathedral_ruins.rt index 667f9e5..1c86a47 100644 --- a/scenes/cathedral_ruins.rt +++ b/scenes/cathedral_ruins.rt @@ -1,5 +1,6 @@ R 2560 1440 A 0.08 200,190,170 +E c 0.0,6.0,12.0 0.03,0,0 70 diff --git a/scenes/dof_test.rt b/scenes/dof_test.rt index 9560bce..2997907 100644 --- a/scenes/dof_test.rt +++ b/scenes/dof_test.rt @@ -1,5 +1,6 @@ R 800 600 A 0.15 255,255,255 +E c 0,3,15 0.05,0,0 60 aperture:0.15 focal:15.0 diff --git a/scenes/materials.rt b/scenes/materials.rt new file mode 100644 index 0000000..fd84795 --- /dev/null +++ b/scenes/materials.rt @@ -0,0 +1,23 @@ +R 1920 1080 +A 0.1 255,255,255 +E +c 0,15,35 0.08,0,0 65 +l -12,28,20 0.8 255,250,240 +l 15,22,30 0.5 220,235,255 +l 0,12,40 0.25 255,255,255 + +pl 0,-0.5,0 0,1,0 230,230,235 + +sp -10,2.5,10 5.0 255,80,80 spec:0.9 +sp -3,2.5,10 5.0 200,220,255 refl:0.6 spec:0.5 +sp 4,2.5,10 5.0 255,255,255 trans:0.9 ior:1.5 spec:0.3 +sp 11,2.5,10 5.0 220,240,255 trans:0.8 ior:2.4 spec:0.4 + +sp -10,2.5,-2 5.0 230,230,240 metal:1.0 rough:0.05 spec:1.0 +sp -3,2.5,-2 5.0 200,200,210 metal:1.0 rough:0.4 spec:0.7 +sp 4,2.5,-2 5.0 170,150,140 metal:1.0 rough:0.8 spec:0.3 +sp 11,2.5,-2 5.0 50,180,80 metal:0.0 rough:0.1 spec:0.8 + +bx -8,3,-14 6,6,6 255,200,100 emit:400,300,200 +cy 0,0,-14 0,1,0 2.5 6.0 180,210,240 metal:0.7 rough:0.2 refl:0.3 spec:0.8 +co 8,0,-14 0,1,0 3.5 6.0 200,180,255 trans:0.5 ior:1.3 spec:0.6 diff --git a/scenes/motion_blur.rt b/scenes/motion_blur.rt index b8de476..8e5bb60 100644 --- a/scenes/motion_blur.rt +++ b/scenes/motion_blur.rt @@ -1,5 +1,6 @@ R 800 600 A 0.15 255,255,255 +E c 0,5,20 0.05,0,0 60 diff --git a/scenes/new_shapes.rt b/scenes/new_shapes.rt index 69b4124..b5da17d 100644 --- a/scenes/new_shapes.rt +++ b/scenes/new_shapes.rt @@ -1,13 +1,15 @@ R 1920 1080 A 0.18 255,255,255 -c 0,4,12 0,-0.15,-1 70 -l 0,20,5 0.45 255,255,255 +E +c 0,14,28 0.15,0,0 70 +l -10,25,10 0.55 255,250,240 +l 10,15,20 0.35 220,230,255 -sp -8,1.5,-3 3.0 255,30,30 -co -4,0,-3 0,1,0 2.0 4.0 30,255,30 -dk 0,2,-5 0,0,1 4.0 255,255,30 -el 4,1.5,-3 2.0,1.2,1.2 30,80,255 -bx 8,1.5,-3 3,3,3 255,140,0 -to 0,5,-8 0,1,0 2.5 0.7 255,30,255 -cy -6,-0.5,-8 0,1,0 1.5 3.0 0,200,200 +sp -12,1.5,2 3.0 255,30,30 +co -6,0,2 0,1,0 2.0 4.0 30,255,30 +dk 0,2,0 0,0,1 4.0 255,255,30 +el 6,1.5,2 2.0,1.2,1.2 30,80,255 +bx 12,1.5,2 3,3,3 255,140,0 +to -4,4,-6 0,1,0 2.5 0.7 255,30,255 +cy 6,0,-6 0,1,0 1.5 3.0 0,200,200 pl 0,-1,0 0,1,0 220,220,230 diff --git a/scenes/pbr_showcase.rt b/scenes/pbr_showcase.rt new file mode 100644 index 0000000..ccecfb8 --- /dev/null +++ b/scenes/pbr_showcase.rt @@ -0,0 +1,23 @@ +R 1920 1080 +A 0.12 255,255,255 +E +c 0,16,38 0.07,0,0 65 +l -15,30,25 0.85 255,250,240 +l 20,25,35 0.55 220,235,255 +l 0,15,50 0.3 255,255,255 + +pl 0,-0.5,0 0,1,0 230,230,235 + +sp -12,2.5,14 5.0 230,30,30 spec:0.8 +sp -4,2.5,14 5.0 30,200,60 spec:0.5 +sp 4,2.5,14 5.0 40,80,230 spec:0.3 +sp 12,2.5,14 5.0 255,255,255 trans:0.9 ior:1.5 + +sp -12,2.5,0 5.0 240,240,245 metal:1.0 rough:0.05 spec:1.0 +sp -4,2.5,0 5.0 210,210,220 metal:1.0 rough:0.3 spec:0.8 +sp 4,2.5,0 5.0 180,160,150 metal:1.0 rough:0.6 spec:0.5 +sp 12,2.5,0 5.0 230,150,90 metal:1.0 rough:0.9 spec:0.3 + +bx -10,3,-14 6,6,6 255,210,60 metal:1.0 rough:0.2 spec:0.9 +cy 0,0,-14 0,1,0 3.0 7.0 180,220,255 metal:0.7 rough:0.15 spec:0.9 +co 10,0,-14 0,1,0 4.0 7.0 100,255,140 metal:0.5 rough:0.3 spec:0.7 diff --git a/scenes/quadrics.rt b/scenes/quadrics.rt index d40c330..5c54f67 100644 --- a/scenes/quadrics.rt +++ b/scenes/quadrics.rt @@ -1,11 +1,13 @@ R 1920 1080 A 0.25 255,255,255 -c 0,5,14 0,-0.2,-1 70 +E +c 0,14,24 0.15,0,0 70 l -3,20,8 0.60 255,255,255 +l 5,15,15 0.35 220,230,255 -hy -5,2,-4 0,1,0 1.5,2.0,0 6.0 255,80,30 -pa 0,0,-4 0,1,0 0.8 5.0 30,200,255 -co 5,0,-4 0,1,0 2.5 5.0 80,255,80 -sp -5,6,-4 1.0 255,255,100 -bx 5,5.5,-4 1.5,1.5,1.5 255,100,255 +hy -8,2,0 0,1,0 1.5,2.0,0 6.0 255,80,30 +pa 0,0,0 0,1,0 0.8 5.0 30,200,255 +co 8,0,0 0,1,0 2.5 5.0 80,255,80 +sp -4,6,0 1.0 255,255,100 +bx 4,5.5,0 1.5,1.5,1.5 255,100,255 pl 0,-1,0 0,1,0 220,220,230 diff --git a/scenes/taurus.rt b/scenes/taurus.rt index dcf0afc..7ab6564 100644 --- a/scenes/taurus.rt +++ b/scenes/taurus.rt @@ -1,5 +1,6 @@ R 1920 1080 A 0.08 255,255,255 +E c 0,14,20 0.2,0,0 60 l -10,20,20 0.5 255,245,235 l 10,18,15 0.4 235,240,255 diff --git a/skills/camera-and-rendering.md b/skills/camera-and-rendering.md index 85263b2..4662f89 100644 --- a/skills/camera-and-rendering.md +++ b/skills/camera-and-rendering.md @@ -139,6 +139,45 @@ DISPLAY=:42 ./miniRT scene.rt -save - Neutral wall/plane colors of 150–200 work better than 220–240 for scenes with white/reflective objects - At least 2 lights from different angles improves depth perception +## Post-42 Scene Generation Rules + +All scenes created after the initial 42 project **MUST** follow these rules. Original 42 scenes (cylinder, exemple, inside_cy, inside_cy_l, inside_sp, inside_sp_l, lights, objects, square, tiny, triangle, error) are left as-is. + +### Drone-view camera placement + +Every showcase scene uses an elevated "drone view" where **all objects are fully visible at first glance**: + +| Parameter | Rule | Typical range | +|-----------|------|---------------| +| Camera Y | max_object_height + 8–12 | 12–16 | +| Camera Z | furthest_object_z + scene_x_span × 0.8 | 20–40 | +| rot_x (pitch) | 0.12–0.18 (22°–32° downward tilt) | 0.15 | +| rot_y (yaw) | 0 always | 0 | +| rot_z (roll) | 0 always (**never** ±1) | 0 | +| FOV | 65–70° | 70 | + +**Checklist for row-based layouts** (e.g. materials, pbr_showcase): +- All objects in every row must be fully visible — no clipping at frame edges +- Clear ground visible between rows (at least 4–6 units of Z separation) +- Camera high enough that rear rows are not occluded by front rows + +### Sky background + +Add the `E` directive (on its own line, after `A`) to enable the gradient sky. This replaces the black void with a blue-to-white horizon gradient. + +``` +R 1920 1080 +A 0.15 255,255,255 +E +c 0,14,28 0.15,0,0 70 +``` + +### Object layout + +- Spread objects along X with at least 1 diameter of spacing between them +- Use 2+ lights from different angles for depth/shadow separation +- Ground plane color: light gray (220–235) to contrast with objects and sky + ### Material properties (parsed in `ft_material.c`) Appended to shape lines as `key:value` pairs: @@ -149,6 +188,9 @@ Appended to shape lines as `key:value` pairs: | `refl:` | 0–1 | Reflectivity (recursive ray tracing, max depth 4) | | `trans:` | 0–1 | Transparency | | `ior:` | >0 | Index of refraction (used with trans) | +| `metal:` | 0–1 | Metallic factor (PBR metallic-roughness workflow) | +| `rough:` | 0–1 | Surface roughness (0 = mirror, 1 = matte) | +| `emit:` | R,G,B | Emissive light (HDR values allowed, e.g. `emit:500,400,300`) | ### Supported shape IDs diff --git a/src/ft_argb.c b/src/ft_argb.c index daa20b2..71c56fe 100644 --- a/src/ft_argb.c +++ b/src/ft_argb.c @@ -22,6 +22,36 @@ static double ft_gamma_correct(double c) return (255.0 * pow(c / 255.0, 1.0 / 2.2)); } +/* ACES filmic tone mapping: maps HDR [0,inf) to [0,1] with filmic curve */ +static double ft_aces_tonemap(double x) +{ + double a; + double b; + + a = x * (2.51 * x + 0.03); + b = x * (2.43 * x + 0.59) + 0.14; + if (b < EPSILON_ZERO) + return (0.0); + x = a / b; + if (x < 0.0) + return (0.0); + if (x > 1.0) + return (1.0); + return (x); +} + +/* Apply ACES tone mapping and sRGB gamma for HDR path tracing output */ +static double ft_hdr_tonemap(double c) +{ + double linear; + + if (c <= 0.0) + return (0.0); + linear = c / 255.0; + linear = ft_aces_tonemap(linear); + return (255.0 * pow(linear, 1.0 / 2.2)); +} + /* Write a gamma-corrected pixel (scaled by resolution factor) into the image * buffer */ void ft_pix(int x, int y, t_window *w, t_argb color) @@ -32,9 +62,18 @@ void ft_pix(int x, int y, t_window *w, t_argb color) unsigned char g; unsigned char b; - r = (unsigned char)ft_gamma_correct(color.r); - g = (unsigned char)ft_gamma_correct(color.g); - b = (unsigned char)ft_gamma_correct(color.b); + if (w->path_trace_spp > 0) + { + r = (unsigned char)ft_hdr_tonemap(color.r); + g = (unsigned char)ft_hdr_tonemap(color.g); + b = (unsigned char)ft_hdr_tonemap(color.b); + } + else + { + r = (unsigned char)ft_gamma_correct(color.r); + g = (unsigned char)ft_gamma_correct(color.g); + b = (unsigned char)ft_gamma_correct(color.b); + } i = 0; while (i < w->resol) { diff --git a/src/ft_env.c b/src/ft_env.c new file mode 100644 index 0000000..82015b8 --- /dev/null +++ b/src/ft_env.c @@ -0,0 +1,45 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* ft_env.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: alienard +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/03/25 00:00:00 by alienard #+# #+# */ +/* Updated: 2026/03/25 00:00:00 by alienard ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "mini_rt.h" + +/* Return sky color for a ray direction using a gradient from horizon to zenith. + * Simulates a simple physically-inspired sky: blue zenith, white-ish horizon, + * warm ground hemisphere. Values are in [0,255] scale for consistency. */ +t_argb ft_env_sky(t_pt dir) +{ + t_argb sky; + double t; + double horizon_r; + double horizon_g; + double horizon_b; + double zenith_r; + double zenith_g; + double zenith_b; + + sky.a = 0; + t = 0.5 * (dir.y + 1.0); + if (t < 0.0) + t = 0.0; + if (t > 1.0) + t = 1.0; + horizon_r = 255.0; + horizon_g = 255.0; + horizon_b = 255.0; + zenith_r = 128.0; + zenith_g = 178.0; + zenith_b = 255.0; + sky.r = (1.0 - t) * horizon_r + t * zenith_r; + sky.g = (1.0 - t) * horizon_g + t * zenith_g; + sky.b = (1.0 - t) * horizon_b + t * zenith_b; + return (sky); +} diff --git a/src/ft_light.c b/src/ft_light.c index d0e0fe8..616fae0 100644 --- a/src/ft_light.c +++ b/src/ft_light.c @@ -69,6 +69,19 @@ static double ft_soft_shadow_sample(t_window *win, t_pt l_vec, t_pt p, return (total / 16.0); } +/* Add PBR contribution from a light source */ +static void ft_add_pbr_light(t_pt *i, t_pt l_vec, t_pt n, t_pt view_dir, + t_shape *sh, t_argb l_col, double shadow_factor, + double light_ratio) +{ + t_argb pbr; + + pbr = ft_pbr_shade(view_dir, l_vec, n, &sh->mat, l_col, sh->color); + i->x += pbr.r * shadow_factor * light_ratio / 255.0; + i->y += pbr.g * shadow_factor * light_ratio / 255.0; + i->z += pbr.b * shadow_factor * light_ratio / 255.0; +} + /* Accumulate ambient and per-light diffuse/specular illumination at a point */ t_pt ft_light(t_window *win, t_pt n, t_pt p, t_shape *sh, t_pt view_dir) { @@ -78,9 +91,12 @@ t_pt ft_light(t_window *win, t_pt n, t_pt p, t_shape *sh, t_pt view_dir) double n_dot_l; double light_dist; double shadow_factor; + int use_pbr; i = ft_add_scal(win->ratio, (t_pt){0, 0, 0}); ft_db_mult_to_add_pt(&i, win->ratio, win->col); + use_pbr = + (sh->mat.roughness > EPSILON_ZERO || sh->mat.metallic > EPSILON_ZERO); cur_light = win->beg_light; while (cur_light) { @@ -93,10 +109,16 @@ t_pt ft_light(t_window *win, t_pt n, t_pt p, t_shape *sh, t_pt view_dir) shadow_factor = ft_soft_shadow_sample(win, l_vec, p, light_dist); if (shadow_factor > EPSILON_NORMAL) { - n_dot_l = cur_light->light_ratio * n_dot_l / ft_lenght(n); - ft_db_mult_to_add_pt(&i, n_dot_l * shadow_factor, - cur_light->col); - ft_add_specular(&i, l_vec, n, view_dir, sh, cur_light->col); + if (use_pbr) + ft_add_pbr_light(&i, l_vec, n, view_dir, sh, cur_light->col, + shadow_factor, cur_light->light_ratio); + else + { + n_dot_l = cur_light->light_ratio * n_dot_l / ft_lenght(n); + ft_db_mult_to_add_pt(&i, n_dot_l * shadow_factor, + cur_light->col); + ft_add_specular(&i, l_vec, n, view_dir, sh, cur_light->col); + } } } cur_light = cur_light->next; diff --git a/src/ft_material.c b/src/ft_material.c index 8598e30..6a4e16b 100644 --- a/src/ft_material.c +++ b/src/ft_material.c @@ -26,6 +26,9 @@ void ft_material_default(t_material *mat) mat->tex_scale_u = 1.0; mat->tex_scale_v = 1.0; mat->bump_strength = 1.0; + mat->metallic = 0.0; + mat->roughness = 0.0; + mat->emission = (t_argb){0, 0, 0, 0}; mat->vel = (t_pt){0, 0, 0}; } @@ -53,6 +56,31 @@ static int ft_parse_mat_prop(t_material *mat, char **line) *line += 5; mat->specular = ft_atof(*line); } + else if (ft_strncmp(*line, "metal:", 6) == 0) + { + *line += 6; + mat->metallic = ft_atof(*line); + } + else if (ft_strncmp(*line, "rough:", 6) == 0) + { + *line += 6; + mat->roughness = ft_atof(*line); + } + else if (ft_strncmp(*line, "emit:", 5) == 0) + { + *line += 5; + mat->emission.r = ft_atof(*line); + while (**line && **line != ',') + (*line)++; + if (**line == ',') + (*line)++; + mat->emission.g = ft_atof(*line); + while (**line && **line != ',') + (*line)++; + if (**line == ',') + (*line)++; + mat->emission.b = ft_atof(*line); + } else if (ft_strncmp(*line, "vel:", 4) == 0) { *line += 4; diff --git a/src/ft_mini_rt.c b/src/ft_mini_rt.c index dc48217..022e461 100644 --- a/src/ft_mini_rt.c +++ b/src/ft_mini_rt.c @@ -62,6 +62,9 @@ void ft_parse(int *check, t_window *win, int fd) ft_cam_init(win, &win->beg_cam, win->line); else if (win->line[0] == 'l' && (ft_isspace(win->line[1])) == 1) ft_light_init(win, &win->beg_light, win->line); + else if (win->line[0] == 'E' && + ((ft_isspace(win->line[1])) == 1 || win->line[1] == '\0')) + win->use_env_sky = 1; else if ((ft_isalpha(win->line[0])) == 1) ft_shape_init(win, &win->beg_sh, win->line); else if (win->line[0] != '\0') diff --git a/src/ft_pathtrace.c b/src/ft_pathtrace.c index 6a16ca8..aeedfc5 100644 --- a/src/ft_pathtrace.c +++ b/src/ft_pathtrace.c @@ -1,7 +1,7 @@ /* ************************************************************************** */ /* */ /* ::: :::::::: */ -/* ft_pathtrace.c :+: :+: :+: */ +/* ft_pathtrace.c :+: :+: :+: */ /* +:+ +:+ +:+ */ /* By: alienard +#+ +:+ +#+ */ /* +#+#+#+#+#+ +#+ */ @@ -12,28 +12,78 @@ #include "mini_rt.h" -/* Compute indirect illumination color contribution from shape albedo */ -static t_argb ft_indirect_color(t_argb indirect, t_argb shape_col) +/* Compute direct lighting at a hit point using PBR or Phong shading */ +static t_argb ft_pt_direct(t_window *win, t_ray *ray, t_shape *sh, t_pt p, + t_argb albedo) { - t_argb result; + t_light *cur; + t_argb color; + t_pt l_vec; + double l_dist; + double shadow; + t_pt view; + + color = (t_argb){0, 0, 0, 0}; + view = ft_normal_vect(ft_neg_pt(ray->dir)); + cur = win->beg_light; + while (cur) + { + l_vec = ft_subtraction(cur->coord, p); + l_dist = ft_lenght(l_vec); + l_vec = ft_div_scal(l_dist, l_vec); + if (ft_dot_product(ray->hit_n, l_vec) > EPSILON_NORMAL) + { + shadow = ft_shadow(win, l_vec, p, l_dist); + if (shadow > EPSILON_NORMAL) + { + if (sh->mat.roughness > EPSILON_ZERO || + sh->mat.metallic > EPSILON_ZERO) + { + t_argb pbr; + + pbr = ft_pbr_shade(view, l_vec, ray->hit_n, &sh->mat, + cur->col, albedo); + color.r += pbr.r * cur->light_ratio; + color.g += pbr.g * cur->light_ratio; + color.b += pbr.b * cur->light_ratio; + } + else + { + double n_dot_l; - result.a = 0; - result.r = indirect.r * (shape_col.r / 255.0); - result.g = indirect.g * (shape_col.g / 255.0); - result.b = indirect.b * (shape_col.b / 255.0); - return (result); + n_dot_l = + cur->light_ratio * ft_dot_product(ray->hit_n, l_vec); + color.r += n_dot_l * cur->col.r * albedo.r / 255.0; + color.g += n_dot_l * cur->col.g * albedo.g / 255.0; + color.b += n_dot_l * cur->col.b * albedo.b / 255.0; + } + } + } + cur = cur->next; + } + color.r += win->ratio * win->col.r * albedo.r / 255.0; + color.g += win->ratio * win->col.g * albedo.g / 255.0; + color.b += win->ratio * win->col.b * albedo.b / 255.0; + return (color); +} + +/* Compute luminance of an ARGB color for Russian Roulette */ +static double ft_luminance(t_argb c) +{ + return (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b); } -/* Trace a single path through the scene with Monte Carlo bounces */ +/* Trace a single path through the scene with Monte Carlo bounces, + * Russian Roulette termination, emissive materials, and environment sky */ static t_argb ft_pathtrace_ray(t_window *win, t_ray *ray, int bounces, unsigned int *seed) { t_shape *min_sh; double min; + t_argb albedo; t_argb color; - t_argb hit_color; t_pt p; - t_pt view_dir; + double rr_prob; min_sh = NULL; min = INFINITY; @@ -42,17 +92,51 @@ static t_argb ft_pathtrace_ray(t_window *win, t_ray *ray, int bounces, else ft_trace_shapes(win->beg_sh, ray, &min, &min_sh); if (min_sh == NULL) - return ((t_argb){0, 0, 0, 0}); - hit_color = min_sh->color; + return (ft_env_sky(ray->dir)); + albedo = min_sh->color; if (min_sh->mat.texture) - hit_color = ft_get_shape_color(min_sh, ray); + albedo = ft_get_shape_color(min_sh, ray); if (min_sh->mat.bump_map) ft_apply_bump_map(min_sh, ray); p = ft_addition(ray->orig, ft_multi_scal(min, ray->dir)); - view_dir = ft_normal_vect(ft_neg_pt(ray->dir)); - color = - ft_albedo(ft_light(win, ray->hit_n, p, min_sh, view_dir), hit_color); - if (bounces > 0) + color = ft_pt_direct(win, ray, min_sh, p, albedo); + color.r += min_sh->mat.emission.r; + color.g += min_sh->mat.emission.g; + color.b += min_sh->mat.emission.b; + if (bounces <= 0) + return (color); + rr_prob = fmin(ft_luminance(albedo) / 255.0, 0.95); + if (bounces < win->path_trace_bounces && ft_rand_float(seed) > rr_prob) + return (color); + if (bounces < win->path_trace_bounces) + { + t_ray bounce; + t_argb indirect; + double cosine_pdf; + double n_dot_d; + + bounce.orig = ft_addition(p, ft_multi_scal(EPSILON_NORMAL, ray->hit_n)); + if (min_sh->mat.reflectivity > EPSILON_ZERO && + ft_rand_float(seed) < min_sh->mat.reflectivity) + bounce.dir = ft_reflect_ray(ray->dir, ray->hit_n); + else + bounce.dir = ft_rand_hemisphere_cosine(ray->hit_n, seed); + bounce.lenght = -1; + bounce.motion_time = ray->motion_time; + indirect = ft_pathtrace_ray(win, &bounce, bounces - 1, seed); + n_dot_d = fmax(ft_dot_product(ray->hit_n, bounce.dir), 0.0); + cosine_pdf = n_dot_d / M_PI; + if (cosine_pdf > EPSILON_ZERO) + { + color.r += indirect.r * (albedo.r / 255.0) * n_dot_d / + (cosine_pdf * rr_prob); + color.g += indirect.g * (albedo.g / 255.0) * n_dot_d / + (cosine_pdf * rr_prob); + color.b += indirect.b * (albedo.b / 255.0) * n_dot_d / + (cosine_pdf * rr_prob); + } + } + else { t_ray bounce; t_argb indirect; @@ -62,10 +146,9 @@ static t_argb ft_pathtrace_ray(t_window *win, t_ray *ray, int bounces, bounce.lenght = -1; bounce.motion_time = ray->motion_time; indirect = ft_pathtrace_ray(win, &bounce, bounces - 1, seed); - indirect = ft_indirect_color(indirect, hit_color); - color.r += indirect.r; - color.g += indirect.g; - color.b += indirect.b; + color.r += indirect.r * (albedo.r / 255.0); + color.g += indirect.g * (albedo.g / 255.0); + color.b += indirect.b * (albedo.b / 255.0); } return (color); } diff --git a/src/ft_pbr.c b/src/ft_pbr.c new file mode 100644 index 0000000..dc2ab0b --- /dev/null +++ b/src/ft_pbr.c @@ -0,0 +1,95 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* ft_pbr.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: alienard +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/03/25 00:00:00 by alienard #+# #+# */ +/* Updated: 2026/03/25 00:00:00 by alienard ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "mini_rt.h" + +/* Schlick's approximation for Fresnel reflectance */ +double ft_fresnel_schlick(double cos_theta, double f0) +{ + double x; + + if (cos_theta < 0.0) + cos_theta = 0.0; + x = 1.0 - cos_theta; + return (f0 + (1.0 - f0) * x * x * x * x * x); +} + +/* GGX/Trowbridge-Reitz normal distribution function */ +static double ft_ggx_ndf(double n_dot_h, double roughness) +{ + double a; + double a2; + double denom; + + a = roughness * roughness; + a2 = a * a; + denom = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0; + denom = M_PI * denom * denom; + if (denom < EPSILON_ZERO) + return (0.0); + return (a2 / denom); +} + +/* Schlick-GGX geometry function for a single direction */ +static double ft_geom_schlick_ggx(double n_dot_v, double roughness) +{ + double k; + + k = (roughness + 1.0) * (roughness + 1.0) / 8.0; + return (n_dot_v / (n_dot_v * (1.0 - k) + k)); +} + +/* Smith's geometry function combining view and light masking */ +static double ft_geom_smith(double n_dot_v, double n_dot_l, double roughness) +{ + return (ft_geom_schlick_ggx(n_dot_v, roughness) * + ft_geom_schlick_ggx(n_dot_l, roughness)); +} + +/* Cook-Torrance PBR BRDF: combines GGX NDF, geometry, and Fresnel */ +t_argb ft_pbr_shade(t_pt view, t_pt light_dir, t_pt normal, t_material *mat, + t_argb light_col, t_argb albedo) +{ + t_argb result; + t_pt half_v; + double n_dot_l; + double n_dot_v; + double n_dot_h; + double h_dot_v; + double d; + double g; + double f; + double f0; + double spec; + double kd; + + result = (t_argb){0, 0, 0, 0}; + n_dot_l = ft_dot_product(normal, light_dir); + if (n_dot_l <= 0.0) + return (result); + n_dot_v = ft_dot_product(normal, view); + if (n_dot_v <= 0.0) + n_dot_v = EPSILON_ZERO; + half_v = ft_normal_vect(ft_addition(view, light_dir)); + n_dot_h = fmax(ft_dot_product(normal, half_v), 0.0); + h_dot_v = fmax(ft_dot_product(half_v, view), 0.0); + f0 = 0.04 * (1.0 - mat->metallic) + mat->metallic; + d = ft_ggx_ndf(n_dot_h, mat->roughness); + g = ft_geom_smith(n_dot_v, n_dot_l, mat->roughness); + f = ft_fresnel_schlick(h_dot_v, f0); + spec = (d * g * f) / (4.0 * n_dot_v * n_dot_l + EPSILON_ZERO); + kd = (1.0 - f) * (1.0 - mat->metallic); + result.r = (kd * albedo.r / 255.0 / M_PI + spec) * light_col.r * n_dot_l; + result.g = (kd * albedo.g / 255.0 / M_PI + spec) * light_col.g * n_dot_l; + result.b = (kd * albedo.b / 255.0 / M_PI + spec) * light_col.b * n_dot_l; + return (result); +} diff --git a/src/ft_ray.c b/src/ft_ray.c index fedcf9c..112912b 100644 --- a/src/ft_ray.c +++ b/src/ft_ray.c @@ -168,7 +168,11 @@ t_argb ft_trace_ray_recursive(t_window *win, t_ray *ray, int depth) ft_trace_shapes(win->beg_sh, ray, &min, &min_sh); ft_trace_csg_shapes(win, ray, &min, &min_sh); if (min_sh == NULL) + { + if (win->use_env_sky) + return (ft_env_sky(ray->dir)); return (black = (t_argb){0, 0, 0, 0}); + } hit_color = min_sh->color; if (min_sh->mat.texture) hit_color = ft_get_shape_color(min_sh, ray); diff --git a/tests/regression/new_shapes.rt b/tests/regression/new_shapes.rt index 69b4124..75fb557 100644 --- a/tests/regression/new_shapes.rt +++ b/tests/regression/new_shapes.rt @@ -1,13 +1,16 @@ R 1920 1080 A 0.18 255,255,255 -c 0,4,12 0,-0.15,-1 70 -l 0,20,5 0.45 255,255,255 +E +c 0,12,22 0.12,0,0 70 +l -10,25,10 0.55 255,250,240 +l 10,15,20 0.35 220,230,255 -sp -8,1.5,-3 3.0 255,30,30 -co -4,0,-3 0,1,0 2.0 4.0 30,255,30 -dk 0,2,-5 0,0,1 4.0 255,255,30 -el 4,1.5,-3 2.0,1.2,1.2 30,80,255 -bx 8,1.5,-3 3,3,3 255,140,0 -to 0,5,-8 0,1,0 2.5 0.7 255,30,255 -cy -6,-0.5,-8 0,1,0 1.5 3.0 0,200,200 pl 0,-1,0 0,1,0 220,220,230 + +sp -10,1.5,0 3.0 255,30,30 spec:0.3 +co -5,0,0 0,1,0 2.5 4.5 30,255,30 spec:0.3 +dk 0,2.5,-2 0,0,1 4.0 255,255,30 +el 5,1.5,0 2.0,1.2,1.2 30,80,255 spec:0.4 +bx 10,1.5,0 3,3,3 255,140,0 spec:0.3 +to -3,4,-7 0,1,0 2.5 0.7 255,30,255 spec:0.5 +cy 5,0,-7 0,1,0 1.8 3.5 0,200,200 spec:0.3 diff --git a/tests/regression/reference/new_shapes.bmp b/tests/regression/reference/new_shapes.bmp index f6817b4..f32f6e3 100755 Binary files a/tests/regression/reference/new_shapes.bmp and b/tests/regression/reference/new_shapes.bmp differ diff --git a/tests/test_all.c b/tests/test_all.c index 34cf8f3..789dc1f 100644 --- a/tests/test_all.c +++ b/tests/test_all.c @@ -10,6 +10,7 @@ void run_camera_tests(void); void run_intersection_tests(void); void run_lighting_tests(void); void run_parsing_tests(void); +void run_pbr_tests(void); int main(void) { @@ -18,5 +19,6 @@ int main(void) run_intersection_tests(); run_lighting_tests(); run_parsing_tests(); + run_pbr_tests(); TEST_REPORT(); } diff --git a/tests/test_pbr.c b/tests/test_pbr.c new file mode 100644 index 0000000..01395a5 --- /dev/null +++ b/tests/test_pbr.c @@ -0,0 +1,394 @@ +#include "mini_rt.h" +#include "minirt_test.h" + +/* ========== ft_fresnel_schlick tests ========== */ + +TEST(test_fresnel_head_on) +{ + ASSERT_DBL_EQ(0.04, ft_fresnel_schlick(1.0, 0.04)); +} + +TEST(test_fresnel_grazing) +{ + ASSERT_DBL_EQ(1.0, ft_fresnel_schlick(0.0, 0.04)); +} + +TEST(test_fresnel_45deg) +{ + double cos_t = cos(M_PI / 4.0); + double x = 1.0 - cos_t; + double expected = 0.04 + 0.96 * x * x * x * x * x; + + ASSERT_DBL_EQ(expected, ft_fresnel_schlick(cos_t, 0.04)); +} + +TEST(test_fresnel_negative_clamp) +{ + ASSERT_DBL_EQ(1.0, ft_fresnel_schlick(-0.5, 0.04)); +} + +TEST(test_fresnel_metal) +{ + ASSERT_DBL_EQ(1.0, ft_fresnel_schlick(1.0, 1.0)); +} + +/* ========== ft_env_sky tests ========== */ + +TEST(test_env_sky_up) +{ + t_argb r = ft_env_sky((t_pt){0, 1, 0}); + + ASSERT_DBL_EQ(128.0, r.r); + ASSERT_DBL_EQ(178.0, r.g); + ASSERT_DBL_EQ(255.0, r.b); +} + +TEST(test_env_sky_down) +{ + t_argb r = ft_env_sky((t_pt){0, -1, 0}); + + ASSERT_DBL_EQ(255.0, r.r); + ASSERT_DBL_EQ(255.0, r.g); + ASSERT_DBL_EQ(255.0, r.b); +} + +TEST(test_env_sky_horizon) +{ + t_argb r = ft_env_sky((t_pt){1, 0, 0}); + + ASSERT_DBL_EQ(191.5, r.r); + ASSERT_DBL_EQ(216.5, r.g); + ASSERT_DBL_EQ(255.0, r.b); +} + +TEST(test_env_sky_below_clamp) +{ + t_argb r = ft_env_sky((t_pt){0, -2, 0}); + + ASSERT_DBL_EQ(255.0, r.r); + ASSERT_DBL_EQ(255.0, r.g); + ASSERT_DBL_EQ(255.0, r.b); +} + +/* ========== ft_material_default tests ========== */ + +TEST(test_material_default_values) +{ + t_material mat; + + memset(&mat, 0xFF, sizeof(t_material)); + ft_material_default(&mat); + ASSERT_DBL_EQ(0.0, mat.reflectivity); + ASSERT_DBL_EQ(0.0, mat.transparency); + ASSERT_DBL_EQ(1.0, mat.refr_index); + ASSERT_DBL_EQ(0.0, mat.specular); + ASSERT_DBL_EQ(32.0, mat.shininess); + ASSERT_TRUE(mat.texture == NULL); + ASSERT_TRUE(mat.bump_map == NULL); + ASSERT_DBL_EQ(1.0, mat.tex_scale_u); + ASSERT_DBL_EQ(1.0, mat.tex_scale_v); + ASSERT_DBL_EQ(1.0, mat.bump_strength); + ASSERT_DBL_EQ(0.0, mat.metallic); + ASSERT_DBL_EQ(0.0, mat.roughness); + ASSERT_ARGB_EQ(0.0, 0.0, 0.0, mat.emission); + ASSERT_PT_EQ(0.0, 0.0, 0.0, mat.vel); +} + +/* ========== ft_reflect_ray tests ========== */ + +TEST(test_reflect_straight_on) +{ + t_pt dir = {0, -1, 0}; + t_pt normal = {0, 1, 0}; + t_pt r = ft_reflect_ray(dir, normal); + + ASSERT_PT_EQ(0, 1, 0, r); +} + +TEST(test_reflect_45deg) +{ + t_pt dir = ft_normal_vect((t_pt){1, -1, 0}); + t_pt normal = {0, 1, 0}; + t_pt r = ft_reflect_ray(dir, normal); + double s = 1.0 / sqrt(2.0); + + ASSERT_PT_EQ(s, s, 0, r); +} + +/* ========== ft_refract_ray tests ========== */ + +TEST(test_refract_normal_incidence) +{ + t_pt dir = {0, 0, -1}; + t_pt normal = {0, 0, 1}; + t_pt r = ft_refract_ray(dir, normal, 1.0); + + ASSERT_PT_EQ(0, 0, -1, r); +} + +TEST(test_refract_total_internal_reflection) +{ + t_pt dir = ft_normal_vect((t_pt){0.866025, 0, -0.5}); + t_pt normal = {0, 0, 1}; + t_pt r = ft_refract_ray(dir, normal, 2.0); + t_pt expected = ft_reflect_ray(dir, normal); + + ASSERT_PT_EQ(expected.x, expected.y, expected.z, r); +} + +/* ========== ft_parse_material tests ========== */ + +TEST(test_parse_material_metal) +{ + t_window win; + t_material mat; + char line[] = "metal:0.8"; + char *p = line; + + ft_window_init(&win); + ft_material_default(&mat); + ft_parse_material(&win, &mat, &p); + ASSERT_DBL_EQ(0.8, mat.metallic); +} + +TEST(test_parse_material_rough) +{ + t_window win; + t_material mat; + char line[] = "rough:0.5"; + char *p = line; + + ft_window_init(&win); + ft_material_default(&mat); + ft_parse_material(&win, &mat, &p); + ASSERT_DBL_EQ(0.5, mat.roughness); +} + +TEST(test_parse_material_emit) +{ + t_window win; + t_material mat; + char line[] = "emit:10,20,30"; + char *p = line; + + ft_window_init(&win); + ft_material_default(&mat); + ft_parse_material(&win, &mat, &p); + ASSERT_DBL_EQ(10.0, mat.emission.r); + ASSERT_DBL_EQ(20.0, mat.emission.g); + ASSERT_DBL_EQ(30.0, mat.emission.b); +} + +TEST(test_parse_material_combined) +{ + t_window win; + t_material mat; + char line[] = "metal:0.9 rough:0.3 emit:5,10,15"; + char *p = line; + + ft_window_init(&win); + ft_material_default(&mat); + ft_parse_material(&win, &mat, &p); + ASSERT_DBL_EQ(0.9, mat.metallic); + ASSERT_DBL_EQ(0.3, mat.roughness); + ASSERT_DBL_EQ(5.0, mat.emission.r); + ASSERT_DBL_EQ(10.0, mat.emission.g); + ASSERT_DBL_EQ(15.0, mat.emission.b); +} + +TEST(test_parse_material_vel) +{ + t_window win; + t_material mat; + char line[] = "vel:1.5,2.0,-0.5"; + char *p = line; + + ft_window_init(&win); + ft_material_default(&mat); + ft_parse_material(&win, &mat, &p); + ASSERT_DBL_EQ(1.5, mat.vel.x); + ASSERT_DBL_EQ(2.0, mat.vel.y); + ASSERT_DBL_EQ(-0.5, mat.vel.z); +} + +/* ========== ft_pbr_shade tests ========== */ + +TEST(test_pbr_shade_head_on) +{ + t_material mat; + t_argb result; + + ft_material_default(&mat); + mat.metallic = 0.0; + mat.roughness = 0.5; + result = ft_pbr_shade((t_pt){0, 0, 1}, (t_pt){0, 0, 1}, (t_pt){0, 0, 1}, + &mat, (t_argb){0, 255, 255, 255}, + (t_argb){0, 255, 255, 255}); + ASSERT_TRUE(result.r > 0); + ASSERT_TRUE(result.g > 0); + ASSERT_TRUE(result.b > 0); + ASSERT_DBL_EQ(result.r, result.g); + ASSERT_DBL_EQ(result.g, result.b); +} + +TEST(test_pbr_shade_backface) +{ + t_material mat; + t_argb result; + + ft_material_default(&mat); + mat.metallic = 0.0; + mat.roughness = 0.5; + result = ft_pbr_shade((t_pt){0, 0, 1}, (t_pt){0, 0, -1}, (t_pt){0, 0, 1}, + &mat, (t_argb){0, 255, 255, 255}, + (t_argb){0, 255, 255, 255}); + ASSERT_ARGB_EQ(0, 0, 0, result); +} + +TEST(test_pbr_shade_metal) +{ + t_material mat; + t_argb result; + + ft_material_default(&mat); + mat.metallic = 1.0; + mat.roughness = 0.3; + result = ft_pbr_shade((t_pt){0, 0, 1}, (t_pt){0, 0, 1}, (t_pt){0, 0, 1}, + &mat, (t_argb){0, 255, 255, 255}, + (t_argb){0, 255, 255, 255}); + ASSERT_TRUE(result.r > 0); +} + +/* ========== ft_light PBR branch test ========== */ + +TEST(test_light_pbr_branch) +{ + t_window win; + t_shape sh; + t_light light; + + ft_window_init(&win); + win.ratio = 0.1; + win.col = (t_argb){0, 255, 255, 255}; + win.beg_sh = NULL; + + memset(&light, 0, sizeof(t_light)); + light.coord = ft_pt_create(0, 10, 0); + light.light_ratio = 0.8; + light.col = (t_argb){0, 255, 255, 255}; + light.next = NULL; + win.beg_light = &light; + + memset(&sh, 0, sizeof(t_shape)); + ft_material_default(&sh.mat); + sh.mat.metallic = 0.5; + sh.mat.roughness = 0.4; + sh.color = (t_argb){0, 200, 200, 200}; + + t_pt n = ft_pt_create(0, 1, 0); + t_pt p = ft_pt_create(0, 0, 0); + t_pt result = ft_light(&win, n, p, &sh, ft_pt_create(0, 0, 1)); + + ASSERT_TRUE(result.x > win.ratio); +} + +/* ========== ft_trace_ray_recursive env sky tests ========== */ + +TEST(test_trace_ray_env_sky_miss) +{ + t_window win; + t_ray ray; + + ft_window_init(&win); + win.use_env_sky = 1; + win.beg_sh = NULL; + win.beg_light = NULL; + + memset(&ray, 0, sizeof(t_ray)); + ray.dir = ft_pt_create(0, 1, 0); + ray.orig = ft_pt_create(0, 0, 0); + ray.lenght = -1; + + t_argb result = ft_trace_ray_recursive(&win, &ray, 0); + t_argb expected = ft_env_sky((t_pt){0, 1, 0}); + + ASSERT_ARGB_EQ(expected.r, expected.g, expected.b, result); +} + +TEST(test_trace_ray_no_env_sky_miss) +{ + t_window win; + t_ray ray; + + ft_window_init(&win); + win.use_env_sky = 0; + win.beg_sh = NULL; + win.beg_light = NULL; + + memset(&ray, 0, sizeof(t_ray)); + ray.dir = ft_pt_create(0, 1, 0); + ray.orig = ft_pt_create(0, 0, 0); + ray.lenght = -1; + + t_argb result = ft_trace_ray_recursive(&win, &ray, 0); + + ASSERT_DBL_EQ(0.0, result.r); + ASSERT_DBL_EQ(0.0, result.g); + ASSERT_DBL_EQ(0.0, result.b); +} + +/* ========== Runner ========== */ + +void run_pbr_tests(void); + +#ifndef TEST_ALL +int main(void) +{ + run_pbr_tests(); + TEST_REPORT(); +} +#endif + +void run_pbr_tests(void) +{ + TEST_SUITE("ft_fresnel_schlick"); + RUN_TEST(test_fresnel_head_on); + RUN_TEST(test_fresnel_grazing); + RUN_TEST(test_fresnel_45deg); + RUN_TEST(test_fresnel_negative_clamp); + RUN_TEST(test_fresnel_metal); + + TEST_SUITE("ft_env_sky"); + RUN_TEST(test_env_sky_up); + RUN_TEST(test_env_sky_down); + RUN_TEST(test_env_sky_horizon); + RUN_TEST(test_env_sky_below_clamp); + + TEST_SUITE("ft_material_default"); + RUN_TEST(test_material_default_values); + + TEST_SUITE("ft_reflect_ray / ft_refract_ray"); + RUN_TEST(test_reflect_straight_on); + RUN_TEST(test_reflect_45deg); + RUN_TEST(test_refract_normal_incidence); + RUN_TEST(test_refract_total_internal_reflection); + + TEST_SUITE("ft_parse_material (PBR)"); + RUN_TEST(test_parse_material_metal); + RUN_TEST(test_parse_material_rough); + RUN_TEST(test_parse_material_emit); + RUN_TEST(test_parse_material_combined); + RUN_TEST(test_parse_material_vel); + + TEST_SUITE("ft_pbr_shade"); + RUN_TEST(test_pbr_shade_head_on); + RUN_TEST(test_pbr_shade_backface); + RUN_TEST(test_pbr_shade_metal); + + TEST_SUITE("ft_light PBR branch"); + RUN_TEST(test_light_pbr_branch); + + TEST_SUITE("ft_trace_ray env sky"); + RUN_TEST(test_trace_ray_env_sky_miss); + RUN_TEST(test_trace_ray_no_env_sky_miss); +} diff --git a/tools/bmp_to_png.py b/tools/bmp_to_png.py new file mode 100755 index 0000000..2c10d5c --- /dev/null +++ b/tools/bmp_to_png.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Convert BMP renders to PNG for visualization in PRs and documentation. + +Usage: + python3 tools/bmp_to_png.py # Convert all regression refs + python3 tools/bmp_to_png.py saves/ # Convert all BMPs in a folder + python3 tools/bmp_to_png.py file.bmp # Convert a single file + python3 tools/bmp_to_png.py --output gallery/ # Specify output directory +""" + +import os +import sys +import glob +from PIL import Image + + +def convert_bmp_to_png(bmp_path, output_dir): + """Convert a single BMP file to PNG.""" + name = os.path.splitext(os.path.basename(bmp_path))[0] + png_path = os.path.join(output_dir, name + ".png") + try: + img = Image.open(bmp_path) + img.save(png_path, "PNG", optimize=True) + size_kb = os.path.getsize(png_path) / 1024 + print(f" {name}.bmp -> {name}.png ({size_kb:.0f} KB)") + return True + except Exception as e: + print(f" {name}.bmp -> FAILED: {e}") + return False + + +def main(): + output_dir = None + sources = [] + + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--output" and i + 1 < len(args): + output_dir = args[i + 1] + i += 2 + else: + sources.append(args[i]) + i += 1 + + # Default: convert regression reference BMPs + if not sources: + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_dir = os.path.dirname(script_dir) + sources = [os.path.join(project_dir, "tests", "regression", "reference")] + + bmp_files = [] + for src in sources: + if os.path.isdir(src): + bmp_files.extend(sorted(glob.glob(os.path.join(src, "*.bmp")))) + elif os.path.isfile(src) and src.lower().endswith(".bmp"): + bmp_files.append(src) + else: + print(f"Warning: skipping '{src}' (not a BMP file or directory)") + + if not bmp_files: + print("No BMP files found.") + sys.exit(1) + + # Default output: renders_preview/ in project root + if output_dir is None: + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_dir = os.path.dirname(script_dir) + output_dir = os.path.join(project_dir, "renders_preview") + + os.makedirs(output_dir, exist_ok=True) + + print(f"Converting {len(bmp_files)} BMP(s) -> PNG in {output_dir}/") + ok = sum(convert_bmp_to_png(f, output_dir) for f in bmp_files) + print(f"\nDone: {ok}/{len(bmp_files)} converted.") + + +if __name__ == "__main__": + main()