Skip to main content

embedded_3dgfx/
texture.rs

1//! Texture mapping support for embedded 3D graphics
2//!
3//! This module provides texture storage, sampling, and management for UV-mapped
4//! 3D rendering. It uses static texture data and power-of-2 dimensions for
5//! efficient wrapping without divisions.
6
7use embedded_graphics_core::pixelcolor::{Rgb565, RgbColor};
8use heapless::Vec as HeaplessVec;
9
10/// A 2D texture with RGB565 pixel data
11///
12/// Textures must have power-of-2 dimensions (8, 16, 32, 64, 128, 256, etc.)
13/// for efficient wrapping using bit masks instead of modulo operations.
14#[derive(Debug, Clone, Copy)]
15pub struct Texture {
16    /// Texture pixel data in RGB565 format
17    pub data: &'static [Rgb565],
18    /// Width of the texture (must be power of 2)
19    pub width: u32,
20    /// Height of the texture (must be power of 2)
21    pub height: u32,
22    /// Bit mask for wrapping width (width - 1)
23    width_mask: u32,
24    /// Bit mask for wrapping height (height - 1)
25    height_mask: u32,
26}
27
28impl Texture {
29    /// Create a new texture
30    ///
31    /// # Arguments
32    /// * `data` - Static RGB565 pixel array (must be width × height elements)
33    /// * `width` - Texture width (must be power of 2)
34    /// * `height` - Texture height (must be power of 2)
35    ///
36    /// # Panics
37    /// Panics if width or height is not a power of 2, or if data length doesn't match dimensions
38    pub fn new(data: &'static [Rgb565], width: u32, height: u32) -> Self {
39        assert!(width.is_power_of_two(), "Texture width must be power of 2");
40        assert!(
41            height.is_power_of_two(),
42            "Texture height must be power of 2"
43        );
44        assert_eq!(
45            data.len(),
46            (width * height) as usize,
47            "Texture data length must match width × height"
48        );
49
50        Self {
51            data,
52            width,
53            height,
54            width_mask: width - 1,
55            height_mask: height - 1,
56        }
57    }
58
59    /// Sample the texture at UV coordinates
60    ///
61    /// Uses nearest-neighbor sampling with wrapping (repeat mode).
62    /// UV coordinates are in the range [0.0, 1.0] where:
63    /// - (0, 0) is the top-left corner
64    /// - (1, 1) is the bottom-right corner
65    ///
66    /// # Arguments
67    /// * `u` - Horizontal texture coordinate (0.0-1.0+, wraps)
68    /// * `v` - Vertical texture coordinate (0.0-1.0+, wraps)
69    #[inline]
70    pub fn sample(&self, u: f32, v: f32) -> Rgb565 {
71        // Convert UV [0.0, 1.0] to texture coordinates [0, width/height)
72        let tex_x = (u * self.width as f32) as u32;
73        let tex_y = (v * self.height as f32) as u32;
74
75        // Wrap coordinates using bit masks (fast for power-of-2 dimensions)
76        let tex_x = tex_x & self.width_mask;
77        let tex_y = tex_y & self.height_mask;
78
79        // Lookup pixel
80        self.data[(tex_y * self.width + tex_x) as usize]
81    }
82
83    /// Sample the texture at UV coordinates (integer version for performance)
84    ///
85    /// Uses fixed-point UV coordinates (16.16 format) for faster inner loops.
86    ///
87    /// # Arguments
88    /// * `u_fixed` - Horizontal texture coordinate in 16.16 fixed-point
89    /// * `v_fixed` - Vertical texture coordinate in 16.16 fixed-point
90    #[inline]
91    pub fn sample_fixed(&self, u_fixed: u32, v_fixed: u32) -> Rgb565 {
92        // Convert from 16.16 fixed-point to texture coordinates
93        // Shift right by 16 to get integer part, then multiply by width/height
94        let tex_x = ((u_fixed >> 16) * self.width) >> 16;
95        let tex_y = ((v_fixed >> 16) * self.height) >> 16;
96
97        // Wrap coordinates
98        let tex_x = tex_x & self.width_mask;
99        let tex_y = tex_y & self.height_mask;
100
101        self.data[(tex_y * self.width + tex_x) as usize]
102    }
103
104    /// Sample the texture at Q16.16 fixed-point UV coordinates using fast affine indexing.
105    ///
106    /// `u_q16` and `v_q16` are 16.16 fixed-point numbers where 65536 represents 1.0.
107    #[inline(always)]
108    pub fn sample_affine_q16(&self, u_q16: u32, v_q16: u32) -> Rgb565 {
109        let tex_x = (((u_q16 as u64 * self.width as u64) >> 16) as u32) & self.width_mask;
110        let tex_y = (((v_q16 as u64 * self.height as u64) >> 16) as u32) & self.height_mask;
111        self.data[(tex_y * self.width + tex_x) as usize]
112    }
113
114    /// Sample the texture at Q16.16 fixed-point UV coordinates using 2xSSAA (4-sample sub-pixel anti-aliasing).
115    ///
116    /// Offsets 4 sub-pixel samples by ±0.25 in Q16 (±0x4000)
117    /// and averages their colors to reduce texture aliasing at non-rectilinear angles.
118    #[inline]
119    pub fn sample_affine_2xssaa_q16(&self, u_q16: u32, v_q16: u32) -> Rgb565 {
120        const OFF: u32 = 0x4000; // 0.25 in Q16.16
121        let p0 = self.sample_affine_q16(u_q16.wrapping_sub(OFF), v_q16.wrapping_sub(OFF));
122        let p1 = self.sample_affine_q16(u_q16.wrapping_add(OFF), v_q16.wrapping_sub(OFF));
123        let p2 = self.sample_affine_q16(u_q16.wrapping_sub(OFF), v_q16.wrapping_add(OFF));
124        let p3 = self.sample_affine_q16(u_q16.wrapping_add(OFF), v_q16.wrapping_add(OFF));
125
126        let r = (p0.r() as u32 + p1.r() as u32 + p2.r() as u32 + p3.r() as u32 + 2) >> 2;
127        let g = (p0.g() as u32 + p1.g() as u32 + p2.g() as u32 + p3.g() as u32 + 2) >> 2;
128        let b = (p0.b() as u32 + p1.b() as u32 + p2.b() as u32 + p3.b() as u32 + 2) >> 2;
129
130        Rgb565::new(r as u8, g as u8, b as u8)
131    }
132
133    /// Sample a horizontal scanline using Q16.16 fixed-point affine UV stepping.
134    pub fn sample_affine_scanline_q16(
135        &self,
136        mut u_q16: u32,
137        mut v_q16: u32,
138        du_q16: u32,
139        dv_q16: u32,
140        out_buffer: &mut [Rgb565],
141    ) {
142        for pixel in out_buffer.iter_mut() {
143            *pixel = self.sample_affine_q16(u_q16, v_q16);
144            u_q16 = u_q16.wrapping_add(du_q16);
145            v_q16 = v_q16.wrapping_add(dv_q16);
146        }
147    }
148
149    /// Get texture dimensions
150    pub fn dimensions(&self) -> (u32, u32) {
151        (self.width, self.height)
152    }
153}
154
155const ANIMATION_ID_FLAG: u32 = 0x8000_0000;
156const ANIMATION_ID_MASK: u32 = !ANIMATION_ID_FLAG;
157const MAX_ANIMATIONS: usize = 16;
158const MAX_ANIMATION_FRAMES: usize = 8;
159
160#[derive(Debug, Clone, Copy)]
161struct TextureAnimation {
162    frames: [u32; MAX_ANIMATION_FRAMES],
163    frame_count: u8,
164    ticks_per_frame: u16,
165    tick_accum: u16,
166    current_frame: u8,
167    looping: bool,
168}
169
170impl TextureAnimation {
171    fn new(frame_ids: &[u32], ticks_per_frame: u16, looping: bool) -> Option<Self> {
172        if frame_ids.is_empty() || frame_ids.len() > MAX_ANIMATION_FRAMES {
173            return None;
174        }
175        let mut frames = [0u32; MAX_ANIMATION_FRAMES];
176        for (i, frame_id) in frame_ids.iter().copied().enumerate() {
177            frames[i] = frame_id;
178        }
179        Some(Self {
180            frames,
181            frame_count: frame_ids.len() as u8,
182            ticks_per_frame: ticks_per_frame.max(1),
183            tick_accum: 0,
184            current_frame: 0,
185            looping,
186        })
187    }
188
189    #[inline]
190    fn current_texture_id(&self) -> u32 {
191        self.frames[self.current_frame as usize]
192    }
193
194    fn tick(&mut self, ticks: u16) {
195        if self.frame_count <= 1 {
196            return;
197        }
198        let mut accum = self.tick_accum.saturating_add(ticks);
199        while accum >= self.ticks_per_frame {
200            accum -= self.ticks_per_frame;
201            if self.current_frame + 1 < self.frame_count {
202                self.current_frame += 1;
203            } else if self.looping {
204                self.current_frame = 0;
205            } else {
206                // Clamp to final frame for non-looping animations.
207                accum = 0;
208                break;
209            }
210        }
211        self.tick_accum = accum;
212    }
213}
214
215/// Texture manager for storing multiple textures
216///
217/// Uses a fixed-size heapless vector for no_std compatibility.
218/// The capacity N determines how many textures can be stored.
219pub struct TextureManager<const N: usize> {
220    textures: HeaplessVec<Texture, N>,
221    animations: HeaplessVec<TextureAnimation, MAX_ANIMATIONS>,
222}
223
224impl<const N: usize> TextureManager<N> {
225    /// Create a new empty texture manager
226    pub fn new() -> Self {
227        Self {
228            textures: HeaplessVec::new(),
229            animations: HeaplessVec::new(),
230        }
231    }
232
233    /// Add a texture to the manager
234    ///
235    /// Returns the texture ID (index) that can be used to reference this texture.
236    ///
237    /// # Returns
238    /// `Some(texture_id)` if successful, `None` if the manager is full
239    pub fn add_texture(&mut self, texture: Texture) -> Option<u32> {
240        self.textures.push(texture).ok()?;
241        Some((self.textures.len() - 1) as u32)
242    }
243
244    /// Get a texture by ID
245    ///
246    /// # Arguments
247    /// * `id` - Texture ID returned by `add_texture()`
248    ///
249    /// # Returns
250    /// `Some(&Texture)` if the ID is valid, `None` otherwise
251    pub fn get(&self, id: u32) -> Option<&Texture> {
252        let resolved = self.resolve_texture_id(id)?;
253        self.textures.get(resolved as usize)
254    }
255
256    /// Add an animated texture sequence.
257    ///
258    /// Returns an animation ID that can be used anywhere a texture ID is accepted.
259    pub fn add_animation(
260        &mut self,
261        frame_ids: &[u32],
262        ticks_per_frame: u16,
263        looping: bool,
264    ) -> Option<u32> {
265        // Ensure all frame IDs resolve to concrete texture slots.
266        for frame_id in frame_ids {
267            let resolved = self.resolve_texture_id(*frame_id)?;
268            if resolved as usize >= self.textures.len() {
269                return None;
270            }
271        }
272
273        let animation = TextureAnimation::new(frame_ids, ticks_per_frame, looping)?;
274        self.animations.push(animation).ok()?;
275        let index = (self.animations.len() - 1) as u32;
276        Some(ANIMATION_ID_FLAG | (index & ANIMATION_ID_MASK))
277    }
278
279    /// Advance all registered texture animations by `ticks`.
280    pub fn tick(&mut self, ticks: u16) {
281        for animation in &mut self.animations {
282            animation.tick(ticks);
283        }
284    }
285
286    /// Check whether an ID represents an animation handle.
287    #[inline]
288    pub fn is_animation_id(id: u32) -> bool {
289        (id & ANIMATION_ID_FLAG) != 0
290    }
291
292    /// Resolve an ID to a concrete texture slot.
293    ///
294    /// For static textures this returns the same ID. For animation IDs it
295    /// returns the current frame's texture ID.
296    pub fn resolve_texture_id(&self, id: u32) -> Option<u32> {
297        if !Self::is_animation_id(id) {
298            return Some(id);
299        }
300        let anim_idx = (id & ANIMATION_ID_MASK) as usize;
301        let animation = self.animations.get(anim_idx)?;
302        Some(animation.current_texture_id())
303    }
304
305    /// Get the number of stored textures
306    pub fn len(&self) -> usize {
307        self.textures.len()
308    }
309
310    /// Check if the manager is empty
311    pub fn is_empty(&self) -> bool {
312        self.textures.is_empty()
313    }
314
315    /// Check if the manager is full
316    pub fn is_full(&self) -> bool {
317        self.textures.len() >= N
318    }
319}
320
321impl<const N: usize> Default for TextureManager<N> {
322    fn default() -> Self {
323        Self::new()
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    extern crate std;
330    use super::*;
331    use embedded_graphics_core::pixelcolor::{Rgb565, WebColors};
332
333    #[test]
334    fn test_texture_creation() {
335        static DATA: [Rgb565; 64] = [Rgb565::CSS_RED; 64];
336        let texture = Texture::new(&DATA, 8, 8);
337
338        assert_eq!(texture.width, 8);
339        assert_eq!(texture.height, 8);
340        assert_eq!(texture.dimensions(), (8, 8));
341    }
342
343    #[test]
344    #[should_panic(expected = "width must be power of 2")]
345    fn test_texture_non_power_of_2_width() {
346        static DATA: [Rgb565; 60] = [Rgb565::CSS_RED; 60];
347        let _texture = Texture::new(&DATA, 10, 6); // 10 is not power of 2
348    }
349
350    #[test]
351    #[should_panic(expected = "height must be power of 2")]
352    fn test_texture_non_power_of_2_height() {
353        static DATA: [Rgb565; 48] = [Rgb565::CSS_RED; 48];
354        let _texture = Texture::new(&DATA, 8, 6); // 6 is not power of 2
355    }
356
357    #[test]
358    #[should_panic(expected = "length must match")]
359    fn test_texture_wrong_data_length() {
360        static DATA: [Rgb565; 60] = [Rgb565::CSS_RED; 60];
361        let _texture = Texture::new(&DATA, 8, 8); // Should be 64 elements
362    }
363
364    #[test]
365    fn test_texture_sampling() {
366        static DATA: [Rgb565; 16] = [
367            Rgb565::CSS_RED,
368            Rgb565::CSS_GREEN,
369            Rgb565::CSS_BLUE,
370            Rgb565::CSS_YELLOW,
371            Rgb565::CSS_CYAN,
372            Rgb565::CSS_MAGENTA,
373            Rgb565::CSS_WHITE,
374            Rgb565::CSS_BLACK,
375            Rgb565::CSS_RED,
376            Rgb565::CSS_GREEN,
377            Rgb565::CSS_BLUE,
378            Rgb565::CSS_YELLOW,
379            Rgb565::CSS_CYAN,
380            Rgb565::CSS_MAGENTA,
381            Rgb565::CSS_WHITE,
382            Rgb565::CSS_BLACK,
383        ];
384
385        let texture = Texture::new(&DATA, 4, 4);
386
387        // Sample at corners
388        let tl = texture.sample(0.0, 0.0);
389        assert_eq!(tl, Rgb565::CSS_RED);
390
391        // Sample in middle (0.5, 0.5) -> (2, 2) -> index 10
392        let mid = texture.sample(0.5, 0.5);
393        assert_eq!(mid, Rgb565::CSS_BLUE);
394    }
395
396    #[test]
397    fn test_texture_wrapping() {
398        static DATA: [Rgb565; 16] = [Rgb565::CSS_RED; 16];
399        let texture = Texture::new(&DATA, 4, 4);
400
401        // Sample beyond 1.0 should wrap
402        let wrapped = texture.sample(1.5, 1.5);
403        assert_eq!(wrapped, Rgb565::CSS_RED);
404    }
405
406    #[test]
407    fn test_texture_manager() {
408        static DATA1: [Rgb565; 16] = [Rgb565::CSS_RED; 16];
409        static DATA2: [Rgb565; 64] = [Rgb565::CSS_GREEN; 64];
410
411        let mut manager = TextureManager::<4>::new();
412
413        assert!(manager.is_empty());
414        assert!(!manager.is_full());
415
416        let id1 = manager.add_texture(Texture::new(&DATA1, 4, 4));
417        assert_eq!(id1, Some(0));
418        assert_eq!(manager.len(), 1);
419
420        let id2 = manager.add_texture(Texture::new(&DATA2, 8, 8));
421        assert_eq!(id2, Some(1));
422        assert_eq!(manager.len(), 2);
423
424        // Retrieve textures
425        let tex1 = manager.get(0).unwrap();
426        assert_eq!(tex1.width, 4);
427
428        let tex2 = manager.get(1).unwrap();
429        assert_eq!(tex2.width, 8);
430    }
431
432    #[test]
433    fn test_texture_manager_full() {
434        static DATA: [Rgb565; 16] = [Rgb565::CSS_RED; 16];
435
436        let mut manager = TextureManager::<2>::new();
437
438        // Fill the manager
439        assert!(manager.add_texture(Texture::new(&DATA, 4, 4)).is_some());
440        assert!(manager.add_texture(Texture::new(&DATA, 4, 4)).is_some());
441        assert!(manager.is_full());
442
443        // Try to add one more (should fail)
444        assert!(manager.add_texture(Texture::new(&DATA, 4, 4)).is_none());
445    }
446
447    #[test]
448    fn test_texture_animation_looping_sequence() {
449        static RED: [Rgb565; 16] = [Rgb565::CSS_RED; 16];
450        static GREEN: [Rgb565; 16] = [Rgb565::CSS_GREEN; 16];
451        static BLUE: [Rgb565; 16] = [Rgb565::CSS_BLUE; 16];
452
453        let mut manager = TextureManager::<8>::new();
454        let red = manager.add_texture(Texture::new(&RED, 4, 4)).unwrap();
455        let green = manager.add_texture(Texture::new(&GREEN, 4, 4)).unwrap();
456        let blue = manager.add_texture(Texture::new(&BLUE, 4, 4)).unwrap();
457
458        let anim_id = manager.add_animation(&[red, green, blue], 2, true).unwrap();
459        assert!(TextureManager::<8>::is_animation_id(anim_id));
460
461        assert_eq!(
462            manager.get(anim_id).unwrap().sample(0.0, 0.0),
463            Rgb565::CSS_RED
464        );
465        manager.tick(2);
466        assert_eq!(
467            manager.get(anim_id).unwrap().sample(0.0, 0.0),
468            Rgb565::CSS_GREEN
469        );
470        manager.tick(2);
471        assert_eq!(
472            manager.get(anim_id).unwrap().sample(0.0, 0.0),
473            Rgb565::CSS_BLUE
474        );
475        manager.tick(2);
476        assert_eq!(
477            manager.get(anim_id).unwrap().sample(0.0, 0.0),
478            Rgb565::CSS_RED
479        );
480    }
481
482    #[test]
483    fn test_texture_animation_non_looping_clamps_final_frame() {
484        static RED: [Rgb565; 16] = [Rgb565::CSS_RED; 16];
485        static GREEN: [Rgb565; 16] = [Rgb565::CSS_GREEN; 16];
486
487        let mut manager = TextureManager::<4>::new();
488        let red = manager.add_texture(Texture::new(&RED, 4, 4)).unwrap();
489        let green = manager.add_texture(Texture::new(&GREEN, 4, 4)).unwrap();
490
491        let anim_id = manager.add_animation(&[red, green], 1, false).unwrap();
492        manager.tick(1);
493        assert_eq!(
494            manager.get(anim_id).unwrap().sample(0.0, 0.0),
495            Rgb565::CSS_GREEN
496        );
497        manager.tick(10);
498        assert_eq!(
499            manager.get(anim_id).unwrap().sample(0.0, 0.0),
500            Rgb565::CSS_GREEN
501        );
502    }
503
504    #[test]
505    fn test_texture_sample_affine_q16() {
506        static DATA: [Rgb565; 16] = [
507            Rgb565::CSS_RED,
508            Rgb565::CSS_GREEN,
509            Rgb565::CSS_BLUE,
510            Rgb565::CSS_YELLOW,
511            Rgb565::CSS_CYAN,
512            Rgb565::CSS_MAGENTA,
513            Rgb565::CSS_WHITE,
514            Rgb565::CSS_BLACK,
515            Rgb565::CSS_RED,
516            Rgb565::CSS_GREEN,
517            Rgb565::CSS_BLUE,
518            Rgb565::CSS_YELLOW,
519            Rgb565::CSS_CYAN,
520            Rgb565::CSS_MAGENTA,
521            Rgb565::CSS_WHITE,
522            Rgb565::CSS_BLACK,
523        ];
524        let texture = Texture::new(&DATA, 4, 4);
525
526        // Q16.16 for (0.5, 0.5) => (32768, 32768)
527        let sample = texture.sample_affine_q16(32768, 32768);
528        assert_eq!(sample, Rgb565::CSS_BLUE);
529    }
530
531    #[test]
532    fn test_texture_sample_affine_scanline_q16() {
533        static DATA: [Rgb565; 16] = [Rgb565::CSS_RED; 16];
534        let texture = Texture::new(&DATA, 4, 4);
535
536        let mut scanline = [Rgb565::CSS_BLACK; 4];
537        texture.sample_affine_scanline_q16(0, 0, 16384, 0, &mut scanline);
538        assert_eq!(scanline[0], Rgb565::CSS_RED);
539    }
540}