diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-18 07:49:57 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-18 07:49:58 +0300 |
| commit | 2e02325bf0762a9ac89f0b66773e3bbd7ceb7fe7 (patch) | |
| tree | 4ca3e1179274862106e6f9614f9f81858b7bd77b | |
| parent | 3edf3dfc12524004935f8da3c003c03aa572700b (diff) | |
| download | fparkan-2e02325bf0762a9ac89f0b66773e3bbd7ceb7fe7.tar.xz fparkan-2e02325bf0762a9ac89f0b66773e3bbd7ceb7fe7.zip | |
feat(material): sample normalized MAT0 phases
| -rw-r--r-- | crates/fparkan-material/src/lib.rs | 258 | ||||
| -rw-r--r-- | docs/reference/materials.md | 7 |
2 files changed, 265 insertions, 0 deletions
diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs index 082d10c..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,6 +825,19 @@ 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, @@ -1363,6 +1490,125 @@ mod tests { } #[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"), @@ -1510,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, @@ -1531,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, @@ -1552,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] diff --git a/docs/reference/materials.md b/docs/reference/materials.md index c148bc8..435bd6e 100644 --- a/docs/reference/materials.md +++ b/docs/reference/materials.md @@ -132,6 +132,13 @@ ping-pong нечётный цикл идёт в обратном направлÐ sampler `animation_block_index` уже выбран вызывающим runtime из block table; packed WEAR handle и номер строки WEAR являются отдельными значениями. +Для native effect path с явной normalized phase режим блока не вычисляется: +конечное значение вне `0..=1` заменяется на `0.5`, затем phase умножается на +последний `end_time_ms` и усекается до целого tick. Поиск интервала идёт от +последнего ключа к первому. При phase `1.0` tick равен duration, поэтому ни один +интервал не подходит: выбирается key 0 и общий interpolation path может +экстраполировать за пределы его первого интервала. + ## Fallback Material resolve: |
