Coffee-machine scene from the Mitsuba gallery rendered in Koji

Coffee-machine scene from the Mitsuba gallery, rendered in Koji.

Koji started as a relatively small experiment: a C++20 CPU renderer for Apple Silicon, built around the idea that I wanted to understand a complete physically based renderer as a system rather than only as a collection of isolated algorithms.

The first version grew very quickly. In the span of a few days it gained the pieces that make a renderer immediately recognizable as one: sampling and film infrastructure, a camera, an Embree geometry backend, a scene representation, direct lighting with multiple importance sampling, a path tracer, tiled rendering, a live preview, and a growing material system.

That pace was useful because it exposed the shape of the problem early. It was also dangerous.

A renderer can accumulate features faster than it accumulates trustworthy boundaries between those features. It can produce plausible images while the architecture underneath them is becoming increasingly difficult to reason about. Worse, names can begin to imply more than the algorithms actually guarantee.

The first important turning point in Koji was therefore not a new light-transport technique. It was a reset: establish an honest baseline, separate the renderer into real subsystems, preserve the images exactly while doing so, and stop treating a label as evidence that an algorithm was complete.

A renderer appears very quickly

The early sequence was deliberately direct.

First came the basic numerical pieces: vectors, sampling, film accumulation, and camera rays. Geometry followed through Embree, which gave Koji a practical CPU ray-intersection backend without forcing the rest of the renderer to own Embree types directly.

Then came Scene IR import, direct-light sampling, MIS, and a unidirectional path tracer. Shortly afterwards I added a tile scheduler and a live preview. Materials expanded beyond a minimal diffuse/specular core into the beginnings of a more production-oriented BSDF system.

External reference scenes as integration tests

Rather than relying only on renderer-authored test scenes, I also started pushing Koji with established external reference workloads. The opening coffee-machine image and the three renders below are scenes from the Mitsuba gallery, translated into Koji's own scene representation. I treat them less as a showcase and more as a small external validation gallery: they exercise scene structure, materials, lighting, cameras, geometry, and import assumptions that were not designed around Koji.

Mitsuba material-test scene rendered in Koji

Material-test scene from the Mitsuba gallery, rendered through Koji. Its value is not merely as a shader demo: it is a compact external workload for coated surfaces, reflections, lighting, geometry, and scene translation.

Mitsuba bathroom scene rendered in Koji

Bathroom scene from the Mitsuba gallery. A full interior makes translation errors harder to hide: indirect illumination, repeated geometry, reflective and dielectric surfaces, cameras, lights, and many material assignments all have to survive the move into Koji.

Mitsuba glass-of-water scene rendered in Koji

Glass-of-water scene from the Mitsuba gallery. Glass, liquid, ice, and dense specular detail make it a demanding reference workload for dielectric handling and difficult light transport.

The Damaged Helmet served a different validation purpose. Rather than testing a complete Mitsuba scene translation, it was used to demonstrate that Koji's glTF path could ingest and render a modern textured PBR asset coherently.

Damaged Helmet glTF asset rendered in Koji

Damaged Helmet rendered through Koji's glTF import path. Here the important result is asset-format feasibility: geometry, textures, and PBR material structure arrive through glTF rather than through a scene authored specifically for the renderer.

Taken together, these renders document integration breadth rather than chronology. They show Koji being tested against external scenes and asset formats with their own material, geometry, lighting, camera, and texture requirements—the kind of pressure that tends to reveal assumptions hidden by renderer-authored tests.

At this stage Koji already looked like a renderer from the outside. It could load geometry, shoot paths, accumulate images, schedule work across tiles, and display progressive results.

That is exactly the point at which architecture starts to matter.

A renderer is unusually good at hiding design mistakes behind output that still looks plausible. If a light-sampling PDF is slightly wrong, the result may simply be too bright. If two integrators do not use the same scene representation, they may both produce reasonable images while comparing two different problems. If a transport path is simplified but given the name of a more general algorithm, screenshots alone do not expose the difference.

The renderer therefore needed a way to distinguish working code from working evidence.

The monolith problem

By the time I stopped to reorganize the project, the main implementation file had grown to 13,619 lines. The main public header had reached 2,241 lines.

This was not just an aesthetic problem.

The large translation unit made unrelated parts of the renderer artificially close to one another. Scene construction, film logic, shading, transport, I/O, and integrator code could all reach implementation details that should have belonged to another layer. Even when that did not create an immediate bug, it made every future change harder to interpret.

The public header had the same issue from the other side: too much of the renderer's internal dependency structure was visible through one interface.

The first refactor split the implementation into 23 focused translation units under areas such as:

Shared implementation-only helpers moved under src/internal/, while the old public renderer.hpp became a tiny compatibility umbrella over dependency-layer headers.

The important question was not whether the new directory tree looked cleaner.

The important question was: did the renderer still produce exactly the same images?

