← All writing

MoonRay Adventures

Getting DreamWorks' MoonRay built for Houdini on macos and farm

Intro

Open MoonRay is DreamWorks’ production path tracer, open-sourced and now ASWF-governed. It ships with a Hydra render delegate (hdMoonray) and a set of Houdini DCC plugins. This is my attempt at getting it built and tested, currently for macos and our farm, then working towards a windows build… maybe.

Over a few months I:

  • built MoonRay from source on Apple Silicon (standalone, then a Houdini variant)
  • fixed the delegate and the Houdini plugin layer until Solaris was genuinely usable — lights, volumes, materials, in viewport and cli denoising, node menu cleanup, matched cli settings to match houdini viewport
  • added new Houdini nodes that didn’t exist (a VdbVolume VOP, a Moonray Mesh Light LOP)
  • ported the whole thing to a headless, CPU-only Rocky Linux 9 render server off the same branch

The work lives in a personal fork of the superproject plus two forked submodules (hdMoonray, moonray_dcc_plugins), all on one shared branch.

moonray solaris


Why bother

Besides it being interesting? We’re looking into alternatives for Karma. It’s a great renderer, but a tad expensive for us mortals here at the bottom of the planet. Getting it in houdini and on the farm will allow us to start testing real production scenes, and seeing where intergration is needed.


The standalone build

The standalone renderer came first, and mostly to prove the dependency chain worked on Apple Silicon. MoonRay is 19 git repos tied together as submodules, with ~27 third-party dependencies built from source via CMake ExternalProject.

Platform: M1 Pro, macOS 15.7.4 Sequoia, Xcode 16.4 (AppleClang 17), CMake 3.31.5.

It built — after five real fixes. The ones worth repeating:

Dylib portability (.. baked into install names). Eight autotools-built dependency dylibs (OpenSSL, libcurl, log4cplus, microhttpd, libuuid, cppunit) ended up with install names like:

/Applications/MoonRay/source/openmoonray/building/macOS/../../../../installs/lib/libssl.3.dylib

macOS dyld requires every intermediate directory in a .. traversal to exist — so any machine without the source/ tree simply cannot load them. Root fix: canonicalise the install prefix with get_filename_component(... ABSOLUTE) before handing it to autotools. For an already-built tree, a script rewrites the -id and every LC_LOAD_DYLIB reference (862 files patched on mine).

MicroHttpd’s autotools trap. The 0.9.72 tarball hardcodes aclocal-1.16 / automake-1.16 in its Makefile; on automake 1.18 make install dies with error 127. The robust fix is to no-op the regeneration tools entirely (ACLOCAL=: AUTOCONF=: AUTOMAKE=: AUTOHEADER=: MAKEINFO=:) plus --disable-doc. Touching timestamps — the obvious fix — is fragile, because ./configure re-establishes the broken mtime ordering.

Qt platform plugin. moonray_gui aborted with Could not find the Qt platform plugin "cocoa" in "" because setup.sh never exported QT_QPA_PLATFORM_PLUGIN_PATH / QT_PLUGIN_PATH. Two lines. Also a distribution lesson: installs/plugins/ must ship alongside installs/lib/.

Textures must be .tx. Plain JPEG/PNG/untiled EXR fail with unknown oiio sample failure, once per sample. MoonRay initialises OIIO’s TextureSystem in strict mode (accept_untiled=false, accept_unmipped=false). Convert first:

maketx input.jpg -o input.tx

This bites in Houdini too, so it’s worth internalising early. (A USD ArResolver that auto-generates .tx on demand is still on my list.)


The Houdini build, and the USD version issues

MoonRay’s dependency set includes USD 22.11. Houdini 20.5.939 ships USD 24.3, with its libraries renamed to libpxr_* and its Python at 3.11. The Houdini variant of MoonRay is supposed to build against Houdini’s USD — but the include and link paths conspire against you in three separate ways.

The include race

Xcode’s generator puts -isystem <installs>/include before the Houdini toolkit include directory. So even with everything else configured correctly, the 22.11 headers win, and the resulting objects link against the wrong libpxr_*/libusd_* namespace. Symptoms are downstream and confusing: shader parameters silently do nothing, “Invalid info:id DwaBaseMaterial” in the log viewer, the delegate half-working.

