diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9ce35f7..9ef7b5fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - `all-is-cubes` library: - `block::EvaluatedBlock`’s fields are now private. Use methods instead. + - `block::EvaluatedBlock::voxel_opacity_mask` now has its own data type, `VoxelOpacityMask`. - `block::EvalBlockError` is now a `struct` with an inner `ErrorKind` enum, instead of an enum, and contains more information. - `block::Move`’s means of construction have been changed to be more systematic and orthogonal. In particular, paired moves are constructed from unpaired ones. diff --git a/all-is-cubes-mesh/src/block_mesh.rs b/all-is-cubes-mesh/src/block_mesh.rs index d5ae79ae7..e0a65044e 100644 --- a/all-is-cubes-mesh/src/block_mesh.rs +++ b/all-is-cubes-mesh/src/block_mesh.rs @@ -5,10 +5,9 @@ use alloc::boxed::Box; use alloc::vec::Vec; use core::fmt; -use std::sync::Arc; -use all_is_cubes::block::EvaluatedBlock; -use all_is_cubes::math::{Face7, FaceMap, OpacityCategory, Vol}; +use all_is_cubes::block::{EvaluatedBlock, VoxelOpacityMask}; +use all_is_cubes::math::{Face7, FaceMap}; use all_is_cubes::space::Space; use all_is_cubes_render::Flaws; @@ -55,7 +54,7 @@ pub struct BlockMesh { /// colors have been embedded in the mesh vertices, making a mesh update required. /// (TODO: We could be more precise about which voxels are so frozen -- revisit /// whether that's worthwhile.) - pub(super) voxel_opacity_mask: Option>>, + pub(super) voxel_opacity_mask: Option, /// Flaws in this mesh, that should be reported as flaws in any rendering containing it. flaws: Flaws, @@ -168,14 +167,9 @@ impl BlockMesh { return false; } - // Need to deref the Vec in self.textures_used before matching - match ( - &self.voxel_opacity_mask, - &mut self.texture_used, - block.voxel_opacity_mask(), - ) { - (Some(old_mask), Some(existing_texture), Some(new_mask)) - if old_mask == new_mask + match (&self.voxel_opacity_mask, &mut self.texture_used) { + (Some(old_mask), Some(existing_texture)) + if old_mask == block.voxel_opacity_mask() && existing_texture .channels() .is_superset_of(texture::needed_channels(block.voxels())) => diff --git a/all-is-cubes-mesh/src/block_mesh/compute.rs b/all-is-cubes-mesh/src/block_mesh/compute.rs index 974d04651..8d0683e42 100644 --- a/all-is-cubes-mesh/src/block_mesh/compute.rs +++ b/all-is-cubes-mesh/src/block_mesh/compute.rs @@ -358,7 +358,7 @@ pub(super) fn compute_block_mesh( output.voxel_opacity_mask = if used_any_vertex_colors { None } else { - block.voxel_opacity_mask().clone() + Some(block.voxel_opacity_mask().clone()) }; } } diff --git a/all-is-cubes/src/block/eval/derived.rs b/all-is-cubes/src/block/eval/derived.rs index e8d528aff..71141bc68 100644 --- a/all-is-cubes/src/block/eval/derived.rs +++ b/all-is-cubes/src/block/eval/derived.rs @@ -1,5 +1,6 @@ use alloc::sync::Arc; use core::ops; +use itertools::Itertools; use euclid::Vector3D; use ordered_float::NotNan; @@ -9,10 +10,16 @@ use ordered_float::NotNan; #[allow(unused_imports)] use num_traits::float::FloatCore as _; -use crate::block; +use crate::block::{ + self, + Resolution::{self, R1}, +}; use crate::math::{Cube, Face6, FaceMap, GridAab, Intensity, OpacityCategory, Rgb, Rgba, Vol}; use crate::raytracer; +#[cfg(doc)] +use crate::block::EvaluatedBlock; + /// Derived properties of an evaluated block. /// /// All of these properties are calculated using only the `attributes` and `voxels` of @@ -59,15 +66,8 @@ pub(in crate::block) struct Derived { // make this its own enum, or a bitmask of all seen values, or something. pub(in crate::block) uniform_collision: Option, - /// The opacity of all voxels. This is redundant with the main data, [`Self::voxels`], - /// and is provided as a pre-computed convenience that can be cheaply compared with - /// other values of the same type. - /// - /// May be [`None`] if the block is fully invisible. (TODO: This is a kludge to avoid - /// obligating [`AIR_EVALUATED`] to allocate at compile time, which is impossible. - /// It doesn't harm normal operation because the point of having this is to compare - /// block shapes, which is trivial if the block is invisible.) - pub(in crate::block) voxel_opacity_mask: Option>>, + /// See [`VoxelOpacityMask`]'s documentation for the use of this. + pub(in crate::block) voxel_opacity_mask: VoxelOpacityMask, } /// Compute the derived properties of block voxels @@ -82,14 +82,18 @@ pub(in crate::block::eval) fn compute_derived( // but this is likely to change. _ = attributes; + let resolution = voxels.resolution(); + // Optimization for single voxels: // don't allocate any `Vol`s or perform any generalized scans. - if let Some(block::Evoxel { - color, - emission, - selectable: _, - collision, - }) = voxels.single_voxel() + if let Some( + voxel @ block::Evoxel { + color, + emission, + selectable: _, + collision, + }, + ) = voxels.single_voxel() { let visible = !color.fully_transparent(); return Derived { @@ -99,20 +103,10 @@ pub(in crate::block::eval) fn compute_derived( opaque: FaceMap::repeat(color.fully_opaque()), visible, uniform_collision: Some(collision), - // Note an edge case shenanigan: - // `AIR_EVALUATED` cannot allocate a mask, and we want this to match the - // output of that so that `EvaluatedBlock::consistency_check()` will agree.) - // It's also useful to skip the mask when the block is invisible, but - // that's not the motivation of doing this this way. - voxel_opacity_mask: if !visible { - None - } else { - Some(Vol::from_element(color.opacity_category())) - }, + voxel_opacity_mask: VoxelOpacityMask::new_r1(voxel), }; } - let resolution = voxels.resolution(); let full_block_bounds = GridAab::for_block(resolution); let data_bounds = voxels.bounds(); let less_than_full = full_block_bounds != data_bounds; @@ -185,24 +179,7 @@ pub(in crate::block::eval) fn compute_derived( collision }; - let visible = voxels.as_vol_ref().as_linear().iter().any( - #[inline(always)] - |voxel| !voxel.color.fully_transparent(), - ); - - // Generate mask only if the block is not invisible, because it will never be - // useful for invisible blocks. (The purpose of the mask is to allow re-texturing - // a mesh of the appropriate shape, and invisible blocks have no mesh.) - let voxel_opacity_mask = if !visible { - None - } else { - Some(voxels.as_vol_ref().map_container(|voxels| { - voxels - .iter() - .map(|voxel| voxel.color.opacity_category()) - .collect() - })) - }; + let voxel_opacity_mask = VoxelOpacityMask::new(resolution, voxels.as_vol_ref()); Derived { color, @@ -222,7 +199,7 @@ pub(in crate::block::eval) fn compute_derived( false } }), - visible, + visible: voxel_opacity_mask.visible(), uniform_collision, voxel_opacity_mask, } @@ -302,6 +279,151 @@ impl ops::AddAssign for VoxSum { } } +/// The visual shape of an [`EvaluatedBlock`]. +/// +/// This data type stores the block's [`Resolution`], every voxel’s [`OpacityCategory`], and no +/// other information. +/// It may be used, when rendering blocks, to decide whether a change in a block +/// affects the geometry of the scene, or just the colors to be drawn. +/// +/// It does not currently allow retrieving the per-voxel information, just comparing the whole +/// using the `==` operator. +/// For individual voxels, consult [`EvaluatedBlock::voxels()`] instead. +/// +/// This type stores the voxel data inline or reference-counted, and is therefore cheap to clone. +#[derive(Clone, Eq, Hash, PartialEq)] +pub struct VoxelOpacityMask(MaskInner); + +/// This enum allows us to create the mask for `block::AIR` without heap allocation. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(in crate::block::eval) enum MaskInner { + /// The entire voxel data has this category. + Uniform(Resolution, GridAab, OpacityCategory), + + /// Invariant: this variant is used only if the categories are not all equal. + /// + /// TODO: Compress this representation by storing it bit-packed instead. + Irregular(Resolution, Vol>), +} + +/// Caution: for consistency of equality, all these constructors must use the same choice of +/// variant. +impl VoxelOpacityMask { + pub(crate) const R1_INVISIBLE: Self = Self(MaskInner::Uniform( + R1, + GridAab::for_block(R1), + OpacityCategory::Invisible, + )); + + #[inline] + pub(crate) fn new_r1(voxel: block::Evoxel) -> Self { + Self(MaskInner::Uniform( + R1, + GridAab::for_block(R1), + voxel.color.opacity_category(), + )) + } + + pub(crate) fn new(resolution: Resolution, voxels: Vol<&[block::Evoxel]>) -> Self { + let uniform_opacity: Option = match voxels + .as_linear() + .iter() + .map( + // TODO: We also need to check the emission color for being nonzero. + // That isn't exactly properly “opacity” but it will align with the purposes + // this is used for. + #[inline(always)] + |voxel| voxel.color.opacity_category(), + ) + .all_equal_value() + { + Ok(cat) => Some(cat), + Err(None) => Some(OpacityCategory::Invisible), + Err(Some(_)) => None, + }; + + if let Some(uniform_opacity) = uniform_opacity { + // If the block is invisible (or has any other uniform opacity), avoid allocating. + // This serves multiple purposes: + // + // * The purpose of the mask is to allow re-texturing a mesh of the appropriate shape, + // and invisible blocks need no mesh, so it will not be useful. + // * It means that this can match the mask of the *constant* [`block::AIR`], + // even though that cannot allocate. + // * It is more efficient to allocate in fewer cases, of course. + + VoxelOpacityMask(MaskInner::Uniform( + resolution, + voxels.bounds(), + uniform_opacity, + )) + } else { + debug_assert_ne!(resolution, R1, "impossible: R1 and irregular opacity"); + + VoxelOpacityMask(MaskInner::Irregular( + resolution, + voxels.map_container(|voxels| { + voxels + .iter() + .map(|voxel| voxel.color.opacity_category()) + .collect() + }), + )) + } + } + + /// Accepts raw category data. + /// Used for tests only. + #[cfg(test)] + pub(crate) fn new_raw(resolution: Resolution, voxels: Vol>) -> Self { + let uniform_opacity: Option = + match voxels.as_linear().iter().all_equal_value() { + Ok(&cat) => Some(cat), + Err(None) => Some(OpacityCategory::Invisible), + Err(Some(_)) => None, + }; + + if let Some(uniform_opacity) = uniform_opacity { + VoxelOpacityMask(MaskInner::Uniform( + resolution, + voxels.bounds(), + uniform_opacity, + )) + } else { + debug_assert_ne!(resolution, R1, "impossible: R1 and irregular opacity"); + + VoxelOpacityMask(MaskInner::Irregular(resolution, voxels)) + } + } + + pub(crate) fn visible(&self) -> bool { + match self.0 { + MaskInner::Uniform(_, _, category) => category != OpacityCategory::Invisible, + // `Irregular` is never used unless there is at least one non-invisible voxel. + MaskInner::Irregular(_, _) => true, + } + } +} + +impl core::fmt::Debug for VoxelOpacityMask { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self.0 { + MaskInner::Uniform(resolution, bounds, opacity) => f + .debug_struct("VoxelOpacityMask") + .field("resolution", &resolution) + .field("bounds", &format_args!("{bounds:?}")) + .field("opacity", &opacity) + .finish(), + // mask data is likely to be too large to be useful to print + MaskInner::Irregular(resolution, ref voxels) => f + .debug_struct("VoxelOpacityMask") + .field("resolution", &resolution) + .field("bounds", &format_args!("{:?}", voxels.bounds())) + .finish_non_exhaustive(), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -377,4 +499,19 @@ mod tests { assert_eq!(v.color(2.), Rgba::new(0.25, 0.75, 0., 0.5)); assert_eq!(v.emission(2.), Rgb::new(0., 0., 1.)); } + + #[test] + fn opacity_mask_constructor_consistency() { + assert_eq!( + VoxelOpacityMask::R1_INVISIBLE, + VoxelOpacityMask::new( + R1, + Vol::from_elements(GridAab::ORIGIN_CUBE, [Evoxel::AIR].as_slice()).unwrap() + ) + ); + assert_eq!( + VoxelOpacityMask::R1_INVISIBLE, + VoxelOpacityMask::new_r1(Evoxel::AIR) + ); + } } diff --git a/all-is-cubes/src/block/eval/evaluated.rs b/all-is-cubes/src/block/eval/evaluated.rs index 404bcf573..7ce5db5cf 100644 --- a/all-is-cubes/src/block/eval/evaluated.rs +++ b/all-is-cubes/src/block/eval/evaluated.rs @@ -1,7 +1,6 @@ //! [`EvaluatedBlock`] and [`Evoxel`]. use alloc::boxed::Box; -use alloc::sync::Arc; use core::{fmt, ptr}; /// Acts as polyfill for float methods @@ -13,9 +12,10 @@ use crate::block::eval::derived::Derived; use crate::block::{ self, Block, BlockAttributes, BlockCollision, Cost, Evoxel, Evoxels, Modifier, Resolution::{self, R1}, + VoxelOpacityMask, }; use crate::inv; -use crate::math::{Face6, Face7, FaceMap, GridAab, OpacityCategory, Rgb, Rgba, Vol}; +use crate::math::{Face6, Face7, FaceMap, GridAab, OpacityCategory, Rgb, Rgba}; // Things mentioned in doc comments only #[cfg(doc)] @@ -93,10 +93,7 @@ impl fmt::Debug for EvaluatedBlock { ds.field("voxels", &format_args!("{:?}", voxels.bounds())); } } - ds.field( - "voxel_opacity_mask", - &format_args!("{:?}", voxel_opacity_mask.as_ref().map(Vol::bounds)), - ); + ds.field("voxel_opacity_mask", &voxel_opacity_mask); ds.field("cost", cost); ds.finish() } @@ -209,16 +206,13 @@ impl EvaluatedBlock { self.derived.uniform_collision } - /// The opacity of all voxels. This is redundant with the main data, [`Self::voxels()`], - /// and is provided as a pre-computed convenience that can be cheaply compared with - /// other values of the same type. + /// The opacity of all voxels. /// - /// May be [`None`] if the block is fully invisible. (TODO: This is a kludge to avoid - /// obligating [`AIR_EVALUATED`] to allocate at compile time, which is impossible. - /// It doesn't harm normal operation because the point of having this is to compare - /// block shapes, which is trivial if the block is invisible.) + /// This is redundant with the main data, [`Self::voxels()`], + /// and is provided as a pre-computed convenience for comparing blocks’ shapes. + /// See [`VoxelOpacityMask`]’s documentation for more information. #[inline] - pub fn voxel_opacity_mask(&self) -> &Option>> { + pub fn voxel_opacity_mask(&self) -> &VoxelOpacityMask { &self.derived.voxel_opacity_mask } @@ -376,7 +370,7 @@ const AIR_DERIVED: Derived = Derived { opaque: FaceMap::repeat_copy(false), visible: false, uniform_collision: Some(BlockCollision::None), - voxel_opacity_mask: None, + voxel_opacity_mask: VoxelOpacityMask::R1_INVISIBLE, }; /// Alternate form of [`EvaluatedBlock`] which may omit the derived information. diff --git a/all-is-cubes/src/block/eval/mod.rs b/all-is-cubes/src/block/eval/mod.rs index 320a3282d..44ad9d96e 100644 --- a/all-is-cubes/src/block/eval/mod.rs +++ b/all-is-cubes/src/block/eval/mod.rs @@ -16,6 +16,7 @@ mod derived; use derived::compute_derived; #[cfg(test)] pub(super) use derived::Derived; +pub use derived::VoxelOpacityMask; mod evaluated; pub(crate) use evaluated::AIR_EVALUATED_REF; diff --git a/all-is-cubes/src/block/eval/tests.rs b/all-is-cubes/src/block/eval/tests.rs index ee90a710f..82df95060 100644 --- a/all-is-cubes/src/block/eval/tests.rs +++ b/all-is-cubes/src/block/eval/tests.rs @@ -35,7 +35,11 @@ fn evaluated_block_debug_simple() { selectable: true, collision: Hard, }, - voxel_opacity_mask: Some(GridAab(0..1, 0..1, 0..1)), + voxel_opacity_mask: VoxelOpacityMask { + resolution: 1, + bounds: GridAab(0..1, 0..1, 0..1), + opacity: Opaque, + }, cost: Cost { components: 1, voxels: 0, @@ -95,7 +99,11 @@ fn evaluated_block_debug_complex() { uniform_collision: None, resolution: 2, voxels: GridAab(0..2, 0..2, 0..2), - voxel_opacity_mask: Some(GridAab(0..2, 0..2, 0..2)), + voxel_opacity_mask: VoxelOpacityMask { + resolution: 2, + bounds: GridAab(0..2, 0..2, 0..2), + .. + }, cost: Cost { components: 1, voxels: 8, @@ -140,18 +148,18 @@ fn from_voxels_zero_bounds() { let attributes = BlockAttributes::default(); let resolution = R4; let bounds = GridAab::from_lower_size([1, 2, 3], [0, 0, 0]); + let voxels = Evoxels::from_many(resolution, Vol::from_fn(bounds, |_| unreachable!())); assert_eq!( EvaluatedBlock::from_voxels( AIR, // caution: incorrect placeholder value attributes.clone(), - Evoxels::from_many(resolution, Vol::from_fn(bounds, |_| unreachable!())), + voxels.clone(), Cost::ZERO ), EvaluatedBlock { block: AIR, // caution: incorrect placeholder value cost: Cost::ZERO, // TODO wrong attributes, - voxels: Evoxels::from_many(resolution, Vol::from_fn(bounds, |_| unreachable!())), derived: Derived { color: Rgba::TRANSPARENT, face_colors: FaceMap::repeat(Rgba::TRANSPARENT), @@ -159,8 +167,9 @@ fn from_voxels_zero_bounds() { opaque: FaceMap::repeat(false), visible: false, uniform_collision: Some(BlockCollision::None), - voxel_opacity_mask: None, - } + voxel_opacity_mask: block::VoxelOpacityMask::new(resolution, voxels.as_vol_ref()), + }, + voxels, } ); } diff --git a/all-is-cubes/src/block/modifier.rs b/all-is-cubes/src/block/modifier.rs index 87fd50a0f..7952e5c9d 100644 --- a/all-is-cubes/src/block/modifier.rs +++ b/all-is-cubes/src/block/modifier.rs @@ -305,13 +305,16 @@ mod tests { opaque: FaceMap::repeat(false).with(rotation.transform(Face6::NY), true), visible: true, uniform_collision: Some(BlockCollision::Hard), - voxel_opacity_mask: Some(Vol::from_fn(block_bounds, |cube| { - if cube.x == 0 { - OpacityCategory::Opaque - } else { - OpacityCategory::Invisible - } - })), + voxel_opacity_mask: block::VoxelOpacityMask::new_raw( + resolution, + Vol::from_fn(block_bounds, |cube| { + if cube.x == 0 { + OpacityCategory::Opaque + } else { + OpacityCategory::Invisible + } + }) + ), }, } ); diff --git a/all-is-cubes/src/block/modifier/move.rs b/all-is-cubes/src/block/modifier/move.rs index 82c9f6caa..d8e43c0d2 100644 --- a/all-is-cubes/src/block/modifier/move.rs +++ b/all-is-cubes/src/block/modifier/move.rs @@ -231,7 +231,7 @@ impl universe::VisitHandles for Move { #[cfg(test)] mod tests { use super::*; - use crate::block::{Composite, EvaluatedBlock, Resolution::*}; + use crate::block::{Composite, EvaluatedBlock, Resolution::*, VoxelOpacityMask}; use crate::content::make_some_blocks; use crate::math::{notnan, rgba_const, FaceMap, GridPoint, OpacityCategory, Rgb, Rgba}; use crate::space::Space; @@ -281,7 +281,10 @@ mod tests { opaque: FaceMap::repeat(false).with(Face6::PY, true), visible: true, uniform_collision: None, - voxel_opacity_mask: Some(Vol::repeat(expected_bounds, OpacityCategory::Opaque)), + voxel_opacity_mask: VoxelOpacityMask::new_raw( + R16, + Vol::repeat(expected_bounds, OpacityCategory::Opaque) + ), } } ); @@ -335,7 +338,10 @@ mod tests { opaque: FaceMap::repeat(false).with(Face6::PY, true), visible: true, uniform_collision: None, - voxel_opacity_mask: Some(Vol::repeat(expected_bounds, OpacityCategory::Opaque)), + voxel_opacity_mask: VoxelOpacityMask::new_raw( + resolution, + Vol::repeat(expected_bounds, OpacityCategory::Opaque) + ), } } ); diff --git a/all-is-cubes/src/block/tests.rs b/all-is-cubes/src/block/tests.rs index 2bc4175d8..113c318d7 100644 --- a/all-is-cubes/src/block/tests.rs +++ b/all-is-cubes/src/block/tests.rs @@ -85,7 +85,7 @@ fn block_debug_with_modifiers() { } mod eval { - use crate::block::{Cost, EvKey, EvaluatedBlock}; + use crate::block::{Cost, EvKey, EvaluatedBlock, VoxelOpacityMask}; use super::{assert_eq, *}; @@ -156,7 +156,7 @@ mod eval { assert_eq!(e.visible(), true); assert_eq!( *e.voxel_opacity_mask(), - Some(Vol::from_element(OpacityCategory::Opaque)) + VoxelOpacityMask::new_raw(R1, Vol::from_element(OpacityCategory::Opaque)) ) } @@ -172,7 +172,7 @@ mod eval { assert_eq!(e.visible(), true); assert_eq!( *e.voxel_opacity_mask(), - Some(Vol::from_element(OpacityCategory::Partial)) + VoxelOpacityMask::new_raw(R1, Vol::from_element(OpacityCategory::Partial)) ) } @@ -185,7 +185,10 @@ mod eval { assert!(e.voxels.single_voxel().is_some()); assert_eq!(e.opaque(), FaceMap::repeat(false)); assert_eq!(e.visible(), false); - assert_eq!(*e.voxel_opacity_mask(), None) + assert_eq!( + *e.voxel_opacity_mask(), + VoxelOpacityMask::new_raw(R1, Vol::from_element(OpacityCategory::Invisible)) + ); } #[test] @@ -240,10 +243,10 @@ mod eval { assert_eq!(e.visible(), true); assert_eq!( *e.voxel_opacity_mask(), - Some(Vol::repeat( - GridAab::for_block(resolution), - OpacityCategory::Opaque, - )) + VoxelOpacityMask::new_raw( + resolution, + Vol::repeat(GridAab::for_block(resolution), OpacityCategory::Opaque,) + ) ); assert_eq!( e.cost,