diff --git a/CLAUDE.md b/CLAUDE.md index 166ea79..d336e5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,11 +315,33 @@ Xform hierarchy into world matrices. Schema mapping: - `crust:openpbr` → decoded 1:1 into `OpenPBR`; every input is the camelCase mirror of the Rust field name (lossless but non-portable). Reference scene: `samples/openpbr_showcase.usda`. - Unbound geometry → grey diffuse `OpenPBR`. +- `UsdLuxDistantLight` → a `DistantLight` in the light list only (no scene geometry). It + points down its local -Z; `inputs:angle` is the source's angular *diameter* (default + 0.53°, the sun's) and a zero angle is widened to `MIN_DISTANT_ANGLE_DEG` rather than + made singular, so the integrator keeps one MIS path instead of a delta special case. + `intensity × color × 2^exposure` is the **irradiance** on a surface facing the light and + the radiance is derived as `E / Ω` — widening the angle softens shadows without changing + exposure (Hydra's normalized convention). Bounce rays find it by *escaping* along a + direction inside its cone, which is the `Light::escaped` half of MIS. +- `UsdLuxDomeLight` → a `DomeLight`: an infinite environment covering every direction, so + once one exists it **replaces the built-in sky gradient** (`Light::escaped` answers for + every escaping ray). Radiance is `intensity × color × 2^exposure` times an optional + lat-long `EnvironmentMap`; only `latlong`/`automatic` `texture:format` is supported and + anything else warns and falls back to the uniform colour. The prim's *rotation* orients + the sky (a dome is at infinity, so its translation and scale are meaningless). The map + is importance-sampled by luminance × sinθ — the Jacobian matters, without it polar + texels are over-sampled — which is what keeps a small bright sun in an HDRI from + becoming a firefly farm. + - **crust-core decodes nothing.** `inputs:texture:file` is resolved against the USD + layer's directory and handed to the host through the `AssetLoader` trait + (`Scene::from_usd_with_assets`); `Scene::from_usd` passes `NoAssets`, which warns and + falls back to the uniform colour. `crust-render` implements it with `exr` (OpenEXR) + and `image` (`.hdr` and LDR, the latter un-gamma'd to linear). This is the seam + general texture support should grow through. - `UsdLuxSphereLight` → emissive `Sphere` geometry + `AreaLight(SphereShape)`; `UsdLuxRectLight` → two emissive `Triangle`s + `AreaLight(RectShape)` (local XY plane, emitting along -Z per UsdLux; effectively one-sided). Sample scene: `samples/rectlight.usda`. - Other lux types (`DiskLight`, `DistantLight`, `DomeLight`, `CylinderLight`) warn once and - are skipped. + Other lux types (`DiskLight`, `CylinderLight`) warn once and are skipped. - **Volumes**: any prim carrying `crust:volume:type` imports as a `VolumeRegion` (checked *first* in the dispatch, so it never becomes geometry — its bounds must not occlude shadow rays). The local box is `[-size/2, size/2]³` when the prim authors `size` (a @@ -411,9 +433,12 @@ feature flag. `nested_native_instance_degrades_gracefully`. When upstream is fixed, delete that arm and splice the inner prototype's parts in with composed transforms — a native instance is a single placement, so it needs no extra level of kernel indirection. -- USD lux light schemas beyond `SphereLight`/`RectLight` are skipped (see above). Disk - lights need a disk primitive; distant/dome lights need non-area `Light` impls and - integrator support for lights without scene geometry. +- **Lighting caveats.** `DiskLight` (needs a disk primitive) and `CylinderLight` are still + skipped. `DomeLight` sampling is nearest-texel with no bilinear filtering, so a + low-resolution HDRI shows texel edges in a mirror; `inputs:texture:format` values other + than `latlong` are refused rather than mapped wrongly; and light-list picking stays + uniform, so a dim dome costs as many shadow rays as a bright sun. Neither infinite light + is visible to the guiding field's spatial structure (they have no position). - **Path guiding** covers surfaces only (no volume/phase guiding) and trains on luminance (no chromatic distributions). Thick transmission — dispersive or not — is a continuous Walter et al. 2007 microfacet BTDF — sampled via VNDF + Snell, evaluable diff --git a/crates/crust-core/src/environment.rs b/crates/crust-core/src/environment.rs new file mode 100644 index 0000000..7142aae --- /dev/null +++ b/crates/crust-core/src/environment.rs @@ -0,0 +1,405 @@ +//! Lat-long environment maps for [`crate::light::DomeLight`]. +//! +//! crust-core decodes nothing: the host hands it pixels through +//! [`crate::scene::AssetLoader`], which is what keeps this crate free of +//! image dependencies. What lives here is the part that is renderer +//! business — the direction↔pixel mapping and the importance sampling that +//! stops a small bright sun in a 4K HDRI from becoming a firefly farm. +//! +//! # Conventions +//! +//! Y-up, matching the rest of crust. For a unit direction `d`: +//! +//! - `v = acos(d.y) / π` — 0 at the +Y pole, 1 at −Y, so image row 0 is the +//! top of the sky (the usual storage order). +//! - `u = 0.5 + atan2(d.x, −d.z) / 2π` — puts −Z, the direction a USD +//! camera looks down, at the centre of the image. + +use glam::Vec3A; + +/// Perceptual weight used to decide where the light is. Importance +/// sampling only needs a scalar that tracks brightness; the sampled +/// radiance is always the full colour. +fn luminance(c: Vec3A) -> f32 { + 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z +} + +/// A piecewise-constant 1D distribution over `[0, 1)`, sampled by inverting +/// its CDF. The building block of the 2D environment distribution: one of +/// these over rows, and one per row over columns. +struct Distribution1D { + /// Unnormalized per-bin weights. + func: Vec, + /// `cdf[i]` is the summed weight below bin `i`; `len = func.len() + 1`. + cdf: Vec, + /// Integral of `func` over `[0, 1)` — the mean of `func`. + integral: f32, +} + +impl Distribution1D { + fn new(func: Vec) -> Self { + let n = func.len(); + let mut cdf = Vec::with_capacity(n + 1); + cdf.push(0.0); + let mut running = 0.0f64; + for &f in &func { + running += f as f64 / n as f64; + cdf.push(running as f32); + } + let integral = running as f32; + if integral > 0.0 { + for c in cdf.iter_mut() { + *c /= integral; + } + } else { + // Degenerate (all-black) input: fall back to uniform, so a + // black environment still samples without dividing by zero. + for (i, c) in cdf.iter_mut().enumerate() { + *c = i as f32 / n as f32; + } + } + Self { + func, + cdf, + integral, + } + } + + /// Inverts the CDF at `u`, returning `(x in [0,1), pdf, bin)`. The pdf + /// is with respect to `x`, so it integrates to 1 over `[0, 1)`. + fn sample(&self, u: f32) -> (f32, f32, usize) { + // First index whose cdf exceeds `u`, minus one. + let bin = match self + .cdf + .binary_search_by(|c| c.partial_cmp(&u).unwrap_or(std::cmp::Ordering::Less)) + { + Ok(i) => i.min(self.func.len() - 1), + Err(i) => i.saturating_sub(1).min(self.func.len() - 1), + }; + let span = self.cdf[bin + 1] - self.cdf[bin]; + let within = if span > 0.0 { + (u - self.cdf[bin]) / span + } else { + 0.5 + }; + let x = (bin as f32 + within) / self.func.len() as f32; + (x, self.pdf(bin), bin) + } + + /// Density of bin `bin` with respect to `x` in `[0, 1)`. + fn pdf(&self, bin: usize) -> f32 { + if self.integral > 0.0 { + self.func[bin] / self.integral + } else { + 1.0 + } + } +} + +/// A lat-long environment map with a 2D sampling distribution built over +/// it. +/// +/// The distribution weights each texel by luminance **times `sin θ`**: a +/// lat-long image devotes as many pixels to a degree near the pole as near +/// the equator, but those polar texels cover far less solid angle, and +/// omitting the Jacobian would over-sample the poles and bias the estimate. +pub struct EnvironmentMap { + width: usize, + height: usize, + /// Row-major, row 0 at the +Y pole. + pixels: Vec, + /// Over rows (`v`). + marginal: Distribution1D, + /// One per row, over columns (`u`). + conditional: Vec, +} + +impl EnvironmentMap { + /// Builds a map from row-major RGB pixels, row 0 at the +Y pole. + /// Returns `None` for an empty or mis-sized buffer. + pub fn new(width: usize, height: usize, pixels: Vec) -> Option { + if width == 0 || height == 0 || pixels.len() != width * height { + return None; + } + let mut conditional = Vec::with_capacity(height); + let mut row_weights = Vec::with_capacity(height); + for y in 0..height { + // Solid-angle weight of this row of texels. + let theta = (y as f32 + 0.5) / height as f32 * std::f32::consts::PI; + let sin_theta = theta.sin(); + let row: Vec = (0..width) + .map(|x| luminance(pixels[y * width + x]).max(0.0) * sin_theta) + .collect(); + let d = Distribution1D::new(row); + row_weights.push(d.integral); + conditional.push(d); + } + Some(Self { + width, + height, + pixels, + marginal: Distribution1D::new(row_weights), + conditional, + }) + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + /// `(u, v)` in `[0, 1)²` for a unit direction — see the module header + /// for the convention. + fn direction_to_uv(d: Vec3A) -> (f32, f32) { + let v = d.y.clamp(-1.0, 1.0).acos() / std::f32::consts::PI; + let u = 0.5 + d.x.atan2(-d.z) / std::f32::consts::TAU; + (u.rem_euclid(1.0), v.clamp(0.0, 1.0)) + } + + /// The inverse of [`Self::direction_to_uv`]. + fn uv_to_direction(u: f32, v: f32) -> Vec3A { + let theta = v * std::f32::consts::PI; + let phi = (u - 0.5) * std::f32::consts::TAU; + let sin_theta = theta.sin(); + Vec3A::new(sin_theta * phi.sin(), theta.cos(), -sin_theta * phi.cos()) + } + + /// Nearest-texel radiance along `direction`. + pub fn radiance(&self, direction: Vec3A) -> Vec3A { + let (u, v) = Self::direction_to_uv(direction); + let x = ((u * self.width as f32) as usize).min(self.width - 1); + let y = ((v * self.height as f32) as usize).min(self.height - 1); + self.pixels[y * self.width + x] + } + + /// Importance-samples a direction. Returns `(direction, radiance, + /// solid-angle pdf)`; `None` only for a wholly black map, which has + /// nothing to sample. + pub fn sample(&self, u1: f32, u2: f32) -> Option<(Vec3A, Vec3A, f32)> { + if self.marginal.integral <= 0.0 { + return None; + } + let (v, pdf_v, row) = self.marginal.sample(u2); + let (u, pdf_u, _) = self.conditional[row].sample(u1); + let direction = Self::uv_to_direction(u, v); + let pdf = self.solid_angle_pdf(pdf_u * pdf_v, v); + (pdf > 0.0).then(|| (direction, self.radiance(direction), pdf)) + } + + /// Solid-angle pdf of `direction` under [`Self::sample`] — the bounce + /// side of MIS, and it must agree with what `sample` reported. + pub fn pdf(&self, direction: Vec3A) -> f32 { + if self.marginal.integral <= 0.0 { + return 0.0; + } + let (u, v) = Self::direction_to_uv(direction); + let x = ((u * self.width as f32) as usize).min(self.width - 1); + let y = ((v * self.height as f32) as usize).min(self.height - 1); + self.solid_angle_pdf(self.conditional[y].pdf(x) * self.marginal.pdf(y), v) + } + + /// Converts a density over `(u, v)` into one over solid angle. The + /// lat-long map covers the sphere as `dω = 2π² sin θ du dv`, so the + /// pdf divides by that; at the poles `sin θ → 0` and the direction is + /// unreachable. + fn solid_angle_pdf(&self, pdf_uv: f32, v: f32) -> f32 { + let sin_theta = (v * std::f32::consts::PI).sin(); + if sin_theta <= 0.0 { + return 0.0; + } + pdf_uv / (2.0 * std::f32::consts::PI * std::f32::consts::PI * sin_theta) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A map with a single bright texel on an otherwise dim background — + /// the case importance sampling exists for. + fn spotty_map(w: usize, h: usize, bright_x: usize, bright_y: usize) -> EnvironmentMap { + let mut pixels = vec![Vec3A::splat(0.05); w * h]; + pixels[bright_y * w + bright_x] = Vec3A::splat(500.0); + EnvironmentMap::new(w, h, pixels).expect("valid map") + } + + #[test] + fn direction_and_uv_round_trip() { + for d in [ + Vec3A::Y, + -Vec3A::Y, + Vec3A::X, + -Vec3A::X, + Vec3A::Z, + -Vec3A::Z, + Vec3A::new(0.3, 0.5, -0.8).normalize(), + Vec3A::new(-0.6, -0.2, 0.7).normalize(), + ] { + let (u, v) = EnvironmentMap::direction_to_uv(d); + let back = EnvironmentMap::uv_to_direction(u, v); + assert!( + back.distance(d) < 1e-4, + "round trip failed for {d:?}: uv=({u}, {v}) -> {back:?}" + ); + } + } + + /// A USD camera looks down -Z, so -Z must land at the centre of the + /// image and +Y at the top row. + #[test] + fn conventions_are_as_documented() { + let (u, v) = EnvironmentMap::direction_to_uv(-Vec3A::Z); + assert!((u - 0.5).abs() < 1e-5, "-Z should be at u = 0.5, got {u}"); + assert!((v - 0.5).abs() < 1e-5, "the horizon should be at v = 0.5, got {v}"); + + let (_, v_top) = EnvironmentMap::direction_to_uv(Vec3A::Y); + assert!(v_top < 1e-5, "+Y should be the top row, got v = {v_top}"); + let (_, v_bot) = EnvironmentMap::direction_to_uv(-Vec3A::Y); + assert!(v_bot > 1.0 - 1e-5, "-Y should be the bottom row, got v = {v_bot}"); + } + + /// `sample` and `pdf` are the two MIS sides of one strategy: for any + /// sampled direction they must report the same density, or emission is + /// double-counted. + #[test] + fn sample_and_pdf_agree() { + let map = spotty_map(64, 32, 40, 8); + let mut rng = openqmc::pcg::Rng::new(11); + for _ in 0..3000 { + let Some((dir, _, pdf)) = map.sample(rng.next_f32(), rng.next_f32()) else { + continue; + }; + let queried = map.pdf(dir); + assert!( + (pdf - queried).abs() <= 1e-3 * pdf.max(queried), + "MIS sides disagree: sample {pdf} vs pdf() {queried} for {dir:?}" + ); + } + } + + /// The sampling density must integrate to 1 over the sphere. Estimated + /// as E[1/pdf] over the map's own samples, which converges to the + /// sphere's total solid angle, 4π, when the density is normalized. + #[test] + fn pdf_is_normalized_over_the_sphere() { + let map = spotty_map(32, 16, 20, 6); + let mut rng = openqmc::pcg::Rng::new(5); + let n = 200_000; + let mut total = 0.0f64; + for _ in 0..n { + if let Some((_, _, pdf)) = map.sample(rng.next_f32(), rng.next_f32()) + && pdf > 0.0 + { + total += 1.0 / pdf as f64; + } + } + let estimate = total / n as f64; + let expected = 4.0 * std::f64::consts::PI; + assert!( + (estimate - expected).abs() < 0.05 * expected, + "E[1/pdf] = {estimate}, want ~{expected} (4π)" + ); + } + + /// Importance sampling must actually chase the bright texel: with one + /// texel 10 000x the background, most samples should land on it. + #[test] + fn sampling_concentrates_on_bright_texels() { + let (w, h, bx, by) = (32usize, 16usize, 20usize, 6usize); + let map = spotty_map(w, h, bx, by); + let mut rng = openqmc::pcg::Rng::new(3); + let (mut hits, mut n) = (0usize, 0usize); + for _ in 0..20_000 { + let Some((dir, _, _)) = map.sample(rng.next_f32(), rng.next_f32()) else { + continue; + }; + n += 1; + let (u, v) = EnvironmentMap::direction_to_uv(dir); + let x = ((u * w as f32) as usize).min(w - 1); + let y = ((v * h as f32) as usize).min(h - 1); + if x == bx && y == by { + hits += 1; + } + } + let fraction = hits as f32 / n as f32; + // Uniform sampling would land there ~1/512 of the time. + assert!( + fraction > 0.5, + "only {:.1}% of samples found the bright texel — importance \ + sampling is not working", + 100.0 * fraction + ); + } + + /// The sin θ Jacobian. Without it, polar texels — which cover far less + /// solid angle than equatorial ones but occupy just as many pixels — + /// would be sampled far too often, and their solid-angle pdf would + /// blow up as `1/sin θ`. + /// + /// Evaluated at texel *centres*, where the density's piecewise-constant + /// discretization vanishes: the pdf of a uniform map is then exactly + /// uniform over the sphere, `1/4π`. (Away from centres it differs by + /// the ratio of the texel's `sin θ` to the direction's — a real + /// property of a piecewise-constant density, not an error. What must + /// hold everywhere is normalization and agreement between the two MIS + /// sides, which `pdf_is_normalized_over_the_sphere` and + /// `sample_and_pdf_agree` cover.) + #[test] + fn uniform_map_has_uniform_solid_angle_pdf() { + let (w, h) = (32usize, 16usize); + let map = EnvironmentMap::new(w, h, vec![Vec3A::ONE; w * h]).expect("valid"); + let expected = 1.0 / (4.0 * std::f32::consts::PI); + + // Every row from the pole to the equator, at the texel centre. + for y in 0..h { + let v = (y as f32 + 0.5) / h as f32; + let u = 0.5 / w as f32; + let d = EnvironmentMap::uv_to_direction(u, v); + let pdf = map.pdf(d); + assert!( + (pdf - expected).abs() < 0.01 * expected, + "row {y} (v = {v}): pdf {pdf} is not uniform (want {expected}) — \ + the sin θ Jacobian is missing or wrong" + ); + } + } + + /// The same property stated the way it fails: a near-pole direction and + /// an equatorial one must have comparable solid-angle densities. Drop + /// the Jacobian and the pole's would be larger by `1/sin θ` — here + /// about tenfold. + #[test] + fn poles_are_not_oversampled() { + let (w, h) = (64usize, 32usize); + let map = EnvironmentMap::new(w, h, vec![Vec3A::ONE; w * h]).expect("valid"); + let at_row = |y: usize| { + let v = (y as f32 + 0.5) / h as f32; + map.pdf(EnvironmentMap::uv_to_direction(0.5 / w as f32, v)) + }; + let pole = at_row(0); + let equator = at_row(h / 2); + assert!( + (pole - equator).abs() < 0.02 * equator, + "pole pdf {pole} vs equator {equator} — polar texels are being \ + over-sampled" + ); + } + + #[test] + fn rejects_malformed_buffers() { + assert!(EnvironmentMap::new(0, 4, vec![]).is_none()); + assert!(EnvironmentMap::new(4, 4, vec![Vec3A::ONE; 3]).is_none()); + } + + /// An all-black map has nothing to sample, and must say so rather than + /// dividing by a zero integral. + #[test] + fn black_map_declines_to_sample() { + let map = EnvironmentMap::new(8, 4, vec![Vec3A::ZERO; 32]).expect("valid"); + assert!(map.sample(0.5, 0.5).is_none()); + assert_eq!(map.pdf(Vec3A::Y), 0.0); + } +} diff --git a/crates/crust-core/src/lib.rs b/crates/crust-core/src/lib.rs index 8154c75..500092c 100644 --- a/crates/crust-core/src/lib.rs +++ b/crates/crust-core/src/lib.rs @@ -2,6 +2,7 @@ mod aabb; mod buffer; mod camera; mod error; +mod environment; mod guiding; mod hittable; mod light; @@ -30,7 +31,12 @@ pub use error::Error; pub use glam::{Mat4, Vec3A}; pub use guiding::{GuidingConfig, GuidingField, SampleData}; pub use hittable::HitRecord; -pub use light::{AreaLight, Light, LightList, LightShape, RectShape, SphereShape}; +pub use environment::EnvironmentMap; +pub use scene::{AssetLoader, NoAssets}; +pub use light::{ + AreaLight, DistantLight, DomeLight, Light, LightList, LightSample, LightShape, RectShape, + SphereShape, +}; pub use material::*; pub use medium::Medium; pub use ray::{MASK_ALL, MASK_CAMERA, MASK_INDIRECT, MASK_SHADOW, Ray}; diff --git a/crates/crust-core/src/light.rs b/crates/crust-core/src/light.rs index 69edd3a..247b795 100644 --- a/crates/crust-core/src/light.rs +++ b/crates/crust-core/src/light.rs @@ -1,5 +1,6 @@ +use crate::environment::EnvironmentMap; use crate::material::{Emissive, Material}; -use glam::Vec3A; +use glam::{Mat3A, Vec3A}; use std::sync::Arc; /// The emitting surface of an area light, decoupled from any material: pure @@ -79,30 +80,74 @@ impl LightShape for RectShape { } } +/// One sampled connection from a shading point to a light: where to aim +/// the shadow ray, how far it must reach, the radiance arriving from that +/// direction, and the solid-angle density of having chosen it. +/// +/// Directions rather than points, because a light can be at infinity — a +/// `DistantLight` or a `DomeLight` has no surface point to aim at. +#[derive(Clone, Copy, Debug)] +pub struct LightSample { + /// Unit direction from the shading point toward the light. + pub direction: Vec3A, + /// How far the shadow ray must be traced. `f32::INFINITY` for lights + /// at infinity — nothing beyond the scene can occlude them. + pub distance: f32, + /// Radiance arriving along `direction`. + pub radiance: Vec3A, + /// Solid-angle pdf of this direction under the light's own sampling. + /// + /// Always finite and positive: crust has no delta lights. A + /// `DistantLight` with a zero `angle` is widened to a small but real + /// cone rather than being made singular, which keeps one MIS path + /// through the integrator instead of two. + pub pdf: f32, +} + /// The `Light` trait is what the integrator's light-sampling strategy (NEE) -/// needs from a light: a surface point to aim a shadow ray at, the -/// solid-angle density of that choice for MIS, the emitted radiance, and — -/// for lights with scene geometry — the geometry id that lets a bounce -/// ray recognize the light it hit. +/// needs from a light: a direction to aim a shadow ray, the solid-angle +/// density of that choice for MIS, the radiance it carries, and — for +/// lights with scene geometry — the geometry id that lets a bounce ray +/// recognize the light it hit. +/// +/// The MIS pairing is the thing to be careful with. Every light has two +/// ways of being found: NEE samples it directly, and a bounce ray may +/// arrive at it by chance. Both sides must evaluate the *same* density or +/// emission is double-counted. For lights with geometry that second path +/// is a bounce hit, weighted with [`Light::pdf_at_point`]; for lights at +/// infinity it is a ray escaping the scene, weighted with +/// [`Light::escaped`]. A light implements whichever applies. pub trait Light: Send + Sync { - /// Samples a point on the light source, uniform by area. + /// Samples a direction from `from` toward the light. `None` when the + /// light cannot be reached from there (below a dome's horizon, say). /// /// # Parameters /// - `u`, `v`: Unit random numbers driving the sample. - fn sample_point(&self, u: f32, v: f32) -> Vec3A; - - /// Solid-angle pdf, as seen from `hit_point`, of `sample_point` having - /// produced `light_point` (which must lie on the light's surface). - fn pdf(&self, hit_point: Vec3A, light_point: Vec3A) -> f32; + fn sample_li(&self, from: Vec3A, u: f32, v: f32) -> Option; + + /// Solid-angle pdf, as seen from `from`, of [`Light::sample_li`] having + /// produced `light_point` — the bounce side of MIS for a light whose + /// geometry a ray hit. Lights at infinity have no such point and keep + /// the default. + fn pdf_at_point(&self, _from: Vec3A, _light_point: Vec3A) -> f32 { + 0.0 + } - /// Radiance emitted by the light surface. Matches `emitted()` of the - /// material bound to the light's scene geometry. - fn emission(&self) -> Vec3A; + /// For a ray that escaped the scene along `direction`: the radiance it + /// picks up and the solid-angle pdf NEE would have used for that + /// direction, as `(radiance, pdf)`. This is the bounce side of MIS for + /// lights at infinity. `None` for lights with finite geometry, and for + /// directions this light does not cover. + fn escaped(&self, _from: Vec3A, _direction: Vec3A) -> Option<(Vec3A, f32)> { + None + } /// The `geom_id` of this light's scene geometry in the world, used to /// recognize the light when a bounce ray hits it. `None` for lights /// with no geometry in the world. - fn geom_id(&self) -> Option; + fn geom_id(&self) -> Option { + None + } } /// A geometric area light: any [`LightShape`] paired with the [`Emissive`] @@ -124,31 +169,42 @@ impl AreaLight { geom_id, } } -} - -impl Light for AreaLight { - fn sample_point(&self, u: f32, v: f32) -> Vec3A { - self.shape.sample_point(u, v) - } - fn pdf(&self, hit_point: Vec3A, light_point: Vec3A) -> f32 { - let direction = light_point - hit_point; + /// Solid-angle pdf of sampling `light_point` uniformly by area, as seen + /// from `from`: `dist² / (cos(θ_light) · area)`, where θ_light is the + /// angle between the light's surface normal at `light_point` and the + /// direction back toward the shaded point. Back-facing points clamp the + /// cosine to zero, so their pdf explodes and both MIS strategies agree + /// the contribution is negligible — area lights are effectively + /// one-sided. + fn solid_angle_pdf(&self, from: Vec3A, light_point: Vec3A) -> f32 { + let direction = light_point - from; let distance_squared = direction.length_squared(); let dir_to_light = direction.normalize(); - // Solid-angle pdf of sampling this point uniformly by area: - // dist^2 / (cos(theta_light) * area), where theta_light is the angle - // between the light's surface normal at `light_point` and the - // direction back toward the shaded point. Back-facing points clamp - // the cosine to zero, so their pdf explodes and both MIS strategies - // agree the contribution is negligible — area lights are effectively - // one-sided. let light_normal = self.shape.normal_at(light_point); let cosine = f32::max(light_normal.dot(-dir_to_light), 0.0); distance_squared / (cosine * self.shape.area() + 1e-4) } +} + +impl Light for AreaLight { + fn sample_li(&self, from: Vec3A, u: f32, v: f32) -> Option { + let light_point = self.shape.sample_point(u, v); + let to_light = light_point - from; + let distance = to_light.length(); + if distance < 1e-6 { + return None; + } + Some(LightSample { + direction: to_light / distance, + distance, + radiance: self.material.emitted(), + pdf: self.solid_angle_pdf(from, light_point), + }) + } - fn emission(&self) -> Vec3A { - self.material.emitted() + fn pdf_at_point(&self, from: Vec3A, light_point: Vec3A) -> f32 { + self.solid_angle_pdf(from, light_point) } fn geom_id(&self) -> Option { @@ -156,6 +212,182 @@ impl Light for AreaLight { } } +/// A `UsdLuxDistantLight`: parallel light from infinitely far away, as the +/// sun is. +/// +/// Two conventions worth stating, because renderers differ. +/// +/// **The cone is always real.** UsdLux gives the source an angular diameter +/// (`inputs:angle`, default 0.53° — the sun's), and authors may set it to +/// zero for perfectly sharp shadows. Rather than making that a delta light, +/// which would need a second MIS path through the integrator, a zero angle +/// is widened to [`MIN_DISTANT_ANGLE_DEG`]. The resulting penumbra is far +/// below a pixel at any sane scene scale, and MIS handles the rest: when a +/// bounce ray happens into the tiny cone the light pdf is enormous, so the +/// bounce side's weight collapses to nothing and no firefly survives. +/// +/// **`intensity × color` is irradiance, not radiance.** The radiance the +/// light emits is derived as `E / Ω` over the cone's solid angle, so +/// widening the angle softens shadows without changing exposure — the +/// behaviour Hydra and most production renderers normalize to. Treating the +/// input as radiance instead would make a sun-sized source almost black. +pub struct DistantLight { + /// Unit direction the light travels *toward* (the direction photons + /// move), so a shading point is lit from `-direction`. + direction: Vec3A, + /// Irradiance on a surface facing the light. + irradiance: Vec3A, + /// Half-angle of the source cone, in radians. + cos_half_angle: f32, + /// Solid angle of the cone, `2π(1 − cos θ)`. + solid_angle: f32, +} + +/// The floor a `DistantLight`'s angular diameter is clamped to, in degrees. +/// Small enough to read as a sharp shadow, large enough that the cone stays +/// a genuine solid angle with a finite pdf. +pub const MIN_DISTANT_ANGLE_DEG: f32 = 0.05; + +impl DistantLight { + /// `direction` is the direction the light travels toward (UsdLux's + /// convention: a distant light points down its local -Z). `angle_deg` + /// is the source's angular *diameter*, as `inputs:angle` gives it. + pub fn new(direction: Vec3A, irradiance: Vec3A, angle_deg: f32) -> Self { + let diameter = angle_deg.clamp(MIN_DISTANT_ANGLE_DEG, 179.0); + let half_angle = 0.5 * diameter.to_radians(); + let cos_half_angle = half_angle.cos(); + Self { + direction: direction.normalize(), + irradiance, + cos_half_angle, + solid_angle: 2.0 * std::f32::consts::PI * (1.0 - cos_half_angle), + } + } + + /// Radiance within the cone: irradiance spread over its solid angle. + fn radiance(&self) -> Vec3A { + self.irradiance / self.solid_angle.max(1e-12) + } + + /// Uniform-cone pdf, constant inside the cone. + fn cone_pdf(&self) -> f32 { + 1.0 / self.solid_angle.max(1e-12) + } + + /// Is `direction` (pointing away from the shaded point) inside the + /// cone of directions this light occupies? + fn covers(&self, direction: Vec3A) -> bool { + direction.dot(-self.direction) >= self.cos_half_angle + } +} + +impl Light for DistantLight { + fn sample_li(&self, _from: Vec3A, u: f32, v: f32) -> Option { + // Uniform direction within the cone around `-direction`. + let cos_theta = 1.0 - u * (1.0 - self.cos_half_angle); + let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt(); + let phi = 2.0 * std::f32::consts::PI * v; + let local = Vec3A::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta); + Some(LightSample { + direction: utils::align_to_normal(local, -self.direction).normalize(), + // Nothing beyond the scene can occlude a light at infinity. + distance: f32::INFINITY, + radiance: self.radiance(), + pdf: self.cone_pdf(), + }) + } + + fn escaped(&self, _from: Vec3A, direction: Vec3A) -> Option<(Vec3A, f32)> { + self.covers(direction) + .then(|| (self.radiance(), self.cone_pdf())) + } +} + +/// A `UsdLuxDomeLight`: an infinite environment surrounding the scene. +/// +/// Covers every direction, so once one exists it *is* the background — the +/// integrator's built-in sky gradient stops applying, because +/// [`Light::escaped`] answers for every ray that leaves. +/// +/// Radiance is a uniform `tint` multiplied by an optional lat-long +/// [`EnvironmentMap`]. With a map, directions are importance-sampled from +/// its luminance so a small bright sun in an HDRI does not become a firefly +/// farm; without one, directions are sampled uniformly over the sphere. +/// +/// `orientation` maps *world* directions into the dome's own space, so a +/// rotated dome prim rotates the sky. It is the inverse of the prim's +/// world transform, cached once. +pub struct DomeLight { + tint: Vec3A, + map: Option>, + /// World → dome-local rotation. + world_to_light: Mat3A, + /// Dome-local → world rotation. + light_to_world: Mat3A, +} + +impl DomeLight { + pub fn new(tint: Vec3A, map: Option>, light_to_world: Mat3A) -> Self { + Self { + tint, + map, + world_to_light: light_to_world.inverse(), + light_to_world, + } + } + + /// Radiance arriving from a world-space `direction`. + fn radiance_toward(&self, direction: Vec3A) -> Vec3A { + match &self.map { + Some(map) => self.tint * map.radiance(self.world_to_light * direction), + None => self.tint, + } + } + + /// Solid-angle pdf of a world-space `direction` under this dome's own + /// sampling: the map's distribution, or uniform over the sphere. + fn pdf_toward(&self, direction: Vec3A) -> f32 { + match &self.map { + Some(map) => map.pdf(self.world_to_light * direction), + None => 1.0 / (4.0 * std::f32::consts::PI), + } + } +} + +impl Light for DomeLight { + fn sample_li(&self, _from: Vec3A, u: f32, v: f32) -> Option { + let (direction, radiance, pdf) = match &self.map { + Some(map) => { + let (local, radiance, pdf) = map.sample(u, v)?; + ((self.light_to_world * local).normalize(), radiance, pdf) + } + None => { + // Uniform over the sphere. + let z = 1.0 - 2.0 * u; + let r = (1.0 - z * z).max(0.0).sqrt(); + let phi = std::f32::consts::TAU * v; + ( + Vec3A::new(r * phi.cos(), z, r * phi.sin()), + Vec3A::ONE, + 1.0 / (4.0 * std::f32::consts::PI), + ) + } + }; + (pdf > 0.0).then(|| LightSample { + direction, + // Nothing in the scene can occlude the environment beyond it. + distance: f32::INFINITY, + radiance: self.tint * radiance, + pdf, + }) + } + + fn escaped(&self, _from: Vec3A, direction: Vec3A) -> Option<(Vec3A, f32)> { + // A dome covers every direction, so every escaping ray finds it. + Some((self.radiance_toward(direction), self.pdf_toward(direction))) + } +} + /// The `LightList` struct manages a collection of light sources in the scene. pub struct LightList { /// A vector of light sources stored as `Arc` for shared ownership. @@ -257,9 +489,135 @@ mod tests { 0, ); // Nearest point on the sphere as seen from below. - let pdf = light.pdf(Vec3A::ZERO, Vec3A::new(0.0, 4.0, 0.0)); + let pdf = light.pdf_at_point(Vec3A::ZERO, Vec3A::new(0.0, 4.0, 0.0)); assert!(pdf.is_finite() && pdf > 0.0); - assert_eq!(light.emission(), Vec3A::splat(10.0)); + + // The sampled connection agrees: it aims upward at the light, stops + // at a finite distance, and reports the same emission and a pdf of + // the same shape. + let s = light + .sample_li(Vec3A::ZERO, 0.3, 0.7) + .expect("a sphere overhead is always reachable"); + assert!(s.direction.is_normalized()); + assert!(s.distance.is_finite() && s.distance > 0.0); + assert_eq!(s.radiance, Vec3A::splat(10.0)); + assert!(s.pdf.is_finite() && s.pdf > 0.0); + + // `sample_li` and `pdf_at_point` are the two MIS sides of one + // strategy and must agree on the density of the same direction. + let point = Vec3A::ZERO + s.direction * s.distance; + let from_point = light.pdf_at_point(Vec3A::ZERO, point); + assert!( + (s.pdf - from_point).abs() <= 1e-3 * s.pdf.max(from_point), + "MIS sides disagree: sample_li {} vs pdf_at_point {}", + s.pdf, + from_point + ); + + // An area light has geometry and no escaped-ray contribution. + assert_eq!(light.geom_id(), Some(0)); + assert!(light.escaped(Vec3A::ZERO, Vec3A::Y).is_none()); + } + + /// The cone convention: every sampled direction lies inside the + /// source cone, and `escaped` agrees about exactly which directions + /// those are. Disagreement here would mean NEE and the bounce side + /// find the light in different sets of directions. + #[test] + fn distant_light_cone_is_consistent() { + let dir = Vec3A::new(0.3, -1.0, 0.2).normalize(); + let light = DistantLight::new(dir, Vec3A::splat(2.0), 10.0); + + let mut rng = openqmc::pcg::Rng::new(7); + for _ in 0..2000 { + let s = light + .sample_li(Vec3A::ZERO, rng.next_f32(), rng.next_f32()) + .expect("a distant light is reachable from anywhere"); + assert!(s.direction.is_normalized()); + assert!( + s.distance.is_infinite(), + "a light at infinity cannot be occluded by anything in the scene" + ); + // Sampled directions must be ones `escaped` also covers. + let (radiance, pdf) = light + .escaped(Vec3A::ZERO, s.direction) + .expect("sample_li produced a direction escaped() does not cover"); + assert_eq!(radiance, s.radiance); + assert!( + (pdf - s.pdf).abs() < 1e-3 * s.pdf, + "MIS sides disagree on the pdf: {} vs {}", + s.pdf, + pdf + ); + } + + // And nothing outside the cone is covered: the opposite hemisphere + // and a direction just past the half-angle both miss. + assert!(light.escaped(Vec3A::ZERO, dir).is_none()); + let outside = utils::align_to_normal( + Vec3A::new(20f32.to_radians().sin(), 0.0, 20f32.to_radians().cos()), + -dir, + ) + .normalize(); + assert!( + light.escaped(Vec3A::ZERO, outside).is_none(), + "a direction 20° off-axis is outside a 10° cone" + ); + } + + /// The energy convention: `intensity × color` is the *irradiance* on a + /// surface facing the light, and radiance is derived over the cone. So + /// widening the angle must soften shadows without changing exposure — + /// `L · Ω` stays put. + /// + /// This is the assumption most easily got backwards; treating the input + /// as radiance instead would make a sun-sized source ~5 orders of + /// magnitude too dark. + #[test] + fn distant_light_irradiance_is_angle_invariant() { + let dir = -Vec3A::Y; + let e = Vec3A::new(3.0, 2.0, 1.0); + for angle in [0.0f32, 0.53, 5.0, 30.0] { + let light = DistantLight::new(dir, e, angle); + let s = light.sample_li(Vec3A::ZERO, 0.4, 0.6).expect("reachable"); + // Radiance integrated over the cone's solid angle (1/pdf) + // returns the authored irradiance, whatever the angle. + let recovered = s.radiance / s.pdf; + assert!( + (recovered - e).length() < 1e-3 * e.length(), + "angle {angle}°: irradiance {recovered:?} != authored {e:?}" + ); + } + } + + /// A zero angle is widened rather than made singular, so the pdf stays + /// finite and the integrator needs no delta-light path. + #[test] + fn distant_light_zero_angle_stays_finite() { + let light = DistantLight::new(-Vec3A::Y, Vec3A::ONE, 0.0); + let s = light.sample_li(Vec3A::ZERO, 0.5, 0.5).expect("reachable"); + assert!(s.pdf.is_finite() && s.pdf > 0.0, "pdf = {}", s.pdf); + assert!( + s.radiance.is_finite(), + "radiance must stay finite: {:?}", + s.radiance + ); + // Still a *tight* cone: a degree off-axis is outside it. + let off = utils::align_to_normal( + Vec3A::new(1f32.to_radians().sin(), 0.0, 1f32.to_radians().cos()), + Vec3A::Y, + ) + .normalize(); + assert!(light.escaped(Vec3A::ZERO, off).is_none()); + } + + /// A distant light has no scene geometry, so bounce rays must never try + /// to attribute a *hit* to it. + #[test] + fn distant_light_has_no_geometry() { + let light = DistantLight::new(-Vec3A::Y, Vec3A::ONE, 1.0); + assert_eq!(light.geom_id(), None); + assert_eq!(light.pdf_at_point(Vec3A::ZERO, Vec3A::Y), 0.0); } #[test] diff --git a/crates/crust-core/src/scene.rs b/crates/crust-core/src/scene.rs index d0cb67c..0ffa31a 100644 --- a/crates/crust-core/src/scene.rs +++ b/crates/crust-core/src/scene.rs @@ -1,4 +1,5 @@ use crate::camera::Camera; +use crate::environment::EnvironmentMap; use crate::light::LightList; use crate::rt_world::World; use crate::tracer::RenderSettings; @@ -58,6 +59,50 @@ impl Scene { /// * `UsdRenderSettings` (plus `crust:*` custom attrs for spp / depth /// / etc.) → `RenderSettings`. Falls back to sensible defaults. pub fn from_usd(path: &std::path::Path) -> Result { - usd_import::load_scene(path) + Scene::from_usd_with_assets(path, &NoAssets) + } + + /// [`Scene::from_usd`], with the host supplying decoded images. + /// + /// crust-core has no image-decoding dependencies by design, so it never + /// opens a texture itself: when a `UsdLuxDomeLight` authors + /// `inputs:texture:file`, the importer resolves the asset path against + /// the USD layer and asks `assets` for the pixels. A host that cannot + /// (or will not) decode returns `None` and the dome falls back to its + /// uniform colour. + pub fn from_usd_with_assets( + path: &std::path::Path, + assets: &dyn AssetLoader, + ) -> Result { + usd_import::load_scene(path, assets) + } +} + +/// How the engine asks its host to decode an image. +/// +/// The seam exists so `crust-core` stays free of image-format +/// dependencies — the CLI already links `exr` and `image`, so decoding +/// belongs there. It is also the natural place to grow general texture +/// support. +pub trait AssetLoader: Send + Sync { + /// Decodes a lat-long environment map. `path` has already been resolved + /// against the USD layer's directory. `None` — for an unreadable file, + /// an unsupported format, or a host that does not decode at all — is + /// not an error: the caller falls back. + fn load_environment(&self, path: &std::path::Path) -> Option; +} + +/// The default host: decodes nothing. `Scene::from_usd` uses it, so a +/// caller that does not care about textures needs no extra ceremony. +pub struct NoAssets; + +impl AssetLoader for NoAssets { + fn load_environment(&self, path: &std::path::Path) -> Option { + tracing::warn!( + "No asset loader: environment map {} ignored — the dome falls back \ + to its uniform colour. Use Scene::from_usd_with_assets to supply one.", + path.display() + ); + None } } diff --git a/crates/crust-core/src/scene/usd_import.rs b/crates/crust-core/src/scene/usd_import.rs index 9a419f0..19a3f15 100644 --- a/crates/crust-core/src/scene/usd_import.rs +++ b/crates/crust-core/src/scene/usd_import.rs @@ -10,7 +10,11 @@ use glam::Mat4 as GMat4; use tracing::{debug, info, warn}; use crate::camera::Camera; -use crate::light::{AreaLight, LightList, RectShape, SphereShape}; +use crate::light::{ + AreaLight, DistantLight as CoreDistantLight, DomeLight as CoreDomeLight, LightList, RectShape, + SphereShape, +}; +use crate::scene::AssetLoader; use crate::material::{Emissive, Material, OpenPBR}; use crate::ray::MASK_ALL; use crate::rt_world::WorldBuilder; @@ -18,7 +22,7 @@ use crate::scene::Scene; use crust_rt::{CurveSegment, Geometry, Scene as RtScene, SceneBuilder as RtSceneBuilder}; use crate::tracer::{RenderSettings, SamplingStrategy}; use crate::volume::{DensityField, VolumeRegion}; -use glam::{Affine3A, Vec3, Vec3A}; +use glam::{Affine3A, Mat3A, Vec3, Vec3A}; use openusd::gf::{Matrix4d, Vec3f}; use openusd::schemas::geom::{ @@ -26,7 +30,8 @@ use openusd::schemas::geom::{ PointBased, PointInstancer, Sphere as UsdSphere, Xform, Xformable, }; use openusd::schemas::lux::{ - CylinderLight, DiskLight, DistantLight, DomeLight, Light as UsdLight, RectLight, SphereLight, + CylinderLight, DiskLight, DistantLight as UsdDistantLight, DomeLight, Light as UsdLight, + RectLight, SphereLight, }; use openusd::schemas::render::{RenderSettings as UsdRenderSettings, RenderSettingsBase}; use openusd::schemas::shade::{self, Material as UsdMaterial, MaterialBindingAPI, Shader}; @@ -48,7 +53,7 @@ const DEFAULT_GUIDING_PROB: f32 = 0.5; /// above any plausible authoring depth. const MAX_INSTANCE_NESTING: usize = 8; -pub(crate) fn load_scene(path: &Path) -> Result { +pub(crate) fn load_scene(path: &Path, assets: &dyn AssetLoader) -> Result { let path_str = path .to_str() .ok_or_else(|| crate::Error::NonUtf8Path(path.to_path_buf()))?; @@ -153,6 +158,10 @@ pub(crate) fn load_scene(path: &Path) -> Result { emit_sphere_light(&mut world, &mut lights, &light, this_world); } else if let Ok(Some(light)) = RectLight::get(&stage, prim.path().clone()) { emit_rect_light(&mut world, &mut lights, &light, this_world); + } else if let Ok(Some(light)) = UsdDistantLight::get(&stage, prim.path().clone()) { + emit_distant_light(&mut lights, &light, this_world); + } else if let Ok(Some(light)) = DomeLight::get(&stage, prim.path().clone()) { + emit_dome_light(&mut lights, &prim, &light, this_world, path, assets); } else { warn_unsupported_light(&stage, &prim); } @@ -1691,6 +1700,135 @@ fn emit_rect_light( ); } +/// Imports a `UsdLuxDistantLight`. The light points down its local -Z, so +/// the world direction it travels toward is that axis under the prim's +/// transform. `inputs:angle` is the source's angular *diameter* in degrees +/// (default 0.53 — the sun's). +/// +/// `intensity × color × 2^exposure` is taken as the irradiance on a surface +/// facing the light; [`DistantLight`] derives the radiance over the cone. +/// The light has no scene geometry, so it is light-list-only: bounce rays +/// find it by escaping along a direction inside its cone. +fn emit_distant_light(lights: &mut LightList, light: &UsdDistantLight, world_xf: GMat4) { + let direction = world_xf.transform_vector3(Vec3::NEG_Z); + if direction.length_squared() < 1e-12 { + warn!("DistantLight has a degenerate orientation — skipped"); + return; + } + let angle = attr_f32(&light.angle_attr()).unwrap_or(0.53); + let irradiance = lux_emission(light); + debug!( + "DistantLight: direction={:?} angle={}° irradiance={:?}", + direction, angle, irradiance + ); + lights.add(Arc::new(CoreDistantLight::new( + Vec3A::new(direction.x, direction.y, direction.z), + irradiance, + angle, + ))); +} + +/// Imports a `UsdLuxDomeLight` as an infinite environment. +/// +/// `inputs:texture:file` is resolved against the USD layer's directory and +/// handed to the host's [`AssetLoader`] — crust-core decodes nothing +/// itself. Without a file, or when the host declines, the dome is its +/// uniform `intensity × color × 2^exposure`. +/// +/// Only `latlong` is supported; `inputs:texture:format` values that mean +/// anything else warn and fall back to the uniform colour rather than +/// silently mapping the image wrongly. The prim's rotation orients the sky. +fn emit_dome_light( + lights: &mut LightList, + prim: &Prim, + light: &DomeLight, + world_xf: GMat4, + stage_path: &Path, + assets: &dyn AssetLoader, +) { + let tint = lux_emission(light); + + let format = light + .texture_format_attr() + .get::() + .ok() + .flatten() + .and_then(|v| match v { + sdf::Value::Token(t) => Some(t.to_string()), + _ => None, + }); + let map = match dome_texture_path(light, stage_path) { + Some(texture) => match format.as_deref() { + // `automatic` infers from the image; for the equirectangular + // images a dome light normally carries that means latlong. + None | Some("latlong") | Some("automatic") => { + let loaded = assets.load_environment(&texture); + if loaded.is_none() { + warn!( + "DomeLight at {}: could not load {} — falling back to \ + the uniform colour", + prim.path(), + texture.display() + ); + } + loaded.map(Arc::new) + } + Some(other) => { + warn!( + "DomeLight at {}: texture:format \"{other}\" is not supported \ + (only latlong) — falling back to the uniform colour", + prim.path() + ); + None + } + }, + None => None, + }; + + // Only the rotation orients the sky; a dome is at infinity, so its + // translation and scale are meaningless. + let m = world_xf.to_cols_array_2d(); + let rotation = Mat3A::from_cols( + Vec3A::new(m[0][0], m[0][1], m[0][2]).normalize_or(Vec3A::X), + Vec3A::new(m[1][0], m[1][1], m[1][2]).normalize_or(Vec3A::Y), + Vec3A::new(m[2][0], m[2][1], m[2][2]).normalize_or(Vec3A::Z), + ); + + info!( + "Imported DomeLight at {} (tint={:?}, {})", + prim.path(), + tint, + match &map { + Some(m) => format!("{}x{} environment map", m.width(), m.height()), + None => "uniform".to_string(), + } + ); + lights.add(Arc::new(CoreDomeLight::new(tint, map, rotation))); +} + +/// The dome's `inputs:texture:file`, resolved against the USD layer's +/// directory when it is relative — the usual way an asset path is authored. +fn dome_texture_path(light: &DomeLight, stage_path: &Path) -> Option { + let asset = match light.texture_file_attr().get::().ok().flatten()? { + sdf::Value::AssetPath(p) => p.to_string(), + sdf::Value::String(p) => p, + _ => return None, + }; + if asset.is_empty() { + return None; + } + let candidate = std::path::Path::new(&asset); + if candidate.is_absolute() { + return Some(candidate.to_path_buf()); + } + Some( + stage_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join(candidate), + ) +} + fn warn_unsupported_light(stage: &Stage, prim: &Prim) { let warn_type = |name: &str| { warn!( @@ -1699,24 +1837,12 @@ fn warn_unsupported_light(stage: &Stage, prim: &Prim) { prim.path() ); }; - if DistantLight::get(stage, prim.path().clone()) - .ok() - .flatten() - .is_some() - { - warn_type("DistantLight"); - } else if DiskLight::get(stage, prim.path().clone()) + if DiskLight::get(stage, prim.path().clone()) .ok() .flatten() .is_some() { warn_type("DiskLight"); - } else if DomeLight::get(stage, prim.path().clone()) - .ok() - .flatten() - .is_some() - { - warn_type("DomeLight"); } else if CylinderLight::get(stage, prim.path().clone()) .ok() .flatten() diff --git a/crates/crust-core/src/tracer.rs b/crates/crust-core/src/tracer.rs index 4f71d19..f17f8dd 100644 --- a/crates/crust-core/src/tracer.rs +++ b/crates/crust-core/src/tracer.rs @@ -772,13 +772,70 @@ fn bounce_emission_weight( }; match lights.find_by_geom(hit.geom_id) { Some(light) => { - let light_pdf = (light.pdf(from, hit.rec.p) / lights.count() as f32).max(1e-6); + let light_pdf = + (light.pdf_at_point(from, hit.rec.p) / lights.count() as f32).max(1e-6); strategy.bounce_weight(bounce_pdf, light_pdf) } None => 1.0, } } +/// The infinite-light half of bounce-side MIS: what a ray that left the +/// scene along `direction` picks up. +/// +/// This is [`bounce_emission_weight`]'s mirror. A light with geometry is +/// found by a bounce ray *hitting* it and weighted by `pdf_at_point`; a +/// light at infinity is found by a bounce ray *escaping* along a direction +/// it covers, and weighted by the pdf reported from `Light::escaped`. Both +/// must use the same density NEE used, or emission is double-counted. +/// +/// Returns the MIS-weighted radiance and whether any light covered the +/// direction — the caller falls back to the sky gradient when nothing did. +fn escaped_emission( + prev: &Option, + lights: &LightList, + direction: Vec3A, + strategy: SamplingStrategy, +) -> (Vec3A, bool) { + if lights.count() == 0 { + return (Vec3A::ZERO, false); + } + // As on the bounce-hit path, a delta or non-evaluable previous vertex + // means NEE could not have found this light, so there is no competing + // strategy and the emission is taken whole. + let competing = match prev { + Some(PrevVertex::Surface(p)) => { + (!p.delta && p.mat.eval(&p.ray, &p.rec, p.dir).is_some()).then_some((p.rec.p, p.pdf)) + } + Some(PrevVertex::Phase { pos, pdf }) => Some((*pos, *pdf)), + // Primary rays, and rays leaving a carried-medium scatter, run no + // NEE — full weight, exactly as `prev = None` means elsewhere. + None => None, + }; + let n_lights = lights.count() as f32; + + let mut radiance = Vec3A::ZERO; + let mut covered = false; + for light in &lights.lights { + let from = competing.map_or(Vec3A::ZERO, |(p, _)| p); + let Some((emitted, pdf)) = light.escaped(from, direction) else { + continue; + }; + covered = true; + let weight = match competing { + Some((_, bounce_pdf)) if strategy.samples_lights() => { + let light_pdf = (pdf / n_lights).max(1e-6); + strategy.bounce_weight(bounce_pdf, light_pdf) + } + // No NEE ran for this vertex (or the strategy does not sample + // lights at all), so nothing competes. + _ => 1.0, + }; + radiance += emitted * weight; + } + (radiance, covered) +} + /// NEE shadow test used at surface and volume vertices alike: ZERO when a /// surface occludes the segment, otherwise the volumetric transmittance /// through every region it crosses (stochastic for heterogeneous regions, @@ -827,21 +884,20 @@ fn volume_nee( return Vec3A::ZERO; }; let n_lights = lights.count() as f32; - let light_point = light.sample_point(nee[1], nee[2]); - let light_dir = light_point - p; - let light_distance = light_dir.length(); - let light_dir_unit = light_dir / light_distance.max(1e-6); - let shadow_ray = Ray::new(p, light_dir_unit) + let Some(s) = light.sample_li(p, nee[1], nee[2]) else { + return Vec3A::ZERO; + }; + let shadow_ray = Ray::new(p, s.direction) .with_time(time) .with_mask(crate::ray::MASK_SHADOW); - let tr = shadow_transmittance(world, volumes, &shadow_ray, light_distance, vertex); + let tr = shadow_transmittance(world, volumes, &shadow_ray, s.distance, vertex); if tr == Vec3A::ZERO { return Vec3A::ZERO; } - let light_pdf = (light.pdf(p, light_point) / n_lights).max(1e-6); - let phase_val = phase.pdf(wi.dot(light_dir_unit)); + let light_pdf = (s.pdf / n_lights).max(1e-6); + let phase_val = phase.pdf(wi.dot(s.direction)); let weight = strategy.light_weight(light_pdf, phase_val); - light.emission() * phase_val * tr * weight / light_pdf + s.radiance * phase_val * tr * weight / light_pdf } /// The integrator: an iterative path tracer in two passes. The forward walk @@ -1073,12 +1129,23 @@ fn trace_path( let Some(hit) = hit_opt else { // === Background === + // A ray leaving the scene is how lights at infinity are found + // by chance, so it is a bounce-side MIS event just like hitting + // an emissive surface. let unit_direction = Vec3A::normalize(ray.direction()); - let t = 0.5 * (unit_direction.y + 1.0); - let sky = (1.0 - t) * Vec3A::new(1.0, 1.0, 1.0) + t * Vec3A::new(0.5, 0.7, 1.0); - // Segment emission is already weighted; the sky pays the + let (mut background, covered) = + escaped_emission(&prev, lights, unit_direction, strategy); + if !covered { + // Nothing at infinity covers this direction — keep the + // built-in sky gradient so scenes without an environment + // light look as they always have. + let t = 0.5 * (unit_direction.y + 1.0); + background += + (1.0 - t) * Vec3A::new(1.0, 1.0, 1.0) + t * Vec3A::new(0.5, 0.7, 1.0); + } + // Segment emission is already weighted; the background pays the // volume transmittance of the final segment. - terminal = vol_emit + vol_tr * sky; + terminal = vol_emit + vol_tr * background; break; }; let rec: HitRecord = hit.rec; @@ -1134,29 +1201,30 @@ fn trace_path( // describe the same strategy or emission is double-counted. let mut nee = Vec3A::ZERO; let nee_s = v.new_domain(K_NEE).draw_sample_f32::<4>(); + // `sample_li` returns `None` when the light cannot be reached from + // this point at all — below a dome's horizon, or a degenerate + // coincident point. if let Some(light) = strategy .samples_lights() .then(|| lights.pick(nee_s[0])) .flatten() + && let Some(ls) = light.sample_li(rec.p, nee_s[1], nee_s[2]) { let n_lights = lights.count() as f32; - let light_point = light.sample_point(nee_s[1], nee_s[2]); - let light_dir = light_point - rec.p; - let light_distance = light_dir.length(); - let light_dir_unit = light_dir.normalize(); + let light_dir_unit = ls.direction; let shadow_ray = Ray::new(rec.p, light_dir_unit) .with_time(ray.time()) .with_mask(crate::ray::MASK_SHADOW); let shadow_tr = - shadow_transmittance(world, volumes, &shadow_ray, light_distance, v); + shadow_transmittance(world, volumes, &shadow_ray, ls.distance, v); if shadow_tr != Vec3A::ZERO { // Unsigned: lights behind the ray-facing normal are reachable // through a continuous transmission lobe (opaque materials // evaluate to zero there anyway). let cosine = rec.normal.dot(light_dir_unit).abs(); - let light_pdf = (light.pdf(rec.p, light_point) / n_lights).max(1e-6); + let light_pdf = (ls.pdf / n_lights).max(1e-6); // Evaluate the BSDF toward the light direction. Delta and // transmissive materials return None — they cannot see a @@ -1178,7 +1246,7 @@ fn trace_path( _ => brdf_pdf, }; let weight = strategy.light_weight(light_pdf, bounce_pdf); - nee += light.emission() * brdf_value * cosine * shadow_tr * weight + nee += ls.radiance * brdf_value * cosine * shadow_tr * weight / light_pdf; } } diff --git a/crates/crust-core/tests/usd_scene.rs b/crates/crust-core/tests/usd_scene.rs index 6f87815..b5d2873 100644 --- a/crates/crust-core/tests/usd_scene.rs +++ b/crates/crust-core/tests/usd_scene.rs @@ -709,3 +709,133 @@ def Xform "W" { let _ = std::fs::remove_file(&path); } + +/// A host that decodes nothing real, so the importer's asset plumbing can +/// be tested without an image dependency in `crust-core`: it records what +/// was asked for and hands back a synthetic two-texel map. +struct FakeAssets { + requested: std::sync::Mutex>, +} + +impl crust_core::AssetLoader for FakeAssets { + fn load_environment(&self, path: &std::path::Path) -> Option { + self.requested.lock().unwrap().push(path.to_path_buf()); + crust_core::EnvironmentMap::new( + 2, + 1, + vec![ + crust_core::Vec3A::new(9.0, 0.0, 0.0), + crust_core::Vec3A::new(0.0, 9.0, 0.0), + ], + ) + } +} + +/// Lights at infinity carry no scene geometry, so they must reach the light +/// list without adding hittables. +#[test] +fn loads_domelight_usda() { + let scene = Scene::from_usd(&sample("domelight.usda")) + .expect("failed to open domelight.usda"); + + assert_eq!( + scene.lights.count(), + 2, + "expected the dome and the distant sun, got {}", + scene.lights.count() + ); + // Two spheres and the floor — neither infinite light contributes + // geometry. + assert_eq!( + scene.world.count(), + 3, + "infinite lights must not add hittables, got {} geometries", + scene.world.count() + ); +} + +/// `inputs:texture:file` is resolved against the USD layer's directory and +/// handed to the host — `crust-core` never opens the file itself. +#[test] +fn dome_texture_is_resolved_and_requested_from_the_host() { + let assets = FakeAssets { + requested: std::sync::Mutex::new(Vec::new()), + }; + let scene = Scene::from_usd_with_assets(&sample("domelight.usda"), &assets) + .expect("failed to open domelight.usda"); + assert_eq!(scene.lights.count(), 2); + + let requested = assets.requested.lock().unwrap(); + assert_eq!( + requested.len(), + 1, + "expected exactly one environment request, got {requested:?}" + ); + let path = &requested[0]; + assert!( + path.ends_with("sky_env.exr"), + "unexpected asset requested: {}", + path.display() + ); + assert!( + path.is_absolute() || path.exists(), + "the relative asset path was not resolved against the layer: {}", + path.display() + ); + assert!( + path.exists(), + "resolved path does not point at the checked-in map: {}", + path.display() + ); +} + +/// Both infinite lights must answer for escaping rays — that is the only +/// way a bounce ray can find them — and neither may claim scene geometry. +#[test] +fn infinite_lights_are_found_by_escaping_rays() { + let scene = Scene::from_usd(&sample("domelight.usda")) + .expect("failed to open domelight.usda"); + + let mut dome_like = 0; + let mut cone_like = 0; + for light in &scene.lights.lights { + assert_eq!( + light.geom_id(), + None, + "a light at infinity must not claim scene geometry" + ); + // A dome covers every direction; the sun covers only its cone. + let covered = [ + crust_core::Vec3A::Y, + -crust_core::Vec3A::Y, + crust_core::Vec3A::X, + -crust_core::Vec3A::Z, + ] + .iter() + .filter(|d| light.escaped(crust_core::Vec3A::ZERO, **d).is_some()) + .count(); + if covered == 4 { + dome_like += 1; + } else { + cone_like += 1; + } + + // Whatever it is, sampling it must agree with `escaped` about the + // pdf — the two MIS sides of one strategy. + let s = light + .sample_li(crust_core::Vec3A::ZERO, 0.37, 0.62) + .expect("an infinite light is reachable from anywhere"); + assert!(s.distance.is_infinite(), "a light at infinity cannot be occluded"); + let (_, pdf) = light + .escaped(crust_core::Vec3A::ZERO, s.direction) + .expect("sample_li produced a direction escaped() does not cover"); + assert!( + (pdf - s.pdf).abs() <= 1e-3 * s.pdf.max(pdf), + "MIS sides disagree: sample_li {} vs escaped {}", + s.pdf, + pdf + ); + } + assert_eq!(dome_like, 1, "expected exactly one all-direction dome"); + assert_eq!(cone_like, 1, "expected exactly one cone-shaped distant light"); +} diff --git a/crates/crust-render/Cargo.toml b/crates/crust-render/Cargo.toml index d653b1f..05916ec 100644 --- a/crates/crust-render/Cargo.toml +++ b/crates/crust-render/Cargo.toml @@ -7,7 +7,7 @@ license-file.workspace = true [dependencies] crust-core = {path = "../crust-core"} clap = { version = "4.5.48", features = ["derive"] } -image = { version = "0.25.6", default-features = false, features = ["png"] } +image = { version = "0.25.6", default-features = false, features = ["png", "hdr"] } indicatif = "0.18.0" tracing.workspace = true exr.workspace = true diff --git a/crates/crust-render/src/main.rs b/crates/crust-render/src/main.rs index a200955..d82a69d 100644 --- a/crates/crust-render/src/main.rs +++ b/crates/crust-render/src/main.rs @@ -2,7 +2,7 @@ use clap::Parser; use crust_core::Buffer; use crust_core::Renderer; use crust_core::SamplingStrategy; -use crust_core::Scene; +use crust_core::{AssetLoader, EnvironmentMap, Scene, Vec3A}; use crust_core::{get_settings, simple_scene}; use exr::prelude::*; use indicatif::ProgressBar; @@ -10,6 +10,90 @@ use std::path::Path; use std::time::{Duration, Instant}; use tracing::{Level, debug, error, info}; +/// The host side of `crust_core::AssetLoader`: the engine asks for pixels, +/// the CLI decodes them. That split is why `crust-core` carries no image +/// dependencies — everything that knows a file format lives here. +/// +/// Formats follow the dependencies already linked for writing output: +/// OpenEXR through `exr`, Radiance `.hdr` and LDR images through `image`. +/// LDR pixels are un-gamma'd to linear, since the renderer works in linear +/// light and an sRGB-encoded sky would be noticeably wrong. +struct CliAssets; + +impl AssetLoader for CliAssets { + fn load_environment(&self, path: &Path) -> Option { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let started = Instant::now(); + let loaded = match ext.as_str() { + "exr" => load_exr_environment(path), + _ => load_image_environment(path), + }; + match &loaded { + Some(map) => info!( + "Loaded environment {} ({}x{}) in {:?}", + path.display(), + map.width(), + map.height(), + started.elapsed() + ), + None => error!("Could not load environment {}", path.display()), + } + loaded + } +} + +fn load_exr_environment(path: &Path) -> Option { + let image = read_first_rgba_layer_from_file( + path, + |resolution, _| { + let (w, h) = (resolution.width(), resolution.height()); + (w, h, vec![Vec3A::ZERO; w * h]) + }, + |(w, _h, pixels): &mut (usize, usize, Vec), + pos, + (r, g, b, _a): (f32, f32, f32, f32)| { + pixels[pos.y() * *w + pos.x()] = Vec3A::new(r, g, b); + }, + ) + .map_err(|e| error!("EXR decode failed for {}: {e}", path.display())) + .ok()?; + let (w, h, pixels) = image.layer_data.channel_data.pixels; + EnvironmentMap::new(w, h, pixels) +} + +fn load_image_environment(path: &Path) -> Option { + let decoded = image::open(path) + .map_err(|e| error!("Image decode failed for {}: {e}", path.display())) + .ok()?; + let rgb = decoded.to_rgb32f(); + let (w, h) = (rgb.width() as usize, rgb.height() as usize); + // `to_rgb32f` keeps HDR values as authored, but rescales integer + // formats to 0..1 *without* removing their sRGB transfer curve. Undo it + // for those, so an LDR sky lights the scene in linear light. + let is_hdr = matches!( + path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref(), + Some("hdr") + ); + let to_linear = |c: f32| { + if is_hdr { + c + } else if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }; + let pixels = rgb + .pixels() + .map(|p| Vec3A::new(to_linear(p[0]), to_linear(p[1]), to_linear(p[2]))) + .collect(); + EnvironmentMap::new(w, h, pixels) +} + #[derive(clap::ValueEnum, Clone, Debug, Copy)] enum LoggerLevel { Debug, @@ -123,7 +207,7 @@ fn main() { let scene: Scene = if let Some(t) = input { let input_path = std::path::Path::new(&t); debug!("Scene loaded at path: {:?}", input_path); - match Scene::from_usd(input_path) { + match Scene::from_usd_with_assets(input_path, &CliAssets) { Ok(scene) => scene, Err(e) => { error!("Failed to load USD scene: {}", e); @@ -201,3 +285,69 @@ fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The host side of the asset seam: an EXR written to disk must come + /// back as pixels the engine can build a map from, with the geometry + /// and values intact. `crust-core` cannot test this — it has no + /// decoder, which is the whole point of the split. + #[test] + fn exr_environment_round_trips() { + let dir = std::env::temp_dir().join("crust_env_round_trip"); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("env.exr"); + + let (w, h) = (8usize, 4usize); + // A value per texel that is unmistakable and not 0..1, so an + // accidental LDR clamp or gamma would show up. + let value = |x: usize, y: usize| (x as f32 + 10.0 * y as f32, 2.0, 0.5); + exr::prelude::write_rgb_file(&path, w, h, |x, y| value(x, y)).expect("write exr"); + + let map = load_exr_environment(&path).expect("decode the EXR we just wrote"); + assert_eq!((map.width(), map.height()), (w, h)); + + // Row 0 is the +Y pole by convention, so straight up must read the + // first row. Sampling nearest-texel, +Y lands in column 0. + let up = map.radiance(Vec3A::Y); + assert!( + (up.x - value(0, 0).0).abs() < 1e-4 && (up.y - 2.0).abs() < 1e-4, + "top-row lookup returned {up:?}" + ); + + // And a high dynamic range value survives unclamped. + let low = map.radiance(-Vec3A::Y); + assert!(low.x > 20.0, "bottom row was clamped or gamma'd: {low:?}"); + + let _ = std::fs::remove_file(&path); + } + + /// LDR images are sRGB-encoded; the renderer works in linear light, so + /// the loader must undo the transfer curve or an image-based sky is + /// noticeably wrong. + #[test] + fn ldr_images_are_converted_to_linear() { + let dir = std::env::temp_dir().join("crust_env_round_trip"); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("env.png"); + + // Mid-grey in sRGB (188/255 ~ 0.7373) is ~0.5 in linear light. + let mut img = image::RgbImage::new(4, 2); + for p in img.pixels_mut() { + *p = image::Rgb([188, 188, 188]); + } + img.save(&path).expect("write png"); + + let map = load_image_environment(&path).expect("decode the PNG we just wrote"); + let c = map.radiance(Vec3A::Y); + assert!( + (c.x - 0.5).abs() < 0.02, + "sRGB 188 should be ~0.5 linear, got {}", + c.x + ); + + let _ = std::fs::remove_file(&path); + } +} diff --git a/samples/domelight.usda b/samples/domelight.usda new file mode 100644 index 0000000..85f0eda --- /dev/null +++ b/samples/domelight.usda @@ -0,0 +1,119 @@ +#usda 1.0 +( + doc = "Lights at infinity: a UsdLuxDomeLight carrying a lat-long HDRI (sky_env.exr, with a small very bright sun disc that importance sampling has to find) and a UsdLuxDistantLight for the sharp key. Neither has scene geometry — bounce rays find them by escaping, so the dome replaces the built-in sky gradient." + defaultPrim = "World" + upAxis = "Y" +) + +def Xform "World" +{ + def Camera "Cam" + { + float focalLength = 28 + float horizontalAperture = 20.955 + double3 xformOp:translate = (0, 2.3, 8.5) + float xformOp:rotateX = -8 + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateX"] + } + + # The environment. `intensity` scales the map; the prim's rotation + # turns the sky, which is why the sun in the HDRI lands where it does. + def DomeLight "Sky" + { + float inputs:intensity = 1.0 + color3f inputs:color = (1, 1, 1) + asset inputs:texture:file = @sky_env.exr@ + token inputs:texture:format = "latlong" + float xformOp:rotateY = 20 + uniform token[] xformOpOrder = ["xformOp:rotateY"] + } + + # The key. `inputs:angle` is the source's angular diameter, so widening + # it softens the shadow without changing exposure. + def DistantLight "Sun" + { + float inputs:intensity = 2.2 + float inputs:angle = 1.5 + color3f inputs:color = (1.0, 0.94, 0.82) + float xformOp:rotateY = 40 + float xformOp:rotateX = -48 + uniform token[] xformOpOrder = ["xformOp:rotateY", "xformOp:rotateX"] + } + + def Sphere "Chrome" (prepend apiSchemas = ["MaterialBindingAPI"]) + { + double radius = 1.0 + rel material:binding = + double3 xformOp:translate = (-1.6, 1.0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Sphere "Matte" (prepend apiSchemas = ["MaterialBindingAPI"]) + { + double radius = 1.0 + rel material:binding = + double3 xformOp:translate = (1.6, 1.0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Mesh "Floor" (prepend apiSchemas = ["MaterialBindingAPI"]) + { + rel material:binding = + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [(-40, 0, -40), (40, 0, -40), (40, 0, 40), (-40, 0, 40)] + } + + def Scope "Looks" + { + def Material "Metal" + { + token outputs:surface.connect = + def Shader "Shader" + { + uniform token info:id = "crust:openpbr" + color3f inputs:baseColor = (0.92, 0.9, 0.86) + float inputs:baseMetalness = 1.0 + float inputs:specularRoughness = 0.08 + token outputs:surface + } + } + + def Material "Clay" + { + token outputs:surface.connect = + def Shader "Shader" + { + uniform token info:id = "crust:openpbr" + color3f inputs:baseColor = (0.72, 0.36, 0.28) + float inputs:specularRoughness = 0.65 + token outputs:surface + } + } + + def Material "Ground" + { + token outputs:surface.connect = + def Shader "Shader" + { + uniform token info:id = "crust:openpbr" + color3f inputs:baseColor = (0.48, 0.47, 0.45) + float inputs:specularRoughness = 0.8 + token outputs:surface + } + } + } +} + +def Scope "Render" +{ + def RenderSettings "settings" + { + int2 resolution = (480, 270) + int crust:samplesPerPixel = 96 + int crust:maxDepth = 8 + int crust:minSamplesPerPixel = 32 + float crust:varianceThreshold = 0.02 + int crust:frame = 0 + } +} diff --git a/samples/sky_env.exr b/samples/sky_env.exr new file mode 100644 index 0000000..37c9d5b Binary files /dev/null and b/samples/sky_env.exr differ