render: add isotropic texture footprint LOD reference (#1460)

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:55:14 -04:00
1 parent 1c9d6617ad
commit b400958c3e
4 files changed
+221

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ pub mod studio;
pub mod surface;
pub mod texture_input;
pub mod texture_interpolation;
pub mod texture_lod;
pub use backend::{
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
+78
View File
@@ -0,0 +1,78 @@
//! Independent isotropic CPU LOD reference from unwrapped UV gradients per shading-grid pixel.
//! No sampling, derivative inference, normal filtering or GPU capability is implied.
use crate::texture_input::MAX_TEXTURE_EDGE;
/// Versioned convention: largest singular value, log2, magnification at zero,
/// saturated at the last floor-halved level. Not a GPU parity guarantee.
pub const LOD_POLICY_ID: &str = "solidcraft.isotropic-singular-value-lod/1";
pub const MAX_UV_GRADIENT: f64 = 1_048_576.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LodError {
Dimensions,
Gradients,
}
impl std::fmt::Display for LodError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Dimensions => "texture dimensions must be positive and within admission limits",
Self::Gradients => "UV gradients must be finite and within the CPU reference bound",
})
}
}
impl std::error::Error for LodError {}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LodSelection {
/// Continuous LOD, explicitly saturated to [0, max_level].
pub lod: f64,
/// Largest singular value of the UV-to-texel differential, before saturation.
pub footprint_texels: f64,
pub max_level: u32,
}
/// Select LOD using caller-supplied derivatives of UNWRAPPED UV per shading-grid pixel.
/// `duv_dx` and `duv_dy` must already account for perspective/placement/mapping.
/// For Sx by Sy supersampling, divide final-image-pixel derivatives by Sx and Sy,
/// respectively. Derivatives already measured on the shading grid need no division.
/// The policy uses a circular differential footprint, not exact pixel-square coverage.
/// It can blur the shorter axis of an elongated footprint; it is not anisotropic.
/// Dimensions describe the base image; its available chain must end at 1x1 through
/// floor-halving. Odd-size levels use the documented log2 approximation.
pub fn select_isotropic_lod(dimensions: [u32; 2], duv_dx: [f64; 2], duv_dy: [f64; 2]) -> Result<LodSelection, LodError> {
let [width, height] = dimensions;
if width == 0 || height == 0 || width > MAX_TEXTURE_EDGE || height > MAX_TEXTURE_EDGE {
return Err(LodError::Dimensions);
}
if duv_dx.iter().chain(&duv_dy).any(|v| !v.is_finite() || v.abs() > MAX_UV_GRADIENT) {
return Err(LodError::Gradients);
}
let [dudx, dvdx] = duv_dx;
let [dudy, dvdy] = duv_dy;
let columns = [dudx * f64::from(width), dvdx * f64::from(height), dudy * f64::from(width), dvdy * f64::from(height)];
let scale = columns.into_iter().map(f64::abs).fold(0.0, f64::max);
let footprint_texels = if scale == 0.0 {
0.0
} else {
let [a, b, c, d] = columns.map(|v| v / scale);
// Largest eigenvalue of J^T J; scaling keeps the quadratic bounded.
let first = a * a + b * b;
let second = c * c + d * d;
let cross = a * c + b * d;
scale * (0.5 * (first + second + (first - second).hypot(2.0 * cross))).sqrt()
};
let mut w = width;
let mut h = height;
let mut max_level = 0;
while w > 1 || h > 1 {
w = (w / 2).max(1);
h = (h / 2).max(1);
max_level += 1;
}
let lod = if footprint_texels <= 1.0 { 0.0 } else { footprint_texels.log2().min(f64::from(max_level)) };
Ok(LodSelection { lod, footprint_texels, max_level })
}
#[cfg(test)]
#[path = "texture_lod_tests.rs"]
mod tests;
+91
View File
@@ -0,0 +1,91 @@
use super::*;
fn close(a: f64, b: f64) {
assert!((a - b).abs() <= 1e-12 * b.abs().max(1.0), "{a} != {b}");
}
#[test]
fn magnification_zero_integer_and_fractional_minification() {
for (footprint, expected) in [(0., 0.), (0.25, 0.), (1., 0.), (2., 1.), (2_f64.sqrt(), 0.5), (8., 3.)] {
let result = select_isotropic_lod([256, 256], [footprint / 256., 0.], [0., footprint / 256.]).unwrap();
close(result.footprint_texels, footprint);
close(result.lod, expected);
assert_eq!(result.max_level, 8);
}
}
#[test]
fn rectangular_textures_scale_each_uv_axis_separately() {
let result = select_isotropic_lod([512, 128], [1. / 512., 0.], [0., 1. / 128.]).unwrap();
close(result.footprint_texels, 1.);
close(result.lod, 0.);
let result = select_isotropic_lod([512, 128], [0., 4. / 128.], [2. / 512., 0.]).unwrap();
close(result.footprint_texels, 4.);
close(result.lod, 2.);
}
#[test]
fn supplied_derivatives_follow_the_actual_supersampling_grid() {
// A final-image pixel covers4 texels. On a2x4 shading grid its per-step
// footprints are2 and1; final downsampling remains a separate operation.
let dx = [4. / 256., 0.];
let dy = [0., 4. / 256.];
let coarse = select_isotropic_lod([256, 256], dx, dy).unwrap();
let fine = select_isotropic_lod([256, 256], dx.map(|v| v / 2.), dy.map(|v| v / 4.)).unwrap();
close(coarse.lod, 2.);
close(fine.footprint_texels, 2.);
close(fine.lod, 1.);
}
#[test]
fn shear_uses_singular_value_rather_than_smaller_max_column_norm() {
// J columns both [2,0]: max column length2, true largest singular value sqrt8.
let result = select_isotropic_lod([256, 256], [2. / 256., 0.], [2. / 256., 0.]).unwrap();
close(result.footprint_texels, 8_f64.sqrt());
close(result.lod, 1.5);
}
#[test]
fn rotation_sign_and_screen_axis_swap_preserve_the_footprint() {
let angle = 0.37_f64;
let (s, c) = angle.sin_cos();
// Rotated texel-space axes with singular values4 and2, rectangular base.
let x = [4. * c / 512., 4. * s / 128.];
let y = [-2. * s / 512., 2. * c / 128.];
for (dx, dy) in [(x, y), (y, x), (x.map(|v| -v), y), (x, y.map(|v| -v))] {
let result = select_isotropic_lod([512, 128], dx, dy).unwrap();
close(result.footprint_texels, 4.);
close(result.lod, 2.);
}
}
#[test]
fn full_chain_saturation_is_explicit_for_odd_single_axis_and_single_texel_images() {
for (dimensions, last) in [([5, 3], 2), ([7, 1], 2), ([1, 7], 2), ([1, 1], 0), ([2048, 2048], 11)] {
let result = select_isotropic_lod(dimensions, [MAX_UV_GRADIENT; 2], [MAX_UV_GRADIENT; 2]).unwrap();
assert_eq!(result.max_level, last);
close(result.lod, f64::from(last));
assert!(result.footprint_texels.is_finite());
}
}
#[test]
fn zero_and_tiny_gradients_are_finite() {
for value in [0., -0., f64::MIN_POSITIVE, f64::from_bits(1)] {
let result = select_isotropic_lod([2048, 2048], [value; 2], [value; 2]).unwrap();
assert!(result.footprint_texels.is_finite());
close(result.lod, 0.);
}
}
#[test]
fn hostile_dimensions_and_each_gradient_component_refuse() {
for dimensions in [[0, 1], [1, 0], [MAX_TEXTURE_EDGE + 1, 1], [u32::MAX; 2]] {
assert_eq!(select_isotropic_lod(dimensions, [0.; 2], [0.; 2]), Err(LodError::Dimensions));
}
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, MAX_UV_GRADIENT + 1., -MAX_UV_GRADIENT - 1.] {
for (x, y) in [([value, 0.], [0., 0.]), ([0., value], [0., 0.]), ([0., 0.], [value, 0.]), ([0., 0.], [0., value])] {
assert_eq!(select_isotropic_lod([2, 2], x, y), Err(LodError::Gradients));
}
}
}
+51
View File
@@ -0,0 +1,51 @@
# Independent CPU LOD reference
`solidcraft_render::texture_lod::select_isotropic_lod` is the small numerical
unit in #1339, supporting the eventual wood consumer under #786. It depends only
on existing texture admission limits and the standard library; #1327/#1332 do
not need to merge first. It installs no texture consumer or renderer capability.
Supply positive base dimensions (each at most 2048) and two derivatives of
unwrapped UV per pixel of the actual shading grid: `duv_dx`, `duv_dy`. Every component must be finite
and have magnitude at most 2^20. Invalid inputs refuse before calculation. The
caller supplies perspective-correct mapping derivatives; the helper does not
infer them from mesh/world bounds, sampled UVs or neighbouring wrapped coordinates.
Differencing after repeat wrapping would turn a seam into a false large footprint.
The shading grid is where samples are evaluated, which may differ from the final
PNG or logical viewport grid. For Sx by Sy supersampling, derivatives measured
per final-image pixel must be divided by Sx for `duv_dx` and Sy for `duv_dy` before
this call. Derivatives already computed on the shading grid must not be divided
again. Choosing a final-pixel footprint for every subsample would over-filter the
image. Downsampling to the final image remains the consumer's separate filter.
The helper infers no SSAA setting, device-pixel ratio or logical display scale.
The texel-space Jacobian is
`[[width*du/dx, width*du/dy], [height*dv/dx, height*dv/dy]]`.
Its largest singular value is the footprint scale. This uses a circular screen
differential convention, not an exact integration over the square pixel area.
It is at least each individual column norm, accounting for shear that a maximum
column estimate misses. An elongated footprint can over-blur its shorter axis:
anisotropic filtering remains separate work.
The implementation scales the Jacobian before forming its quadratic and uses
f64 arithmetic. Admitted gradient/dimension bounds keep all intermediate values
finite. Zero and subpixel footprints select LOD0. Otherwise LOD is log2(scale),
explicitly saturated to the final level of a complete floor-halved chain ending
at 1x1. The result includes the unsaturated scale and maximum level. For odd
dimensions log2 remains a convention approximating non-power-of-two level ratios;
it is not a claim of exact area integration or alias-free minification.
Policy identifier `solidcraft.isotropic-singular-value-lod/1` fixes that
convention. A consumer must explicitly reconcile it with its prepared chain;
incomplete chains and different halving/filter policies cannot silently substitute.
No sampling, alpha/normal interpretation, derivative persistence, allocation,
resource cache, UI, engine command, GPU code or dependency is added. f64 reference
results alone do not certify GPU/f32 precision or visual parity.
Original analytic tests cover zero/magnification, integer and fractional LOD,
rectangular texture scaling, rotated/signed/swapped axes, a sheared Jacobian,
odd/single-axis chains and hostile/extreme inputs. This invisible numerical
helper has no useful app screenshot. The visible consumer still requires typed
bindings, local millimetre mapping, preparation ownership, CPU/GPU integration,
PNG and offline reopen evidence. HDRI and final rendering remain separate work.