The fix that actually holds: a build wrapper that temporarily moves installs/include/pxr aside so clang falls through to Houdini’s toolkit headers. Ugly, but deterministic — and verified with otool -L (the delegate must show libpxr_* and zero libusd_*).

The cleaner long-term fix is to build the deps with -DNO_USD=1 so standalone USD is never installed at all. That’s what the fork does now.

Houdini 20.5 renames and Python 3.11

Three files needed to be told the truth about Houdini 20.5:

  • CMakeMacOSPresets.json — Houdini install dir, libhboost_python311-mt-a64.dylib (was python39), and CMAKE_PREFIX_PATH with PREFIX_PXR first.
  • building/macOS/pxr-houdini/pxrTargets.cmake — Python 3.9 → 3.11 include/lib paths, and removing the toolkit include from every target’s INTERFACE_SYSTEM_INCLUDE_DIRECTORIES (26 targets) so CMake stops classifying it as a system include.
  • pxrTargets-release.cmake — usdRiImaging → libpxr_usdRiPxrImaging.dylib, a Houdini 20.5 rename. Without it, the MoonRay delegate simply disappears from Houdini’s renderer list with a “not supported” note.

A compiler bug you have to patch around

Houdini’s sdf/childrenProxy.h trips a clang phase-1 error on SdfChildrenProxy::_Set. A tiny header patch adding a _Set stub unblocks it. The same include chain is why MoonRay’s hdMoonrayAdapters plugin is excluded from the Houdini build entirely, which turns out to matter a lot later (see the mesh lights section).

Python bindings and setup.sh

Do not source scripts/setup.sh for the Houdini variant. It prepends the standalone USD 22.11 Python bindings and crashes Houdini’s 3.11 interpreter. Use setupHoudini.sh, which strips those paths and exports PXR_PLUGINPATH_NAME / PXR_PLUGIN_PATH. I later hardened it with a prepend_unique_path helper (sourcing twice used to duplicate entries) and made HOUDINI_PATH composable with whatever houdini_setup already set.

Also: BUILD_QT_APPS=NO for the Houdini preset. It drops moonray_gui and arras_render, but the Arras runtime (arras4_core, mcrt_*, execComp) still builds, so the delegate’s render path is intact.


Making Solaris work

The build succeeding is roughly the halfway point. Here’s what was broken once MoonRay showed up in the Houdini viewport.

long vs long long on arm64

On macOS arm64, long and long long are distinct C++ types. Houdini hands integer render settings across as long long; the delegate’s ValueConverter only handled long and int. Result: cannot convert long long to Int for basically every scene variable.

Fixed by adding long long handling to TYPE_BOOL/TYPE_INT/TYPE_LONG/TYPE_FLOAT, later refactored into two helpers — _extractIntegral64() (int/long/long long → int64_t) and _narrowIntChecked() (bounds-checked narrowing that logs instead of silently wrapping).

A companion case: Houdini exports ramp interpolation as a single TfToken ("hermite"), while MoonRay’s iridescence_interpolations is an int vector. The RDL2 default is already all-hermite, so the right answer is a silent skip.

Scene-object references from USD

ValueConverter::setAttribute declared TYPE_SCENE_OBJECT and TYPE_SCENE_OBJECT_VECTOR “not supported yet”. That’s pervasive: it breaks OpenVdbMap.openvdb_geometry, every camera’s medium_geometry/medium_material, BakeCamera.geometry, ProjectCameraMap*.projector — anything that points at another scene object.

The fix is central rather than per-site: setAttribute now takes a RenderDelegate* and resolves the authored prim path(s) via getSceneObject(), with the five object-handling call sites (Material, Camera ×2, Light, LightFilter) passing their delegate through.

Lights

Three separate bugs, all user-visible on day one of using the Light LOP:

