Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 68 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,25 @@ Logging uses `tracing`; set verbosity with `-l debug|info|warn|error|trace` (def

## Workspace layout

Four crates under `crates/`:
Five crates under `crates/`:

- **`crust-core`** — the whole engine as a library (`crust_core`): renderer, integrator,
materials, primitives, lights, BVH, USD import. Everything of substance lives here.
- **`crust-rt`** (lib name `crust_rt`) — the intersection kernel, factored out the way
`openqmc-rs` was, behind a deliberately **Embree-shaped API**: `Geometry` values
(triangle meshes with optional per-vertex shading normals, analytic spheres, round
curve segments, single-level `Instance`s with transform motion blur) attach to a
`SceneBuilder` with per-geometry visibility masks; `commit()` builds the acceleration
structure; `Scene::intersect`/`Scene::occluded` mirror `rtcIntersect1`/`rtcOccluded1`.
Hits are plain `Copy` `RayHit`s carrying `geom_id`/`prim_id` — the kernel never sees
materials. Instanced hits report the *instance's* top-level `geom_id` with the inner
`prim_id`. Internals: watertight Woop-2013 triangles, rounded-cone curves, and the
parallel deterministic SBVH build collapsed to BVH4 (details below). Depends only on
glam + rayon; deliberately swappable for Embree bindings behind the same seam.

- **`crust-core`** — the engine as a library (`crust_core`): renderer, integrator,
materials, lights, volumes, path guiding, USD import — everything above the
intersection layer, which it consumes from `crust-rt` through `rt_world.rs`
(`WorldBuilder`/`World`: kernel geometries paired with a `geom_id`-indexed
material table).
UI-free by design: no progress-bar or image-encoding dependencies; progress is
reported through a `ProgressCallback`, and fallible entry points return
`crust_core::Error` instead of exiting.
Expand Down Expand Up @@ -156,14 +171,30 @@ material types, `simple_scene`, `get_settings`). Prefer importing from `crust_co

## Core traits (extension points)

- **`Hittable`** (`hittable.rs`) — `hit(ray, t_min, t_max) -> Option<Hit>` + `bounding_box()`.
`HitRecord` is `Copy` geometry only (point, normal, `t`, `front_face`); `Hit` pairs it with
a **borrowed** `&dyn Material`, so traversal never touches an `Arc` refcount. Implemented by
`Sphere`, `Triangle`, `SmoothTriangle`, `Mesh`, `HittableList`, `Bvh`. Rendering uses a
**two-level BVH**: `Renderer::new` builds a top-level `Bvh` (`bvh.rs`) over the scene's
`HittableList`, and each imported mesh carries its own nested `Bvh` over triangles
(`usd_import.rs`). `Bvh` is a flat node array with binned-SAH splits and iterative
traversal — deterministic for a given scene.
- **Geometry & intersection** — there is no `Hittable` trait anymore: all intersection
lives in the **`crust-rt`** kernel crate (see the workspace layout), and crust-core
talks to it through `rt_world.rs`: a `WorldBuilder` pairs every attached
`rt::Geometry` with its `Arc<dyn Material>` (`attach(...) -> geom_id`), and the
committed `World` resolves kernel hits back to materials **by `geom_id`** —
`World::intersect` returns a `WorldHit { rec: HitRecord, mat, geom_id, prim_id }`,
`World::occluded` is the shadow-ray early-exit query (NEE never uses closest-hit).
`HitRecord` (`hittable.rs`) remains the material-facing `Copy` hit geometry (point,
ray-facing normal, `t`, `front_face`). Inside the kernel: `Sphere`,
triangles with one shared **watertight** intersector (Woop et al. 2013 — dominant-axis
shear, 2D edge functions with f64 fallback on exact-zero ties; no pinholes along
shared edges), `RoundCurves` (sphere-swept cones for hair), and `Instance` (a
committed inner `rt::Scene` placed by a transform — rays transform into local space
with unnormalized direction so `t` carries over, normals map back by
inverse-transpose; an optional end-of-shutter transform lerps per-ray for motion
blur). Per-geometry masks gate intersection on `ray.mask` (`MASK_*` consts,
re-exported from the kernel). The build is reference-based **SBVH** (binned object
SAH + spatial splits gated by the α-overlap test, references clipped via exact
Sutherland-Hodgman for triangles and duplicated across children), runs subtrees in
parallel via `rayon::join` above 4096 refs, is **deterministic** (input-only
decisions, pinned by a build-twice test), and is then **collapsed to BVH4**: 4-wide
SoA nodes whose slab tests run on `Vec4` lanes (`safe_inv` keeps zero-direction
components NaN-free; closest-hit traversal orders lanes near-to-far, occlusion
traversal early-exits).
- **`Material`** (`material/material.rs`) —
`scatter_importance(r_in, rec) -> Option<ScatterSample>` used by the integrator
(`ScatterSample.delta` marks singular lobes like transmission: never mixed with a
Expand All @@ -183,10 +214,10 @@ material types, `simple_scene`, `get_settings`). Prefer importing from `crust_co
- **`Light`** (`light.rs`) — `sample_point`/`pdf`/`emission`/`material`. The one
implementation is **`AreaLight`**: a `LightShape` (pure emitting geometry —
`SphereShape`, `RectShape`) paired with the `Arc<Emissive>` its scene geometry carries.
Lights are stored in a `LightList` and their surfaces are also added to `world` as
Lights are stored in a `LightList` and their surfaces are also attached to `world` as
emissive geometry (Cornell-box semantics: a light is both light and visible object) —
sharing one `Emissive` Arc, which is how the integrator attributes a bounce-hit
emissive surface to its light (`LightList::find_by_material`, address identity).
the `AreaLight` records the geometry's `geom_id`, which is how the integrator
attributes a bounce-hit emissive surface to its light (`LightList::find_by_geom`).
**NEE samples one light per vertex** (uniform pick), so the light strategy's MIS
density is `light.pdf / n_lights` — the bounce side evaluates the exact same
expression for the light it hit; keep the two sides identical or emission is
Expand All @@ -212,7 +243,19 @@ The only scene format. `load_scene` opens the stage, imports `RenderSettings` fi
camera needs the aspect ratio), then traverses prims with an explicit stack that bakes the
Xform hierarchy into world matrices. Schema mapping:

