Changing a renderer can be of dubious utility from an artistic perspective. It smells to me like software people changing editors, Linux distros, or programming languages, so I was reluctant to dive in. What could possibly come from changing renderers if they’re so similar? Well, I thought of a few…
Working with Arnold
I had previously been using Autodesk’s Arnold for car rendering because they ship native Apple Silicon builds and have solid support for OSL. I put together a shader library and found the overall experience reasonable. Given that the car itself is in USD, I chose to adopt USD for the rest of the pipeline. This was possibly to my detriment.
While Arnold is an amazing, production-proven renderer, its USD integration was firmly in its early stages. When I started, kick was using its own translation layer, which didn’t support instancing and some other things. OK, annoying, but they fixed it. What about shaders?
Arnold’s support for OSL, including closures, meant that I could futz with both pattern creation and material response. For patterns, I wrote a couple of shaders for MSDF textures. These load a signed distance field approximation of decals from a texture. They are cheap to load (like 128 × 128 for certain logos), have “infinite” resolution, and make it easy to apply graphic patterning such as outlines versus infill. One node loads one decal, while OSL struct parameters carry the mask, color, texture coordinates, and other values. All I basically do is create a big chain of all the decals on the car.

After seeing a talk on Imageworks’ use of OSL, I thought implementing something similar for OpenPBR Surface would be a good starting point. Weighted mixing allowed multiple materials to be combined cleanly. For a car, this meant decals atop splatter paint atop carbon fiber.
Performance left a little to be desired. I tagged many of the shader parameters with [[ int interactive = 1 ]] to prevent constant-folding and so-on, but rendering was still slow. What about Arnold GPU? The bad news is that OptiX ran out of stack space (because of course it does). There is no good news.
Using an environment variable (OSL_OPTIONS), OSL provides effectively a back door into the shading system, so I had hope. (Note: this is blocked in the RenderMan fork of OSL.) What about groupdata allocation? Nothing. What about disabling OptiX inlining? Nothing. What about random combinations of the lazy_* options? Nothing. Arnold provides great logging, so I had a reasonable idea of which changes were helping. Memory usage was going down, but not nearly enough. Nothing I did got my car onto the GPU using anything above basic shading networks :(
Switching to PRMan
My interest in RenderMan came from better USD support and more production-oriented GPU rendering.
For USD, RenderMan exposes a substantial amount of functionality through its shipped schemas, which could be interesting, though I had only just started using it.
XPU is focused on getting the CPU and GPU paths to behave the same. Interestingly, RenderMan doesn’t use OptiX. There are a couple of interesting differences relating to visibility bit masks, custom intersectors, and memory usage. They do support OSL, but don’t expose closures to end users even though they are used internally (they literally bake the .oso bytecode into the shipped .so).
Step 1 was just opening usdview and seeing if it would take kindly to the car’s geometry.
$ usdview mk11.usda --renderer "RenderMan XPU"
Segmentation fault (core dumped)
Crap. Because all the car’s prototypes (~1700 in total) are meshed at once, I was worried that just one of those prototypes could be weirdly meshed and causing the trouble. Successfully rendering the prototypes individually told me this wasn’t the case. (Thankfully, my mesh validation was working.) I decided to take the blue pill, crack open gdb, and see if that got me anywhere. The call stack was basically:
X_PxrPathTracer.so
xpu::RenderSceneT<xpu::DeviceCpu>
│
└── TraceRays(...) const
│
├── xpu::Context
├── TraceHitSides
├── PresenceShadeHitFunc&
│
├── VolumeDensityShadeHitFunc& <--
│
└── VolumeConfig
Volume-density hit/shading callback
Weird. The crash happens while rendering curves, and yet the prototypes were fine individually. I concluded that there was something wrong with the assembly. I thought the easiest thing to try was to disable presence caching on the curves (via primvars:ri:curve:opacitysamples). No. Nothing. Luckily, the issue was actually much simpler.
Engineers are exactly like 3D artists in that they don’t always produce clean CAD models. Though there was tremendous deduplication by virtue of instancing, this doesn’t address accidentally authored duplication. In production rendering, this is a somewhat common problem in texture systems, for example, which often load requested textures to sniff out ones that are visually similar.
For Mk11, there were two instances of one of the powertrain assemblies (below), which led to curves occupying the same region in space and thus caused the crash. In some ways, I’m glad the crash occurred because it forced me to scan the entire car for duplicated parts, of which there were quite a few. Though these didn’t cause any crashes, they certainly increased memory usage! Perhaps it would be worth performing some sort of analysis, and maybe even an automated deduplication pass, on input assemblies in step-to-usd before meshing.