Light Symptom Cause
Point Couldn't find DSO for 'PointLight' The .ds authored moonray:class from the Houdini menu label; MoonRay has no PointLight (a point is a SphereLight)
Env / Dome Couldn't find DSO for 'None' The dome LOP has no lighttype parm and its prim type is DomeLight_1, so the class expression fell through to None
Distant Renders black MoonRay’s DistantLight needs normalized = true; Houdini authors UsdLux’s normalize = false, which the delegate honours

The class fix is the satisfying one: rather than patching the label→class expression, stop authoring moonray:class at all (flip its control companion default from "set" to "none") and let the delegate’s own type map do the derivation — sphereLight→SphereLight, domeLight→EnvLight, distantLight→DistantLight. Fewer moving parts, .ds-only, no rebuild.

Distant normalize is scoped by narrowing the hidewhen on the moonray:normalized control to UsdLuxDistantLight only, so area lights are untouched.

Point lights needed a delegate change too. Houdini’s “Point” light authors a UsdLuxSphereLight with treatAsPoint = true and no radius — so it inherited the UsdLux schema default of 0.5 and rendered as a fat sphere (and, with a cone, an oversized soft spot, because lens_radius maps from the same parameter). The delegate ignored treatAsPoint entirely. Now it maps to a small radius — 0.01, deliberately not 0, because MoonRay bakes 1/radius into the light transform and rejects a zero radius as singular, which turns the light off. normalized is forced true so intensity behaves as power, the standard point-light convention.

Worth flagging for anyone adopting this: existing point lights get roughly 3× dimmer, because they were previously an unnormalised 0.5-radius sphere. That’s the correct behaviour, but it’s a re-tune.

Volumes

The mental model that nobody writes down: the geometry holds the grids, the shader only tints. VdbGeometry owns model / density_grid / emission_grid / velocity_grid; VdbVolume only multiplies the baked result.

So emission does not come from the shader. If emission_grid is unset, there is nothing for incandescence_gain_mult to multiply, and your volume will never glow no matter what you do on the material.

The delegate only special-cases two USD field names — density (which also sets model) and velocity. temperature is ignored. Houdini’s default velocity field name is v, which also doesn’t match. Everything else must go through the generic primvar override path, and it must be a primvar in the primvars: namespace:

string primvars:moonray:emission_grid = "temperature_rgb"   # ✅ read
string moonray:emission_grid = "temperature_rgb"            # ❌ silently ignored

Denoising

“Optix mode not supported in this build” in Houdini, while standalone denoising was fine. Three fixes in hdMoonray, and only the third one is the interesting one:

  1. ArrasSettings::applySettings() never called setDenoiseEngine() — the OIDN toggle was dead code and the engine stayed on its OptiX default.
  2. setDenoiseEngine() had no macOS guard — OptiX is compiled out on macOS, so it must force OIDN.
  3. The decisive one: ArrasRenderer::connect() set the denoise mode on each freshly created ClientReceiverFb, but not the engine. So the receiver kept its OptiX default and the corrected engine value never actually arrived.

Fixes 1 and 2 are necessary and completely insufficient without 3. Both 1 and 3 are platform-independent — upstream PR candidates.

Per-output denoise controls that actually author

Adding denoise toggles to the RenderSettings LOP took three attempts, and each failure taught something about Solaris:

  1. Bare-named parms don’t route — husk only forwards moonray:-namespaced attributes from the RenderSettings prim.
  2. So rename them to the Houdini-encoded namespaced form (xn__moonrayenableDenoise_o8a = moonray:enableDenoise, computed with hou.text.encode()). Still nothing.
  3. The missing piece — spotted by staring at every other MoonRay setting — is the control companion parm (the “Set or Create / Block / Do Nothing” menu). That’s what tells the Render Settings LOP to author the attribute onto the prim at all.

Delegate-side, the flag coalesces the bare (viewport) and namespaced (RenderSettings prim) keys with override semantics rather than OR, so the node can also turn denoise off. Defaults are “Set or Create”, i.e. the node drives the default rather than inheriting from the viewport.

While in there: MoonRay’s .ds generator had emitted a batch of scene variables as top-level parms outside any group{}, so they rendered as loose widgets under the tab bar. Moved into their proper tabs (Frame / Sampling / Driver), with Denoising placed right after Sampling.

