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}
34
35impl BlendMode {
36 /// Decode the on-wire `u8`. Returns `None` for an unknown discriminant.
37 #[must_use]
38 pub fn from_u8(v: u8) -> Option<Self> {
39 match v {
40 0 => Some(Self::Opaque),
41 1 => Some(Self::AlphaBlend),
42 2 => Some(Self::Additive),
43 _ => None,
44 }
45 }
46
47 /// The on-wire discriminant.
48 #[must_use]
49 pub fn as_u8(self) -> u8 {
50 self as u8
51 }
52}
53
54/// One material: an opacity and a blend mode, indexed out of a
55/// [`MaterialTable`] by a per-voxel material id.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub struct Material {
58 /// Opacity for [`BlendMode::AlphaBlend`] / intensity scale for
59 /// [`BlendMode::Additive`]; ignored for [`BlendMode::Opaque`].
60 /// `0` = fully transparent, `255` = fully opaque / full intensity.
61 pub alpha: u8,
62 /// How the voxel composites with what is behind it.
63 pub mode: BlendMode,
64}
65
66impl Material {
67 /// The reserved, fully-opaque material at table id `0` — the
68 /// back-compat default for every voxel without explicit material data.
69 pub const OPAQUE: Self = Self {
70 alpha: 255,
71 mode: BlendMode::Opaque,
72 };
73
74 /// An [`BlendMode::AlphaBlend`] material with opacity `alpha`.
75 #[must_use]
76 pub fn alpha_blend(alpha: u8) -> Self {
77 Self {
78 alpha,
79 mode: BlendMode::AlphaBlend,
80 }
81 }
82
83 /// An [`BlendMode::Additive`] glow material scaled by `alpha`.
84 #[must_use]
85 pub fn additive(alpha: u8) -> Self {
86 Self {
87 alpha,
88 mode: BlendMode::Additive,
89 }
90 }
91
92 /// True for [`BlendMode::Opaque`] — the first-hit, fully-occluding path.
93 #[must_use]
94 pub fn is_opaque(self) -> bool {
95 matches!(self.mode, BlendMode::Opaque)
96 }
97}
98
99impl Default for Material {
100 fn default() -> Self {
101 Self::OPAQUE
102 }
103}
104
105/// A 256-entry palette of [`Material`]s indexed by a per-voxel `u8` material
106/// id. The renderer owns one table (a global palette); voxels reference
107/// materials by id rather than embedding them.
108///
109/// Id `0` is permanently [`Material::OPAQUE`] and cannot be redefined —
110/// it is the value every material-free voxel resolves to, so the opaque
111/// world stays byte-for-byte unchanged. [`set`](Self::set) silently
112/// ignores id 0.
113#[derive(Clone, Debug)]
114pub struct MaterialTable {
115 materials: [Material; 256],
116}
117
118impl MaterialTable {
119 /// A fresh palette: every id is [`Material::OPAQUE`].
120 #[must_use]
121 pub fn new() -> Self {
122 Self {
123 materials: [Material::OPAQUE; 256],
124 }
125 }
126
127 /// Define material `id`. Id `0` is reserved as [`Material::OPAQUE`] and
128 /// cannot be overwritten — defining it is a no-op that returns `false`;
129 /// any other id returns `true`.
130 pub fn set(&mut self, id: u8, mat: Material) -> bool {
131 if id == 0 {
132 return false;
133 }
134 self.materials[id as usize] = mat;
135 true
136 }
137
138 /// The material at `id` ([`Material::OPAQUE`] for any never-set id).
139 #[must_use]
140 pub fn get(&self, id: u8) -> Material {
141 self.materials[id as usize]
142 }
143
144 /// True when every id is [`BlendMode::Opaque`] — lets a backend skip the
145 /// whole transparency path while nothing translucent is defined.
146 #[must_use]
147 pub fn all_opaque(&self) -> bool {
148 self.materials.iter().all(|m| m.is_opaque())
149 }
150
151 /// The backing 256-entry array, for backends that upload it wholesale.
152 #[must_use]
153 pub fn as_array(&self) -> &[Material; 256] {
154 &self.materials
155 }
156}
157
158impl Default for MaterialTable {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164/// Resolve a voxel's **material id** from a colour→material map — the
165/// authoring bridge for mixed-material models (TV.3). A model is colour-coded
166/// (e.g. cyan voxels = glass, grey = an opaque frame); `map` pairs an RGB
167/// colour (`0xRRGGBB`, the brightness byte is ignored) with the material id
168/// it maps to. A voxel whose colour isn't in `map` resolves to `0`
169/// ([`Material::OPAQUE`]). Linear scan — `map` is tiny (a handful of material
170/// colours), so this stays cheap even called per voxel.
171#[must_use]
172pub fn material_for_color(map: &[(u32, u8)], col: u32) -> u8 {
173 let rgb = col & 0x00ff_ffff;
174 for &(c, id) in map {
175 if c & 0x00ff_ffff == rgb {
176 return id;
177 }
178 }
179 0
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn blend_mode_round_trips() {
188 for m in [
189 BlendMode::Opaque,
190 BlendMode::AlphaBlend,
191 BlendMode::Additive,
192 ] {
193 assert_eq!(BlendMode::from_u8(m.as_u8()), Some(m));
194 }
195 assert_eq!(BlendMode::from_u8(3), None);
196 assert_eq!(BlendMode::default(), BlendMode::Opaque);
197 }
198
199 #[test]
200 fn material_defaults_opaque() {
201 assert_eq!(Material::default(), Material::OPAQUE);
202 assert!(Material::OPAQUE.is_opaque());
203 assert!(!Material::alpha_blend(128).is_opaque());
204 assert!(!Material::additive(200).is_opaque());
205 }
206
207 #[test]
208 fn table_starts_all_opaque() {
209 let t = MaterialTable::new();
210 assert!(t.all_opaque());
211 for id in 0..=255u8 {
212 assert_eq!(t.get(id), Material::OPAQUE);
213 }
214 }
215
216 #[test]
217 fn table_set_and_get() {
218 let mut t = MaterialTable::new();
219 assert!(t.set(1, Material::alpha_blend(64)));
220 assert_eq!(t.get(1), Material::alpha_blend(64));
221 assert!(!t.all_opaque());
222 assert!(t.set(255, Material::additive(255)));
223 assert_eq!(t.get(255), Material::additive(255));
224 }
225
226 #[test]
227 fn id_zero_is_locked_opaque() {
228 let mut t = MaterialTable::new();
229 assert!(!t.set(0, Material::alpha_blend(0)));
230 assert_eq!(t.get(0), Material::OPAQUE);
231 assert!(t.all_opaque());
232 }
233}