- `UsdGeomMesh` → world-baked triangles wrapped in a `BVHNode`; `UsdGeomSphere` → analytic `Sphere`.
- `UsdGeomMesh` → a *local-space* committed `rt::Scene` (one `TriangleMesh` geometry)
placed by an `rt::Geometry::Instance`; prims with identical points/topology/material
share one kernel scene (content hash + memoized material Arcs, so binding paths
compare by pointer). Non-invertible transforms fall back to world-space baking.
`UsdGeomSphere` → analytic `Sphere` geometry.
- `UsdGeomBasisCurves` → an instanced `rt::Geometry::RoundCurves` batch: `linear` curves
directly, `cubic` (bezier | bspline | catmullRom) flattened at 8 samples per span; widths
(USD diameters) resolve per-vertex / per-curve / constant by array length.
- Any geometry prim may author `crust:rayMask` (int; bit 0 camera, bit 1 shadow, bit 2
indirect — default all) to hide from ray categories, and `crust:motion:translate`
(float3, world-space) to streak through that translation over the shutter (transform
motion blur; primary rays draw a `K_TIME` shutter sample and every secondary/shadow ray
inherits the path's time). Sample scenes: `samples/motionblur.usda`, `samples/curves.usda`.
- Materials resolve via `MaterialBindingAPI`, dispatched on the bound shader's `info:id`:
- `UsdPreviewSurface` → mapped into `OpenPBR` (portable; `diffuseColor→baseColor`,
`metallic→baseMetalness`, `roughness→specularRoughness`, etc.).
Expand Down Expand Up @@ -247,6 +290,16 @@ feature flag.

## Known incomplete work

- **Geometry/acceleration caveats.** Motion blur is transform-only and lerps the *matrix*
linearly (no deformation blur, no quaternion motion — a large shutter rotation bows
slightly, but the union-of-endpoints bbox stays conservative). Curve import flattens
cubic spans to polylines (no exact cubic intersector) and lerps widths across a span in
parameter; the rounded-cone can report an interior sphere surface for rays *starting
inside* the hull (irrelevant for opaque hair). Mesh-BVH sharing needs identical
points/topology *and* material binding — `UsdGeomPointInstancer` and `instanceable`
composition arcs are not consumed. Emissive curves/instances are not light-list entries
(BSDF-sampled only, like emissive volumes).

- **Upstream `openusd` xformOp bug, worked around locally.** `openusd` 0.5.0 (latest as
of 2026-06) composes multi-op `xformOpOrder` stacks in the wrong order (the authored
translate comes back multiplied by the scale), which used to make
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@ cargo run --release -- -i samples/cornellbox.usda -o cornell.exr
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). Each mesh's
kernel scene is built in local space and **instanced**: prims sharing
points/topology/material share one triangle BVH under an instance transform.
`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
Expand Down
1 change: 1 addition & 0 deletions crates/crust-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ path = "src/lib.rs"
[dependencies]
utils = { path = "../utils" }
openqmc = { path = "../openqmc-rs", package = "openqmc-rs" }
crust-rt = { path = "../crust-rt" }
rayon = "1.10.0"
glam.workspace = true
tracing.workspace = true
Expand Down
90 changes: 4 additions & 86 deletions crates/crust-core/src/aabb.rs
Original file line number Diff line number Diff line change
@@ -1,87 +1,5 @@
use crate::ray::Ray;
use glam::Vec3A;
//! The axis-aligned bounding box now lives in the `crust-rt` kernel
//! crate; re-exported here for the modules (guiding, volumes) that use it
//! as a plain geometric type.

#[derive(Debug, Clone, Copy)]
pub struct AABB {
pub minimum: Vec3A,
pub maximum: Vec3A,
}

impl AABB {
pub fn new(minimum: Vec3A, maximum: Vec3A) -> Self {
AABB { minimum, maximum }
}

pub fn surrounding_box(box0: AABB, box1: AABB) -> AABB {
let small = Vec3A::new(
box0.minimum.x.min(box1.minimum.x),
box0.minimum.y.min(box1.minimum.y),
box0.minimum.z.min(box1.minimum.z),
);
let big = Vec3A::new(
box0.maximum.x.max(box1.maximum.x),
box0.maximum.y.max(box1.maximum.y),
box0.maximum.z.max(box1.maximum.z),
);
AABB {
minimum: small,
maximum: big,
}
}

pub fn hit(&self, ray: &Ray, mut t_min: f32, mut t_max: f32) -> bool {
for a in 0..3 {
let inv_d = 1.0 / ray.direction()[a];
let mut t0 = (self.minimum[a] - ray.origin()[a]) * inv_d;
let mut t1 = (self.maximum[a] - ray.origin()[a]) * inv_d;

if inv_d < 0.0 {
std::mem::swap(&mut t0, &mut t1);
}

t_min = t_min.max(t0);
t_max = t_max.min(t1);

if t_max <= t_min {
return false;
}
}
true
}

pub fn compare_x(a: AABB, b: AABB) -> std::cmp::Ordering {
a.minimum.x.partial_cmp(&b.minimum.x).unwrap()
}

pub fn compare_y(a: AABB, b: AABB) -> std::cmp::Ordering {
a.minimum.y.partial_cmp(&b.minimum.y).unwrap()
}

pub fn compare_z(a: AABB, b: AABB) -> std::cmp::Ordering {
a.minimum.z.partial_cmp(&b.minimum.z).unwrap()
}
}

pub fn triangle_aabb(v0: Vec3A, v1: Vec3A, v2: Vec3A) -> AABB {
let mut min = Vec3A::new(
v0[0].min(v1[0]).min(v2[0]),
v0[1].min(v1[1]).min(v2[1]),
v0[2].min(v1[2]).min(v2[2]),
);
let mut max = Vec3A::new(
v0[0].max(v1[0]).max(v2[0]),
v0[1].max(v1[1]).max(v2[1]),
v0[2].max(v1[2]).max(v2[2]),
);
// An axis-aligned triangle has zero extent along one axis, and the slab
// test in `AABB::hit` rejects zero-thickness boxes (`t_max <= t_min`).
// Pad degenerate axes so flat geometry stays hittable.
const PAD: f32 = 1e-4;
for a in 0..3 {
if max[a] - min[a] < PAD {
min[a] -= PAD;
max[a] += PAD;
}
}
AABB::new(min, max)
}
pub use crust_rt::AABB;
Loading
Loading