diff options
| author | Valentin Popov <valentin@popov.link> | 2026-07-18 13:53:11 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-07-18 13:53:11 +0300 |
| commit | bfb048698ecbec09e745f5733b8f5df45225d268 (patch) | |
| tree | e0f2b041f38ff6be71afec4459bb35e7ed7db83e /adapters/fparkan-render-vulkan/src/ffi | |
| parent | 94de743581db6f2bb68285951c8c2a0088bc9ce6 (diff) | |
| download | fparkan-bfb048698ecbec09e745f5733b8f5df45225d268.tar.xz fparkan-bfb048698ecbec09e745f5733b8f5df45225d268.zip | |
feat(render): bridge legacy camera to Vulkan
Diffstat (limited to 'adapters/fparkan-render-vulkan/src/ffi')
5 files changed, 154 insertions, 24 deletions
diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs index 6f2005e..b7e84bd 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -211,7 +211,7 @@ pub(super) fn create_static_mesh_vertex_buffer( device: &VulkanLogicalDeviceProbe, mesh: &VulkanStaticMesh, ) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> { - let mut bytes = Vec::with_capacity(mesh.vertices.len() * 7 * std::mem::size_of::<f32>()); + let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>()); for vertex in &mesh.vertices { for value in vertex .position diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 11c3377..5db7e3b 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -111,6 +111,11 @@ impl VulkanSmokeRenderer { .mesh .validate() .map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?; + if !create_info.camera.is_finite() { + return Err(VulkanSmokeRendererError::InvalidStaticCamera { + context: "non-finite clip_from_world matrix", + }); + } let draw_texture_indices = resolve_draw_texture_indices(&create_info.mesh.draw_ranges, &create_info.materials) .map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?; @@ -226,6 +231,7 @@ impl VulkanSmokeRenderer { textures, draw_ranges: create_info.mesh.draw_ranges.clone(), draw_texture_indices, + camera: create_info.camera, frame_sync: Vec::new(), images_in_flight: Vec::new(), current_frame: 0, @@ -671,6 +677,14 @@ impl VulkanSmokeRenderer { 0, vk::IndexType::UINT16, ); + let clip_from_world = matrix_bytes(self.camera.clip_from_world); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX, + 0, + &clip_from_world, + ); for (range, &texture_index) in self.draw_ranges.iter().zip(&self.draw_texture_indices) { let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or( VulkanSmokeRendererError::InvariantViolation { @@ -700,7 +714,7 @@ impl VulkanSmokeRenderer { command_buffer, resources.pipeline_layout, vk::ShaderStageFlags::FRAGMENT, - 0, + 64, &alpha_cutoff, ); device.device().cmd_draw_indexed( @@ -928,6 +942,14 @@ impl VulkanSmokeRenderer { } } +fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] { + let mut bytes = [0; 64]; + for (index, value) in matrix.into_iter().enumerate() { + bytes[index * 4..(index + 1) * 4].copy_from_slice(&value.to_ne_bytes()); + } + bytes +} + fn fnv1a64(bytes: &[u8]) -> u64 { bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index b94e460..14301f2 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -1,6 +1,6 @@ use ash::vk; use fparkan_platform::{NativeWindowHandles, RenderRequest}; -use fparkan_render::{LegacyPipelineState, PipelineKey}; +use fparkan_render::{LegacyD3d7Projection, LegacyPipelineState, PipelineKey, RawCameraTransform}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; @@ -26,10 +26,11 @@ pub struct VulkanSmokeRendererCreateInfo { /// Whether validation layers must be enabled. pub enable_validation: bool, /// Static indexed geometry uploaded before the first live frame. - /// - /// This initial bridge keeps positions in clip-space. MSH transforms, - /// materials, and textures are deliberately higher-level Stage 3 work. pub mesh: VulkanStaticMesh, + /// Camera contract for static geometry. + /// + /// The default identity matrix retains the initial XY diagnostic viewer. + pub camera: VulkanStaticCamera, /// Material textures uploaded before the first live frame. /// /// An empty list retains the compatibility white fallback. A singleton list @@ -43,14 +44,68 @@ pub struct VulkanSmokeRendererCreateInfo { /// One vertex accepted by the initial static Vulkan geometry path. #[derive(Clone, Copy, Debug, PartialEq)] pub struct VulkanStaticVertex { - /// Position in Vulkan clip-space XY coordinates. - pub position: [f32; 2], + /// World or clip position transformed by [`VulkanStaticCamera`]. + pub position: [f32; 3], /// Linear RGB vertex color. pub color: [f32; 3], /// Texture coordinate consumed by the static material bridge. pub uv: [f32; 2], } +/// A static geometry camera represented in the shader's matrix memory order. +/// +/// `clip_from_world` contains row-major D3D7 data. GLSL's default column-major +/// `mat4` interpretation deliberately transposes that storage, making +/// `matrix * vec4(position, 1)` equivalent to D3D7's row-vector multiplication. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanStaticCamera { + /// Row-major clip-from-world transform consumed by the vertex shader. + pub clip_from_world: [f32; 16], +} + +impl VulkanStaticCamera { + /// Builds a static camera from the recovered Ngi32 D3D7 camera contract. + #[must_use] + pub fn from_legacy_d3d7( + transform: RawCameraTransform, + projection: LegacyD3d7Projection, + ) -> Option<Self> { + let view = transform.try_direct3d7_view_row_major()?; + let projection = projection.try_direct3d7_projection_row_major()?; + Some(Self { + clip_from_world: multiply_row_major(view, projection), + }) + } + + /// Returns whether every matrix element is finite. + #[must_use] + pub fn is_finite(self) -> bool { + self.clip_from_world.iter().all(|value| value.is_finite()) + } +} + +impl Default for VulkanStaticCamera { + fn default() -> Self { + Self { + clip_from_world: [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ], + } + } +} + +fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { + let mut result = [0.0; 16]; + for row in 0..4 { + for column in 0..4 { + result[row * 4 + column] = (0..4) + .map(|inner| left[row * 4 + inner] * right[inner * 4 + column]) + .sum(); + } + } + result +} + /// Static indexed geometry uploaded to live Vulkan buffers. #[derive(Clone, Debug, PartialEq)] pub struct VulkanStaticMesh { @@ -176,17 +231,17 @@ impl VulkanStaticMesh { Self { vertices: vec![ VulkanStaticVertex { - position: [0.0, -0.55], + position: [0.0, -0.55, 0.0], color: [1.0, 0.2, 0.2], uv: [0.5, 0.0], }, VulkanStaticVertex { - position: [0.55, 0.55], + position: [0.55, 0.55, 0.0], color: [0.2, 1.0, 0.2], uv: [1.0, 1.0], }, VulkanStaticVertex { - position: [-0.55, 0.55], + position: [-0.55, 0.55, 0.0], color: [0.2, 0.4, 1.0], uv: [0.0, 1.0], }, @@ -251,6 +306,44 @@ mod static_mesh_tests { } #[test] + fn static_camera_composes_recovered_d3d7_view_before_projection() { + let transform = RawCameraTransform { + words: [ + 0.0_f32.to_bits(), + (-1.0_f32).to_bits(), + 0.0_f32.to_bits(), + 10.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 20.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 30.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, 1024, 768], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, + }; + + let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection) + .expect("recovered camera inputs are valid"); + + assert!(camera.is_finite()); + assert_eq!(camera.clip_from_world[0], 0.65_f32.cos()); + assert_eq!(camera.clip_from_world[6], 700.0_f32 / 699.5); + assert_eq!(camera.clip_from_world[7], 0.65_f32.sin()); + } + + #[test] fn static_mesh_rejects_bad_triangle_topology_and_indices() { let no_vertices = VulkanStaticMesh { vertices: Vec::new(), @@ -547,6 +640,11 @@ pub enum VulkanSmokeRendererError { /// Validation failure detail. context: &'static str, }, + /// The static camera matrix contains a non-finite element. + InvalidStaticCamera { + /// Validation failure detail. + context: &'static str, + }, /// The submitted static texture cannot be represented by this path. InvalidStaticTexture { /// Validation failure detail. @@ -574,6 +672,9 @@ impl std::fmt::Display for VulkanSmokeRendererError { write!(f, "{context}: no compatible Vulkan memory type") } Self::InvalidStaticMesh { context } => write!(f, "invalid static mesh: {context}"), + Self::InvalidStaticCamera { context } => { + write!(f, "invalid static camera: {context}") + } Self::InvalidStaticTexture { context } => { write!(f, "invalid static texture: {context}") } @@ -600,6 +701,7 @@ pub struct VulkanSmokeRenderer { pub(super) textures: Vec<VulkanAllocatedImage>, pub(super) draw_ranges: Vec<VulkanStaticDrawRange>, pub(super) draw_texture_indices: Vec<usize>, + pub(super) camera: VulkanStaticCamera, pub(super) frame_sync: Vec<VulkanFrameSync>, pub(super) images_in_flight: Vec<vk::Fence>, pub(super) current_frame: usize, diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index 6d87687..7558db6 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -411,10 +411,16 @@ fn create_pipeline_layout( descriptor_set_layout: vk::DescriptorSetLayout, ) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> { let set_layouts = [descriptor_set_layout]; - let push_constant_ranges = [vk::PushConstantRange::default() - .stage_flags(vk::ShaderStageFlags::FRAGMENT) - .offset(0) - .size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX))]; + let push_constant_ranges = [ + vk::PushConstantRange::default() + .stage_flags(vk::ShaderStageFlags::VERTEX) + .offset(0) + .size(64), + vk::PushConstantRange::default() + .stage_flags(vk::ShaderStageFlags::FRAGMENT) + .offset(64) + .size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX)), + ]; let create_info = vk::PipelineLayoutCreateInfo::default() .set_layouts(&set_layouts) .push_constant_ranges(&push_constant_ranges); @@ -620,24 +626,24 @@ fn create_graphics_pipeline( ]; let vertex_binding = vk::VertexInputBindingDescription::default() .binding(0) - .stride(u32::try_from(7 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)) + .stride(u32::try_from(8 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)) .input_rate(vk::VertexInputRate::VERTEX); let vertex_attributes = [ vk::VertexInputAttributeDescription::default() .binding(0) .location(0) - .format(vk::Format::R32G32_SFLOAT) + .format(vk::Format::R32G32B32_SFLOAT) .offset(0), vk::VertexInputAttributeDescription::default() .binding(0) .location(1) .format(vk::Format::R32G32B32_SFLOAT) - .offset(u32::try_from(2 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)), + .offset(u32::try_from(3 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)), vk::VertexInputAttributeDescription::default() .binding(0) .location(2) .format(vk::Format::R32G32_SFLOAT) - .offset(u32::try_from(5 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)), + .offset(u32::try_from(6 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)), ]; let vertex_bindings = [vertex_binding]; let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default() diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs index 0f6f5a8..a842c73 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -669,13 +669,13 @@ fn triangle_shader_manifest_hashes_are_stable() { TRIANGLE_VERTEX_SOURCE_SHA256 ); assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH); - assert_eq!(report.modules[0].word_count, 290); + assert_eq!(report.modules[0].word_count, 358); assert_eq!( report.modules[0].sha256, - "1984662f78873b70135ec444ccd86d86fa66dfca3f615d19ddaf0cda1587d4cf" + "4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d" ); assert_eq!(report.modules[0].descriptor_sets, 0); - assert_eq!(report.modules[0].push_constant_bytes, 0); + assert_eq!(report.modules[0].push_constant_bytes, 64); assert_eq!( report.modules[0].compile_command, TRIANGLE_VERTEX_COMPILE_COMMAND @@ -687,11 +687,11 @@ fn triangle_shader_manifest_hashes_are_stable() { assert!(!report.modules[0].interface_hash.is_empty()); assert_eq!( report.modules[1].sha256, - "536a5c9a4389f9d34ca11a25f20d0acbc53d3eb0782c18375326647319336a85" + "8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b" ); assert_eq!( report.manifest_hash, - "5a16fb791e86bb790cd2d85151627e0d39193c5c073a9294130f149d4ff5ba58" + "cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5" ); } |
