render: add perspective UV interpolation and derivatives (#1461)

Co-Authored-By: selune <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-15 18:04:09 -04:00
1 parent 3dd8e2d176
commit 061c0f3333
4 files changed
+273

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ mod raster;
pub mod studio;
pub mod surface;
pub mod texture_input;
pub mod texture_interpolation;
pub use backend::{
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
+114
View File
@@ -0,0 +1,114 @@
//! Perspective-correct unwrapped UV and analytic gradients from caller-owned barycentrics.
//! No projection, clipping, raster coverage, instance transform or texture sampling.
pub const UV_INTERPOLATION_POLICY: &str = "solidcraft.perspective-uv-differential/1";
/// Matches the existing raster's near-edge admission; does not replace its top-left rule.
pub const BARYCENTRIC_EDGE_TOLERANCE: f64 = 1e-9;
const MAX_UV: f64 = 1_000_000.0;
const MAX_DERIVATIVE: f64 = 1_000_000.0;
const SUM_TOLERANCE: f64 = 1e-12;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InterpolationError {
VertexUv,
ReciprocalW,
Barycentrics,
Derivatives,
Output,
}
impl std::fmt::Display for InterpolationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::VertexUv => "vertex UV must be finite and within one million repeats",
Self::ReciprocalW => "reciprocal clip-w must be finite and in [1e-12, 1e12]",
Self::Barycentrics => "barycentric weights exceed near-edge bounds or do not sum to one",
Self::Derivatives => "barycentric derivatives must be bounded, finite and sum to zero",
Self::Output => "interpolated UV or gradients exceed the numerical reference bounds",
})
}
}
impl std::error::Error for InterpolationError {}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UvDifferential {
pub uv: [f64; 2],
/// Unwrapped UV per actual shading-grid pixel step, not necessarily final-image pixels.
pub duv_dx: [f64; 2],
pub duv_dy: [f64; 2],
}
#[derive(Clone, Copy, Debug)]
pub struct PerspectiveUv {
anchor: [f64; 2],
deltas: [[f64; 2]; 3],
reciprocal_w: [f64; 3],
}
impl PerspectiveUv {
/// Vertices must already have valid positive clip-w after caller-owned clipping.
/// Only a common reciprocal-w scale is removed; UVs are never wrapped or flipped.
pub fn new(uv: [[f64; 2]; 3], reciprocal_w: [f64; 3]) -> Result<Self, InterpolationError> {
if uv.iter().flatten().any(|v| !v.is_finite() || v.abs() > MAX_UV) {
return Err(InterpolationError::VertexUv);
}
if reciprocal_w.iter().any(|v| !v.is_finite() || !(1e-12..=1e12).contains(v)) {
return Err(InterpolationError::ReciprocalW);
}
let [anchor, _, _] = uv;
let [anchor_u, anchor_v] = anchor;
let scale = reciprocal_w.into_iter().fold(0.0, f64::max);
let deltas = uv.map(|[u, v]| [u - anchor_u, v - anchor_v]);
Ok(Self { anchor, deltas, reciprocal_w: reciprocal_w.map(|v| v / scale) })
}
/// Barycentrics and derivatives come from the same projected triangle/shading grid.
/// No coverage rule is applied here: weights beyond the explicit near-edge bound, malformed partitions and
/// unsupported gradients refuse instead of falling back to affine interpolation.
pub fn evaluate(&self, weights: [f64; 3], dweights_dx: [f64; 3], dweights_dy: [f64; 3]) -> Result<UvDifferential, InterpolationError> {
let admitted_weights = -BARYCENTRIC_EDGE_TOLERANCE..=1.0 + 2.0 * BARYCENTRIC_EDGE_TOLERANCE;
if weights.iter().any(|v| !v.is_finite() || !admitted_weights.contains(v)) || (weights.into_iter().sum::<f64>() - 1.0).abs() > SUM_TOLERANCE {
return Err(InterpolationError::Barycentrics);
}
for derivatives in [dweights_dx, dweights_dy] {
let scale = derivatives.into_iter().map(f64::abs).fold(1.0, f64::max);
if derivatives.iter().any(|v| !v.is_finite() || v.abs() > MAX_DERIVATIVE)
|| derivatives.into_iter().sum::<f64>().abs() > SUM_TOLERANCE * scale
{
return Err(InterpolationError::Derivatives);
}
}
let denominator = weighted_sum(weights, self.reciprocal_w);
if !(denominator > 0.0) || !denominator.is_finite() {
return Err(InterpolationError::Output);
}
let dx_denominator = weighted_sum(dweights_dx, self.reciprocal_w);
let dy_denominator = weighted_sum(dweights_dy, self.reciprocal_w);
let evaluate_channel = |anchor, [a, b, c]: [f64; 3]| {
let [qa, qb, qc] = self.reciprocal_w;
let corrected = [a * qa, b * qb, c * qc];
let delta = weighted_sum(weights, corrected) / denominator;
// Avoid denominator squared and subtract the anchor before differentiating.
[
anchor + delta,
(weighted_sum(dweights_dx, corrected) - delta * dx_denominator) / denominator,
(weighted_sum(dweights_dy, corrected) - delta * dy_denominator) / denominator,
]
};
let [[u0, v0], [u1, v1], [u2, v2]] = self.deltas;
let [anchor_u, anchor_v] = self.anchor;
let [u, du_dx, du_dy] = evaluate_channel(anchor_u, [u0, u1, u2]);
let [v, dv_dx, dv_dy] = evaluate_channel(anchor_v, [v0, v1, v2]);
let result = UvDifferential { uv: [u, v], duv_dx: [du_dx, dv_dx], duv_dy: [du_dy, dv_dy] };
if result.uv.iter().chain(&result.duv_dx).chain(&result.duv_dy).any(|v| !v.is_finite() || v.abs() > MAX_UV) {
return Err(InterpolationError::Output);
}
Ok(result)
}
}
fn weighted_sum(a: [f64; 3], b: [f64; 3]) -> f64 {
a.into_iter().zip(b).map(|(a, b)| a * b).sum()
}
#[cfg(test)]
#[path = "texture_interpolation_tests.rs"]
mod tests;
@@ -0,0 +1,104 @@
use super::*;
const UV: [[f64; 2]; 3] = [[0., 0.], [1., 0.], [0., 1.]];
const WEIGHTS: [f64; 3] = [0.5, 0.25, 0.25];
const DX: [f64; 3] = [-1., 1., 0.];
const DY: [f64; 3] = [-1., 0., 1.];
fn close(actual: [f64; 2], expected: [f64; 2]) {
for (a, b) in actual.into_iter().zip(expected) {
assert!((a - b).abs() < 1e-10, "{actual:?} != {expected:?}");
}
}
#[test]
fn orthographic_uv_and_derivatives_match_the_affine_plane() {
let result = PerspectiveUv::new(UV, [1.; 3]).unwrap().evaluate(WEIGHTS, DX, DY).unwrap();
close(result.uv, [0.25, 0.25]);
close(result.duv_dx, [1., 0.]);
close(result.duv_dy, [0., 1.]);
}
#[test]
fn perspective_quotient_and_derivatives_match_closed_form() {
let result = PerspectiveUv::new(UV, [1., 2., 1.]).unwrap().evaluate(WEIGHTS, DX, DY).unwrap();
close(result.uv, [0.4, 0.2]);
close(result.duv_dx, [1.28, -0.16]);
close(result.duv_dy, [0., 0.8]);
}
#[test]
fn common_clip_scale_and_vertex_order_preserve_the_result() {
for scale in [1e-6, 1., 1e6] {
for order in [[0, 1, 2], [2, 1, 0], [1, 2, 0]] {
let q = [scale, 2. * scale, scale];
let interpolation = PerspectiveUv::new(order.map(|i| UV[i]), order.map(|i| q[i])).unwrap();
let result = interpolation.evaluate(order.map(|i| WEIGHTS[i]), order.map(|i| DX[i]), order.map(|i| DY[i])).unwrap();
close(result.uv, [0.4, 0.2]);
close(result.duv_dx, [1.28, -0.16]);
close(result.duv_dy, [0., 0.8]);
}
}
}
#[test]
fn constant_large_offset_has_exactly_zero_gradients() {
let interpolation = PerspectiveUv::new([[999_999., -999_999.]; 3], [1e-12, 1., 1e12]).unwrap();
let result = interpolation.evaluate(WEIGHTS, DX, DY).unwrap();
assert_eq!(result.uv, [999_999., -999_999.]);
assert_eq!(result.duv_dx, [0.; 2]);
assert_eq!(result.duv_dy, [0.; 2]);
}
#[test]
fn vertices_edges_and_shading_grid_units_remain_explicit() {
let interpolation = PerspectiveUv::new(UV, [1., 2., 1.]).unwrap();
close(interpolation.evaluate([0., 1., 0.], DX, DY).unwrap().uv, [1., 0.]);
close(interpolation.evaluate([0.5, 0.5, 0.], DX, DY).unwrap().uv, [2. / 3., 0.]);
let fine = interpolation.evaluate(WEIGHTS, DX.map(|v| v / 2.), DY.map(|v| v / 4.)).unwrap();
close(fine.uv, [0.4, 0.2]);
close(fine.duv_dx, [0.64, -0.08]);
close(fine.duv_dy, [0., 0.2]);
}
#[test]
fn invalid_vertices_and_reciprocal_w_are_refused() {
for value in [f64::NAN, f64::INFINITY, MAX_UV + 1., -MAX_UV - 1.] {
assert!(matches!(PerspectiveUv::new([[value, 0.]; 3], [1.; 3]), Err(InterpolationError::VertexUv)));
}
for value in [f64::NAN, f64::INFINITY, 0., -1., 1e-13, 1e13] {
assert!(matches!(PerspectiveUv::new(UV, [1., value, 1.]), Err(InterpolationError::ReciprocalW)));
}
}
#[test]
fn malformed_partitions_and_gradients_are_refused() {
let interpolation = PerspectiveUv::new(UV, [1.; 3]).unwrap();
for weights in [[0.; 3], [1.; 3], [-0.1, 0.6, 0.5], [f64::NAN, 0., 1.], [f64::INFINITY, 0., 0.]] {
assert_eq!(interpolation.evaluate(weights, DX, DY), Err(InterpolationError::Barycentrics));
}
for derivatives in [[1.; 3], [f64::NAN, 0., 0.], [f64::INFINITY, 0., 0.], [-MAX_DERIVATIVE - 1., MAX_DERIVATIVE + 1., 0.]] {
assert_eq!(interpolation.evaluate(WEIGHTS, derivatives, DY), Err(InterpolationError::Derivatives));
assert_eq!(interpolation.evaluate(WEIGHTS, DX, derivatives), Err(InterpolationError::Derivatives));
}
}
#[test]
fn unsupported_output_gradient_refuses_without_affine_fallback() {
let interpolation = PerspectiveUv::new(UV, [1e-12, 1e12, 1.]).unwrap();
assert_eq!(interpolation.evaluate([1., 0., 0.], DX, DY), Err(InterpolationError::Output));
assert!(PerspectiveUv::new(UV, [1.; 3]).unwrap().evaluate(WEIGHTS, DX, DY).is_ok());
}
#[test]
fn near_edge_admission_keeps_exact_weights_and_refuses_beyond_bound_or_nonpositive_denominator() {
let e = BARYCENTRIC_EDGE_TOLERANCE;
let interpolation = PerspectiveUv::new(UV, [1.; 3]).unwrap();
let result = interpolation.evaluate([-e, 1. + 2. * e, -e], DX, DY).unwrap();
close(result.uv, [1. + 2. * e, -e]);
close(result.duv_dx, [1., 0.]);
close(result.duv_dy, [0., 1.]);
let outside = -e * 1.00001;
assert_eq!(interpolation.evaluate([outside, 0.5, 0.5 - outside], DX, DY), Err(InterpolationError::Barycentrics));
let perspective = PerspectiveUv::new(UV, [1e12, 1e-12, 1.]).unwrap();
assert_eq!(perspective.evaluate([-e, 1. + e, 0.], DX, DY), Err(InterpolationError::Output));
}
+54
View File
@@ -0,0 +1,54 @@
# Perspective UV differential reference
The independent L1 helper in #1346 supports the first wood consumer #1345.
`solidcraft_render::texture_interpolation::PerspectiveUv` accepts three unwrapped
vertex UVs and positive reciprocal clip-w values. `evaluate` takes the existing
rasterizer's screen barycentric weights and their x/y derivatives. It returns UV
and analytic perspective-correct derivatives without changing raster coverage,
projection, clipping, local mm mapping, instance transforms or resource ownership.
No pending helper branch, new dependency, file asset or GPU code is required.
Vertex UV magnitudes are at most one million repeats. Reciprocal-w is finite in
[1e-12, 1e12]; only a common scale is removed, preserving relative perspective.
Weights must be finite in [-1e-9, 1+2e-9] and sum to one within 1e-12. The lower
bound matches the existing raster's near-edge tolerance; the upper bound follows
from two weights at that lower bound and partition of unity. This admits bounded
near-edge extrapolation and preserves the exact supplied weights, including small
negative UV results. It neither clamps weights nor replaces the caller's shared-edge
top-left decision. Negative near-edge weights can make a perspective denominator
nonpositive at extreme clip-w ratios; that explicitly refuses. The caller retains
its existing raw reciprocal-w sum/depth/coverage guards before invoking the helper.
Each derivative must
be finite with magnitude at most one million and sum to zero within
`1e-12 * max(1, largest_component_magnitude)`. No weights or derivatives are
silently repaired, normalized or clamped. The caller must supply derivatives
consistent with its projected triangle; admission alone cannot prove that.
For q=1/w, the interpolation is `UV = sum(b*q*UV_vertex) / sum(b*q)`.
The helper subtracts the first vertex's UV before forming the numerator, then
adds it back to the result. This makes a constant large UV offset have exactly
zero derivatives instead of cancellation artifacts. Derivatives use the quotient
rule as `(dN - (N/D)*dD)/D`, avoiding a squared tiny denominator. Intermediates
are f64. Returned UV and derivative magnitudes must remain at most one million,
finite, or the evaluation refuses. No affine fallback hides unsupported results.
UVs remain unwrapped: do not repeat-wrap vertices before interpolation. No Y flip
or normal/tangent interpretation is added. The barycentric derivatives are per
pixel of the actual shading grid. When starting from final-image-pixel derivatives
and supersampling Sx by Sy, the caller divides x/y derivatives by Sx/Sy exactly
once. Already shading-grid derivatives must not be rescaled. This matches #1339's
LOD boundary without introducing a dependency on that unpublished implementation.
Caller responsibilities include positive clip-w after near-plane clipping,
triangle degeneracy/coverage/top-left rules, sample placement and valid derivatives.
This is a numerical reference, not a new rasterizer, clipping algorithm or proof
of that caller policy. GPU interpolation/gradient precision requires separate parity
evidence. Anisotropy, normal filtering, grain-frame inference and a visible texture
consumer remain separate work. No HDRI or final renderer capability is advertised.
Version identifier `solidcraft.perspective-uv-differential/1` documents the input,
partition, precision and refusal policy. Original analytic tests cover affine and
perspective values/derivatives, reciprocal-w scaling, vertex order, large constant
offsets, edges, supersampling units and hostile/refused outputs. This invisible
helper has no useful app screenshot; actual wood still requires PNG, CPU/GPU and
offline-reopen evidence from the installed consumer.