diff options
Diffstat (limited to 'apps/fparkan-game')
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 735 |
1 files changed, 690 insertions, 45 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 1260af9..3b3969e 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -32,8 +32,8 @@ use fparkan_path::ResourceName; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; use fparkan_render::{ - LegacyBlendMode, LegacyD3d7Projection, LegacyDepthMode, LegacyIron3dEulerTransform, - LegacyPipelineState, RawCameraTransform, + LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode, + LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform, }; use fparkan_render_vulkan::{ node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, @@ -185,7 +185,7 @@ struct FreeFlightCamera { position: [f32; 3], yaw: f32, pitch: f32, - vertical_fov: f32, + horizontal_fov: f32, near_plane: f32, far_plane: f32, move_speed: f32, @@ -240,9 +240,9 @@ impl FreeFlightCamera { position, yaw, pitch, - vertical_fov: std::f32::consts::FRAC_PI_3, - near_plane: (radius * 0.01).clamp(0.05, 0.1), - far_plane: (radius * 32.0).max(100.0), + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, // Keep the default traversal speed comfortable on a full mission // map; Shift/Ctrl still provide the coarse speed controls. move_speed: (radius * 0.03).clamp(4.0, 40.0), @@ -335,6 +335,15 @@ impl FreeFlightCamera { normalize3(cross3(self.right(), self.forward())).unwrap_or([0.0, 0.0, 1.0]) } + fn vertical_fov(self, aspect: f32) -> f32 { + let aspect = if aspect.is_finite() && aspect > f32::EPSILON { + aspect + } else { + 16.0 / 9.0 + }; + 2.0 * ((self.horizontal_fov * 0.5).tan() / aspect).atan() + } + fn look_delta(&mut self, dx: f64, dy: f64) { const SENSITIVITY: f32 = 0.0025; let dx = dx as f32; @@ -415,7 +424,7 @@ impl FreeFlightCamera { -dot3(forward, self.position), 1.0, ]; - let half_fov = (self.vertical_fov * 0.5).clamp(0.05, 1.5); + let half_fov = self.vertical_fov(aspect) * 0.5; let focal = half_fov.tan().recip(); let near_plane = self.near_plane.max(0.001); let far_plane = self.far_plane.max(near_plane + 0.001); @@ -449,7 +458,7 @@ impl FreeFlightCamera { forward: self.forward(), right: self.right(), up: self.up(), - vertical_fov: self.vertical_fov, + vertical_fov: self.vertical_fov(aspect), near_plane: self.near_plane, far_plane: self.far_plane, clip_from_world: camera.clip_from_world, @@ -915,6 +924,113 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) - diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 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; + }; + let Some(x_axis) = transform + .try_transform_scaled_point([1.0, 0.0, 0.0], scale) + .map(|point| sub3(point, origin)) + else { + return 0; + }; + let Some(y_axis) = transform + .try_transform_scaled_point([0.0, 1.0, 0.0], scale) + .map(|point| sub3(point, origin)) + else { + return 0; + }; + let Some(z_axis) = transform + .try_transform_scaled_point([0.0, 0.0, 1.0], scale) + .map(|point| sub3(point, origin)) + else { + return 0; + }; + // Node and mount poses in the preview are unit-quaternion rotations, so + // they have determinant +1 and preserve this exact object-transform sign. + let determinant_sign = dot3(x_axis, cross3(y_axis, z_axis)); + if !determinant_sign.is_finite() || determinant_sign == 0.0 { + 0 + } else if determinant_sign < 0.0 { + -1 + } else { + 1 + } +} + +fn native_msh_cull_mode(batch_flags: u16, handedness: i8) -> LegacyCullMode { + if batch_flags & 0x2 != 0 || batch_flags & 0x4000 != 0 || handedness == 0 { + LegacyCullMode::Disabled + } else if handedness > 0 { + // The Y-negated projection maps native d > 0 faces to Vulkan's back + // under the renderer's CLOCKWISE front-face convention. + LegacyCullMode::FrontFace + } else { + LegacyCullMode::BackFace + } +} + +fn native_msh_first_triangle_visible( + mesh: &VulkanStaticMesh, + range: &VulkanStaticDrawRange, + eye_world: [f32; 3], + handedness: i8, +) -> bool { + if range.batch_flags & 0x2 != 0 || range.batch_flags & 0x4000 == 0 { + return true; + } + if handedness == 0 { + return false; + } + let Some(indices) = usize::try_from(range.first_index) + .ok() + .and_then(|first| mesh.indices.get(first..first.checked_add(3)?)) + else { + return false; + }; + let Some(v0) = usize::try_from(indices[0]) + .ok() + .and_then(|index| mesh.vertices.get(index)) + else { + return false; + }; + let Some(v1) = usize::try_from(indices[1]) + .ok() + .and_then(|index| mesh.vertices.get(index)) + else { + return false; + }; + let Some(v2) = usize::try_from(indices[2]) + .ok() + .and_then(|index| mesh.vertices.get(index)) + else { + return false; + }; + let [v0, v1, v2] = [v0.position, v1.position, v2.position]; + let face = cross3(sub3(v1, v0), sub3(v2, v0)); + let facing = dot3(sub3(eye_world, v0), face) * f32::from(handedness); + facing.is_finite() && facing > 0.0 +} + +fn world_draw_range_index_count( + mesh: &VulkanStaticMesh, + range: &VulkanStaticDrawRange, + original_index_count: u32, + diffuse_alpha: f32, + eye_world: [f32; 3], + native_msh_handedness: Option<i8>, +) -> u32 { + if !(diffuse_alpha > 0.0) { + return 0; + } + if native_msh_handedness.is_some_and(|handedness| { + !native_msh_first_triangle_visible(mesh, range, eye_world, handedness) + }) { + return 0; + } + original_index_count +} + fn configure_world_material_categories( mesh: &mut VulkanStaticMesh, materials: &[VulkanStaticMaterial], @@ -1252,6 +1368,7 @@ fn append_environment_material( sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: matches!(fallback_row, 0..=2), + screen_tl: ENVIRONMENT_SPRITE_ROWS.contains(&fallback_row), }); phase_material_indices.push(index); } @@ -1312,6 +1429,7 @@ fn append_environment_gradient_material( sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: true, + screen_tl: false, }); Ok(index) } @@ -1362,6 +1480,7 @@ fn append_shadow_material(materials: &mut Vec<VulkanStaticMaterial>) -> Result<u sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: false, + screen_tl: false, }); Ok(index) } @@ -1934,7 +2053,7 @@ fn screen_gradient_quad_vertices( let width = viewport[0].max(1.0); let height = viewport[1].max(1.0); let aspect = width / height; - let fov = camera.vertical_fov.clamp(0.05, 3.0); + let fov = camera.vertical_fov; // The quad is placed close to the camera and drawn with depth disabled. // It therefore covers the complete projected viewport while the later // dome, terrain, and object ranges provide their own depth-tested detail. @@ -1981,6 +2100,7 @@ impl EnvironmentGpuScene { fn new( mesh: &mut VulkanStaticMesh, + native_handedness_by_range: &mut HashMap<usize, i8>, materials: &mut Vec<VulkanStaticMaterial>, preview_material_animations: &mut Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: &mut Vec<usize>, @@ -2400,6 +2520,14 @@ impl EnvironmentGpuScene { old_order.extend(sprite_indices.iter().copied()); old_order.extend([rain_index, snow_index, lightning_index]); let remap = reorder_draw_ranges(mesh, &old_order)?; + let mut remapped_handedness = HashMap::with_capacity(native_handedness_by_range.len()); + for (old_range_index, handedness) in std::mem::take(native_handedness_by_range) { + let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| { + format!("native MSH draw range remap is missing range {old_range_index}") + })?; + remapped_handedness.insert(new_range_index, handedness); + } + *native_handedness_by_range = remapped_handedness; for layer in &mut scene.sky_layers { layer.range.range_index = *remap .get(layer.range.range_index) @@ -2639,17 +2767,33 @@ impl EnvironmentGpuScene { viewport, horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)), ) { - vertices = Some(sprite_quad_vertices_pixels( - camera, - frame.direction, - (camera.far_plane * 0.5).max(camera.near_plane * 2.0), - half_width, - half_height, - viewport, - frame.color, - frame.alpha, - frame.uv, - )); + if ENVIRONMENT_SPRITE_ROWS.contains(&sprite.row) { + if let Some(center_pixels) = projected { + vertices = Some(screen_tl_quad_vertices_pixels( + center_pixels, + half_width, + half_height, + viewport, + frame.color, + frame.alpha, + frame.uv, + )); + } else { + active = false; + } + } else { + vertices = Some(sprite_quad_vertices_pixels( + camera, + frame.direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + half_width, + half_height, + viewport, + frame.color, + frame.alpha, + frame.uv, + )); + } } else { active = false; } @@ -2797,7 +2941,7 @@ fn horizontal_fov(camera: &PreviewCameraFrame, aspect: f32) -> f32 { } else { 16.0 / 9.0 }; - 2.0 * ((camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan() * aspect).atan() + 2.0 * ((camera.vertical_fov * 0.5).tan() * aspect).atan() } /// Tests the camera-to-sun segment against the validated mission terrain. @@ -2993,11 +3137,11 @@ fn project_direction_pixels( return None; } let depth = dot3(camera.forward(), direction); - if !depth.is_finite() || depth <= camera.near_plane.max(0.001) { + if !depth.is_finite() || depth <= 1.0e-4 { return None; } let aspect = width / height; - let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_vertical = (camera.vertical_fov * 0.5).tan(); let half_horizontal = half_vertical * aspect; let ndc_x = dot3(camera.right(), direction) / (depth * half_horizontal); let ndc_y = dot3(camera.up(), direction) / (depth * half_vertical); @@ -3021,7 +3165,7 @@ fn screen_ray_from_pixels( return None; } let aspect = viewport[0] / viewport[1]; - let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_vertical = (camera.vertical_fov * 0.5).tan(); let half_horizontal = half_vertical * aspect; let ndc_x = pixels[0] / viewport[0] * 2.0 - 1.0; let ndc_y = 1.0 - pixels[1] / viewport[1] * 2.0; @@ -3053,7 +3197,7 @@ fn sprite_quad_vertices_pixels( let width = viewport[0].max(1.0); let height = viewport[1].max(1.0); let aspect = width / height; - let vertical_fov = camera.vertical_fov.clamp(0.05, 3.0); + let vertical_fov = camera.vertical_fov; let horizontal_fov_radians = horizontal_fov(camera, aspect); let world_per_pixel_x = 2.0 * depth * (horizontal_fov_radians * 0.5).tan() / width; let world_per_pixel_y = 2.0 * depth * (vertical_fov * 0.5).tan() / height; @@ -3074,6 +3218,50 @@ fn sprite_quad_vertices_pixels( ] } +fn screen_tl_quad_vertices_pixels( + center_pixels: [f32; 2], + half_width_pixels: f32, + half_height_pixels: f32, + viewport: [f32; 2], + color: [f32; 3], + alpha: f32, + uv: [[f32; 2]; 4], +) -> [VulkanStaticVertex; 4] { + let width = viewport[0].max(1.0); + let height = viewport[1].max(1.0); + let half_width = half_width_pixels.max(0.0); + let half_height = half_height_pixels.max(0.0); + let pixel_corners = [ + [ + center_pixels[0] - half_width, + center_pixels[1] + half_height, + ], + [ + center_pixels[0] + half_width, + center_pixels[1] + half_height, + ], + [ + center_pixels[0] - half_width, + center_pixels[1] - half_height, + ], + [ + center_pixels[0] + half_width, + center_pixels[1] - half_height, + ], + ]; + // Positive-height Vulkan viewports map NDC -Y to top-down screen pixels. + let uv = [uv[1], uv[2], uv[0], uv[3]]; + std::array::from_fn(|index| { + let pixel = pixel_corners[index]; + let position = [ + pixel[0] * (2.0 / width) - 1.0, + pixel[1] * (2.0 / height) - 1.0, + 0.999, + ]; + environment_vertex_with_alpha(position, color, alpha, uv[index]) + }) +} + fn precipitation_quad_vertices( kind: PrecipitationKind, screen: &ScreenBillboard, @@ -3113,7 +3301,7 @@ fn world_from_ndc( camera.near_plane.max(0.001) }; let aspect = viewport[0].max(1.0) / viewport[1].max(1.0); - let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_vertical = (camera.vertical_fov * 0.5).tan(); let half_horizontal = half_vertical * aspect; let offset = add3( scale3(camera.right(), ndc[0] * half_horizontal), @@ -3585,6 +3773,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { struct StaticPreviewScene { mesh: VulkanStaticMesh, + native_handedness_by_range: HashMap<usize, i8>, materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: Vec<usize>, @@ -3652,6 +3841,11 @@ const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT"); const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU"); const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT"); +// shortcut: the preview does not load shade.cfg yet, so use CShade's native +// defaults until the settings loader is implemented. +const NATIVE_DEFAULT_ROB_SHADOWS_ON: bool = true; +const NATIVE_DEFAULT_BLD_SHADOWS_ON: bool = false; + /// Maps the resolved registry type to the native object-kind selector used by /// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the /// renderer receives the registry provenance from the prepared visual. @@ -3664,6 +3858,14 @@ fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> { } } +fn native_shadow_caster_enabled(native_kind: u32) -> bool { + if native_kind == 4 { + NATIVE_DEFAULT_ROB_SHADOWS_ON + } else { + NATIVE_DEFAULT_BLD_SHADOWS_ON + } +} + fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose { let rotated = rotate_unit_vector(child.translation, parent.rotation); VulkanNodePose { @@ -3796,6 +3998,7 @@ fn static_preview_mesh_and_materials( indices: Vec::new(), draw_ranges: Vec::new(), }; + let mut native_handedness_by_range = HashMap::new(); let mut materials = Vec::new(); let mut material_animations = Vec::new(); let mut sun_occlusion_range_indices = Vec::new(); @@ -3994,7 +4197,7 @@ fn static_preview_mesh_and_materials( }; if should_render { let pose_buffer = component_pose_buffers[component_index].as_ref(); - let component = match (pose_buffer, mount_pose, static_animation_frame) { + let mut component = match (pose_buffer, mount_pose, static_animation_frame) { (Some(poses), Some(mount), _) => { project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( &model.validated, @@ -4046,6 +4249,11 @@ fn static_preview_mesh_and_materials( } } .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; + let component_handedness = native_msh_handedness(transform, mission_root.scale); + for range in &mut component.draw_ranges { + range.pipeline_state.cull = + native_msh_cull_mode(range.batch_flags, component_handedness); + } let component_shadow_triangles = static_mesh_shadow_triangles(&component); if root_native_kind.is_some() { root_shadow_triangles.extend(component_shadow_triangles); @@ -4084,13 +4292,18 @@ fn static_preview_mesh_and_materials( &lightmap_textures, Some(&mut material_animations), )?; + for range_index in range_start..mesh.draw_ranges.len() { + native_handedness_by_range.insert(range_index, component_handedness); + } if root_sun_eligible { sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); } mesh_components += 1; } } - if let Some(native_kind) = root_native_kind { + 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( @@ -4131,6 +4344,7 @@ fn static_preview_mesh_and_materials( let shadow_material_index = append_shadow_material(&mut materials)?; Ok(StaticPreviewScene { mesh, + native_handedness_by_range, materials, material_animations, sun_occlusion_range_indices, @@ -4296,6 +4510,7 @@ fn static_preview_terrain_base_materials( sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: false, + screen_tl: false, }); phase_material_indices.push(usize::from(preview_selector)); } @@ -4501,6 +4716,7 @@ fn static_preview_component_materials( sky_nebula_stars: false, lightmap_mode: false, sky_far_depth: false, + screen_tl: false, }); phase_material_indices.push(usize::from(preview_selector)); } @@ -4854,6 +5070,7 @@ fn run_static_vulkan_mode( let sky_mesh = environment.sky.mesh().clone(); Some(EnvironmentGpuScene::new( &mut preview.mesh, + &mut preview.native_handedness_by_range, &mut preview.materials, &mut preview.material_animations, &mut preview.sun_occlusion_range_indices, @@ -4891,6 +5108,7 @@ fn run_static_vulkan_mode( struct StaticVulkanApp { mesh: VulkanStaticMesh, + native_handedness_by_range: HashMap<usize, i8>, materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, sun_occlusion_range_indices: Vec<usize>, @@ -4971,6 +5189,7 @@ impl StaticVulkanApp { .collect(); Self { mesh: preview.mesh, + native_handedness_by_range: preview.native_handedness_by_range, materials: preview.materials, material_animations: preview.material_animations, sun_occlusion_range_indices: preview.sun_occlusion_range_indices, @@ -5049,7 +5268,14 @@ impl StaticVulkanApp { renderer .set_draw_range_index_count( range_index, - if alpha > 0.0 { range.index_count } else { 0 }, + world_draw_range_index_count( + &self.mesh, + range, + range.index_count, + alpha, + self.view_camera.position, + self.native_handedness_by_range.get(&range_index).copied(), + ), ) .map_err(|error| { format!("configure world draw range {range_index} count: {error}") @@ -5063,6 +5289,8 @@ impl StaticVulkanApp { materials: &[VulkanStaticMaterial], world_range_indices: &[usize], world_range_index_counts: &HashMap<usize, u32>, + native_handedness_by_range: &HashMap<usize, i8>, + eye_world: [f32; 3], active_materials: &HashMap<usize, ActiveMaterialState>, renderer: &mut VulkanSmokeRenderer, ) -> Result<(), String> { @@ -5083,13 +5311,17 @@ impl StaticVulkanApp { renderer .set_draw_range_transparency(range_index, native_world_transparent(range, alpha)) .map_err(|error| format!("update world draw range {range_index}: {error}"))?; - let count = if alpha > 0.0 { - *world_range_index_counts - .get(&range_index) - .ok_or_else(|| "world draw range count is missing".to_string())? - } else { - 0 - }; + let original_index_count = *world_range_index_counts + .get(&range_index) + .ok_or_else(|| "world draw range count is missing".to_string())?; + let count = world_draw_range_index_count( + mesh, + range, + original_index_count, + alpha, + eye_world, + native_handedness_by_range.get(&range_index).copied(), + ); renderer .set_draw_range_index_count(range_index, count) .map_err(|error| format!("update world draw range {range_index} count: {error}"))?; @@ -5248,6 +5480,8 @@ impl StaticVulkanApp { &self.materials, &self.world_range_indices, &self.world_range_index_counts, + &self.native_handedness_by_range, + self.view_camera.position, &active_materials, renderer, )?; @@ -6220,10 +6454,13 @@ mod tests { } #[test] - fn native_sun_object_source_types_map_to_world_kinds() { + fn native_shadow_kind_mapping_keeps_sun_eligibility_separate_from_caster_defaults() { assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3)); assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4)); assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10)); + assert!(!native_shadow_caster_enabled(3)); + assert!(native_shadow_caster_enabled(4)); + assert!(!native_shadow_caster_enabled(10)); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT))); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU))); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT))); @@ -6429,7 +6666,7 @@ mod tests { position: [0.0, 0.0, 0.0], yaw: 0.0, pitch: 0.0, - vertical_fov: std::f32::consts::FRAC_PI_2, + horizontal_fov: std::f32::consts::FRAC_PI_2, near_plane: 1.0, far_plane: 11.0, move_speed: 1.0, @@ -6454,6 +6691,116 @@ mod tests { } #[test] + fn free_flight_camera_uses_native_horizontal_fov_for_every_viewport_aspect() { + let horizontal_fov = 1.3_f32; + let camera = FreeFlightCamera::from_bounds([0.0; 3], [100.0; 3]) + .expect("finite free-flight bounds"); + assert_eq!(camera.horizontal_fov, horizontal_fov); + assert_eq!(camera.near_plane, 0.5); + assert_eq!(camera.far_plane, 700.0); + + let assert_edges = |frame: PreviewCameraFrame, aspect: f32| { + let expected_vertical_fov = + 2.0 * ((horizontal_fov * 0.5).tan() / aspect).atan(); + assert!((frame.vertical_fov - expected_vertical_fov).abs() < 1.0e-5); + let depth = 10.0; + let center = add3(frame.position, scale3(frame.forward, depth)); + let right_edge = add3( + center, + scale3(frame.right, depth * (horizontal_fov * 0.5).tan()), + ); + let top_edge = add3( + center, + scale3(frame.up, depth * (expected_vertical_fov * 0.5).tan()), + ); + let center_clip = row_vector_transform( + [center[0], center[1], center[2], 1.0], + frame.clip_from_world, + ); + let right_clip = row_vector_transform( + [right_edge[0], right_edge[1], right_edge[2], 1.0], + frame.clip_from_world, + ); + let top_clip = row_vector_transform( + [top_edge[0], top_edge[1], top_edge[2], 1.0], + frame.clip_from_world, + ); + assert!(center_clip[3] > 0.0 && right_clip[3] > 0.0 && top_clip[3] > 0.0); + assert!(center_clip[0].abs() < 5.0e-4, "aspect {aspect}: {center_clip:?}"); + assert!(center_clip[1].abs() < 5.0e-4, "aspect {aspect}: {center_clip:?}"); + assert!( + (right_clip[0] / right_clip[3] - 1.0).abs() < 1.0e-4, + "aspect {aspect}: {right_clip:?}" + ); + assert!( + (top_clip[1] / top_clip[3] + 1.0).abs() < 1.0e-4, + "aspect {aspect}: {top_clip:?}" + ); + }; + + for aspect in [0.05, 0.75, 4.0 / 3.0, 16.0 / 9.0, 20.0] { + assert_edges(camera.preview_frame(aspect), aspect); + + let projection = LegacyD3d7Projection { + viewport: [0, 0, 1000, (1000.0 / aspect).round() as i32], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: horizontal_fov, + }; + let raw_transform = RawCameraTransform { + words: [ + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + ], + }; + let captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection) + .expect("valid D3D7 projection"); + assert_edges(captured, projection.viewport[2] as f32 / projection.viewport[3] as f32); + } + } + + #[test] + fn direction_projection_accepts_front_offscreen_rays_but_rejects_horizon() { + let camera = FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 1.0, + } + .preview_frame(16.0 / 9.0); + let viewport = [1280.0, 720.0]; + let offscreen = project_direction_pixels(&camera, [0.2, 0.979_795_9, 0.0], viewport) + .expect("front-facing directions are not rejected by the near distance"); + assert!(offscreen[0] < 0.0 || offscreen[0] > viewport[0]); + assert!(project_direction_pixels(&camera, [1.0e-5, 1.0, 0.0], viewport).is_none()); + assert!(project_direction_pixels(&camera, [-1.0, 0.0, 0.0], viewport).is_none()); + + let ray = screen_ray_from_pixels(&camera, [viewport[0] * 1.1, viewport[1] * 0.5], viewport) + .expect("an offscreen pixel still has a view ray"); + let ray_pixels = project_direction_pixels(&camera, ray, viewport) + .expect("the offscreen ray remains in the front hemisphere"); + assert!((ray_pixels[0] - viewport[0] * 1.1).abs() < 1.0e-3); + assert!((ray_pixels[1] - viewport[1] * 0.5).abs() < 1.0e-3); + } + + #[test] fn native_sky_heading_selects_matching_gradient_sectors_for_both_camera_origins() { use fparkan_fx::atmosphere::{PackedColor, SkySample, SKY_COLOR_COUNT}; @@ -6487,7 +6834,7 @@ mod tests { position: [0.0; 3], yaw: forward[1].atan2(forward[0]), pitch: 0.0, - vertical_fov: std::f32::consts::FRAC_PI_3, + horizontal_fov: projection.field_of_view_radians, near_plane: projection.near_plane, far_plane: projection.far_plane, move_speed: 1.0, @@ -6544,7 +6891,7 @@ mod tests { position: [10.0, 20.0, 30.0], yaw: 0.0, pitch: 0.0, - vertical_fov: 1.0, + horizontal_fov: 1.0, near_plane: 0.1, far_plane: 100.0, move_speed: 1.0, @@ -6596,7 +6943,7 @@ mod tests { position: [0.25, 0.25, 34.0], yaw: 0.0, pitch: 0.0, - vertical_fov: 1.0, + horizontal_fov: 1.0, near_plane: 0.1, far_plane: 100.0, move_speed: 1.0, @@ -6695,7 +7042,7 @@ mod tests { position: [0.0, 0.0, 0.0], yaw: 0.0, pitch: 0.0, - vertical_fov: 1.0, + horizontal_fov: 1.0, near_plane: 0.1, far_plane: 100.0, move_speed: 2.0, @@ -6722,7 +7069,7 @@ mod tests { position: [0.0, 0.0, 0.0], yaw: 0.0, pitch: 0.6, - vertical_fov: 1.0, + horizontal_fov: 1.0, near_plane: 0.1, far_plane: 100.0, move_speed: 2.0, @@ -7143,7 +7490,7 @@ mod tests { position: [10.0, 20.0, 30.0], yaw: 0.0, pitch: 0.0, - vertical_fov: std::f32::consts::FRAC_PI_2, + horizontal_fov: std::f32::consts::FRAC_PI_2, near_plane: 0.1, far_plane: 100.0, move_speed: 1.0, @@ -7194,7 +7541,7 @@ mod tests { position: [0.0; 3], yaw: 0.0, pitch: 0.0, - vertical_fov: 1.0, + horizontal_fov: 1.0, near_plane: 0.1, far_plane: 100.0, move_speed: 1.0, @@ -7217,4 +7564,302 @@ mod tests { [uv[1], uv[2], uv[0], uv[3]] ); } + + #[test] + fn native_tl_sprite_pixels_use_current_viewport_and_preserve_depth_999() { + let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]; + let small = screen_tl_quad_vertices_pixels( + [160.0, 120.0], + 40.0, + 30.0, + [640.0, 480.0], + [1.0; 3], + 1.0, + uv, + ); + let resized = screen_tl_quad_vertices_pixels( + [320.0, 240.0], + 80.0, + 60.0, + [1280.0, 960.0], + [1.0; 3], + 1.0, + uv, + ); + let wide = screen_tl_quad_vertices_pixels( + [640.0, 360.0], + 100.0, + 45.0, + [1280.0, 720.0], + [1.0; 3], + 1.0, + uv, + ); + + for (small, resized) in small.iter().zip(resized) { + for axis in 0..2 { + assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6); + } + assert_eq!(small.position[2], 0.999); + assert_eq!(resized.position[2], 0.999); + } + assert_eq!(small[0].uv, [0.0, 1.0]); + assert_eq!(small[2].uv, [0.0, 0.0]); + for (actual, expected) in [ + (wide[0].position, [-0.15625, 0.125, 0.999]), + (wide[2].position, [-0.15625, -0.125, 0.999]), + ] { + for axis in 0..3 { + assert!((actual[axis] - expected[axis]).abs() < 1.0e-6); + } + } + } + + #[test] + fn native_tl_depth_999_is_nearer_than_opaque_geometry_at_320_units() { + let camera = FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 1.0, + }; + let frame = camera.preview_frame(16.0 / 9.0); + let opaque_clip = row_vector_transform([320.0, 0.0, 0.0, 1.0], frame.clip_from_world); + let opaque_depth = opaque_clip[2] / opaque_clip[3]; + + assert!((opaque_depth - 0.999151_18).abs() < 1.0e-6); + assert!(0.999 < opaque_depth); + } + + fn facing_mesh( + batch_flags: u16, + positions: &[[f32; 3]], + ) -> (VulkanStaticMesh, VulkanStaticDrawRange) { + let range = VulkanStaticDrawRange { + first_index: 0, + index_count: u32::try_from(positions.len()).expect("fixture index count fits u32"), + material_index: 0, + lightmap_index: 0, + batch_flags, + pipeline_state: LegacyPipelineState::default(), + alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, + }; + let mesh = VulkanStaticMesh { + vertices: positions + .iter() + .copied() + .map(|position| VulkanStaticVertex { + position, + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 0.0, + }) + .collect(), + indices: (0..positions.len()) + .map(|index| u32::try_from(index).expect("fixture index fits u32")) + .collect(), + draw_ranges: vec![range], + }; + (mesh, range) + } + + fn native_front_triangle() -> [[f32; 3]; 3] { + [[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]] + } + + fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] { + let center = add3(camera.position, scale3(camera.forward, 10.0)); + let mirror = if mirror { -1.0 } else { 1.0 }; + [ + add3( + add3(center, scale3(camera.right, -2.0 * mirror)), + scale3(camera.up, -2.0), + ), + add3( + add3(center, scale3(camera.right, 2.0 * mirror)), + scale3(camera.up, -2.0), + ), + add3(center, scale3(camera.up, 2.0)), + ] + } + + fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 { + let screen = triangle.map(|point| { + let clip = + row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world); + assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite())); + // This is the positive-height viewport transform used by the Vulkan renderer. + [ + (clip[0] / clip[3] + 1.0) * 0.5, + (clip[1] / clip[3] + 1.0) * 0.5, + ] + }); + let shoelace = (0..3) + .map(|index| { + let next = (index + 1) % 3; + screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1] + }) + .sum::<f32>() + * 0.5; + // Vulkan's framebuffer signed-area definition is the negative shoelace area. + -shoelace + } + + #[test] + fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() { + let identity = RawCameraTransform { + words: [ + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + ], + }; + let projection = LegacyD3d7Projection { + viewport: [0, 0, 1280, 1024], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, + }; + let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection) + .expect("valid legacy camera projection"); + let free_flight = FreeFlightCamera { + position: legacy.position, + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.3, + near_plane: 0.5, + far_plane: 700.0, + move_speed: 1.0, + } + .preview_frame(1280.0 / 1024.0); + + for camera in [legacy, free_flight] { + let front = camera_face_triangle(camera, false); + let back = [front[0], front[2], front[1]]; + let front_d = dot3( + sub3(camera.position, front[0]), + cross3(sub3(front[1], front[0]), sub3(front[2], front[0])), + ); + let back_d = dot3( + sub3(camera.position, back[0]), + cross3(sub3(back[1], back[0]), sub3(back[2], back[0])), + ); + assert!(front_d > 0.0); + assert!(back_d < 0.0); + + let front_vk_area = projected_vulkan_area(camera, front); + let back_vk_area = projected_vulkan_area(camera, back); + // Vulkan CLOCKWISE front faces have negative framebuffer area. + // The native-visible d>0 face is therefore a Vulkan back face. + assert!(front_vk_area > 0.0, "front area: {front_vk_area}"); + assert!(back_vk_area < 0.0, "back area: {back_vk_area}"); + assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace); + + let mirrored_front = camera_face_triangle(camera, true); + let mirrored_d = dot3( + sub3(camera.position, mirrored_front[0]), + cross3( + sub3(mirrored_front[1], mirrored_front[0]), + sub3(mirrored_front[2], mirrored_front[0]), + ), + ); + assert!(mirrored_d < 0.0); + assert!(projected_vulkan_area(camera, mirrored_front) < 0.0); + assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace); + } + } + + #[test] + fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() { + let rotated = LegacyIron3dEulerTransform { + translation: [8.0, -4.0, 3.0], + orientation_radians: [0.4, -0.7, 1.2], + }; + assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1); + assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1); + assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0); + assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled); + } + + #[test] + fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() { + let front = native_front_triangle(); + let back = front.map(|point| [-point[0], point[1], point[2]]); + let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]]; + let (mesh, range) = facing_mesh(0x4000, &front_then_back); + assert!(native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); + + let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]]; + let (mesh, range) = facing_mesh(0x4000, &back_then_front); + assert!(!native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); + + let (mesh, range) = facing_mesh(0x4002, &back_then_front); + assert!(native_msh_first_triangle_visible( + &mesh, &range, [0.0; 3], 1 + )); + assert_eq!( + native_msh_cull_mode(range.batch_flags, 1), + LegacyCullMode::Disabled + ); + } + + #[test] + fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() { + let triangle = native_front_triangle(); + let (mesh, range) = facing_mesh(0x4000, &triangle); + let original_count = range.index_count; + + assert_eq!( + world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)), + 0 + ); + assert_eq!( + world_draw_range_index_count( + &mesh, + &range, + original_count, + 1.0, + [0.0, 0.0, -2.0], + Some(1), + ), + 0 + ); + assert_eq!( + world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)), + original_count + ); + assert_eq!( + world_draw_range_index_count( + &mesh, + &range, + original_count, + 1.0, + [0.0, 0.0, -2.0], + None, + ), + original_count + ); + } } |
