From 845f4913cf1ee24bf2a7081432d9c9b8bc5e1611 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:39:30 +0200 Subject: [PATCH] render: add opaque CPU sampling and bounded mip chains (#1459) Co-Authored-By: selune <0xlillith@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5.5 --- crates/render/src/lib.rs | 1 + crates/render/src/texture_sampling.rs | 95 +++++++++ crates/render/src/texture_sampling/mips.rs | 191 ++++++++++++++++++ .../render/src/texture_sampling/mips/tests.rs | 115 +++++++++++ crates/render/src/texture_sampling/tests.rs | 94 +++++++++ docs/render-texture-mips.md | 69 +++++++ docs/render-texture-sampling.md | 47 +++++ 7 files changed, 612 insertions(+) create mode 100644 crates/render/src/texture_sampling.rs create mode 100644 crates/render/src/texture_sampling/mips.rs create mode 100644 crates/render/src/texture_sampling/mips/tests.rs create mode 100644 crates/render/src/texture_sampling/tests.rs create mode 100644 docs/render-texture-mips.md create mode 100644 docs/render-texture-sampling.md diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9a1af147..126ecf9c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,7 @@ pub mod surface; pub mod texture_input; pub mod texture_interpolation; pub mod texture_lod; +pub mod texture_sampling; pub use backend::{ Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits, diff --git a/crates/render/src/texture_sampling.rs b/crates/render/src/texture_sampling.rs new file mode 100644 index 00000000..72efd461 --- /dev/null +++ b/crates/render/src/texture_sampling.rs @@ -0,0 +1,95 @@ +//! 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 { + 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 { + 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; diff --git a/crates/render/src/texture_sampling/mips.rs b/crates/render/src/texture_sampling/mips.rs new file mode 100644 index 00000000..c0bffec1 --- /dev/null +++ b/crates/render/src/texture_sampling/mips.rs @@ -0,0 +1,191 @@ +//! Bounded linear area-filtered mip preparation for opaque CPU reference sampling. +//! Call from the existing preparation host; row cancellation does not yield a browser event loop. +use super::{SamplingError, TextureView}; + +/// Derived pixel storage only, excluding the borrowed base and small level metadata. +pub const MAX_MIP_TAIL_BYTES: usize = 32 * 1024 * 1024; +/// Include this recipe in preparation/cache identity, alongside source/role/policies. +/// V1 means floor-halved linear area filtering, opaque RGB and generated alpha=1. +pub const MIP_RECIPE_ID: &str = "solidcraft.opaque-linear-area-mips/1"; +const MAX_LEVELS: usize = 12; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MipError { + Budget, + Allocation, + Cancelled, + Lod, + Sampling(SamplingError), +} +impl std::fmt::Display for MipError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Budget => f.write_str("mip tail storage exceeds the admitted budget"), + Self::Allocation => f.write_str("cannot allocate mip tail storage"), + Self::Cancelled => f.write_str("mip preparation cancelled"), + Self::Lod => f.write_str("LOD must be finite and within the prepared level range"), + Self::Sampling(e) => e.fmt(f), + } + } +} +impl std::error::Error for MipError {} +impl From for MipError { + fn from(value: SamplingError) -> Self { + Self::Sampling(value) + } +} + +struct Level { + rgba: Vec, + width: usize, + height: usize, +} +impl Level { + fn view(&self) -> TextureView<'_> { + // Private levels are created only from admitted pixels and convex averages. + // Reuse validation rather than rescan every stored pixel on each sample. + TextureView { rgba: &self.rgba, width: self.width, height: self.height } + } +} + +/// Immutable derived levels; level zero borrows the admitted base without copying it. +/// Generated levels have alpha=1, matching the explicitly opaque RGB sampler. +pub struct MipChain<'a> { + base: TextureView<'a>, + tail: Vec, + tail_bytes: usize, + retained_bytes: usize, +} +impl<'a> MipChain<'a> { + /// Generate all floor-halved levels through 1x1, or return no chain on failure. + /// `max_tail_bytes` bounds derived pixel capacity, not total process memory. + /// Preparation is synchronous and must run in the caller-owned preparation host. + pub fn prepare(base: TextureView<'a>, max_tail_bytes: usize, cancelled: impl Fn() -> bool) -> Result { + if max_tail_bytes > MAX_MIP_TAIL_BYTES { + return Err(MipError::Budget); + } + check_cancel(&cancelled)?; + let required = required_bytes(base.width, base.height)?; + if required > max_tail_bytes { + return Err(MipError::Budget); + } + let mut chain = Self { base, tail: Vec::new(), tail_bytes: 0, retained_bytes: size_of::() }; + chain.tail.try_reserve_exact(MAX_LEVELS - 1).map_err(|_| MipError::Allocation)?; + let metadata_bytes = + chain.tail.capacity().checked_mul(size_of::()).and_then(|n| n.checked_add(size_of::())).ok_or(MipError::Budget)?; + chain.retained_bytes = metadata_bytes; + while chain.level_count() < MAX_LEVELS { + check_cancel(&cancelled)?; + let source = chain.level(chain.level_count() - 1).ok_or(MipError::Budget)?; + if source.width == 1 && source.height == 1 { + break; + } + let width = (source.width / 2).max(1); + let height = (source.height / 2).max(1); + let length = width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or(MipError::Budget)?; + let mut rgba = Vec::new(); + rgba.try_reserve_exact(length).map_err(|_| MipError::Allocation)?; + let bytes = rgba.capacity().checked_mul(size_of::()).ok_or(MipError::Budget)?; + let next_bytes = chain.tail_bytes.checked_add(bytes).ok_or(MipError::Budget)?; + if next_bytes > max_tail_bytes { + return Err(MipError::Budget); + } + let retained_bytes = metadata_bytes.checked_add(next_bytes).ok_or(MipError::Budget)?; + for y in 0..height { + check_cancel(&cancelled)?; + for x in 0..width { + let rgb = area_average(source, x, y, width, height)?; + rgba.extend(rgb.map(|v| v as f32)); + rgba.push(1.0); + } + } + check_cancel(&cancelled)?; + chain.tail.push(Level { rgba, width, height }); + chain.tail_bytes = next_bytes; + chain.retained_bytes = retained_bytes; + } + check_cancel(&cancelled)?; + Ok(chain) + } + + pub fn level_count(&self) -> usize { + self.tail.len() + 1 + } + pub fn tail_bytes(&self) -> usize { + self.tail_bytes + } + /// Retained pixel/level Vec capacities plus the chain value. Charge to the + /// existing host pool; borrowed base ownership and allocator overhead are separate. + pub fn retained_bytes(&self) -> usize { + self.retained_bytes + } + /// Borrow a prepared level without allocation or another full-image admission scan. + pub fn level(&self, index: usize) -> Option> { + if index == 0 { Some(self.base) } else { self.tail.get(index - 1).map(Level::view) } + } + pub fn dimensions(&self, index: usize) -> Option<[u32; 2]> { + self.level(index).map(|v| [v.width as u32, v.height as u32]) + } + + /// Explicit LOD only: bilinear within each level, linear between adjacent levels. + /// This does not choose a footprint, provide anisotropy or certify GPU parity. + /// Out-of-range LOD refuses rather than silently clamping to a different policy. + pub fn sample_repeat_lod_opaque(&self, uv: [f64; 2], lod: f64) -> Result<[f64; 3], MipError> { + let last = self.level_count() - 1; + if !lod.is_finite() || lod < 0.0 || lod > last as f64 { + return Err(MipError::Lod); + } + let low = lod.floor() as usize; + let high = (low + 1).min(last); + let t = lod - low as f64; + let a = self.level(low).ok_or(MipError::Lod)?.sample_repeat_opaque(uv)?; + let b = self.level(high).ok_or(MipError::Lod)?.sample_repeat_opaque(uv)?; + let mut rgb = [0.0; 3]; + for ((output, a), b) in rgb.iter_mut().zip(a).zip(b) { + *output = a * (1.0 - t) + b * t; + } + Ok(rgb) + } +} + +fn check_cancel(cancelled: &impl Fn() -> bool) -> Result<(), MipError> { + if cancelled() { Err(MipError::Cancelled) } else { Ok(()) } +} + +fn required_bytes(mut width: usize, mut height: usize) -> Result { + let mut bytes = 0usize; + for _ in 1..MAX_LEVELS { + if width == 1 && height == 1 { + return Ok(bytes); + } + width = (width / 2).max(1); + height = (height / 2).max(1); + let level_bytes = width.checked_mul(height).and_then(|n| n.checked_mul(4 * size_of::())).ok_or(MipError::Budget)?; + bytes = bytes.checked_add(level_bytes).ok_or(MipError::Budget)?; + } + if width == 1 && height == 1 { Ok(bytes) } else { Err(MipError::Budget) } +} + +fn area_average(source: TextureView<'_>, x: usize, y: usize, width: usize, height: usize) -> Result<[f64; 3], MipError> { + // Integer overlap numerators avoid dropping the final row/column at odd sizes. + // All factors are bounded by the validated 2048 edge; at most 3x3 source cells. + let x_start = x * source.width; + let x_end = (x + 1) * source.width; + let y_start = y * source.height; + let y_end = (y + 1) * source.height; + let mut rgb = [0.0; 3]; + for sy in y_start / height..y_end.div_ceil(height) { + let wy = ((sy + 1) * height).min(y_end) - (sy * height).max(y_start); + for sx in x_start / width..x_end.div_ceil(width) { + let wx = ((sx + 1) * width).min(x_end) - (sx * width).max(x_start); + let weight = wx as f64 * wy as f64 / (source.width as f64 * source.height as f64); + for (channel, output) in rgb.iter_mut().enumerate() { + *output += source.channel(sx, sy, channel)? * weight; + } + } + } + Ok(rgb) +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/texture_sampling/mips/tests.rs b/crates/render/src/texture_sampling/mips/tests.rs new file mode 100644 index 00000000..417f228a --- /dev/null +++ b/crates/render/src/texture_sampling/mips/tests.rs @@ -0,0 +1,115 @@ +use super::*; +use std::cell::Cell; + +fn close(actual: [f64; 3], expected: [f64; 3]) { + for (a, b) in actual.into_iter().zip(expected) { + assert!((a - b).abs() < 1e-7, "{actual:?} != {expected:?}"); + } +} + +#[test] +fn linear_checkerboard_mean_and_fractional_lod() { + let pixels = [0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 0., 0., 0., 0.]; + let base = TextureView::new(&pixels, [2, 2]).unwrap(); + let chain = MipChain::prepare(base, 16, || false).unwrap(); + assert_eq!(chain.level_count(), 2); + assert_eq!(chain.tail_bytes(), 16); + assert_eq!(chain.retained_bytes(), chain.tail_bytes() + chain.tail.capacity() * size_of::() + size_of::>()); + assert_eq!(MIP_RECIPE_ID, "solidcraft.opaque-linear-area-mips/1"); + close(chain.sample_repeat_lod_opaque([0.25, 0.25], 0.).unwrap(), [0.; 3]); + close(chain.sample_repeat_lod_opaque([0.25, 0.25], 1.).unwrap(), [0.5; 3]); + close(chain.sample_repeat_lod_opaque([0.25, 0.25], 0.25).unwrap(), [0.125; 3]); + close(chain.sample_repeat_lod_opaque([0.25, 0.25], 0.5).unwrap(), [0.25; 3]); +} + +#[test] +fn odd_edges_contribute_by_area_instead_of_being_dropped_or_overweighted() { + // Five pixels reduce to two equal-width cells: [0,0,0] and [0,0,1]. + // The middle pixel straddles the boundary, the last pixel weighs 1/2.5. + let mut pixels = [0., 0., 0., 1.].repeat(5); + pixels[16] = 1.; + let chain = MipChain::prepare(TextureView::new(&pixels, [5, 1]).unwrap(), 48, || false).unwrap(); + assert_eq!(chain.dimensions(1), Some([2, 1])); + close(chain.level(1).unwrap().sample_repeat_opaque([0.75, 0.5]).unwrap(), [0.4, 0., 0.]); + close(chain.sample_repeat_lod_opaque([0., 0.], 2.).unwrap(), [0.2, 0., 0.]); +} + +#[test] +fn rectangular_odd_pyramid_preserves_independent_mean() { + let mut pixels = Vec::new(); + for i in 0..15 { + pixels.extend([i as f32 / 16., 0.25, (14 - i) as f32 / 16., (i % 2) as f32]); + } + let chain = MipChain::prepare(TextureView::new(&pixels, [5, 3]).unwrap(), 48, || false).unwrap(); + assert_eq!(chain.level_count(), 3); + assert_eq!(chain.dimensions(0), Some([5, 3])); + assert_eq!(chain.dimensions(1), Some([2, 1])); + assert_eq!(chain.dimensions(2), Some([1, 1])); + assert_eq!(chain.dimensions(usize::MAX), None); + close(chain.sample_repeat_lod_opaque([-9.5, 12.], 2.).unwrap(), [7. / 16., 0.25, 7. / 16.]); +} + +#[test] +fn single_texel_borrows_base_and_needs_no_pixel_budget() { + let pixels = [0.25, 0.5, 0.75, 0.]; + let chain = MipChain::prepare(TextureView::new(&pixels, [1, 1]).unwrap(), 0, || false).unwrap(); + assert_eq!(chain.level_count(), 1); + assert_eq!(chain.tail_bytes(), 0); + assert!(std::ptr::eq(chain.level(0).unwrap().rgba.as_ptr(), pixels.as_ptr())); + close(chain.sample_repeat_lod_opaque([-0.1, 0.7], 0.).unwrap(), [0.25, 0.5, 0.75]); +} + +#[test] +fn constant_images_and_single_axes_are_invariant_at_all_lods() { + for dimensions in [[1, 7], [7, 1], [7, 5]] { + let pixels = [0.25, 0.5, 0.75, 0.].repeat((dimensions[0] * dimensions[1]) as usize); + let chain = MipChain::prepare(TextureView::new(&pixels, dimensions).unwrap(), MAX_MIP_TAIL_BYTES, || false).unwrap(); + for quarter in 0..=(chain.level_count() - 1) * 4 { + close(chain.sample_repeat_lod_opaque([-2.34, 1.92], quarter as f64 / 4.).unwrap(), [0.25, 0.5, 0.75]); + } + } +} + +#[test] +fn hostile_lod_and_uv_refuse_without_changing_the_chain() { + let pixels = [0., 0., 0., 1.].repeat(4); + let chain = MipChain::prepare(TextureView::new(&pixels, [2, 2]).unwrap(), 16, || false).unwrap(); + for lod in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.001, 1.001, f64::MAX] { + assert_eq!(chain.sample_repeat_lod_opaque([0., 0.], lod), Err(MipError::Lod)); + } + assert_eq!(chain.sample_repeat_lod_opaque([f64::NAN, 0.], 0.5), Err(MipError::Sampling(SamplingError::Coordinates))); + close(chain.sample_repeat_lod_opaque([0.25, 0.25], 0.).unwrap(), [0.; 3]); +} + +#[test] +fn exact_budget_and_global_cap_are_admitted_before_work() { + let pixels = [0., 0., 0., 1.].repeat(4); + let base = TextureView::new(&pixels, [2, 2]).unwrap(); + assert!(matches!(MipChain::prepare(base, 15, || false), Err(MipError::Budget))); + assert!(MipChain::prepare(base, 16, || false).is_ok()); + assert!(matches!(MipChain::prepare(base, MAX_MIP_TAIL_BYTES + 1, || false), Err(MipError::Budget))); + assert!(matches!(MipChain::prepare(base, usize::MAX, || false), Err(MipError::Budget))); + assert_eq!(required_bytes(2048, 2048).unwrap(), 22_369_616); + assert!(required_bytes(2048, 2048).unwrap() < MAX_MIP_TAIL_BYTES); +} + +#[test] +fn cancellation_before_during_and_after_preparation_never_returns_a_partial_chain() { + let pixels = [0.25, 0.5, 0.75, 0.].repeat(64); + let base = TextureView::new(&pixels, [8, 8]).unwrap(); + let successful_calls = Cell::new(0); + MipChain::prepare(base, MAX_MIP_TAIL_BYTES, || { + successful_calls.set(successful_calls.get() + 1); + false + }) + .unwrap(); + for stop in 1..=successful_calls.get() { + let calls = Cell::new(0); + let result = MipChain::prepare(base, MAX_MIP_TAIL_BYTES, || { + calls.set(calls.get() + 1); + calls.get() == stop + }); + assert!(matches!(result, Err(MipError::Cancelled))); + } + assert!(MipChain::prepare(base, MAX_MIP_TAIL_BYTES, || false).is_ok()); +} diff --git a/crates/render/src/texture_sampling/tests.rs b/crates/render/src/texture_sampling/tests.rs new file mode 100644 index 00000000..93040acf --- /dev/null +++ b/crates/render/src/texture_sampling/tests.rs @@ -0,0 +1,94 @@ +use super::*; + +const PIXELS: [f32; 16] = [1., 0., 0., 0., 0., 1., 0., 1., 0., 0., 1., 1., 1., 1., 1., 1.]; + +fn close(actual: [f64; 3], expected: [f64; 3]) { + for (a, b) in actual.into_iter().zip(expected) { + assert!((a - b).abs() < 1e-9, "{actual:?} != {expected:?}"); + } +} + +#[test] +fn centers_orientation_and_explicit_opaque_alpha() { + let view = TextureView::new(&PIXELS, [2, 2]).unwrap(); + for (uv, rgb) in [([0.25, 0.25], [1., 0., 0.]), ([0.75, 0.25], [0., 1., 0.]), ([0.25, 0.75], [0., 0., 1.]), ([0.75, 0.75], [1., 1., 1.])] { + close(view.sample_repeat_opaque(uv).unwrap(), rgb); + } + close(view.sample_repeat_opaque([0.5, 0.5]).unwrap(), [0.5; 3]); + close(view.sample_repeat_opaque([0.5, 0.25]).unwrap(), [0.5, 0.5, 0.]); + close(view.sample_repeat_opaque([0.375, 0.625]).unwrap(), [0.375, 0.25, 0.75]); +} + +#[test] +fn linear_black_white_mixing_is_not_encoded_srgb_averaging() { + let pixels = [0., 0., 0., 1., 1., 1., 1., 1.]; + let view = TextureView::new(&pixels, [2, 1]).unwrap(); + // Linear 0.5, which would encode to about sRGB 0.735, not encoded 0.5. + close(view.sample_repeat_opaque([0.5, 0.5]).unwrap(), [0.5; 3]); +} + +#[test] +fn signed_repeats_and_both_periodic_seams() { + let view = TextureView::new(&PIXELS, [2, 2]).unwrap(); + for uv in [[0., 0.], [1., 1.], [-1., -1.], [MAX_REPEAT_COORDINATE, -MAX_REPEAT_COORDINATE]] { + close(view.sample_repeat_opaque(uv).unwrap(), [0.5; 3]); + } + close(view.sample_repeat_opaque([-0.75, -0.25]).unwrap(), [0., 0., 1.]); + for uv in [[0.13, 0.92], [0., 0.25], [0.75, 0.]] { + close(view.sample_repeat_opaque(uv).unwrap(), view.sample_repeat_opaque([uv[0] - 7., uv[1] + 9.]).unwrap()); + } + for epsilon in [-1e-12, 0., 1e-12] { + close(view.sample_repeat_opaque([epsilon, 0.25]).unwrap(), [0.5, 0.5, 0.]); + close(view.sample_repeat_opaque([0.25, epsilon]).unwrap(), [0.5, 0., 0.5]); + } +} + +#[test] +fn constant_and_single_texel_invariance() { + for dimensions in [[1, 1], [1, 3], [3, 1], [3, 5]] { + let pixels = [0.25, 0.5, 0.75, 0.].repeat((dimensions[0] * dimensions[1]) as usize); + let view = TextureView::new(&pixels, dimensions).unwrap(); + for uv in [[0., 0.], [-12.125, 3.25], [1., 1.], [MAX_REPEAT_COORDINATE - 0.125, 0.5]] { + close(view.sample_repeat_opaque(uv).unwrap(), [0.25, 0.5, 0.75]); + } + } +} + +#[test] +fn rejects_hostile_storage_and_channels() { + for dimensions in [[0, 1], [1, 0], [MAX_TEXTURE_EDGE + 1, 1], [u32::MAX, u32::MAX]] { + assert_eq!(TextureView::new(&[], dimensions).unwrap_err(), SamplingError::Dimensions); + } + for pixels in [&[][..], &[0.; 3][..], &[0.; 5][..]] { + assert_eq!(TextureView::new(pixels, [1, 1]).unwrap_err(), SamplingError::Length); + } + for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] { + for channel in 0..4 { + let mut pixels = [0.; 4]; + pixels[channel] = invalid; + assert_eq!(TextureView::new(&pixels, [1, 1]).unwrap_err(), SamplingError::Values); + } + } +} + +#[test] +fn rejects_nonfinite_and_extreme_coordinates() { + let view = TextureView::new(&PIXELS, [2, 2]).unwrap(); + for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, MAX_REPEAT_COORDINATE + 0.001, -MAX_REPEAT_COORDINATE - 0.001] { + for uv in [[invalid, 0.], [0., invalid]] { + assert_eq!(view.sample_repeat_opaque(uv), Err(SamplingError::Coordinates)); + } + } +} + +#[test] +fn reference_bound_retains_fractional_centers_and_maximum_edge() { + let view = TextureView::new(&PIXELS, [2, 2]).unwrap(); + close(view.sample_repeat_opaque([MAX_REPEAT_COORDINATE - 0.75, -MAX_REPEAT_COORDINATE + 0.25]).unwrap(), [1., 0., 0.]); + let mut pixels = [0., 0., 0., 1.].repeat(MAX_TEXTURE_EDGE as usize); + pixels[0] = 1.; + let view = TextureView::new(&pixels, [MAX_TEXTURE_EDGE, 1]).unwrap(); + let half_texel = 0.5 / f64::from(MAX_TEXTURE_EDGE); + close(view.sample_repeat_opaque([half_texel, 0.]).unwrap(), [1., 0., 0.]); + close(view.sample_repeat_opaque([half_texel - MAX_REPEAT_COORDINATE, 0.]).unwrap(), [1., 0., 0.]); +} diff --git a/docs/render-texture-mips.md b/docs/render-texture-mips.md new file mode 100644 index 00000000..33dc6378 --- /dev/null +++ b/docs/render-texture-mips.md @@ -0,0 +1,69 @@ +# Opaque CPU mip reference + +The numerical follow-up #1332 reuses the reference sampler in #1327 through +`solidcraft_render::texture_sampling::mips`. It adds no installed renderer, +resource storage, normalization, file decoder, worker, cache or GPU capability. +The visible wood consumer remains separate work. + +`MipChain::prepare(base, max_tail_bytes, cancelled)` borrows a validated +`TextureView` as level zero. Each following level has dimensions +`[max(1, width / 2), max(1, height / 2)]` until 1x1, at most 12 levels including +the base. All derived levels are immutable and allocated through fallible reserve. +The base is not copied. `level` borrows a prepared view without another whole-image +admission scan; `dimensions`, `level_count` and `tail_bytes` expose its accounting. + +The caller chooses a derived pixel capacity budget no larger than 32 MiB. +The complete required pixel size is checked before work, and retained Vec capacity +is checked before filling each level. A maximum admitted 2048x2048 base requires +22,369,616 derived pixel bytes. The borrowed base (up to 64 MiB), small bounded +level metadata, allocator overhead and other normalization/preparation allocations +are separate. This is not a total process-memory bound. The existing resource host +must count both the retained base lease and derived levels against its cache budget. +`retained_bytes()` includes the actual pixel and level-vector capacities and the +chain value. The host must also budget allocator overhead conservatively; this +helper's cap is not an additional allowance outside #1237's shared preparation pool. +Keep the borrowed base alive through its existing prepared-result/lease. + +`MIP_RECIPE_ID` is `solidcraft.opaque-linear-area-mips/1`: include it in preparation +and cache identity alongside source digest, role, normalization policies and host +generation. This recipe fixes floor-halving, linear area filtering and opaque alpha. +It must not alias level-zero-only output, a normal filter or another alpha policy. +Future changes to those semantics need a new recipe identity. + +Every destination texel is an area average over its equal-sized cell in the source +image, treating source texels as piecewise constant. Integer overlap numerators +include partial source cells. Thus odd edges are neither discarded nor given the +weight of a complete destination cell. For example, [0,0,0,0,1] reduces to [0,0.4] +then [0.2], preserving its original mean. Intermediates use f64 and each generated +level is stored in f32, so successive levels include f32 rounding. No transfer +function is applied; the input must already be linear normalized color/data. + +The API is deliberately opaque: alpha is ignored in RGB filtering and generated +levels have alpha=1. This is not premultiplied filtering or transparent compositing. +Normal maps need a separate role-aware vector filtering policy and must not use +this helper as though scalar averaging produced valid unit normals. There is no +hidden Y flip, tangent basis, grain-axis inference or source orientation transform. + +Cancellation is checked before work, at each level, before every destination row, +after a level and before returning the complete chain. Failure/cancellation drops +local partial results; callers receive no partial chain. Preparation is synchronous +and belongs to the existing caller-owned host. Cancellation polls do not yield the +browser event loop, schedule a worker or establish browser responsiveness. + +`sample_repeat_lod_opaque(uv, lod)` applies the #1327 periodic bilinear convention +within each level and interpolates adjacent levels in linear space. LOD must be +finite in [0, level_count - 1]; out-of-range values refuse. Signed/unwrapped UVs +keep the sampler's existing finite 2^20 repeat bound. Sampling performs no +allocation or full-image validation scan. GPU/f32 parity is not inferred from +the CPU arithmetic. + +LOD is supplied explicitly. Screen-space derivatives, footprint selection, +anisotropic filtering and grazing-angle grain remain consumer work; a mip chain +alone is not a proof of alias-free wood. Typed bindings, local mm mapping and +inverse-instance placement, CPU/GPU consumers, PNG/offline reopen and real native +visual evidence are still required. HDRI and final rendering are not enabled. + +Original generated tests cover linear checkerboard and fractional LOD values, +area-weighted odd/rectangular means, 1x1 and single-axis images, budget boundaries, +hostile LOD/UVs and cancellation at every publication checkpoint. No third-party +assets, code or dependencies are added. This numerical helper has no app screenshot. diff --git a/docs/render-texture-sampling.md b/docs/render-texture-sampling.md new file mode 100644 index 00000000..ee1be1f4 --- /dev/null +++ b/docs/render-texture-sampling.md @@ -0,0 +1,47 @@ +# CPU texture reference sampler + +`solidcraft_render::texture_sampling` supplies the independent numerical unit in +#1327. It does not install a texture consumer or close the wood rendering work. + +Construct `TextureView::new(rgba, [width, height])` from immutable normalized float +RGBA. Dimensions must be positive and no larger than the existing admission edge +limit (2048); storage is exactly `width * height * 4` floats, at most 64 MiB. +Every channel, including alpha, must be finite and within [0, 1]. Construction +scans once and borrows the caller's storage. Sampling does constant work without +allocating. The sampler neither decodes nor converts color, and owns no cache. + +The normalization contribution #1267 can provide the inputs directly through +`NormalizedTexture::rgba()` and `receipt().output_dimensions`; no normalization +implementation or dependency is copied into this module. Raw encoded RGBA8 from +the older admission utility is not interchangeable with normalized linear color. + +`sample_repeat_opaque([u, v])` returns three f64 channels. Both axes use periodic +bilinear level-zero addressing. UV (0, 0) is the top-left image boundary; v grows +downwards. Texel (x, y) is centered at ((x + 0.5) / width, (y + 0.5) / height). +An integer coordinate lies halfway between the last and first texels. Negative +and unwrapped coordinates repeat; nothing clamps UVs or flips rows a second time. + +Interpolation is in the supplied linear color or data space, with f64 arithmetic +on f32 input samples. The helper applies no sRGB transfer, normal-vector decoding, +tangent basis or engineering grain-axis interpretation. Alpha is deliberately +ignored for this opaque API: even zero-alpha RGB participates. It must not be used +as a transparent compositor; there is no claim of premultiplied-alpha filtering. + +Coordinates must be finite and have magnitude at most 2^20 repeats. Reduction +precedes multiplication by dimensions. Around this bound, f64 input spacing is +at most 2^-32 repeats, or 2^-21 texels at maximum width. Smaller sub-ULP offsets +cannot be recovered. Tests use a 1e-9 absolute channel tolerance for analytic +fixtures; this is not a universal error bound or f32 GPU parity certification. +Consumers must reject or explicitly reconcile larger mapping ranges rather than +silently clamp them. GPU upload/filtering precision requires separate evidence. + +Original generated 1x1/2x2 and rectangular constant fixtures check centers, row +orientation, linear black/white mixing, interpolation, both seams, signed/integer +repeats, alpha policy and hostile storage/coordinate refusal. No third-party +assets or code are added. This invisible helper has no useful app screenshot. + +Still required for visible wood: typed bindings and persistence, local millimetre +mapping with one inverse-instance transform, resource lifetime/budgets, product +CPU/GPU integration, minification/mips, PNG and offline reopen evidence. Bilinear +level-zero filtering alone does not prevent aliasing of oblique/minified grain. +HDRI, glass, final integration and mechanical wood properties are separate work.