Update controlled component geometry and mounts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s

This commit is contained in:
Valentin Popov committed 2026-10-18 14:09:06 +04:00
1 parent 88132dc9e1
commit 13f563bb79
2 files changed
+764 -20

No files matched your search

+740 -8
View File
@@ -32,7 +32,7 @@ use fparkan_msh::{
}; };
use fparkan_path::ResourceName; use fparkan_path::ResourceName;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; use fparkan_prototype::{unit_component_tree, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO};
use fparkan_render::{ use fparkan_render::{
LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode, LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode,
LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform, LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform,
@@ -1588,6 +1588,49 @@ fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle>
.collect() .collect()
} }
fn expand_shadow_sphere_to_triangles(
sphere: &mut ShadowSphere,
triangles: &[ShadowTriangle],
) -> Result<bool, String> {
if !sphere.radius.is_finite()
|| sphere.radius < 0.0
|| !sphere.center.iter().all(|value| value.is_finite())
{
return Err("animated shadow caster has an invalid bounding sphere".to_string());
}
let mut changed = false;
for point in triangles.iter().flat_map(|triangle| triangle.positions) {
if !point.iter().all(|value| value.is_finite()) {
return Err("animated shadow triangle has a non-finite position".to_string());
}
let offset = sub3(point, sphere.center);
let distance = length3(offset)
.ok_or_else(|| "animated shadow point distance is non-finite".to_string())?;
if distance <= sphere.radius {
continue;
}
let next_radius = 0.5 * (sphere.radius + distance);
let center_shift = if distance > f32::EPSILON {
scale3(offset, (next_radius - sphere.radius) / distance)
} else {
[0.0; 3]
};
sphere.center = add3(sphere.center, center_shift);
sphere.radius = next_radius;
changed = true;
}
Ok(changed)
}
fn shadow_sphere_enclosing_triangles(
baseline: ShadowSphere,
triangles: &[ShadowTriangle],
) -> Result<ShadowSphere, String> {
let mut sphere = baseline;
expand_shadow_sphere_to_triangles(&mut sphere, triangles)?;
Ok(sphere)
}
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct ShadowComponentSphere { struct ShadowComponentSphere {
center: [f32; 3], center: [f32; 3],
@@ -3781,9 +3824,16 @@ struct StaticPreviewScene {
material_animations: Vec<MaterialAnimationBinding>, material_animations: Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: Vec<usize>, sun_occlusion_range_indices: Vec<usize>,
shadow_casters: Vec<ShadowCaster>, shadow_casters: Vec<ShadowCaster>,
shadow_sphere_baselines: Vec<ShadowSphere>,
shadow_receivers: Vec<ShadowTriangle>, shadow_receivers: Vec<ShadowTriangle>,
shadow_material_index: usize, shadow_material_index: usize,
terrain: Arc<TerrainWorld>, terrain: Arc<TerrainWorld>,
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
control_animation_instances: Vec<ControlAnimationInstance>,
dynamic_component_geometry: Vec<DynamicComponentGeometry>,
dynamic_component_mounts: Vec<DynamicComponentMount>,
shadow_caster_indices_by_object: Vec<Option<usize>>,
camera: VulkanStaticCamera, camera: VulkanStaticCamera,
view_camera: PreviewCameraFrame, view_camera: PreviewCameraFrame,
captured_viewport: Option<CapturedViewport>, captured_viewport: Option<CapturedViewport>,
@@ -3793,6 +3843,35 @@ struct StaticPreviewScene {
terrain_components: usize, terrain_components: usize,
} }
#[derive(Clone, Debug)]
struct ControlAnimationInstance {
object_index: usize,
component_index: usize,
model: Arc<ModelAsset>,
control: PreparedComponentControl,
state: ControlInstanceState,
}
#[derive(Clone, Debug)]
struct DynamicComponentGeometry {
object_index: usize,
component_index: usize,
model: Arc<ModelAsset>,
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
vertex_start: usize,
vertex_count: usize,
shadow_triangle_range: Option<std::ops::Range<usize>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DynamicComponentMount {
object_index: usize,
component_index: usize,
parent_index: usize,
socket: usize,
}
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
struct CapturedViewport { struct CapturedViewport {
/// Original D3D7 viewport rectangle `(left, top, right, bottom)`. The /// Original D3D7 viewport rectangle `(left, top, right, bottom)`. The
@@ -3998,6 +4077,90 @@ fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec<Option
.collect() .collect()
} }
fn control_has_tag21(control: &PreparedComponentControl) -> bool {
control.typed_records.iter().any(|record| record.tag == 21)
}
fn component_chain_has_tag21(
tree: Option<&[UnitComponentTreeNode]>,
controls: &[Option<PreparedComponentControl>],
component_index: usize,
) -> bool {
let Some(tree) = tree else {
return false;
};
let mut current = Some(component_index);
for _ in 0..=tree.len() {
let Some(index) = current else {
return false;
};
if controls
.get(index)
.and_then(Option::as_ref)
.is_some_and(control_has_tag21)
{
return true;
}
current = tree.get(index).and_then(|node| node.parent_index);
}
false
}
fn refresh_dynamic_component_mounts(
mounts: &[DynamicComponentMount],
pose_buffers: &[Vec<Option<NodePoseBuffer>>],
mount_poses: &mut [Vec<Option<VulkanNodePose>>],
) -> Result<bool, String> {
let mut changed = false;
for mount in mounts {
let parent_mounted = mount_poses
.get(mount.object_index)
.and_then(|poses| poses.get(mount.parent_index))
.copied()
.flatten();
let parent_hierarchy = pose_buffers
.get(mount.object_index)
.and_then(|buffers| buffers.get(mount.parent_index))
.and_then(Option::as_ref)
.ok_or_else(|| {
format!(
"dynamic component {} parent {} has no sampled Node38 hierarchy",
mount.component_index, mount.parent_index
)
})?;
let socket_pose = if parent_mounted.is_some() {
node38_pose_relative_to_root_from_hierarchy(parent_hierarchy, mount.socket)
} else {
node38_pose_from_hierarchy(parent_hierarchy, mount.socket)
}
.ok_or_else(|| {
format!(
"dynamic component {} mount socket {} is absent from parent {}",
mount.component_index, mount.socket, mount.parent_index
)
})?;
let root_pose = parent_mounted.unwrap_or(VulkanNodePose {
translation: [0.0; 3],
rotation: [0.0, 0.0, 0.0, 1.0],
});
let next_pose = compose_unit_pose(root_pose, socket_pose);
let slot = mount_poses
.get_mut(mount.object_index)
.and_then(|poses| poses.get_mut(mount.component_index))
.ok_or_else(|| {
format!(
"dynamic component {} mount slot is outside object {}",
mount.component_index, mount.object_index
)
})?;
if *slot != Some(next_pose) {
*slot = Some(next_pose);
changed = true;
}
}
Ok(changed)
}
fn static_preview_control_pose_buffer( fn static_preview_control_pose_buffer(
model: &ModelAsset, model: &ModelAsset,
control: &PreparedComponentControl, control: &PreparedComponentControl,
@@ -4045,6 +4208,35 @@ fn static_preview_control_pose_buffer(
.or_else(|| node38_fallback_hierarchy(model))) .or_else(|| node38_fallback_hierarchy(model)))
} }
fn project_dynamic_component_geometry(
dynamic: &DynamicComponentGeometry,
pose: &NodePoseBuffer,
mount: Option<VulkanNodePose>,
) -> Result<VulkanStaticMesh, String> {
let projected = if let Some(mount) = mount {
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
&dynamic.model,
dynamic.transform,
dynamic.scale,
pose,
mount,
)
} else {
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
&dynamic.model,
dynamic.transform,
dynamic.scale,
pose,
)
};
projected.map_err(|error| {
format!(
"update mission object {} component {} geometry: {error}",
dynamic.object_index, dynamic.component_index
)
})
}
fn static_preview_mesh_and_materials( fn static_preview_mesh_and_materials(
assets: &MissionAssets, assets: &MissionAssets,
terrain: &TerrainWorld, terrain: &TerrainWorld,
@@ -4068,6 +4260,13 @@ fn static_preview_mesh_and_materials(
let mut material_animations = Vec::new(); let mut material_animations = Vec::new();
let mut sun_occlusion_range_indices = Vec::new(); let mut sun_occlusion_range_indices = Vec::new();
let mut shadow_casters = Vec::new(); let mut shadow_casters = Vec::new();
let mut shadow_sphere_baselines = Vec::new();
let mut shadow_caster_indices_by_object = Vec::with_capacity(roots.len());
let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len());
let mut component_mount_poses_by_object = Vec::with_capacity(roots.len());
let mut control_animation_instances = Vec::new();
let mut dynamic_component_geometry = Vec::new();
let mut dynamic_component_mounts = Vec::new();
let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?; let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?;
let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root); let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root);
let terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh) let terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh)
@@ -4136,6 +4335,7 @@ fn static_preview_mesh_and_materials(
component_controls.map_or(0, Vec::len) component_controls.map_or(0, Vec::len)
)); ));
} }
let component_controls = component_controls.map_or(&[][..], Vec::as_slice);
let root_native_kind = component_refs let root_native_kind = component_refs
.first() .first()
.and_then(|(visual, _)| native_shadow_kind(visual.source_type)); .and_then(|(visual, _)| native_shadow_kind(visual.source_type));
@@ -4153,15 +4353,25 @@ fn static_preview_mesh_and_materials(
continue; continue;
}; };
let component_control = component_controls let component_control = component_controls
.and_then(|controls| controls.get(component_index)) .get(component_index)
.and_then(Option::as_ref); .and_then(Option::as_ref);
let control_animates_component = component_control.is_some_and(control_has_tag21);
let timeline_moves_component = static_animation_frame.is_none()
&& (control_animates_component
|| component_chain_has_tag21(
tree.as_deref(),
component_controls,
component_index,
));
let runtime_model = timeline_moves_component.then(|| Arc::new(model.validated.clone()));
let mut control_state = ControlInstanceState::default();
component_pose_buffers[component_index] = component_pose_buffers[component_index] =
match (static_animation_frame, component_control) { match (static_animation_frame, component_control) {
(None, Some(control)) => static_preview_control_pose_buffer( (None, Some(control)) => static_preview_control_pose_buffer(
&model.validated, &model.validated,
control, control,
0, 0,
&mut ControlInstanceState::default(), &mut control_state,
)?, )?,
(animation_frame, control) => static_preview_component_pose_buffer( (animation_frame, control) => static_preview_component_pose_buffer(
&model.validated, &model.validated,
@@ -4169,6 +4379,20 @@ fn static_preview_mesh_and_materials(
animation_frame, animation_frame,
)?, )?,
}; };
if control_animates_component && static_animation_frame.is_none() {
control_animation_instances.push(ControlAnimationInstance {
object_index,
component_index,
model: runtime_model
.as_ref()
.map(Arc::clone)
.ok_or_else(|| "animated component model was not retained".to_string())?,
control: component_control
.ok_or_else(|| "tag-21 component control disappeared".to_string())?
.clone(),
state: control_state,
});
}
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)
}); });
@@ -4263,6 +4487,31 @@ fn static_preview_mesh_and_materials(
if let Some(mount_pose) = mount_pose { if let Some(mount_pose) = mount_pose {
component_mount_poses[component_index] = Some(mount_pose); component_mount_poses[component_index] = Some(mount_pose);
} }
if timeline_moves_component && mount_pose.is_some() {
let parent_index = tree
.as_ref()
.and_then(|tree| tree[component_index].parent_index)
.ok_or_else(|| {
format!("mounted dynamic component {component_index} has no parent")
})?;
let socket = usize::try_from(
records
.get(component_index)
.ok_or_else(|| {
format!("unit component {component_index} is outside decoded records")
})?
.parent_or_link,
)
.map_err(|_| {
format!("unit component {component_index} has a negative mount socket")
})?;
dynamic_component_mounts.push(DynamicComponentMount {
object_index,
component_index,
parent_index,
socket,
});
}
let transform = LegacyIron3dEulerTransform { let transform = LegacyIron3dEulerTransform {
translation: mission_root.position, translation: mission_root.position,
@@ -4328,6 +4577,11 @@ fn static_preview_mesh_and_materials(
native_msh_cull_mode(range.batch_flags, component_handedness); native_msh_cull_mode(range.batch_flags, component_handedness);
} }
let component_shadow_triangles = static_mesh_shadow_triangles(&component); let component_shadow_triangles = static_mesh_shadow_triangles(&component);
let shadow_triangle_range = root_native_kind.map(|_| {
let start = root_shadow_triangles.len();
let end = start.saturating_add(component_shadow_triangles.len());
start..end
});
if root_native_kind.is_some() { if root_native_kind.is_some() {
root_shadow_triangles.extend(component_shadow_triangles); root_shadow_triangles.extend(component_shadow_triangles);
let node1_pose = mount_pose.and_then(|_| match pose_buffer { let node1_pose = mount_pose.and_then(|_| match pose_buffer {
@@ -4356,6 +4610,8 @@ fn static_preview_mesh_and_materials(
&mut material_animations, &mut material_animations,
)?; )?;
let lightmap_textures = static_preview_lightmap_textures(assets, visual)?; let lightmap_textures = static_preview_lightmap_textures(assets, visual)?;
let vertex_start = mesh.vertices.len();
let vertex_count = component.vertices.len();
let range_start = mesh.draw_ranges.len(); let range_start = mesh.draw_ranges.len();
append_static_preview_component_with_lightmaps( append_static_preview_component_with_lightmaps(
&mut mesh, &mut mesh,
@@ -4371,15 +4627,31 @@ fn static_preview_mesh_and_materials(
if root_sun_eligible { if root_sun_eligible {
sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
} }
if timeline_moves_component && vertex_count != 0 {
dynamic_component_geometry.push(DynamicComponentGeometry {
object_index,
component_index,
model: runtime_model.as_ref().map(Arc::clone).ok_or_else(|| {
"animated component model was not retained".to_string()
})?,
transform,
scale: mission_root.scale,
vertex_start,
vertex_count,
shadow_triangle_range,
});
}
mesh_components += 1; mesh_components += 1;
} }
} }
if let Some(native_kind) = component_pose_buffers_by_object.push(component_pose_buffers);
component_mount_poses_by_object.push(component_mount_poses);
let shadow_caster_index = if let Some(native_kind) =
root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind)) root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind))
{ {
let caster_id = u32::try_from(shadow_casters.len()) let caster_id = u32::try_from(shadow_casters.len())
.map_err(|_| "shadow caster id exceeds u32".to_string())?; .map_err(|_| "shadow caster id exceeds u32".to_string())?;
if let Some(caster) = aggregate_shadow_caster( aggregate_shadow_caster(
caster_id, caster_id,
native_kind, native_kind,
LegacyIron3dEulerTransform { LegacyIron3dEulerTransform {
@@ -4389,10 +4661,17 @@ fn static_preview_mesh_and_materials(
mission_root.scale, mission_root.scale,
root_shadow_triangles, root_shadow_triangles,
&root_shadow_spheres, &root_shadow_spheres,
) { )
.map(|caster| {
let index = shadow_casters.len();
shadow_sphere_baselines.push(caster.sphere);
shadow_casters.push(caster); shadow_casters.push(caster);
} index
} })
} else {
None
};
shadow_caster_indices_by_object.push(shadow_caster_index);
} }
if mesh_components == 0 { if mesh_components == 0 {
return Err("selected static preview roots have no mesh-backed visual".to_string()); return Err("selected static preview roots have no mesh-backed visual".to_string());
@@ -4422,9 +4701,16 @@ fn static_preview_mesh_and_materials(
material_animations, material_animations,
sun_occlusion_range_indices, sun_occlusion_range_indices,
shadow_casters, shadow_casters,
shadow_sphere_baselines,
shadow_receivers, shadow_receivers,
shadow_material_index, shadow_material_index,
terrain: Arc::new(terrain.clone()), terrain: Arc::new(terrain.clone()),
component_pose_buffers: component_pose_buffers_by_object,
component_mount_poses: component_mount_poses_by_object,
control_animation_instances,
dynamic_component_geometry,
dynamic_component_mounts,
shadow_caster_indices_by_object,
camera, camera,
view_camera, view_camera,
captured_viewport, captured_viewport,
@@ -5186,6 +5472,13 @@ struct StaticVulkanApp {
material_animations: Vec<MaterialAnimationBinding>, material_animations: Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: Vec<usize>, sun_occlusion_range_indices: Vec<usize>,
terrain: Arc<TerrainWorld>, terrain: Arc<TerrainWorld>,
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
control_animation_instances: Vec<ControlAnimationInstance>,
dynamic_component_geometry: Vec<DynamicComponentGeometry>,
dynamic_component_mounts: Vec<DynamicComponentMount>,
shadow_caster_indices_by_object: Vec<Option<usize>>,
shadow_sphere_baselines: Vec<ShadowSphere>,
world_range_indices: Vec<usize>, world_range_indices: Vec<usize>,
world_range_index_counts: HashMap<usize, u32>, world_range_index_counts: HashMap<usize, u32>,
camera: VulkanStaticCamera, camera: VulkanStaticCamera,
@@ -5267,6 +5560,13 @@ impl StaticVulkanApp {
material_animations: preview.material_animations, material_animations: preview.material_animations,
sun_occlusion_range_indices: preview.sun_occlusion_range_indices, sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
terrain: preview.terrain, terrain: preview.terrain,
component_pose_buffers: preview.component_pose_buffers,
component_mount_poses: preview.component_mount_poses,
control_animation_instances: preview.control_animation_instances,
dynamic_component_geometry: preview.dynamic_component_geometry,
dynamic_component_mounts: preview.dynamic_component_mounts,
shadow_caster_indices_by_object: preview.shadow_caster_indices_by_object,
shadow_sphere_baselines: preview.shadow_sphere_baselines,
world_range_indices, world_range_indices,
world_range_index_counts, world_range_index_counts,
camera: preview.camera, camera: preview.camera,
@@ -5469,6 +5769,151 @@ impl StaticVulkanApp {
self.material_time_ms = self.material_elapsed.as_millis() as u32; self.material_time_ms = self.material_elapsed.as_millis() as u32;
} }
fn update_controlled_component_geometry(&mut self) -> Result<(), String> {
let mut pose_changed = false;
for instance in &mut self.control_animation_instances {
let pose = static_preview_control_pose_buffer(
&instance.model,
&instance.control,
self.material_time_ms,
&mut instance.state,
)?;
let slot = self
.component_pose_buffers
.get_mut(instance.object_index)
.and_then(|buffers| buffers.get_mut(instance.component_index))
.ok_or_else(|| {
format!(
"animated component {} pose slot is outside object {}",
instance.component_index, instance.object_index
)
})?;
if *slot != pose {
*slot = pose;
pose_changed = true;
}
}
if !pose_changed {
return Ok(());
}
// Mounts are recalculated from the sampled hierarchy even when the
// parent has no visible draw geometry of its own.
let mounts_changed = refresh_dynamic_component_mounts(
&self.dynamic_component_mounts,
&self.component_pose_buffers,
&mut self.component_mount_poses,
)?;
let mut geometry_changed = false;
let mut shadow_updates = Vec::new();
let mut updated_shadow_casters = HashSet::new();
for dynamic in &self.dynamic_component_geometry {
let pose = self
.component_pose_buffers
.get(dynamic.object_index)
.and_then(|buffers| buffers.get(dynamic.component_index))
.and_then(Option::as_ref)
.ok_or_else(|| {
format!(
"animated component {} has no current Node38 hierarchy",
dynamic.component_index
)
})?;
let mount = self
.component_mount_poses
.get(dynamic.object_index)
.and_then(|poses| poses.get(dynamic.component_index))
.copied()
.flatten();
let projected = project_dynamic_component_geometry(dynamic, pose, mount)?;
if projected.vertices.len() != dynamic.vertex_count {
return Err(format!(
"animated component {} changed projected vertex count from {} to {}",
dynamic.component_index,
dynamic.vertex_count,
projected.vertices.len()
));
}
let end = dynamic
.vertex_start
.checked_add(dynamic.vertex_count)
.ok_or_else(|| "animated component vertex range overflowed".to_string())?;
let destination = self
.mesh
.vertices
.get_mut(dynamic.vertex_start..end)
.ok_or_else(|| {
format!(
"animated component {} vertex range is outside the shared mesh",
dynamic.component_index
)
})?;
if destination != projected.vertices.as_slice() {
destination.copy_from_slice(&projected.vertices);
geometry_changed = true;
}
if let Some(range) = dynamic.shadow_triangle_range.clone() {
let triangles = static_mesh_shadow_triangles(&projected);
if triangles.len() != range.len() {
return Err(format!(
"animated component {} changed shadow triangle count from {} to {}",
dynamic.component_index,
range.len(),
triangles.len()
));
}
shadow_updates.push((dynamic.object_index, range, triangles));
}
}
for (object_index, range, triangles) in shadow_updates {
let Some(caster_index) = self
.shadow_caster_indices_by_object
.get(object_index)
.copied()
.flatten()
else {
continue;
};
let caster = self
.shadow_casters
.get_mut(caster_index)
.ok_or_else(|| format!("shadow caster {caster_index} is unavailable"))?;
let destination = caster.triangles.get_mut(range).ok_or_else(|| {
format!("animated component shadow range is outside caster {caster_index}")
})?;
destination.clone_from_slice(&triangles);
updated_shadow_casters.insert(caster_index);
}
for caster_index in updated_shadow_casters {
let baseline = self
.shadow_sphere_baselines
.get(caster_index)
.copied()
.ok_or_else(|| format!("shadow sphere baseline {caster_index} is unavailable"))?;
let caster = self
.shadow_casters
.get_mut(caster_index)
.ok_or_else(|| format!("shadow caster {caster_index} is unavailable"))?;
// shortcut: native animated sphere updates are unverified; derive
// a conservative envelope from the native startup sphere and all
// current triangles, never from a prior frame's expanded bounds.
caster.sphere = shadow_sphere_enclosing_triangles(baseline, &caster.triangles)?;
}
if geometry_changed || mounts_changed {
let sort_keys = self.world_draw_sort_keys(self.view_camera.position)?;
if let Some(renderer) = self.renderer.as_mut() {
for (range_index, sort_key) in sort_keys {
renderer
.set_draw_range_sort_key(range_index, sort_key)
.map_err(|error| {
format!("update animated component sort key {range_index}: {error}")
})?;
}
}
}
Ok(())
}
fn update_shadow( fn update_shadow(
mesh: &mut VulkanStaticMesh, mesh: &mut VulkanStaticMesh,
shadow_cache: &mut ShadowPageCache, shadow_cache: &mut ShadowPageCache,
@@ -5971,6 +6416,11 @@ impl ApplicationHandler for StaticVulkanApp {
return; return;
} }
self.update_material_clock(); self.update_material_clock();
if let Err(error) = self.update_controlled_component_geometry() {
self.error = Some(error);
event_loop.exit();
return;
}
if let Err(error) = self.update_environment() { if let Err(error) = self.update_environment() {
self.error = Some(error); self.error = Some(error);
event_loop.exit(); event_loop.exit();
@@ -6318,6 +6768,39 @@ mod tests {
} }
} }
fn control_pose_drawable_test_model() -> ModelAsset {
let mut model = control_pose_test_model();
model.nodes_raw[8..10].copy_from_slice(&0_u16.to_le_bytes());
model.slots = vec![fparkan_msh::Slot {
tri_start: 0,
tri_count: 1,
batch_start: 0,
batch_count: 1,
aabb_min: [0.0; 3],
aabb_max: [1.0; 3],
sphere_center: [0.0; 3],
sphere_radius: 1.0,
opaque: [0; 5],
}];
model.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
model.normals = Some(vec![[0, 0, 127, 0]; 3]);
model.uv0 = Some(vec![[0, 0]; 3]);
model.uv1 = None;
model.indices = vec![0, 1, 2];
model.batches = vec![fparkan_msh::Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
lightmap_index: u8::MAX,
local_batch_index: 0,
index_count: 3,
index_start: 0,
vertex_count: 3,
base_vertex: 0,
}];
model
}
fn control_pose_test_control() -> PreparedComponentControl { fn control_pose_test_control() -> PreparedComponentControl {
let mut fixed_words = [0; 44]; let mut fixed_words = [0; 44];
fixed_words[2] = 0; fixed_words[2] = 0;
@@ -6432,6 +6915,222 @@ mod tests {
assert!(mismatch.contains("does not match MSH node count")); assert!(mismatch.contains("does not match MSH node count"));
} }
#[test]
fn exto_parent_control_updates_visible_child_mount_without_draw_geometry() {
let mut model = control_pose_test_model();
let mut socket_node = vec![u8::MAX; model.node_stride];
socket_node[..2].copy_from_slice(&[0, 0]);
socket_node[2..4].copy_from_slice(&0_u16.to_le_bytes());
socket_node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
socket_node[6..8].copy_from_slice(&1_u16.to_le_bytes());
model.nodes_raw.extend(socket_node);
model.node_count = 2;
let mut control = control_pose_test_control();
control.model_node_count = 2;
let unit_record = |immediate_child_count| fparkan_prototype::UnitComponentRecord {
archive_raw: [0; 32],
resource_raw: [0; 32],
kind: 0,
parent_or_link: 1,
description_raw: [0; 32],
tail0: 0,
immediate_child_count,
};
let unit_tree = unit_component_tree(&[unit_record(1), unit_record(1), unit_record(0)])
.expect("preorder Unit DAT tree");
let controls = vec![None, Some(control.clone()), None];
assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO)));
assert!(unit_component_is_visible(2, Some(PROTOTYPE_TYPE_EXTO)));
assert!(component_chain_has_tag21(Some(&unit_tree), &controls, 2));
assert_eq!(unit_tree[1].parent_index, Some(0));
assert_eq!(unit_tree[2].parent_index, Some(1));
assert!(
unit_tree[2]
.parent_index
.is_some_and(|parent_index| parent_index < 2),
"Unit DAT preorder places a parent before its child"
);
let mut state = ControlInstanceState::default();
let initial_hierarchy = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
.expect("initial EXTO parent pose")
.expect("initial parent hierarchy");
let later_hierarchy = static_preview_control_pose_buffer(&model, &control, 225, &mut state)
.expect("animated EXTO parent pose")
.expect("animated parent hierarchy");
let mut pose_buffers = vec![vec![None, Some(initial_hierarchy), None]];
let mounts = [DynamicComponentMount {
object_index: 0,
component_index: 2,
parent_index: 1,
socket: 1,
}];
let initial = node38_pose_from_hierarchy(
pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"),
1,
)
.expect("parent socket");
let mut mount_poses = vec![vec![None, None, Some(initial)]];
pose_buffers[0][1] = Some(later_hierarchy);
let parent_geometry = DynamicComponentGeometry {
object_index: 0,
component_index: 1,
model: Arc::new(model.clone()),
transform: LegacyIron3dEulerTransform {
translation: [0.0; 3],
orientation_radians: [0.0; 3],
},
scale: [1.0; 3],
vertex_start: 0,
vertex_count: 0,
shadow_triangle_range: None,
};
let empty_parent_mesh = project_dynamic_component_geometry(
&parent_geometry,
pose_buffers[0][1].as_ref().expect("EXTO parent hierarchy"),
None,
)
.expect("zero-geometry EXTO parent");
assert!(empty_parent_mesh.vertices.is_empty());
assert!(empty_parent_mesh.indices.is_empty());
assert!(
refresh_dynamic_component_mounts(&mounts, &pose_buffers, &mut mount_poses)
.expect("refresh child mount")
);
assert_eq!(
mount_poses[0][2].expect("child mount").translation,
[0.0, 2.45, 0.0]
);
// The EXTO parent has a sampled Node38 hierarchy but no projected draw
// vertices. Its hierarchy still drives the visible child's mount.
}
#[test]
fn controlled_component_update_keeps_vertices_and_shadow_triangles_in_sync() {
let model = Arc::new(control_pose_drawable_test_model());
let control = control_pose_test_control();
let dynamic = DynamicComponentGeometry {
object_index: 0,
component_index: 0,
model: Arc::clone(&model),
transform: LegacyIron3dEulerTransform {
translation: [0.0; 3],
orientation_radians: [0.0; 3],
},
scale: [1.0; 3],
vertex_start: 0,
vertex_count: 3,
shadow_triangle_range: Some(0..1),
};
let mut state = ControlInstanceState::default();
let initial_pose = static_preview_control_pose_buffer(&model, &control, 0, &mut state)
.expect("initial hierarchy")
.expect("initial Node38 pose");
let initial_mesh = project_dynamic_component_geometry(&dynamic, &initial_pose, None)
.expect("initial projected component");
let initial_triangles = static_mesh_shadow_triangles(&initial_mesh);
let baseline = ShadowSphere {
center: [0.0; 3],
radius: 1.0,
};
let view_camera = FreeFlightCamera {
position: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
}
.preview_frame(16.0 / 9.0);
let preview = StaticPreviewScene {
mesh: initial_mesh.clone(),
native_handedness_by_range: HashMap::new(),
materials: Vec::new(),
material_animations: Vec::new(),
sun_occlusion_range_indices: Vec::new(),
shadow_casters: vec![ShadowCaster {
id: 0,
world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
sphere: baseline,
native_kind: 4,
triangles: initial_triangles.clone(),
}],
shadow_sphere_baselines: vec![baseline],
shadow_receivers: Vec::new(),
shadow_material_index: 0,
terrain: Arc::new(TerrainWorld::default()),
component_pose_buffers: vec![vec![Some(initial_pose)]],
component_mount_poses: vec![vec![None]],
control_animation_instances: vec![ControlAnimationInstance {
object_index: 0,
component_index: 0,
model: Arc::clone(&model),
control,
state,
}],
dynamic_component_geometry: vec![dynamic],
dynamic_component_mounts: Vec::new(),
shadow_caster_indices_by_object: vec![Some(0)],
camera: view_camera.vulkan_camera(),
view_camera,
captured_viewport: None,
free_camera: None,
camera_mode: "test",
mesh_components: 1,
terrain_components: 0,
};
let mut app = StaticVulkanApp::new(
preview,
None,
None,
EnvironmentGpuRange {
range_index: usize::MAX,
vertex_start: initial_mesh.vertices.len(),
vertex_capacity: 0,
material_index: 0,
},
initial_mesh.vertices.len(),
initial_mesh.indices.len(),
None,
0,
"control-test",
1,
None,
false,
StartupTimings::default(),
);
app.material_time_ms = 225;
app.update_controlled_component_geometry()
.expect("update projected component and caster");
assert_eq!(app.mesh.indices, initial_mesh.indices);
assert_eq!(app.mesh.vertices.len(), initial_mesh.vertices.len());
assert_ne!(
initial_mesh.vertices[0].position,
app.mesh.vertices[0].position
);
assert!((app.mesh.vertices[0].position[1] - 0.45).abs() < 1.0e-4);
let b_vertices = app.mesh.vertices.clone();
let b_triangles = app.shadow_casters[0].triangles.clone();
let b_sphere = app.shadow_casters[0].sphere;
assert_ne!(b_triangles, initial_triangles);
assert!(b_sphere.radius > baseline.radius);
app.update_controlled_component_geometry()
.expect("repeat the same control timestamp");
assert_eq!(app.mesh.vertices, b_vertices);
assert_eq!(app.shadow_casters[0].triangles, b_triangles);
assert_eq!(app.shadow_casters[0].sphere, b_sphere);
app.material_time_ms = 0;
app.update_controlled_component_geometry()
.expect("return to first control pose");
assert_eq!(app.mesh.vertices, initial_mesh.vertices);
assert_eq!(app.shadow_casters[0].triangles, initial_triangles);
assert_eq!(app.shadow_casters[0].sphere, baseline);
}
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;
@@ -6737,6 +7436,39 @@ mod tests {
)); ));
} }
#[test]
fn animated_shadow_bounds_rebuild_from_native_baseline() {
let baseline = ShadowSphere {
center: [0.0; 3],
radius: 1.0,
};
let pose_a = [shadow_triangle(
[[0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.5]],
0,
)];
let pose_b = [shadow_triangle(
[[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]],
0,
)];
let first_a =
shadow_sphere_enclosing_triangles(baseline, &pose_a).expect("initial animated bounds");
assert_eq!(first_a, baseline);
let at_b =
shadow_sphere_enclosing_triangles(baseline, &pose_b).expect("expanded animated bounds");
assert!((at_b.center[0] - 1.5).abs() < 1.0e-5);
assert!((at_b.radius - 2.5).abs() < 1.0e-5);
assert!(length3(at_b.center).expect("finite sphere center") + 1.0 <= at_b.radius);
for point in pose_b.iter().flat_map(|triangle| triangle.positions) {
assert!(
length3(sub3(point, at_b.center)).expect("finite point distance") <= at_b.radius
);
}
let second_a = shadow_sphere_enclosing_triangles(baseline, &pose_a)
.expect("return to first animated pose");
assert_eq!(second_a, first_a);
}
#[test] #[test]
fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() { fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() {
let parent = VulkanNodePose { let parent = VulkanNodePose {
+24 -12
View File
@@ -362,18 +362,30 @@ local.set_translation(pose.position);
world[n] = world[parent(n)] * local; world[n] = world[parent(n)] * local;
``` ```
Статический Vulkan preview берёт начальный кадр узла из явной ссылки прототипа Статический Vulkan preview берёт CTLD `.ctl` из явной ссылки прототипа.
на CTLD `.ctl`. Для поддерживаемых Control timeline и tag 21 он выполняет один Поддерживаемые Control timeline с tag 21 сэмплируются при запуске на native
сэмпл при native clock `0`, передавая конечные type-1 значения разрешённых NDP clock `0`; разрешённые NDP providers передают конечные type-1 значения.
providers; неподдерживаемый режим/variable mapping и отсутствующий или Неподдерживаемый режим или variable mapping, а также отсутствующее или
неразрешённый gate value возвращают preview к статическим CTLD bindings. неразрешённое gate value оставляют компонент на статических CTLD bindings. В
Используются только включённые строки, а при повторе node ID поздняя строка timeline участвуют только включённые строки; при повторном node ID поздняя
заменяет раннюю. Без строка заменяет раннюю.
`--static-animation-frame` preview применяет эти настройки узлов; узлы без
настройки остаются на кадре `0`. Явный `--static-animation-frame N` Без `--static-animation-frame` preview сохраняет экземпляр Control и при
переопределяет их и задаёт `N` всем узлам, пропуская Control-сэмпл. каждом redraw сэмплирует поддерживаемый timeline на текущем native clock.
Результат — одна стартовая статическая поза, не обновление анимации, Control Обновлённая Node38-поза перепроецирует видимую геометрию в заранее выделенный
или AI по ходу кадров. vertex span, оставляя индексы и draw ranges неизменными. Если в пути Unit DAT
компонента есть такой Control, заново вычисляется его EXTO mount socket и
перепроецируется дочерняя геометрия; дочерний mount использует preorder-порядок
Unit DAT. Внутренние INTO-компоненты здесь не считаются глобальными MSH
socket mounts. Обновлённые вершины и shadow-треугольники попадают в общий
mesh upload перед следующим кадром. Culling sphere восстанавливается от
начальной native sphere и расширяется до текущих shadow-треугольников; это
`shortcut:`, потому что native обновление bounding sphere для анимированной
геометрии пока не установлено. Это путь сэмплирования Control-позы, не AI или
анимация эффектов.
Явный `--static-animation-frame N` переопределяет timeline и задаёт `N` всем
узлам, пропуская runtime Control-сэмпл.
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