Skip to main content

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
18use crate::color::Rgb;
19
20/// How a voxel's colour combines with what is already behind it along a ray.
21#[repr(u8)]
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
23pub enum BlendMode {
24    /// Fully opaque: the first solid hit wins and occludes everything behind
25    /// it — the existing render path. A voxel's `alpha` is ignored.
26    #[default]
27    Opaque = 0,
28    /// Front-to-back `over` compositing; `alpha` is the voxel's opacity.
29    /// Glass, smoke, water.
30    AlphaBlend = 1,
31    /// Commutative additive glow: contributes `alpha`·colour to the pixel
32    /// without occluding what is behind it (order-independent). Spells,
33    /// fire, magic auras, muzzle flashes.
34    Additive = 2,
35    /// Thickness-aware Beer–Lambert absorption for **filled** volumes (true
36    /// smoke, fog, murky water). Unlike [`AlphaBlend`](BlendMode::AlphaBlend) (which composites one
37    /// alpha per surface run, so opacity is independent of thickness — ideal
38    /// for shells/glass), `Volumetric` weights each voxel's opacity by the
39    /// ray's path length through it: the per-cell effective opacity is
40    /// `1 − (1 − alpha)^seg_len` where `seg_len` is the traversed length in
41    /// voxel units. A boundary sliver contributes ≈0 (no voxel-grid dicing)
42    /// while opacity grows smoothly with depth. Occludes like `AlphaBlend`.
43    Volumetric = 3,
44}
45
46impl BlendMode {
47    /// Decode the on-wire `u8`. Returns `None` for an unknown discriminant.
48    #[must_use]
49    pub fn from_u8(v: u8) -> Option<Self> {
50        match v {
51            0 => Some(Self::Opaque),
52            1 => Some(Self::AlphaBlend),
53            2 => Some(Self::Additive),
54            3 => Some(Self::Volumetric),
55            _ => None,
56        }
57    }
58
59    /// The on-wire discriminant.
60    #[must_use]
61    pub fn as_u8(self) -> u8 {
62        self as u8
63    }
64}
65
66/// One material: an opacity and a blend mode, indexed out of a
67/// [`MaterialTable`] by a per-voxel material id.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub struct Material {
70    /// Opacity for [`BlendMode::AlphaBlend`] / intensity scale for
71    /// [`BlendMode::Additive`]; ignored for [`BlendMode::Opaque`].
72    /// `0` = fully transparent, `255` = fully opaque / full intensity.
73    pub alpha: u8,
74    /// How the voxel composites with what is behind it.
75    pub mode: BlendMode,
76    /// Self-emission (EV stage): `0` = a normal lit voxel; anything
77    /// greater renders the voxel at
78    /// `albedo × ((128 + (emissive >> 1)) / 128)` — from ~1.0× up to
79    /// ~2.0× over-bright at `255` — **skipping** the baked brightness
80    /// byte, per-face side shades, the dynamic light rig, shadows and
81    /// cel bands (fog still applies). Orthogonal to `mode`: an
82    /// [`BlendMode::AlphaBlend`] crystal can glow through its own
83    /// translucency. Emission onto *surrounding* voxels is separate —
84    /// that is the lightmode-2 point-light bake (`BakeMode::PointLights`
85    /// in `roxlap-scene`), not this field.
86    pub emissive: u8,
87}
88
89impl Material {
90    /// The reserved, fully-opaque material at table id `0` — the
91    /// back-compat default for every voxel without explicit material data.
92    pub const OPAQUE: Self = Self {
93        alpha: 255,
94        mode: BlendMode::Opaque,
95        emissive: 0,
96    };
97
98    /// An [`BlendMode::AlphaBlend`] material with opacity `alpha`.
99    #[must_use]
100    pub fn alpha_blend(alpha: u8) -> Self {
101        Self {
102            alpha,
103            mode: BlendMode::AlphaBlend,
104            emissive: 0,
105        }
106    }
107
108    /// An [`BlendMode::Additive`] glow material scaled by `alpha`.
109    #[must_use]
110    pub fn additive(alpha: u8) -> Self {
111        Self {
112            alpha,
113            mode: BlendMode::Additive,
114            emissive: 0,
115        }
116    }
117
118    /// An opaque **emissive** material (EV stage): renders at
119    /// `albedo × ((128 + (emissive >> 1)) / 128)` regardless of baked
120    /// lighting, side shades, the dynamic rig or shadows. Combine with
121    /// a translucent mode via [`with_emissive`](Self::with_emissive)
122    /// (e.g. `Material::alpha_blend(160).with_emissive(255)` for a
123    /// glowing crystal).
124    #[must_use]
125    pub fn glow(emissive: u8) -> Self {
126        Self {
127            alpha: 255,
128            mode: BlendMode::Opaque,
129            emissive,
130        }
131    }
132
133    /// This material with its [`emissive`](Self::emissive) set — the
134    /// builder for translucent glowing materials.
135    #[must_use]
136    pub fn with_emissive(mut self, emissive: u8) -> Self {
137        self.emissive = emissive;
138        self
139    }
140
141    /// A [`BlendMode::Volumetric`] (Beer–Lambert) material whose `alpha` is
142    /// the per-voxel-unit absorption — opacity accrues with the ray's path
143    /// length through filled volumes (smoke/fog/murky water).
144    #[must_use]
145    pub fn volumetric(alpha: u8) -> Self {
146        Self {
147            alpha,
148            mode: BlendMode::Volumetric,
149            emissive: 0,
150        }
151    }
152
153    /// True for [`BlendMode::Opaque`] — the first-hit, fully-occluding path.
154    #[must_use]
155    pub fn is_opaque(self) -> bool {
156        matches!(self.mode, BlendMode::Opaque)
157    }
158}
159
160impl Default for Material {
161    fn default() -> Self {
162        Self::OPAQUE
163    }
164}
165
166/// A 256-entry palette of [`Material`]s indexed by a per-voxel `u8` material
167/// id. The renderer owns one table (a global palette); voxels reference
168/// materials by id rather than embedding them.
169///
170/// Id `0` is permanently [`Material::OPAQUE`] and cannot be redefined —
171/// it is the value every material-free voxel resolves to, so the opaque
172/// world stays byte-for-byte unchanged. [`set`](Self::set) silently
173/// ignores id 0.
174#[derive(Clone, Debug)]
175pub struct MaterialTable {
176    materials: [Material; 256],
177}
178
179impl MaterialTable {
180    /// A fresh palette: every id is [`Material::OPAQUE`].
181    #[must_use]
182    pub fn new() -> Self {
183        Self {
184            materials: [Material::OPAQUE; 256],
185        }
186    }
187
188    /// Define material `id`. Id `0` is reserved as [`Material::OPAQUE`] and
189    /// cannot be overwritten — defining it is a no-op that returns `false`;
190    /// any other id returns `true`.
191    pub fn set(&mut self, id: u8, mat: Material) -> bool {
192        if id == 0 {
193            return false;
194        }
195        self.materials[id as usize] = mat;
196        true
197    }
198
199    /// The material at `id` ([`Material::OPAQUE`] for any never-set id).
200    #[must_use]
201    pub fn get(&self, id: u8) -> Material {
202        self.materials[id as usize]
203    }
204
205    /// True when every id is [`BlendMode::Opaque`] — lets a backend skip the
206    /// whole transparency path while nothing translucent is defined. Blend
207    /// mode only: an opaque **emissive** material still returns `true` here —
208    /// gate the emissive path with [`any_emissive`](Self::any_emissive).
209    #[must_use]
210    pub fn all_opaque(&self) -> bool {
211        self.materials.iter().all(|m| m.is_opaque())
212    }
213
214    /// True when any id has a non-zero [`Material::emissive`] (EV stage) —
215    /// backends that gate their material lookup on translucency alone must
216    /// also consult this, or an opaque glowing palette would silently stay
217    /// on the pre-material fast path.
218    #[must_use]
219    pub fn any_emissive(&self) -> bool {
220        self.materials.iter().any(|m| m.emissive > 0)
221    }
222
223    /// The backing 256-entry array, for backends that upload it wholesale.
224    #[must_use]
225    pub fn as_array(&self) -> &[Material; 256] {
226        &self.materials
227    }
228}
229
230impl Default for MaterialTable {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236/// Resolve a voxel's **material id** from a colour→material map — the
237/// authoring bridge for mixed-material models (TV.3). A model is colour-coded
238/// (e.g. cyan voxels = glass, grey = an opaque frame); `map` pairs an RGB
239/// colour (`0xRRGGBB`, the brightness byte is ignored) with the material id
240/// it maps to. A voxel whose colour isn't in `map` resolves to `0`
241/// ([`Material::OPAQUE`]). Linear scan — `map` is tiny (a handful of material
242/// colours), so this stays cheap even called per voxel.
243#[must_use]
244pub fn material_for_color(map: &[(Rgb, u8)], col: u32) -> u8 {
245    let rgb = col & 0x00ff_ffff;
246    for &(c, id) in map {
247        if c.0 & 0x00ff_ffff == rgb {
248            return id;
249        }
250    }
251    0
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn blend_mode_round_trips() {
260        for m in [
261            BlendMode::Opaque,
262            BlendMode::AlphaBlend,
263            BlendMode::Additive,
264            BlendMode::Volumetric,
265        ] {
266            assert_eq!(BlendMode::from_u8(m.as_u8()), Some(m));
267        }
268        assert_eq!(BlendMode::from_u8(4), None);
269        assert_eq!(BlendMode::default(), BlendMode::Opaque);
270        // Volumetric is translucent (not the opaque first-hit path).
271        assert!(!Material::volumetric(128).is_opaque());
272    }
273
274    #[test]
275    fn material_defaults_opaque() {
276        assert_eq!(Material::default(), Material::OPAQUE);
277        assert!(Material::OPAQUE.is_opaque());
278        assert!(!Material::alpha_blend(128).is_opaque());
279        assert!(!Material::additive(200).is_opaque());
280    }
281
282    #[test]
283    fn emissive_defaults_zero_and_builds() {
284        // EV.0 — every existing constructor stays non-emissive.
285        assert_eq!(Material::OPAQUE.emissive, 0);
286        assert_eq!(Material::alpha_blend(128).emissive, 0);
287        assert_eq!(Material::additive(200).emissive, 0);
288        assert_eq!(Material::volumetric(80).emissive, 0);
289        // glow() is opaque-mode (first-hit path) but emissive.
290        let g = Material::glow(255);
291        assert!(g.is_opaque());
292        assert_eq!(g.emissive, 255);
293        // Builder composes with translucent modes.
294        let crystal = Material::alpha_blend(160).with_emissive(255);
295        assert!(!crystal.is_opaque());
296        assert_eq!(crystal.alpha, 160);
297        assert_eq!(crystal.emissive, 255);
298    }
299
300    #[test]
301    fn table_any_emissive() {
302        let mut t = MaterialTable::new();
303        assert!(!t.any_emissive());
304        // An opaque glowing palette is still `all_opaque` (blend-mode
305        // gate) but must trip the emissive gate.
306        assert!(t.set(7, Material::glow(200)));
307        assert!(t.all_opaque());
308        assert!(t.any_emissive());
309    }
310
311    #[test]
312    fn table_starts_all_opaque() {
313        let t = MaterialTable::new();
314        assert!(t.all_opaque());
315        for id in 0..=255u8 {
316            assert_eq!(t.get(id), Material::OPAQUE);
317        }
318    }
319
320    #[test]
321    fn table_set_and_get() {
322        let mut t = MaterialTable::new();
323        assert!(t.set(1, Material::alpha_blend(64)));
324        assert_eq!(t.get(1), Material::alpha_blend(64));
325        assert!(!t.all_opaque());
326        assert!(t.set(255, Material::additive(255)));
327        assert_eq!(t.get(255), Material::additive(255));
328    }
329
330    #[test]
331    fn id_zero_is_locked_opaque() {
332        let mut t = MaterialTable::new();
333        assert!(!t.set(0, Material::alpha_blend(0)));
334        assert_eq!(t.get(0), Material::OPAQUE);
335        assert!(t.all_opaque());
336    }
337}