Skip to content

Latest commit

Β 

History

346 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Crust Render


A physically-based path tracer written in 100% safe Rust (edition 2024, forbid(unsafe_code) on every crate but crust-core, which is deny so that one test-only counting allocator β€” a GlobalAlloc, which cannot be implemented safely β€” can opt out explicitly). It loads scenes directly from USD β€” including production-scale assets such as Disney Animation's Moana Island dataset β€” and implements its own watertight ray/triangle kernel, SBVH acceleration structure, OpenPBR ΓΌbershader, MaterialX graph reader, volumetric integrator and Practical Path Guiding, with no dependency on Embree, OpenPGL, or any existing renderer core. It is an independent, single-author project rather than a production renderer: the architecture and formulas are informed by PBRT, Ray Tracing in One Weekend, Autodesk Standard Surface / OpenPBR and the published Embree/OpenPGL papers, but every kernel, material model and importer here is a from-scratch implementation, and the known limitations β€” no GPU path, no deformation motion blur, no OpenVDB import β€” are documented rather than hidden. See docs/embree_comparison.md for a detailed, feature-by-feature comparison against Embree's intersection kernels.

πŸ“Έ Preview

preview


✨ Features

  • βœ… Physically-Based Path Tracing
    • One material to rule them all: the OpenPBR ΓΌbershader (diffuse, metal, glass, coat, fuzz, thin-film, subsurface, emission)
  • πŸ” Recursive Ray Scattering with depth control
  • πŸ’‘ Multiple Light Sources
    • Emissive materials
    • Light sampling & MIS (Multiple Importance Sampling) with selectable strategy: power / balance heuristic, or light-only / bsdf-only for diagnosis (samples/veach_mis.usda is the classic comparison scene)
  • βš™οΈ Material System
    • Trait-based (Material), with OpenPBR as the single surface shader
    • Microfacet GGX BRDF with Fresnel and geometry terms
    • Rust-side presets: OpenPBR::diffuse / metal / glass / glossy
  • 🧩 MaterialX (.mtlx) look-dev graphs, read directly β€” standalone BSDF nodes composed with layer/mix, textured through UV/UDIM image sets and tangent-space normal maps, reduced onto OpenPBR at every shading point, plus uniform_edf emission an image can drive (the one input that uses an HDR texture's range, since an albedo above 1 creates energy and is clamped)
  • 🧠 Importance Sampling
    • Supports BRDF- and light-based sampling
  • 🧭 Path Guiding (opt-in)
    • Pure-Rust Practical Path Guiding (SD-tree), one-sample MIS with the BSDF
  • ⚑ Adaptive Sampling
    • Pixels stop early once their relative standard error drops below crust:varianceThreshold (after crust:minSamplesPerPixel samples)
  • 🌫️ Volume Rendering
    • Free-standing smoke/fog/absorption/fire volume regions (homogeneous, procedural fBm noise, or an inline voxel grid), with NEE + MIS at scatter vertices and transmittance-aware shadow rays
  • πŸ§ͺ Modular Design
    • Clean separation between renderer, integrator, materials, scene
  • Owen-Scrambled Sobol Sampling
    • Per-pixel decorrelated low-discrepancy sampling (Burley 2020) for camera, light, and BSDF rays

πŸš€ Getting Started

πŸ”§ Build and Run

Scenes are loaded from USD (.usda, .usdc, .usdz) via the pure-Rust openusd crate. Camera, geometry, lights, materials, and render settings all live in the USD stage.

# render a bundled sample
cargo run --release -- -i samples/openpbr_showcase.usda -o out.exr
cargo run --release -- -i samples/cornellbox.usda -o cornell.exr

# run with no scene β†’ hard-coded procedural fallback
cargo run --release

πŸ“ Geometry & acceleration

All intersection lives in the crust-rt kernel crate, behind an Embree-shaped API (Geometry β†’ SceneBuilder β†’ commit() β†’ intersect/occluded, ID-based hits β€” swappable for Embree bindings behind the same seam, in 100 % safe Rust). Meshes triangulate with a watertight ray/triangle test (Woop et al. 2013 β€” no pinholes along shared edges) and build a BVH4: a parallel, deterministic, reference-based SAH build with SBVH spatial splits (Stich et al. 2009), collapsed into 4-wide SIMD nodes whose slab tests run on Vec4 lanes. Shadow rays use a dedicated early-exit occlusion query (the rtcIntersect/rtcOccluded split). Mesh geometry is instanced only when it is actually reused: prims sharing points/topology/material share one triangle BVH under an instance transform, while geometry placed exactly once is baked into world space so its triangles sit directly in the top-level BVH. Instancing a single placement buys no sharing and costs every entering ray a transform plus a cold descent into a second tree β€” dropping it took instance descents from 3.85 to 0.13 per camera ray on samples/cornellbox.usda. UsdGeomBasisCurves import as round curve segments (sphere-swept cones; cubic bezier/bspline/catmullRom spans flatten to polylines) β€” see samples/curves.usda. Two per-prim extras:

  • crust:motion:translate = (x, y, z) β€” transform motion blur: the prim streaks through that world-space translation over the shutter (samples/motionblur.usda).
  • crust:rayMask = <int> β€” ray visibility mask (bit 0 camera, bit 1 shadow, bit 2 indirect): e.g. 6 is a shadow-caster hidden from the camera.

🎨 Materials

Every UsdGeomMesh / UsdGeomSphere binds a UsdShadeMaterial via MaterialBindingAPI. The bound Shader is resolved by its info:id:

  • info:id = "UsdPreviewSurface" β†’ mapped into OpenPBR (diffuseColor β†’ baseColor, metallic β†’ baseMetalness, roughness β†’ specularRoughness, opacity β†’ geometryOpacity, emissiveColor β†’ emissionColor, ior β†’ specularIor, clearcoat β†’ coatWeight, clearcoatRoughness β†’ coatRoughness). Portable across DCC apps.
  • info:id = "crust:openpbr" β†’ decodes the full OpenPBR surface 1:1. Every input is the camelCase mirror of the Rust field name (baseColor, subsurfaceRadiusScale, geometryThinWalled, …). Non-portable but lossless. See samples/openpbr_showcase.usda for the seven-preset reference scene.

Unbound geometry falls back to a grey diffuse OpenPBR.

🧩 MaterialX

materialx

The two DPEL MaterialX assets from NVIDIA β€” Teapot and Lion β€” rendered from their shipped .mtlx graphs (samples/materialx_showcase.usda).

A Material prim can be nothing but a reference into a MaterialX document:

def Material "TeapotCeramic" (
    prepend references = @Looks/teapot_ceramic_ldX.mtlx@</MaterialX/Materials/surfacematerial_teapot_ceramic>
)
{
}

USD normally resolves that through a MaterialX file-format plugin, which the pure-Rust openusd does not have β€” so the prim composes empty and would fall back to grey. Crust reads the .mtlx itself, in the standalone crust-mtlx crate (no renderer dependency, just an XML parser and glam):

  • the document parses to a name-addressed node graph and is compiled once into a slot-indexed program β€” ~30 pattern node types (image, tiledimage, normalmap, mix, remap, contrast, artistic_ior, …) β€” that runs per shading point with no name lookups and no allocation;
  • the BSDF half β€” standalone oren_nayar_diffuse_bsdf / dielectric_bsdf / conductor_bsdf / sheen_bsdf nodes glued with layer and mix, which is how production look-dev is authored β€” is flattened into weighted lobes at compile time and pooled onto OpenPBR's lobe stack at shading time, so sampling, MIS and energy compensation stay in the one ΓΌbershader. A dielectric layered over another specular (a glaze over a satin glaze, a varnish over metal) becomes OpenPBR's coat β€” the second specular lobe β€” with its own roughness and IOR; only deeper stacks average;
  • image nodes resolve through the AssetLoader seam to UV/UDIM textures (primvars:st, <UDIM> tile sets, per-input colour space from the graph's own colorspace attribute) with a tangent frame for normal maps; the host decoder in crust-assets caps tile resolution (CRUST_TEX_MAX, default 1024 β€” the teapot's ceramic alone is 2.7 GB at full resolution) and keeps a trilinear mip pyramid below that cap, selected per hit by the ray cone's footprint. CRUST_TEX_STREAM=1 swaps that whole path for a streaming tile cache instead: textures pre-converted to a tiled, mip-mapped .tx (OIIO's format, read and written natively) are paged in a 64x64 tile at a time under a byte budget, so memory stops tracking the scene's texture footprint and the resolution cap stops being needed at all. A .tx is backed by a tiled TIFF for 8-bit sources and by a tiled, mip-mapped OpenEXR for float ones β€” the backing is picked by magic number rather than extension, and the choice follows the source's actual range, so an HDR texture keeps values above 1.0 that a u8 tile would clip.