The TAB menu

The 126 MoonRay shader HDAs each baked four redundant TAB-menu placements into their Tools.shelf section: DW Moonray, Digital Assets, DreamWorks/All, and DreamWorks/HIDDEN. HIDDEN was a byte-identical duplicate of All; DW Moonray was the incomplete one, missing 10 nodes.

A Python tool rewrites each HDA’s Tools.shelf via a hotl expand/collapse round-trip into a single Moonray/<Subfolder> entry, suffix-classified into six folders — Materials 25, Normal Maps 11, Maps 65, Displacement 4, Display Filters 18, Volumes 3 (= 126, full coverage). Idempotent, backs up *.hda.orig, 21 tests.


Nodes that didn’t exist

VdbVolume as a first-class Solaris node

VdbVolume is the shader you need for non-trivial VDB rendering, and it was registered with USD’s Sdr registry (so hand-authored USD worked) but had no Houdini UI at all — unlike its siblings BaseVolume and CutoutVolume. Authoring a Volume + OpenVDBAsset without it gets you has no moonray or fallback shaders.

Three pieces:

  1. VOP HDA — cloned from BaseVolume by expanding with hotl -X, editing the text sections, and re-collapsing with hotl -C. 9 attributes from VdbVolume.json.
  2. LOP dialog .ds — generated, with hou.text.encoded xn__inputs… parameter names and Set/Block/Do-Nothing control companions, byte-for-byte matching BaseVolume’s conventions down to the tab indentation in help strings.
  3. moonray_nodes.json entry — full shape parity. (Interesting finding: nothing in the open-source codebase reads this file at runtime — grep turns up only the CMake install rule. It’s a metadata mirror for a DreamWorks-internal tool.)

Also fixed along the way: the CMake install still shipped python3.9libs/, so the .ds loader never ran under Houdini 20.5’s Python 3.11. (An existing symlink in the installed tree was quietly masking it.)

Mesh lights, and the plugin that isn’t there

This one had a false start worth including, because the correction is the story.

I initially assumed mesh lights worked via direct USD authoring and only lacked a UI. They don’t. A MoonrayMeshLight prim — and a standard UsdLuxGeometryLight — is silently dropped: MoonRay never sees a light, and the emitter mesh just becomes render geometry.

Root cause, confirmed with otool -L: the hdMoonrayAdapters plugin (which provides MoonrayMeshLightAdapter) shows 26 libusd_* and zero libpxr_* — it’s a leftover from the standalone build. It’s intentionally excluded from the Houdini build because it doesn’t compile against USD 24.3 (that same SdfChildrenProxy::_Set include chain). So Houdini’s UsdImaging never images those prim types.

Porting the adapters is a real project. The workaround is a carrier prim piggyback:

carrier SphereLight (radius 0.01)
  + uniform token moonray:class = "MeshLight"
  + string moonray:geometry = "/path/to/emitter"
  + MeshLight params
        │  UsdImaging images the SphereLight (a type it knows) → sphereLight Sprim
        ▼
  delegate reads moonray:class first → builds an RDL2 MeshLight
  delegate resolves moonray:geometry (string) → emitter Rprim → attaches geometry
        ▼
  importance-sampled MeshLight renders

Two components:

  • Delegate: a moonray:geometry string fallback in the geometry handler (used when the inputs:geometry relationship yields nothing, because the adapter that would forward it isn’t running), reusing Mesh::geometryForMeshLight and the scene-object path resolution from the earlier fix. Plus suppressing the now-legitimate “class may not be compatible with USD light type” warning.
  • A Moonray Mesh Light LOP HDA so the user never sees the piggyback: full MeshLight parameter UI matching the other MoonRay lights, a Scene Graph Tree prim picker for the emitter, and an On toggle that drives the carrier’s visibility (the delegate ignores moonray:on — a light’s on/off is visibility plus intensity).

The critical, non-obvious requirement: the emitter must be invisible / out of the render layer, or MoonRay refuses it (“cannot be referenced … in Layer” → “MeshLight did not load”). The node always hides it.

