Inspiration
I wanted to build the thing I was most sure I could not build.
Every AI image tool works the same way: a diffusion model hallucinates pixels that look plausible. The reflections are not reflections. The shadows are not shadows. They are statistical guesses that resemble them. I wanted the opposite. I wanted a render where every pixel is earned, computed by simulating light bouncing through a scene, and I wanted to reach it by typing a sentence.
A penumbra is the soft outer edge of a shadow, the region where a light source is only partly blocked. It is the thing a real renderer spends most of its effort getting right, and the thing cheaper rendering fakes. It seemed like the right name.
What it does
You describe a scene in plain language. Claude writes a complete scene specification (geometry, materials, camera placement) and a path tracer I wrote from scratch in C++ renders it by simulating how light actually travels.
No image generation model is involved anywhere. Every pixel comes from tracing rays until they find a light.
How I built it
The renderer came first. About 2,500 lines of C++17, no graphics libraries, zlib as the only dependency. It solves the rendering equation by Monte Carlo integration:
$$L_o(\mathbf{x}, \omega_o) = L_e(\mathbf{x}, \omega_o) + \int_{\Omega} f_r(\mathbf{x}, \omega_i, \omega_o) \, L_i(\mathbf{x}, \omega_i) \, (\omega_i \cdot \mathbf{n}) \, d\omega_i$$
It has a bounding volume hierarchy, work-stealing multithreading, Lambertian, metal and dielectric materials with Schlick-Fresnel and total internal reflection, emissive area lights, a real camera aperture for depth of field, and a homogeneous participating medium for fog.
Then the language layer. Claude Opus 5 is given exactly one tool, emit_scene,
carrying a JSON schema, and tool_choice forces it to call that tool. The reply is
therefore structured data rather than prose that has to be parsed out of a message.
The interesting part is the arrow going backwards. Neither tool calling nor schema validation catches a scene that is well formed but wrong: a material name spelled almost right, a camera pointing at nothing, a sphere with a negative radius. So the renderer validates, and when it refuses, its complaint goes back to Claude as the tool's result. I wrote the error messages specifically for this. They name the offending field and list the valid options:
error: in objects[1]: unknown material type 'lambertain'
(expected: lambertian, metal, dielectric, diffuse_light)
error: in objects[0]: sphere 'radius' must be greater than 0
The model fixes itself, usually on the next attempt. The web UI shows these corrections as they happen rather than hiding them, because watching the system catch and repair its own mistake is the most interesting thing it does.
Waiting on a path tracer is the worst part of using one, so samples are gathered in doubling passes and the image is published after each one. A recognisable picture arrives in under a second and sharpens from there. Each pass adds to an accumulation buffer, so the total work is identical to rendering all the samples at once.
Challenges I ran into
My first Cornell box took 74 seconds and was still covered in noise. That is not a sample count problem, it is an algorithm problem. A naive path tracer bounces randomly and has to get lucky to hit a small light, so most paths return nothing and the rare lucky ones blow out into white speckle.
The fix was next-event estimation. Half of each bounce's samples aim directly at emissive geometry, half go through the material's own scattering lobe, and the result is divided by the combined probability density. Both halves are unbiased alone, so the converged image is identical. Only the speed changes:
| Time | Result | |
|---|---|---|
| Brute force | 74s | heavy noise |
| Light importance sampling | 42s | clean |
Faster and cleaner, because paths stop wasting bounces on directions that contribute nothing.
Fog defeated me three times. My first attempt let the model choose the fog density directly, and every image came out black. The second attempt used better prompting and also came out black. The reason took me embarrassingly long to see: density is defined per unit distance, so the same number is impenetrable in a scene ten units across and invisible in one three hundred units across. The model was picking a value before it had finished deciding how big the scene was, and being wrong by a factor of three renders the image solid black.
The third attempt worked, and it changed how I think about this kind of system. The
model now chooses between faint, moderate and thick, and my adapter computes
the actual density from the geometry the model produced, targeting a fixed optical
depth between the camera and the furthest light. Distance through the medium is then
sampled analytically:
$$t = \frac{-\ln(1 - \xi)}{\sigma_t}, \qquad \xi \sim U(0,1)$$
which is chosen so the sampling probability cancels the transmittance term exactly.
A gateway silently dropped a parameter. I originally used structured outputs
(output_config.format), which constrains decoding itself and is the stronger
guarantee. The gateway I was routing through accepted the parameter and ignored it,
so the model answered in prose and the failure surfaced a long way from its cause.
Switching to forced tool calling fixed it, because tool calling is old enough that
everything supports it.
Lights that were invisible from one side. A colour bleed scene came back with black rectangles where the glowing panels should be. Emissive surfaces were one-sided, and quad winding order is invisible in a scene file and easy to get backwards. I made emission two-sided. The model should not have to reason about vertex ordering to make a lamp that works.
What I learned
The most important design question was which decisions belong to the model and which belong to the code. Image size and sample count come from the server, never from the prompt, because a scene description should not be able to request a twenty minute render. Fog density is computed from geometry, because it is a number the model cannot judge without information it does not have yet. The model decides what the scene is. The code decides what things cost and anything that depends on scale.
Error messages are an interface. I had always written them for humans. Here they are consumed by a model that has to act on them, and the difference between "invalid material" and "unknown material type 'lambertain' (expected: lambertian, metal, dielectric, diffuse_light)" is the difference between a loop that converges and one that does not.
I also learned a great deal of physically based rendering maths I had only ever read about: importance sampling, probability density functions over solid angles, orthonormal bases, Fresnel equations, and why unbiased estimators matter.
What's next
Deterministic rendering via per-pixel seeding, so the same prompt gives a byte-identical image. A denoiser, which would help the foggy scenes most. Adaptive sampling that spends effort where variance is highest rather than uniformly.
Log in or sign up for Devpost to join the conversation.