diff --git a/README.md b/README.md index 448c562..948ca56 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,27 @@ A pure Python raytracer that renders the Cornell Box scene with: - Python 3.8+ - numpy - Pillow +- rich (progress bars, colored tables) +- matplotlib (render metrics graphs) ```bash -pip install numpy Pillow +pip install numpy Pillow rich matplotlib ``` +## Features + +- **Rich Progress Bar** - animated progress with ETA, speed, and row count +- **Scene Configuration Table** - formatted parameter display +- **Region Brightness Analysis** - color-coded brightness per region (left/right wall, floor, ceiling, center) +- **Render Metrics Graph** - saved as `render_metrics.png`, shows brightness convergence and render preview +- **Live Preview** - `preview.png` updated during rendering +- **Reflections** - mirror/chrome spheres reflect surrounding geometry +- **Caustics & Refraction** - glass spheres bend light (dielectric material with Schlick approximation) +- **Multiple Bounces** - recursive path tracing up to configurable depth +- **Next Event Estimation (NEE)** - explicit light sampling for fast convergence +- **Anti-aliasing** - supersampled pixels with jittered rays +- **Multiprocessing** - parallel rendering across CPU cores + ## Usage ```bash @@ -80,13 +96,23 @@ The scene is the classic **Cornell Box**: | Dielectric | Snell refraction + Schlick fresnel + random bounce | | Light | Emissive surface, sampled via NEE | +## Progress Indicators + +The renderer uses **Rich** for terminal UI and **matplotlib** for metrics graphs: + +- **Progress Bar** - animated with spinner, bar, percentage, ETA, and row count +- **Settings Table** - formatted parameter display with colored values +- **Region Brightness** - color-coded ASCII bar chart showing brightness per region +- **Metrics Graph** - saved as `render_metrics.png`, shows brightness convergence curve and live render preview +- **Preview Image** - saved as `preview.png` and updated during rendering + ## Performance -Approximate render times (with multiprocessing, 4 workers): +Measured on 4 workers, single machine: | Resolution | spp | Time | |------------|-----|------| -| 200x150 | 20 | ~2 min | +| 200x150 | 20 | ~40s | | 400x300 | 50 | ~10 min | | 800x600 | 50 | ~40 min | diff --git a/cornell_hq.png b/cornell_hq.png new file mode 100644 index 0000000..5273596 Binary files /dev/null and b/cornell_hq.png differ diff --git a/raytracer.py b/raytracer.py index cf4bb23..25483ca 100644 --- a/raytracer.py +++ b/raytracer.py @@ -427,20 +427,127 @@ def render(scene, width=800, height=600, samples_per_pixel=50, max_depth=15, max_depth, lookfrom, lookat, vfov, s, e, w * 100000 + 42)) from concurrent.futures import ProcessPoolExecutor, as_completed - with ProcessPoolExecutor(max_workers=workers) as executor: - futures = {executor.submit(render_chunk, r): r[9] for r in ranges} - completed = 0 - for f in as_completed(futures): - chunk_start, chunk_img = f.result() - end = chunk_start + chunk_img.shape[0] - image[chunk_start:end] = chunk_img - completed += 1 - print(f"Chunk {completed}/{workers} done ({time.time() - start:.1f}s)") - img = (image * 255).astype(np.uint8) - Image.fromarray(img, 'RGB').save(output) + from rich.console import Console + from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn + from rich.table import Table + from rich.panel import Panel + from matplotlib import pyplot as plt + + console = Console() + + console.print() + console.print(Panel.fit( + "[bold magenta]Cornell Box Raytracer[/bold magenta]\n" + "[dim]Reflections | Caustics | Multiple Bounces | NEE[/dim]", + border_style="bright_blue", padding=(0, 2) + )) + + settings = Table(title="Scene Configuration", show_header=False) + settings.add_column("Parameter", style="cyan", no_wrap=True) + settings.add_column("Value", style="white") + settings.add_row("Resolution", f"[bold]{width}x{height}[/bold]") + settings.add_row("Samples/pixel", f"[bold yellow]{samples_per_pixel}[/bold yellow]") + settings.add_row("Max bounces", f"[bold green]{max_depth}[/bold green]") + settings.add_row("Workers", f"[bold magenta]{workers}[/bold magenta]") + settings.add_row("Output", f"[bold]{output}[/bold]") + console.print(settings) + console.print() + + progress = Progress( + SpinnerColumn(finished_text="[DONE]", style="green"), + TextColumn("[progress.description]{task.description}"), + BarColumn(bar_width=50, style="bright_blue", complete_style="bright_green"), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TextColumn("[bold]{task.completed}/{task.total}[/bold] rows"), + TimeElapsedColumn(), + TimeRemainingColumn(), + ) + + brightness_history = [] + timestamps = [] + completed_rows = 0 + total_rows = height + + with progress: + render_task = progress.add_task("Rendering", total=total_rows) + preview_path = "preview.png" + + with ProcessPoolExecutor(max_workers=workers) as executor: + futures = {executor.submit(render_chunk, r): r[9] for r in ranges} + for f in as_completed(futures): + chunk_start, chunk_img = f.result() + chunk_end = chunk_start + chunk_img.shape[0] + image[chunk_start:chunk_end] = chunk_img + completed_rows += (chunk_end - chunk_start) + + progress.update(render_task, completed=completed_rows) + + img = (np.clip(image, 0.0, 1.0) * 255).astype(np.uint8) + Image.fromarray(img, 'RGB').save(preview_path) + + elapsed = time.time() - start + brightness = float(image.mean()) + brightness_history.append(brightness) + timestamps.append(elapsed) + elapsed = time.time() - start - print(f"Saved {output} in {elapsed:.1f}s") + img = (np.clip(image, 0.0, 1.0) * 255).astype(np.uint8) + Image.fromarray(img, 'RGB').save(output) + + console.print() + console.print(Panel.fit( + f"[bold green]Rendered {output}[/bold green]\n" + f"[dim]Total time: {elapsed:.1f}s | " + f"Speed: {width * height * samples_per_pixel / elapsed / 1e6:.2f} MPix*samp/s[/dim]", + border_style="bright_green", padding=(0, 2) + )) + + region_table = Table(title="Region Brightness Analysis") + region_table.add_column("Region", style="cyan", no_wrap=True) + region_table.add_column("Brightness", justify="right", style="white") + region_table.add_column("Visual", justify="center") + h_start, h_end = height // 3, 2 * height // 3 + regions = [ + ("Left Wall", image[:, :width // 4]), + ("Right Wall", image[:, 3 * width // 4:]), + ("Floor", image[2 * height // 3:, h_start:h_end]), + ("Ceiling", image[:height // 3, h_start:h_end]), + ("Center", image[h_start:h_end, h_start:h_end]), + ] + for name, region in regions: + brightness = float(region.mean()) * 255 + bar_len = 20 + filled = int(brightness / 255 * bar_len) + bar = "#" * filled + "-" * (bar_len - filled) + color = "green" if brightness > 80 else ("yellow" if brightness > 40 else "red") + region_table.add_row(name, f"{brightness:.1f}", f"[{color}]{bar}[/{color}]") + console.print(region_table) + console.print() + + fig, axes = plt.subplots(1, 2, figsize=(14, 5)) + ax1, ax2 = axes + + t_arr = np.array(timestamps) + b_arr = np.array(brightness_history) + ax1.plot(t_arr, b_arr, color='#22cc66', linewidth=2) + ax1.set_xlabel("Time (s)", fontsize=11) + ax1.set_ylabel("Mean Brightness", fontsize=11) + ax1.set_title("Brightness Convergence", fontsize=13, fontweight='bold') + ax1.grid(True, alpha=0.3) + ax1.fill_between(t_arr, b_arr, alpha=0.15, color='#22cc66') + + preview = (np.clip(image, 0.0, 1.0) * 255).astype(np.uint8) + ax2.imshow(preview) + ax2.set_title("Render Preview", fontsize=13, fontweight='bold') + ax2.axis('off') + + plt.tight_layout() + metrics_path = "render_metrics.png" + plt.savefig(metrics_path, dpi=150, bbox_inches='tight') + plt.close() + console.print(f"[dim]Metrics graph saved to [bold]{metrics_path}[/bold][/dim]") + return image diff --git a/render_metrics.png b/render_metrics.png new file mode 100644 index 0000000..d49f900 Binary files /dev/null and b/render_metrics.png differ