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::kv6::Kv6;
31
32/// Voxlap's sprite-flags bit 0: disable normal-based face shading.
33pub const SPRITE_FLAG_NO_SHADING: u32 = 1 << 0;
34/// Voxlap's sprite-flags bit 1: voxnum points at a `kfatype`
35/// (animated). When clear (default), points at a `kv6data`.
36pub const SPRITE_FLAG_KFA: u32 = 1 << 1;
37/// Voxlap's sprite-flags bit 2: skip rendering entirely.
38pub const SPRITE_FLAG_INVISIBLE: u32 = 1 << 2;
39/// Voxlap's sprite-flags bit 3: render without z-buffer test.
40pub const SPRITE_FLAG_NO_Z: u32 = 1 << 3;
41/// roxlap extension (XS.4), bit 4: this sprite does **not** cast a hard
42/// shadow onto terrain / other sprites. Clear (the default) ⇒ it casts.
43pub const SPRITE_FLAG_NO_SHADOW_CAST: u32 = 1 << 4;
44/// roxlap extension (XS.4), bit 5: this sprite does **not** receive hard
45/// shadows (it isn't darkened by occluders). Clear (the default) ⇒ it
46/// receives. Both shadow bits default to participating, matching the
47/// dynamic-lighting rig's "shadows on" intent; set a bit to opt a sprite
48/// out (e.g. a glowing effect that shouldn't be shadowed).
49pub const SPRITE_FLAG_NO_SHADOW_RECEIVE: u32 = 1 << 5;
50
51/// A KV6 voxel sprite positioned in world space.
52///
53/// Mirror of voxlap's `vx5sprite` for the kv6 case
54/// (`flags & SPRITE_FLAG_KFA == 0`; see [`SPRITE_FLAG_KFA`]).
55/// Owns its [`Kv6`] by value. `p` / `s` / `h` / `f` are voxlap's
56/// per-axis world-space basis: `s` is the `kv6.xsiz` direction,
57/// `h` the `ysiz` direction, `f` the `zsiz` direction. For an
58/// axis-aligned sprite, `s = [1,0,0]`, `h = [0,1,0]`,
59/// `f = [0,0,1]`.
60#[derive(Debug, Clone)]
61pub struct Sprite {
62 /// Voxel data + bounding-box pivots. Loaded from a `.kv6`
63 /// file via [`crate::kv6::parse`] or built procedurally.
64 pub kv6: Kv6,
65 /// World-space position of the sprite's pivot (xpiv, ypiv,
66 /// zpiv inside the kv6 maps to this point).
67 pub p: [f32; 3],
68 /// World-space basis vector for the kv6's local +x. Length
69 /// scales the sprite along that axis (typically `1.0` for
70 /// unit-scale).
71 pub s: [f32; 3],
72 /// World-space basis vector for the kv6's local +y.
73 pub h: [f32; 3],
74 /// World-space basis vector for the kv6's local +z.
75 pub f: [f32; 3],
76 /// Voxlap-style flags bitfield. See [`SPRITE_FLAG_NO_SHADING`],
77 /// [`SPRITE_FLAG_KFA`], [`SPRITE_FLAG_INVISIBLE`],
78 /// [`SPRITE_FLAG_NO_Z`].
79 pub flags: u32,
80 /// Voxel-material id (TV stage) applied uniformly to this sprite's
81 /// voxels — indexes the renderer's global
82 /// [`MaterialTable`](crate::material::MaterialTable) for opacity + blend
83 /// mode. `0` (the default) is opaque, so an un-set sprite renders exactly
84 /// as before. See `PORTING-TRANSPARENCY.md`.
85 pub material: u8,
86 /// Per-instance opacity multiplier (TV stage), `0..=255` (`255` =
87 /// unscaled, the default). Scales the material's alpha so an effect can
88 /// fade out by cheap per-frame updates without re-uploading its volume.
89 pub alpha_mul: u8,
90 /// Per-voxel material colour map (TV.3): `(rgb, material_id)` pairs that
91 /// classify this model's voxels into materials by colour — a mixed model
92 /// (opaque frame + glass, bottle + potion). **Empty** (the default) means
93 /// the whole sprite uses [`material`](Self::material) uniformly (the TV.1
94 /// path). See [`crate::material::material_for_color`].
95 pub material_map: Vec<(u32, u8)>,
96}
97
98impl Sprite {
99 /// Convenience constructor for an axis-aligned sprite at
100 /// world position `pos`. Basis is identity, flags = 0
101 /// (kv6 + normal shading + visible + z-tested).
102 ///
103 /// # Examples
104 ///
105 /// ```
106 /// use roxlap_formats::kv6::Kv6;
107 /// use roxlap_formats::sprite::Sprite;
108 ///
109 /// # let kv6 = Kv6 {
110 /// # xsiz: 1, ysiz: 1, zsiz: 1,
111 /// # xpiv: 0.5, ypiv: 0.5, zpiv: 0.5,
112 /// # voxels: vec![], xlen: vec![0], ylen: vec![vec![0]],
113 /// # palette: None,
114 /// # };
115 /// // ... after `let kv6 = kv6::parse(&bytes)?;` or similar:
116 /// let sprite = Sprite::axis_aligned(kv6, [1024.0, 1024.0, 100.0]);
117 /// assert_eq!(sprite.flags, 0);
118 /// assert_eq!(sprite.s, [1.0, 0.0, 0.0]);
119 /// ```
120 #[must_use]
121 pub fn axis_aligned(kv6: Kv6, pos: [f32; 3]) -> Self {
122 Self {
123 kv6,
124 p: pos,
125 s: [1.0, 0.0, 0.0],
126 h: [0.0, 1.0, 0.0],
127 f: [0.0, 0.0, 1.0],
128 flags: 0,
129 material: 0,
130 alpha_mul: 255,
131 material_map: Vec::new(),
132 }
133 }
134
135 /// XS.4 — whether this sprite casts a hard shadow (the [dynamic-lighting
136 /// stage](../../../PORTING-DYNLIGHT.md) decides it darkens terrain / other
137 /// sprites). `true` unless [`SPRITE_FLAG_NO_SHADOW_CAST`] is set.
138 #[must_use]
139 pub fn casts_shadow(&self) -> bool {
140 self.flags & SPRITE_FLAG_NO_SHADOW_CAST == 0
141 }
142
143 /// XS.4 — whether this sprite receives hard shadows (is darkened by
144 /// occluders). `true` unless [`SPRITE_FLAG_NO_SHADOW_RECEIVE`] is set.
145 #[must_use]
146 pub fn receives_shadow(&self) -> bool {
147 self.flags & SPRITE_FLAG_NO_SHADOW_RECEIVE == 0
148 }
149
150 /// XS.4 — set whether this sprite casts a hard shadow (clears/sets
151 /// [`SPRITE_FLAG_NO_SHADOW_CAST`]). Returns `self` for chaining.
152 #[must_use]
153 pub fn with_casts_shadow(mut self, casts: bool) -> Self {
154 if casts {
155 self.flags &= !SPRITE_FLAG_NO_SHADOW_CAST;
156 } else {
157 self.flags |= SPRITE_FLAG_NO_SHADOW_CAST;
158 }
159 self
160 }
161
162 /// XS.4 — set whether this sprite receives hard shadows (clears/sets
163 /// [`SPRITE_FLAG_NO_SHADOW_RECEIVE`]). Returns `self` for chaining.
164 #[must_use]
165 pub fn with_receives_shadow(mut self, receives: bool) -> Self {
166 if receives {
167 self.flags &= !SPRITE_FLAG_NO_SHADOW_RECEIVE;
168 } else {
169 self.flags |= SPRITE_FLAG_NO_SHADOW_RECEIVE;
170 }
171 self
172 }
173
174 /// Carve a sphere out of this sprite's voxel model, controlling
175 /// the colour of the interior the cut exposes. Thin delegate to
176 /// [`Kv6::carve_sphere_with_colfunc`] — `centre` / `radius`,
177 /// `solid`, and `colfunc` are all in **kv6-local** voxel
178 /// coordinates (the sprite pose `p`/`s`/`h`/`f` is untouched). See
179 /// that method for why the `solid` occupancy predicate is required.
180 pub fn carve_sphere_with_colfunc<S, C>(
181 &mut self,
182 centre: [i32; 3],
183 radius: u32,
184 solid: S,
185 colfunc: C,
186 ) where
187 S: Fn(i32, i32, i32) -> bool,
188 C: Fn(i32, i32, i32) -> u32,
189 {
190 self.kv6
191 .carve_sphere_with_colfunc(centre, radius, solid, colfunc);
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 /// XS.4 — sprite shadow flags default to participating and toggle cleanly,
200 /// independently of each other.
201 #[test]
202 fn sprite_shadow_flags_default_on_and_toggle() {
203 let kv6 = Kv6::from_fn_shaded(2, 2, 2, |_, _, _| Some(0x8033_4455));
204 let s = Sprite::axis_aligned(kv6, [0.0; 3]);
205 // Default: casts + receives.
206 assert!(s.casts_shadow() && s.receives_shadow());
207
208 let no_cast = s.clone().with_casts_shadow(false);
209 assert!(!no_cast.casts_shadow() && no_cast.receives_shadow());
210 assert_eq!(
211 no_cast.flags & SPRITE_FLAG_NO_SHADOW_CAST,
212 SPRITE_FLAG_NO_SHADOW_CAST
213 );
214
215 let no_recv = s.clone().with_receives_shadow(false);
216 assert!(no_recv.casts_shadow() && !no_recv.receives_shadow());
217
218 // Toggling one bit leaves the other (and unrelated flags) untouched.
219 let neither = s
220 .clone()
221 .with_casts_shadow(false)
222 .with_receives_shadow(false);
223 assert!(!neither.casts_shadow() && !neither.receives_shadow());
224 let back = neither.with_casts_shadow(true);
225 assert!(back.casts_shadow() && !back.receives_shadow());
226 }
227
228 #[test]
229 fn carve_sphere_delegates_to_kv6_and_leaves_pose() {
230 const BASE: u32 = 0x8033_4455;
231 let kv6 = Kv6::from_fn_shaded(16, 16, 16, |_, _, _| Some(BASE));
232 let mut sprite = Sprite::axis_aligned(kv6, [10.0, 20.0, 30.0]);
233 let before = sprite.kv6.voxels.len();
234
235 sprite.carve_sphere_with_colfunc([8, 8, 8], 4, |_, _, _| true, |_, _, _| 0x8000_FF00);
236
237 // The hollowed-out shell has more surface voxels than the
238 // solid hull did, so the model definitely changed.
239 assert_ne!(sprite.kv6.voxels.len(), before);
240 // Pose untouched — carving is kv6-local only.
241 assert_eq!(sprite.p, [10.0, 20.0, 30.0]);
242 assert_eq!(sprite.s, [1.0, 0.0, 0.0]);
243 }
244}