render: add isotropic texture footprint LOD reference (#1460)
Co-Authored-By: selune <0xlillith@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+221
No files matched your search
@@ -15,6 +15,7 @@ pub mod studio;
|
|||||||
pub mod surface;
|
pub mod surface;
|
||||||
pub mod texture_input;
|
pub mod texture_input;
|
||||||
pub mod texture_interpolation;
|
pub mod texture_interpolation;
|
||||||
|
pub mod texture_lod;
|
||||||
|
|
||||||
pub use backend::{
|
pub use backend::{
|
||||||
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
|
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in new issue
Block a user