Files
OneCAD/crates/render/src/texture_sampling.rs
T
2026-10-15 19:39:30 -04:00

96 lines
4.1 KiB
Rust

//! Allocation-free periodic reference sampling of already normalized, linear RGBA.
//! No decoding, transfer function, normal interpretation or renderer capability is implied.
use crate::texture_input::MAX_TEXTURE_EDGE;
pub mod mips;
/// CPU reference bound: beyond this, fractional repeat precision is deliberately refused.
/// This is not a GPU/f32 coordinate precision guarantee.
pub const MAX_REPEAT_COORDINATE: f64 = 1_048_576.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SamplingError {
Dimensions,
Length,
Values,
Coordinates,
}
impl std::fmt::Display for SamplingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Dimensions => "texture dimensions must be positive and at most 2048",
Self::Length => "texture storage must be exactly width * height * 4 floats",
Self::Values => "normalized texture channels must be finite and in [0, 1]",
Self::Coordinates => "repeat coordinates must be finite and within the CPU reference bound",
})
}
}
impl std::error::Error for SamplingError {}
/// Validated borrowed normalized RGBA. Construction scans the bounded input once;
/// sampling does constant work, without allocations. The caller retains ownership.
#[derive(Clone, Copy, Debug)]
pub struct TextureView<'a> {
rgba: &'a [f32],
width: usize,
height: usize,
}
impl<'a> TextureView<'a> {
pub fn new(rgba: &'a [f32], dimensions: [u32; 2]) -> Result<Self, SamplingError> {
let [width, height] = dimensions;
if width == 0 || height == 0 || width > MAX_TEXTURE_EDGE || height > MAX_TEXTURE_EDGE {
return Err(SamplingError::Dimensions);
}
let width = usize::try_from(width).map_err(|_| SamplingError::Dimensions)?;
let height = usize::try_from(height).map_err(|_| SamplingError::Dimensions)?;
let length = width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or(SamplingError::Length)?;
if rgba.len() != length {
return Err(SamplingError::Length);
}
if rgba.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
return Err(SamplingError::Values);
}
Ok(Self { rgba, width, height })
}
/// Bilinear level-zero RGB for an explicitly opaque consumer. Alpha is validated
/// but deliberately ignored (even zero-alpha RGB participates). This is NOT
/// transparent compositing or premultiplied-alpha filtering.
/// UV (0,0) is the top-left image boundary, and v increases downwards. Texel
/// centers are ((x+.5)/width, (y+.5)/height). Both axes repeat, without a Y flip.
/// Inputs must already be linear color or linear data; no transfer is applied.
pub fn sample_repeat_opaque(&self, uv: [f64; 2]) -> Result<[f64; 3], SamplingError> {
if uv.iter().any(|v| !v.is_finite() || v.abs() > MAX_REPEAT_COORDINATE) {
return Err(SamplingError::Coordinates);
}
let [u, v] = uv;
let (x0, x1, tx) = axis(u, self.width);
let (y0, y1, ty) = axis(v, self.height);
let mut rgb = [0.0; 3];
for (channel, output) in rgb.iter_mut().enumerate() {
let a = self.channel(x0, y0, channel)?;
let b = self.channel(x1, y0, channel)?;
let c = self.channel(x0, y1, channel)?;
let d = self.channel(x1, y1, channel)?;
*output = (a * (1.0 - tx) + b * tx) * (1.0 - ty) + (c * (1.0 - tx) + d * tx) * ty;
}
Ok(rgb)
}
fn channel(&self, x: usize, y: usize, channel: usize) -> Result<f64, SamplingError> {
self.rgba.get((y * self.width + x) * 4 + channel).copied().map(f64::from).ok_or(SamplingError::Length)
}
}
fn axis(coordinate: f64, size: usize) -> (usize, usize, f64) {
// Reduce before scaling: no huge-coordinate multiplication or unbounded index.
let position = coordinate.rem_euclid(1.0) * size as f64 - 0.5;
let lower = position.floor();
let first = (lower as i64).rem_euclid(size as i64) as usize;
(first, (first + 1) % size, position - lower)
}
#[cfg(test)]
mod tests;