roxlap_formats/material.rs
1//! Voxel materials — opacity + blend mode for transparent voxels (TV stage).
2//!
3//! Every voxel roxlap renders is opaque today: both backends are per-pixel
4//! front-to-back 3D-DDA raymarchers that stop at the first solid voxel. A
5//! *material* attaches an opacity and a blend mode to a voxel so the march
6//! can instead accumulate it front-to-back over what lies behind (the
7//! per-pixel DDA visits cells strictly front-to-back, so this is
8//! order-correct without any sorting — see `PORTING-TRANSPARENCY.md`).
9//!
10//! Materials are kept out of the `0x80RRGGBB` colour word: its high byte is
11//! voxlap's lightmode-1 *brightness*, not alpha (see [`crate::kv6`] and
12//! [`crate::vxl`]). Instead a voxel carries a one-byte **material id** that
13//! indexes a [`MaterialTable`] — a 256-entry global palette the renderer
14//! owns. Id `0` is permanently [`Material::OPAQUE`], so a model or grid that
15//! carries no material data resolves every voxel to id 0 and renders exactly
16//! as before.
17
18/// How a voxel's colour combines with what is already behind it along a ray.
19#[repr(u8)]
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
21pub enum BlendMode {
22 /// Fully opaque: the first solid hit wins and occludes everything behind
23 /// it — the existing render path. A voxel's `alpha` is ignored.
24 #[default]
25 Opaque = 0,
26 /// Front-to-back `over` compositing; `alpha` is the voxel's opacity.
27 /// Glass, smoke, water.
28 AlphaBlend = 1,
29 /// Commutative additive glow: contributes `alpha`·colour to the pixel
30 /// without occluding what is behind it (order-independent). Spells,
31 /// fire, magic auras, muzzle flashes.
32 Additive = 2,
33 /// Thickness-aware Beer–Lambert absorption for **filled** volumes (true
34 /// smoke, fog, murky water). Unlike [`AlphaBlend`] (which composites one
35 /// alpha per surface run, so opacity is independent of thickness — ideal
36 /// for shells/glass), `Volumetric` weights each voxel's opacity by the
37 /// ray's path length through it: the per-cell effective opacity is
38 /// `1 − (1 − alpha)^seg_len` where `seg_len` is the traversed length in
39 /// voxel units. A boundary sliver contributes ≈0 (no voxel-grid dicing)
40 /// while opacity grows smoothly with depth. Occludes like `AlphaBlend`.
41 Volumetric = 3,
42}
43
44impl BlendMode {
45 /// Decode the on-wire `u8`. Returns `None` for an unknown discriminant.
46 #[must_use]
47 pub fn from_u8(v: u8) -> Option<Self> {
48 match v {
49 0 => Some(Self::Opaque),
50 1 => Some(Self::AlphaBlend),
51 2 => Some(Self::Additive),
52 3 => Some(Self::Volumetric),
53 _ => None,
54 }
55 }
56
57 /// The on-wire discriminant.
58 #[must_use]
59 pub fn as_u8(self) -> u8 {
60 self as u8
61 }
62}
63
64/// One material: an opacity and a blend mode, indexed out of a
65/// [`MaterialTable`] by a per-voxel material id.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub struct Material {
68 /// Opacity for [`BlendMode::AlphaBlend`] / intensity scale for
69 /// [`BlendMode::Additive`]; ignored for [`BlendMode::Opaque`].
70 /// `0` = fully transparent, `255` = fully opaque / full intensity.
71 pub alpha: u8,
72 /// How the voxel composites with what is behind it.
73 pub mode: BlendMode,
74}
75
76impl Material {
77 /// The reserved, fully-opaque material at table id `0` — the
78 /// back-compat default for every voxel without explicit material data.
79 pub const OPAQUE: Self = Self {
80 alpha: 255,
81 mode: BlendMode::Opaque,
82 };
83
84 /// An [`BlendMode::AlphaBlend`] material with opacity `alpha`.
85 #[must_use]
86 pub fn alpha_blend(alpha: u8) -> Self {
87 Self {
88 alpha,
89 mode: BlendMode::AlphaBlend,
90 }
91 }
92
93 /// An [`BlendMode::Additive`] glow material scaled by `alpha`.
94 #[must_use]
95 pub fn additive(alpha: u8) -> Self {
96 Self {
97 alpha,
98 mode: BlendMode::Additive,
99 }
100 }
101
102 /// A [`BlendMode::Volumetric`] (Beer–Lambert) material whose `alpha` is
103 /// the per-voxel-unit absorption — opacity accrues with the ray's path
104 /// length through filled volumes (smoke/fog/murky water).
105 #[must_use]
106 pub fn volumetric(alpha: u8) -> Self {
107 Self {
108 alpha,
109 mode: BlendMode::Volumetric,
110 }
111 }
112
113 /// True for [`BlendMode::Opaque`] — the first-hit, fully-occluding path.
114 #[must_use]
115 pub fn is_opaque(self) -> bool {
116 matches!(self.mode, BlendMode::Opaque)
117 }
118}
119
120impl Default for Material {
121 fn default() -> Self {
122 Self::OPAQUE
123 }
124}
125
126/// A 256-entry palette of [`Material`]s indexed by a per-voxel `u8` material
127/// id. The renderer owns one table (a global palette); voxels reference
128/// materials by id rather than embedding them.
129///
130/// Id `0` is permanently [`Material::OPAQUE`] and cannot be redefined —
131/// it is the value every material-free voxel resolves to, so the opaque
132/// world stays byte-for-byte unchanged. [`set`](Self::set) silently
133/// ignores id 0.
134#[derive(Clone, Debug)]
135pub struct MaterialTable {
136 materials: [Material; 256],
137}
138
139impl MaterialTable {
140 /// A fresh palette: every id is [`Material::OPAQUE`].
141 #[must_use]
142 pub fn new() -> Self {
143 Self {
144 materials: [Material::OPAQUE; 256],
145 }
146 }
147
148 /// Define material `id`. Id `0` is reserved as [`Material::OPAQUE`] and
149 /// cannot be overwritten — defining it is a no-op that returns `false`;
150 /// any other id returns `true`.
151 pub fn set(&mut self, id: u8, mat: Material) -> bool {
152 if id == 0 {
153 return false;
154 }
155 self.materials[id as usize] = mat;
156 true
157 }
158
159 /// The material at `id` ([`Material::OPAQUE`] for any never-set id).
160 #[must_use]
161 pub fn get(&self, id: u8) -> Material {
162 self.materials[id as usize]
163 }
164
165 /// True when every id is [`BlendMode::Opaque`] — lets a backend skip the
166 /// whole transparency path while nothing translucent is defined.
167 #[must_use]
168 pub fn all_opaque(&self) -> bool {
169 self.materials.iter().all(|m| m.is_opaque())
170 }
171
172 /// The backing 256-entry array, for backends that upload it wholesale.
173 #[must_use]
174 pub fn as_array(&self) -> &[Material; 256] {
175 &self.materials
176 }
177}
178
179impl Default for MaterialTable {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185/// Resolve a voxel's **material id** from a colour→material map — the
186/// authoring bridge for mixed-material models (TV.3). A model is colour-coded
187/// (e.g. cyan voxels = glass, grey = an opaque frame); `map` pairs an RGB
188/// colour (`0xRRGGBB`, the brightness byte is ignored) with the material id
189/// it maps to. A voxel whose colour isn't in `map` resolves to `0`
190/// ([`Material::OPAQUE`]). Linear scan — `map` is tiny (a handful of material
191/// colours), so this stays cheap even called per voxel.
192#[must_use]
193pub fn material_for_color(map: &[(u32, u8)], col: u32) -> u8 {
194 let rgb = col & 0x00ff_ffff;
195 for &(c, id) in map {
196 if c & 0x00ff_ffff == rgb {
197 return id;
198 }
199 }
200 0
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn blend_mode_round_trips() {
209 for m in [
210 BlendMode::Opaque,
211 BlendMode::AlphaBlend,
212 BlendMode::Additive,
213 BlendMode::Volumetric,
214 ] {
215 assert_eq!(BlendMode::from_u8(m.as_u8()), Some(m));
216 }
217 assert_eq!(BlendMode::from_u8(4), None);
218 assert_eq!(BlendMode::default(), BlendMode::Opaque);
219 // Volumetric is translucent (not the opaque first-hit path).
220 assert!(!Material::volumetric(128).is_opaque());
221 }
222
223 #[test]
224 fn material_defaults_opaque() {
225 assert_eq!(Material::default(), Material::OPAQUE);
226 assert!(Material::OPAQUE.is_opaque());
227 assert!(!Material::alpha_blend(128).is_opaque());
228 assert!(!Material::additive(200).is_opaque());
229 }
230
231 #[test]
232 fn table_starts_all_opaque() {
233 let t = MaterialTable::new();
234 assert!(t.all_opaque());
235 for id in 0..=255u8 {
236 assert_eq!(t.get(id), Material::OPAQUE);
237 }
238 }
239
240 #[test]
241 fn table_set_and_get() {
242 let mut t = MaterialTable::new();
243 assert!(t.set(1, Material::alpha_blend(64)));
244 assert_eq!(t.get(1), Material::alpha_blend(64));
245 assert!(!t.all_opaque());
246 assert!(t.set(255, Material::additive(255)));
247 assert_eq!(t.get(255), Material::additive(255));
248 }
249
250 #[test]
251 fn id_zero_is_locked_opaque() {
252 let mut t = MaterialTable::new();
253 assert!(!t.set(0, Material::alpha_blend(0)));
254 assert_eq!(t.get(0), Material::OPAQUE);
255 assert!(t.all_opaque());
256 }
257}