diff options
Diffstat (limited to 'crates/fparkan-material')
| -rw-r--r-- | crates/fparkan-material/src/lib.rs | 315 |
1 files changed, 315 insertions, 0 deletions
diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs index da0b716..d61aa84 100644 --- a/crates/fparkan-material/src/lib.rs +++ b/crates/fparkan-material/src/lib.rs @@ -293,6 +293,8 @@ pub enum MaterialError { MissingMaterial(String), /// A material document has no phases for runtime sampling. EmptyMaterial, + /// A normalized MAT0 sample phase is not finite. + InvalidNormalizedPhase, /// An animation block has no positive duration for a multi-key timeline. InvalidAnimationDuration { /// Selected animation block. @@ -366,6 +368,9 @@ impl std::fmt::Display for MaterialError { Self::Resource(message) => write!(f, "{message}"), Self::MissingMaterial(name) => write!(f, "missing material: {name}"), Self::EmptyMaterial => write!(f, "material has no phases"), + Self::InvalidNormalizedPhase => { + write!(f, "normalized MAT0 phase must be finite") + } Self::InvalidAnimationDuration { block_index } => { write!(f, "MAT0 animation block {block_index} has zero duration") } @@ -670,6 +675,115 @@ pub fn sample_material_phase( }) } +/// Samples a MAT0 animation block from the normalized phase used by native +/// material effects. +/// +/// Unlike [`sample_material_phase`], this path does not interpret the block's +/// animation mode or derive a local time from a clock. It multiplies the +/// normalized phase by the final key end time, truncates to an integer tick, +/// searches key intervals from the end, and interpolates the selected phase +/// with the following phase using the block's native interpolation mask. +/// Finite phases outside `0..=1` use the native fallback phase `0.5`; +/// non-finite inputs are rejected. As with [`sample_material_phase`], a +/// missing block index falls back to block zero and an unanimated document +/// samples its first phase. +/// +/// # Errors +/// +/// Returns an error for a non-finite phase, an empty material, a malformed +/// multi-key duration, non-increasing key end times, or a key that references +/// no phase. +pub fn sample_material_normalized_phase( + document: &Mat0Document, + animation_block_index: usize, + normalized_phase: f32, +) -> Result<MaterialPhaseSample, MaterialError> { + if !normalized_phase.is_finite() { + return Err(MaterialError::InvalidNormalizedPhase); + } + + let normalized_phase = if (0.0..=1.0).contains(&normalized_phase) { + normalized_phase + } else { + 0.5 + }; + + let Some(first_phase) = document.phases.first() else { + return Err(MaterialError::EmptyMaterial); + }; + + let Some((selected_block_index, block)) = document + .animation_blocks + .get(animation_block_index) + .map(|block| (animation_block_index, block)) + .or_else(|| document.animation_blocks.first().map(|block| (0, block))) + else { + return Ok(sample_static_phase(0, first_phase)); + }; + + if block.keys.is_empty() { + return Ok(sample_static_phase(0, first_phase)); + } + + let keys = &block.keys; + let resolve_phase = |key_index: usize, key: &MaterialKey| { + let phase_index = usize::from(key.k0); + document + .phases + .get(phase_index) + .map(|phase| (phase_index, phase)) + .ok_or(MaterialError::InvalidAnimationPhaseIndex { + block_index: selected_block_index, + key_index, + phase_index: key.k0, + phase_count: document.phases.len(), + }) + }; + + if keys.len() == 1 { + let (phase_index, phase) = resolve_phase(0, &keys[0])?; + return Ok(sample_static_phase(phase_index, phase)); + } + + let duration = u32::from(keys[keys.len() - 1].k1); + if duration == 0 { + return Err(MaterialError::InvalidAnimationDuration { + block_index: selected_block_index, + }); + } + validate_key_times(keys, selected_block_index)?; + for (key_index, key) in keys.iter().enumerate() { + resolve_phase(key_index, key)?; + } + + // f64 preserves the exact u16-duration by f32-phase product, matching the + // native x87 extended product before truncation toward zero. + let tick = ((duration as f64) * f64::from(normalized_phase)).trunc() as u32; + let (key_index, previous_end) = select_key_backwards(keys, tick); + let key = &keys[key_index]; + let next_key_index = (key_index + 1) % keys.len(); + let next_key = &keys[next_key_index]; + let (phase_index, phase) = resolve_phase(key_index, key)?; + let (_, next_phase) = resolve_phase(next_key_index, next_key)?; + let span = u32::from(key.k1) - previous_end; + if span == 0 { + return Err(MaterialError::InvalidAnimationKeyTimes { + block_index: selected_block_index, + key_index, + }); + } + // Keep the native endpoint behavior: phase 1 produces `tick == duration`, + // falls back to key zero, and may extrapolate beyond its first interval. + let fraction = tick.wrapping_sub(previous_end) as f32 / span as f32; + let coefficients = + interpolate_coefficients(phase, next_phase, block.interpolation_mask(), fraction); + Ok(MaterialPhaseSample { + phase_index, + coefficients, + texture_raw: phase.texture_raw, + }) +} + fn sample_static_phase(phase_index: usize, phase: &MaterialPhase) -> MaterialPhaseSample { MaterialPhaseSample { phase_index, @@ -711,12 +825,29 @@ fn select_key(keys: &[MaterialKey], local_time_ms: u32) -> (usize, u32) { } } +fn select_key_backwards(keys: &[MaterialKey], tick: u32) -> (usize, u32) { + let mut key_index = keys.len() - 1; + while key_index > 0 { + let start = u32::from(keys[key_index - 1].k1); + let end = u32::from(keys[key_index].k1); + if start <= tick && tick < end { + return (key_index, start); + } + key_index -= 1; + } + (0, 0) +} + fn interpolate_coefficients( left: &MaterialPhase, right: &MaterialPhase, mask: u32, fraction: f32, ) -> MaterialCoefficients { + // World3D 0x3030 receives source and destination wrappers one dword before + // each D3DMATERIAL7 body. Its +4/+14/+24/+34 offsets therefore select + // parameters 4..6, 0..2, 8..10, and 12..14; +0x20 selects parameter 3. + // The other alpha/extra fields, power, and page remain from the left phase. let left = left.coefficients(); let right = right.coefficients(); let mut output = left; @@ -1306,6 +1437,178 @@ mod tests { } #[test] + fn material_animation_interpolation_masks_match_native_rebased_offsets() { + let left = phase_with_parameters( + [ + 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, + ], + b"LEFT", + ); + let right = phase_with_parameters( + [ + 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, + 240, + ], + b"RIGHT", + ); + let rgb = |first: u8| [first, first + 10, first + 20].map(|value| f32::from(value) / 255.0); + let cases = [ + (0, rgb(10), rgb(50), rgb(90), rgb(130), 0.4), + (1, rgb(40), rgb(50), rgb(90), rgb(130), 0.4), + (2, rgb(10), rgb(80), rgb(90), rgb(130), 0.4), + (4, rgb(10), rgb(50), rgb(120), rgb(130), 0.4), + (8, rgb(10), rgb(50), rgb(90), rgb(160), 0.4), + (16, rgb(10), rgb(50), rgb(90), rgb(130), 0.7), + (0x1f, rgb(40), rgb(80), rgb(120), rgb(160), 0.7), + ]; + + for (mask, additive, directional, specular, extra, opacity) in cases { + let output = interpolate_coefficients(&left, &right, mask, 0.5); + for (actual, expected) in [ + (output.additive_rgb, additive), + (output.directional_rgb, directional), + (output.specular_rgb, specular), + (output.extra_rgb, extra), + ] { + for channel in 0..3 { + assert!( + (actual[channel] - expected[channel]).abs() < 1e-6, + "mask {mask:#x}, channel {channel}: got {}, expected {}", + actual[channel], + expected[channel] + ); + } + } + + assert!((output.opacity - opacity).abs() < 1e-6, "mask {mask:#x}"); + assert_eq!(output.directional_extra, 80.0 / 255.0); + assert_eq!(output.specular_extra, 120.0 / 255.0); + assert_eq!(output.extra_alpha, 160.0 / 255.0); + assert_eq!(output.power, 170); + assert_eq!(output.page_index, -76); + } + } + + #[test] + fn normalized_material_phase_uses_truncated_tick_and_native_fallback_endpoint() { + let phases = vec![ + phase_with_parameters([0; 18], b"LEFT"), + phase_with_parameters([255; 18], b"RIGHT"), + ]; + let keys = vec![ + MaterialKey { + k0: 0, + k1: 100, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 200, + k2: 0, + }, + ]; + // RandomPerQuery must not override the explicit normalized phase. + let document = animation_document((0x1f_u32 << 3) | 3, phases, keys); + + let early = sample_material_normalized_phase(&document, 0, 0.25).expect("early"); + assert_eq!(early.phase_index, 0); + assert_eq!(&early.texture_raw[..4], b"LEFT"); + assert!((early.coefficients.additive_rgb[0] - 0.5).abs() < 1e-6); + + // 200 * 0.504 = 100.8; truncation yields tick 100, at the start of key 1. + let truncated = sample_material_normalized_phase(&document, 0, 0.504).expect("tick"); + assert_eq!(truncated.phase_index, 1); + assert_eq!(&truncated.texture_raw[..5], b"RIGHT"); + assert_eq!(truncated.coefficients.additive_rgb[0], 1.0); + + // Native backward search falls through to key zero at the final tick. + let endpoint = sample_material_normalized_phase(&document, 0, 1.0).expect("endpoint"); + assert_eq!(endpoint.phase_index, 0); + assert_eq!(&endpoint.texture_raw[..4], b"LEFT"); + assert!((endpoint.coefficients.additive_rgb[0] - 2.0).abs() < 1e-6); + } + + #[test] + fn normalized_material_phase_preserves_precision_before_native_truncation() { + let document = animation_document( + 1 << 3, + vec![ + phase_with_parameters([0; 18], b"LEFT"), + phase_with_parameters([255; 18], b"RIGHT"), + ], + vec![ + MaterialKey { + k0: 0, + k1: 32_765, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 32_767, + k2: 0, + }, + ], + ); + + // The exact product is below 32_767; f32 multiplication rounds it up. + let sample = sample_material_normalized_phase(&document, 0, f32::from_bits(0x3f7f_ffff)) + .expect("near endpoint"); + assert_eq!(sample.phase_index, 1); + assert_eq!(&sample.texture_raw[..5], b"RIGHT"); + } + + #[test] + fn normalized_material_phase_uses_half_for_finite_out_of_range_values() { + let document = animation_document( + 1 << 3, + vec![ + phase_with_parameters([0; 18], b"LEFT"), + phase_with_parameters([255; 18], b"RIGHT"), + ], + vec![ + MaterialKey { + k0: 0, + k1: 100, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 200, + k2: 0, + }, + ], + ); + + let midpoint = sample_material_normalized_phase(&document, 0, 0.5).expect("midpoint"); + let below = sample_material_normalized_phase(&document, 0, -0.1).expect("below"); + let above = sample_material_normalized_phase(&document, 0, 1.1).expect("above"); + assert_eq!(below, midpoint); + assert_eq!(above, midpoint); + } + + #[test] + fn normalized_material_phase_rejects_non_finite_values() { + let document = animation_document( + 0, + vec![phase_with_parameters([0; 18], b"ONLY")], + vec![MaterialKey { + k0: 0, + k1: 0, + k2: 0, + }], + ); + + assert!(matches!( + sample_material_normalized_phase(&document, 0, f32::NAN), + Err(MaterialError::InvalidNormalizedPhase) + )); + assert!(matches!( + sample_material_normalized_phase(&document, 0, f32::INFINITY), + Err(MaterialError::InvalidNormalizedPhase) + )); + } + + #[test] fn material_ping_pong_reverses_local_time_and_clamp_holds_last_phase() { let phases = vec![ phase_with_parameters([0; 18], b"A"), @@ -1453,6 +1756,10 @@ mod tests { sample_material_phase(&invalid_ref, 0, 0, 0, 0), Err(MaterialError::InvalidAnimationPhaseIndex { .. }) )); + assert!(matches!( + sample_material_normalized_phase(&invalid_ref, 0, 0.5), + Err(MaterialError::InvalidAnimationPhaseIndex { .. }) + )); let invalid_times = animation_document( 0, @@ -1474,6 +1781,10 @@ mod tests { sample_material_phase(&invalid_times, 0, 0, 0, 0), Err(MaterialError::InvalidAnimationKeyTimes { .. }) )); + assert!(matches!( + sample_material_normalized_phase(&invalid_times, 0, 0.5), + Err(MaterialError::InvalidAnimationKeyTimes { .. }) + )); let zero_duration = animation_document( 0, @@ -1495,6 +1806,10 @@ mod tests { sample_material_phase(&zero_duration, 0, 0, 0, 0), Err(MaterialError::InvalidAnimationDuration { .. }) )); + assert!(matches!( + sample_material_normalized_phase(&zero_duration, 0, 0.5), + Err(MaterialError::InvalidAnimationDuration { .. }) + )); } #[test] |
