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.
This commit is contained in:
1 parent
1ac8bf4aa8
commit
88432d226a
4 files changed
+310
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Self, SurfaceError> {
|
||||
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<Vec3, SurfaceError> {
|
||||
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<Vec3, SurfaceError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user