The shipped DPEL documents address UDIM sets as Albedo.<UDIM>.png β€” a bare < inside an attribute value, which is not well-formed XML. MaterialX's own reader tolerates it; crust escapes the token before parsing, since rejecting the document would mean no material at all rather than a wrong path.

Samples: samples/materialx_basic.usda is a self-contained fixture (20 KiB of textures, what the tests run against); materialx_teapot.usda, materialx_lion.usda and materialx_showcase.usda are shot layers for the DPEL assets, which are not checked in β€” download MaterialXTeapotLion first. cargo run --release -p crust-render --example mtlx_shade -- file.mtlx prints the OpenPBR parameters a graph reduces to at a given (u, v) β€” the way to check a MaterialX surface, since a wrong colour-space decode still renders as a plausible surface.

πŸ’‘ Lights

UsdLuxSphereLight maps to an Emissive sphere + an AreaLight over the same surface. UsdLuxRectLight maps to two emissive triangles + an AreaLight (local XY plane, emitting along -Z per UsdLux; effectively one-sided) β€” see samples/rectlight.usda. Following the industry convention (Arnold, RenderMan, Karma), a light's source geometry is invisible to camera rays by default β€” park lights inside the frame without them showing up β€” while shadow and indirect rays still see it, so occlusion and reflections of lights are unchanged. Author custom bool crust:light:cameraVisible = 1 on the light prim to render the source itself (the classic Cornell-box look), or author crust:rayMask for full per-category control (it wins outright when present). See samples/light_visibility.usda for all three spellings. UsdLuxDistantLight and UsdLuxDomeLight import as infinite lights with no scene geometry; DiskLight and CylinderLight warn once and are skipped β€” follow-up work.

