Sample initial component control poses
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s

This commit is contained in:
Valentin Popov committed 2026-10-18 13:32:41 +04:00
1 parent 74dc42a399
commit 47cb2a693f
2 files changed
+247 -16

No files matched your search

+236 -11
View File
@@ -7,9 +7,11 @@ use fparkan_assets::{
load_material_name_texture_rgba8_and_phase_with_document_from_root, load_material_name_texture_rgba8_and_phase_with_document_from_root,
load_resource_entry_bytes_from_root, load_resource_entry_bytes_from_root,
load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root, load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root,
page_uv_transform, sample_material_phase, Mat0Document, MaterialPhase, MaterialPhaseSample, page_uv_transform, sample_control_node_times, sample_material_phase, ControlInstanceState,
PreparedControlNodeBinding, PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage, ControlSamplingError, Mat0Document, MaterialPhase, MaterialPhaseSample,
PreparedVisual, RgbaImage, StandaloneWearMaterialLoader, PreparedComponentControl, PreparedControlGateProvider, PreparedControlNodeBinding,
PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage,
StandaloneWearMaterialLoader,
}; };
use fparkan_fx::atmosphere::TypedAtmosphere; use fparkan_fx::atmosphere::TypedAtmosphere;
use fparkan_fx::environment::{ use fparkan_fx::environment::{
@@ -3981,6 +3983,68 @@ fn static_preview_component_pose_buffer(
.or_else(|| node38_fallback_hierarchy(model))) .or_else(|| node38_fallback_hierarchy(model)))
} }
fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec<Option<f32>> {
control
.typed_records
.iter()
.filter(|record| record.tag == 21)
.map(|record| match &record.gate_provider {
PreparedControlGateProvider::Resolved {
initial_type1_value,
..
} if initial_type1_value.is_finite() => Some(*initial_type1_value),
_ => None,
})
.collect()
}
fn static_preview_control_pose_buffer(
model: &ModelAsset,
control: &PreparedComponentControl,
native_clock_ms: u32,
state: &mut ControlInstanceState,
) -> Result<Option<NodePoseBuffer>, String> {
if control.model_node_count != model.node_count {
return Err(format!(
"control model node count {} does not match MSH node count {}",
control.model_node_count, model.node_count
));
}
let gate_values = initial_control_gate_values(control);
let sampled_times = match sample_control_node_times(
control,
native_clock_ms,
&gate_values,
state,
) {
Ok(times) => times,
Err(
error @ (ControlSamplingError::UnsupportedTag21Mode { .. }
| ControlSamplingError::UnsupportedVariableMapping { .. }
| ControlSamplingError::MissingGateProvider { .. }
| ControlSamplingError::GateProviderKeyOutOfRange { .. }
| ControlSamplingError::MissingGateProviderValue { .. }
| ControlSamplingError::GateProviderCountMismatch { .. }),
) => {
return static_preview_component_pose_buffer(model, &control.bindings, None)
.map_err(|fallback| {
format!(
"control timeline sample failed ({error:?}) and static CTLD fallback failed: {fallback}"
)
});
}
Err(error) => {
return Err(format!("control timeline sample failed: {error:?}"));
}
};
let node_times = sampled_times
.into_iter()
.map(|time| time.map(AnimationTime))
.collect::<Vec<_>>();
Ok(node38_sampled_hierarchy_at_times(model, &node_times)
.or_else(|| node38_fallback_hierarchy(model)))
}
fn static_preview_mesh_and_materials( fn static_preview_mesh_and_materials(
assets: &MissionAssets, assets: &MissionAssets,
terrain: &TerrainWorld, terrain: &TerrainWorld,
@@ -4088,15 +4152,23 @@ fn static_preview_mesh_and_materials(
let Some(model) = *model else { let Some(model) = *model else {
continue; continue;
}; };
let component_bindings = component_controls let component_control = component_controls
.and_then(|controls| controls.get(component_index)) .and_then(|controls| controls.get(component_index))
.and_then(Option::as_ref) .and_then(Option::as_ref);
.map_or(&[][..], |control| control.bindings.as_slice()); component_pose_buffers[component_index] =
component_pose_buffers[component_index] = static_preview_component_pose_buffer( match (static_animation_frame, component_control) {
&model.validated, (None, Some(control)) => static_preview_control_pose_buffer(
component_bindings, &model.validated,
static_animation_frame, control,
)?; 0,
&mut ControlInstanceState::default(),
)?,
(animation_frame, control) => static_preview_component_pose_buffer(
&model.validated,
control.map_or(&[][..], |control| control.bindings.as_slice()),
animation_frame,
)?,
};
let should_render = tree.as_ref().map_or(true, |_| { let should_render = tree.as_ref().map_or(true, |_| {
unit_component_is_visible(component_index, visual.source_type) unit_component_is_visible(component_index, visual.source_type)
}); });
@@ -6206,6 +6278,159 @@ fn usage() -> String {
mod tests { mod tests {
use super::*; use super::*;
fn control_pose_test_model() -> ModelAsset {
let mut nodes = vec![u8::MAX; 38];
nodes[..2].copy_from_slice(&[0, 0]);
nodes[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
nodes[4..6].copy_from_slice(&0_u16.to_le_bytes());
nodes[6..8].copy_from_slice(&1_u16.to_le_bytes());
ModelAsset {
node_stride: 38,
node_count: 1,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: vec![],
positions: vec![],
normals: None,
uv0: None,
uv1: None,
indices: vec![],
batches: vec![],
node_names: None,
animation: Some(fparkan_msh::ModelAnimation {
keys: vec![
fparkan_animation::AnimKey24 {
time: AnimationTime(0.0),
pose: fparkan_animation::Pose::default(),
},
fparkan_animation::AnimKey24 {
time: AnimationTime(1.0),
pose: fparkan_animation::Pose {
translation: [0.0, 2.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
],
frame_map: vec![0, 1],
frame_count: 2,
}),
}
}
fn control_pose_test_control() -> PreparedComponentControl {
let mut fixed_words = [0; 44];
fixed_words[2] = 0;
fixed_words[6] = u32::MAX;
fixed_words[9] = 1.0_f32.to_bits();
fixed_words[10] = 1.0_f32.to_bits();
let archive = || {
fparkan_path::normalize_relative(b"test.rlb", fparkan_path::PathPolicy::HostCompatible)
.expect("test archive path")
};
PreparedComponentControl {
source: fparkan_resource::ResourceKey {
archive: archive(),
name: ResourceName(b"animated.ctl".to_vec()),
type_id: None,
},
model_node_count: 1,
bindings: vec![],
timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow {
row_index: 0,
node_index: 0,
frame_a: 0.0,
frame_b: 1.0,
initial_blend: 0.0,
raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0],
flags: 0,
}],
typed_records: vec![fparkan_assets::PreparedControlTypedRecord {
tag: 21,
fixed_words,
values: vec![0],
string_bytes: vec![],
gate_provider: PreparedControlGateProvider::Resolved {
key: 0,
ndp_source: fparkan_resource::ResourceKey {
archive: archive(),
name: ResourceName(b"animated.ndp".to_vec()),
type_id: None,
},
row_count: 1,
initial_type1_value: 1.0,
},
}],
node_data: None,
effect_section: fparkan_assets::PreparedControlEffectSection::Absent,
point_list: None,
}
}
#[test]
fn control_timeline_and_ndp_gate_change_component_pose_at_sampled_time() {
let model = control_pose_test_model();
let control = control_pose_test_control();
let gate_values = initial_control_gate_values(&control);
let mut raw_state = ControlInstanceState::default();
let raw_initial = sample_control_node_times(&control, 0, &gate_values, &mut raw_state)
.expect("initial raw control timeline");
let raw_later = sample_control_node_times(&control, 225, &gate_values, &mut raw_state)
.expect("later raw control timeline");
assert_eq!(raw_initial, vec![Some(0.0)]);
assert!((raw_later[0].expect("node 0 time") - 0.225).abs() < 1.0e-5);
let mut state = ControlInstanceState::default();
let initial = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
.expect("initial control pose")
.expect("sampled initial hierarchy");
let later = static_preview_control_pose_buffer(&model, &control, 225, &mut state)
.expect("later control pose")
.expect("sampled later hierarchy");
assert_eq!(initial.poses[0].translation, [0.0; 3]);
assert!(
(later.poses[0].translation[1] - 0.45).abs() < 1.0e-4,
"later sampled pose: {:?}",
later.poses[0]
);
let mut closed_gate = control.clone();
let ndp_source = match &control.typed_records[0].gate_provider {
PreparedControlGateProvider::Resolved { ndp_source, .. } => ndp_source.clone(),
_ => panic!("fixture has a resolved NDP source"),
};
closed_gate.typed_records[0].gate_provider = PreparedControlGateProvider::Resolved {
key: 0,
ndp_source,
row_count: 1,
initial_type1_value: 0.0,
};
let mut closed_state = ControlInstanceState::default();
let closed_initial =
static_preview_control_pose_buffer(&model, &closed_gate, 0, &mut closed_state)
.expect("closed-gate initial pose")
.expect("closed-gate initial hierarchy");
let closed_later =
static_preview_control_pose_buffer(&model, &closed_gate, 225, &mut closed_state)
.expect("closed-gate control pose")
.expect("closed-gate hierarchy");
assert_eq!(initial.poses[0], closed_initial.poses[0]);
assert_eq!(closed_initial.poses[0], closed_later.poses[0]);
assert_ne!(later.poses[0], closed_later.poses[0]);
let mut mismatched_control = control.clone();
mismatched_control.model_node_count += 1;
let mismatch = static_preview_control_pose_buffer(
&model,
&mismatched_control,
225,
&mut ControlInstanceState::default(),
)
.expect_err("control/mesh node-count mismatch is rejected");
assert!(mismatch.contains("does not match MSH node count"));
}
fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) { fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) {
const NODE_STRIDE: usize = 38; const NODE_STRIDE: usize = 38;
const SLOT_STRIDE: usize = 68; const SLOT_STRIDE: usize = 68;
+11 -5
View File
@@ -363,11 +363,17 @@ world[n] = world[parent(n)] * local;
``` ```
Статический Vulkan preview берёт начальный кадр узла из явной ссылки прототипа Статический Vulkan preview берёт начальный кадр узла из явной ссылки прототипа
на CTLD `.ctl`. Используются только включённые строки, а при повторе node ID на CTLD `.ctl`. Для поддерживаемых Control timeline и tag 21 он выполняет один
поздняя строка заменяет раннюю. Без `--static-animation-frame` preview применяет сэмпл при native clock `0`, передавая конечные type-1 значения разрешённых NDP
эти настройки узлов; узлы без настройки остаются на кадре `0`. Явный providers; неподдерживаемый режим/variable mapping и отсутствующий или
`--static-animation-frame N` переопределяет их и задаёт `N` всем узлам. неразрешённый gate value возвращают preview к статическим CTLD bindings.
Результат — одна статическая поза, не обновление анимации, Control или AI. Используются только включённые строки, а при повторе node ID поздняя строка
заменяет раннюю. Без
`--static-animation-frame` preview применяет эти настройки узлов; узлы без
настройки остаются на кадре `0`. Явный `--static-animation-frame N`
переопределяет их и задаёт `N` всем узлам, пропуская Control-сэмпл.
Результат — одна стартовая статическая поза, не обновление анимации, Control
или AI по ходу кадров.
MSH sampler выбирает type 19 frame округлением `(time - 0.5)` к ближайшему MSH sampler выбирает type 19 frame округлением `(time - 0.5)` к ближайшему
целому с ties-to-even, затем интерполирует выбранный type 8 key и следующий целому с ties-to-even, затем интерполирует выбранный type 8 key и следующий
key в исходном `time`; это portable-вариант native x87 conversion в default key в исходном `time`; это portable-вариант native x87 conversion в default