feat(material): sample normalized MAT0 phases
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m41s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m41s
This commit is contained in:
2 files changed
+265
No files matched your search
@@ -293,6 +293,8 @@ pub enum MaterialError {
|
|||||||
MissingMaterial(String),
|
MissingMaterial(String),
|
||||||
/// A material document has no phases for runtime sampling.
|
/// A material document has no phases for runtime sampling.
|
||||||
EmptyMaterial,
|
EmptyMaterial,
|
||||||
|
/// A normalized MAT0 sample phase is not finite.
|
||||||
|
InvalidNormalizedPhase,
|
||||||
/// An animation block has no positive duration for a multi-key timeline.
|
/// An animation block has no positive duration for a multi-key timeline.
|
||||||
InvalidAnimationDuration {
|
InvalidAnimationDuration {
|
||||||
/// Selected animation block.
|
/// Selected animation block.
|
||||||
@@ -366,6 +368,9 @@ impl std::fmt::Display for MaterialError {
|
|||||||
Self::Resource(message) => write!(f, "{message}"),
|
Self::Resource(message) => write!(f, "{message}"),
|
||||||
Self::MissingMaterial(name) => write!(f, "missing material: {name}"),
|
Self::MissingMaterial(name) => write!(f, "missing material: {name}"),
|
||||||
Self::EmptyMaterial => write!(f, "material has no phases"),
|
Self::EmptyMaterial => write!(f, "material has no phases"),
|
||||||
|
Self::InvalidNormalizedPhase => {
|
||||||
|
write!(f, "normalized MAT0 phase must be finite")
|
||||||
|
}
|
||||||
Self::InvalidAnimationDuration { block_index } => {
|
Self::InvalidAnimationDuration { block_index } => {
|
||||||
write!(f, "MAT0 animation block {block_index} has zero duration")
|
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 {
|
fn sample_static_phase(phase_index: usize, phase: &MaterialPhase) -> MaterialPhaseSample {
|
||||||
MaterialPhaseSample {
|
MaterialPhaseSample {
|
||||||
phase_index,
|
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(
|
fn interpolate_coefficients(
|
||||||
left: &MaterialPhase,
|
left: &MaterialPhase,
|
||||||
right: &MaterialPhase,
|
right: &MaterialPhase,
|
||||||
@@ -1362,6 +1489,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]
|
#[test]
|
||||||
fn material_ping_pong_reverses_local_time_and_clamp_holds_last_phase() {
|
fn material_ping_pong_reverses_local_time_and_clamp_holds_last_phase() {
|
||||||
let phases = vec![
|
let phases = vec![
|
||||||
@@ -1510,6 +1756,10 @@ mod tests {
|
|||||||
sample_material_phase(&invalid_ref, 0, 0, 0, 0),
|
sample_material_phase(&invalid_ref, 0, 0, 0, 0),
|
||||||
Err(MaterialError::InvalidAnimationPhaseIndex { .. })
|
Err(MaterialError::InvalidAnimationPhaseIndex { .. })
|
||||||
));
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
sample_material_normalized_phase(&invalid_ref, 0, 0.5),
|
||||||
|
Err(MaterialError::InvalidAnimationPhaseIndex { .. })
|
||||||
|
));
|
||||||
|
|
||||||
let invalid_times = animation_document(
|
let invalid_times = animation_document(
|
||||||
0,
|
0,
|
||||||
@@ -1531,6 +1781,10 @@ mod tests {
|
|||||||
sample_material_phase(&invalid_times, 0, 0, 0, 0),
|
sample_material_phase(&invalid_times, 0, 0, 0, 0),
|
||||||
Err(MaterialError::InvalidAnimationKeyTimes { .. })
|
Err(MaterialError::InvalidAnimationKeyTimes { .. })
|
||||||
));
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
sample_material_normalized_phase(&invalid_times, 0, 0.5),
|
||||||
|
Err(MaterialError::InvalidAnimationKeyTimes { .. })
|
||||||
|
));
|
||||||
|
|
||||||
let zero_duration = animation_document(
|
let zero_duration = animation_document(
|
||||||
0,
|
0,
|
||||||
@@ -1552,6 +1806,10 @@ mod tests {
|
|||||||
sample_material_phase(&zero_duration, 0, 0, 0, 0),
|
sample_material_phase(&zero_duration, 0, 0, 0, 0),
|
||||||
Err(MaterialError::InvalidAnimationDuration { .. })
|
Err(MaterialError::InvalidAnimationDuration { .. })
|
||||||
));
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
sample_material_normalized_phase(&zero_duration, 0, 0.5),
|
||||||
|
Err(MaterialError::InvalidAnimationDuration { .. })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ ping-pong нечётный цикл идёт в обратном направл
|
|||||||
sampler `animation_block_index` уже выбран вызывающим runtime из block table;
|
sampler `animation_block_index` уже выбран вызывающим runtime из block table;
|
||||||
packed WEAR handle и номер строки WEAR являются отдельными значениями.
|
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
|
## Fallback
|
||||||
|
|
||||||
Material resolve:
|
Material resolve:
|
||||||
|
|||||||
Reference in new issue
Block a user