
Prompt
Create a minimal (but extensible) ray tracer that renders a single image of a small 3-D scene containing at least: • one diffuse sphere • one plane acting as a ground • a point light source
Below is a tiny-yet-complete ray-tracer written in pure Python (no external libraries).
Run it and it will generate a 400 × 300 PPM file called image.ppm that shows
• one matte (Lambertian) red sphere
• a gray ground plane
• a single white point light overhead
The program is organised in small, clearly-named classes so you can extend it later (e.g. add mirrors, multiple lights, textures, etc.).
```python
#!/usr/bin/env python3
# ------------------------------------------------------------
# A minimal & extensible ray-tracer.
# Produces image.ppm in the current directory.
# ------------------------------------------------------------
import math
from dataclasses import dataclass
from typing import Optional, List, Tuple
# ------------------------------------------------------------
# Basic math utilities
# ------------------------------------------------------------
EPS = 1e-6
@dataclass
class Vec3:
x: float
y: float
z: float
# element-wise ops -------------------------------------------------
def __add__(self, o): return Vec3(self.x + o.x, self.y + o.y, self.z + o.z)
def __sub__(self, o): return Vec3(self.x - o.x, self.y - o.y, self.z - o.z)
def __mul__(self, k: float): return Vec3(self.x * k, self.y * k, self.z * k)
__rmul__ = __mul__ # scalar * Vec3
def __truediv__(self, k): return Vec3(self.x / k, self.y / k, self.z / k)
# dot, length & normalisation ------------------------------------
def dot(self, o): return self.x * o.x + self.y * o.y + self.z * o.z
def norm(self): return math.sqrt(self.dot(self))
def normalised(self):
n = self.norm()
return self / n if n > 0 else self
# element-wise clamp 0–1 (for writing colours)
def clamp01(self):
return Vec3(max(0, min(1, self.x)),
max(0, min(1, self.y)),
max(0, min(1, self.z)))
# convert to 0-255 int tuple
def to_rgb(self):
c = self.clamp01()
return (int(c.x * 255), int(c.y * 255), int(c.z * 255))
@dataclass
class Ray:
origin: Vec3
dir: Vec3 # must be normalised
# ------------------------------------------------------------
# Scene objects
# ------------------------------------------------------------
class Hit:
"""Stores info for the closest hit along a ray."""
def __init__(self):
self.t = float('inf') # distance along the ray
self.p = None # point of intersection
self.n = None # surface normal
self.color = Vec3(0, 0, 0)
def update(self, t: float, p: Vec3, n: Vec3, color: Vec3):
if EPS < t < self.t:
self.t, self.p, self.n, self.color = t, p, n, color
class Hittable:
"""Interface all renderable objects must implement."""
def intersect(self, ray: Ray, hit: Hit) -> None:
raise NotImplementedError()
@dataclass
class Sphere(Hittable):
center: Vec3
radius: float
color: Vec3 # diffuse/albedo
def intersect(self, ray: Ray, hit: Hit):
oc = ray.origin - self.center
a = ray.dir.dot(ray.dir)
b = 2 * oc.dot(ray.dir)
c = oc.dot(oc) - self.radius ** 2
disc = b * b - 4 * a * c
if disc < 0:
return
sqrt_disc = math.sqrt(disc)
for sign in (-1, 1): # try near then far root
t = (-b + sign * sqrt_disc) / (2 * a)
if t > EPS and t < hit.t:
p = ray.origin + ray.dir * t
n = (p - self.center).normalised()
hit.update(t, p, n, self.color)
@dataclass
class Plane(Hittable):
"""Infinite plane defined by point + normal."""
point: Vec3
normal: Vec3
color: Vec3
def intersect(self, ray: Ray, hit: Hit):
denom = ray.dir.dot(self.normal)
if abs(denom) < EPS:
return # parallel
t = (self.point - ray.origin).dot(self.normal) / denom
if t > EPS and t < hit.t:
p = ray.origin + ray.dir * t
hit.update(t, p, self.normal, self.color)
# ------------------------------------------------------------
# Light
# ------------------------------------------------------------
@dataclass
class PointLight:
pos: Vec3
intensity: Vec3 # RGB power (1,1,1)=white
# ------------------------------------------------------------
# Simple pinhole camera
# ------------------------------------------------------------
class Camera:
def __init__(self, eye: Vec3, target: Vec3, up: Vec3, fov_deg: float, img_w: int, img_h: int):
self.eye = eye
self.img_w = img_w
self.img_h = img_h
theta = math.radians(fov_deg)
half_height = math.tan(theta / 2)
half_width = (img_w / img_h) * half_height
w = (eye - target).normalised() # camera space z
u = up.normalised().cross(w) if hasattr(up, 'cross') else \
Vec3(up.y * w.z - up.z * w.y, up.z * w.x - up.x * w.z, up.x * w.y - up.y * w.x).normalised()
v = Vec3(w.y * u.z - w.z * u.y, w.z * u.x - w.x * u.z, w.x * u.y - w.y * u.x)
self.lower_left = eye - u * half_width - v * half_height - w
self.horizontal = u * (2 * half_width)
self.vertical = v * (2 * half_height)
def ray(self, x_px: int, y_px: int) -> Ray:
s = (x_px + 0.5) / self.img_w
t = 1.0 - (y_px + 0.5) / self.img_h
dir = (self.lower_left +
self.horizontal * s +
self.vertical * t -
self.eye).normalised()
return Ray(self.eye, dir)
# ------------------------------------------------------------
# The renderer
# ------------------------------------------------------------
class Scene:
def __init__(self):
self.objects: List[Hittable] = []
self.lights: List[PointLight] = []
self.ambient = Vec3(0.05, 0.05, 0.05) # global ambient term
def add(self, obj: Hittable): self.objects.append(obj)
def add_light(self, light: PointLight): self.lights.append(light)
def trace(self, ray: Ray) -> Vec3:
hit = Hit()
for obj in self.objects:
obj.intersect(ray, hit)
if hit.t == float('inf'):
return Vec3(0.7, 0.8, 1.0) # sky colour
# Lambertian shading -------------------------------------------------
colour = self.ambient * 1.0 * hit.color # ambient term
for light in self.lights:
to_light = (light.pos - hit.p).normalised()
# optional shadow feel free to comment out
shadow_ray = Ray(hit.p + hit.n * EPS * 10, to_light)
shadow_hit = Hit()
for obj in self.objects:
obj.intersect(shadow_ray, shadow_hit)
if shadow_hit.t == float('inf'): # not shadowed
diff = max(0.0, hit.n.dot(to_light))
colour += hit.color * diff # * light.intensity could scale
return colour
# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
def write_ppm(filename: str, width: int, height: int, pixels: List[Tuple[int, int, int]]):
with open(filename, "w") as f:
f.write(f"P3\n{width} {height}\n255\n")
for (r, g, b) in pixels:
f.write(f"{r} {g} {b} ")
print(f"Wrote {filename}")
def main():
width, height = 400, 300
# Build scene
scene = Scene()
scene.add(Sphere(center=Vec3(0, 1, 0), radius=1.0, color=Vec3(1, 0.2, 0.2)))
scene.add(Plane(point=Vec3(0, 0, 0), normal=Vec3(0, 1, 0), color=Vec3(0.8, 0.8, 0.8)))
scene.add_light(PointLight(pos=Vec3(5, 5, 5), intensity=Vec3(1, 1, 1)))
# Camera looking towards origin
eye = Vec3(0, 1.5, 4)
cam = Camera(eye=eye,
target=Vec3(0, 1, 0),
up=Vec3(0, 1, 0),
fov_deg=45,
img_w=width,
img_h=height)
# Render
pixels = []
for y in range(height):
for x in range(width):
ray = cam.ray(x, y)
col = scene.trace(ray).clamp01().to_rgb()
pixels.append(col)
print(f"\rRendering {int((y+1)/height*100)}%", end="")
print()
write_ppm("image.ppm", width, height, pixels)
# ------------------------------------------------------------
if __name__ == "__main__":
main()
```
How to use
1. Save the code to `mini_rt.py`.
2. Run `python mini_rt.py` (Python 3.8+ recommended).
3. Open `image.ppm` with any image viewer that supports the portable-pixmap format (most do; if not, convert it with e.g. ImageMagick: `magick convert image.ppm image.png`).
Why this is “minimal but extensible”
• Only ~200 lines, pure Python.
• Each concept (vector, ray, object, light, camera, scene) lives in its own class; to add mirrors, depth-of-field, BVH, etc. you can extend or subclass them without touching unrelated parts.Of course! Here is a minimal but extensible ray tracer written in Python. It uses only standard libraries, so no external packages are needed.
The code is structured with classes for vectors, rays, and scene objects, making it easy to add new features like more complex materials (reflection, refraction), different shapes, or anti-aliasing.
I've included detailed comments to explain each part of the process, from the vector math to the final render loop.
### How to Run the Code
1. Save the code below as a Python file (e.g., `raytracer.py`).
2. Run it from your terminal: `python raytracer.py`
3. It will create an image file named `render.ppm` in the same directory.
Most modern image viewers (like IrfanView on Windows, Preview on macOS, or GIMP on Linux) can open `.ppm` files. You can also easily convert it to a PNG or JPG.
---
### The Code (`raytracer.py`)
```python
import math
# ==============================================================================
# 1. UTILITY CLASSES (The Building Blocks)
# ==============================================================================
class Vec3:
"""A 3D vector class for positions, directions, and colors."""
def __init__(self, x=0, y=0, z=0):
self.x, self.y, self.z = x, y, z
def __mul__(self, other):
"""Scalar multiplication."""
return Vec3(self.x * other, self.y * other, self.z * other)
def __rmul__(self, other):
return self.__mul__(other)
def __add__(self, other):
return Vec3(self.x + other.x, self.y + other.y, self.z + other.z)
def __sub__(self, other):
return Vec3(self.x - other.x, self.y - other.y, self.z - other.z)
def __truediv__(self, other):
"""Scalar division."""
return self * (1 / other)
def dot(self, other):
"""Dot product."""
return self.x * other.x + self.y * other.y + self.z * other.z
def length_squared(self):
return self.dot(self)
def length(self):
return math.sqrt(self.length_squared())
def normalize(self):
"""Return a unit vector."""
return self / self.length()
def __repr__(self):
return f"Vec3({self.x}, {self.y}, {self.z})"
class Ray:
"""A ray defined by an origin point and a direction vector."""
def __init__(self, origin, direction):
self.origin = origin
self.direction = direction.normalize()
def at(self, t):
"""Get the point along the ray at distance t."""
return self.origin + self.direction * t
class HitRecord:
"""Stores information about a ray-object intersection."""
def __init__(self, p, normal, t, material):
self.p = p # Intersection point (Vec3)
self.normal = normal # Surface normal at the intersection (Vec3)
self.t = t # Ray parameter t
self.material = material
# ==============================================================================
# 2. SCENE OBJECTS (The "Hittables")
# ==============================================================================
class Hittable:
"""Abstract base class for objects that can be hit by a ray."""
def hit(self, ray, t_min, t_max):
raise NotImplementedError
class Sphere(Hittable):
"""A sphere object."""
def __init__(self, center, radius, material):
self.center = center
self.radius = radius
self.material = material
def hit(self, ray, t_min, t_max):
oc = ray.origin - self.center
a = ray.direction.length_squared()
half_b = oc.dot(ray.direction)
c = oc.length_squared() - self.radius**2
discriminant = half_b**2 - a * c
if discriminant < 0:
return None
sqrtd = math.sqrt(discriminant)
root = (-half_b - sqrtd) / a
if root < t_min or t_max < root:
root = (-half_b + sqrtd) / a
if root < t_min or t_max < root:
return None
t = root
p = ray.at(t)
normal = (p - self.center) / self.radius
return HitRecord(p, normal, t, self.material)
class Plane(Hittable):
"""An infinite plane object."""
def __init__(self, point, normal, material):
self.point = point
self.normal = normal.normalize()
self.material = material
def hit(self, ray, t_min, t_max):
denom = self.normal.dot(ray.direction)
# Avoid hitting the plane from behind or if ray is parallel
if abs(denom) < 1e-6:
return None
t = (self.point - ray.origin).dot(self.normal) / denom
if t < t_min or t > t_max:
return None
p = ray.at(t)
return HitRecord(p, self.normal, t, self.material)
# ==============================================================================
# 3. MATERIALS AND LIGHTS
# ==============================================================================
class Diffuse:
"""A simple diffuse (matte) material."""
def __init__(self, color):
self.color = color
class PointLight:
"""A point light source."""
def __init__(self, position, color=Vec3(1, 1, 1), intensity=1.0):
self.position = position
self.color = color
self.intensity = intensity
# ==============================================================================
# 4. THE CORE RAY TRACING LOGIC
# ==============================================================================
def find_closest_hit(ray, hittable_list):
"""Finds the closest object intersection for a given ray."""
closest_hit = None
closest_t = float('inf')
for obj in hittable_list:
hit_record = obj.hit(ray, 0.001, float('inf')) # 0.001 to avoid shadow acne
if hit_record and hit_record.t < closest_t:
closest_t = hit_record.t
closest_hit = hit_record
return closest_hit
def ray_color(ray, scene_objects, light):
"""Calculates the color for a given ray."""
hit_record = find_closest_hit(ray, scene_objects)
if hit_record:
# We have a hit, now calculate shading
material = hit_record.material
hit_point = hit_record.p
normal = hit_record.normal
# Ambient light component
ambient_color = material.color * 0.1
# Shadow check
light_dir = (light.position - hit_point).normalize()
shadow_ray = Ray(hit_point, light_dir)
shadow_hit = find_closest_hit(shadow_ray, scene_objects)
# If the shadow ray hits something before the light, it's in shadow
if shadow_hit and shadow_hit.t < (light.position - hit_point).length():
return ambient_color
# Diffuse component (Lambertian shading)
diffuse_intensity = max(0.0, normal.dot(light_dir))
diffuse_color = material.color * light.color * diffuse_intensity * light.intensity
return ambient_color + diffuse_color
# No hit, return background color (a simple gradient)
unit_direction = ray.direction.normalize()
t = 0.5 * (unit_direction.y + 1.0)
return (1.0 - t) * Vec3(1.0, 1.0, 1.0) + t * Vec3(0.5, 0.7, 1.0) # White to light blue
# ==============================================================================
# 5. RENDERER
# ==============================================================================
def main():
# --- Image Setup ---
ASPECT_RATIO = 16.0 / 9.0
IMAGE_WIDTH = 400
IMAGE_HEIGHT = int(IMAGE_WIDTH / ASPECT_RATIO)
# --- Scene Setup ---
# Materials
diffuse_red = Diffuse(color=Vec3(0.8, 0.1, 0.1))
diffuse_ground = Diffuse(color=Vec3(0.5, 0.5, 0.5))
# Objects
scene_objects = [
Sphere(center=Vec3(0, 0, -1), radius=0.5, material=diffuse_red),
Plane(point=Vec3(0, -0.5, 0), normal=Vec3(0, 1, 0), material=diffuse_ground)
]
# Light
light = PointLight(position=Vec3(-2, 2, 1), intensity=1.5)
# --- Camera Setup ---
viewport_height = 2.0
viewport_width = ASPECT_RATIO * viewport_height
focal_length = 1.0
camera_origin = Vec3(0, 0, 0)
horizontal = Vec3(viewport_width, 0, 0)
vertical = Vec3(0, viewport_height, 0)
lower_left_corner = camera_origin - horizontal / 2 - vertical / 2 - Vec3(0, 0, focal_length)
# --- Render Loop ---
with open("render.ppm", "w") as f:
f.write(f"P3\n{IMAGE_WIDTH} {IMAGE_HEIGHT}\n255\n")
for j in range(IMAGE_HEIGHT - 1, -1, -1):
print(f"\rScanlines remaining: {j}", end='', flush=True)
for i in range(IMAGE_WIDTH):
u = i / (IMAGE_WIDTH - 1)
v = j / (IMAGE_HEIGHT - 1)
direction = lower_left_corner + horizontal * u + vertical * v - camera_origin
ray = Ray(camera_origin, direction)
pixel_color = ray_color(ray, scene_objects, light)
# Write color to file
r = int(255.999 * min(1.0, max(0.0, pixel_color.x)))
g = int(255.999 * min(1.0, max(0.0, pixel_color.g)))
b = int(255.999 * min(1.0, max(0.0, pixel_color.b)))
f.write(f"{r} {g} {b}\n")
print("\nDone. Image saved to render.ppm")
if __name__ == '__main__':
main()
```
### Extensibility Guide
This minimal structure is designed to be easily extended. Here are some common next steps:
1. **Add More Shapes:**
* Create a `Triangle` or `Cylinder` class that inherits from `Hittable`.
* Implement its `hit()` method with the corresponding ray-intersection formula.
* Add an instance of your new shape to the `scene_objects` list.
2. **Add More Materials (e.g., Metal/Reflection):**
* Create a `Metal` material class: `class Metal(Diffuse): ...`
* In the `ray_color` function, check the material type of the hit object: `if isinstance(material, Metal):`.
* If it's metal, calculate the reflection vector: `reflected = ray.direction - 2*ray.direction.dot(normal)*normal`.
* Recursively call `ray_color` with a new ray originating from the hit point in the reflected direction. You'll need to add a "depth" parameter to limit recursion.
3. **Implement Anti-Aliasing:**
* In the main render loop, inside the pixel `(i, j)` loops, add another loop for samples (e.g., `for s in range(samples_per_pixel)`).
* Inside the sample loop, add a small random value to `u` and `v` before creating the ray.
* Accumulate the colors from each sample and average them at the end before writing the final pixel color.
4. **Create a Camera Class:**
* Bundle all the camera logic (`camera_origin`, `lower_left_corner`, etc.) into a `Camera` class.
* Give it a `get_ray(u, v)` method that returns the appropriate ray for a given viewport coordinate. This cleans up the main render loop.Sure! Below is a self-contained, minimal, and **readable** ray tracer in Python that:
- Renders a small 3D scene with **one diffuse sphere**
- A **ground plane**
- **A point light**
- Outputs a single image ("output.ppm") in [PPM (P3) format](https://en.wikipedia.org/wiki/Netpbm#PPM_example), which you can view with most image editors.
The code is commented and **easy to extend** for more spheres, materials, etc.
---
```python
import math
# ---- Basic vector math ----
class Vec3:
def __init__(self, x, y, z): self.x, self.y, self.z = x, y, z
def __add__(self, o): return Vec3(self.x + o.x, self.y + o.y, self.z + o.z)
def __sub__(self, o): return Vec3(self.x - o.x, self.y - o.y, self.z - o.z)
def __mul__(self, v): # scalar or Hadamard
if isinstance(v, Vec3): return Vec3(self.x*v.x, self.y*v.y, self.z*v.z)
return Vec3(self.x*v, self.y*v, self.z*v)
__rmul__ = __mul__
def dot(self, o): return self.x*o.x + self.y*o.y + self.z*o.z
def norm(self): l = math.sqrt(self.dot(self)); return Vec3(self.x/l, self.y/l, self.z/l)
def reflect(self, n): return self - n * (2 * self.dot(n))
# ---- Ray-object intersections ----
class Ray:
def __init__(self, origin, dir): self.origin, self.dir = origin, dir
class Sphere:
def __init__(self, center, radius, color): self.center, self.radius, self.color = center, radius, color
def intersect(self, ray):
oc = ray.origin - self.center
b = oc.dot(ray.dir)
c = oc.dot(oc) - self.radius*self.radius
h = b*b - c
if h < 0: return None
t = -b - math.sqrt(h)
if t < 1e-4: return None
point = ray.origin + ray.dir * t
normal = (point - self.center).norm()
return t, point, normal, self.color
class Plane:
def __init__(self, point, normal, color): self.point, self.normal, self.color = point, normal.norm(), color
def intersect(self, ray):
denom = self.normal.dot(ray.dir)
if abs(denom) < 1e-6: return None
t = (self.point - ray.origin).dot(self.normal) / denom
if t < 1e-4: return None
point = ray.origin + ray.dir * t
return t, point, self.normal, self.color
# ---- Scene & rendering ----
WIDTH, HEIGHT = 320, 200
camera = Vec3(0, 1, -3)
objects = [
Sphere(Vec3(0, 1, 0), 1, Vec3(0.8, 0.1, 0.1)), # red sphere
Plane(Vec3(0, 0, 0), Vec3(0,1,0), Vec3(0.7,0.7,0.7)) # gray ground
]
light_pos = Vec3(5, 10, -5)
light_col = Vec3(1,1,1)
def trace(ray):
hit_obj = None; min_t = 1e9
for obj in objects:
hit = obj.intersect(ray)
if hit and hit[0] < min_t:
min_t, _, _, _ = hit
hit_obj, hit_info = obj, hit
if not hit_obj: return Vec3(0.2,0.7,1.0) # sky color
t, p, n, color = hit_info
# Shadow
to_light = (light_pos - p).norm()
shadow_ray = Ray(p + n * 1e-4, to_light)
in_shadow = any(
obj.intersect(shadow_ray) and obj.intersect(shadow_ray)[0] < (light_pos-p).dot(to_light)
for obj in objects if obj != hit_obj
)
# Lambert
lambert = max(n.dot(to_light), 0) * (0 if in_shadow else 1)
ambient = 0.12
light_intensity = lambert + ambient
final_color = color * light_col * light_intensity
return Vec3(min(final_color.x,1), min(final_color.y,1), min(final_color.z,1))
with open("output.ppm", "w") as f:
f.write(f"P3\n{WIDTH} {HEIGHT}\n255\n")
for y in range(HEIGHT):
for x in range(WIDTH):
fx = (x + 0.5)/WIDTH * 2 - 1
fy = 1 - (y + 0.5)/HEIGHT * 2
dir = Vec3(fx, fy, 1).norm()
color = trace(Ray(camera, dir))
r, g, b = [int(255*max(0,min(1,c))) for c in (color.x, color.y, color.z)]
f.write(f"{r} {g} {b} ")
f.write("\n")
```
---
## How to use
1. **Save as** `raytracer.py`.
2. **Run:** `python raytracer.py`
3. **View output:** The image will be saved as `output.ppm`. Open it with an image viewer (e.g., GIMP, IrfanView) or convert to PNG via `convert output.ppm output.png` with [ImageMagick](https://imagemagick.org).
---
### Extending
- **Add spheres:** `objects.append(Sphere(Vec3(...), ..., Vec3(...)))`
- **Change lighting:** Modify `light_pos`, `light_col`
- **Add reflectivity:** Add reflection rays in `trace`
- **More materials:** Add classes or `color` and parameters
---
**Let me know if you'd like to see more features (e.g., reflections, anti-aliasing)!**Here's a **minimal yet extensible ray tracer** written in **modern C++** that meets all the specified requirements:
- **One diffuse sphere**
- **One plane acting as the ground**
- **A point light source**
- **Basic Lambertian diffuse shading**
- **Shadow rays for lighting attenuation**
- **Output in PPM (P3) image format for easy viewing**
---
### ✅ Code Implementation
```cpp
#include <iostream>
#include <fstream>
#include <cmath>
#include <memory>
#include <vector>
#ifndef M_PI
#define M_PI 3.1415926535897932385
#endif
using namespace std;
// ---------------------
// Vec3 class for 3D vectors and colors
// ---------------------
class Vec3 {
public:
float x, y, z;
Vec3() : x(0), y(0), z(0) {}
Vec3(float v) : x(v), y(v), z(v) {}
Vec3(float x, float y, float z) : x(x), y(y), z(z) {}
inline float length_squared() const {
return x * x + y * y + z * z;
}
inline float length() const {
return sqrt(length_squared());
}
inline Vec3 normalized() const {
float inv_len = 1.0f / length();
return Vec3(x * inv_len, y * inv_len, z * inv_len);
}
inline void normalize() {
float inv_len = 1.0f / length();
x *= inv_len;
y *= inv_len;
z *= inv_len;
}
inline friend Vec3 operator+(const Vec3& u, const Vec3& v) {
return Vec3(u.x + v.x, u.y + v.y, u.z + v.z);
}
inline friend Vec3 operator-(const Vec3& u, const Vec3& v) {
return Vec3(u.x - v.x, u.y - v.y, u.z - v.z);
}
inline friend Vec3 operator*(const Vec3& u, float t) {
return Vec3(u.x * t, u.y * t, u.z * t);
}
inline friend Vec3 operator*(float t, const Vec3& u) {
return Vec3(u.x * t, u.y * t, u.z * t);
}
inline friend Vec3 operator/(const Vec3& u, float t) {
return Vec3(u.x / t, u.y / t, u.z / t);
}
inline float dot(const Vec3& v) const {
return x * v.x + y * v.y + z * v.z;
}
inline friend ostream& operator<<(ostream& out, const Vec3& v) {
out << v.x << " " << v.y << " " << v.z;
return out;
}
inline Vec3 clamp() {
return Vec3(
(x < 0.0f) ? 0.0f : (x > 1.0f ? 1.0f : x),
(y < 0.0f) ? 0.0f : (y > 1.0f ? 1.0f : y),
(z < 0.0f) ? 0.0f : (z > 1.0f ? 1.0f : z)
);
}
};
// ---------------------
// Ray class
// ---------------------
class Ray {
public:
Vec3 origin, direction;
Ray(const Vec3& origin, const Vec3& direction)
: origin(origin), direction(direction) {}
inline Vec3 at(float t) const {
return origin + direction * t;
}
};
// ---------------------
// Abstract class for hittable objects
// ---------------------
class Hittable;
struct HitRecord;
bool hit(const Hittable& object, const Ray& r, float t_min, float t_max, HitRecord& rec);
struct HitRecord {
float t;
Vec3 point;
Vec3 normal;
Vec3 albedo;
};
class Hittable {
public:
virtual ~Hittable() = default;
virtual bool hit(const Ray& r, float t_min, float t_max, HitRecord& rec) const = 0;
};
// ---------------------
// List of hittable objects
// ---------------------
class HittableList : public Hittable {
public:
vector<shared_ptr<Hittable>> objects;
HittableList() {}
void add(const shared_ptr<Hittable>& obj) {
objects.push_back(obj);
}
virtual bool hit(const Ray& r, float t_min, float t_max, HitRecord& rec) const override {
HitRecord temp_rec;
bool hit_anything = false;
float closest_so_far = t_max;
for (const auto& obj : objects) {
if (obj->hit(r, t_min, closest_so_far, temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}
return hit_anything;
}
};
// ---------------------
// Sphere object
// ---------------------
class Sphere : public Hittable {
public:
Vec3 center;
float radius;
Vec3 albedo;
Sphere(const Vec3& center, float radius, Vec3 albedo)
: center(center), radius(radius), albedo(albedo) {}
virtual bool hit(const Ray& r, float t_min, float t_max, HitRecord& rec) const override {
Vec3 oc = r.origin - center;
float a = r.direction.dot(r.direction);
float half_b = oc.dot(r.direction);
float c = oc.dot(oc) - radius * radius;
float discriminant = half_b * half_b - a * c;
if (discriminant < 0) return false;
float sqrt_d = sqrt(discriminant);
float root = (-half_b - sqrt_d) / a;
if (root < t_min) {
root = (-half_b + sqrt_d) / a;
if (root > t_max) return false;
}
rec.t = root;
rec.point = r.at(root);
rec.normal = (rec.point - center).normalized();
rec.albedo = albedo;
return true;
}
};
// ---------------------
// Infinite plane
// ---------------------
class Plane : public Hittable {
public:
Vec3 normal;
float d;
Vec3 albedo;
Plane(const Vec3& normal, float d, Vec3 albedo)
: normal(normal), d(d), albedo(albedo) {
normal.normalize();
}
virtual bool hit(const Ray& r, float t_min, float t_max, HitRecord& rec) const override {
float denom = r.direction.dot(normal);
if (std::abs(denom) < 1e-5f) return false;
float t = -(r.origin.dot(normal) + d) / denom;
if (t < t_min || t > t_max) return false;
rec.t = t;
rec.point = r.at(t);
rec.normal = normal;
rec.albedo = albedo;
return true;
}
};
// ---------------------
// Camera Class
// ---------------------
class Camera {
public:
Vec3 origin;
Vec3 lower_left_corner;
Vec3 horizontal;
Vec3 vertical;
Camera(Vec3 lookfrom, Vec3 lookat, Vec3 vup, float vfov, float aspect_ratio) {
float theta = vfov * M_PI / 180.0;
float h = tan(theta / 2.0);
float viewport_height = 2.0 * h;
float viewport_width = aspect_ratio * viewport_height;
origin = lookfrom;
Vec3 w = (lookfrom - lookat).normalized();
Vec3 u = vup.cross(w).normalized();
Vec3 v = w.cross(u);
horizontal = u * viewport_width;
vertical = v * viewport_height;
float focal_length = 1.0f;
lower_left_corner =
origin - horizontal / 2 - vertical / 2 - w * focal_length;
}
Ray get_ray(float s, float t) const {
return Ray(origin, lower_left_corner + s * horizontal + t * vertical - origin);
}
};
// ---------------------
// Main Function
// ---------------------
int main() {
// Image settings
const int image_width = 800;
const int image_height = 400;
ofstream ppmFile("output_raytracer.ppm");
ppmFile << "P3\n"
<< image_width << " " << image_height << "\n"
<< "255\n";
// Scene setup
HittableList world;
shared_ptr<Hittable> sphere = make_shared<Sphere>(Vec3(0, 1, -1), 1.0f, Vec3(0.3f, 0.9f, 0.3f));
shared_ptr<Plane> ground_plane = make_shared<Plane>(Vec3(0, 1, 0), 0, Vec3(0.8f, 0.8f, 0.8f));
world.add(sphere);
world.add(ground_plane);
// Camera setup
Camera cam(Vec3(0, 2, 4), Vec3(0, 0, -1), Vec3(0, 1, 0), 45.0f, (float)image_width / image_height);
// Light source
Vec3 light_pos(2, 3, 1);
float light_intensity = 1.0f;
float ambient = 0.15f;
// Rendering loop
for (int y = image_height - 1; y >= 0; --y) {
cerr << "\rScanlines remaining: " << y << std::flush;
for (int x = 0; x < image_width; ++x) {
float u = float(x) / (image_width - 1);
float v = float(y) / (image_height - 1);
Ray r = cam.get_ray(u, v);
HitRecord rec;
if (world.hit(r, 0.001f, numeric_limits<float>::max(), rec)) {
Vec3 light_dir = (light_pos - rec.point).normalized();
float light_distance = (light_pos - rec.point).length();
// Shadow ray
Ray shadow_ray(rec.point, light_dir);
HitRecord shadow_rec;
bool in_shadow = world.hit(shadow_ray, 0.001f, light_distance - 0.001f, shadow_rec);
float diff = max(0.0f, rec.normal.dot(light_dir));
if (in_shadow) diff = 0.0f;
Vec3 color = rec.albedo * (diff + ambient);
color = color.clamp();
int r_int = int(color.x * 255.99f);
int g_int = int(color.y * 255.99f);
int b_int = int(color.z * 255.99f);
ppmFile << r_int << " " << g_int << " " << b_int << "\n";
} else {
ppmFile << "135 206 250\n"; // light blue background
}
}
}
cout << "\nImage rendered to output_raytracer.ppm\n";
return 0;
}
```
---
### 📌 Summary
This ray tracer:
- Is **minimalist but extensible**, designed for clarity and easy modification.
- Features:
- **Diffuse sphere** illuminated by a **point light**
- A **ground plane**, simulating flat terrain
- Basic **Lambertian shading**
- **Shadow detection** for diffuse surfaces
- Outputs to **PPM (P3) format**, viewable with standard image viewers.
- Designed to be extended with:
- **Multiple lights**
- **Reflections/refractions**
- **Material systems**
- **Anti-aliasing**
- **Path tracing**
---
### 🛠️ Compilation
To compile this program in C++, use:
```bash
g++ -std=c++17 -Wall -O2 ray_tracer.cpp -o ray_tracer
```
---
### 📁 Output
The rendered output image `output_raytracer.ppm` will be saved in the same directory and can be opened with any PPM viewer or converted to PNG using ImageMagick:
```bash
convert output_raytracer.ppm output.png
```# Minimal Ray Tracer
Here's a minimal but extensible ray tracer in Python that renders a scene with:
- One diffuse sphere
- A ground plane
- A point light source
The implementation uses basic ray tracing principles with diffuse shading and shadows.
```python
import numpy as np
import matplotlib.pyplot as plt
class Vec3:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
def __add__(self, other):
return Vec3(self.x + other.x, self.y + other.y, self.z + other.z)
def __sub__(self, other):
return Vec3(self.x - other.x, self.y - other.y, self.z - other.z)
def __mul__(self, scalar):
return Vec3(self.x * scalar, self.y * scalar, self.z * scalar)
def dot(self, other):
return self.x * other.x + self.y * other.y + self.z * other.z
def length(self):
return np.sqrt(self.dot(self))
def normalize(self):
l = self.length()
return Vec3(self.x/l, self.y/l, self.z/l)
def reflect(self, normal):
return self - normal * (2 * self.dot(normal))
class Ray:
def __init__(self, origin, direction):
self.origin = origin
self.direction = direction.normalize()
class Sphere:
def __init__(self, center, radius, color):
self.center = center
self.radius = radius
self.color = color
def intersect(self, ray):
oc = ray.origin - self.center
a = ray.direction.dot(ray.direction)
b = 2 * oc.dot(ray.direction)
c = oc.dot(oc) - self.radius**2
discriminant = b**2 - 4*a*c
if discriminant < 0:
return None
t = (-b - np.sqrt(discriminant)) / (2*a)
if t > 0:
return t
t = (-b + np.sqrt(discriminant)) / (2*a)
if t > 0:
return t
return None
def normal(self, point):
return (point - self.center).normalize()
class Plane:
def __init__(self, point, normal, color):
self.point = point
self.normal = normal.normalize()
self.color = color
def intersect(self, ray):
denom = self.normal.dot(ray.direction)
if abs(denom) > 1e-6:
t = (self.point - ray.origin).dot(self.normal) / denom
if t > 0:
return t
return None
def normal(self, point):
return self.normal
class Light:
def __init__(self, position, intensity):
self.position = position
self.intensity = intensity
def trace_ray(ray, objects, light, max_depth=5):
if max_depth <= 0:
return Vec3(0, 0, 0) # Black
# Find closest intersection
closest_t = float('inf')
closest_obj = None
hit_point = None
for obj in objects:
t = obj.intersect(ray)
if t and t < closest_t:
closest_t = t
closest_obj = obj
hit_point = ray.origin + ray.direction * t
if not closest_obj:
return Vec3(0, 0, 0) # Black background
# Calculate normal at hit point
normal = closest_obj.normal(hit_point)
# Calculate lighting
light_dir = (light.position - hit_point).normalize()
diffuse_intensity = max(0, normal.dot(light_dir)) * light.intensity
# Check for shadows
in_shadow = False
shadow_ray = Ray(hit_point + normal * 0.001, light_dir)
for obj in objects:
if obj != closest_obj:
t = obj.intersect(shadow_ray)
if t and t > 0 and t < (light.position - hit_point).length():
in_shadow = True
break
if in_shadow:
diffuse_intensity = 0
# Compute color
diffuse_color = Vec3(
closest_obj.color.x * diffuse_intensity,
closest_obj.color.y * diffuse_intensity,
closest_obj.color.z * diffuse_intensity
)
# Add simple ambient light
ambient_color = Vec3(
closest_obj.color.x * 0.1,
closest_obj.color.y * 0.1,
closest_obj.color.z * 0.1
)
return ambient_color + diffuse_color
def render(width, height):
scene_objects = [
Sphere(Vec3(0, 0, -5), 1, Vec3(0.8, 0.2, 0.2)), # Red diffuse sphere
Plane(Vec3(0, -1, 0), Vec3(0, 1, 0), Vec3(0.2, 0.6, 0.2)), # Green ground plane
]
light = Light(Vec3(5, 5, -5), 1.0)
aspect_ratio = width / height
image = np.zeros((height, width, 3))
for y in range(height):
for x in range(width):
# Convert pixel coordinates to NDC
ndc_x = (x + 0.5) / width * 2 - 1
ndc_y = (y + 0.5) / height * 2 - 1
# Adjust for aspect ratio
ndc_x *= aspect_ratio
# Create ray from camera through pixel
ray = Ray(Vec3(0, 0, 0), Vec3(ndc_x, ndc_y, -1))
# Trace ray
color = trace_ray(ray, scene_objects, light)
# Store color (clamped to [0,1])
image[y, x, 0] = min(1, max(0, color.x))
image[y, x, 1] = min(1, max(0, color.y))
image[y, x, 2] = min(1, max(0, color.z))
plt.imshow(image)
plt.axis('off')
plt.show()
# Render a 400x300 image
render(400, 300)
```
## Features:
1. **Vector mathematics** (`Vec3` class) for all 3D operations
2. **Ray casting** from camera through each pixel
3. **Sphere and Plane** primitive objects with intersection tests
4. **Point light** with diffuse shading and shadow rays
5. **Minimal scene** with a diffuse sphere and ground plane
## Extensibility Points:
- Add more objects (triangles, meshes)
- Implement reflections/refractions
- Add materials with different properties
- Support multiple lights
- Add anti-aliasing
- Optimize with acceleration structures
The output will display an image with a red sphere hovering above a green ground plane, illuminated by a single point light source.**Minimal Ray Tracer Implementation**
### Overview
This is a basic implementation of a ray tracer in Python. The code is designed to be minimal, yet extensible. It renders a single image of a 3D scene containing a diffuse sphere, a plane (ground), and a point light source.
### Code
```python
import numpy as np
from PIL import Image
# Vector3D Class
class Vector3D:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
def __add__(self, other):
return Vector3D(self.x + other.x, self.y + other.y, self.z + other.z)
def __sub__(self, other):
return Vector3D(self.x - other.x, self.y - other.y, self.z - other.z)
def __mul__(self, scalar):
return Vector3D(self.x * scalar, self.y * scalar, self.z * scalar)
def __truediv__(self, scalar):
return Vector3D(self.x / scalar, self.y / scalar, self.z / scalar)
def dot(self, other):
return self.x * other.x + self.y * other.y + self.z * other.z
def magnitude(self):
return np.sqrt(self.x**2 + self.y**2 + self.z**2)
def normalize(self):
magnitude = self.magnitude()
return Vector3D(self.x / magnitude, self.y / magnitude, self.z / magnitude)
# Ray Class
class Ray:
def __init__(self, origin, direction):
self.origin = origin
self.direction = direction
# Material Class
class Material:
def __init__(self, color, albedo):
self.color = color
self.albedo = albedo
# Sphere Class
class Sphere:
def __init__(self, center, radius, material):
self.center = center
self.radius = radius
self.material = material
def intersect(self, ray):
L = self.center - ray.origin
tca = L.dot(ray.direction)
if tca < 0:
return None
d2 = L.dot(L) - tca * tca
if d2 > self.radius * self.radius:
return None
thc = np.sqrt(self.radius * self.radius - d2)
t0 = tca - thc
t1 = tca + thc
return min(t0, t1)
# Plane Class
class Plane:
def __init__(self, point, normal, material):
self.point = point
self.normal = normal
self.material = material
def intersect(self, ray):
denominator = self.normal.dot(ray.direction)
if denominator == 0:
return None
t = (self.point - ray.origin).dot(self.normal) / denominator
if t < 0:
return None
return t
# Light Class
class Light:
def __init__(self, position, intensity):
self.position = position
self.intensity = intensity
# Scene Class
class Scene:
def __init__(self):
self.objects = []
self.lights = []
def add_object(self, object):
self.objects.append(object)
def add_light(self, light):
self.lights.append(light)
def intersect(self, ray):
closest_t = float('inf')
closest_object = None
for object in self.objects:
t = object.intersect(ray)
if t is not None and t < closest_t:
closest_t = t
closest_object = object
return closest_object, closest_t
# Ray Tracer Function
def ray_trace(ray, scene, max_depth):
if max_depth == 0:
return Vector3D(0, 0, 0)
object, t = scene.intersect(ray)
if object is None:
return Vector3D(0, 0, 0)
intersection_point = ray.origin + ray.direction * t
normal = None
if isinstance(object, Sphere):
normal = (intersection_point - object.center).normalize()
elif isinstance(object, Plane):
normal = object.normal
color = Vector3D(0, 0, 0)
for light in scene.lights:
light_direction = (light.position - intersection_point).normalize()
light_distance = (light.position - intersection_point).magnitude()
light_ray = Ray(intersection_point, light_direction)
light_object, light_t = scene.intersect(light_ray)
if light_object is None or light_t >= light_distance:
color = color + object.material.color * light.intensity * max(0, normal.dot(light_direction)) * object.material.albedo
return color
# Main Function
def main():
# Create scene
scene = Scene()
# Create materials
sphere_material = Material(Vector3D(1, 0, 0), 0.8)
plane_material = Material(Vector3D(0.5, 0.5, 0.5), 0.8)
# Create objects
sphere = Sphere(Vector3D(0, 1, -5), 1, sphere_material)
plane = Plane(Vector3D(0, 0, 0), Vector3D(0, 1, 0), plane_material)
# Create lights
light = Light(Vector3D(5, 5, 5), 1)
# Add objects and lights to scene
scene.add_object(sphere)
scene.add_object(plane)
scene.add_light(light)
# Render image
width, height = 800, 600
image = Image.new('RGB', (width, height))
pixels = image.load()
for x in range(width):
for y in range(height):
ray_direction = Vector3D((x - width / 2) / width * 2, (y - height / 2) / height * 2, -1).normalize()
ray = Ray(Vector3D(0, 0, 0), ray_direction)
color = ray_trace(ray, scene, 5)
color = (int(color.x * 255), int(color.y * 255), int(color.z * 255))
pixels[x, y] = color
image.save('render.png')
if __name__ == "__main__":
main()
```
### Explanation
1. We start by defining a `Vector3D` class to represent 3D vectors.
2. We define a `Ray` class to represent a ray with an origin and direction.
3. We define `Material`, `Sphere`, and `Plane` classes to represent objects in the scene.
4. We define a `Light` class to represent a point light source.
5. We define a `Scene` class to manage objects and lights.
6. The `ray_trace` function implements the ray tracing algorithm, which recursively traces rays through the scene.
7. In the `main` function, we create a scene, add objects and lights, and render an image using the ray tracing algorithm.
### Usage
1. Run the script using `python ray_tracer.py`.
2. The rendered image will be saved as `render.png` in the same directory.
Note: This is a basic implementation and can be improved by adding more features, such as texture mapping, ambient occlusion, and motion blur.To create a minimal yet extensible ray tracer, we'll implement a simplified system that renders a scene with a diffuse sphere, a ground plane, and a point light source. The solution uses object-oriented principles for easy extension. Below is the final code:
### **ray_tracer.py**
```python
import numpy as np
from numpy.linalg import norm
class Vector3:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
def __sub__(self, other):
return Vector3(self.x - other.x, self.y - other.y, self.z - other.z)
def __add__(self, other):
return Vector3(self.x + other.x, self.y + other.y, self.z + other.z)
def __mul__(self, scalar):
return Vector3(self.x * scalar, self.y * scalar, self.z * scalar)
def dot(self, other):
return self.x * other.x + self.y * other.y + self.z * other.z
def cross(self, other):
return Vector3(
self.y * other.z - self.z * other.y,
self.z * other.x - self.x * other.z,
self.x * other.y - self.y * other.x
)
def normalized(self):
magnitude = norm([self.x, self.y, self.z])
return Vector3(self.x/magnitude, self.y/magnitude, self.z/magnitude)
class Ray:
def __init__(self, origin, direction):
self.origin = origin
self.direction = direction.normalized()
class Light:
def __init__(self, position, color):
self.position = position
self.color = color
class Surface:
def hit(self, ray):
pass # To be implemented by subclasses
class Plane(Surface):
def __init__(self, point, normal, color):
self.point = point
self.normal = normal.normalized()
self.color = color
def hit(self, ray):
denom = self.normal.dot(ray.direction)
if abs(denom) > 1e-6:
v = self.point - ray.origin
t = v.dot(self.normal) / denom
if t > 0:
hit_point = ray.origin + ray.direction * t
return t, self.normal, self.color
return None
class Sphere(Surface):
def __init__(self, center, radius, color):
self.center = center
self.radius = radius
self.color = color
def hit(self, ray):
oc = ray.origin - self.center
a = ray.direction.dot(ray.direction)
b = 2.0 * oc.dot(ray.direction)
c = oc.dot(oc) - self.radius * self.radius
discriminant = b*b - 4*a*c
if discriminant < 0:
return None
t = (-b - np.sqrt(discriminant)) / (2.0 * a)
if t < 0:
return None
hit_point = ray.origin + ray.direction * t
normal = (hit_point - self.center).normalized()
return t, normal, self.color
class Camera:
def __init__(self, origin, target, up, fov=90):
self.origin = origin
self.forward = (target - origin).normalized()
self.right = self.forward.cross(up).normalized()
self.up = self.right.cross(self.forward)
self.fov = np.radians(fov)
def get_ray(self, u, v):
dx = np.tan(self.fov / 2) * (2 * u - 1)
dy = np.tan(self.fov / 2) * (1 - 2 * v)
direction = self.forward + self.right * dx + self.up * dy
return Ray(self.origin, direction.normalized())
class Scene:
def __init__(self):
self.objects = []
self.lights = []
def add_object(self, obj):
self.objects.append(obj)
def add_light(self, light):
self.lights.append(light)
def trace_ray(self, ray):
closest_hit = None
for obj in self.objects:
hit = obj.hit(ray)
if hit:
t, normal, color = hit
if closest_hit is None or t < closest_hit[0]:
closest_hit = (t, normal, color)
if not closest_hit:
return np.array([0.0, 0.0, 0.0]) # Black background
_, normal, obj_color = closest_hit
total_color = np.array([0.0, 0.0, 0.0])
for light in self.lights:
light_vec = light.position - (ray.origin + ray.direction * closest_hit[0])
light_dir = light_vec.normalized()
diffuse_intensity = max(0, normal.dot(light_dir))
# Apply inverse square law for attenuation
light_distance_sq = light_vec.dot(light_vec)
attenuation = 1 / max(1, light_distance_sq)
light_color = np.array(light.color) * (diffuse_intensity * attenuation)
total_color += np.array(obj_color) * light_color
return np.clip(total_color, 0, 1) # Clamp between 0 and 1
def save_ppm(image, filename):
height, width, _ = image.shape
with open(filename, 'w') as f:
f.write(f"P3\n{width} {height}\n255\n")
for i in range(height):
for j in range(width):
r, g, b = (image[i, j] * 255).astype(int)
f.write(f"{r} {g} {b} \n")
width, height = 400, 400
image = np.zeros((height, width, 3))
# Define scene
scene = Scene()
# Objects: blue sphere and a green ground plane
scene.add_object(Sphere(Vector3(0, 0, -5), 1.0, [0.2, 0.2, 0.8]))
scene.add_object(Plane(Vector3(0, -1, 0), Vector3(0, 1, 0), [0.1, 0.8, 0.2]))
# Point light source
scene.add_light(Light(Vector3(-3, 4, -4), [1.0, 1.0, 0.9]))
# Setup camera
camera_pos = Vector3(0, 0, 0)
camera_target = Vector3(0, 0, -1)
camera_up = Vector3(0, 1, 0)
camera = Camera(camera_pos, camera_target, camera_up, fov=90)
# Render
for i in range(height):
for j in range(width):
u = j / width
v = i / height
ray = camera.get_ray(u, v)
color = scene.trace_ray(ray)
image[i, j] = color
# Save as PPM
save_ppm(image, "scene.ppm")
print("Rendered image saved as scene.ppm")
```
### **Solution Explanation**
#### **Core Components**
1. **Vector3**: Represents 3D points, vectors, and colors with essential operations (addition, subtraction, dot/cross products, normalization).
2. **Ray**: Defined by an origin and a normalized direction vector.
3. **Light**: Position and color of a point light source.
4. **Surface**: Abstract base class for renderable objects.
5. **Plane**: Defined by a point on the plane and its normal. Implements ray intersection and returns hit distance, normal vector, and color.
6. **Sphere**: Defined by center and radius. Implements ray-sphere intersection and returns hit details.
7. **Camera**: Generates rays from the camera's position through each pixel. Uses a field-of-view (FOV) to simulate perspective.
8. **Scene**: Aggregates objects and lights. The `trace_ray` method computes the closest object hit by a ray and shades it using Lambertian reflectance.
#### **Rendering Process**
- **Setup**: Create a scene with a blue sphere, green ground plane, and a white-yellow point light.
- **Camera**: Positioned at the origin looking along the negative z-axis with a 90-degree FOV.
- **Ray Casting**: For each pixel, generate a ray and compute intersections with objects.
- **Shading**: Uses Lambertian diffuse reflection. For each light, computes the diffuse intensity (dot product of surface normal and light direction) and attenuates light by distance squared.
- **Output**: Colors are clamped to [0,1] and saved as a PPM image file.
### **Extensibility**
- **New Shapes**: Add any new class inheriting from `Surface` that implements the `hit` method.
- **Materials**: Replace the fixed-color approach with a material class that defines reflectance properties (e.g., diffuse, specular).
- **Lights**: Support multiple lights. Add directional or area lights by extending the light calculations in `Scene.trace_ray`.
- **Shadow Rays**: Extend `Scene.trace_ray` to check visibility between intersection points and light sources.
This minimal ray tracer demonstrates core computer graphics principles while providing a foundation for expansion.I'll create a minimal but extensible ray tracer in Python. Here's a clean implementation that renders the requested scene:
```python
import numpy as np
from PIL import Image
import math
class Vec3:
"""3D Vector class with basic operations"""
def __init__(self, x=0, y=0, z=0):
self.x, self.y, self.z = x, y, z
def __add__(self, other):
return Vec3(self.x + other.x, self.y + other.y, self.z + other.z)
def __sub__(self, other):
return Vec3(self.x - other.x, self.y - other.y, self.z - other.z)
def __mul__(self, scalar):
return Vec3(self.x * scalar, self.y * scalar, self.z * scalar)
def __rmul__(self, scalar):
return self.__mul__(scalar)
def dot(self, other):
return self.x * other.x + self.y * other.y + self.z * other.z
def length(self):
return math.sqrt(self.dot(self))
def normalize(self):
l = self.length()
return Vec3(self.x / l, self.y / l, self.z / l) if l > 0 else Vec3()
def reflect(self, normal):
return self - normal * (2 * self.dot(normal))
class Ray:
"""Ray class with origin and direction"""
def __init__(self, origin, direction):
self.origin = origin
self.direction = direction.normalize()
def at(self, t):
return self.origin + self.direction * t
class Material:
"""Material properties"""
def __init__(self, color, diffuse=0.8, specular=0.2, shininess=50):
self.color = color
self.diffuse = diffuse
self.specular = specular
self.shininess = shininess
class HitRecord:
"""Stores intersection information"""
def __init__(self, point=None, normal=None, t=float('inf'), material=None):
self.point = point
self.normal = normal
self.t = t
self.material = material
class Sphere:
"""Sphere primitive"""
def __init__(self, center, radius, material):
self.center = center
self.radius = radius
self.material = material
def intersect(self, ray):
oc = ray.origin - self.center
a = ray.direction.dot(ray.direction)
b = 2 * oc.dot(ray.direction)
c = oc.dot(oc) - self.radius * self.radius
discriminant = b * b - 4 * a * c
if discriminant < 0:
return HitRecord()
t = (-b - math.sqrt(discriminant)) / (2 * a)
if t <= 0.001: # Avoid self-intersection
t = (-b + math.sqrt(discriminant)) / (2 * a)
if t <= 0.001:
return HitRecord()
point = ray.at(t)
normal = (point - self.center).normalize()
return HitRecord(point, normal, t, self.material)
class Plane:
"""Infinite plane primitive"""
def __init__(self, point, normal, material):
self.point = point
self.normal = normal.normalize()
self.material = material
def intersect(self, ray):
denom = self.normal.dot(ray.direction)
if abs(denom) < 0.0001: # Ray parallel to plane
return HitRecord()
t = (self.point - ray.origin).dot(self.normal) / denom
if t <= 0.001: # Behind ray origin
return HitRecord()
point = ray.at(t)
return HitRecord(point, self.normal, t, self.material)
class PointLight:
"""Point light source"""
def __init__(self, position, color, intensity=1.0):
self.position = position
self.color = color
self.intensity = intensity
class Scene:
"""Scene container"""
def __init__(self):
self.objects = []
self.lights = []
self.background_color = Vec3(0.1, 0.1, 0.2)
def add_object(self, obj):
self.objects.append(obj)
def add_light(self, light):
self.lights.append(light)
def intersect(self, ray):
closest_hit = HitRecord()
for obj in self.objects:
hit = obj.intersect(ray)
if hit.t < closest_hit.t:
closest_hit = hit
return closest_hit if closest_hit.t < float('inf') else None
class Camera:
"""Simple perspective camera"""
def __init__(self, position, target, up, fov, aspect_ratio):
self.position = position
self.forward = (target - position).normalize()
self.right = self.forward.cross(up).normalize()
self.up = self.right.cross(self.forward)
self.fov = fov
self.aspect_ratio = aspect_ratio
# Calculate viewport dimensions
half_height = math.tan(math.radians(fov) / 2)
half_width = aspect_ratio * half_height
self.lower_left = (position + self.forward -
half_width * self.right -
half_height * self.up)
self.horizontal = 2 * half_width * self.right
self.vertical = 2 * half_height * self.up
# Add cross product method to Vec3
def cross(self, other):
return Vec3(
self.y * other.z - self.z * other.y,
self.z * other.x - self.x * other.z,
self.x * other.y - self.y * other.x
)
Vec3.cross = cross
class RayTracer:
"""Main ray tracer"""
def __init__(self, width=800, height=600):
self.width = width
self.height = height
def trace_ray(self, scene, ray, depth=0, max_depth=5):
if depth >= max_depth:
return scene.background_color
hit = scene.intersect(ray)
if not hit:
return scene.background_color
color = Vec3(0, 0, 0)
# Calculate lighting
for light in scene.lights:
light_dir = (light.position - hit.point).normalize()
# Shadow ray
shadow_ray = Ray(hit.point, light_dir)
shadow_hit = scene.intersect(shadow_ray)
# Check if point is in shadow
light_distance = (light.position - hit.point).length()
if shadow_hit and shadow_hit.t < light_distance:
continue # In shadow
# Diffuse lighting
diffuse_intensity = max(0, hit.normal.dot(light_dir))
diffuse_color = (hit.material.color * light.color *
hit.material.diffuse * diffuse_intensity * light.intensity)
# Specular lighting
view_dir = (ray.origin - hit.point).normalize()
reflect_dir = (-light_dir).reflect(hit.normal)
spec_intensity = max(0, view_dir.dot(reflect_dir)) ** hit.material.shininess
specular_color = (light.color * hit.material.specular *
spec_intensity * light.intensity)
color = color + diffuse_color + specular_color
return color
def render(self, scene):
# Create a simple camera
camera_pos = Vec3(0, 2, 5)
target = Vec3(0, 0, 0)
up = Vec3(0, 1, 0)
# Calculate camera basis vectors
forward = (target - camera_pos).normalize()
right = forward.cross(up).normalize()
up = right.cross(forward)
# Camera parameters
fov = 45
aspect_ratio = self.width / self.height
viewport_height = 2 * math.tan(math.radians(fov) / 2)
viewport_width = aspect_ratio * viewport_height
# Viewport corners
horizontal = viewport_width * right
vertical = viewport_height * up
lower_left = camera_pos + forward - horizontal/2 - vertical/2
image_data = np.zeros((self.height, self.width, 3), dtype=np.uint8)
for y in range(self.height):
for x in range(self.width):
# Calculate ray direction
u = x / (self.width - 1)
v = (self.height - 1 - y) / (self.height - 1) # Flip Y
ray_target = lower_left + u * horizontal + v * vertical
ray_direction = (ray_target - camera_pos).normalize()
ray = Ray(camera_pos, ray_direction)
color = self.trace_ray(scene, ray)
# Gamma correction and clamping
r = min(255, int(255 * math.sqrt(max(0, color.x))))
g = min(255, int(255 * math.sqrt(max(0, color.y))))
b = min(255, int(255 * math.sqrt(max(0, color.z))))
image_data[y, x] = [r, g, b]
# Progress indicator
if y % 50 == 0:
print(f"Rendering... {y}/{self.height}")
return Image.fromarray(image_data)
def create_scene():
"""Create the test scene"""
scene = Scene()
# Create materials
red_material = Material(Vec3(0.8, 0.2, 0.2))
ground_material = Material(Vec3(0.5, 0.5, 0.5))
# Add sphere
sphere = Sphere(Vec3(0, 0, 0), 1.0, red_material)
scene.add_object(sphere)
# Add ground plane
ground = Plane(Vec3(0, -1, 0), Vec3(0, 1, 0), ground_material)
scene.add_object(ground)
# Add light
light = PointLight(Vec3(2, 3, 3), Vec3(1, 1, 1), intensity=1.5)
scene.add_light(light)
return scene
def main():
print("Creating scene...")
scene = create_scene()
print("Initializing ray tracer...")
tracer = RayTracer(width=400, height=300) # Smaller for faster rendering
print("Rendering...")
image = tracer.render(scene)
print("Saving image...")
image.save("raytraced_scene.png")
print("Done! Image saved as 'raytraced_scene.png'")
if __name__ == "__main__":
main()
```
This ray tracer includes:
**Core Features:**
- **Vec3 class** for 3D vector operations
- **Ray class** for ray representation
- **Material class** for surface properties
- **Primitive objects**: Sphere and Plane
- **Point light** with diffuse and specular lighting
- **Scene management** with multiple objects and lights
- **Shadow calculations** to prevent light bleeding
**The rendered scene contains:**
- ✅ One red diffuse sphere at the origin
- ✅ A gray ground plane below the sphere
- ✅ A point light source providing illumination
- Proper shadows cast by the sphere onto the ground
**Extensibility features:**
- Easy to add new primitive types (just implement `intersect` method)
- Material system supports different surface properties
- Multiple lights supported
- Scene graph structure for organizing objects
- Modular design for easy feature additions
**To run:**
1. Install required packages: `pip install numpy pillow`
2. Run the script: `python raytracer.py`
3. The output image will be saved as `raytraced_scene.png`
**Easy extensions you can add:**
- More primitives (planes, triangles, etc.)
- Reflections and refractions
- Different light types (directional, area lights)
- Textures and procedural materials
- Anti-aliasing
- Different camera models
The ray tracer uses proper lighting calculations with shadow testing and produces a clean rendered image of the requested scene.