Status: working and user-verified emitting in the Solaris viewport. Open items — the standalone variant isn’t rebuilt with the change yet (so farm hd_render / RDL export don’t have it), and moving the emitter live doesn’t re-sync the running render (the delegate never marks the referencing light dirty when its emitter changes).

The same piggyback should extend to the nine light filters, since the delegate already reads moonray:class for filters and stock UsdImaging images a base UsdLuxLightFilter. Procedurals are the one case with no workaround — no native carrier to piggyback on — so those genuinely need the adapters ported.


The debugging technique that paid for itself

Set one environment variable and the delegate dumps the exact RDL2 scene it built:

export HDMOONRAY_RDLA_OUTPUT=/tmp/dump.rdla

This turns “why does it look wrong?” into a diff. The case that made it: husk CLI renders of a subdivided head (DwaSkinMaterial SSS + 8K displacement) looked flatter and waxier than the Solaris viewport, as if SSS wasn’t contributing. Both used the same RenderSettings LOP.

Dumping RDL from both paths and diffing showed it was scene translation, not render settings — two independent causes:

  1. Husk’s default complexity had been OOM-killing the render (mesh_resolution 256 on a catmullClark cage), so --complexity 0 had been added as a workaround. That overshoots: refineLevel 0 makes the delegate disable subdivision entirely — raw faceted cage, displacement applied at cage vertices only.
  2. Husk defaults meshes to force single-sided, while the viewport .ds defaults doubleSided=1 → force two-sided. Backlit ear translucency needs those backfaces.

Fix: HDMOONRAY_DOUBLESIDED=1 and --complexity 1. The filtered RDL geometry sections then came out byte-identical to the viewport dump, and the visual match was confirmed viewport ↔ MPlay ↔ EXR. ~63 s, 1.3 GB peak.

One caveat: the env var works for husk but not the Solaris viewport, where the hip’s saved sceneviewrenderopts overrides it — set the viewport’s “Rdl output” field manually instead.

Two upstream-worthy gaps fell out of this: the delegate’s default mesh_resolution of 256 is pathological for subd and should adapt or cap, and there’s no scene-level tessellation control anywhere in the MoonRay RenderSettings tab — you’re stuck between husk’s global --complexity and per-mesh moonray:mesh_resolution.


From patches to forks

For a while the work lived as uncommitted edits plus .patch files in a separate “managing” repo. That model collapses under its own weight around the point where you have delegate changes, HDA changes, .ds changes, and CMake changes in flight simultaneously.

The migration: three personal forks (openmoonray, hdMoonray, moonray_dcc_plugins), all on one branch, with the other 17 submodules tracking upstream and MoonRay core unforked.

The one trap: .gitmodules must use absolute URLs. Upstream uses relative ones (../hdMoonray), which resolve against your fork’s origin and 404 for the 17 repos you haven’t forked. After the rewrite, a clean recursive clone resolves all 19 submodules with zero 404s and no patch step.

Reconciling the trees also caught genuine undocumented drift (the scene-object reference work existed only in the build tree), which is its own argument for committing source instead of shuffling patches.

The from-scratch sign-off

Then I did the thing everyone skips: wiped installs/, build/, build-deps/, and ran the documented bootstrap from zero.

It found five defects on the happy path, none of which the working tree had ever exercised:

  1. A NOUSD → NO_USD typo across the docs. CMake silently ignores unused variables, so the deps build cheerfully installed USD 22.11 anyway — recreating exactly the stale-USD condition that breaks Houdini linkage.
  2. The MicroHttpd autotools fix was documented but never actually committed.
  3. The build wrapper hard-required installs/include/pxr — which a correct NO_USD build never creates. The wrapper errored on its own happy path.
  4. An Xcode dependency cycle (EnvLight ↔ moonray_rendering_pbr_tests) failed the full build; tests aren’t needed for the render path, so BUILD_TESTING: OFF.
  5. The render helper broke under macOS’s stock bash 3.2 (set -u plus an empty array expansion), and shipped without its execute bit.

