From 5890416fd74f4868d16814bd87221370e34062db Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 1 Nov 2026 01:09:36 +0400 Subject: perf(render): retain static mesh prefixes between frames --- .../fparkan-render-vulkan/src/ffi/resources.rs | 41 ++++-- adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 163 ++++++++++++++------- .../fparkan-render-vulkan/src/ffi/smoke_types.rs | 6 + 3 files changed, 140 insertions(+), 70 deletions(-) (limited to 'adapters/fparkan-render-vulkan/src/ffi') diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs index 0b43565..fe71d12 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -243,18 +243,25 @@ pub(super) fn create_vertex_buffer( /// Replaces the bytes of a host-visible static vertex allocation. /// -/// The live renderer waits for the device before calling this helper, so a +/// The live renderer waits for submitted readers before calling this helper, so a /// single shared allocation can safely carry camera-relative sky and weather /// geometry between frames. pub(super) fn write_static_mesh_vertex_buffer( device: &VulkanLogicalDeviceProbe, buffer: &VulkanAllocatedBuffer, + first_vertex: usize, vertices: &[super::VulkanStaticVertex], ) -> Result<(), VulkanSmokeRendererError> { let bytes = encode_static_vertices(vertices)?; + let offset = first_vertex + .checked_mul(14 * std::mem::size_of::()) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh vertex upload offset overflows address space", + })?; write_mapped_buffer( device, buffer, + offset, &bytes, "vkMapMemory(static mesh vertex update)", ) @@ -295,29 +302,28 @@ fn encode_static_vertices( fn write_mapped_buffer( device: &VulkanLogicalDeviceProbe, buffer: &VulkanAllocatedBuffer, + offset: usize, bytes: &[u8], context: &'static str, ) -> Result<(), VulkanSmokeRendererError> { - let byte_len = - u64::try_from(bytes.len()).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { - context: "static mesh upload exceeds address space", - })?; - if byte_len == 0 { - return Err(VulkanSmokeRendererError::InvalidStaticMesh { - context: "static mesh upload cannot be empty", - }); + if bytes.is_empty() { + return Ok(()); } // SAFETY: The caller validates that the allocation is large enough and // waits for all queue work that could read it before this write. let mapped = unsafe { - device - .device() - .map_memory(buffer.memory, 0, byte_len, vk::MemoryMapFlags::empty()) + device.device().map_memory( + buffer.memory, + 0, + vk::WHOLE_SIZE, + vk::MemoryMapFlags::empty(), + ) } .map_err(|result| VulkanSmokeRendererError::VulkanOperation { context, result })?; - // SAFETY: `mapped` covers exactly the byte slice copied below. + // SAFETY: The caller bounds the upload against the allocation. Mapping + // from zero also meets minMemoryMapAlignment for arbitrary vertex offsets. unsafe { - std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::(), bytes.len()); + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::().add(offset), bytes.len()); device.device().unmap_memory(buffer.memory); } Ok(()) @@ -351,12 +357,19 @@ pub(super) fn create_index_buffer( pub(super) fn write_static_mesh_index_buffer( device: &VulkanLogicalDeviceProbe, buffer: &VulkanAllocatedBuffer, + first_index: usize, indices: &[u32], ) -> Result<(), VulkanSmokeRendererError> { let bytes = encode_static_indices(indices)?; + let offset = first_index.checked_mul(std::mem::size_of::()).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh index upload offset overflows address space", + }, + )?; write_mapped_buffer( device, buffer, + offset, &bytes, "vkMapMemory(static mesh index update)", ) diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 0e1cebb..69bf23e 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -464,6 +464,9 @@ impl VulkanSmokeRenderer { readback_copy_count: 0, readback_byte_count: 0, readback_fnv1a64: 0, + mesh_vertex_upload_bytes: 0, + mesh_index_upload_bytes: 0, + mesh_full_upload_bytes: 0, }, }; renderer.rebuild_swapchain_resources(false)?; @@ -495,6 +498,9 @@ impl VulkanSmokeRenderer { readback_copy_count: 0, readback_byte_count: 0, readback_fnv1a64: 0, + mesh_vertex_upload_bytes: 0, + mesh_index_upload_bytes: 0, + mesh_full_upload_bytes: 0, }; Ok(renderer) } @@ -628,7 +634,7 @@ impl VulkanSmokeRenderer { /// /// The initial mesh owns enough vertices for the scene's dynamic slots; /// callers update those slots in place as camera-relative environment - /// geometry moves. The device is idled before the host-visible write so + /// geometry moves. Submitted readers finish before the host-visible write so /// no submitted command can observe a partial update. pub fn update_vertices( &mut self, @@ -639,15 +645,8 @@ impl VulkanSmokeRenderer { context: "updated static mesh vertex count differs from allocation", }); } - // SAFETY: The renderer owns the queue and all submitted work; waiting - // for idle makes the shared vertex allocation quiescent. - unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| { - VulkanSmokeRendererError::VulkanOperation { - context: "vkDeviceWaitIdle(static mesh vertex update)", - result, - } - })?; - write_static_mesh_vertex_buffer(self.device_ref()?, self.vertex_buffer_ref()?, vertices) + self.wait_for_submitted_frames()?; + write_static_mesh_vertex_buffer(self.device_ref()?, self.vertex_buffer_ref()?, 0, vertices) } /// Uploads the current shared mesh while retaining dynamic draw state. @@ -658,6 +657,9 @@ impl VulkanSmokeRenderer { /// transparency classification, and active counts owned by other setters /// remain untouched. The allocations grow to the largest submitted frame /// and never grow for a frame that fits the retained capacity. + /// `unchanged_vertices` and `unchanged_indices` retain prefixes already + /// uploaded by the caller; only the remaining suffixes are written. A + /// growing allocation receives the full slice, including its prefix. /// /// A range with `index_count == 0` is valid and disables that range for the /// frame. The mesh itself still needs at least one vertex and one index so @@ -667,6 +669,8 @@ impl VulkanSmokeRenderer { vertices: &[super::VulkanStaticVertex], indices: &[u32], geometry_ranges: &[super::VulkanDynamicDrawRange], + unchanged_vertices: usize, + unchanged_indices: usize, ) -> Result<(), VulkanSmokeRendererError> { validate_geometry_upload( vertices, @@ -676,19 +680,17 @@ impl VulkanSmokeRenderer { self.vertex_count, self.index_capacity, )?; + let vertex_upload = + geometry_upload_range(vertices.len(), unchanged_vertices, self.vertex_count)?; + let index_upload = + geometry_upload_range(indices.len(), unchanged_indices, self.index_capacity)?; let vertex_grew = vertices.len() > self.vertex_count; let index_grew = indices.len() > self.index_capacity; - // SAFETY: The renderer owns the queue and all submitted work. Waiting - // before either writing or replacing a shared allocation keeps old - // command buffers from observing a partial upload. - unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| { - VulkanSmokeRendererError::VulkanOperation { - context: "vkDeviceWaitIdle(dynamic mesh update)", - result, - } - })?; + if !vertex_upload.is_empty() || !index_upload.is_empty() { + self.wait_for_submitted_frames()?; + } let (new_vertex_buffer, new_index_buffer) = if vertex_grew || index_grew { let instance = self.instance_ref()?; @@ -712,9 +714,12 @@ impl VulkanSmokeRenderer { None }; if !vertex_grew { - if let Err(error) = - write_static_mesh_vertex_buffer(device, self.vertex_buffer_ref()?, vertices) - { + if let Err(error) = write_static_mesh_vertex_buffer( + device, + self.vertex_buffer_ref()?, + vertex_upload.start, + &vertices[vertex_upload.clone()], + ) { if let Some(buffer) = new_vertex_buffer.as_ref() { destroy_allocated_buffer(device, buffer); } @@ -725,9 +730,12 @@ impl VulkanSmokeRenderer { } } if !index_grew { - if let Err(error) = - write_static_mesh_index_buffer(device, self.index_buffer_ref()?, indices) - { + if let Err(error) = write_static_mesh_index_buffer( + device, + self.index_buffer_ref()?, + index_upload.start, + &indices[index_upload.clone()], + ) { if let Some(buffer) = new_vertex_buffer.as_ref() { destroy_allocated_buffer(device, buffer); } @@ -740,8 +748,18 @@ impl VulkanSmokeRenderer { (new_vertex_buffer, new_index_buffer) } else { let device = self.device_ref()?; - write_static_mesh_vertex_buffer(device, self.vertex_buffer_ref()?, vertices)?; - write_static_mesh_index_buffer(device, self.index_buffer_ref()?, indices)?; + write_static_mesh_vertex_buffer( + device, + self.vertex_buffer_ref()?, + vertex_upload.start, + &vertices[vertex_upload.clone()], + )?; + write_static_mesh_index_buffer( + device, + self.index_buffer_ref()?, + index_upload.start, + &indices[index_upload.clone()], + )?; (None, None) }; @@ -767,6 +785,19 @@ impl VulkanSmokeRenderer { geometry_ranges, &mut self.draw_order_dirty, ); + self.report.mesh_vertex_upload_bytes = self + .report + .mesh_vertex_upload_bytes + .saturating_add((vertex_upload.len() as u64).saturating_mul(56)); + self.report.mesh_index_upload_bytes = self + .report + .mesh_index_upload_bytes + .saturating_add((index_upload.len() as u64).saturating_mul(4)); + self.report.mesh_full_upload_bytes = self + .report + .mesh_full_upload_bytes + .saturating_add((vertices.len() as u64).saturating_mul(56)) + .saturating_add((indices.len() as u64).saturating_mul(4)); Ok(()) } @@ -776,7 +807,7 @@ impl VulkanSmokeRenderer { /// /// The updated image must have the same dimensions and generated/authored /// mip count as the texture allocated during renderer creation. The - /// renderer idles its device before the transfer, so callers may invoke + /// renderer waits for submitted readers before the transfer, so callers may invoke /// this between frame updates without taking ownership of Vulkan handles. pub fn update_material_texture( &mut self, @@ -807,17 +838,7 @@ impl VulkanSmokeRenderer { context: "dynamic material texture index is out of bounds", }); } - { - let device = self.device_ref()?; - // SAFETY: every submission made by this renderer uses this device; the - // idle wait makes all descriptor image reads quiescent before upload. - unsafe { device.device().device_wait_idle() }.map_err(|result| { - VulkanSmokeRendererError::VulkanOperation { - context: "vkDeviceWaitIdle(dynamic texture update)", - result, - } - })?; - } + self.wait_for_submitted_frames()?; let shared_texture = self .texture_slot_indices .iter() @@ -1388,6 +1409,24 @@ impl VulkanSmokeRenderer { }) } + fn wait_for_submitted_frames(&self) -> Result<(), VulkanSmokeRendererError> { + let fences: Vec<_> = self.frame_sync.iter().map(|sync| sync.fence).collect(); + if fences.is_empty() { + return Ok(()); + } + // SAFETY: Every graphics submission signals one of these owned fences. + // Presentation does not read mesh buffers, textures or descriptor sets. + unsafe { + self.device_ref()? + .device() + .wait_for_fences(&fences, true, u64::MAX) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkWaitForFences(shared resource update)", + result, + }) + } + /// Draws and presents one indexed-triangle frame. /// /// # Errors @@ -1433,14 +1472,7 @@ impl VulkanSmokeRenderer { if needs_point_light_growth { // Updating every material's binding 6 and replacing the shared // SSBO requires all prior command buffers to finish. - // SAFETY: The renderer owns the logical device and no submission - // can observe the replacement after this idle point. - unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| { - VulkanSmokeRendererError::VulkanOperation { - context: "vkDeviceWaitIdle(point-light buffer growth)", - result, - } - })?; + self.wait_for_submitted_frames()?; let instance = self.instance .as_ref() @@ -1464,14 +1496,7 @@ impl VulkanSmokeRenderer { // The storage buffer is shared by all in-flight frames. Wait for // every prior use before replacing the records so a setter cannot // race a vertex shader from the previous frame. - // SAFETY: The renderer owns the logical device and no submission - // can observe the host write after this idle point. - unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| { - VulkanSmokeRendererError::VulkanOperation { - context: "vkDeviceWaitIdle(material specular update)", - result, - } - })?; + self.wait_for_submitted_frames()?; let resources = self.resources_ref()?; write_material_specular_buffer( self.device_ref()?, @@ -2231,6 +2256,23 @@ impl Drop for VulkanSmokeRenderer { } } +fn geometry_upload_range( + count: usize, + unchanged: usize, + capacity: usize, +) -> Result, VulkanSmokeRendererError> { + if unchanged > count || unchanged > capacity { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "unchanged mesh prefix exceeds uploaded or retained geometry", + }); + } + Ok(if count > capacity { + 0..count + } else { + unchanged..count + }) +} + fn validate_geometry_upload( vertices: &[super::VulkanStaticVertex], indices: &[u32], @@ -2572,7 +2614,7 @@ mod tests { use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh}; use super::{ apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index, - draw_material_alphas, mark_draw_order_dirty_on_activity_change, + draw_material_alphas, geometry_upload_range, mark_draw_order_dirty_on_activity_change, set_and_mark_dirty_if_changed, sorted_world_draw_order, take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order, validate_geometry_upload, world_material_pipeline_states, RollbackOnDrop, @@ -2732,6 +2774,15 @@ mod tests { assert_eq!(ranges[1].index_count, 3); } + #[test] + fn geometry_upload_preserves_prefix_until_allocation_grows() { + assert_eq!(geometry_upload_range(12, 8, 16), Ok(8..12)); + assert_eq!(geometry_upload_range(8, 8, 16), Ok(8..8)); + assert_eq!(geometry_upload_range(20, 8, 16), Ok(0..20)); + assert!(geometry_upload_range(8, 9, 16).is_err()); + assert!(geometry_upload_range(20, 17, 16).is_err()); + } + #[test] fn geometry_update_can_disable_empty_shadow_range_without_zero_sized_upload() { let mesh = VulkanStaticMesh::smoke_triangle(); diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 0996fa1..39b415a 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -1333,6 +1333,12 @@ pub struct VulkanSmokeRendererReport { pub readback_byte_count: u64, /// Stable FNV-1a hash of the final completed readback artifact. pub readback_fnv1a64: u64, + /// Vertex bytes written by dynamic geometry uploads, excluding initialization. + pub mesh_vertex_upload_bytes: u64, + /// Index bytes written by dynamic geometry uploads, excluding initialization. + pub mesh_index_upload_bytes: u64, + /// Bytes the same updates would write without retaining unchanged prefixes. + pub mesh_full_upload_bytes: u64, } /// Measured validation counters from the live smoke loop. -- cgit v1.2.3