diff options
Diffstat (limited to 'apps/fparkan-game')
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 1223 |
1 files changed, 1200 insertions, 23 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 02d9a1b..0dff5ea 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -10,8 +10,8 @@ use fparkan_assets::{ page_uv_transform, sample_control_node_times, sample_material_phase, ControlInstanceState, ControlSamplingError, Mat0Document, MaterialPhase, MaterialPhaseSample, PreparedComponentControl, PreparedControlGateProvider, PreparedControlNodeBinding, - PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage, - StandaloneWearMaterialLoader, + PreparedMaterialPhase, PreparedModel, PreparedTexture, PreparedTextureUsage, PreparedVisual, + RgbaImage, StandaloneWearMaterialLoader, }; use fparkan_fx::atmosphere::TypedAtmosphere; use fparkan_fx::environment::{ @@ -28,7 +28,7 @@ use fparkan_fx::sky::{ }; use fparkan_msh::{ node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times, - ModelAsset, + selected_slot, Group, Lod, ModelAsset, NodeId, SlotId, }; use fparkan_path::ResourceName; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; @@ -53,8 +53,8 @@ use fparkan_render_vulkan::{ }; use fparkan_runtime::{ create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, - loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionMode, - MissionObjectDraft, MissionRequest, + loaded_mission_object_drafts, loaded_terrain, query_building_segment_world, BuildingMeshNode, + EngineServices, MissionAssets, MissionMode, MissionObjectDraft, MissionRequest, }; use fparkan_terrain::{ FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld, @@ -1053,6 +1053,13 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) - diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 } +fn native_msh_diffuse_alpha_override(batch_flags: u16, type16_present: bool) -> Option<f32> { + if type16_present || batch_flags & 0x0008 == 0 { + return None; + } + (batch_flags & 0x0040 != 0 && batch_flags & 0x0010 == 0).then_some(0.0) +} + fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 { let Some(origin) = transform.try_transform_scaled_point([0.0; 3], scale) else { return 0; @@ -2273,6 +2280,7 @@ impl EnvironmentGpuScene { fn new( mesh: &mut VulkanStaticMesh, native_handedness_by_range: &mut HashMap<usize, i8>, + type16_present_ranges: &mut HashSet<usize>, materials: &mut Vec<VulkanStaticMaterial>, preview_material_animations: &mut Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: &mut Vec<usize>, @@ -2700,6 +2708,15 @@ impl EnvironmentGpuScene { remapped_handedness.insert(new_range_index, handedness); } *native_handedness_by_range = remapped_handedness; + let mut remapped_type16_present_ranges = + HashSet::with_capacity(type16_present_ranges.len()); + for old_range_index in std::mem::take(type16_present_ranges) { + let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| { + format!("type-16 MSH draw range remap is missing range {old_range_index}") + })?; + remapped_type16_present_ranges.insert(new_range_index); + } + *type16_present_ranges = remapped_type16_present_ranges; for layer in &mut scene.sky_layers { layer.range.range_index = *remap .get(layer.range.range_index) @@ -3965,6 +3982,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { struct StaticPreviewScene { mesh: VulkanStaticMesh, native_handedness_by_range: HashMap<usize, i8>, + type16_present_ranges: HashSet<usize>, materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: Vec<usize>, @@ -3977,6 +3995,7 @@ struct StaticPreviewScene { component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>, control_animation_instances: Vec<ControlAnimationInstance>, dynamic_component_geometry: Vec<DynamicComponentGeometry>, + building_collision_bindings: Vec<BuildingCollisionBinding>, dynamic_component_mounts: Vec<DynamicComponentMount>, shadow_caster_indices_by_object: Vec<Option<usize>>, camera: VulkanStaticCamera, @@ -4009,6 +4028,208 @@ struct DynamicComponentGeometry { shadow_triangle_range: Option<std::ops::Range<usize>>, } +#[derive(Clone)] +struct BuildingCollisionBinding { + object_index: usize, + component_index: usize, + model_source: String, + model: Arc<ModelAsset>, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mounted: bool, + selected_nodes: Vec<(NodeId, SlotId)>, +} + +fn building_collision_binding( + object_index: usize, + component_index: usize, + model_source: String, + model: Arc<ModelAsset>, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mounted: bool, +) -> Result<BuildingCollisionBinding, String> { + let context = || { + format!( + "building collision object {object_index} component {component_index} model {model_source}" + ) + }; + if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() { + return Err(format!("{} has no complete Node38 pose data", context())); + } + if model.triangle_descriptors.is_none() { + return Err(format!("{} has no type-7 collision descriptors", context())); + } + if root_transform.try_row_major().is_none() + || !owner_scale.iter().all(|value| value.is_finite()) + || owner_scale.contains(&0.0) + { + return Err(format!("{} has an invalid world transform", context())); + } + let first_node = usize::from(mounted); + let mut selected_nodes = Vec::new(); + for node_index in first_node..model.node_count { + let node = NodeId( + u32::try_from(node_index) + .map_err(|_| format!("{} node index exceeds u32", context()))?, + ); + let Some(slot) = selected_slot(&model, node, Lod(0), Group(0)) else { + continue; + }; + let slot_index = usize::try_from(slot.0) + .map_err(|_| format!("{} node {} has an invalid slot id", context(), node.0))?; + let slot_data = model.slots.get(slot_index).ok_or_else(|| { + format!( + "{} node {} selects missing slot {}", + context(), + node.0, + slot.0 + ) + })?; + let batch_start = usize::from(slot_data.batch_start); + let batch_end = batch_start + .checked_add(usize::from(slot_data.batch_count)) + .ok_or_else(|| format!("{} node {} batch range overflows", context(), node.0))?; + let batches = model.batches.get(batch_start..batch_end).ok_or_else(|| { + format!( + "{} node {} batch range {batch_start}..{batch_end} is invalid", + context(), + node.0 + ) + })?; + if batches.iter().any(|batch| batch.index_count != 0) { + selected_nodes.push((node, slot)); + } + } + Ok(BuildingCollisionBinding { + object_index, + component_index, + model_source, + model, + root_transform, + owner_scale, + mounted, + selected_nodes, + }) +} + +fn building_collision_nodes( + binding: &BuildingCollisionBinding, + pose_buffer: Option<&NodePoseBuffer>, + mount_pose: Option<VulkanNodePose>, +) -> Result<Vec<BuildingMeshNode>, String> { + let context = || { + format!( + "building collision object {} component {} model {}", + binding.object_index, binding.component_index, binding.model_source + ) + }; + if binding.mounted != mount_pose.is_some() { + return Err(format!( + "{} has {} mount pose, expected {}", + context(), + if mount_pose.is_some() { "a" } else { "no" }, + if binding.mounted { "a" } else { "no" } + )); + } + binding + .selected_nodes + .iter() + .map(|(node, slot)| { + let node_index = + usize::try_from(node.0).map_err(|_| format!("{} node id is invalid", context()))?; + let pose = match (pose_buffer, binding.mounted) { + (Some(hierarchy), true) => { + node38_pose_relative_to_root_from_hierarchy(hierarchy, node_index) + } + (Some(hierarchy), false) => node38_pose_from_hierarchy(hierarchy, node_index), + (None, true) => node38_pose_relative_to_root(&binding.model, None, node_index), + (None, false) => node38_pose(&binding.model, None, node_index), + } + .ok_or_else(|| format!("{} node {} has no current Node38 pose", context(), node.0))?; + let world_from_node = component_node_world_matrix( + binding.root_transform, + binding.owner_scale, + mount_pose, + pose, + ) + .ok_or_else(|| format!("{} node {} has an invalid world matrix", context(), node.0))?; + Ok(BuildingMeshNode { + node: *node, + slot: *slot, + world_from_node, + }) + }) + .collect() +} + +fn component_node_world_matrix( + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mount_pose: Option<VulkanNodePose>, + node_pose: VulkanNodePose, +) -> Option<[f32; 16]> { + if !owner_scale + .iter() + .chain(node_pose.translation.iter()) + .chain(node_pose.rotation.iter()) + .chain(mount_pose.iter().flat_map(|pose| pose.translation.iter())) + .chain(mount_pose.iter().flat_map(|pose| pose.rotation.iter())) + .all(|value| value.is_finite()) + { + return None; + } + let pose_matrix = |pose: VulkanNodePose| { + let [x, y, z] = [ + rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation), + rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation), + rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation), + ]; + [ + x[0], + y[0], + z[0], + pose.translation[0], + x[1], + y[1], + z[1], + pose.translation[1], + x[2], + y[2], + z[2], + pose.translation[2], + 0.0, + 0.0, + 0.0, + 1.0, + ] + }; + let scale = [ + owner_scale[0], + 0.0, + 0.0, + 0.0, + 0.0, + owner_scale[1], + 0.0, + 0.0, + 0.0, + 0.0, + owner_scale[2], + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ]; + let mut world = multiply_row_major(root_transform.try_row_major()?, scale); + if let Some(mount) = mount_pose { + world = multiply_row_major(world, pose_matrix(mount)); + } + world = multiply_row_major(world, pose_matrix(node_pose)); + world.iter().all(|value| value.is_finite()).then_some(world) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct DynamicComponentMount { object_index: usize, @@ -4401,6 +4622,7 @@ fn static_preview_mesh_and_materials( draw_ranges: Vec::new(), }; let mut native_handedness_by_range = HashMap::new(); + let mut type16_present_ranges = HashSet::new(); let mut materials = Vec::new(); let mut material_animations = Vec::new(); let mut sun_occlusion_range_indices = Vec::new(); @@ -4412,6 +4634,9 @@ fn static_preview_mesh_and_materials( let mut control_animation_instances = Vec::new(); let mut dynamic_component_geometry = Vec::new(); let mut dynamic_component_mounts = Vec::new(); + let mut building_collision_bindings = Vec::new(); + let mut collision_models = + HashMap::<fparkan_assets::AssetId<PreparedModel>, Arc<ModelAsset>>::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) @@ -4494,7 +4719,15 @@ fn static_preview_mesh_and_materials( let mut component_mount_poses = vec![None; component_refs.len()]; let mut component_pose_buffers = vec![None; component_refs.len()]; for (component_index, (visual, model)) in component_refs.iter().enumerate() { + let should_render = tree.as_ref().map_or(true, |_| { + unit_component_is_visible(component_index, visual.source_type) + }); let Some(model) = *model else { + if legacy_camera.is_none() && root_native_kind == Some(3) && should_render { + return Err(format!( + "building collision object {object_index} component {component_index} has no model" + )); + } continue; }; let component_control = component_controls @@ -4538,9 +4771,6 @@ fn static_preview_mesh_and_materials( state: control_state, }); } - let should_render = tree.as_ref().map_or(true, |_| { - unit_component_is_visible(component_index, visual.source_type) - }); // Native AniMesh only resolves global MSH node sockets for EXTO // geometry. Internal INTO components use controller/equipment // slots, whose `parent_or_link` values are not MSH node indices. @@ -4662,6 +4892,21 @@ fn static_preview_mesh_and_materials( translation: mission_root.position, orientation_radians: mission_root.orientation_raw, }; + if legacy_camera.is_none() && root_native_kind == Some(3) && should_render { + let shared_model = collision_models + .entry(model.id) + .or_insert_with(|| Arc::new(model.validated.clone())) + .clone(); + building_collision_bindings.push(building_collision_binding( + object_index, + component_index, + format!("{:?}", model.source), + shared_model, + transform, + mission_root.scale, + mount_pose.is_some(), + )?); + } if should_render { let pose_buffer = component_pose_buffers[component_index].as_ref(); let mut component = match (pose_buffer, mount_pose, static_animation_frame) { @@ -4768,6 +5013,9 @@ fn static_preview_mesh_and_materials( )?; for range_index in range_start..mesh.draw_ranges.len() { native_handedness_by_range.insert(range_index, component_handedness); + if model.validated.type16_present { + type16_present_ranges.insert(range_index); + } } if root_sun_eligible { sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); @@ -4842,6 +5090,7 @@ fn static_preview_mesh_and_materials( Ok(StaticPreviewScene { mesh, native_handedness_by_range, + type16_present_ranges, materials, material_animations, sun_occlusion_range_indices, @@ -4854,6 +5103,7 @@ fn static_preview_mesh_and_materials( component_mount_poses: component_mount_poses_by_object, control_animation_instances, dynamic_component_geometry, + building_collision_bindings, dynamic_component_mounts, shadow_caster_indices_by_object, camera, @@ -5562,6 +5812,7 @@ fn run_static_vulkan_mode( Some(EnvironmentGpuScene::new( &mut preview.mesh, &mut preview.native_handedness_by_range, + &mut preview.type16_present_ranges, &mut preview.materials, &mut preview.material_animations, &mut preview.sun_occlusion_range_indices, @@ -5677,6 +5928,7 @@ fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f3 struct StaticVulkanApp { mesh: VulkanStaticMesh, native_handedness_by_range: HashMap<usize, i8>, + type16_present_ranges: HashSet<usize>, materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: Vec<usize>, @@ -5685,6 +5937,7 @@ struct StaticVulkanApp { component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>, control_animation_instances: Vec<ControlAnimationInstance>, dynamic_component_geometry: Vec<DynamicComponentGeometry>, + building_collision_bindings: Vec<BuildingCollisionBinding>, dynamic_component_mounts: Vec<DynamicComponentMount>, shadow_caster_indices_by_object: Vec<Option<usize>>, shadow_sphere_baselines: Vec<ShadowSphere>, @@ -5765,6 +6018,7 @@ impl StaticVulkanApp { Self { mesh: preview.mesh, native_handedness_by_range: preview.native_handedness_by_range, + type16_present_ranges: preview.type16_present_ranges, materials: preview.materials, material_animations: preview.material_animations, sun_occlusion_range_indices: preview.sun_occlusion_range_indices, @@ -5773,6 +6027,7 @@ impl StaticVulkanApp { component_mount_poses: preview.component_mount_poses, control_animation_instances: preview.control_animation_instances, dynamic_component_geometry: preview.dynamic_component_geometry, + building_collision_bindings: preview.building_collision_bindings, 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, @@ -5832,7 +6087,17 @@ impl StaticVulkanApp { .materials .get(usize::from(range.material_index)) .map_or(1.0, |material| material.diffuse_alpha); - let transparent = native_world_transparent(range, alpha); + let alpha_override = native_msh_diffuse_alpha_override( + range.batch_flags, + self.type16_present_ranges.contains(&range_index), + ); + let effective_alpha = alpha_override.unwrap_or(alpha); + renderer + .set_draw_range_diffuse_alpha_override(range_index, alpha_override) + .map_err(|error| { + format!("configure world draw range {range_index} alpha: {error}") + })?; + let transparent = native_world_transparent(range, effective_alpha); renderer .set_draw_range_transparency(range_index, transparent) .map_err(|error| format!("configure world draw range {range_index}: {error}"))?; @@ -5853,7 +6118,7 @@ impl StaticVulkanApp { &self.mesh, range, range.index_count, - alpha, + effective_alpha, self.view_camera.position, self.native_handedness_by_range.get(&range_index).copied(), ), @@ -5869,6 +6134,7 @@ impl StaticVulkanApp { mesh: &VulkanStaticMesh, materials: &[VulkanStaticMaterial], world_range_indices: &[usize], + type16_present_ranges: &HashSet<usize>, world_range_index_counts: &HashMap<usize, u32>, native_handedness_by_range: &HashMap<usize, i8>, eye_world: [f32; 3], @@ -5881,7 +6147,7 @@ impl StaticVulkanApp { .get(range_index) .ok_or_else(|| "world draw range index is out of bounds".to_string())?; let state = active_materials.get(&range_index); - let alpha = state.map_or_else( + let material_alpha = state.map_or_else( || { materials .get(usize::from(range.material_index)) @@ -5889,6 +6155,14 @@ impl StaticVulkanApp { }, |state| state.diffuse_alpha, ); + let alpha_override = native_msh_diffuse_alpha_override( + range.batch_flags, + type16_present_ranges.contains(&range_index), + ); + let alpha = alpha_override.unwrap_or(material_alpha); + renderer + .set_draw_range_diffuse_alpha_override(range_index, alpha_override) + .map_err(|error| format!("update world draw range {range_index} alpha: {error}"))?; renderer .set_draw_range_transparency(range_index, native_world_transparent(range, alpha)) .map_err(|error| format!("update world draw range {range_index}: {error}"))?; @@ -5920,19 +6194,20 @@ impl StaticVulkanApp { } fn update_free_camera(&mut self, delta_seconds: f32, viewport: [f32; 2]) -> Result<(), String> { - if self.free_camera.is_none() { + let Some(mut free_camera) = self.free_camera else { return Ok(()); - } - let aspect = viewport[0] / viewport[1].max(1.0); - let view_camera = { - let free_camera = self - .free_camera - .as_mut() - .ok_or_else(|| "free-flight camera disappeared".to_string())?; - free_camera.advance(&self.pressed_keys, delta_seconds.min(0.1)); - free_camera.keep_above_terrain(&self.terrain)?; - free_camera.preview_frame(aspect) }; + let aspect = viewport[0] / viewport[1].max(1.0); + let safe_position = free_camera.position; + free_camera.advance(&self.pressed_keys, delta_seconds.clamp(0.0, 0.1)); + free_camera.keep_above_terrain(&self.terrain)?; + if free_camera.position != safe_position + && self.building_segment_blocked([safe_position, free_camera.position])? + { + free_camera.position = safe_position; + } + let view_camera = free_camera.preview_frame(aspect); + self.free_camera = Some(free_camera); let camera = view_camera.vulkan_camera(); self.view_camera = view_camera; self.camera = camera; @@ -5952,6 +6227,38 @@ impl StaticVulkanApp { Ok(()) } + fn building_segment_blocked(&self, world_segment: [[f32; 3]; 2]) -> Result<bool, String> { + for binding in &self.building_collision_bindings { + if binding.selected_nodes.is_empty() { + continue; + } + let pose_buffer = self + .component_pose_buffers + .get(binding.object_index) + .and_then(|components| components.get(binding.component_index)) + .and_then(Option::as_ref); + let mount_pose = self + .component_mount_poses + .get(binding.object_index) + .and_then(|components| components.get(binding.component_index)) + .copied() + .flatten(); + let nodes = building_collision_nodes(binding, pose_buffer, mount_pose)?; + if query_building_segment_world(&binding.model, &nodes, world_segment) + .map_err(|error| { + format!( + "query building collision object {} component {} model {}: {error}", + binding.object_index, binding.component_index, binding.model_source + ) + })? + .is_some() + { + return Ok(true); + } + } + Ok(false) + } + fn update_material_clock(&mut self, sample: FrameClockSample) { self.material_time_ms = sample.time_ms; } @@ -6322,6 +6629,7 @@ impl StaticVulkanApp { &self.mesh, &self.materials, &self.world_range_indices, + &self.type16_present_ranges, &self.world_range_index_counts, &self.native_handedness_by_range, frame.camera.position, @@ -6378,9 +6686,9 @@ impl StaticVulkanApp { ) -> Result<SceneFrameUpdate, String> { let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)]; let aspect = viewport[0] / viewport[1]; - self.update_free_camera(sample.delta_seconds, viewport)?; self.update_material_clock(sample); self.update_controlled_component_geometry(sample)?; + self.update_free_camera(sample.delta_seconds, viewport)?; let material_update = sample_material_animations( &mut self.material_animations, &self.materials, @@ -7342,8 +7650,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: vec![], batches: vec![], + triangle_descriptors: None, node_names: None, animation: Some(fparkan_msh::ModelAnimation { keys: vec![ @@ -7449,6 +7759,552 @@ mod tests { } } + fn node38_fixture(parent: u16, map_start: u16, fallback: u16, drawable: bool) -> Vec<u8> { + let mut node = Vec::with_capacity(38); + node.extend_from_slice(&[0, 0]); + node.extend_from_slice(&parent.to_le_bytes()); + node.extend_from_slice(&map_start.to_le_bytes()); + node.extend_from_slice(&fallback.to_le_bytes()); + for slot in 0..15 { + let value = if drawable && slot == 0 { 0 } else { u16::MAX }; + node.extend_from_slice(&value.to_le_bytes()); + } + node + } + + fn collision_fixture_model(root_drawable: bool) -> ModelAsset { + let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, root_drawable); + nodes.extend(node38_fixture(0, 0, 1, true)); + ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 3.0, + slots: vec![fparkan_msh::Slot { + tri_start: 0, + tri_count: 1, + batch_start: 0, + batch_count: 1, + aabb_min: [-2.0, 0.0, -2.0], + aabb_max: [2.0, 0.0, 2.0], + sphere_center: [0.0; 3], + sphere_radius: 2.0, + opaque: [0; 5], + }], + positions: vec![[-2.0, 0.0, -2.0], [0.0, 0.0, 2.0], [2.0, 0.0, -2.0]], + normals: Some(vec![[0, 127, 0, 0]; 3]), + uv0: None, + uv1: None, + type16_present: false, + indices: vec![0, 1, 2], + batches: vec![fparkan_msh::Batch { + batch_flags: 0, + material_index_hi: 0, + material_index: 0, + lightmap_index: 0, + local_batch_index: 0, + index_count: 3, + index_start: 0, + vertex_count: 3, + base_vertex: 0, + }], + triangle_descriptors: Some(vec![fparkan_msh::TriangleDescriptor { + tri_flags: 0, + opaque: [0; 4], + normal: [0, 32767, 0], + }]), + 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: [20.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + ], + frame_map: vec![0, 1], + frame_count: 2, + }), + } + } + + fn doorway_collision_model() -> ModelAsset { + let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false); + nodes.extend(node38_fixture(0, u16::MAX, 0, true)); + ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 5.0, + slots: vec![fparkan_msh::Slot { + tri_start: 0, + tri_count: 4, + batch_start: 0, + batch_count: 1, + aabb_min: [-4.0, 0.0, -2.0], + aabb_max: [4.0, 0.0, 2.0], + sphere_center: [0.0; 3], + sphere_radius: 5.0, + opaque: [0; 5], + }], + positions: vec![ + [-4.0, 0.0, -2.0], + [-1.0, 0.0, -2.0], + [-1.0, 0.0, 2.0], + [-4.0, 0.0, 2.0], + [1.0, 0.0, -2.0], + [4.0, 0.0, -2.0], + [4.0, 0.0, 2.0], + [1.0, 0.0, 2.0], + ], + normals: None, + uv0: None, + uv1: None, + type16_present: false, + indices: vec![0, 3, 2, 0, 2, 1, 4, 7, 6, 4, 6, 5], + batches: vec![fparkan_msh::Batch { + batch_flags: 0, + material_index_hi: 0, + material_index: 0, + lightmap_index: 0, + local_batch_index: 0, + index_count: 12, + index_start: 0, + vertex_count: 8, + base_vertex: 0, + }], + triangle_descriptors: Some( + (0..4) + .map(|_| fparkan_msh::TriangleDescriptor { + tri_flags: 0, + opaque: [0; 4], + normal: [0, 32767, 0], + }) + .collect(), + ), + node_names: None, + animation: Some(fparkan_msh::ModelAnimation { + keys: vec![fparkan_animation::AnimKey24 { + time: AnimationTime(0.0), + pose: fparkan_animation::Pose::default(), + }], + frame_map: Vec::new(), + frame_count: 0, + }), + } + } + + fn collision_test_terrain() -> Arc<TerrainWorld> { + let mut land = fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![ + [-100.0, -100.0, 0.0], + [100.0, -100.0, 0.0], + [0.0, 100.0, 0.0], + ], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(1), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }; + set_test_terrain_lod0_range(&mut land); + Arc::new(TerrainWorld::from_land_msh(&land).expect("collision test terrain")) + } + + fn collision_test_app( + model: Arc<ModelAsset>, + root_transform: LegacyIron3dEulerTransform, + position: [f32; 3], + ) -> StaticVulkanApp { + let hierarchy = static_preview_component_pose_buffer(&model, &[], None) + .expect("fixture static preview pose") + .expect("fixture Node38 pose"); + let camera = FreeFlightCamera { + position, + yaw: -std::f32::consts::FRAC_PI_2, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 60.0, + ground_clearance: 0.0, + }; + let view_camera = camera.preview_frame(16.0 / 9.0); + let preview = StaticPreviewScene { + mesh: VulkanStaticMesh { + vertices: Vec::new(), + indices: Vec::new(), + draw_ranges: Vec::new(), + }, + native_handedness_by_range: HashMap::new(), + type16_present_ranges: HashSet::new(), + materials: Vec::new(), + material_animations: Vec::new(), + sun_occlusion_range_indices: Vec::new(), + shadow_casters: Vec::new(), + shadow_sphere_baselines: Vec::new(), + shadow_receivers: Vec::new(), + shadow_material_index: 0, + terrain: collision_test_terrain(), + component_pose_buffers: vec![vec![Some(hierarchy)]], + component_mount_poses: vec![vec![None]], + control_animation_instances: Vec::new(), + dynamic_component_geometry: Vec::new(), + building_collision_bindings: vec![building_collision_binding( + 0, + 0, + "collision fixture".to_string(), + Arc::clone(&model), + root_transform, + [1.0; 3], + false, + ) + .expect("fixture building collision binding")], + dynamic_component_mounts: Vec::new(), + shadow_caster_indices_by_object: vec![None], + camera: view_camera.vulkan_camera(), + view_camera, + captured_viewport: None, + free_camera: Some(camera), + camera_mode: "test", + mesh_components: 0, + terrain_components: 0, + }; + let mut app = StaticVulkanApp::new( + preview, + None, + None, + EnvironmentGpuRange { + range_index: usize::MAX, + vertex_start: 0, + vertex_capacity: 0, + material_index: 0, + }, + 0, + 0, + None, + 0, + None, + "collision-test", + 1, + None, + false, + StartupTimings::default(), + ); + app.pressed_keys.insert(KeyCode::KeyW); + app + } + + #[test] + fn mounted_static_building_binding_matches_renderer_and_hides_root_node() { + let model = collision_fixture_model(true); + let binding = building_collision_binding( + 4, + 5, + "static fixture model".to_string(), + Arc::new(model.clone()), + LegacyIron3dEulerTransform { + translation: [13.0, -4.0, 9.0], + orientation_radians: [0.4, -0.3, 0.7], + }, + [2.0, 3.0, 0.5], + true, + ) + .expect("static building collision binding"); + assert_eq!(binding.selected_nodes, vec![(NodeId(1), SlotId(0))]); + + let root = VulkanNodePose { + translation: [1.0, 2.0, 3.0], + rotation: [0.0, 0.0, 0.29552022, 0.9553365], + }; + let child = compose_unit_pose( + root, + VulkanNodePose { + translation: [2.0, -1.0, 0.5], + rotation: [0.0, 0.19866933, 0.0, 0.9800666], + }, + ); + let hierarchy = NodePoseBuffer { + poses: [root, child] + .into_iter() + .map(|pose| fparkan_animation::Pose { + translation: pose.translation, + rotation: pose.rotation, + }) + .collect(), + }; + let mount = VulkanNodePose { + translation: [5.0, 7.0, 11.0], + rotation: [0.0, 0.0, 0.38268343, 0.9238795], + }; + let nodes = building_collision_nodes(&binding, Some(&hierarchy), Some(mount)) + .expect("mounted collision matrices"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].node, NodeId(1)); + + let projected = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &model, + binding.root_transform, + binding.owner_scale, + &hierarchy, + mount, + ) + .expect("mounted renderer projection"); + assert_eq!(projected.vertices.len(), 3); + for (actual, source) in projected.vertices.iter().zip(&model.positions) { + let matrix = nodes[0].world_from_node; + let expected = [ + matrix[0] * source[0] + matrix[1] * source[1] + matrix[2] * source[2] + matrix[3], + matrix[4] * source[0] + matrix[5] * source[1] + matrix[6] * source[2] + matrix[7], + matrix[8] * source[0] + matrix[9] * source[1] + matrix[10] * source[2] + matrix[11], + ]; + for axis in 0..3 { + assert!((actual.position[axis] - expected[axis]).abs() < 1.0e-4); + } + } + } + + #[test] + fn high_speed_free_flight_stops_at_static_building_wall() { + let mut app = collision_test_app( + Arc::new(collision_fixture_model(false)), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [0.0, 8.0, 0.0], + ); + app.update_free_camera(0.1, [1280.0, 720.0]) + .expect("continuous building query"); + assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]); + } + + #[test] + fn small_nonzero_building_scale_remains_queryable() { + let model = Arc::new(collision_fixture_model(false)); + let binding = building_collision_binding( + 0, + 0, + "tiny-scale fixture".to_string(), + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [1.0e-8, 1.0, 1.0], + false, + ) + .expect("small nonzero scale is invertible"); + let hierarchy = static_preview_component_pose_buffer(&model, &[], None) + .expect("fixture static preview pose") + .expect("fixture Node38 pose"); + let nodes = building_collision_nodes(&binding, Some(&hierarchy), None) + .expect("small-scale collision matrix"); + assert!(query_building_segment_world( + &binding.model, + &nodes, + [[0.0, 8.0, 0.0], [0.0, 2.0, 0.0]], + ) + .expect("small-scale query") + .is_some()); + } + + #[test] + fn free_flight_passes_through_authored_doorway() { + let mut app = collision_test_app( + Arc::new(doorway_collision_model()), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [0.0, 8.0, 0.0], + ); + app.update_free_camera(0.1, [1280.0, 720.0]) + .expect("doorway query"); + let position = app.free_camera.unwrap().position; + assert!((position[1] - 2.0).abs() < 1.0e-4); + } + + #[test] + fn shared_building_models_keep_each_placed_instance_query_separate() { + let model = Arc::new(collision_fixture_model(false)); + let far = building_collision_binding( + 0, + 0, + "shared fixture".to_string(), + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 100.0, 0.0], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + false, + ) + .expect("first placed building"); + let near = building_collision_binding( + 1, + 0, + "shared fixture".to_string(), + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + false, + ) + .expect("second placed building"); + assert!(Arc::ptr_eq(&far.model, &near.model)); + + let mut app = collision_test_app( + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 100.0, 0.0], + orientation_radians: [0.0; 3], + }, + [0.0, 8.0, 0.0], + ); + let hierarchy = static_preview_component_pose_buffer(&model, &[], None) + .expect("fixture static pose") + .expect("fixture Node38 pose"); + app.component_pose_buffers = vec![vec![Some(hierarchy.clone())], vec![Some(hierarchy)]]; + app.component_mount_poses = vec![vec![None], vec![None]]; + app.building_collision_bindings = vec![far, near]; + app.update_free_camera(0.1, [1280.0, 720.0]) + .expect("both placed buildings are queried independently"); + assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]); + } + + #[test] + fn collision_uses_updated_control_pose_and_pending_retry_does_not_move_again() { + let model = Arc::new(collision_fixture_model(false)); + let mut app = collision_test_app( + Arc::clone(&model), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [0.0, 8.0, 0.0], + ); + let mut control = control_pose_test_control(); + control.model_node_count = 2; + control.timeline_rows[0].node_index = 1; + if let PreparedControlGateProvider::Resolved { + initial_type1_value, + .. + } = &mut control.typed_records[0].gate_provider + { + *initial_type1_value = 0.0; + } else { + panic!("fixture has a resolved NDP gate"); + } + app.control_animation_instances = vec![ControlAnimationInstance { + object_index: 0, + component_index: 0, + model: Arc::clone(&model), + control, + state: ControlInstanceState::default(), + }]; + + let first = FrameClockSample { + id: SceneSampleId::Replay(0), + time_ms: 0, + delta_seconds: 0.1, + }; + let update = app + .prepare_scene_update(first, [1280.0, 720.0]) + .expect("closed moving wall blocks the first frame"); + assert!(app.lifecycle.prepare(update)); + let safe_position = app.free_camera.unwrap().position; + let closed_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation; + assert_eq!(safe_position, [0.0, 8.0, 0.0]); + + if let PreparedControlGateProvider::Resolved { + initial_type1_value, + .. + } = &mut app.control_animation_instances[0].control.typed_records[0].gate_provider + { + *initial_type1_value = 1.0; + } + app.refresh_pending_viewport([1024.0, 768.0]) + .expect("pending retry only refreshes its viewport"); + assert_eq!(app.free_camera.unwrap().position, safe_position); + assert_eq!( + app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation, + closed_pose + ); + assert_eq!(app.lifecycle.record_presented(), Some(0)); + + app.prepare_scene_update( + FrameClockSample { + id: SceneSampleId::Replay(1), + time_ms: 1000, + delta_seconds: 0.1, + }, + [1024.0, 768.0], + ) + .expect("the next sampled pose opens the path"); + let open_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation; + assert!(open_pose[0] > 2.0, "door node did not move: {open_pose:?}"); + assert!((app.free_camera.unwrap().position[1] - 2.0).abs() < 1.0e-4); + } + + #[test] + fn missing_free_camera_keeps_legacy_view_without_querying_buildings() { + let mut app = collision_test_app( + Arc::new(collision_fixture_model(false)), + LegacyIron3dEulerTransform { + translation: [0.0, 5.0, 0.0], + orientation_radians: [0.0; 3], + }, + [0.0, 8.0, 0.0], + ); + app.free_camera = None; + app.building_collision_bindings + .push(BuildingCollisionBinding { + object_index: 0, + component_index: 0, + model_source: "legacy fixture".to_string(), + model: Arc::new(collision_fixture_model(false)), + root_transform: LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + owner_scale: [1.0; 3], + mounted: false, + selected_nodes: vec![(NodeId(u32::MAX), SlotId(u32::MAX))], + }); + let camera = app.camera; + let view_camera = app.view_camera; + app.update_free_camera(0.1, [1024.0, 768.0]) + .expect("legacy camera skips free-flight collision"); + assert_eq!(app.camera, camera); + assert_eq!(app.view_camera, view_camera); + } + #[test] fn control_timeline_and_ndp_gate_change_component_pose_at_sampled_time() { let model = control_pose_test_model(); @@ -7670,6 +8526,7 @@ mod tests { let preview = StaticPreviewScene { mesh: initial_mesh.clone(), native_handedness_by_range: HashMap::new(), + type16_present_ranges: HashSet::new(), materials: Vec::new(), material_animations: Vec::new(), sun_occlusion_range_indices: Vec::new(), @@ -7694,6 +8551,7 @@ mod tests { state, }], dynamic_component_geometry: vec![dynamic], + building_collision_bindings: Vec::new(), dynamic_component_mounts: Vec::new(), shadow_caster_indices_by_object: vec![Some(0)], camera: view_camera.vulkan_camera(), @@ -7990,6 +8848,289 @@ mod tests { assert_eq!(next_update.camera.forward, controller_after_input.forward()); } + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_plant_wall_and_door_use_the_app_camera_bridge() { + use std::path::PathBuf; + + fn set_segment(app: &mut StaticVulkanApp, segment: [[f32; 3]; 2]) { + let delta = sub3(segment[1], segment[0]); + let horizontal_distance = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt(); + app.free_camera = Some(FreeFlightCamera { + position: segment[0], + yaw: delta[1].atan2(delta[0]), + pitch: delta[2].atan2(horizontal_distance), + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: length3(delta).expect("nonzero native test segment") / 0.1, + ground_clearance: 0.0, + }); + app.pressed_keys.clear(); + app.pressed_keys.insert(KeyCode::KeyW); + } + + const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]]; + const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]]; + let root = PathBuf::from( + std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed tests"), + ); + let mission = "MISSIONS/Autodemo.00/data.tma"; + let args = Args::parse(&[ + "--root".to_string(), + root.to_string_lossy().into_owned(), + "--mission".to_string(), + mission.to_string(), + ]) + .expect("licensed AutoDemo app arguments"); + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)))) + .expect("create licensed AutoDemo engine"); + let loaded = load_requested_mission(&mut engine, &args).expect("load AutoDemo mission"); + let assets = loaded_mission_assets(&engine).expect("AutoDemo assets"); + let terrain = loaded_terrain(&engine).expect("AutoDemo terrain"); + let roots = loaded_mission_object_drafts(&engine).expect("AutoDemo object drafts"); + let preview = static_preview_mesh_and_materials( + assets, + terrain, + roots, + None, + None, + None, + &args.root, + &loaded.land_msh_path, + ) + .expect("build AutoDemo static preview"); + let mut component_bindings = preview + .building_collision_bindings + .iter() + .map(|binding| (binding.object_index, binding.component_index)) + .collect::<Vec<_>>(); + component_bindings.sort_unstable(); + assert_eq!( + component_bindings, + vec![(4, 0), (4, 5), (5, 0), (5, 5), (6, 0), (7, 0)] + ); + + let shadow_range = EnvironmentGpuRange { + range_index: preview.mesh.draw_ranges.len(), + vertex_start: 0, + vertex_capacity: 0, + material_index: preview.shadow_material_index, + }; + let mut app = StaticVulkanApp::new( + preview, + None, + None, + shadow_range, + 0, + 0, + None, + 2, + None, + mission, + loaded.object_count, + None, + false, + StartupTimings::default(), + ); + let plant = app + .building_collision_bindings + .iter() + .find(|binding| binding.object_index == 4 && binding.component_index == 0) + .cloned() + .expect("static PLANT root collision binding"); + let hierarchy = app.component_pose_buffers[4][0] + .as_ref() + .expect("PLANT current root pose"); + let plant_nodes = + building_collision_nodes(&plant, Some(hierarchy), app.component_mount_poses[4][0]) + .expect("PLANT current collision nodes"); + let plant_node = plant_nodes + .iter() + .copied() + .find(|node| node.node == NodeId(1) && node.slot == SlotId(0)) + .expect("native fixture node and slot"); + let body_to_world = |point| { + plant + .root_transform + .try_transform_scaled_point(point, plant.owner_scale) + .expect("finite placed-body transform") + }; + let world_wall = BODY_WALL.map(body_to_world); + let world_door = BODY_DOOR.map(body_to_world); + let direct_wall_hit = query_building_segment_world(&plant.model, &[plant_node], world_wall) + .expect("direct PLANT body-space query") + .expect("verified PLANT wall hit"); + assert_eq!(direct_wall_hit.node, NodeId(1)); + assert_eq!(direct_wall_hit.slot, SlotId(0)); + assert_eq!(direct_wall_hit.batch_index, 5); + assert_eq!(direct_wall_hit.triangle_descriptor_index, 62); + let closed_door_hit = query_building_segment_world(&plant.model, &plant_nodes, world_door) + .expect("linked PLANT door query") + .expect("the default Node3 door blocks the center path"); + assert_eq!(closed_door_hit.node, NodeId(3)); + assert_eq!(closed_door_hit.batch_index, 100); + assert_eq!(closed_door_hit.triangle_descriptor_index, 855); + + let terrain_bounds = app + .terrain + .surface_bounds() + .expect("AutoDemo terrain surface bounds"); + for point in world_wall.into_iter().chain(world_door) { + for axis in 0..2 { + assert!( + (terrain_bounds[0][axis]..=terrain_bounds[1][axis]).contains(&point[axis]), + "native fixture endpoint {point:?} is outside terrain bounds {terrain_bounds:?}" + ); + } + let floor = app + .terrain + .height_at([point[0], point[1]]) + .expect("fixture terrain query"); + if let Some(floor) = floor { + assert!( + point[2] >= floor, + "native fixture height {} is below terrain {floor} at {point:?}", + point[2] + ); + } else { + let foundation = app + .terrain + .basement_foundation() + .expect("missing terrain height needs a BAS foundation"); + assert!(!foundation.fragments.is_empty()); + let source_floor = terrain + .height_at([point[0], point[1]]) + .expect("source fixture terrain query") + .expect("BAS-cut fixture endpoint has source Land.msh terrain"); + eprintln!( + "native fixture endpoint {point:?} has source Land.msh floor {source_floor} but no BAS-foundation surface" + ); + } + let mut constrained = FreeFlightCamera { + position: point, + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 0.0, + ground_clearance: 0.0, + }; + constrained + .keep_above_terrain(&app.terrain) + .expect("terrain constraint preserves native fixture height"); + assert_eq!(constrained.position, point); + } + + set_segment(&mut app, world_wall); + let zero_delta = FrameClockSample { + id: SceneSampleId::Replay(0), + time_ms: 0, + delta_seconds: 0.0, + }; + let update = app + .prepare_scene_update(zero_delta, [1280.0, 720.0]) + .expect("zero-delta resume initialization"); + assert!(app.lifecycle.prepare(update)); + assert_eq!(app.free_camera.unwrap().position, world_wall[0]); + assert_eq!(app.lifecycle.record_presented(), Some(0)); + + let wall_frame = FrameClockSample { + id: SceneSampleId::Replay(1), + time_ms: 0, + delta_seconds: 0.1, + }; + let update = app + .prepare_scene_update(wall_frame, [1280.0, 720.0]) + .expect("high-speed wall movement through the app update path"); + assert!(app.lifecycle.prepare(update)); + assert_eq!(app.free_camera.unwrap().position, world_wall[0]); + app.pressed_keys.clear(); + app.pressed_keys.insert(KeyCode::KeyD); + app.refresh_pending_viewport([1024.0, 768.0]) + .expect("retry of the pending wall frame"); + assert_eq!(app.free_camera.unwrap().position, world_wall[0]); + assert_eq!(app.lifecycle.record_presented(), Some(1)); + + set_segment(&mut app, world_door); + let closed_frame = FrameClockSample { + id: SceneSampleId::Replay(2), + time_ms: 0, + delta_seconds: 0.1, + }; + let update = app + .prepare_scene_update(closed_frame, [1024.0, 768.0]) + .expect("the closed Node3 door blocks through the app update path"); + assert!(app.lifecycle.prepare(update)); + assert_eq!(app.free_camera.unwrap().position, world_door[0]); + assert_eq!(app.lifecycle.record_presented(), Some(2)); + + let control = assets + .object_component_controls + .get(4) + .and_then(|components| components.first()) + .and_then(Option::as_ref) + .expect("PLANT root retains its CTLD control"); + let node3_binding = control + .bindings + .iter() + .find(|binding| binding.node_index == 3) + .expect("PLANT CTLD binding for the authored Node3 door"); + assert_eq!(node3_binding.frame_a, 0.0); + assert_eq!(node3_binding.frame_b, 1.0); + assert_eq!(node3_binding.initial_blend, 0.0); + assert_eq!( + control + .typed_records + .iter() + .filter(|record| record.tag == 21) + .count(), + 0, + "the PLANT control has no tag-21 record to advance Node3 from its initial frame" + ); + let mut open_node_times = + static_preview_node_times(plant.model.node_count, &control.bindings) + .expect("actual initial PLANT CTLD node times"); + assert_eq!(open_node_times[3], Some(AnimationTime(0.0))); + // This samples the door's authored frame B directly; it does not simulate a mission gate. + open_node_times[3] = Some(AnimationTime(1.0)); + app.component_pose_buffers[4][0] = Some( + node38_sampled_hierarchy_at_times(&plant.model, &open_node_times) + .expect("sample the actual PLANT model at its authored Node3 frame B"), + ); + refresh_dynamic_component_mounts( + &app.dynamic_component_mounts, + &app.component_pose_buffers, + &mut app.component_mount_poses, + ) + .expect("refresh mounts from the authored open-door hierarchy"); + let open_hierarchy = app.component_pose_buffers[4][0] + .as_ref() + .expect("sampled PLANT root pose"); + let open_nodes = building_collision_nodes( + &plant, + Some(open_hierarchy), + app.component_mount_poses[4][0], + ) + .expect("sampled PLANT collision nodes"); + assert!( + query_building_segment_world(&plant.model, &open_nodes, world_door) + .expect("open PLANT door query") + .is_none(), + "the authored Node3 time-1 pose leaves the doorway ray clear" + ); + + set_segment(&mut app, world_door); + app.update_free_camera(0.1, [1024.0, 768.0]) + .expect("open doorway movement through the app camera update"); + let position = app.free_camera.unwrap().position; + for axis in 0..3 { + assert!((position[axis] - world_door[1][axis]).abs() < 1.0e-4); + } + } + fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) { const NODE_STRIDE: usize = 38; const SLOT_STRIDE: usize = 68; @@ -8241,6 +9382,17 @@ mod tests { } #[test] + fn native_cpu_shaded_msh_alpha_override_uses_only_the_constant_batch_rule() { + assert_eq!(native_msh_diffuse_alpha_override(0x0148, false), Some(0.0)); + assert_eq!(native_msh_diffuse_alpha_override(0x0108, false), None); + assert_eq!(native_msh_diffuse_alpha_override(0x0118, false), None); + assert_eq!(native_msh_diffuse_alpha_override(0x0058, false), None); + assert_eq!(native_msh_diffuse_alpha_override(0x0148, true), None); + assert_eq!(native_msh_diffuse_alpha_override(0x0058, true), None); + assert_eq!(native_msh_diffuse_alpha_override(0x0040, false), None); + } + + #[test] fn material_animation_selects_pipeline_category_with_active_phase() { let variant = MaterialVariantSet { phase_material_indices: vec![10, 11], @@ -10008,4 +11160,29 @@ mod tests { original_count ); } + #[test] + fn world_draw_range_count_clears_and_reapplies_constant_alpha_override() { + let triangle = native_front_triangle(); + let (mesh, mut range) = facing_mesh(0x0148, &triangle); + let original_count = range.index_count; + let mut alpha_override = None; + let update = |range: &VulkanStaticDrawRange, alpha_override: &mut Option<f32>| { + *alpha_override = native_msh_diffuse_alpha_override(range.batch_flags, false); + world_draw_range_index_count( + &mesh, + range, + original_count, + alpha_override.unwrap_or(1.0), + [0.0; 3], + None, + ) + }; + + assert_eq!(update(&range, &mut alpha_override), 0); + range.batch_flags = 0x0108; + assert_eq!(update(&range, &mut alpha_override), original_count); + range.batch_flags = 0x0148; + assert_eq!(update(&range, &mut alpha_override), 0); + assert_eq!(original_count, 3); + } } |