Refactoring without moving the target

For this kind of architectural change, visual inspection is a weak test.

A renderer is stochastic, and two images can look identical while differing numerically. Conversely, if the purpose of a change is purely structural, even a tiny output difference deserves investigation because it means the refactor was not actually behavior-preserving.

So I froze a small matrix of renderer outputs before the split and compared the linear PFM files afterwards.

The matrix covered 12 representative paths: direct lighting, path tracing, material behavior, nested dielectrics, the shadow-terminator path, the then-current bidirectional diagnostic, and several of the experimental photon/VCM-labelled routes.

Every final SHA-256 matched the corresponding pre-refactor image.

That gave the refactor a much stronger meaning than “the scenes still look right.” The code moved; the rendered floating-point output did not.

The compile-time effect was also measurable. Recompiling the old monolithic renderer translation unit took about 1.75 s in the measured setup. Recompiling a touched module such as src/core/math.cpp took about 0.61 s under the same compiler, architecture, warning flags, and include configuration—a reduction of roughly 65% for that edit/rebuild path.

The source decomposition can be summarized like this:

Source decomposition from the monolithic renderer to explicit subsystems
Source decomposition: the monolithic renderer before the refactor and the explicit subsystems afterwards. Tap to open the full diagram.

This diagram is intentionally about source decomposition, not the renderer's final runtime data flow. The more meaningful Scene IR → render-data → integrator boundary comes later in the article.

Labels are not algorithms

The architectural cleanup exposed a second problem that turned out to be even more important.

Some renderer paths had names that were stronger than the implementations behind them.

One early estimator, for example, had been described in metadata as a bidirectional path tracer. In reality it was a much more limited two-bounce next-event construction. During the baseline cleanup I renamed it to nee-2bounce.

That may sound like a documentation correction, but I think it is a renderer-design issue.

Names become assumptions. Once something is called “BDPT”, every later test, scene, benchmark, and comparison tends to inherit the idea that full bidirectional transport exists. Eventually the label stops being descriptive and starts becoming part of the architecture.

The same cleanup removed hard-coded classifications attached to later “production” transport paths where the measurements did not justify those conclusions.

What I did not do at this stage was silently repair every transport problem discovered during the audit. Several known approximations and fabricated terms were deliberately left in place temporarily so that the architectural decomposition could remain byte-identical. The transport corrections belonged to a later revision where changes in rendered output could be measured and explained separately.

That distinction became a useful rule for the project:

A structural refactor should not smuggle in an algorithmic correction, and an algorithmic correction should not hide inside a structural refactor.

It makes the history slower to write, but much easier to trust.

The boundary Koji eventually settled on

The refactor was only the beginning, but it established the direction that the current renderer still follows.

At a high level, Koji now separates three things:

Koji renderer data flow from Scene IR through transport and output
Koji's high-level boundary between portable scene description, compiled render state, and transport execution. Tap to open the full diagram.

The distinction is important.

Scene IR owns the portable description of the scene: cameras, geometry, instances, materials, lights, media, animation, and capability requirements.

Render data owns compiled geometry and backend objects. This is where a portable scene description becomes something the CPU renderer can efficiently intersect and shade.

Integrators consume renderer-owned contracts. They should not need to know how Assimp imported a mesh, how the GUI is implemented, or which Embree C handle happens to own an acceleration structure.

That separation did not emerge fully formed during the first refactor. Scene/integrator decoupling, a real path-space library, semantic capability registries, and the later validation system each strengthened it. But the k0.4a cleanup established the basic rule: implementation boundaries should correspond to renderer concepts, not historical accidents in one large file.

An honest baseline is more useful than an impressive baseline

There is a temptation in graphics programming to measure progress by feature count.

Path tracing. BDPT. SPPM. VCM. Subsurface scattering. Motion blur. Texture filtering. Denoising. Live preview.

Those labels are useful shorthand, but they are not evidence.

The more consequential result of Koji's first major refactor was that it made it possible to ask harder questions later:

Those questions eventually became more important to the project than adding another checkbox.

The immediate baseline was modest: the build was clean with -Werror, the renderer had been decomposed into bounded modules, the 12 reference PFMs were byte-identical, and transport/integrator sources no longer pulled importer, GUI, Embree C API, or oneTBB implementation details across their boundaries.

It was not yet the final architecture. Some private helper fragments were still transitional. The scene library was still too coupled to historical milestone IDs. The validation system was still based on CTest. The real bidirectional transport work was still ahead.

But for the first time, the renderer had a baseline I could use to tell the difference between changing the code and changing the algorithm.

That distinction becomes the foundation for the rest of this series.

In the next article I will look at the next architectural step: turning scenes into data through Scene IR, so that the same scene can be consumed independently by path tracing, light tracing, BDPT, SPPM, VCM, and diagnostic integrators without embedding the renderer's development history into the scene itself.