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