🌫️ Volumes

Any prim carrying crust:volume:type imports as a free-standing VolumeRegion β€” an oriented box, outside the surface BVH so it never occludes shadow rays β€” with a homogeneous, smoke (procedural fBm noise), or grid (inline voxel data) density field and its own Οƒβ‚›/σₐ/anisotropy/ emission. Scatter vertices inside a region get NEE with MIS against the phase function, and shadow rays attenuate through volumes via ratio/Beer- Lambert transmittance. See samples/fog.usda (homogeneous god rays) and samples/smoke.usda (noise plume + emissive ember + explicit grid).

πŸŽ₯ Camera & render settings

UsdGeomCamera provides focalLength / horizontalAperture / verticalAperture / fStop / focusDistance plus the ancestor Xform stack. UsdRenderSettings provides resolution; per-render params live in the crust: namespace as custom attrs:

def RenderSettings "settings" {
    int2 resolution = (640, 360)
    int crust:samplesPerPixel = 128
    int crust:maxDepth = 32
    int crust:minSamplesPerPixel = 32
    float crust:varianceThreshold = 0.05
    int crust:frame = 0
    bool crust:pathGuiding = false
    int crust:guidingTrainIterations = 8
    float crust:guidingProb = 0.5
    token crust:samplingStrategy = "power"   # power | balance | light | bsdf
    token crust:pixelFilter = "triangle"     # box | triangle | gaussian | blackman | mitchell
    float crust:pixelFilterRadius = 1.0      # pixels from the pixel center
}

