perf(render): share identical material texture images
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
This commit is contained in:
4 files changed
+127
-76
No files matched your search
@@ -210,83 +210,46 @@ impl VulkanSmokeRenderer {
|
|||||||
rgba8: vec![255, 255, 255, 0],
|
rgba8: vec![255, 255, 255, 0],
|
||||||
mip_levels: Vec::new(),
|
mip_levels: Vec::new(),
|
||||||
};
|
};
|
||||||
// Fallback stages are immutable compatibility images. Keep one
|
// Equal source pixels and authored mip chains can share one image.
|
||||||
// allocation for each fallback kind and map every logical material
|
// Dynamic updates detach their slot before changing a shared image.
|
||||||
// slot to it. Authored images remain one-per-slot because dynamic
|
|
||||||
// phase uploads must never couple two materials.
|
|
||||||
let texture_sources = if create_info.materials.is_empty() {
|
let texture_sources = if create_info.materials.is_empty() {
|
||||||
vec![
|
vec![
|
||||||
(&fallback_texture, Some(0_usize)),
|
&fallback_texture,
|
||||||
(&fallback_detail_texture, Some(1_usize)),
|
&fallback_detail_texture,
|
||||||
(&fallback_overlay_texture, Some(2_usize)),
|
&fallback_overlay_texture,
|
||||||
(&fallback_detail_texture, Some(1_usize)),
|
&fallback_detail_texture,
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
let mut sources = Vec::with_capacity(create_info.materials.len() * 4);
|
let mut sources = Vec::with_capacity(create_info.materials.len() * 4);
|
||||||
for material in &create_info.materials {
|
for material in &create_info.materials {
|
||||||
sources.push((&material.texture, None));
|
sources.push(&material.texture);
|
||||||
sources.push((
|
sources.push(
|
||||||
material
|
material
|
||||||
.detail_texture
|
.detail_texture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(&fallback_detail_texture, |texture| texture),
|
.unwrap_or(&fallback_detail_texture),
|
||||||
material.detail_texture.is_none().then_some(1_usize),
|
);
|
||||||
));
|
sources.push(
|
||||||
sources.push((
|
|
||||||
material
|
material
|
||||||
.overlay_texture
|
.overlay_texture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(&fallback_overlay_texture, |texture| texture),
|
.unwrap_or(&fallback_overlay_texture),
|
||||||
material.overlay_texture.is_none().then_some(2_usize),
|
);
|
||||||
));
|
sources.push(
|
||||||
sources.push((
|
|
||||||
material
|
material
|
||||||
.overlay_detail_texture
|
.overlay_detail_texture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(&fallback_detail_texture, |texture| texture),
|
.unwrap_or(&fallback_detail_texture),
|
||||||
material.overlay_detail_texture.is_none().then_some(1_usize),
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
sources
|
sources
|
||||||
};
|
};
|
||||||
let mut textures = Vec::with_capacity(texture_sources.len());
|
let (unique_sources, texture_slot_indices) = deduplicate_texture_sources(&texture_sources);
|
||||||
let mut texture_slot_indices = Vec::with_capacity(texture_sources.len());
|
let mut textures = Vec::with_capacity(unique_sources.len());
|
||||||
let mut fallback_image_indices = [None; 3];
|
for texture_source in unique_sources {
|
||||||
for (texture_source, fallback_kind) in texture_sources {
|
let image =
|
||||||
let texture_index = if let Some(fallback_kind) = fallback_kind {
|
match create_static_texture_image(&instance, &device, command_pool, texture_source)
|
||||||
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,
|
|
||||||
) {
|
|
||||||
Ok(image) => image,
|
Ok(image) => image,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
for texture in &textures {
|
for texture in &textures {
|
||||||
@@ -299,12 +262,9 @@ impl VulkanSmokeRenderer {
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let texture_index = textures.len();
|
textures.push(image);
|
||||||
textures.push(image);
|
|
||||||
texture_index
|
|
||||||
};
|
|
||||||
texture_slot_indices.push(texture_index);
|
|
||||||
}
|
}
|
||||||
|
let initial_texture_image_count = textures.len() as u64;
|
||||||
let material_alphas = if create_info.materials.is_empty() {
|
let material_alphas = if create_info.materials.is_empty() {
|
||||||
vec![[1.0, 0.0]]
|
vec![[1.0, 0.0]]
|
||||||
} else {
|
} else {
|
||||||
@@ -467,6 +427,8 @@ impl VulkanSmokeRenderer {
|
|||||||
mesh_vertex_upload_bytes: 0,
|
mesh_vertex_upload_bytes: 0,
|
||||||
mesh_index_upload_bytes: 0,
|
mesh_index_upload_bytes: 0,
|
||||||
mesh_full_upload_bytes: 0,
|
mesh_full_upload_bytes: 0,
|
||||||
|
initial_texture_image_count,
|
||||||
|
texture_detach_count: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
renderer.rebuild_swapchain_resources(false)?;
|
renderer.rebuild_swapchain_resources(false)?;
|
||||||
@@ -501,6 +463,8 @@ impl VulkanSmokeRenderer {
|
|||||||
mesh_vertex_upload_bytes: 0,
|
mesh_vertex_upload_bytes: 0,
|
||||||
mesh_index_upload_bytes: 0,
|
mesh_index_upload_bytes: 0,
|
||||||
mesh_full_upload_bytes: 0,
|
mesh_full_upload_bytes: 0,
|
||||||
|
initial_texture_image_count,
|
||||||
|
texture_detach_count: 0,
|
||||||
};
|
};
|
||||||
Ok(renderer)
|
Ok(renderer)
|
||||||
}
|
}
|
||||||
@@ -846,7 +810,7 @@ impl VulkanSmokeRenderer {
|
|||||||
.count()
|
.count()
|
||||||
> 1;
|
> 1;
|
||||||
if shared_texture {
|
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
|
// requested slot before uploading so this public update method
|
||||||
// keeps its one-material/one-slot semantics.
|
// keeps its one-material/one-slot semantics.
|
||||||
let image = {
|
let image = {
|
||||||
@@ -857,6 +821,7 @@ impl VulkanSmokeRenderer {
|
|||||||
let new_texture_index = self.textures.len();
|
let new_texture_index = self.textures.len();
|
||||||
self.textures.push(image);
|
self.textures.push(image);
|
||||||
self.texture_slot_indices[logical_texture_index] = new_texture_index;
|
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 device = self.device_ref()?;
|
||||||
let resources = self.resources_ref()?;
|
let resources = self.resources_ref()?;
|
||||||
update_material_texture_descriptor(
|
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(
|
fn geometry_upload_range(
|
||||||
count: usize,
|
count: usize,
|
||||||
unchanged: usize,
|
unchanged: usize,
|
||||||
@@ -2614,10 +2596,11 @@ mod tests {
|
|||||||
use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh};
|
use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh};
|
||||||
use super::{
|
use super::{
|
||||||
apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index,
|
apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index,
|
||||||
draw_material_alphas, geometry_upload_range, mark_draw_order_dirty_on_activity_change,
|
deduplicate_texture_sources, draw_material_alphas, geometry_upload_range,
|
||||||
set_and_mark_dirty_if_changed, sorted_world_draw_order,
|
mark_draw_order_dirty_on_activity_change, set_and_mark_dirty_if_changed,
|
||||||
take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order,
|
sorted_world_draw_order, take_runtime_children_with_validation_snapshot,
|
||||||
validate_geometry_upload, world_material_pipeline_states, RollbackOnDrop,
|
take_runtime_owners_in_dependency_order, validate_geometry_upload,
|
||||||
|
world_material_pipeline_states, RollbackOnDrop,
|
||||||
};
|
};
|
||||||
use fparkan_render::{
|
use fparkan_render::{
|
||||||
LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState,
|
LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState,
|
||||||
@@ -2774,6 +2757,54 @@ mod tests {
|
|||||||
assert_eq!(ranges[1].index_count, 3);
|
assert_eq!(ranges[1].index_count, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn geometry_upload_preserves_prefix_until_allocation_grows() {
|
fn geometry_upload_preserves_prefix_until_allocation_grows() {
|
||||||
assert_eq!(geometry_upload_range(12, 8, 16), Ok(8..12));
|
assert_eq!(geometry_upload_range(12, 8, 16), Ok(8..12));
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ impl VulkanStaticMaterial {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One authored RGBA8 mip level accepted by the Vulkan texture upload path.
|
/// 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 {
|
pub struct VulkanStaticTextureMip {
|
||||||
/// Mip width in texels.
|
/// Mip width in texels.
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
@@ -570,7 +570,7 @@ pub struct VulkanStaticTextureMip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
|
/// 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 {
|
pub struct VulkanStaticTexture {
|
||||||
/// Image width in texels.
|
/// Image width in texels.
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
@@ -1339,6 +1339,10 @@ pub struct VulkanSmokeRendererReport {
|
|||||||
pub mesh_index_upload_bytes: u64,
|
pub mesh_index_upload_bytes: u64,
|
||||||
/// Bytes the same updates would write without retaining unchanged prefixes.
|
/// Bytes the same updates would write without retaining unchanged prefixes.
|
||||||
pub mesh_full_upload_bytes: u64,
|
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.
|
/// Measured validation counters from the live smoke loop.
|
||||||
@@ -1484,9 +1488,8 @@ pub struct VulkanSmokeRenderer {
|
|||||||
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
|
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
|
||||||
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
|
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
|
||||||
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
|
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
|
||||||
/// Positional material slots mapped to the owned image list. Fallback
|
/// Positional material slots mapped to deduplicated images. Updating a
|
||||||
/// stages may share one image without coupling independently animated
|
/// shared image first detaches that slot, preserving independent animation.
|
||||||
/// material textures.
|
|
||||||
pub(super) texture_slot_indices: Vec<usize>,
|
pub(super) texture_slot_indices: Vec<usize>,
|
||||||
pub(super) textures: Vec<VulkanAllocatedImage>,
|
pub(super) textures: Vec<VulkanAllocatedImage>,
|
||||||
pub(super) vertex_count: usize,
|
pub(super) vertex_count: usize,
|
||||||
|
|||||||
@@ -10617,6 +10617,12 @@ impl StaticVulkanApp {
|
|||||||
report.renderer_report.mesh_index_upload_bytes,
|
report.renderer_report.mesh_index_upload_bytes,
|
||||||
report.renderer_report.mesh_full_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!(
|
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}}}",
|
"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,
|
self.mission,
|
||||||
|
|||||||
+13
-2
@@ -1151,6 +1151,17 @@ WEAR и MAT0 до объединения геометрии. Индексы GPU
|
|||||||
`u16`. Terrain передаёт в shader базовый, detail и overlay stages, lightmap и
|
`u16`. Terrain передаёт в shader базовый, detail и overlay stages, lightmap и
|
||||||
animation phase; object batches сохраняют их legacy blend/depth/alpha state.
|
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-анимация,
|
Рельеф образует неизменяемое начало общего vertex buffer. Control-анимация,
|
||||||
FX и окружение обновляют следующие вершины, а новые индексы нужны только
|
FX и окружение обновляют следующие вершины, а новые индексы нужны только
|
||||||
геометрии проекционных теней в конце index buffer. Поэтому кадр записывает
|
геометрии проекционных теней в конце index buffer. Поэтому кадр записывает
|
||||||
@@ -1160,8 +1171,8 @@ FX и окружение обновляют следующие вершины,
|
|||||||
|
|
||||||
Перед записью общих ресурсов renderer ждёт fences всех отправленных кадров.
|
Перед записью общих ресурсов renderer ждёт fences всех отправленных кадров.
|
||||||
Одного fence текущего кадрового слота недостаточно: другой кадр ещё может
|
Одного fence текущего кадрового слота недостаточно: другой кадр ещё может
|
||||||
читать ту же память. Ожидание всего устройства остаётся на пересоздании
|
читать ту же память. Ожидание всего устройства пока остаётся на пересоздании
|
||||||
swapchain, диагностическом readback и завершении работы. Загрузка изменённых
|
swapchain и набора pipelines, диагностическом readback и завершении работы. Загрузка изменённых
|
||||||
текстур пока использует отдельную синхронную transfer-команду. Строка
|
текстур пока использует отдельную синхронную transfer-команду. Строка
|
||||||
`render_geometry_upload` показывает записанные vertex/index bytes и объём,
|
`render_geometry_upload` показывает записанные vertex/index bytes и объём,
|
||||||
который потребовался бы при полной перезаливке, без начальной загрузки.
|
который потребовался бы при полной перезаливке, без начальной загрузки.
|
||||||
|
|||||||
Reference in new issue
Block a user