diff options
| author | Valentin Popov <valentin@popov.link> | 2026-11-01 00:18:03 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-11-01 00:18:03 +0300 |
| commit | 3aa9493a02abd27f4f0c803f594047d37ff9058b (patch) | |
| tree | e71a98772a9cb136754038214734d8b757985ba0 | |
| parent | 5890416fd74f4868d16814bd87221370e34062db (diff) | |
| download | fparkan-3aa9493a02abd27f4f0c803f594047d37ff9058b.tar.xz fparkan-3aa9493a02abd27f4f0c803f594047d37ff9058b.zip | |
perf(render): share identical material texture images
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 169 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs | 13 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 6 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 15 |
4 files changed, 127 insertions, 76 deletions
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 69bf23e..2140cab 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -210,83 +210,46 @@ impl VulkanSmokeRenderer { rgba8: vec![255, 255, 255, 0], mip_levels: Vec::new(), }; - // Fallback stages are immutable compatibility images. Keep one - // allocation for each fallback kind and map every logical material - // slot to it. Authored images remain one-per-slot because dynamic - // phase uploads must never couple two materials. + // Equal source pixels and authored mip chains can share one image. + // Dynamic updates detach their slot before changing a shared image. let texture_sources = if create_info.materials.is_empty() { vec![ - (&fallback_texture, Some(0_usize)), - (&fallback_detail_texture, Some(1_usize)), - (&fallback_overlay_texture, Some(2_usize)), - (&fallback_detail_texture, Some(1_usize)), + &fallback_texture, + &fallback_detail_texture, + &fallback_overlay_texture, + &fallback_detail_texture, ] } else { let mut sources = Vec::with_capacity(create_info.materials.len() * 4); for material in &create_info.materials { - sources.push((&material.texture, None)); - sources.push(( + sources.push(&material.texture); + sources.push( material .detail_texture .as_ref() - .map_or(&fallback_detail_texture, |texture| texture), - material.detail_texture.is_none().then_some(1_usize), - )); - sources.push(( + .unwrap_or(&fallback_detail_texture), + ); + sources.push( material .overlay_texture .as_ref() - .map_or(&fallback_overlay_texture, |texture| texture), - material.overlay_texture.is_none().then_some(2_usize), - )); - sources.push(( + .unwrap_or(&fallback_overlay_texture), + ); + sources.push( material .overlay_detail_texture .as_ref() - .map_or(&fallback_detail_texture, |texture| texture), - material.overlay_detail_texture.is_none().then_some(1_usize), - )); + .unwrap_or(&fallback_detail_texture), + ); } sources }; - let mut textures = Vec::with_capacity(texture_sources.len()); - let mut texture_slot_indices = Vec::with_capacity(texture_sources.len()); - let mut fallback_image_indices = [None; 3]; - for (texture_source, fallback_kind) in texture_sources { - let texture_index = if let Some(fallback_kind) = fallback_kind { - if let Some(texture_index) = fallback_image_indices[fallback_kind] { - texture_index - } else { - let image = match create_static_texture_image( - &instance, - &device, - command_pool, - texture_source, - ) { - Ok(image) => image, - Err(error) => { - for texture in &textures { - destroy_allocated_image(&device, texture); - } - // SAFETY: These resources belong to this live device and are rolled back before it drops. - unsafe { device.device().destroy_command_pool(command_pool, None) }; - destroy_allocated_buffer(&device, &index_buffer); - destroy_allocated_buffer(&device, &vertex_buffer); - return Err(error); - } - }; - let texture_index = textures.len(); - textures.push(image); - fallback_image_indices[fallback_kind] = Some(texture_index); - texture_index - } - } else { - let image = match create_static_texture_image( - &instance, - &device, - command_pool, - texture_source, - ) { + let (unique_sources, texture_slot_indices) = deduplicate_texture_sources(&texture_sources); + let mut textures = Vec::with_capacity(unique_sources.len()); + for texture_source in unique_sources { + let image = + match create_static_texture_image(&instance, &device, command_pool, texture_source) + { Ok(image) => image, Err(error) => { for texture in &textures { @@ -299,12 +262,9 @@ impl VulkanSmokeRenderer { return Err(error); } }; - let texture_index = textures.len(); - textures.push(image); - texture_index - }; - texture_slot_indices.push(texture_index); + textures.push(image); } + let initial_texture_image_count = textures.len() as u64; let material_alphas = if create_info.materials.is_empty() { vec![[1.0, 0.0]] } else { @@ -467,6 +427,8 @@ impl VulkanSmokeRenderer { mesh_vertex_upload_bytes: 0, mesh_index_upload_bytes: 0, mesh_full_upload_bytes: 0, + initial_texture_image_count, + texture_detach_count: 0, }, }; renderer.rebuild_swapchain_resources(false)?; @@ -501,6 +463,8 @@ impl VulkanSmokeRenderer { mesh_vertex_upload_bytes: 0, mesh_index_upload_bytes: 0, mesh_full_upload_bytes: 0, + initial_texture_image_count, + texture_detach_count: 0, }; Ok(renderer) } @@ -846,7 +810,7 @@ impl VulkanSmokeRenderer { .count() > 1; if shared_texture { - // A fallback image can serve many logical slots. Detach the + // One image can serve many logical slots. Detach the // requested slot before uploading so this public update method // keeps its one-material/one-slot semantics. let image = { @@ -857,6 +821,7 @@ impl VulkanSmokeRenderer { let new_texture_index = self.textures.len(); self.textures.push(image); self.texture_slot_indices[logical_texture_index] = new_texture_index; + self.report.texture_detach_count = self.report.texture_detach_count.saturating_add(1); let device = self.device_ref()?; let resources = self.resources_ref()?; update_material_texture_descriptor( @@ -2256,6 +2221,23 @@ impl Drop for VulkanSmokeRenderer { } } +fn deduplicate_texture_sources<'a>( + sources: &[&'a super::VulkanStaticTexture], +) -> (Vec<&'a super::VulkanStaticTexture>, Vec<usize>) { + let mut indices = std::collections::HashMap::new(); + let mut unique = Vec::new(); + let mut slots = Vec::with_capacity(sources.len()); + for &source in sources { + let index = *indices.entry(source).or_insert_with(|| { + let index = unique.len(); + unique.push(source); + index + }); + slots.push(index); + } + (unique, slots) +} + fn geometry_upload_range( count: usize, unchanged: usize, @@ -2614,10 +2596,11 @@ mod tests { use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh}; use super::{ apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index, - 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, + deduplicate_texture_sources, 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, }; use fparkan_render::{ LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState, @@ -2775,6 +2758,54 @@ mod tests { } #[test] + fn texture_deduplication_compares_pixels_extents_and_authored_mips() { + use super::super::{VulkanStaticTexture, VulkanStaticTextureMip}; + let base = VulkanStaticTexture { + width: 2, + height: 2, + rgba8: vec![255; 16], + mip_levels: Vec::new(), + }; + let copy = base.clone(); + let authored = VulkanStaticTexture { + mip_levels: vec![ + VulkanStaticTextureMip { + width: 2, + height: 2, + rgba8: base.rgba8.clone(), + }, + VulkanStaticTextureMip { + width: 1, + height: 1, + rgba8: vec![255; 4], + }, + ], + ..base.clone() + }; + let mut different_mip = authored.clone(); + different_mip.mip_levels[1].rgba8[0] = 0; + let different_extent = VulkanStaticTexture { + width: 1, + height: 4, + ..base.clone() + }; + let sources = [ + &base, + ©, + &authored, + &different_mip, + &different_extent, + ©, + ]; + for source in sources { + source.validate().expect("valid texture"); + } + let (unique, slots) = deduplicate_texture_sources(&sources); + assert_eq!(unique.len(), 4); + assert_eq!(slots, [0, 0, 1, 2, 3, 0]); + } + + #[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)); diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 39b415a..5bc5d8c 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -559,7 +559,7 @@ impl VulkanStaticMaterial { } /// One authored RGBA8 mip level accepted by the Vulkan texture upload path. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct VulkanStaticTextureMip { /// Mip width in texels. pub width: u32, @@ -570,7 +570,7 @@ pub struct VulkanStaticTextureMip { } /// Decoded RGBA8 image accepted by the initial Vulkan texture upload path. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct VulkanStaticTexture { /// Image width in texels. pub width: u32, @@ -1339,6 +1339,10 @@ pub struct VulkanSmokeRendererReport { pub mesh_index_upload_bytes: u64, /// Bytes the same updates would write without retaining unchanged prefixes. pub mesh_full_upload_bytes: u64, + /// Distinct GPU images allocated for initial material texture slots. + pub initial_texture_image_count: u64, + /// Shared images detached for independent material-slot updates. + pub texture_detach_count: u64, } /// Measured validation counters from the live smoke loop. @@ -1484,9 +1488,8 @@ pub struct VulkanSmokeRenderer { pub(super) swapchain_resources: Option<VulkanSwapchainResources>, pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>, pub(super) index_buffer: Option<VulkanAllocatedBuffer>, - /// Positional material slots mapped to the owned image list. Fallback - /// stages may share one image without coupling independently animated - /// material textures. + /// Positional material slots mapped to deduplicated images. Updating a + /// shared image first detaches that slot, preserving independent animation. pub(super) texture_slot_indices: Vec<usize>, pub(super) textures: Vec<VulkanAllocatedImage>, pub(super) vertex_count: usize, diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index ddca7b1..42239f6 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -10617,6 +10617,12 @@ impl StaticVulkanApp { report.renderer_report.mesh_index_upload_bytes, report.renderer_report.mesh_full_upload_bytes, ); + eprintln!( + "render_texture_upload slots={} initial_images={} detached_images={}", + self.materials.len() * 4, + report.renderer_report.initial_texture_image_count, + report.renderer_report.texture_detach_count, + ); self.output = Some(format!( "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, control_clock_ms={}, control_clock_mode={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, readback_sequence={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}", self.mission, diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 8f9dd11..c1563fc 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1151,6 +1151,17 @@ WEAR и MAT0 до объединения геометрии. Индексы GPU `u16`. Terrain передаёт в shader базовый, detail и overlay stages, lightmap и animation phase; object batches сохраняют их legacy blend/depth/alpha state. +При первоначальной загрузке одинаковые текстуры разных material slots +используют один GPU image. Ключ включает размеры, RGBA8 и всю авторскую +mip-цепочку: совпадения только базового изображения недостаточно. Хеш ускоряет +поиск, а полное сравнение данных исключает ошибочное объединение при коллизии. +Слоты сохраняют собственные descriptors, UV и параметры материала. При первой +смене общей текстуры renderer создаёт отдельный image только для изменяемого +слота; следующие фазы обновляют его на месте. Поэтому анимация одного материала +не меняет соседний. Строка `render_texture_upload` показывает число слотов, +начальных GPU images и отделений при обновлении. CPU-копии исходных пикселей +в материалах эта оптимизация не объединяет. + Рельеф образует неизменяемое начало общего vertex buffer. Control-анимация, FX и окружение обновляют следующие вершины, а новые индексы нужны только геометрии проекционных теней в конце index buffer. Поэтому кадр записывает @@ -1160,8 +1171,8 @@ FX и окружение обновляют следующие вершины, Ð Перед записью общих ресурсов renderer ждёт fences всех отправленных кадров. Одного fence текущего кадрового слота недостаточно: другой кадр ещё может -читать ту же память. Ожидание всего устройства остаётся на пересоздании -swapchain, диагностическом readback и завершении работы. Загрузка изменённых +читать ту же память. Ожидание всего устройства пока остаётся на пересоздании +swapchain и набора pipelines, диагностическом readback и завершении работы. Загрузка изменённых текстур пока использует отдельную синхронную transfer-команду. Строка `render_geometry_upload` показывает записанные vertex/index bytes и объём, который потребовался бы при полной перезаливке, без начальной загрузки. |