Missing attrs fall back to sensible defaults (128 spp, 32 depth, 640Γ—360, guiding off, triangle filter at radius 1).

The pixel filter reconstructs the image from the samples: triangle (the default), gaussian and blackman trade a little sharpness for smoother edges and less pixel-to-pixel noise, mitchell sharpens with negative lobes (may ring next to hard contrast), and box at radius 0.5 is the classic one-sample-per-pixel-footprint jitter β€” bit-identical to renders from before filtering existed. Each filter has its own default radius (box 0.5, triangle 1, gaussian/blackman 1.5, mitchell 2); crust:pixelFilterRadius overrides it. Filtering is applied by filter importance sampling β€” sample positions are drawn from the filter's own distribution β€” so it costs nothing per sample and adaptive sampling keeps working per pixel.

🧭 Path guiding

An opt-in, pure-Rust implementation of Practical Path Guiding (MΓΌller et al. 2017) β€” the SD-tree algorithm family that Intel's OpenPGL generalizes, reimplemented natively so the renderer stays dependency-light and 100% safe Rust. The renderer learns a spatio-directional distribution of incident radiance (a binary spatial tree over the scene whose leaves hold adaptive directional quadtrees) over progressive training passes with geometrically growing budgets (1, 2, 4, … spp), then renders the final image by one-sample MIS: each secondary bounce draws its direction from the learned distribution with probability crust:guidingProb and from the BSDF otherwise, dividing by the mixture pdf.

Enable it per scene with bool crust:pathGuiding = true on the RenderSettings prim. crust:guidingTrainIterations controls how many training passes run before the final pass (their total cost is 2^iterations βˆ’ 1 spp β€” not wasted: every pass is blended into the final image weighted by inverse variance, so the training budget contributes at equal total spp). Guiding pays off on scenes where light is hard to find by chance β€” the bundled samples/cornellbox_guided.usda hides its only light behind a shroud so all transport is multi-bounce, and guiding cuts MSE against a converged reference by ~20% at equal final spp:

cargo run --release -- -i samples/cornellbox_guided.usda

