Ray tracing is a rendering technique for generating images by simulating the path of light as pixels in an image plane. Unlike rasterization (used in most real-time games), which projects objects onto the screen, ray tracing works backward—shooting rays from the camera through each pixel and into the scene to determine the pixel's color. This approach naturally produces stunningly realistic effects like accurate shadows, reflections, and refractions.
This article will break down the fundamental concepts and guide you through building a basic ray tracer in Java from scratch.
1. Core Concept: How Ray Tracing Works
The simplest form of ray tracing, often called "eye-based" or "whitted-style" ray tracing, follows this algorithm:
- For each pixel on the virtual screen (our image), create a ray that originates from the "eye" (camera) and passes through that pixel.
- Check if the ray intersects with any object in the scene.
- Find the closest object that the ray hits.
- Calculate the color at that intersection point. This can be as simple as using the object's color or as complex as shooting new rays for shadows (shadow rays), reflections (reflection rays), and refractions (transparency).
2. The Mathematical Foundation
We need a few basic linear algebra concepts. We'll represent everything in 3D space.
Vectors (Vec3)
A vector represents a point in 3D space or a direction. We'll use it for both.
public class Vec3 {
public final double x, y, z;
public Vec3(double x, double y, double z) {
this.x = x;
this.y = y;
this.z = z;
}
// Vector addition
public Vec3 add(Vec3 other) {
return new Vec3(x + other.x, y + other.y, z + other.z);
}
// Vector subtraction
public Vec3 subtract(Vec3 other) {
return new Vec3(x - other.x, y - other.y, z - other.z);
}
// Scalar multiplication
public Vec3 multiply(double t) {
return new Vec3(x * t, y * t, z * t);
}
// Dot product (measures alignment)
public double dot(Vec3 other) {
return x * other.x + y * other.y + z * other.z;
}
// Length of the vector
public double length() {
return Math.sqrt(dot(this));
}
// Unit vector (normalized)
public Vec3 normalize() {
double len = length();
return new Vec3(x / len, y / len, z / len);
}
}
Rays
A ray is defined by an origin point A and a direction vector B. It can be expressed as a parametric equation: P(t) = A + t * B, where t is a scalar.
t < 0: Behind the origin.t = 0: At the origin.t > 0: In front of the origin.
public class Ray {
public final Vec3 origin;
public final Vec3 direction;
public Ray(Vec3 origin, Vec3 direction) {
this.origin = origin;
this.direction = direction;
}
// Point at parameter t
public Vec3 pointAt(double t) {
return origin.add(direction.multiply(t));
}
}
3. Building the Scene: Spheres and Intersection
Spheres are the simplest objects to intersect with a ray. The equation for a sphere centered at center with radius R is:
(P - C) · (P - C) = R²
Substituting the ray equation P(t) = A + t * B, we get a quadratic equation:
(B · B)t² + 2B · (A - C)t + (A - C) · (A - C) - R² = 0
We solve for t using the discriminant.
public class Sphere {
public final Vec3 center;
public final double radius;
public final Vec3 color;
public Sphere(Vec3 center, double radius, Vec3 color) {
this.center = center;
this.radius = radius;
this.color = color;
}
// Check if a ray hits the sphere. Returns the 't' value or -1 if no hit.
public double hit(Ray ray) {
Vec3 oc = ray.origin.subtract(center);
double a = ray.direction.dot(ray.direction);
double b = 2.0 * oc.dot(ray.direction);
double c = oc.dot(oc) - radius * radius;
double discriminant = b * b - 4 * a * c;
if (discriminant < 0) {
return -1.0; // No intersection
} else {
// Return the closest intersection point (smallest positive t)
return (-b - Math.sqrt(discriminant)) / (2.0 * a);
}
}
}
4. The Core Ray Tracing Loop
Now, let's put it all together. We'll create a simple scene with a few spheres and render it.
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
import java.io.File;
public class SimpleRayTracer {
// Trace a ray into the scene and return a color
public static Vec3 trace(Ray ray, Sphere[] spheres) {
double closestT = Double.MAX_VALUE;
Sphere closestSphere = null;
// Find the closest sphere the ray hits
for (Sphere sphere : spheres) {
double t = sphere.hit(ray);
if (t > 0.0001 && t < closestT) { // Small epsilon to avoid self-intersection
closestT = t;
closestSphere = sphere;
}
}
// If we hit a sphere, return its color. Otherwise, return a background color.
if (closestSphere != null) {
return closestSphere.color;
} else {
// Simple gradient background
Vec3 unitDirection = ray.direction.normalize();
double t = 0.5 * (unitDirection.y + 1.0); // Map from [-1,1] to [0,1]
return new Vec3(1.0, 1.0, 1.0).multiply(1.0 - t).add(new Vec3(0.5, 0.7, 1.0).multiply(t));
}
}
public static void main(String[] args) throws Exception {
// Image dimensions
int width = 800;
int height = 600;
// Camera setup
Vec3 origin = new Vec3(0, 0, 0); // Camera at origin
// Define the viewport (the virtual screen in the world)
double viewportHeight = 2.0;
double viewportWidth = viewportHeight * (double) width / height;
double focalLength = 1.0; // Distance from camera to viewport
Vec3 horizontal = new Vec3(viewportWidth, 0, 0);
Vec3 vertical = new Vec3(0, viewportHeight, 0);
// Lower-left corner of the viewport
Vec3 lowerLeftCorner = origin
.subtract(horizontal.multiply(0.5))
.subtract(vertical.multiply(0.5))
.subtract(new Vec3(0, 0, focalLength));
// Create scene with spheres
Sphere[] spheres = {
new Sphere(new Vec3(0, 0, -2), 0.5, new Vec3(0.8, 0.3, 0.3)), // Red sphere
new Sphere(new Vec3(1, 0, -3), 0.8, new Vec3(0.3, 0.8, 0.3)), // Green sphere
new Sphere(new Vec3(-1, 0, -2.5), 0.4, new Vec3(0.3, 0.3, 0.8)) // Blue sphere
};
// Create BufferedImage to store the result
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// Render loop: For each pixel...
for (int j = height - 1; j >= 0; j--) {
for (int i = 0; i < width; i++) {
// Calculate the ray direction for this pixel
double u = (double) i / (width - 1);
double v = (double) j / (height - 1);
Vec3 direction = lowerLeftCorner
.add(horizontal.multiply(u))
.add(vertical.multiply(v))
.subtract(origin);
Ray ray = new Ray(origin, direction);
Vec3 color = trace(ray, spheres);
// Convert color from [0,1] to [0,255] and clamp values
int r = (int) (255 * clamp(color.x, 0.0, 1.0));
int g = (int) (255 * clamp(color.y, 0.0, 1.0));
int b = (int) (255 * clamp(color.z, 0.0, 1.0));
int rgb = (r << 16) | (g << 8) | b;
image.setRGB(i, height - 1 - j, rgb); // Flip Y-axis for correct orientation
}
}
// Save the image
ImageIO.write(image, "PNG", new File("simple_raytrace.png"));
System.out.println("Rendered image saved as 'simple_raytrace.png'");
}
private static double clamp(double value, double min, double max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
}
5. Understanding the Code: Key Steps
- Camera Setup: We define a virtual viewport in 3D space. The
lowerLeftCorneris the world coordinate of the bottom-left pixel of our image. - Ray Generation: For each pixel
(i, j), we calculate its position on the viewport (u,vcoordinates). The ray's direction is from the camera origin to that point on the viewport. - Intersection Testing: The
tracefunction loops through all spheres, finding the closest one the ray hits (the one with the smallest positivet). - Coloring: If a sphere is hit, we use its color. If not, we return a background color (a simple blue-to-white gradient in this case).
- Image Creation: We use Java's
BufferedImageto set the color of each pixel and save the final result as a PNG file.
Output
When you run this code, it will generate an image simple_raytrace.png showing three colored spheres against a gradient sky-blue background.
Next Steps: Enhancing Your Ray Tracer
This is just the beginning. To make the images more realistic, you can add:
- Lighting & Shadows:
- Define light sources in the scene.
- Shoot a "shadow ray" from the intersection point towards the light. If it hits an object before the light, the point is in shadow.
- Implement the Phong reflection model (Ambient + Diffuse + Specular).
- Surface Normals:
- The normal vector at a point on a sphere is
(point - center).normalize(). - Use normals for diffuse shading (Lambertian reflection).
- The normal vector at a point on a sphere is
- Reflections:
- Calculate the reflection direction using
R = I - 2*(I·N)*N, whereIis the incoming ray andNis the normal. - Recursively call
tracewith the new reflection ray.
- Calculate the reflection direction using
- Antialiasing:
- Shoot multiple rays per pixel (with slight random offsets) and average the colors to reduce jagged edges.
- More Primitives:
- Implement planes, triangles, and other shapes.
Conclusion
Building a basic ray tracer in Java is an excellent way to understand the fundamentals of computer graphics. While this implementation is simple, it demonstrates the core algorithm that powers the most photorealistic renders in movies and professional visualization. From this foundation, you can incrementally add complexity to create stunningly realistic images, all while deepening your understanding of light, geometry, and programming.