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:
selune authored and GitHub committed 2026-10-14 10:00:03 -04:00
1 parent 1ac8bf4aa8
commit 88432d226a
4 files changed
+310

No files matched your search

+5
View File
@@ -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.
+1
View File
@@ -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,
+235
View File
@@ -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);
}
}
+69
View File
@@ -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.