Every continuous lobe is guided β€” including refraction: thick glass uses a real Walter et al. 2007 microfacet BTDF with a proper VNDF-based pdf, so the guiding field can learn and sample directions straight through it. Dispersion is continuous too β€” each RGB channel refracts with its own IOR (one channel's IOR sampled uniformly, three per-channel BTDF evaluations with a channel-averaged mixture pdf), so dispersive glass joins the NEE and guiding mixtures instead of being a hero-wavelength delta lobe. Only thin-walled transmission (a genuinely singular lobe) and volume scattering are excluded; untrained regions fall back to plain BSDF sampling, so the estimator stays unbiased everywhere.

🎯 Multiple importance sampling

Direct lighting is estimated by two strategies at once β€” light sampling (next-event estimation) and BSDF sampling β€” combined with a Veach MIS heuristic. Neither strategy works everywhere: light sampling collapses on near-mirror surfaces (the sampled direction almost never lands inside the narrow lobe), BSDF sampling collapses on rough surfaces lit by small lights (the sampled lobe almost never hits the light). MIS weights each sample by how well its strategy could have produced it, so every regime stays clean.

crust:samplingStrategy (or the --strategy CLI override) selects how the two sides combine:

  • power β€” Ξ²=2 power-heuristic MIS, the default
  • balance β€” balance-heuristic MIS
  • light β€” light sampling only (NEE at full weight, bounce-hit emission dropped)
  • bsdf β€” BSDF sampling only (no shadow rays, bounce-hit emission at full weight)

All four are unbiased; they differ only in variance. light and bsdf exist to visualize what MIS balances between, after Veach's classic scene:

# four glossy plates (roughness 0.01 β†’ 0.25) Γ— four sphere lights of equal
# power (radius 0.05 β†’ 1.35) β€” render one strategy at a time and compare
cargo run --release -- -i samples/veach_mis.usda -o veach_light.exr --strategy light
cargo run --release -- -i samples/veach_mis.usda -o veach_bsdf.exr  --strategy bsdf
cargo run --release -- -i samples/veach_mis.usda -o veach_mis.exr   --strategy power

Light-only renders the rough plates cleanly but leaves the smooth plates' reflections dark and firefly-ridden; bsdf-only is the exact mirror image; MIS matches the cleaner of the two everywhere.

veach

Moana Benchmark

moana

Disney Animation's Moana Island scene is the industry's standard stress test for production renderers β€” 20 heavily-instanced elements (foliage, ocean, terrain, dressed sets) totaling billions of triangles once instances are expanded. Crust imports usd/island.usda directly, with no preprocessing, flattening, or format conversion, and renders it end to end.

Measured numbers from that import (see CLAUDE.md for the full breakdown):

  • 3,151,850 geometries composing to 21,904,388 top-level BVH primitives, importing in ~6m18s (of which ~4m45s is USD traversal) at a ~47.6 GiB peak.

  • ~57.7 M unique top-level triangles across the 20 elements β€” the largest being the ocean (osOcean, 15.6 M), coral (isCoral, 14.5 M) and the two mountains (6.7 M / 6.4 M triangles) β€” with instancing (native instanceable prims and PointInstancers, nested to arbitrary depth) reusing shared geometry rather than duplicating it, which is what keeps memory bounded on a dataset this size.

  • A streaming importer: rather than composing the whole USD stage at once, the importer opens a cheap index stage, then composes and drops one masked stage per top-level subtree. This bounds peak composition to roughly one element at a time instead of the whole island β€” 117.10 GiB / 13:20 β†’ 43.76 GiB / 09:19 measured on this scene, with pixel-identical output.

  • Ptex per-face texturing over the island's 2,576,238 texture faces, mip-capped by default to keep memory tractable: 4.58 GiB at the default 32Γ—32 cap versus 494 GiB if every face loaded at its authored full resolution. Each face carries a mip pyramid below that cap, so the cap is a memory ceiling rather than an accidental anti-aliaser β€” and can therefore come down: 16Γ—16 plus a full pyramid is around 2.45 GiB, under half the default, and filters better at distance.

  • Streaming Ptex, which replaces that cap: the pyramid stays on disk and one tile of one level of one face is paged in behind a bounded cache, so memory scales with the cache rather than with the asset. Measured at 640Γ—360 / 8 spp against the same build preloading: Ptex residency 5.98 β†’ 0.61 GiB, Load assets 01:40.7 β†’ 27.3 s, traverse-phase RSS 47.34 β†’ 41.48 GiB, peak RSS 51.28 β†’ 47.08 GiB, and Render costs +1.2% β€” the whole run finished 69 s sooner. It is opt-in, and on this scene it takes two environment variables rather than one:

    CRUST_PTEX_STREAM=1 CRUST_PTEX_STREAM_MIPSPACE=file CRUST_PTEX_CACHE_MB=2048 \
        cargo run --release -- -i usd/island.usda --stats

    CRUST_PTEX_STREAM_MIPSPACE=file is the one that is easy to miss, and without it nothing streams. A .ptx's stored mip levels were reduced in the file's own display encoding, while the preloaded pyramid is reduced in linear light β€” the same mismatch crust:mipspace refuses for streamed UV textures, and refused here for the same reason: level 0 stays perfectly correct and only minification is wrong, so it reads as a filtering bug rather than a colour one. So a mipmapped .ptx is declined and preloaded by default, which on the island means all of them. =file accepts the file's chain (darker under minification, up to 0.147 on the tiled test fixture) and the residency above; --stats prints which backend actually ran either way. See docs/ptex_streaming.md.

  • Two UsdLuxDomeLight environment textures authored on the stage (a modeling choice in the source asset, not a crust limitation) currently both decode and both light the scene, peaking at ~11 GiB for that pair alone β€” the first lever to pull if memory is tight is disabling the inactive one (sky_dome_cam_llc).

This is a correctness and scalability benchmark, not a performance claim: the point is that a hand-written, dependency-light Rust importer and renderer can open, resolve material bindings and instancing for, and render a real production dataset of this size without special-casing it.

CLI

cargo run --release -- -i scene.usda   # input USD scene (.usda/.usdc/.usdz)
    -o out.exr                         # output EXR (+ tone-mapped PNG next to it)
    -s 256                             # override samples per pixel
    -f 1012                            # USD time code (frame) to render
    --strategy power                   # power | balance | light | bsdf
    --filter gaussian                  # box | triangle | gaussian | blackman | mitchell
    --filter-radius 1.5                # filter radius in pixels
    -b                                 # bucket (16Γ—16 tile) rendering
    -l debug                           # log level

Known limitations

Documented gaps rather than silent ones β€” see CLAUDE.md's "Known incomplete work" for the full, per-feature detail and workarounds:

  • No GPU path. Everything runs on the CPU, parallelized with Rayon; there is no wavefront/GPU renderer and no coherent ray-packet traversal.
  • SIMD stops at 128 bits. BVH traversal and triangle packets use SSE2/NEON-width vectors (glam); reaching AVX2/AVX-512 in safe, portable Rust would need std::simd (nightly-only) or unsafe intrinsics, so it is deliberately not done.
  • Motion blur is transform-only. Linear matrix lerp per instance; no deformation (per-vertex) blur and no quaternion-correct rotation blur.
  • No OpenVDB / UsdVolVolume import. Volumes are homogeneous, procedural noise, or an inline voxel grid authored directly in USD.
  • Texture filtering is isotropic. Minification is filtered β€” ray cones give each hit a footprint, and both the UV and Ptex paths read trilinear mip pyramids from it β€” but the filter has no direction, so a chart stretched in one axis over-blurs at grazing angles where an EWA or ripmap filter would not. Cone spread also ignores surface curvature and the lens aperture.
  • Streamed Ptex cannot build its mip chain in linear light. Ptex streams now β€” ptex-rs grew the PtexCache equivalent this needed, so the cache is the reader's rather than a second one here β€” but a level read off disk was reduced in the file's own encoding, where the preloaded pyramid is reduced in linear light. That is refused rather than shipped quietly, so a mipmapped .ptx preloads unless CRUST_PTEX_STREAM_MIPSPACE=file opts into the file's chain (see the Moana section above). Retiring the gate needs the reduction to happen in the reader against a declared working space; doing it here would mean a second pyramid cache and a full-resolution read to answer a coarse lookup.
  • An HDR texture's range now reaches the film, through emission. MaterialX's edf is read, so a uniform_edf driven by an image drives emissionColor, and nothing between the texture and the film clamps it. On samples/materialx_emissive.usda the same frame preloaded and streamed differs by exactly 15.0 at most β€” the fixture's authored 16.0 against the 8-bit path's clamp at 1.0. What remains: base colour above 1 is still clamped, correctly (an albedo above 1 creates energy; a radiance above 1 is just a bright light), the preloaded decoder still narrows to 8 bits so HDR needs CRUST_TEX_STREAM=1 and a converted .tx, and an emissive MaterialX surface is not a light-list entry β€” it is found by BSDF sampling only, like emissive curves and volumes. Dome lights were never affected: that path is f32 end to end.
  • Only uniform_edf among MaterialX's EDFs. conical_edf, measured_edf and generalized_schlick_edf are directional distributions and crust's emitter is uniform, so they are refused and reported rather than approximated into a plausible glow at the wrong intensity.
  • MaterialX layering caps at two stacked specular interfaces; a third dielectric layer is averaged into the coat rather than kept distinct, and MaterialX transmission nodes have no glass lobe equivalent yet.
  • Path guiding covers surfaces only β€” no volume/phase-function guiding, and it trains on luminance rather than a chromatic distribution.
  • Some USD light types (DiskLight, CylinderLight) and material inputs (subsurface*/specularTint on PxrDisneyBsdf) are read and warned about rather than mapped.

Releases

Packages

Used by

Contributors

Languages