Skip to main content

roxlap_formats/
sprite.rs

1//! KV6 sprite — a parsed [`Kv6`] paired with a world-space pose.
2//!
3//! Mirror of voxlap's `vx5sprite` (``) for the kv6
4//! case (`flags & SPRITE_FLAG_KFA == 0`). Pure data — owns the
5//! [`Kv6`] voxel grid plus the four `point3d` fields voxlap calls
6//! `p` (pivot position), `s` (x-basis), `h` (y-basis), `f`
7//! (z-basis), and the bitfield `flags`.
8//!
9//! Rendering lives in `roxlap-core` (`draw_sprite`); this module
10//! only models the data shape so downstream tools (file
11//! converters, model inspectors, asset pipelines) can build and
12//! manipulate sprites without depending on the full renderer.
13//!
14//! Voxlap's 64-byte layout, for reference:
15//!
16//! ```text
17//! point3d p;       // position
18//! int32_t flags;   // bit 0: 0=normal shading
19//!                  // bit 1: 0=kv6data, 1=kfatype
20//!                  // bit 2: 0=normal, 1=invisible
21//!                  // bit 3: 0=z-tested, 1=overlay (no-z)
22//! point3d s;       // x-basis (kv6data.xsiz direction)
23//! kv6data *voxnum; // (or kfatype *kfaptr if flag bit 1 set)
24//! point3d h;       // y-basis
25//! int32_t kfatim;
26//! point3d f;       // z-basis
27//! int32_t okfatim;
28//! ```
29
30use crate::color::{Rgb, VoxColor};
31use crate::kv6::Kv6;
32
33/// Voxlap's sprite-flags bit 0: disable normal-based face shading.
34pub const SPRITE_FLAG_NO_SHADING: u32 = 1 << 0;
35/// Voxlap's sprite-flags bit 1: voxnum points at a `kfatype`
36/// (animated). When clear (default), points at a `kv6data`.
37pub const SPRITE_FLAG_KFA: u32 = 1 << 1;
38/// Voxlap's sprite-flags bit 2: skip rendering entirely.
39pub const SPRITE_FLAG_INVISIBLE: u32 = 1 << 2;
40/// Voxlap's sprite-flags bit 3: render without z-buffer test.
41pub const SPRITE_FLAG_NO_Z: u32 = 1 << 3;
42/// roxlap extension (XS.4), bit 4: this sprite does **not** cast a hard
43/// shadow onto terrain / other sprites. Clear (the default) ⇒ it casts.
44pub const SPRITE_FLAG_NO_SHADOW_CAST: u32 = 1 << 4;
45/// roxlap extension (XS.4), bit 5: this sprite does **not** receive hard
46/// shadows (it isn't darkened by occluders). Clear (the default) ⇒ it
47/// receives. Both shadow bits default to participating, matching the
48/// dynamic-lighting rig's "shadows on" intent; set a bit to opt a sprite
49/// out (e.g. a glowing effect that shouldn't be shadowed).
50pub const SPRITE_FLAG_NO_SHADOW_RECEIVE: u32 = 1 << 5;
51
52/// roxlap extension (BB.2b), bit 6: light this sprite with a fixed **world-up**
53/// surface normal instead of the DDA hit-face normal — stable directional
54/// shading for a camera-facing billboard (whose face normal would otherwise
55/// track the camera). Clear ⇒ the face normal (default). Ignored if
56/// [`SPRITE_FLAG_LIGHT_AMBIENT_ONLY`] is also set.
57pub const SPRITE_FLAG_LIGHT_WORLD_UP: u32 = 1 << 6;
58/// roxlap extension (BB.2b), bit 7: light this sprite with **ambient only**
59/// (no sun / point-light direct term) — the flattest, most Doom-faithful
60/// billboard look (a flat cutout that ignores the light direction). Clear ⇒
61/// direct lighting applies. Takes precedence over
62/// [`SPRITE_FLAG_LIGHT_WORLD_UP`].
63pub const SPRITE_FLAG_LIGHT_AMBIENT_ONLY: u32 = 1 << 7;
64
65/// The neutral (no-op) value for [`Sprite::tint`] — white, so every channel
66/// multiplies by 1.0.
67pub const NO_TINT: u32 = 0x00FF_FFFF;
68
69/// A KV6 voxel sprite positioned in world space.
70///
71/// Mirror of voxlap's `vx5sprite` for the kv6 case
72/// (`flags & SPRITE_FLAG_KFA == 0`; see [`SPRITE_FLAG_KFA`]).
73/// Owns its [`Kv6`] by value. `p` / `s` / `h` / `f` are voxlap's
74/// per-axis world-space basis: `s` is the `kv6.xsiz` direction,
75/// `h` the `ysiz` direction, `f` the `zsiz` direction. For an
76/// axis-aligned sprite, `s = [1,0,0]`, `h = [0,1,0]`,
77/// `f = [0,0,1]`.
78#[derive(Debug, Clone)]
79pub struct Sprite {
80    /// Voxel data + bounding-box pivots. Loaded from a `.kv6`
81    /// file via [`crate::kv6::parse`] or built procedurally.
82    pub kv6: Kv6,
83    /// World-space position of the sprite's pivot (xpiv, ypiv,
84    /// zpiv inside the kv6 maps to this point).
85    pub p: [f32; 3],
86    /// World-space basis vector for the kv6's local +x. Length
87    /// scales the sprite along that axis (typically `1.0` for
88    /// unit-scale).
89    pub s: [f32; 3],
90    /// World-space basis vector for the kv6's local +y.
91    pub h: [f32; 3],
92    /// World-space basis vector for the kv6's local +z.
93    pub f: [f32; 3],
94    /// Voxlap-style flags bitfield. See [`SPRITE_FLAG_NO_SHADING`],
95    /// [`SPRITE_FLAG_KFA`], [`SPRITE_FLAG_INVISIBLE`],
96    /// [`SPRITE_FLAG_NO_Z`].
97    pub flags: u32,
98    /// Voxel-material id (TV stage) applied uniformly to this sprite's
99    /// voxels — indexes the renderer's global
100    /// [`MaterialTable`](crate::material::MaterialTable) for opacity + blend
101    /// mode. `0` (the default) is opaque, so an un-set sprite renders exactly
102    /// as before. See `PORTING-TRANSPARENCY.md`.
103    pub material: u8,
104    /// Per-instance opacity multiplier (TV stage), `0..=255` (`255` =
105    /// unscaled, the default). Scales the material's alpha so an effect can
106    /// fade out by cheap per-frame updates without re-uploading its volume.
107    pub alpha_mul: u8,
108    /// Per-instance RGB colour tint, packed `0x00RRGGBB`. Each rendered voxel's
109    /// colour is multiplied by this (per channel, `out = c * tint / 255`), so a
110    /// host can recolour instances of one model cheaply. `0x00FF_FFFF` (white,
111    /// the default) is a no-op. Alpha is **not** carried here — use a
112    /// translucent material + [`alpha_mul`](Self::alpha_mul) for transparency.
113    pub tint: u32,
114    /// Per-voxel material colour map (TV.3): `(rgb, material_id)` pairs that
115    /// classify this model's voxels into materials by colour — a mixed model
116    /// (opaque frame + glass, bottle + potion). **Empty** (the default) means
117    /// the whole sprite uses [`material`](Self::material) uniformly (the TV.1
118    /// path). See [`crate::material::material_for_color`].
119    pub material_map: Vec<(Rgb, u8)>,
120}
121
122impl Sprite {
123    /// Convenience constructor for an axis-aligned sprite at
124    /// world position `pos`. Basis is identity, flags = 0
125    /// (kv6 + normal shading + visible + z-tested).
126    ///
127    /// # Examples
128    ///
129    /// ```
130    /// use roxlap_formats::kv6::Kv6;
131    /// use roxlap_formats::sprite::Sprite;
132    ///
133    /// # let kv6 = Kv6 {
134    /// #     xsiz: 1, ysiz: 1, zsiz: 1,
135    /// #     xpiv: 0.5, ypiv: 0.5, zpiv: 0.5,
136    /// #     voxels: vec![], xlen: vec![0], ylen: vec![vec![0]],
137    /// #     palette: None,
138    /// # };
139    /// // ... after `let kv6 = kv6::parse(&bytes)?;` or similar:
140    /// let sprite = Sprite::axis_aligned(kv6, [1024.0, 1024.0, 100.0]);
141    /// assert_eq!(sprite.flags, 0);
142    /// assert_eq!(sprite.s, [1.0, 0.0, 0.0]);
143    /// ```
144    #[must_use]
145    pub fn axis_aligned(kv6: Kv6, pos: [f32; 3]) -> Self {
146        Self {
147            kv6,
148            p: pos,
149            s: [1.0, 0.0, 0.0],
150            h: [0.0, 1.0, 0.0],
151            f: [0.0, 0.0, 1.0],
152            flags: 0,
153            material: 0,
154            alpha_mul: 255,
155            tint: NO_TINT,
156            material_map: Vec::new(),
157        }
158    }
159
160    /// Set this sprite's RGB colour tint (`0x00RRGGBB`, white = no-op). Returns
161    /// `self` for chaining. See [`tint`](Self::tint).
162    #[must_use]
163    pub fn with_tint(mut self, tint: u32) -> Self {
164        self.tint = tint & 0x00FF_FFFF;
165        self
166    }
167
168    /// XS.4 — whether this sprite casts a hard shadow (the [dynamic-lighting
169    /// stage](../../../PORTING-DYNLIGHT.md) decides it darkens terrain / other
170    /// sprites). `true` unless [`SPRITE_FLAG_NO_SHADOW_CAST`] is set.
171    #[must_use]
172    pub fn casts_shadow(&self) -> bool {
173        self.flags & SPRITE_FLAG_NO_SHADOW_CAST == 0
174    }
175
176    /// XS.4 — whether this sprite receives hard shadows (is darkened by
177    /// occluders). `true` unless [`SPRITE_FLAG_NO_SHADOW_RECEIVE`] is set.
178    #[must_use]
179    pub fn receives_shadow(&self) -> bool {
180        self.flags & SPRITE_FLAG_NO_SHADOW_RECEIVE == 0
181    }
182
183    /// XS.4 — set whether this sprite casts a hard shadow (clears/sets
184    /// [`SPRITE_FLAG_NO_SHADOW_CAST`]). Returns `self` for chaining.
185    #[must_use]
186    pub fn with_casts_shadow(mut self, casts: bool) -> Self {
187        if casts {
188            self.flags &= !SPRITE_FLAG_NO_SHADOW_CAST;
189        } else {
190            self.flags |= SPRITE_FLAG_NO_SHADOW_CAST;
191        }
192        self
193    }
194
195    /// XS.4 — set whether this sprite receives hard shadows (clears/sets
196    /// [`SPRITE_FLAG_NO_SHADOW_RECEIVE`]). Returns `self` for chaining.
197    #[must_use]
198    pub fn with_receives_shadow(mut self, receives: bool) -> Self {
199        if receives {
200            self.flags &= !SPRITE_FLAG_NO_SHADOW_RECEIVE;
201        } else {
202            self.flags |= SPRITE_FLAG_NO_SHADOW_RECEIVE;
203        }
204        self
205    }
206
207    /// Carve a sphere out of this sprite's voxel model, controlling
208    /// the colour of the interior the cut exposes. Thin delegate to
209    /// [`Kv6::carve_sphere_with_colfunc`] — `centre` / `radius`,
210    /// `solid`, and `colfunc` are all in **kv6-local** voxel
211    /// coordinates (the sprite pose `p`/`s`/`h`/`f` is untouched). See
212    /// that method for why the `solid` occupancy predicate is required.
213    pub fn carve_sphere_with_colfunc<S, C>(
214        &mut self,
215        centre: [i32; 3],
216        radius: u32,
217        solid: S,
218        colfunc: C,
219    ) where
220        S: Fn(i32, i32, i32) -> bool,
221        C: Fn(i32, i32, i32) -> VoxColor,
222    {
223        self.kv6
224            .carve_sphere_with_colfunc(centre, radius, solid, colfunc);
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    /// XS.4 — sprite shadow flags default to participating and toggle cleanly,
233    /// independently of each other.
234    #[test]
235    fn sprite_shadow_flags_default_on_and_toggle() {
236        let kv6 = Kv6::from_fn_shaded(2, 2, 2, |_, _, _| Some(VoxColor(0x8033_4455)));
237        let s = Sprite::axis_aligned(kv6, [0.0; 3]);
238        // Default: casts + receives.
239        assert!(s.casts_shadow() && s.receives_shadow());
240
241        let no_cast = s.clone().with_casts_shadow(false);
242        assert!(!no_cast.casts_shadow() && no_cast.receives_shadow());
243        assert_eq!(
244            no_cast.flags & SPRITE_FLAG_NO_SHADOW_CAST,
245            SPRITE_FLAG_NO_SHADOW_CAST
246        );
247
248        let no_recv = s.clone().with_receives_shadow(false);
249        assert!(no_recv.casts_shadow() && !no_recv.receives_shadow());
250
251        // Toggling one bit leaves the other (and unrelated flags) untouched.
252        let neither = s
253            .clone()
254            .with_casts_shadow(false)
255            .with_receives_shadow(false);
256        assert!(!neither.casts_shadow() && !neither.receives_shadow());
257        let back = neither.with_casts_shadow(true);
258        assert!(back.casts_shadow() && !back.receives_shadow());
259    }
260
261    #[test]
262    fn carve_sphere_delegates_to_kv6_and_leaves_pose() {
263        const BASE: VoxColor = VoxColor(0x8033_4455);
264        let kv6 = Kv6::from_fn_shaded(16, 16, 16, |_, _, _| Some(BASE));
265        let mut sprite = Sprite::axis_aligned(kv6, [10.0, 20.0, 30.0]);
266        let before = sprite.kv6.voxels.len();
267
268        sprite.carve_sphere_with_colfunc(
269            [8, 8, 8],
270            4,
271            |_, _, _| true,
272            |_, _, _| VoxColor(0x8000_FF00),
273        );
274
275        // The hollowed-out shell has more surface voxels than the
276        // solid hull did, so the model definitely changed.
277        assert_ne!(sprite.kv6.voxels.len(), before);
278        // Pose untouched — carving is kv6-local only.
279        assert_eq!(sprite.p, [10.0, 20.0, 30.0]);
280        assert_eq!(sprite.s, [1.0, 0.0, 0.0]);
281    }
282}