Verified end-to-end afterwards via all three paths: husk sign-off render, interactive Houdini, and CLI. 26 deps, 163 shader .so, 175 shader_json.

One operational lesson: the deps ExternalProject build cannot be incrementally resumed — git-based deps re-run their update step on every cmake --build and cascade a rebuild downstream, which defeats stamp-touching. Run it uninterrupted, in one pass.


Porting to a headless Rocky 9 render server

Same branch, same delegate source, Houdini 20.5.939 / Python 3.11 / USD 24.3 — but rooted at /opt/MoonRay, no Qt, no CUDA, no OptiX, CPU only.

Only two Linux-specific problems, and both are the same shape as the macOS pxr-header dance: Houdini shadowing system libraries.

Houdini on every shell’s LD_LIBRARY_PATH. The box installs Houdini via /etc/profile.d/houdini-20.5.*.sh, which forces /opt/hfs*/dsolib into every login shell. Houdini’s bundled libcurl (dead CA path) and libtiff (unversioned symbols) shadow the system ones — GitHub fetches fail during the deps build, and OpenImageIO fails to link. The fix is a build-env.sh that scrubs Houdini out of the shell before every build step. Nothing MoonRay builds needs it there: the deps are NO_USD, and husk plus the delegate resolve their libraries via RUNPATH.

OpenVDB. Rocky gets most dependencies from dnf, but OpenVDB has to be source-built into installs/ (like macOS) for the Houdini build, otherwise Houdini’s bundled OpenVDB 11 headers shadow dnf’s 9.1 and you get a link mismatch.

The payoff of a CPU-only preset: MOONRAY_USE_OPTIX=NO ⇒ HDMOONRAY_NO_OPTIX ⇒ the delegate auto-selects OIDN and never reaches the OptiX abort. Verified by deliberately enabling denoise without specifying the engine and confirming no "Optix mode not supported in this build" — which is exactly what the three-part denoise fix from before was for.

Render-verified end to end, including OIDN on CPU, 2026-07-21.

One helper script, two operating systems

The husk wrapper is a single cross-OS script installed to $OMR/bin/render-usd. It picks OMR/HFS by uname, scrubs Houdini’s dsolib on Linux, hard-codes the renderer and --complexity 1 (the OOM guard from Part 5), and passes everything after the input file straight through to husk. A sibling render-denoise wraps MoonRay’s denoiser in -mode oidn, the only mode available on a GPU-less server.

$OMR/bin/render-usd scene.usd --frame 1 --output /tmp/out.exr
$OMR/bin/render-denoise beauty.exr denoised/beauty.exr

What’s still open

  • Light filters (9 shaders) via the same carrier piggyback as mesh lights.
  • Porting hdMoonrayAdapters to USD 24.3 — the only route to procedurals, and the cleanup that would retire the whole dual-USD split.
  • The Houdini-variant hd_render CLI links both USDs (Python 3.9 and 3.11) and aborts on double-registered Tf debug symbols. Workaround: use the standalone hd_render.
  • Automatic .tx conversion via a custom ArResolver, so textures just work.
  • Display filter .ds files (18 shaders) — possibly intentional, needs verifying.
  • Delegate-side subd defaults — mesh_resolution 256 is a footgun for catmullClark, and there’s no scene-level tessellation control.
  • Houdini 21 — the upgrade path is scoped (paths, libpxr_python replacing libhboost_python311), not yet taken.
  • Deadline intergration.
  • Houdini Version not hard coded O_o.

Several of these are platform-independent and upstream-worthy: the denoise engine wiring, the long long conversions, the scene-object reference support, the subd defaults. The macOS-specific guards are not.


Deets

  • Fork: github.com/llienofdoom/openmoonray (branch macos-houdini), plus forks of hdMoonray and moonray_dcc_plugins.
  • Upstream: Open MoonRay , now under the Academy Software Foundation (moved from dreamworksanimation in 2026).
  • macOS: M1 Pro, Sequoia 15.7.4, Xcode 16.4, Houdini 20.5.939.
  • Linux: Rocky 9, 8 cores, headless, Houdini 20.5.939.