aboutsummaryrefslogtreecommitdiff
path: root/apps/fparkan-game
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-18 13:09:06 +0300
committerValentin Popov <valentin@popov.link>2026-10-18 13:09:06 +0300
commit13f563bb79228fdb075698c4ed79deea3cc65301 (patch)
tree6e199b8b9c70de275c4225c8aea9ea7a766249c9 /apps/fparkan-game
parent88132dc9e1eed8df0b4db97c7ae068c14fc6e938 (diff)
downloadfparkan-13f563bb79228fdb075698c4ed79deea3cc65301.tar.xz
fparkan-13f563bb79228fdb075698c4ed79deea3cc65301.zip
Update controlled component geometry and mounts
Diffstat (limited to 'apps/fparkan-game')
-rw-r--r--apps/fparkan-game/src/main.rs748
1 files changed, 740 insertions, 8 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 7068882..0c4c7eb 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -32,7 +32,7 @@ use fparkan_msh::{
};
use fparkan_path::ResourceName;
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::{
LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode,
LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform,
@@ -1588,6 +1588,49 @@ fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle>
.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)]
struct ShadowComponentSphere {
center: [f32; 3],
@@ -3781,9 +3824,16 @@ struct StaticPreviewScene {
material_animations: Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: Vec<usize>,
shadow_casters: Vec<ShadowCaster>,
+ shadow_sphere_baselines: Vec<ShadowSphere>,
shadow_receivers: Vec<ShadowTriangle>,
shadow_material_index: usize,
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,
view_camera: PreviewCameraFrame,
captured_viewport: Option<CapturedViewport>,
@@ -3793,6 +3843,35 @@ struct StaticPreviewScene {
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)]
struct CapturedViewport {
/// Original D3D7 viewport rectangle `(left, top, right, bottom)`. The
@@ -3998,6 +4077,90 @@ fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec<Option
.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(
model: &ModelAsset,
control: &PreparedComponentControl,
@@ -4045,6 +4208,35 @@ fn static_preview_control_pose_buffer(
.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(
assets: &MissionAssets,
terrain: &TerrainWorld,
@@ -4068,6 +4260,13 @@ fn static_preview_mesh_and_materials(
let mut material_animations = Vec::new();
let mut sun_occlusion_range_indices = 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 mut terrain_material_loader = StandaloneWearMaterialLoader::new(root);
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)
));
}
+ let component_controls = component_controls.map_or(&[][..], Vec::as_slice);
let root_native_kind = component_refs
.first()
.and_then(|(visual, _)| native_shadow_kind(visual.source_type));
@@ -4153,15 +4353,25 @@ fn static_preview_mesh_and_materials(
continue;
};
let component_control = component_controls
- .and_then(|controls| controls.get(component_index))
+ .get(component_index)
.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] =
match (static_animation_frame, component_control) {
(None, Some(control)) => static_preview_control_pose_buffer(
&model.validated,
control,
0,
- &mut ControlInstanceState::default(),
+ &mut control_state,
)?,
(animation_frame, control) => static_preview_component_pose_buffer(
&model.validated,
@@ -4169,6 +4379,20 @@ fn static_preview_mesh_and_materials(
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, |_| {
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 {
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 {
translation: mission_root.position,
@@ -4328,6 +4577,11 @@ fn static_preview_mesh_and_materials(
native_msh_cull_mode(range.batch_flags, component_handedness);
}
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() {
root_shadow_triangles.extend(component_shadow_triangles);
let node1_pose = mount_pose.and_then(|_| match pose_buffer {
@@ -4356,6 +4610,8 @@ fn static_preview_mesh_and_materials(
&mut material_animations,
)?;
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();
append_static_preview_component_with_lightmaps(
&mut mesh,
@@ -4371,15 +4627,31 @@ fn static_preview_mesh_and_materials(
if root_sun_eligible {
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;
}
}
- 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))
{
let caster_id = u32::try_from(shadow_casters.len())
.map_err(|_| "shadow caster id exceeds u32".to_string())?;
- if let Some(caster) = aggregate_shadow_caster(
+ aggregate_shadow_caster(
caster_id,
native_kind,
LegacyIron3dEulerTransform {
@@ -4389,10 +4661,17 @@ fn static_preview_mesh_and_materials(
mission_root.scale,
root_shadow_triangles,
&root_shadow_spheres,
- ) {
+ )
+ .map(|caster| {
+ let index = shadow_casters.len();
+ shadow_sphere_baselines.push(caster.sphere);
shadow_casters.push(caster);
- }
- }
+ index
+ })
+ } else {
+ None
+ };
+ shadow_caster_indices_by_object.push(shadow_caster_index);
}
if mesh_components == 0 {
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,
sun_occlusion_range_indices,
shadow_casters,
+ shadow_sphere_baselines,
shadow_receivers,
shadow_material_index,
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,
view_camera,
captured_viewport,
@@ -5186,6 +5472,13 @@ struct StaticVulkanApp {
material_animations: Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: Vec<usize>,
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_index_counts: HashMap<usize, u32>,
camera: VulkanStaticCamera,
@@ -5267,6 +5560,13 @@ impl StaticVulkanApp {
material_animations: preview.material_animations,
sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
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_index_counts,
camera: preview.camera,
@@ -5469,6 +5769,151 @@ impl StaticVulkanApp {
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(
mesh: &mut VulkanStaticMesh,
shadow_cache: &mut ShadowPageCache,
@@ -5971,6 +6416,11 @@ impl ApplicationHandler for StaticVulkanApp {
return;
}
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() {
self.error = Some(error);
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 {
let mut fixed_words = [0; 44];
fixed_words[2] = 0;
@@ -6432,6 +6915,222 @@ mod tests {
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) {
const NODE_STRIDE: usize = 38;
const SLOT_STRIDE: usize = 68;
@@ -6738,6 +7437,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]
fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() {
let parent = VulkanNodePose {
translation: [10.0, 20.0, 30.0],