From ec1803da274ae3fbce814905ecf73d10b5b773c7 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Thu, 5 Feb 2026 11:34:32 +0000 Subject: [PATCH] Add sprite/billboard system with W-fade for 4D rendering Implements a sprite rendering system for 2D billboards in 4D space: - SpriteSheet: Atlas metadata with frame UV calculation - Sprite: 4D-positioned billboard with builder pattern API - SpriteBatch: Collects sprites, provides back-to-front sorting - WFadeConfig: Configurable fade based on W distance from camera Key features: - Sprites always face camera in XYZ (classic billboard) - W-fade creates smooth transparency as sprites move through 4D - Linear W-fade: alpha = 1.0 at camera W, 0.0 at fade range - Sorting by 3D distance (W ignored since billboards project to XYZ) Includes 22 unit tests for W-fade math, sorting, and batch operations. --- crates/rust4d_render/src/sprite/batch.rs | 328 +++++++++++++++++++++++ crates/rust4d_render/src/sprite/mod.rs | 76 ++++++ crates/rust4d_render/src/sprite/types.rs | 323 ++++++++++++++++++++++ 3 files changed, 727 insertions(+) create mode 100644 crates/rust4d_render/src/sprite/batch.rs create mode 100644 crates/rust4d_render/src/sprite/mod.rs create mode 100644 crates/rust4d_render/src/sprite/types.rs diff --git a/crates/rust4d_render/src/sprite/batch.rs b/crates/rust4d_render/src/sprite/batch.rs new file mode 100644 index 0000000..d571571 --- /dev/null +++ b/crates/rust4d_render/src/sprite/batch.rs @@ -0,0 +1,328 @@ +//! SpriteBatch for efficient sprite rendering +//! +//! Collects sprites during a frame and provides sorted output for rendering +//! with proper transparency handling. + +use std::collections::HashMap; +use rust4d_math::Vec4; +use super::types::{Sprite, SpriteSheet}; + +/// Batch renderer for sprites +/// +/// Collects sprites during a frame, manages sprite sheets, and provides +/// sorted output for rendering with correct transparency ordering. +pub struct SpriteBatch { + /// Sprites queued for rendering this frame + sprites: Vec, + /// Registered sprite sheets + sheets: HashMap, +} + +impl SpriteBatch { + /// Create a new empty sprite batch + pub fn new() -> Self { + Self { + sprites: Vec::new(), + sheets: HashMap::new(), + } + } + + /// Create a sprite batch with pre-allocated capacity + pub fn with_capacity(sprite_capacity: usize) -> Self { + Self { + sprites: Vec::with_capacity(sprite_capacity), + sheets: HashMap::new(), + } + } + + /// Register a sprite sheet for use with sprites + pub fn register_sheet(&mut self, sheet: SpriteSheet) { + self.sheets.insert(sheet.name.clone(), sheet); + } + + /// Get a registered sprite sheet by name + pub fn get_sheet(&self, name: &str) -> Option<&SpriteSheet> { + self.sheets.get(name) + } + + /// Check if a sprite sheet is registered + pub fn has_sheet(&self, name: &str) -> bool { + self.sheets.contains_key(name) + } + + /// Add a sprite to render this frame + pub fn add(&mut self, sprite: Sprite) { + self.sprites.push(sprite); + } + + /// Add a sprite with automatic W-fade alpha applied + /// + /// This calculates the W-fade and multiplies it with the sprite's + /// existing alpha value before adding to the batch. + pub fn add_4d(&mut self, position: Vec4, current_w_slice: f32, mut sprite: Sprite) { + sprite.position = position; + let fade_alpha = Self::calculate_w_fade(position.w, current_w_slice, sprite.w_fade_range); + sprite.color_tint[3] *= fade_alpha; + self.sprites.push(sprite); + } + + /// Clear all sprites for the next frame + /// + /// Does not clear registered sprite sheets. + pub fn clear(&mut self) { + self.sprites.clear(); + } + + /// Get the number of sprites currently in the batch + pub fn len(&self) -> usize { + self.sprites.len() + } + + /// Check if the batch is empty + pub fn is_empty(&self) -> bool { + self.sprites.is_empty() + } + + /// Get sprites sorted for rendering (back to front) + /// + /// Sorts sprites by 3D distance from the camera (farthest first) + /// so that transparency blending works correctly. + pub fn get_sorted(&self, camera_pos: Vec4) -> Vec<&Sprite> { + let mut sorted: Vec<&Sprite> = self.sprites.iter().collect(); + sorted.sort_by(|a, b| { + let dist_a = a.distance_3d(camera_pos); + let dist_b = b.distance_3d(camera_pos); + // Sort by distance descending (far sprites first) + dist_b.partial_cmp(&dist_a).unwrap_or(std::cmp::Ordering::Equal) + }); + sorted + } + + /// Get sprites sorted and filtered by visibility + /// + /// Returns sprites that have non-zero alpha after W-fade, + /// sorted back to front. + pub fn get_visible_sorted(&self, camera_pos: Vec4, current_w: f32) -> Vec<(&Sprite, f32)> { + let mut visible: Vec<(&Sprite, f32)> = self.sprites + .iter() + .filter_map(|sprite| { + let w_fade = Self::calculate_w_fade(sprite.position.w, current_w, sprite.w_fade_range); + let final_alpha = sprite.color_tint[3] * w_fade; + if final_alpha > 0.001 { + Some((sprite, final_alpha)) + } else { + None + } + }) + .collect(); + + visible.sort_by(|a, b| { + let dist_a = a.0.distance_3d(camera_pos); + let dist_b = b.0.distance_3d(camera_pos); + dist_b.partial_cmp(&dist_a).unwrap_or(std::cmp::Ordering::Equal) + }); + + visible + } + + /// Calculate alpha value for W-fade effect + /// + /// Returns 1.0 when sprite is at the same W as camera, + /// linearly decreasing to 0.0 at the fade_range distance. + /// + /// # Arguments + /// * `sprite_w` - The W coordinate of the sprite + /// * `camera_w` - The W coordinate of the camera/slice + /// * `fade_range` - The W distance at which the sprite becomes fully transparent + pub fn calculate_w_fade(sprite_w: f32, camera_w: f32, fade_range: f32) -> f32 { + if fade_range <= 0.0 { + return 1.0; // No fade, always fully opaque + } + + let w_distance = (sprite_w - camera_w).abs(); + if w_distance >= fade_range { + 0.0 // Fully transparent + } else { + 1.0 - (w_distance / fade_range) // Linear fade + } + } + + /// Iterate over all sprites in insertion order + pub fn iter(&self) -> impl Iterator { + self.sprites.iter() + } + + /// Get all sprites as a slice (unsorted) + pub fn sprites(&self) -> &[Sprite] { + &self.sprites + } + + /// Get all registered sheets + pub fn sheets(&self) -> &HashMap { + &self.sheets + } +} + +impl Default for SpriteBatch { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_w_fade_same_w() { + // Sprite at same W as camera = fully opaque + let alpha = SpriteBatch::calculate_w_fade(5.0, 5.0, 2.0); + assert_eq!(alpha, 1.0); + } + + #[test] + fn test_w_fade_beyond_range() { + // Sprite at W distance >= fade_range = fully transparent + let alpha = SpriteBatch::calculate_w_fade(5.0, 0.0, 2.0); + assert_eq!(alpha, 0.0); + + let alpha = SpriteBatch::calculate_w_fade(-3.0, 0.0, 2.0); + assert_eq!(alpha, 0.0); + } + + #[test] + fn test_w_fade_linear_interpolation() { + // Sprite at half fade distance = 50% alpha + let alpha = SpriteBatch::calculate_w_fade(1.0, 0.0, 2.0); + assert!((alpha - 0.5).abs() < 0.0001); + + // Sprite at quarter fade distance = 75% alpha + let alpha = SpriteBatch::calculate_w_fade(0.5, 0.0, 2.0); + assert!((alpha - 0.75).abs() < 0.0001); + + // Sprite at three-quarter fade distance = 25% alpha + let alpha = SpriteBatch::calculate_w_fade(1.5, 0.0, 2.0); + assert!((alpha - 0.25).abs() < 0.0001); + } + + #[test] + fn test_w_fade_negative_direction() { + // W-fade should work in both directions + let alpha_pos = SpriteBatch::calculate_w_fade(1.0, 0.0, 2.0); + let alpha_neg = SpriteBatch::calculate_w_fade(-1.0, 0.0, 2.0); + assert!((alpha_pos - alpha_neg).abs() < 0.0001); + } + + #[test] + fn test_w_fade_zero_range() { + // Zero fade range = always opaque + let alpha = SpriteBatch::calculate_w_fade(100.0, 0.0, 0.0); + assert_eq!(alpha, 1.0); + } + + #[test] + fn test_sprite_batch_add_and_clear() { + let mut batch = SpriteBatch::new(); + + batch.add(Sprite::new(Vec4::ZERO, "sheet1")); + batch.add(Sprite::new(Vec4::new(1.0, 0.0, 0.0, 0.0), "sheet1")); + assert_eq!(batch.len(), 2); + + batch.clear(); + assert_eq!(batch.len(), 0); + assert!(batch.is_empty()); + } + + #[test] + fn test_sprite_batch_sorting_far_first() { + let mut batch = SpriteBatch::new(); + + // Add sprites at different distances + batch.add(Sprite::new(Vec4::new(1.0, 0.0, 0.0, 0.0), "sheet")); // Distance 1 + batch.add(Sprite::new(Vec4::new(5.0, 0.0, 0.0, 0.0), "sheet")); // Distance 5 + batch.add(Sprite::new(Vec4::new(3.0, 0.0, 0.0, 0.0), "sheet")); // Distance 3 + + let camera = Vec4::ZERO; + let sorted = batch.get_sorted(camera); + + // Far sprites should come first + assert_eq!(sorted[0].position.x, 5.0); + assert_eq!(sorted[1].position.x, 3.0); + assert_eq!(sorted[2].position.x, 1.0); + } + + #[test] + fn test_sprite_batch_sorting_ignores_w() { + let mut batch = SpriteBatch::new(); + + // Same XYZ but different W - should have same sort order based on XYZ + batch.add(Sprite::new(Vec4::new(1.0, 0.0, 0.0, 100.0), "sheet")); + batch.add(Sprite::new(Vec4::new(5.0, 0.0, 0.0, 0.0), "sheet")); + + let camera = Vec4::ZERO; + let sorted = batch.get_sorted(camera); + + // W=100 sprite is at XYZ distance 1, should be second + // W=0 sprite is at XYZ distance 5, should be first + assert_eq!(sorted[0].position.x, 5.0); + assert_eq!(sorted[1].position.x, 1.0); + } + + #[test] + fn test_sheet_registration() { + let mut batch = SpriteBatch::new(); + + let sheet = SpriteSheet::new("enemies", 32, 32, 4, 4); + batch.register_sheet(sheet); + + assert!(batch.has_sheet("enemies")); + assert!(!batch.has_sheet("nonexistent")); + + let retrieved = batch.get_sheet("enemies").unwrap(); + assert_eq!(retrieved.name, "enemies"); + assert_eq!(retrieved.frame_width, 32); + } + + #[test] + fn test_add_4d_applies_w_fade() { + let mut batch = SpriteBatch::new(); + + let sprite = Sprite::new(Vec4::ZERO, "sheet") + .with_w_fade_range(2.0) + .with_color(1.0, 1.0, 1.0, 1.0); + + // Position at W=1.0, camera at W=0.0, fade_range=2.0 + // Expected alpha: 1.0 - (1.0 / 2.0) = 0.5 + let position = Vec4::new(0.0, 0.0, 0.0, 1.0); + batch.add_4d(position, 0.0, sprite); + + assert_eq!(batch.sprites[0].position.w, 1.0); + assert!((batch.sprites[0].color_tint[3] - 0.5).abs() < 0.0001); + } + + #[test] + fn test_get_visible_sorted_filters_transparent() { + let mut batch = SpriteBatch::new(); + + // Visible sprite (W=0 at camera W=0) + batch.add(Sprite::new(Vec4::new(1.0, 0.0, 0.0, 0.0), "sheet").with_w_fade_range(2.0)); + + // Invisible sprite (W=5 at camera W=0, fade_range=2) + batch.add(Sprite::new(Vec4::new(2.0, 0.0, 0.0, 5.0), "sheet").with_w_fade_range(2.0)); + + let camera = Vec4::ZERO; + let visible = batch.get_visible_sorted(camera, 0.0); + + // Only one sprite should be visible + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].0.position.x, 1.0); + assert!((visible[0].1 - 1.0).abs() < 0.0001); // Full alpha + } + + #[test] + fn test_with_capacity() { + let batch = SpriteBatch::with_capacity(100); + assert!(batch.is_empty()); + // Capacity is an implementation detail, just verify it doesn't crash + } +} diff --git a/crates/rust4d_render/src/sprite/mod.rs b/crates/rust4d_render/src/sprite/mod.rs new file mode 100644 index 0000000..f50a279 --- /dev/null +++ b/crates/rust4d_render/src/sprite/mod.rs @@ -0,0 +1,76 @@ +//! Sprite/Billboard rendering system for 4D +//! +//! This module provides a sprite rendering system for displaying 2D billboards +//! in 4D space. Sprites always face the camera in XYZ space (classic billboard +//! behavior) while being positioned in 4D with W-fade effects. +//! +//! # Key Features +//! +//! - **4D Positioning**: Sprites have full 4D positions (x, y, z, w) +//! - **W-Fade**: Sprites fade based on their W distance from the camera +//! - **Billboard Behavior**: Sprites always face the camera in XYZ space +//! - **Sprite Sheets**: Support for animated sprites via atlas textures +//! - **Depth Sorting**: Automatic back-to-front sorting for correct transparency +//! +//! # Example +//! +//! ```ignore +//! use rust4d_render::sprite::{SpriteBatch, Sprite, SpriteSheet}; +//! use rust4d_math::Vec4; +//! +//! // Create a sprite batch +//! let mut batch = SpriteBatch::new(); +//! +//! // Register a sprite sheet +//! let sheet = SpriteSheet::new("enemies", 32, 32, 4, 4); +//! batch.register_sheet(sheet); +//! +//! // Add sprites +//! batch.add( +//! Sprite::new(Vec4::new(0.0, 1.0, 0.0, 0.0), "enemies") +//! .with_frame(0) +//! .with_size(1.0, 1.0) +//! .with_w_fade_range(2.0) +//! ); +//! +//! // Get sorted sprites for rendering +//! let camera_pos = Vec4::new(0.0, 1.0, 5.0, 0.0); +//! let sorted = batch.get_sorted(camera_pos); +//! +//! // Render sprites back-to-front... +//! +//! // Clear for next frame +//! batch.clear(); +//! ``` +//! +//! # W-Fade System +//! +//! Sprites fade based on their distance from the camera in the W dimension: +//! +//! - At W distance 0: Fully opaque (alpha = 1.0) +//! - At W distance = w_fade_range: Fully transparent (alpha = 0.0) +//! - In between: Linear interpolation +//! +//! This creates a smooth transition effect as sprites move through 4D space. +//! +//! # Billboard Behavior +//! +//! Sprites are "billboards" - they always face the camera: +//! +//! - Position in 4D determines where they appear +//! - They face the camera (no 3D rotation) +//! - Size determines world-space dimensions +//! +//! # Depth Sorting +//! +//! For correct transparency rendering, sprites should be rendered back-to-front. +//! Use `get_sorted()` to get sprites in the correct order based on their 3D +//! distance from the camera (W dimension is ignored for depth sorting since +//! billboards are rendered at their XYZ projection). + +pub mod types; +pub mod batch; + +// Re-export main types for convenience +pub use types::{Sprite, SpriteSheet, WFadeConfig}; +pub use batch::SpriteBatch; diff --git a/crates/rust4d_render/src/sprite/types.rs b/crates/rust4d_render/src/sprite/types.rs new file mode 100644 index 0000000..5ebc8bd --- /dev/null +++ b/crates/rust4d_render/src/sprite/types.rs @@ -0,0 +1,323 @@ +//! Sprite and SpriteSheet types for 4D billboard rendering +//! +//! Sprites are 2D billboards positioned in 4D space. They always face the camera +//! in XYZ space (classic billboard behavior) while being positioned in 4D. Their +//! visibility fades based on W distance from the camera's current W slice. + +use rust4d_math::Vec4; + +/// A sprite sheet (atlas of frames) +/// +/// Contains metadata about a texture atlas that can be used for animations +/// or tile-based sprite sets. The actual texture data is managed separately +/// by the rendering backend. +#[derive(Clone, Debug)] +pub struct SpriteSheet { + /// Unique identifier for this sheet + pub name: String, + /// Width of each frame in pixels + pub frame_width: u32, + /// Height of each frame in pixels + pub frame_height: u32, + /// Number of columns in the atlas + pub columns: u32, + /// Number of rows in the atlas + pub rows: u32, + // Note: texture handle would be added when GPU resources are integrated +} + +impl SpriteSheet { + /// Create a new sprite sheet with the given parameters + pub fn new(name: impl Into, frame_width: u32, frame_height: u32, columns: u32, rows: u32) -> Self { + Self { + name: name.into(), + frame_width, + frame_height, + columns, + rows, + } + } + + /// Total number of frames in this sheet + pub fn frame_count(&self) -> u32 { + self.columns * self.rows + } + + /// Get UV coordinates for a specific frame + /// + /// Returns (u_min, v_min, u_max, v_max) normalized to [0, 1] + pub fn frame_uvs(&self, frame: u32) -> [f32; 4] { + let frame = frame % self.frame_count(); + let col = frame % self.columns; + let row = frame / self.columns; + + let u_step = 1.0 / self.columns as f32; + let v_step = 1.0 / self.rows as f32; + + let u_min = col as f32 * u_step; + let v_min = row as f32 * v_step; + let u_max = u_min + u_step; + let v_max = v_min + v_step; + + [u_min, v_min, u_max, v_max] + } +} + +/// A single sprite instance to render +/// +/// Sprites are billboards that always face the camera in XYZ space. They are +/// positioned in 4D and fade based on their W distance from the current camera slice. +#[derive(Clone, Debug)] +pub struct Sprite { + /// 4D position (center of the sprite) + pub position: Vec4, + /// Which sprite sheet to use + pub sheet_name: String, + /// Frame index within the sprite sheet + pub frame: u32, + /// Size in world units [width, height] + pub size: [f32; 2], + /// Distance in W before fade starts (0.0 = no fade, fade linearly to transparent at this distance) + pub w_fade_range: f32, + /// RGBA color tint/modulate + pub color_tint: [f32; 4], +} + +impl Sprite { + /// Create a new sprite with default settings + pub fn new(position: Vec4, sheet_name: impl Into) -> Self { + Self { + position, + sheet_name: sheet_name.into(), + frame: 0, + size: [1.0, 1.0], + w_fade_range: 2.0, // Default fade range of 2 world units + color_tint: [1.0, 1.0, 1.0, 1.0], // White (no tint) + } + } + + /// Set the frame index + pub fn with_frame(mut self, frame: u32) -> Self { + self.frame = frame; + self + } + + /// Set the size in world units + pub fn with_size(mut self, width: f32, height: f32) -> Self { + self.size = [width, height]; + self + } + + /// Set the W fade range + pub fn with_w_fade_range(mut self, range: f32) -> Self { + self.w_fade_range = range; + self + } + + /// Set the color tint + pub fn with_color(mut self, r: f32, g: f32, b: f32, a: f32) -> Self { + self.color_tint = [r, g, b, a]; + self + } + + /// Get the 3D distance from a camera position (for depth sorting) + /// + /// Uses only XYZ components since sprites are billboards + pub fn distance_3d(&self, camera_pos: Vec4) -> f32 { + let dx = self.position.x - camera_pos.x; + let dy = self.position.y - camera_pos.y; + let dz = self.position.z - camera_pos.z; + (dx * dx + dy * dy + dz * dz).sqrt() + } +} + +/// W-fade configuration for sprites +/// +/// Controls how sprites fade based on their W distance from the camera's +/// current viewing slice. +#[derive(Clone, Debug)] +pub struct WFadeConfig { + /// Current camera W slice position + pub current_w: f32, + /// W distance where fade begins (closer sprites are fully opaque) + pub fade_start: f32, + /// W distance where sprites become fully transparent + pub fade_end: f32, +} + +impl WFadeConfig { + /// Create a new W fade configuration + pub fn new(current_w: f32, fade_start: f32, fade_end: f32) -> Self { + Self { + current_w, + fade_start, + fade_end, + } + } + + /// Calculate the alpha multiplier for a sprite at the given W position + pub fn calculate_alpha(&self, sprite_w: f32) -> f32 { + let w_distance = (sprite_w - self.current_w).abs(); + + if w_distance <= self.fade_start { + 1.0 // Fully opaque + } else if w_distance >= self.fade_end { + 0.0 // Fully transparent + } else { + // Linear interpolation between fade_start and fade_end + let fade_range = self.fade_end - self.fade_start; + 1.0 - (w_distance - self.fade_start) / fade_range + } + } +} + +impl Default for WFadeConfig { + fn default() -> Self { + Self { + current_w: 0.0, + fade_start: 0.0, + fade_end: 2.0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sprite_sheet_frame_count() { + let sheet = SpriteSheet::new("test", 32, 32, 4, 4); + assert_eq!(sheet.frame_count(), 16); + } + + #[test] + fn test_sprite_sheet_uvs() { + let sheet = SpriteSheet::new("test", 32, 32, 4, 4); + + // First frame (top-left) + let uvs = sheet.frame_uvs(0); + assert_eq!(uvs[0], 0.0); // u_min + assert_eq!(uvs[1], 0.0); // v_min + assert_eq!(uvs[2], 0.25); // u_max + assert_eq!(uvs[3], 0.25); // v_max + + // Second frame (second column, first row) + let uvs = sheet.frame_uvs(1); + assert_eq!(uvs[0], 0.25); + assert_eq!(uvs[1], 0.0); + assert_eq!(uvs[2], 0.5); + assert_eq!(uvs[3], 0.25); + + // Fifth frame (first column, second row) + let uvs = sheet.frame_uvs(4); + assert_eq!(uvs[0], 0.0); + assert_eq!(uvs[1], 0.25); + assert_eq!(uvs[2], 0.25); + assert_eq!(uvs[3], 0.5); + } + + #[test] + fn test_sprite_sheet_uvs_wrapping() { + let sheet = SpriteSheet::new("test", 32, 32, 4, 4); + + // Frame 16 should wrap to frame 0 + let uvs_0 = sheet.frame_uvs(0); + let uvs_16 = sheet.frame_uvs(16); + assert_eq!(uvs_0, uvs_16); + } + + #[test] + fn test_sprite_creation() { + let sprite = Sprite::new(Vec4::new(1.0, 2.0, 3.0, 4.0), "test_sheet"); + assert_eq!(sprite.position.x, 1.0); + assert_eq!(sprite.position.y, 2.0); + assert_eq!(sprite.position.z, 3.0); + assert_eq!(sprite.position.w, 4.0); + assert_eq!(sprite.sheet_name, "test_sheet"); + assert_eq!(sprite.frame, 0); + } + + #[test] + fn test_sprite_builder_pattern() { + let sprite = Sprite::new(Vec4::ZERO, "sheet") + .with_frame(5) + .with_size(2.0, 3.0) + .with_w_fade_range(5.0) + .with_color(1.0, 0.0, 0.0, 0.5); + + assert_eq!(sprite.frame, 5); + assert_eq!(sprite.size, [2.0, 3.0]); + assert_eq!(sprite.w_fade_range, 5.0); + assert_eq!(sprite.color_tint, [1.0, 0.0, 0.0, 0.5]); + } + + #[test] + fn test_sprite_distance_3d() { + let sprite = Sprite::new(Vec4::new(3.0, 0.0, 4.0, 100.0), "sheet"); + let camera = Vec4::new(0.0, 0.0, 0.0, 0.0); + + // 3-4-5 triangle in XZ, W is ignored for 3D distance + assert!((sprite.distance_3d(camera) - 5.0).abs() < 0.0001); + } + + #[test] + fn test_w_fade_config_same_w() { + let config = WFadeConfig::new(5.0, 0.0, 2.0); + + // Sprite at same W as camera = fully opaque + let alpha = config.calculate_alpha(5.0); + assert_eq!(alpha, 1.0); + } + + #[test] + fn test_w_fade_config_beyond_range() { + let config = WFadeConfig::new(0.0, 0.0, 2.0); + + // Sprite at W distance >= fade_end = fully transparent + let alpha = config.calculate_alpha(3.0); + assert_eq!(alpha, 0.0); + + let alpha = config.calculate_alpha(-2.5); + assert_eq!(alpha, 0.0); + } + + #[test] + fn test_w_fade_config_linear_interpolation() { + let config = WFadeConfig::new(0.0, 0.0, 2.0); + + // Sprite at W=1.0 (half of fade range) = 50% alpha + let alpha = config.calculate_alpha(1.0); + assert!((alpha - 0.5).abs() < 0.0001); + + // Sprite at W=0.5 = 75% alpha + let alpha = config.calculate_alpha(0.5); + assert!((alpha - 0.75).abs() < 0.0001); + + // Sprite at W=1.5 = 25% alpha + let alpha = config.calculate_alpha(1.5); + assert!((alpha - 0.25).abs() < 0.0001); + } + + #[test] + fn test_w_fade_config_with_fade_start() { + // Fade only starts at W distance of 1.0, ends at 3.0 + let config = WFadeConfig::new(0.0, 1.0, 3.0); + + // Within fade_start = fully opaque + let alpha = config.calculate_alpha(0.5); + assert_eq!(alpha, 1.0); + + // At fade_start = fully opaque + let alpha = config.calculate_alpha(1.0); + assert_eq!(alpha, 1.0); + + // Halfway through fade range (1.0 to 3.0, midpoint at 2.0) + let alpha = config.calculate_alpha(2.0); + assert!((alpha - 0.5).abs() < 0.0001); + + // At fade_end = fully transparent + let alpha = config.calculate_alpha(3.0); + assert_eq!(alpha, 0.0); + } +} -- knotmirror 0.0.1