Over the past two lessons, you've built a sophisticated camera system that rivals professional rendering software. You can position your camera anywhere in 3D space, point it at any target, control the field of view, and create beautiful depth of field effects that make some objects sharp while others blur naturally. Your camera is now a powerful creative tool that gives you complete control over how your scenes are captured and presented.
However, a great camera is only as good as the scene it's photographing. In this final lesson of the course, you'll learn how to create a scene that's worthy of your advanced camera system. You'll build what's known in the ray tracing community as a "showcase scene" — a complex world filled with many objects of different materials, sizes, and properties that demonstrates the full capabilities of your ray tracer.
This lesson focuses on three interconnected skills. First, you'll learn how to use randomness and loops to generate complex worlds programmatically rather than placing each object manually. This technique allows you to create scenes with hundreds of objects using just a few lines of code. Second, you'll understand how to make strategic decisions about material distribution and object placement to create visually interesting compositions. Finally, and perhaps most importantly, you'll learn how to balance render quality settings against render time to produce beautiful images without waiting hours for each frame.
By the end of this lesson, you'll render a classic ray tracing image: a large ground plane with hundreds of small spheres scattered across it, punctuated by a few large "hero" spheres that draw the eye. This scene has become iconic in the ray tracing world because it effectively demonstrates reflections, refractions, depth of field, and material variety all in a single image. More importantly, you'll understand the principles behind creating this scene, which you can apply to any future rendering projects.
Creating complex scenes by manually placing each object would be tedious and time-consuming. Imagine writing code to position 400 spheres individually — you'd need to specify the location, radius, and material for each one. Instead, you can use loops and randomness to generate interesting scenes programmatically. This approach not only saves time but also creates natural-looking variation that would be difficult to achieve manually.
The foundation of procedural scene generation is the random_double() function you've been using throughout the course. As a reminder, this function returns a random floating-point number between 0 and 1, and it can also accept minimum and maximum values to generate numbers in a specific range. You'll use this function to randomize positions, colors, material properties, and even which type of material to use for each object.
Let's start by creating a ground plane. In ray tracing, a common technique for creating a floor is to use a very large sphere positioned below the scene. The top of this sphere appears flat when viewed from above, creating the illusion of an infinite ground plane. Here's how you create this ground sphere:
The ground sphere is centered at position (0, -1000, 0) with a radius of 1000 units. This means its top surface sits at y = 0, which becomes your ground level. The sphere is so large that its curvature is imperceptible in your rendered images — it looks like a flat plane. The material is a gray lambertian (diffuse) surface with color (0.5, 0.5, 0.5), which provides a neutral backdrop that doesn't distract from the more interesting objects you'll place on top of it.
Now you can generate many small spheres scattered across this ground plane. You'll use nested loops to create a grid-like distribution with randomness added to make it look natural. The outer loop iterates over x-coordinates and the inner loop over z-coordinates, creating a 2D grid of potential sphere positions:
The loops iterate from -11 to 10 in both directions, creating a 22 × 22 grid of potential positions. For each grid cell, you calculate a sphere center by starting at the integer grid position (a, 0.2, b) and adding a random offset. The y-coordinate is fixed at 0.2, which places the bottom of each sphere (with radius 0.2) right on the ground plane at y = 0. The x and z coordinates add 0.9 * random_double(), which gives a random offset between 0 and 0.9 units. This offset prevents the spheres from being perfectly aligned on a grid, creating a more natural, scattered appearance.
The if statement performs an important check to prevent spheres from overlapping with a special location at (4, 0.2, 0). This is where you'll later place one of your large hero spheres, and you want to keep the area around it clear. The condition calculates the distance between the current sphere center and this reserved location, and if the distance is less than 0.9 units, it skips creating a sphere at this position using continue. This creates a clear space around your hero sphere, making it stand out visually.
The variable choose stores a random number that you'll use in the next section to decide which type of material to assign to each sphere. By generating this random number once per sphere and using it to make multiple decisions, you ensure consistent randomness throughout your scene generation.
This loop structure creates approximately 400 spheres (22 × 22 minus the ones skipped near the hero sphere location). Each sphere is small (radius 0.2) and positioned at ground level with slight random variations in x and z. This forms the foundation of your scene — a field of small spheres that will showcase different materials and create visual interest through their variety and number.
With the spatial distribution of spheres established, you need to decide what material each sphere should have. Random material assignment creates visual variety and demonstrates the different material types your ray tracer supports. However, purely random assignment might create an unbalanced scene. Instead, you'll use a probabilistic approach that favors certain materials over others, creating a more aesthetically pleasing distribution.
The strategy uses the choose variable you generated earlier, which contains a random number between 0 and 1. By checking this number against threshold values, you can assign materials with specific probabilities. Here's how the material selection works inside your nested loops:
The first condition checks if choose < 0.7, which will be true 70% of the time since choose is uniformly distributed between 0 and 1. When this condition is true, you create a lambertian (diffuse) material. The albedo color is generated by multiplying pairs of random numbers together. This technique, squaring the random values, biases the colors toward darker tones because multiplying two numbers less than 1 produces an even smaller number. For example, 0.5 * 0.5 = 0.25. This creates a nice variety of muted, natural-looking colors rather than overly bright, saturated ones.
The second condition checks if choose < 0.9, which catches values between 0.7 and 0.9 — a 20% probability. These spheres become metal. The albedo for metal uses random_double(0.5, 1) for each color component, which generates brighter colors between 0.5 and 1.0. This makes the metal spheres more reflective and visually distinct from the darker diffuse spheres. The fuzz parameter is randomized between 0.0 and 0.5, creating metals that range from perfectly mirror-like (fuzz = 0) to slightly rough and scattered (fuzz = 0.5).
The final else clause catches the remaining 10% of spheres and makes them glass (dielectric) with a refractive index of 1.5. Glass spheres are the most visually striking because they refract light, creating interesting distortions and reflections. By making them relatively rare (only 10%), they become special focal points that draw the viewer's eye.
This probabilistic distribution creates a balanced scene where most spheres are diffuse (providing a solid foundation), some are metallic (adding sparkle and reflections), and a few are glass (creating dramatic visual interest). The randomness ensures that no two renders of this scene will look exactly the same, even though the overall character remains consistent.
Beyond the small scattered spheres, you'll add three large "hero" spheres that serve as the main subjects of your image. These spheres are placed manually at specific locations and use carefully chosen materials to create visual contrast:
The center sphere at (0, 1, 0) is glass, making it the most visually interesting of the three. The left sphere at (-4, 1, 0) is a brownish diffuse material, providing a solid, matte contrast to the shiny spheres. The right sphere at (4, 1, 0) is a perfectly smooth metal with a warm, golden color. These three spheres are much larger (radius 1.0 compared to 0.2 for the small spheres) and positioned at y = 1, making them tower above the field of small spheres. Their strategic placement and material choices create a focal point for your composition and demonstrate the full range of materials your ray tracer can handle.
Now that you have a complex scene to render, you need to understand how to control the quality of your final image. Two parameters in your camera class directly affect image quality: samples_per_pixel and max_depth. These settings determine how much computation your ray tracer performs for each pixel, and understanding their effects is crucial for producing high-quality images efficiently.
The samples_per_pixel parameter controls how many rays your ray tracer shoots for each pixel in your image. Remember that ray tracing is a Monte Carlo process — you take many random samples and average them to approximate the true color of each pixel. When you set samples_per_pixel = 100, your ray tracer generates 100 different rays for each pixel, each with slightly different random offsets and (if using depth of field) different origins on the defocus disk. The colors from all these rays are averaged together to produce the final pixel color.
Higher sample counts reduce noise in your images. Noise appears as random speckles or grain, particularly visible in areas with complex lighting, reflections, or shadows. With only 10 samples per pixel, your image will look very noisy — you'll see obvious random variation in colors, especially in reflective or refractive materials. At 100 samples per pixel, the noise is much less noticeable, and the image looks cleaner and more professional. At 500 samples per pixel, the image becomes very smooth, with noise barely perceptible even in challenging areas.
The relationship between sample count and noise follows a mathematical principle: noise decreases proportionally to the square root of the sample count. This means that to reduce noise by half, you need to quadruple the number of samples. Going from 100 to 400 samples reduces noise by half, but it also quadruples your render time. This is why there are diminishing returns at higher sample counts — the improvement in quality becomes less noticeable even though render time continues to increase linearly.
The max_depth parameter controls how many times a ray can bounce before the ray tracer stops following it. When a ray hits a reflective surface like metal, it bounces and continues traveling in a new direction. When it hits glass, it might split into a reflected ray and a refracted ray, both of which continue bouncing. The max_depth parameter limits this recursion to prevent infinite loops and control render time.
Setting max_depth = 1 means rays stop after hitting the first surface — you'd see objects but no reflections or refractions at all. With max_depth = 5, rays can bounce up to five times, which is enough to see basic reflections and refractions but might cut off complex light paths. At max_depth = 50, rays can bounce many times, capturing subtle effects like light bouncing between multiple reflective surfaces or passing through multiple layers of glass.
For most scenes, a max_depth between 20 and 50 provides good results. Values below 20 might cause reflections or refractions to appear darker than they should because some light paths get cut off prematurely. Values above 50 rarely make a visible difference because most rays naturally terminate before reaching such deep recursion — they either escape the scene, hit a diffuse surface that absorbs them, or become so dim through multiple bounces that their contribution becomes negligible.
These two parameters interact with each other. If you set samples_per_pixel very high but max_depth very low, you'll get a smooth image with little noise, but reflections and refractions will look wrong because light paths are being cut off. Conversely, if you set max_depth very high but samples_per_pixel very low, you'll capture complex light interactions, but the image will be too noisy to appreciate them. You need both parameters set appropriately to achieve high-quality results.
Understanding the quality parameters is one thing, but knowing how to use them effectively in practice is another. Render time increases dramatically with higher quality settings, and you need strategies for finding the right balance between quality and the time you're willing to wait for an image.
A practical approach is to start with low settings for preview renders and gradually increase them as you refine your scene. For initial testing — when you're positioning the camera, adjusting materials, or experimenting with scene composition — use settings like samples_per_pixel = 10 and max_depth = 10. These settings produce noisy images with limited light bounces, but they render quickly, often in seconds rather than minutes. This allows you to iterate rapidly, making changes and seeing results immediately.
Once you're satisfied with your scene composition and camera positioning, increase to medium settings for a better preview. Try samples_per_pixel = 50 and max_depth = 20. At these settings, noise is reduced significantly and reflections look more realistic. The render might take a few minutes, but the quality improvement is substantial. This is a good setting for sharing work-in-progress images or for final renders when time is limited.
For your final, high-quality render — the image you'll save and share as your finished work — use settings like samples_per_pixel = 200 and max_depth = 50. These settings produce very clean images with accurate reflections and refractions. The render time might be 20 minutes to an hour or more depending on your hardware and image resolution, but the quality justifies the wait for a final showcase piece.
The diminishing returns principle is important to keep in mind. Going from 10 to 50 samples per pixel produces a dramatic quality improvement — the image goes from obviously noisy to reasonably clean. Going from 50 to 200 samples also improves quality noticeably, reducing noise further and smoothing out subtle details. However, going from 200 to 500 samples produces a much smaller visible improvement. The image gets slightly smoother, but most viewers wouldn't notice the difference unless comparing the images side by side. Meanwhile, render time increases by 2.5 times. For most purposes, 200 samples per pixel hits the sweet spot where quality is high and render time is reasonable.
Image resolution also plays a role in render time. The example code uses image_width = 600 with an aspect ratio of 3:2, producing a 600 × 400 pixel image. This is a good size for learning and experimentation. If you want a larger final image, say 1200 × 800 pixels, remember that you're quadrupling the number of pixels (doubling both width and height), which roughly quadruples render time. Start with lower resolution for testing, then increase to your target resolution only for the final render.
One useful technique is to render a small region of your image at high quality to test settings before committing to a full render. While the example code doesn't include this feature, many production ray tracers allow you to render just a portion of the image. This lets you see how 500 samples per pixel looks in a small area without waiting for the entire image to render. You can then decide if the quality improvement justifies the longer render time for the full image.
You've reached the end of this advanced camera and rendering course, and it's worth taking a moment to reflect on what you've accomplished. When you started this course, you already had a working ray tracer with basic materials and lighting. Through these three lessons, you've transformed it into a sophisticated rendering system with professional-grade camera controls and the ability to create complex, visually stunning scenes.
In the first lesson, you learned how to position and orient your camera in 3D space using the lookfrom, lookat, and vup parameters. You explored how field of view and aspect ratio affect the perspective and framing of your images, giving you the ability to compose shots with precision.
The second lesson introduced depth of field effects using the thin-lens approximation. By sampling ray origins from a defocus disk, you created realistic blur for out-of-focus objects and gained control over focus_dist and defocus_angle. This added a photographic quality to your renders.
In this final lesson, you brought everything together by generating complex scenes programmatically with randomness and loops. You applied strategies for distributing materials and learned to manage render quality through samples_per_pixel and max_depth, balancing image quality and render time.
The scene you created—a field of random spheres with three hero spheres—is a classic in ray tracing, demonstrating the full capabilities of your renderer. More importantly, you now understand the principles behind camera control, material interaction, Monte Carlo sampling, and quality management. This foundation will serve you well as you explore more advanced rendering techniques in the future.
Take pride in your accomplishment: you’ve built a ray tracer with advanced camera controls and complex scene composition, understanding every step along the way.
