From 88432d226af517bf4df6498900b00df68b89c876 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:00:03 +0200 Subject: [PATCH] Render: bounded opaque surface contract and numerical BRDF reference (#1019) A renderer-independent OpaqueSurface (colour, metallic fraction, roughness, dielectric IOR) with bounded inputs, an original GGX/Smith/Schlick reference response with Lambert diffuse, and exact piecewise sRGB8 decoding. Numerical contract only: no document format, command or UI change. --- ROADMAP.md | 5 + crates/render/src/lib.rs | 1 + crates/render/src/surface.rs | 235 +++++++++++++++++++++++++++++++++++ docs/render-surface.md | 69 ++++++++++ 4 files changed, 310 insertions(+) create mode 100644 crates/render/src/surface.rs create mode 100644 docs/render-surface.md diff --git a/ROADMAP.md b/ROADMAP.md index 1f6ac851..1fbc2048 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -83,6 +83,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## Render foundation +- The initial opaque surface numerical contract for #1004 provides bounded linear + color/metallic/roughness/IOR admission and an original tested direct-light BRDF. + It does not enable PBR rendering or change stored appearances. See + [the model, evidence and remaining consumer gates](docs/render-surface.md). + - A bounded cooperative Radiance RGBE input utility is available in L1 render; it does not yet enable HDRI/PBR rendering or a document import command. See [the input contract](docs/render-hdr-input.md) and #813 for integration work. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cb9d89cd..34e2ee49 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,7 @@ mod camera; mod curve_display; pub mod lighting; mod raster; +pub mod surface; pub use backend::{ Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits, diff --git a/crates/render/src/surface.rs b/crates/render/src/surface.rs new file mode 100644 index 00000000..89f0aa48 --- /dev/null +++ b/crates/render/src/surface.rs @@ -0,0 +1,235 @@ +//! Bounded opaque metallic/roughness surfaces in linear sRGB. +//! This numerical contract does not enable a renderer or persist appearances. + +use std::f64::consts::PI; + +use solidcraft_geom::Vec3; + +/// Lowest admitted perceptual roughness. Perfect delta mirrors need another integrator. +pub const MIN_ROUGHNESS: f64 = 0.045; + +/// Original single-scattering GGX/Smith/Schlick preview model, with attenuated Lambert diffuse. +/// Private fields keep every instance finite and bounded; deserializers must use `new`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OpaqueSurface { + base_color: Vec3, + metallic: f64, + roughness: f64, + dielectric_ior: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SurfaceError { + BaseColor, + Metallic, + Roughness, + DielectricIor, + Direction, +} + +impl std::fmt::Display for SurfaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::BaseColor => "linear base color channels must be finite and in [0, 1]", + Self::Metallic => "metallic fraction must be finite and in [0, 1]", + Self::Roughness => "perceptual roughness must be finite and in [0.045, 1]", + Self::DielectricIor => "dielectric IOR must be finite and in [1, 3]", + Self::Direction => "surface directions must be finite and nonzero", + }) + } +} +impl std::error::Error for SurfaceError {} + +impl OpaqueSurface { + /// `base_color` is a linear-sRGB reflectance, never encoded sRGB or an HDR emitter. + /// IOR is relative to air; it only controls dielectric normal-incidence reflectance. + pub fn new(base_color: Vec3, metallic: f64, roughness: f64, dielectric_ior: f64) -> Result { + if !base_color.is_finite() || ![base_color.x, base_color.y, base_color.z].into_iter().all(|c| (0.0..=1.0).contains(&c)) { + return Err(SurfaceError::BaseColor); + } + if !metallic.is_finite() || !(0.0..=1.0).contains(&metallic) { + return Err(SurfaceError::Metallic); + } + if !roughness.is_finite() || !(MIN_ROUGHNESS..=1.0).contains(&roughness) { + return Err(SurfaceError::Roughness); + } + if !dielectric_ior.is_finite() || !(1.0..=3.0).contains(&dielectric_ior) { + return Err(SurfaceError::DielectricIor); + } + Ok(Self { base_color, metallic, roughness, dielectric_ior }) + } + + pub fn base_color(self) -> Vec3 { + self.base_color + } + pub fn metallic(self) -> f64 { + self.metallic + } + pub fn roughness(self) -> f64 { + self.roughness + } + pub fn dielectric_ior(self) -> f64 { + self.dielectric_ior + } + + pub fn normal_reflectance(self) -> Vec3 { + let ratio = (self.dielectric_ior - 1.0) / (self.dielectric_ior + 1.0); + Vec3::new(ratio * ratio, ratio * ratio, ratio * ratio).lerp(self.base_color, self.metallic) + } + + /// BRDF in inverse steradians. Directions point away from the surface. + /// No incident radiance, cosine weight, exposure or display transfer is included. + /// Back-facing/tangent directions return zero; malformed directions are errors. + pub fn brdf(self, normal: Vec3, view: Vec3, light: Vec3) -> Result { + let n = unit(normal)?; + let v = unit(view)?; + let l = unit(light)?; + let nv = n.dot(v).clamp(-1.0, 1.0); + let nl = n.dot(l).clamp(-1.0, 1.0); + if nv <= 0.0 || nl <= 0.0 { + return Ok(Vec3::ZERO); + } + let h = unit(v + l)?; + let nh = n.dot(h).clamp(0.0, 1.0); + let vh = v.dot(h).clamp(0.0, 1.0); + let alpha = self.roughness * self.roughness; + let a2 = alpha * alpha; + // This form avoids subtracting two nearly equal values at nh = 1. + let d_denom = (1.0 - nh * nh) + nh * nh * a2; + let distribution = a2 / (PI * d_denom * d_denom); + // G1 / cos(theta) has a finite grazing limit: no epsilon cosine clamp. + let visibility = |cos: f64| 2.0 / (cos + (a2 + (1.0 - a2) * cos * cos).sqrt()); + let specular = distribution * 0.25 * visibility(nv) * visibility(nl); + let f0 = self.normal_reflectance(); + let fresnel = |cos: f64| f0 + (Vec3::new(1.0, 1.0, 1.0) - f0) * (1.0 - cos).powi(5); + let f = fresnel(vh); + // Symmetric entry/exit attenuation avoids adding a full Lambert substrate + // to grazing reflection. Internal reflection / multi-scattering is omitted. + let fv = fresnel(nv); + let fl = fresnel(nl); + let diffuse = self.base_color * ((1.0 - self.metallic) / PI); + Ok(Vec3::new( + f.x * specular + (1.0 - fv.x) * (1.0 - fl.x) * diffuse.x, + f.y * specular + (1.0 - fv.y) * (1.0 - fl.y) * diffuse.y, + f.z * specular + (1.0 - fv.z) * (1.0 - fl.z) * diffuse.z, + )) + } +} + +/// Exact piecewise sRGB decoding of an existing 8-bit color channel. +pub fn srgb8_to_linear(channel: u8) -> f64 { + let c = f64::from(channel) / 255.0; + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +fn unit(v: Vec3) -> Result { + if !v.is_finite() { + return Err(SurfaceError::Direction); + } + let scale = v.x.abs().max(v.y.abs()).max(v.z.abs()); + if scale == 0.0 { + return Err(SurfaceError::Direction); + } + let scaled = v / scale; + Ok(scaled / scaled.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn surface(metallic: f64, roughness: f64, ior: f64) -> OpaqueSurface { + OpaqueSurface::new(Vec3::new(0.8, 0.5, 0.2), metallic, roughness, ior).unwrap() + } + + #[test] + fn admission_rejects_nonfinite_out_of_range_and_delta_mirrors() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.01, 1.01] { + assert!(OpaqueSurface::new(Vec3::new(bad, 0.0, 0.0), 0.0, 0.5, 1.5).is_err()); + assert!(OpaqueSurface::new(Vec3::ZERO, bad, 0.5, 1.5).is_err()); + } + for bad in [f64::NAN, f64::INFINITY, 0.0, MIN_ROUGHNESS - 1e-6, 1.01] { + assert!(OpaqueSurface::new(Vec3::ZERO, 0.0, bad, 1.5).is_err()); + } + for bad in [f64::NAN, f64::INFINITY, 0.99, 3.01] { + assert!(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, bad).is_err()); + } + assert!(OpaqueSurface::new(Vec3::ZERO, 0.0, MIN_ROUGHNESS, 1.0).is_ok()); + } + + #[test] + fn analytic_normal_incidence_dielectric_and_conductor_limits() { + let dielectric = surface(0.0, 1.0, 1.5); + assert!((dielectric.normal_reflectance().x - 0.04).abs() < 1e-14); + let conductor = surface(1.0, 1.0, 1.5); + assert_eq!(conductor.normal_reflectance(), conductor.base_color()); + let actual = conductor.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap(); + assert!((actual - conductor.base_color() / (4.0 * PI)).len() < 1e-14); + let index_matched = surface(0.0, 1.0, 1.0); + assert!((index_matched.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap() - index_matched.base_color() / PI).len() < 1e-14); + } + + #[test] + fn reciprocal_and_finite_at_grazing_and_extreme_direction_scales() { + for metallic in [0.0, 0.5, 1.0] { + for roughness in [MIN_ROUGHNESS, 0.5, 1.0] { + let s = surface(metallic, roughness, 3.0); + for (v, l) in [ + (Vec3::new(0.4, 0.1, 1.0), Vec3::new(-0.3, 0.2, 0.8)), + (Vec3::new(1.0, 0.0, 1e-300), Vec3::new(-1.0, 0.0, 1e-300)), + (Vec3::new(1e308, 1e308, 1e308), Vec3::new(-1e-300, 0.0, 1e-300)), + ] { + let a = s.brdf(Vec3::Z, v, l).unwrap(); + let b = s.brdf(Vec3::Z, l, v).unwrap(); + assert!(a.is_finite() && a.x >= 0.0 && a.y >= 0.0 && a.z >= 0.0); + assert!((a - b).len() <= 1e-10 * (1.0 + a.len())); + } + } + } + let s = surface(0.0, 0.5, 1.5); + assert_eq!(s.brdf(Vec3::Z, Vec3::Z, -Vec3::Z).unwrap(), Vec3::ZERO); + for bad in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)] { + assert_eq!(s.brdf(bad, Vec3::Z, Vec3::Z), Err(SurfaceError::Direction)); + assert_eq!(s.brdf(Vec3::Z, bad, Vec3::Z), Err(SurfaceError::Direction)); + assert_eq!(s.brdf(Vec3::Z, Vec3::Z, bad), Err(SurfaceError::Direction)); + } + } + + #[test] + fn deterministic_hemisphere_reflectance_does_not_create_energy() { + // Midpoint quadrature of f * cos(theta) d(omega), not a universal proof. + // Roughness >= .25 resolves the lobe on this grid; narrower lobes are not + // certified by this quadrature. Analytic and finite-input tests are separate. + const MU: usize = 256; + const PHI: usize = 256; + for metallic in [0.0, 0.5, 1.0] { + for roughness in [0.25, 0.5, 1.0] { + for ior in [1.0, 1.5, 3.0] { + for cosine in [0.2_f64, 1.0] { + let s = OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), metallic, roughness, ior).unwrap(); + let v = Vec3::new((1.0 - cosine * cosine).sqrt(), 0.0, cosine); + let mut energy = Vec3::ZERO; + for i in 0..MU { + let mu = (i as f64 + 0.5) / MU as f64; + let radius = (1.0 - mu * mu).sqrt(); + for j in 0..PHI { + let phi = 2.0 * PI * (j as f64 + 0.5) / PHI as f64; + energy += s.brdf(Vec3::Z, v, Vec3::new(radius * phi.cos(), radius * phi.sin(), mu)).unwrap() * mu; + } + } + energy = energy * (2.0 * PI / (MU * PHI) as f64); + assert!(energy.is_finite() && energy.x <= 1.025, "m={metallic}, r={roughness}, ior={ior}, nv={cosine}: {energy:?}"); + } + } + } + } + } + + #[test] + fn srgb_conversion_includes_the_linear_toe() { + assert_eq!(srgb8_to_linear(0), 0.0); + assert_eq!(srgb8_to_linear(255), 1.0); + assert!((srgb8_to_linear(10) - (10.0 / 255.0) / 12.92).abs() < 1e-15); + assert!((srgb8_to_linear(128) - 0.21586050011389926).abs() < 1e-14); + } +} diff --git a/docs/render-surface.md b/docs/render-surface.md new file mode 100644 index 00000000..029c89ba --- /dev/null +++ b/docs/render-surface.md @@ -0,0 +1,69 @@ +# Opaque surface numerical contract + +First numerical slice of [#1004](https://github.com/bherbruck/solidcraft/issues/1004), +under #813/#620. `solidcraft_render::surface` is an original, device-independent +reference for the upcoming integrated consumer. It does not change existing looks, +commands, document formats, snapshots or viewport rendering. No backend advertises +PBR because this module exists. No external code, dataset or file asset is added. + +## Admission and color + +`OpaqueSurface::new` accepts linear-sRGB reflectance channels in [0, 1], metallic +fraction in [0, 1], perceptual roughness in [0.045, 1] and dielectric relative IOR +in [1, 3]. All values must be finite. Private fields prevent bypassing admission; +future command/deserialization adapters must call this constructor. The lower +roughness bound is an explicit preview limit, not a silently clamped perfect mirror. +These are rendering bounds, not a claim that all real materials fit this model. + +`srgb8_to_linear` decodes existing color channels with the piecewise sRGB transfer, +including its linear toe. Texture roles, other source primaries, HDR radiance, +exposure, tone mapping and output encoding are separate responsibilities. Mechanical +density, elastic constants, condition-dependent measurements and material axes are +not inputs to this visual contract. + +## Response and limitations + +`brdf(normal, view, light)` returns RGB response in inverse steradians. Directions +point away from the surface and are normalized using max-component scaling, allowing +finite extreme input magnitudes without overflow. Zero/nonfinite directions fail; +directions tangent to or below the surface return zero. Consumers apply incident +radiance and the light cosine exactly once, then exposure/display transfer separately. + +The specular term uses isotropic GGX, separable Smith masking/shadowing and Schlick +Fresnel. Alpha is perceptual roughness squared. Dielectric normal reflectance is +`((ior - 1)/(ior + 1))²`; metallic fraction interpolates it towards base color and +removes the diffuse substrate. The algebra evaluates `G1/cos(theta)` directly to +avoid unstable division at grazing angles without clamping the cosine to an epsilon. + +Diffuse is Lambert weighted by `(1-metallic) * (1-F(n·view)) * (1-F(n·light))` per +channel. This symmetric entry/exit attenuation preserves reciprocity and avoids +adding unattenuated diffuse to grazing reflection. It is a conservative preview +approximation; there is no internal reflection or microfacet multiple-scattering +compensation. The model can lose energy, particularly rough metals. It is not an +exact layered BSDF, spectral conductor model or measurement-derived material fit. + +No transmission, refraction, absorption, thin film, anisotropy, subsurface scattering, +texture mapping, IBL, caustics or path integration is provided. In particular, this +opaque model must not be used as an opacity-based substitute for glass. + +## Evidence and remaining gates + +Tests use independent normal-incidence analytic values, reciprocal direction pairs, +grazing/extreme-scale inputs, invalid admission and sRGB reference values. Deterministic +256×256 hemisphere quadrature checks white reflectance at metallic fractions +0/0.5/1, roughness 0.25/0.5/1, IOR 1/1.5/3 and view cosines 0.2/1, with a 0.025 +numerical margin. This finite grid is not a universal energy-conservation proof and +does not resolve the narrowest admitted lobes. Narrow-lobe importance quadrature +and device parity remain consumer acceptance gates. + +An initial half-vector-only diffuse attenuation failed the grazing white dielectric +test (integrated response 1.2324 at roughness 0.25, view cosine 0.2). The symmetric +entry/exit formulation replaces it; the test tolerance was not relaxed to accept +that result. + +Next: connect this meaning through design-owned appearance definitions, existing +assignment commands and product scenes; test persistence, undo and unchanged mass; +install the actual integrated GPU/CPU consumers and record original native examples. +Only then advertise executor capabilities. Unsupported executors must refuse the +new response atomically rather than silently render the legacy scalar approximation. +No screenshots are claimed for this numerical-only slice.