aboutsummaryrefslogtreecommitdiff
path: root/adapters
diff options
context:
space:
mode:
Diffstat (limited to 'adapters')
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag31
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag.spvbin10632 -> 14512 bytes
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert13
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin20096 -> 20384 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs50
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs4
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/reflection.rs449
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs49
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs1012
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs149
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs176
11 files changed, 1529 insertions, 404 deletions
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag
index 6a6e752..5d10152 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag
@@ -9,12 +9,15 @@ layout(location = 5) in vec3 in_base_diffuse;
layout(location = 6) in vec3 in_base_specular;
layout(location = 7) in vec3 in_overlay_diffuse;
layout(location = 8) in vec3 in_overlay_specular;
+layout(location = 9) in vec3 in_world_position;
layout(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D base_color;
layout(set = 0, binding = 1) uniform sampler2D base_detail;
layout(set = 0, binding = 2) uniform sampler2D overlay_color;
layout(set = 0, binding = 3) uniform sampler2D overlay_detail;
+layout(set = 1, binding = 0) uniform sampler2D reflection_color;
+layout(set = 1, binding = 1) uniform sampler2D embm_bump;
layout(std140, set = 0, binding = 4) uniform FrameConstants {
mat4 clip_from_world;
@@ -28,6 +31,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants {
vec4 fog_color;
vec4 fog_distances;
vec4 camera_position;
+ vec4 embm_bounds;
+ vec4 embm_wave;
} frame;
layout(push_constant) uniform MaterialConstants {
@@ -66,6 +71,31 @@ layout(push_constant) uniform MaterialConstants {
} material;
void main() {
+ int mode = int(round(material.material_mode));
+ if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_position.z < frame.camera_position.w) {
+ discard;
+ }
+ if ((mode & 64) != 0) {
+ vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0));
+ vec2 span = frame.embm_bounds.zw - frame.embm_bounds.xy;
+ vec2 plane_uv = vec2(1.0) - (in_world_position.yx - frame.embm_bounds.yx) / span.yx;
+ vec2 bump_uv = plane_uv * frame.embm_wave.y + frame.embm_wave.x;
+ vec2 duv = max(vec2(-1.0), (texture(embm_bump, bump_uv).rg * 255.0 - 128.0) / 127.0);
+ vec2 displacement = duv * frame.embm_wave.zw;
+ // shortcut: convert native map-UV displacement to the current mirror projection;
+ // replace this projection when accepting the original shifted reflection camera.
+ vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0);
+ vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0);
+ if (displaced_clip.w > 0.000001) {
+ reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5;
+ }
+ vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular;
+ out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha);
+ if (out_color.a < material.alpha_cutoff) {
+ discard;
+ }
+ return;
+ }
vec2 base_uv = vec2(material.base_page_x, material.base_page_y)
+ in_uv * vec2(material.base_page_w, material.base_page_h);
vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y)
@@ -78,7 +108,6 @@ void main() {
vec4 detail = texture(base_detail, detail_uv);
vec4 overlay = texture(overlay_color, overlay_uv);
vec4 overlay_detail_sample = texture(overlay_detail, overlay_detail_uv);
- int mode = int(round(material.material_mode));
int combiner_mode = mode & 3;
// Native sky mode 4 combines nebula and stars as
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
index 7b7c3da..de3ace6 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert
index d1e2e04..8e04536 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert
@@ -19,6 +19,7 @@ layout(location = 5) out vec3 out_base_diffuse;
layout(location = 6) out vec3 out_base_specular;
layout(location = 7) out vec3 out_overlay_diffuse;
layout(location = 8) out vec3 out_overlay_specular;
+layout(location = 9) out vec3 out_world_position;
layout(std140, set = 0, binding = 4) uniform FrameConstants {
mat4 clip_from_world;
@@ -32,6 +33,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants {
vec4 fog_color;
vec4 fog_distances;
vec4 camera_position;
+ vec4 embm_bounds;
+ vec4 embm_wave;
} frame;
layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular {
@@ -130,12 +133,13 @@ float native_specular_power(float cosine, float power) {
}
float native_fog_factor(vec3 world_position) {
- float fog_span = frame.fog_distances.y - frame.fog_distances.x;
- float distance_to_camera = distance(world_position, frame.camera_position.xyz);
- if (fog_span <= 0.000001 || distance_to_camera <= frame.fog_distances.x) {
+ float fog_span_squared = frame.fog_distances.w - frame.fog_distances.z;
+ vec3 to_camera = world_position - frame.camera_position.xyz;
+ float distance_squared = dot(to_camera, to_camera);
+ if (fog_span_squared <= 0.000001 || distance_squared <= frame.fog_distances.z) {
return 0.0;
}
- return clamp((distance_to_camera - frame.fog_distances.x) / fog_span, 0.0, 1.0);
+ return clamp((distance_squared - frame.fog_distances.z) / fog_span_squared, 0.0, 1.0);
}
vec3 normalized_or(vec3 value, vec3 fallback) {
@@ -240,6 +244,7 @@ LightingTerms native_material_lighting(
}
void main() {
+ out_world_position = in_position;
out_color = in_color;
out_uv = in_uv;
out_detail_uv = in_detail_uv;
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
index f74b1f8..439e2a0 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index f691b34..d813b91 100644
--- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs
+++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
@@ -866,6 +866,7 @@ fn append_node_fallback_batch(
batch_flags: batch.batch_flags,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
Ok(())
@@ -1105,7 +1106,21 @@ fn project_land_msh_to_static_mesh_with_lod0(
pub fn project_land_msh_to_static_mesh_in_world_space(
terrain: &LandMeshDocument,
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
- let lod0 = static_terrain_lod0_assignments(terrain)?;
+ project_land_msh_lod_to_static_mesh_in_world_space(terrain, TerrainLod::Lod0)
+}
+
+/// Projects one authored terrain LOD without changing source positions or attributes.
+///
+/// # Errors
+/// Returns an error for invalid slot assignments or an empty selected LOD.
+pub fn project_land_msh_lod_to_static_mesh_in_world_space(
+ terrain: &LandMeshDocument,
+ lod: TerrainLod,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ let mut lod0 = terrain
+ .face_lod_assignments()
+ .map_err(VulkanAssetMeshError::InvalidTerrainLodMapping)?;
+ lod0.retain(|assignment| assignment.lod == lod);
let indices = static_terrain_indices(terrain, &lod0)?;
let vertices = terrain
.positions
@@ -1212,6 +1227,7 @@ fn static_terrain_draw_ranges(
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?;
let material_index = first_face.material_tag;
let terrain_base_only = first_face.flags.0 & 0x400 == 0;
+ let terrain_reflection = first_face.flags.0 & 0x20000 != 0;
let mut end_face = start_face + 1;
while end_face < lod0.len() {
let next_face = terrain
@@ -1220,6 +1236,10 @@ fn static_terrain_draw_ranges(
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?;
if next_face.material_tag != material_index
|| (next_face.flags.0 & 0x400 == 0) != terrain_base_only
+ || (next_face.flags.0 & 0x20000 != 0) != terrain_reflection
+ || (terrain_reflection
+ && terrain.positions[usize::from(first_face.vertices[0])][2]
+ != terrain.positions[usize::from(next_face.vertices[0])][2])
{
break;
}
@@ -1239,6 +1259,7 @@ fn static_terrain_draw_ranges(
batch_flags: 0,
pipeline_state: world_pipeline_state(),
terrain_base_only,
+ terrain_reflection,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
start_face = end_face;
@@ -1285,6 +1306,7 @@ fn static_model_indices_and_ranges(
batch_flags: batch.batch_flags,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
}
@@ -1492,6 +1514,7 @@ mod tests {
batch_flags: 0,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
}]
);
@@ -2022,6 +2045,7 @@ mod tests {
batch_flags: 0,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference:
VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
},
@@ -2033,6 +2057,7 @@ mod tests {
batch_flags: 0,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference:
VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
},
@@ -2118,6 +2143,13 @@ mod tests {
assert_eq!(mesh.draw_ranges[0].index_count, 6);
assert_eq!(mesh.draw_ranges[0].material_index, 0x0101);
+ let far = project_land_msh_lod_to_static_mesh_in_world_space(&terrain, TerrainLod::Lod1)
+ .expect("LOD1 terrain projection");
+ assert_eq!(far.indices, [3, 4, 5, 3, 4, 5]);
+ assert_eq!(far.draw_ranges.len(), 1);
+ assert_eq!(far.draw_ranges[0].material_index, 0x0202);
+ assert_eq!(far.vertices[3].position, [10.0, 10.0, 10.0]);
+
terrain.nodes_raw.clear();
assert!(matches!(
project_land_msh_to_static_mesh_in_world_space(&terrain),
@@ -2277,5 +2309,21 @@ mod tests {
assert!(ranges.iter().all(|range| {
range.alpha_test_reference == VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE
}));
+ terrain.faces = vec![
+ terrain_face_with_tag_and_flags(0xff04, 0x400, [0, 1, 2]),
+ terrain_face_with_tag_and_flags(0xff04, 0x20400, [0, 1, 2]),
+ terrain_face_with_tag_and_flags(0xff04, 0x20400, [0, 1, 2]),
+ terrain_face_with_tag_and_flags(0xff04, 0x20400, [3, 4, 5]),
+ ];
+ terrain.positions.extend([[0.0, 0.0, 10.0]; 3]);
+ set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 4]]);
+ let assignments = static_terrain_lod0_assignments(&terrain).unwrap();
+ let ranges = static_terrain_draw_ranges(&terrain, &assignments).unwrap();
+ assert_eq!(ranges.len(), 3);
+ assert!(!ranges[0].terrain_reflection);
+ assert!(ranges[1].terrain_reflection);
+ assert_eq!(ranges[1].index_count, 6);
+ assert!(ranges[2].terrain_reflection);
+ assert_eq!(ranges[2].index_count, 3);
}
}
diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs
index 9584405..030b8ac 100644
--- a/adapters/fparkan-render-vulkan/src/ffi.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi.rs
@@ -11,6 +11,7 @@
mod asset_mesh;
mod capabilities;
mod instance;
+mod reflection;
mod resources;
mod runtime;
mod smoke;
@@ -22,7 +23,8 @@ mod validation;
pub use self::asset_mesh::{
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
- node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh,
+ node38_pose_relative_to_root_from_hierarchy,
+ project_land_msh_lod_to_static_mesh_in_world_space, project_land_msh_to_static_mesh,
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
new file mode 100644
index 0000000..62a0d1e
--- /dev/null
+++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
@@ -0,0 +1,449 @@
+#![allow(unsafe_code)]
+
+use super::{
+ VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe, VulkanLogicalDeviceProbe,
+ VulkanSmokeRendererError,
+};
+use ash::vk;
+
+pub(super) fn reflected_pipeline_state(
+ mut state: fparkan_render::LegacyPipelineState,
+) -> fparkan_render::LegacyPipelineState {
+ use fparkan_render::LegacyCullMode;
+ state.cull = match state.cull {
+ LegacyCullMode::Disabled => LegacyCullMode::Disabled,
+ LegacyCullMode::BackFace => LegacyCullMode::FrontFace,
+ LegacyCullMode::FrontFace => LegacyCullMode::BackFace,
+ };
+ state
+}
+
+pub(super) fn mesh_reflection_planes(mesh: &super::VulkanStaticMesh) -> Vec<Option<f32>> {
+ mesh.draw_ranges
+ .iter()
+ .map(|range| {
+ if !range.terrain_reflection || range.index_count == 0 {
+ return None;
+ }
+ let indices = &mesh.indices[range.first_index as usize
+ ..range.first_index as usize + range.index_count as usize];
+ let height = mesh.vertices[indices[0] as usize].position[2];
+ indices
+ .iter()
+ .all(|&index| (mesh.vertices[index as usize].position[2] - height).abs() <= 1.0e-4)
+ .then_some(height)
+ })
+ .collect()
+}
+
+pub(super) fn mesh_reflection_bounds(mesh: &super::VulkanStaticMesh) -> Option<[f32; 4]> {
+ let mut bounds = [
+ f32::INFINITY,
+ f32::INFINITY,
+ f32::NEG_INFINITY,
+ f32::NEG_INFINITY,
+ ];
+ for range in mesh
+ .draw_ranges
+ .iter()
+ .filter(|range| range.terrain_reflection)
+ {
+ for &index in &mesh.indices
+ [range.first_index as usize..range.first_index as usize + range.index_count as usize]
+ {
+ let position = mesh.vertices[index as usize].position;
+ bounds[0] = bounds[0].min(position[0]);
+ bounds[1] = bounds[1].min(position[1]);
+ bounds[2] = bounds[2].max(position[0]);
+ bounds[3] = bounds[3].max(position[1]);
+ }
+ }
+ (bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1])
+ .then_some(bounds)
+}
+
+/// Owned before pipeline creation; a dummy set prevents attachment feedback in the mirror pass.
+pub(super) struct ReflectionResources {
+ device: ash::Device,
+ pub layout: vk::DescriptorSetLayout,
+ pool: vk::DescriptorPool,
+ sampler: vk::Sampler,
+ bump_sampler: vk::Sampler,
+ pub sets: [vk::DescriptorSet; 2],
+ dummy: Option<VulkanAllocatedImage>,
+ bump: Option<VulkanAllocatedImage>,
+ pub color: Option<VulkanAllocatedImage>,
+ depth: Option<VulkanDepthAttachment>,
+ pub framebuffer: vk::Framebuffer,
+}
+
+impl ReflectionResources {
+ pub fn new(
+ instance: &VulkanInstanceProbe,
+ device: &VulkanLogicalDeviceProbe,
+ command_pool: vk::CommandPool,
+ enabled: bool,
+ ) -> Result<Self, VulkanSmokeRendererError> {
+ let mut owned = Self {
+ device: device.device().clone(),
+ layout: vk::DescriptorSetLayout::null(),
+ pool: vk::DescriptorPool::null(),
+ sampler: vk::Sampler::null(),
+ bump_sampler: vk::Sampler::null(),
+ sets: [vk::DescriptorSet::null(); 2],
+ dummy: None,
+ bump: None,
+ color: None,
+ depth: None,
+ framebuffer: vk::Framebuffer::null(),
+ };
+ let bindings: Vec<_> = (0..2)
+ .map(|binding| {
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(binding)
+ .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT)
+ })
+ .collect();
+ let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
+ // SAFETY: The device is live; owned rolls back every handle on later failure.
+ owned.layout = unsafe {
+ owned
+ .device
+ .create_descriptor_set_layout(&layout_info, None)
+ }
+ .map_err(|result| VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateDescriptorSetLayout(reflection)",
+ result,
+ })?;
+ let size = vk::DescriptorPoolSize::default()
+ .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
+ .descriptor_count(4);
+ let pool_info = vk::DescriptorPoolCreateInfo::default()
+ .max_sets(2)
+ .pool_sizes(std::slice::from_ref(&size));
+ // SAFETY: The pool owns only the two reflection descriptor sets below.
+ owned.pool =
+ unsafe { owned.device.create_descriptor_pool(&pool_info, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateDescriptorPool(reflection)",
+ result,
+ }
+ })?;
+ let layouts = [owned.layout; 2];
+ let allocation = vk::DescriptorSetAllocateInfo::default()
+ .descriptor_pool(owned.pool)
+ .set_layouts(&layouts);
+ // SAFETY: The pool has capacity for these two live layouts.
+ let sets =
+ unsafe { owned.device.allocate_descriptor_sets(&allocation) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkAllocateDescriptorSets(reflection)",
+ result,
+ }
+ })?;
+ owned.sets.copy_from_slice(&sets);
+ let sampler = vk::SamplerCreateInfo::default()
+ .mag_filter(vk::Filter::LINEAR)
+ .min_filter(vk::Filter::LINEAR)
+ .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
+ .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
+ .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE);
+ // SAFETY: No optional device feature is used by this sampler.
+ owned.sampler =
+ unsafe { owned.device.create_sampler(&sampler, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateSampler(reflection)",
+ result,
+ }
+ })?;
+ // Material images can be replaced by animation, so the dummy has its own lifetime.
+ owned.dummy = Some(super::create_static_texture_image(
+ instance,
+ device,
+ command_pool,
+ &super::VulkanStaticTexture {
+ width: 1,
+ height: 1,
+ rgba8: vec![255; 4],
+ mip_levels: Vec::new(),
+ },
+ )?);
+ if enabled {
+ let sampler = sampler
+ .address_mode_u(vk::SamplerAddressMode::REPEAT)
+ .address_mode_v(vk::SamplerAddressMode::REPEAT)
+ .address_mode_w(vk::SamplerAddressMode::REPEAT);
+ // SAFETY: The immutable bump map uses supported linear filtering and a single mip level.
+ owned.bump_sampler =
+ unsafe { owned.device.create_sampler(&sampler, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateSampler(EMBM)",
+ result,
+ }
+ })?;
+ let rgba8 = fparkan_render::water::legacy_embm_bump_map()
+ .into_iter()
+ .flat_map(|duv| {
+ [
+ (i16::from(duv[0]) + 128) as u8,
+ (i16::from(duv[1]) + 128) as u8,
+ 0,
+ 255,
+ ]
+ })
+ .collect();
+ owned.bump = Some(super::create_static_texture_image(
+ instance,
+ device,
+ command_pool,
+ &super::VulkanStaticTexture {
+ width: fparkan_render::water::EMBM_BUMP_SIZE,
+ height: fparkan_render::water::EMBM_BUMP_SIZE,
+ rgba8,
+ mip_levels: Vec::new(),
+ },
+ )?);
+ }
+ for set in owned.sets {
+ owned.write_image(set, owned.dummy.as_ref().expect("dummy image exists").view);
+ }
+ Ok(owned)
+ }
+
+ pub fn create_target(
+ &mut self,
+ instance: &VulkanInstanceProbe,
+ device: &VulkanLogicalDeviceProbe,
+ extent: (u32, u32),
+ format: i32,
+ depth_request: fparkan_platform::DepthStencilSupport,
+ render_pass: vk::RenderPass,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ let info = vk::ImageCreateInfo::default()
+ .image_type(vk::ImageType::TYPE_2D)
+ .format(vk::Format::from_raw(format))
+ .extent(vk::Extent3D {
+ width: extent.0,
+ height: extent.1,
+ depth: 1,
+ })
+ .mip_levels(1)
+ .array_layers(1)
+ .samples(vk::SampleCountFlags::TYPE_1)
+ .tiling(vk::ImageTiling::OPTIMAL)
+ .usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::SAMPLED)
+ .sharing_mode(vk::SharingMode::EXCLUSIVE)
+ .initial_layout(vk::ImageLayout::UNDEFINED);
+ // SAFETY: This target uses the validated non-zero swapchain extent and format.
+ let image = unsafe { self.device.create_image(&info, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateImage(reflection)",
+ result,
+ }
+ })?;
+ self.color = Some(VulkanAllocatedImage {
+ image,
+ memory: vk::DeviceMemory::null(),
+ view: vk::ImageView::null(),
+ width: extent.0,
+ height: extent.1,
+ mip_levels: 1,
+ });
+ // SAFETY: The image belongs to this device and has not been bound yet.
+ let requirements = unsafe { self.device.get_image_memory_requirements(image) };
+ let memory_type_index = super::resources::find_memory_type(
+ instance,
+ device.physical_device(),
+ requirements.memory_type_bits,
+ vk::MemoryPropertyFlags::DEVICE_LOCAL,
+ )
+ .ok_or(VulkanSmokeRendererError::MissingMemoryType {
+ context: "reflection target",
+ })?;
+ let allocation = vk::MemoryAllocateInfo::default()
+ .allocation_size(requirements.size)
+ .memory_type_index(memory_type_index);
+ // SAFETY: The allocation follows this image's memory requirements.
+ let memory =
+ unsafe { self.device.allocate_memory(&allocation, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkAllocateMemory(reflection)",
+ result,
+ }
+ })?;
+ self.color.as_mut().expect("color image exists").memory = memory;
+ // SAFETY: The allocation is compatible with the image and offset zero.
+ unsafe { self.device.bind_image_memory(image, memory, 0) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkBindImageMemory(reflection)",
+ result,
+ }
+ })?;
+ let view_info = vk::ImageViewCreateInfo::default()
+ .image(image)
+ .view_type(vk::ImageViewType::TYPE_2D)
+ .format(vk::Format::from_raw(format))
+ .subresource_range(super::color_subresource_range(1));
+ // SAFETY: The image is bound and the view covers its single color subresource.
+ let view =
+ unsafe { self.device.create_image_view(&view_info, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateImageView(reflection)",
+ result,
+ }
+ })?;
+ self.color.as_mut().expect("color image exists").view = view;
+ self.depth = Some(super::create_depth_attachment(
+ instance,
+ device,
+ extent,
+ depth_request,
+ )?);
+ let attachments = [view, self.depth.as_ref().expect("depth exists").image.view];
+ let info = vk::FramebufferCreateInfo::default()
+ .render_pass(render_pass)
+ .attachments(&attachments)
+ .width(extent.0)
+ .height(extent.1)
+ .layers(1);
+ // SAFETY: The target formats and extent match the shared render pass.
+ self.framebuffer =
+ unsafe { self.device.create_framebuffer(&info, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateFramebuffer(reflection)",
+ result,
+ }
+ })?;
+ self.write_image(self.sets[1], view);
+ Ok(())
+ }
+
+ fn write_image(&self, set: vk::DescriptorSet, view: vk::ImageView) {
+ let image = vk::DescriptorImageInfo::default()
+ .sampler(self.sampler)
+ .image_view(view)
+ .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
+ let bump = self
+ .bump
+ .as_ref()
+ .or(self.dummy.as_ref())
+ .expect("dummy image exists");
+ let images = [
+ image,
+ vk::DescriptorImageInfo::default()
+ .sampler(if self.bump.is_some() {
+ self.bump_sampler
+ } else {
+ self.sampler
+ })
+ .image_view(bump.view)
+ .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL),
+ ];
+ let writes: Vec<_> = images
+ .iter()
+ .enumerate()
+ .map(|(binding, image)| {
+ vk::WriteDescriptorSet::default()
+ .dst_set(set)
+ .dst_binding(binding as u32)
+ .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
+ .image_info(std::slice::from_ref(image))
+ })
+ .collect();
+ // SAFETY: Sets are initialized before submission; the dummy remains valid for every pass.
+ unsafe { self.device.update_descriptor_sets(&writes, &[]) };
+ }
+}
+
+impl Drop for ReflectionResources {
+ fn drop(&mut self) {
+ // SAFETY: The parent waits for submissions and drops this owner before the render pass/device.
+ // Null handles also cover partial construction, including failures before memory binding.
+ unsafe {
+ self.device.destroy_framebuffer(self.framebuffer, None);
+ self.device.destroy_descriptor_pool(self.pool, None);
+ self.device.destroy_sampler(self.sampler, None);
+ self.device.destroy_sampler(self.bump_sampler, None);
+ self.device.destroy_descriptor_set_layout(self.layout, None);
+ for image in self
+ .color
+ .iter()
+ .chain(self.depth.iter().map(|depth| &depth.image))
+ .chain(self.dummy.iter())
+ .chain(self.bump.iter())
+ {
+ self.device.destroy_image_view(image.view, None);
+ self.device.destroy_image(image.image, None);
+ self.device.free_memory(image.memory, None);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
+
+ #[test]
+ fn reflection_requires_a_flat_flagged_surface_and_reverses_only_culling() {
+ let mut mesh = VulkanStaticMesh {
+ vertices: vec![
+ VulkanStaticVertex {
+ position: [0.0, 0.0, 150.0],
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 0.0,
+ };
+ 3
+ ],
+ indices: vec![0, 1, 2],
+ draw_ranges: vec![VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: 255,
+ batch_flags: 0,
+ terrain_base_only: false,
+ terrain_reflection: true,
+ pipeline_state: fparkan_render::LegacyPipelineState::default(),
+ alpha_test_reference: 1,
+ }],
+ };
+ mesh.vertices[1].position = [10.0, 0.0, 150.0];
+ mesh.vertices[2].position = [5.0, 20.0, 150.0];
+ let mut unused = mesh.vertices[0];
+ unused.position = [-10_000.0, -10_000.0, 150.0];
+ mesh.vertices.push(unused);
+ assert_eq!(mesh_reflection_bounds(&mesh), Some([0.0, 0.0, 10.0, 20.0]));
+ assert_eq!(mesh_reflection_planes(&mesh), vec![Some(150.0)]);
+ mesh.vertices[2].position[2] = 151.0;
+ assert_eq!(mesh_reflection_planes(&mesh), vec![None]);
+ mesh.vertices[2].position[2] = 150.0;
+ mesh.draw_ranges[0].terrain_reflection = false;
+ assert_eq!(mesh_reflection_planes(&mesh), vec![None]);
+ assert_eq!(mesh_reflection_bounds(&mesh), None);
+ for cull in [
+ fparkan_render::LegacyCullMode::Disabled,
+ fparkan_render::LegacyCullMode::BackFace,
+ fparkan_render::LegacyCullMode::FrontFace,
+ ] {
+ let mut state = mesh.draw_ranges[0].pipeline_state;
+ state.cull = cull;
+ state.alpha_test = true;
+ state.depth_bias = fparkan_render::LegacyDepthBias::ProjectedDecal;
+ assert_eq!(
+ reflected_pipeline_state(reflected_pipeline_state(state)),
+ state
+ );
+ let reflected = reflected_pipeline_state(state);
+ assert_eq!(reflected.depth, state.depth);
+ assert_eq!(reflected.blend, state.blend);
+ assert_eq!(reflected.alpha_test, state.alpha_test);
+ }
+ }
+}
diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
index 0b43565..498a472 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::<f32>())
+ .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::<u8>(), bytes.len());
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>().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::<u32>()).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)",
)
@@ -515,7 +528,7 @@ pub(super) fn create_static_texture_image(
})
}
-/// Allocates two aligned camera slots used by the dynamic frame UBO.
+/// Allocates a main and reflected view slot for each in-flight frame.
pub(super) fn create_frame_uniform_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
@@ -532,7 +545,7 @@ pub(super) fn create_frame_uniform_buffer(
)? / alignment
* alignment;
let size = stride
- .checked_mul(super::VULKAN_FRAME_SLOT_COUNT as u64)
+ .checked_mul(2 * super::VULKAN_FRAME_SLOT_COUNT as u64)
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "frame uniform buffer size overflow",
})?;
@@ -875,7 +888,7 @@ pub(super) fn write_frame_uniform(
.ok_or(VulkanSmokeRendererError::InvariantViolation {
context: "frame uniform slot offset",
})?;
- // SAFETY: The offset is one of the two allocated aligned slots and the
+ // SAFETY: The caller supplies an allocated, fence-safe main or reflection slot; the
// mapped range is exactly one complete frame block.
let mapped = unsafe {
device.device().map_memory(
@@ -1372,7 +1385,7 @@ fn create_host_visible_buffer(
Ok(VulkanAllocatedBuffer { buffer, memory })
}
-fn find_memory_type(
+pub(super) fn find_memory_type(
instance: &VulkanInstanceProbe,
physical_device: vk::PhysicalDevice,
type_bits: u32,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index 522f95f..bd745ad 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 {
@@ -397,6 +357,33 @@ impl VulkanSmokeRenderer {
.map(|material| material.material_index)
.collect()
};
+ let reflection_planes = super::reflection::mesh_reflection_planes(&create_info.mesh);
+ let unsupported_nonplanar = create_info
+ .mesh
+ .draw_ranges
+ .iter()
+ .zip(&reflection_planes)
+ .filter(|(range, plane)| range.terrain_reflection && plane.is_none())
+ .count();
+ let mut plane_heights = reflection_planes
+ .iter()
+ .flatten()
+ .map(|z| z.to_bits())
+ .collect::<Vec<_>>();
+ plane_heights.sort_unstable();
+ plane_heights.dedup();
+ if !plane_heights.is_empty() {
+ eprintln!("render_coverage planar_reflection_shifted_camera_unsupported={} (native bump map and clock; using a projective mirror until shifted-camera mapping is implemented)", plane_heights.len());
+ eprintln!("render_coverage planar_reflection_other_planes_unsupported={} (nearest active plane is reflected)", plane_heights.len().saturating_sub(1));
+ }
+ if unsupported_nonplanar != 0 {
+ eprintln!("render_coverage planar_reflection_nonplanar_unsupported={unsupported_nonplanar} (ordinary terrain fallback)");
+ }
+ let mut frame_uniforms = super::VulkanFrameUniforms::from_camera(create_info.camera);
+ if let Some(bounds) = super::reflection::mesh_reflection_bounds(&create_info.mesh) {
+ frame_uniforms.embm_bounds = bounds;
+ eprintln!("render_reflection embm_bounds={bounds:?}");
+ }
let mut renderer = Self {
instance: Some(instance),
validation,
@@ -404,6 +391,7 @@ impl VulkanSmokeRenderer {
device: Some(device),
swapchain: Some(swapchain),
command_pool,
+ pipeline_cache: vk::PipelineCache::null(),
swapchain_resources: None,
vertex_buffer: Some(vertex_buffer),
index_buffer: Some(index_buffer),
@@ -418,6 +406,7 @@ impl VulkanSmokeRenderer {
.map(|range| range.index_count)
.collect(),
draw_ranges: create_info.mesh.draw_ranges.clone(),
+ reflection_planes,
draw_texture_indices,
draw_order: (0..create_info.mesh.draw_ranges.len()).collect(),
draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()],
@@ -440,7 +429,7 @@ impl VulkanSmokeRenderer {
material_specular_dirty: true,
readback_enabled: false,
camera: create_info.camera,
- frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera),
+ frame_uniforms,
point_lights: Vec::new(),
point_lights_explicit: false,
frame_sync: Vec::new(),
@@ -464,8 +453,26 @@ 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,
+ initial_texture_image_count,
+ texture_detach_count: 0,
+ pipeline_creation_count: 0,
},
};
+ // SAFETY: This cache is owned by the renderer and only used from its
+ // exclusive methods; it outlives every pipeline creation and swapchain.
+ renderer.pipeline_cache = unsafe {
+ renderer
+ .device_ref()?
+ .device()
+ .create_pipeline_cache(&vk::PipelineCacheCreateInfo::default(), None)
+ }
+ .map_err(|result| VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreatePipelineCache",
+ result,
+ })?;
renderer.rebuild_swapchain_resources(false)?;
let device_ref = renderer.device_ref()?;
let swapchain_ref = renderer.swapchain_ref()?;
@@ -495,6 +502,12 @@ 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,
+ initial_texture_image_count,
+ texture_detach_count: 0,
+ pipeline_creation_count: renderer.report.pipeline_creation_count,
};
Ok(renderer)
}
@@ -536,6 +549,22 @@ impl VulkanSmokeRenderer {
self.frame_uniforms
}
+ fn active_reflection_plane(&self) -> Option<f32> {
+ // shortcut: one target serves the nearest active water height; add per-plane targets
+ // when accepting maps whose visible water surfaces have different heights.
+ self.draw_ranges
+ .iter()
+ .zip(&self.reflection_planes)
+ .filter(|(range, _)| range.index_count != 0)
+ .filter_map(|(_, plane)| *plane)
+ .filter(|&z| z < self.frame_uniforms.camera_position[2])
+ .min_by(|a, b| {
+ (self.frame_uniforms.camera_position[2] - a)
+ .abs()
+ .total_cmp(&(self.frame_uniforms.camera_position[2] - b).abs())
+ })
+ }
+
/// Replaces the camera for subsequent frames without recreating GPU resources.
///
/// # Errors
@@ -628,7 +657,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 +668,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 +680,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 +692,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 +703,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 +737,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 +753,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 +771,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 +808,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 +830,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 +861,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()
@@ -825,7 +869,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 = {
@@ -836,6 +880,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(
@@ -976,11 +1021,7 @@ impl VulkanSmokeRenderer {
},
)?;
self.draw_range_base_pipeline_states[range_index] = next_base;
- let pipeline_changed = range.pipeline_state != next_state;
range.pipeline_state = next_state;
- if pipeline_changed {
- self.rebuild_pipeline_resources()?;
- }
Ok(())
}
@@ -1049,7 +1090,6 @@ impl VulkanSmokeRenderer {
},
)?;
let next_state = apply_world_transparency(base_state, transparent);
- let pipeline_changed = range.pipeline_state != next_state;
range.pipeline_state = next_state;
// Calling this setter opts the range into the world queue. Fixed
// screen/sky/weather ranges remain `None` because their pass order is
@@ -1059,9 +1099,6 @@ impl VulkanSmokeRenderer {
Some(transparent),
&mut self.draw_order_dirty,
);
- if pipeline_changed {
- self.rebuild_pipeline_resources()?;
- }
Ok(())
}
@@ -1388,6 +1425,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 +1488,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 +1512,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()?,
@@ -1519,6 +1560,27 @@ impl VulkanSmokeRenderer {
}
self.images_in_flight[image_index_usize] = in_flight_fence;
let frame_bytes = self.frame_uniforms.to_ne_bytes();
+ let reflection_bytes = self
+ .active_reflection_plane()
+ .map(|height| {
+ let mut frame = self.frame_uniforms;
+ frame.clip_from_world =
+ fparkan_render::reflect_clip_from_world_about_z(frame.clip_from_world, height)
+ .ok_or(VulkanSmokeRendererError::InvalidStaticCamera {
+ context: "non-finite reflected clip transform",
+ })?;
+ frame.camera_position[2] = 2.0 * height - frame.camera_position[2];
+ if !frame.is_finite() {
+ return Err(VulkanSmokeRendererError::InvalidStaticCamera {
+ context: "non-finite reflected camera position",
+ });
+ }
+ let mut bytes = frame.to_ne_bytes();
+ let offset = super::smoke_types::REFLECTION_CLIP_HEIGHT_OFFSET;
+ bytes[offset..offset + 4].copy_from_slice(&height.to_ne_bytes());
+ Ok(bytes)
+ })
+ .transpose()?;
{
let explicit_point_lights = self.point_lights_explicit;
let legacy_point_light = self.frame_uniforms.point_light;
@@ -1544,9 +1606,18 @@ impl VulkanSmokeRenderer {
device,
&resources.frame_uniform_buffer,
resources.frame_uniform_stride,
- self.current_frame,
+ self.current_frame * 2,
&frame_bytes,
)?;
+ if let Some(bytes) = &reflection_bytes {
+ write_frame_uniform(
+ device,
+ &resources.frame_uniform_buffer,
+ resources.frame_uniform_stride,
+ self.current_frame * 2 + 1,
+ bytes,
+ )?;
+ }
write_point_light_buffer(
device,
&resources.point_light_buffer,
@@ -1683,11 +1754,15 @@ impl VulkanSmokeRenderer {
device,
swapchain,
self.command_pool,
+ self.pipeline_cache,
self.vertex_buffer_ref()?,
self.index_buffer_ref()?,
self.textures_ref()?,
&self.texture_slot_indices,
- &self.draw_ranges,
+ &self.draw_range_base_pipeline_states,
+ self.draw_ranges
+ .iter()
+ .any(|range| range.terrain_reflection),
self.depth_request,
reuse_command_pool,
)?,
@@ -1706,32 +1781,14 @@ impl VulkanSmokeRenderer {
self.report.swapchain_image_count = swapchain_image_count;
self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format;
self.report.swapchain_image_usage = swapchain_image_usage;
+ self.report.pipeline_creation_count = self
+ .report
+ .pipeline_creation_count
+ .saturating_add(resources.pipelines.len() as u64);
self.swapchain_resources = Some(resources);
Ok(())
}
- /// Rebuilds only the swapchain-owned pipeline bundle after a structural
- /// range-state change. Draw-range setters can be called while a renderer
- /// is live, so all prior submissions are completed before handles are
- /// destroyed and recreated.
- fn rebuild_pipeline_resources(&mut self) -> Result<(), VulkanSmokeRendererError> {
- if self.swapchain_resources.is_none() {
- return Ok(());
- }
- let device = self.device_ref()?;
- // SAFETY: The renderer owns every submission on this device. Waiting
- // here makes the old pipelines, framebuffers, and command buffers
- // quiescent before their destruction.
- unsafe { device.device().device_wait_idle() }.map_err(|result| {
- VulkanSmokeRendererError::VulkanOperation {
- context: "vkDeviceWaitIdle(draw range pipeline update)",
- result,
- }
- })?;
- self.destroy_swapchain_resources();
- self.rebuild_swapchain_resources(true)
- }
-
/// Recomputes the stable command-recording order. Without explicit
/// backgrounds, opaque world ranges followed by the sorted transparent
/// world ranges replace the existing world slots. With explicit
@@ -1761,6 +1818,7 @@ impl VulkanSmokeRenderer {
self.rebuild_draw_order();
self.draw_order_dirty = false;
}
+ let reflection_plane = self.active_reflection_plane();
let device = self.device_ref()?;
let swapchain = self.swapchain_ref()?;
let resources = self.resources_ref()?;
@@ -1807,185 +1865,269 @@ impl VulkanSmokeRenderer {
height: swapchain.report.plan.extent.1,
},
};
- let render_pass_info = vk::RenderPassBeginInfo::default()
- .render_pass(resources.render_pass)
- .framebuffer(resources.framebuffers[image_index])
- .render_area(render_area)
- .clear_values(&clear_values);
- // SAFETY: All commands target live frame resources owned by this renderer.
- unsafe {
- device.device().cmd_begin_render_pass(
- command_buffer,
- &render_pass_info,
- vk::SubpassContents::INLINE,
- );
- let vertex_buffers = [self.vertex_buffer_ref()?.buffer];
- let offsets = [0_u64];
- device
- .device()
- .cmd_bind_vertex_buffers(command_buffer, 0, &vertex_buffers, &offsets);
- device.device().cmd_bind_index_buffer(
- command_buffer,
- self.index_buffer_ref()?.buffer,
- 0,
- vk::IndexType::UINT32,
- );
- let mut viewport_depth = None;
- for &range_index in &self.draw_order {
- let range = self.draw_ranges.get(range_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "stable draw order range handle",
- },
- )?;
- let texture_index = *self.draw_texture_indices.get(range_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "stable draw texture range handle",
- },
- )?;
- let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static draw pipeline variant",
- },
- )?;
- device.device().cmd_bind_pipeline(
+ for reflected in [true, false] {
+ if reflected && reflection_plane.is_none() {
+ continue;
+ }
+ let framebuffer = if reflected {
+ resources.reflection.framebuffer
+ } else {
+ resources.framebuffers[image_index]
+ };
+ let render_pass_info = vk::RenderPassBeginInfo::default()
+ .render_pass(resources.render_pass)
+ .framebuffer(framebuffer)
+ .render_area(render_area)
+ .clear_values(&clear_values);
+ // SAFETY: All commands target live frame resources owned by this renderer.
+ unsafe {
+ device.device().cmd_begin_render_pass(
command_buffer,
- vk::PipelineBindPoint::GRAPHICS,
- *pipeline,
+ &render_pass_info,
+ vk::SubpassContents::INLINE,
);
- let frame_offset = u32::try_from(
- resources
- .frame_uniform_stride
- .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)),
- )
- .unwrap_or(u32::MAX);
- let material_offset = u32::try_from(
- resources
- .material_specular_stride
- .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)),
- )
- .unwrap_or(u32::MAX);
- let point_light_offset = u32::try_from(
- resources
- .point_light_stride
- .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)),
- )
- .unwrap_or(u32::MAX);
- let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material descriptor set",
- },
- )?;
- device.device().cmd_bind_descriptor_sets(
+ let vertex_buffers = [self.vertex_buffer_ref()?.buffer];
+ let offsets = [0_u64];
+ device.device().cmd_bind_vertex_buffers(
command_buffer,
- vk::PipelineBindPoint::GRAPHICS,
- resources.pipeline_layout,
0,
- std::slice::from_ref(descriptor_set),
- &[frame_offset, material_offset, point_light_offset],
+ &vertex_buffers,
+ &offsets,
);
- let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes();
- device.device().cmd_push_constants(
+ device.device().cmd_bind_index_buffer(
command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ self.index_buffer_ref()?.buffer,
0,
- &alpha_cutoff,
- );
- let material_alpha = self.material_alphas.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material alpha",
- },
- )?;
- let diffuse_alpha_override = self
- .draw_range_diffuse_alpha_overrides
- .get(range_index)
- .ok_or(VulkanSmokeRendererError::InvariantViolation {
- context: "draw-range diffuse-alpha override",
- })?;
- let material_alphas =
- draw_material_alphas(*material_alpha, *diffuse_alpha_override)
- .map(f32::to_ne_bytes)
- .concat();
- device.device().cmd_push_constants(
- command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
- 4,
- &material_alphas,
- );
- let material_lighting = self.material_lighting.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material lighting",
- },
- )?;
- let material_lighting = material_lighting
- .iter()
- .flatten()
- .flat_map(|value| value.to_ne_bytes())
- .collect::<Vec<_>>();
- device.device().cmd_push_constants(
- command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
- 12,
- &material_lighting,
- );
- let material_uv_transforms = self.material_uv_transforms.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material UV transforms",
- },
- )?;
- let material_uv_transforms = material_uv_transforms
- .iter()
- .flatten()
- .flat_map(|value| value.to_ne_bytes())
- .collect::<Vec<_>>();
- device.device().cmd_push_constants(
- command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
- 60,
- &material_uv_transforms,
- );
- let material_mode = self.material_unlit.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material unlit flag",
- },
- )?;
- let material_mode = if range.terrain_base_only {
- ((*material_mode).round() as i32 | 16) as f32
- } else {
- *material_mode
- };
- let viewport = super::swapchain_resources::draw_viewport(
- swapchain.report.plan.extent,
- material_mode,
+ vk::IndexType::UINT32,
);
- let next_viewport_depth = [viewport.min_depth, viewport.max_depth];
- if viewport_depth != Some(next_viewport_depth) {
- device
- .device()
- .cmd_set_viewport(command_buffer, 0, &[viewport]);
- viewport_depth = Some(next_viewport_depth);
- }
- let material_mode = [material_mode.to_ne_bytes()];
- device.device().cmd_push_constants(
+ device.device().cmd_bind_descriptor_sets(
command_buffer,
+ vk::PipelineBindPoint::GRAPHICS,
resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
- 124,
- &material_mode.concat(),
- );
- device.device().cmd_draw_indexed(
- command_buffer,
- range.index_count,
1,
- range.first_index,
- 0,
- 0,
+ &[resources.reflection.sets
+ [usize::from(!reflected && reflection_plane.is_some())]],
+ &[],
);
+ let mut viewport_depth = None;
+ for &range_index in &self.draw_order {
+ let range = self.draw_ranges.get(range_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "stable draw order range handle",
+ },
+ )?;
+ let texture_index = *self.draw_texture_indices.get(range_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "stable draw texture range handle",
+ },
+ )?;
+ let mode = self.material_unlit[texture_index].round() as i32;
+ if reflected && (range.terrain_reflection || mode & 8 != 0) {
+ continue;
+ }
+ let state = if reflected {
+ super::reflection::reflected_pipeline_state(range.pipeline_state)
+ } else {
+ range.pipeline_state
+ };
+ let pipeline = resources.pipelines.get(&state.into()).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static draw pipeline variant",
+ },
+ )?;
+ device.device().cmd_bind_pipeline(
+ command_buffer,
+ vk::PipelineBindPoint::GRAPHICS,
+ *pipeline,
+ );
+ let frame_offset = u32::try_from(
+ resources.frame_uniform_stride.saturating_mul(
+ u64::try_from(self.current_frame * 2 + usize::from(reflected))
+ .unwrap_or(u64::MAX),
+ ),
+ )
+ .unwrap_or(u32::MAX);
+ let material_offset = u32::try_from(
+ resources
+ .material_specular_stride
+ .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)),
+ )
+ .unwrap_or(u32::MAX);
+ let point_light_offset = u32::try_from(
+ resources
+ .point_light_stride
+ .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)),
+ )
+ .unwrap_or(u32::MAX);
+ let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material descriptor set",
+ },
+ )?;
+ device.device().cmd_bind_descriptor_sets(
+ command_buffer,
+ vk::PipelineBindPoint::GRAPHICS,
+ resources.pipeline_layout,
+ 0,
+ std::slice::from_ref(descriptor_set),
+ &[frame_offset, material_offset, point_light_offset],
+ );
+ let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 0,
+ &alpha_cutoff,
+ );
+ let material_alpha = self.material_alphas.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material alpha",
+ },
+ )?;
+ let diffuse_alpha_override = self
+ .draw_range_diffuse_alpha_overrides
+ .get(range_index)
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "draw-range diffuse-alpha override",
+ })?;
+ let material_alphas =
+ draw_material_alphas(*material_alpha, *diffuse_alpha_override)
+ .map(f32::to_ne_bytes)
+ .concat();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 4,
+ &material_alphas,
+ );
+ let material_lighting = self.material_lighting.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material lighting",
+ },
+ )?;
+ let material_lighting = material_lighting
+ .iter()
+ .flatten()
+ .flat_map(|value| value.to_ne_bytes())
+ .collect::<Vec<_>>();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 12,
+ &material_lighting,
+ );
+ let material_uv_transforms = self
+ .material_uv_transforms
+ .get(texture_index)
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "static material UV transforms",
+ })?;
+ let material_uv_transforms = material_uv_transforms
+ .iter()
+ .flatten()
+ .flat_map(|value| value.to_ne_bytes())
+ .collect::<Vec<_>>();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 60,
+ &material_uv_transforms,
+ );
+ let material_mode = self.material_unlit.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material unlit flag",
+ },
+ )?;
+ let material_mode = if range.terrain_base_only {
+ ((*material_mode).round() as i32 | 16) as f32
+ } else {
+ *material_mode
+ };
+ let material_mode = (material_mode.round() as i32
+ | if reflected { 32 } else { 0 }
+ | if !reflected
+ && reflection_plane.is_some()
+ && self.reflection_planes[range_index] == reflection_plane
+ {
+ 64
+ } else {
+ 0
+ }) as f32;
+ let viewport = super::swapchain_resources::draw_viewport(
+ swapchain.report.plan.extent,
+ material_mode,
+ );
+ let next_viewport_depth = [viewport.min_depth, viewport.max_depth];
+ if viewport_depth != Some(next_viewport_depth) {
+ device
+ .device()
+ .cmd_set_viewport(command_buffer, 0, &[viewport]);
+ viewport_depth = Some(next_viewport_depth);
+ }
+ let material_mode = [material_mode.to_ne_bytes()];
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 124,
+ &material_mode.concat(),
+ );
+ device.device().cmd_draw_indexed(
+ command_buffer,
+ range.index_count,
+ 1,
+ range.first_index,
+ 0,
+ 0,
+ );
+ }
+ device.device().cmd_end_render_pass(command_buffer);
+ if reflected {
+ let image = resources.reflection.color.as_ref().ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "reflection target",
+ },
+ )?;
+ let barrier = vk::ImageMemoryBarrier::default()
+ .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
+ .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+ .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
+ .dst_access_mask(vk::AccessFlags::SHADER_READ)
+ .image(image.image)
+ .subresource_range(super::color_subresource_range(1));
+ device.device().cmd_pipeline_barrier(
+ command_buffer,
+ vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT,
+ vk::PipelineStageFlags::FRAGMENT_SHADER,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[barrier],
+ );
+ }
}
- device.device().cmd_end_render_pass(command_buffer);
+ }
+ // SAFETY: The main pass has completed its writes to this acquired swapchain image.
+ unsafe {
+ let barrier = vk::ImageMemoryBarrier::default()
+ .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
+ .new_layout(vk::ImageLayout::PRESENT_SRC_KHR)
+ .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
+ .image(resources.images[image_index])
+ .subresource_range(super::color_subresource_range(1));
+ device.device().cmd_pipeline_barrier(
+ command_buffer,
+ vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT,
+ vk::PipelineStageFlags::BOTTOM_OF_PIPE,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[barrier],
+ );
if self.readback_enabled {
if let (Some(image), Some(readback)) = (
resources.images.get(image_index),
@@ -2048,7 +2190,7 @@ impl VulkanSmokeRenderer {
}
}
- // SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path.
+ // SAFETY: Every render pass and attachment transition has been recorded in this buffer.
unsafe { device.device().end_command_buffer(command_buffer) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkEndCommandBuffer",
@@ -2081,6 +2223,15 @@ impl VulkanSmokeRenderer {
fn destroy_device_owned_resources(&mut self) {
self.destroy_swapchain_resources();
if let Some(device) = self.device.as_ref() {
+ if self.pipeline_cache != vk::PipelineCache::null() {
+ // SAFETY: All pipeline creation has finished on this device.
+ unsafe {
+ device
+ .device()
+ .destroy_pipeline_cache(self.pipeline_cache, None)
+ };
+ self.pipeline_cache = vk::PipelineCache::null();
+ }
for texture in self.textures.drain(..) {
destroy_allocated_image(device, &texture);
}
@@ -2231,6 +2382,40 @@ 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,
+ capacity: usize,
+) -> Result<std::ops::Range<usize>, 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],
@@ -2405,6 +2590,18 @@ fn draw_material_alphas(
]
}
+pub(super) fn pipeline_variants(original: LegacyPipelineState) -> [LegacyPipelineState; 12] {
+ std::array::from_fn(|index| match index {
+ 0 => original,
+ 1 => apply_world_transparency(original, true),
+ _ => {
+ world_material_pipeline_states(original, ((index - 2) / 2) as u8, Some(index % 2 != 0))
+ .expect("categories 0..=4 are valid")
+ .1
+ }
+ })
+}
+
fn world_material_pipeline_states(
original: LegacyPipelineState,
category: u8,
@@ -2572,12 +2769,15 @@ 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,
- 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, pipeline_variants, 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,
};
- use fparkan_render::{LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState};
use std::cell::RefCell;
use std::rc::Rc;
@@ -2682,6 +2882,7 @@ mod tests {
batch_flags: 0x108,
pipeline_state: base_state,
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 17,
},
VulkanStaticDrawRange {
@@ -2692,6 +2893,7 @@ mod tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
];
@@ -2731,6 +2933,93 @@ mod tests {
}
#[test]
+ fn pipeline_variants_keep_authored_alpha_and_prepare_both_depth_policies() {
+ let original = LegacyPipelineState {
+ alpha_test: true,
+ depth: LegacyDepthMode::TestWrite,
+ cull: LegacyCullMode::FrontFace,
+ depth_bias: LegacyDepthBias::ProjectedDecal,
+ ..LegacyPipelineState::default()
+ };
+ let states = pipeline_variants(original);
+ let keys: std::collections::BTreeSet<_> = states
+ .iter()
+ .map(|state| fparkan_render::PipelineKey::from(*state))
+ .collect();
+ assert_eq!(keys.len(), 12);
+ assert_eq!(states[0], original);
+ assert_eq!(states[1], apply_world_transparency(original, true));
+ for state in states {
+ assert_eq!(state.cull, original.cull);
+ assert_eq!(state.depth_bias, original.depth_bias);
+ }
+ let disabled = pipeline_variants(LegacyPipelineState {
+ depth: LegacyDepthMode::Disabled,
+ ..original
+ });
+ assert!(disabled
+ .iter()
+ .all(|state| state.depth == LegacyDepthMode::Disabled));
+ }
+
+ #[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,
+ &copy,
+ &authored,
+ &different_mip,
+ &different_extent,
+ &copy,
+ ];
+ 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));
+ 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();
let geometry = [VulkanDynamicDrawRange {
@@ -2761,6 +3050,7 @@ mod tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
}];
let mut capacities = [6];
@@ -2947,6 +3237,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
})
.collect::<Vec<_>>();
@@ -2971,6 +3262,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
super::super::VulkanStaticDrawRange {
@@ -2981,6 +3273,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
super::super::VulkanStaticDrawRange {
@@ -2991,6 +3284,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
];
@@ -3015,6 +3309,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
})
.collect::<Vec<_>>();
@@ -3108,6 +3403,7 @@ mod tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
})
.collect::<Vec<_>>();
@@ -3145,12 +3441,75 @@ mod tests {
}
#[test]
+ fn control_fx_queue_follows_opaque_and_sky_without_reactivating_empty_ranges() {
+ const SCREEN_GRADIENT: usize = 0;
+ const CONTROL_FX: usize = 1;
+ const TERRAIN: usize = 2;
+ const BUILDING: usize = 3;
+ const SHADOW: usize = 4;
+ const NEBULA: usize = 5;
+ const DOME: usize = 6;
+ const CLOUDS: usize = 7;
+ const EMPTY_CONTROL_FX: usize = 8;
+ const SUN: usize = 9;
+ const WEATHER: usize = 10;
+ let mut ranges = (0..11)
+ .map(|index| VulkanStaticDrawRange {
+ first_index: index * 3,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
+ terrain_reflection: false,
+ alpha_test_reference: 0,
+ })
+ .collect::<Vec<_>>();
+ ranges[EMPTY_CONTROL_FX].index_count = 0;
+ let mut world_queue = vec![None; ranges.len()];
+ world_queue[TERRAIN] = Some(false);
+ world_queue[BUILDING] = Some(false);
+ world_queue[CONTROL_FX] = Some(true);
+ world_queue[EMPTY_CONTROL_FX] = Some(true);
+ let mut sort_keys = vec![None; ranges.len()];
+ sort_keys[CONTROL_FX] = Some(20.0);
+ sort_keys[EMPTY_CONTROL_FX] = Some(30.0);
+
+ let order = sorted_world_draw_order(
+ ranges.len(),
+ &ranges,
+ &world_queue,
+ &sort_keys,
+ &[NEBULA, DOME, CLOUDS],
+ );
+ assert_eq!(
+ order,
+ vec![
+ SCREEN_GRADIENT,
+ TERRAIN,
+ BUILDING,
+ SHADOW,
+ NEBULA,
+ DOME,
+ CLOUDS,
+ CONTROL_FX,
+ EMPTY_CONTROL_FX,
+ SUN,
+ WEATHER,
+ ]
+ );
+ assert_eq!(ranges[EMPTY_CONTROL_FX].index_count, 0);
+ }
+
+ #[test]
fn material_category_and_transparency_preserve_world_state_in_either_order() {
let original = LegacyPipelineState {
blend: LegacyBlendMode::Opaque,
depth: LegacyDepthMode::TestWrite,
cull: LegacyCullMode::FrontFace,
alpha_test: false,
+ depth_bias: LegacyDepthBias::ProjectedDecal,
};
let (base, category_first) =
world_material_pipeline_states(original, 1, Some(true)).expect("category 1");
@@ -3162,14 +3521,17 @@ mod tests {
assert!(base.alpha_test);
assert_eq!(base.depth, LegacyDepthMode::TestWrite);
assert_eq!(base.cull, LegacyCullMode::FrontFace);
+ assert_eq!(base.depth_bias, LegacyDepthBias::ProjectedDecal);
assert_eq!(category_first.depth, LegacyDepthMode::TestReadOnly);
assert_eq!(category_first.cull, LegacyCullMode::FrontFace);
+ assert_eq!(category_first.depth_bias, LegacyDepthBias::ProjectedDecal);
let fixed_state = LegacyPipelineState {
blend: LegacyBlendMode::Additive,
depth: LegacyDepthMode::Disabled,
cull: LegacyCullMode::Disabled,
alpha_test: false,
+ depth_bias: LegacyDepthBias::Disabled,
};
assert_eq!(
world_material_pipeline_states(fixed_state, 1, None),
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index f004cb2..90fd7b4 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -192,7 +192,8 @@ impl Default for VulkanPointLight {
/// every device. Four directional lights and one point light fit in a small
/// dynamic UBO while the push-constant range remains available for material
/// state.
-pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368;
+pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 400;
+pub(super) const REFLECTION_CLIP_HEIGHT_OFFSET: usize = 364;
/// Number of CPU/GPU frame slots shared by the swapchain renderer.
pub const VULKAN_FRAME_SLOT_COUNT: usize = 2;
@@ -235,6 +236,10 @@ pub struct VulkanFrameUniforms {
pub fog_end: f32,
/// World-space camera position used for fog distance.
pub camera_position: [f32; 3],
+ /// XY bounds of the original reflective faces: min X/Y, max X/Y.
+ pub embm_bounds: [f32; 4],
+ /// Native wave scroll fraction in the ten-second cycle.
+ pub embm_phase: f32,
}
impl VulkanFrameUniforms {
@@ -258,6 +263,8 @@ impl VulkanFrameUniforms {
fog_start: f32::MAX,
fog_end: f32::MAX,
camera_position: [0.0; 3],
+ embm_bounds: [0.0, 0.0, 1.0, 1.0],
+ embm_phase: 0.0,
}
}
@@ -283,7 +290,17 @@ impl VulkanFrameUniforms {
.chain(self.fog_color)
.chain([self.fog_start, self.fog_end])
.chain(self.camera_position)
+ .chain(self.embm_bounds)
+ .chain([self.embm_phase])
.all(f32::is_finite)
+ && self.embm_bounds[2] > self.embm_bounds[0]
+ && self.embm_bounds[3] > self.embm_bounds[1]
+ && (self.embm_bounds[2] - self.embm_bounds[0]).is_finite()
+ && (self.embm_bounds[3] - self.embm_bounds[1]).is_finite()
+ && (0.0..1.0).contains(&self.embm_phase)
+ && (self.fog_end <= self.fog_start
+ || ((self.fog_start * self.fog_start).is_finite()
+ && (self.fog_end * self.fog_end).is_finite()))
}
/// Serializes one frame slot in the exact `std140` member order.
@@ -344,13 +361,30 @@ impl VulkanFrameUniforms {
self.fog_color[2],
0.0,
]);
- values[84..88].copy_from_slice(&[self.fog_start, self.fog_end, 0.0, 0.0]);
+ let squared_fog_range = if self.fog_end > self.fog_start {
+ [self.fog_start * self.fog_start, self.fog_end * self.fog_end]
+ } else {
+ [0.0; 2]
+ };
+ values[84..88].copy_from_slice(&[
+ self.fog_start,
+ self.fog_end,
+ squared_fog_range[0],
+ squared_fog_range[1],
+ ]);
values[88..92].copy_from_slice(&[
self.camera_position[0],
self.camera_position[1],
self.camera_position[2],
0.0,
]);
+ values[92..96].copy_from_slice(&self.embm_bounds);
+ values[96..100].copy_from_slice(&[
+ self.embm_phase,
+ fparkan_render::water::EMBM_BUMP_TILE,
+ fparkan_render::water::EMBM_COEFFICIENTS[0],
+ fparkan_render::water::EMBM_COEFFICIENTS[1],
+ ]);
let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES];
for (index, value) in values.into_iter().enumerate() {
let offset = index * std::mem::size_of::<f32>();
@@ -424,6 +458,8 @@ pub struct VulkanStaticDrawRange {
pub batch_flags: u16,
/// Terrain ranges that use the native lit base-only path (face flag 0x400 clear).
pub terrain_base_only: bool,
+ /// Terrain face flag 0x20000 requests a water reflection surface.
+ pub terrain_reflection: bool,
/// Backend-neutral fixed-function state for this source range.
pub pipeline_state: LegacyPipelineState,
/// Alpha-test reference in legacy 0..=255 units; dynamic material data.
@@ -546,7 +582,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,
@@ -557,7 +593,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,
@@ -756,8 +792,9 @@ impl VulkanStaticMesh {
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
}],
}
@@ -846,6 +883,8 @@ mod static_mesh_tests {
fog_start: 13.0,
fog_end: 14.0,
camera_position: [15.0, 16.0, 17.0],
+ embm_bounds: [100.0, 200.0, 300.0, 600.0],
+ embm_phase: 0.25,
};
let bytes = frame.to_ne_bytes();
assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES);
@@ -864,6 +903,28 @@ mod static_mesh_tests {
assert_eq!(read(320), 10.0);
assert_eq!(read(336), 13.0);
assert_eq!(read(352), 15.0);
+ assert_eq!(read(368), 100.0);
+ assert_eq!(read(380), 600.0);
+ assert_eq!(read(384), 0.25);
+ assert_eq!(read(388), 100.0);
+ assert_eq!(read(392), 0.01);
+ }
+
+ #[test]
+ fn embm_uniforms_reject_invalid_bounds_and_phase() {
+ for (bounds, phase) in [
+ ([0.0; 4], 0.0),
+ ([-f32::MAX, 0.0, f32::MAX, 1.0], 0.0),
+ ([0.0, 0.0, 1.0, 1.0], f32::NAN),
+ ([0.0, 0.0, 1.0, 1.0], 1.0),
+ ] {
+ assert!(!VulkanFrameUniforms {
+ embm_bounds: bounds,
+ embm_phase: phase,
+ ..Default::default()
+ }
+ .is_finite());
+ }
}
#[test]
@@ -875,6 +936,31 @@ mod static_mesh_tests {
}
#[test]
+ fn fog_uniforms_match_live_native_squared_distance_range() {
+ // Terrain ShadeIndexedStrided: far=0x442389e6, far²=0x48d0f1ca;
+ // distance²=0x47707b04 gives visibility=0x3f5b2b94.
+ let frame = VulkanFrameUniforms {
+ fog_start: 0.0,
+ fog_end: f32::from_bits(0x4423_89e6),
+ ..Default::default()
+ };
+ assert!(frame.is_finite());
+ let bytes = frame.to_ne_bytes();
+ let read = |offset| f32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
+ assert_eq!(read(344), 0.0);
+ assert_eq!(read(348).to_bits(), 0x48d0_f1ca);
+ let visibility = 1.0 - (f32::from_bits(0x4770_7b04) - read(344)) / (read(348) - read(344));
+ assert!((visibility - f32::from_bits(0x3f5b_2b94)).abs() < 1.0e-6);
+ assert!(VulkanFrameUniforms::default().is_finite());
+ assert!(!VulkanFrameUniforms {
+ fog_start: 0.0,
+ fog_end: f32::MAX,
+ ..frame
+ }
+ .is_finite());
+ }
+
+ #[test]
fn static_camera_composes_recovered_d3d7_view_before_projection() {
let transform = RawCameraTransform {
words: [
@@ -992,8 +1078,9 @@ mod static_mesh_tests {
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
}],
};
@@ -1023,8 +1110,9 @@ mod static_mesh_tests {
material_index: 7,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
VulkanStaticDrawRange {
@@ -1033,8 +1121,9 @@ mod static_mesh_tests {
material_index: 2,
lightmap_index: u8::MAX,
batch_flags: 0,
- terrain_base_only: false,
pipeline_state: LegacyPipelineState::default(),
+ terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
];
@@ -1101,6 +1190,11 @@ mod static_mesh_tests {
},
];
+ assert_eq!(
+ resolve_draw_texture_indices(&ranges, &materials),
+ Ok(vec![1, 0])
+ );
+
let mut screen_tl = materials[0].clone();
screen_tl.screen_tl = true;
screen_tl.sky_far_depth = true;
@@ -1109,11 +1203,9 @@ mod static_mesh_tests {
far_sky.unlit = true;
far_sky.sky_far_depth = true;
assert_eq!(far_sky.encoded_material_mode(), 5.0);
-
- assert_eq!(
- resolve_draw_texture_indices(&ranges, &materials),
- Ok(vec![1, 0])
- );
+ let mut nebula_with_stars = far_sky.clone();
+ nebula_with_stars.sky_nebula_stars = true;
+ assert_eq!(nebula_with_stars.encoded_material_mode(), 6.0);
let mut non_finite_base = materials.clone();
non_finite_base[0].specular_rgb[0] = f32::NAN;
@@ -1149,6 +1241,8 @@ mod static_mesh_tests {
fn alpha_cutoff_is_dynamic_and_disabled_outside_alpha_test_pipeline() {
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
let disabled = VulkanStaticDrawRange {
+ terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 200,
..base
};
@@ -1157,6 +1251,8 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 128,
..base
};
@@ -1167,6 +1263,8 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
..base
};
@@ -1176,6 +1274,8 @@ mod static_mesh_tests {
alpha_test: true,
..LegacyPipelineState::default()
},
+ terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
..base
};
@@ -1288,6 +1388,18 @@ 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,
+ /// 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,
+ /// Graphics pipelines created across swapchain lifetimes.
+ pub pipeline_creation_count: u64,
}
/// Measured validation counters from the live smoke loop.
@@ -1430,12 +1542,12 @@ pub struct VulkanSmokeRenderer {
pub(super) device: Option<VulkanLogicalDeviceProbe>,
pub(super) swapchain: Option<VulkanSwapchainProbe>,
pub(super) command_pool: vk::CommandPool,
+ pub(super) pipeline_cache: vk::PipelineCache,
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,
@@ -1444,6 +1556,7 @@ pub struct VulkanSmokeRenderer {
pub(super) index_capacity: usize,
pub(super) draw_range_capacities: Vec<u32>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
+ pub(super) reflection_planes: Vec<Option<f32>>,
pub(super) draw_texture_indices: Vec<usize>,
/// Stable range handles used when recording the native pass order.
pub(super) draw_order: Vec<usize>,
@@ -1472,8 +1585,8 @@ pub struct VulkanSmokeRenderer {
pub(super) material_selectors: Vec<u16>,
/// Whether the host-side specular records need a fence-safe GPU upload.
pub(super) material_specular_dirty: bool,
- /// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native
- /// sky nebula/stars texture composite, 3 native lightmap composite.
+ /// Encoded material mode pushed at offset 124: low bits select the
+ /// combiner, bit 4 selects sky far-depth, and bit 8 selects screen/TL.
pub(super) material_unlit: Vec<f32>,
pub(super) readback_enabled: bool,
pub(super) camera: VulkanStaticCamera,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
index 05ce927..af20dfa 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
@@ -3,7 +3,8 @@
use ash::vk;
use fparkan_platform::DepthStencilSupport;
use fparkan_render::{
- LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState, PipelineKey,
+ LegacyBlendMode, LegacyCullMode, LegacyDepthBias, LegacyDepthMode, LegacyPipelineState,
+ PipelineKey,
};
use std::collections::BTreeMap;
@@ -17,6 +18,7 @@ use super::{
};
pub(super) struct VulkanSwapchainResources {
+ pub(super) reflection: super::reflection::ReflectionResources,
pub(super) image_views: Vec<vk::ImageView>,
pub(super) images: Vec<vk::Image>,
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
@@ -145,6 +147,7 @@ pub(super) fn ensure_point_light_capacity(
}
struct PartialSwapchainResources {
+ reflection: Option<super::reflection::ReflectionResources>,
image_views: Vec<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>,
depth_attachments: Vec<VulkanDepthAttachment>,
@@ -173,11 +176,13 @@ pub(super) fn create_swapchain_resources(
device: &VulkanLogicalDeviceProbe,
swapchain: &VulkanSwapchainProbe,
command_pool: vk::CommandPool,
+ pipeline_cache: vk::PipelineCache,
vertex_buffer: &VulkanAllocatedBuffer,
index_buffer: &VulkanAllocatedBuffer,
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
- draw_ranges: &[super::VulkanStaticDrawRange],
+ base_pipeline_states: &[LegacyPipelineState],
+ reflection_enabled: bool,
depth_request: DepthStencilSupport,
reuse_command_pool: bool,
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
@@ -192,6 +197,7 @@ pub(super) fn create_swapchain_resources(
result: error,
})?;
let mut partial = PartialSwapchainResources {
+ reflection: None,
image_views: create_swapchain_image_views(
device,
&images,
@@ -243,6 +249,18 @@ pub(super) fn create_swapchain_resources(
};
partial.frame_uniform_stride = frame_uniform_stride;
partial.frame_uniform_buffer = Some(frame_uniform_buffer);
+ partial.reflection = match super::reflection::ReflectionResources::new(
+ instance,
+ device,
+ command_pool,
+ reflection_enabled,
+ ) {
+ Ok(reflection) => Some(reflection),
+ Err(error) => {
+ destroy_partial_swapchain_resources(device, command_pool, partial);
+ return Err(error);
+ }
+ };
let material_count = texture_slot_indices.len() / 4;
let (material_specular_buffer, material_specular_stride) =
match create_material_specular_buffer(instance, device, material_count) {
@@ -280,11 +298,18 @@ pub(super) fn create_swapchain_resources(
) = match create_swapchain_pipeline_bundle(
instance,
device,
+ pipeline_cache,
swapchain.report.plan.format.format,
swapchain.report.plan.extent,
textures,
texture_slot_indices,
- draw_ranges,
+ base_pipeline_states,
+ partial
+ .reflection
+ .as_ref()
+ .expect("reflection descriptors exist")
+ .layout,
+ reflection_enabled,
depth_request,
partial
.frame_uniform_buffer
@@ -316,6 +341,24 @@ pub(super) fn create_swapchain_resources(
partial.descriptor_pool = Some(descriptor_pool);
partial.sampler = Some(sampler);
partial.pipelines = pipelines;
+ if reflection_enabled {
+ if let Err(error) = partial
+ .reflection
+ .as_mut()
+ .expect("reflection descriptors exist")
+ .create_target(
+ instance,
+ device,
+ swapchain.report.plan.extent,
+ swapchain.report.plan.format.format,
+ depth_request,
+ render_pass,
+ )
+ {
+ destroy_partial_swapchain_resources(device, command_pool, partial);
+ return Err(error);
+ }
+ }
for _ in 1..images.len() {
match create_depth_attachment(
instance,
@@ -391,6 +434,10 @@ pub(super) fn create_swapchain_resources(
});
}
Ok(VulkanSwapchainResources {
+ reflection: partial
+ .reflection
+ .take()
+ .expect("reflection descriptors exist"),
image_views: partial.image_views,
images,
readback_buffers: partial.readback_buffers,
@@ -448,11 +495,14 @@ fn create_swapchain_image_views(
fn create_swapchain_pipeline_bundle(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
+ pipeline_cache: vk::PipelineCache,
format: i32,
extent: (u32, u32),
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
- draw_ranges: &[super::VulkanStaticDrawRange],
+ base_pipeline_states: &[LegacyPipelineState],
+ reflection_layout: vk::DescriptorSetLayout,
+ reflection_enabled: bool,
depth_request: DepthStencilSupport,
frame_uniform_buffer: &VulkanAllocatedBuffer,
material_specular_buffer: &VulkanAllocatedBuffer,
@@ -494,8 +544,8 @@ fn create_swapchain_pipeline_bundle(
unsafe { device.device().destroy_render_pass(render_pass, None) };
destroy_depth_attachment(device, &depth_attachment);
})?;
- let pipeline_layout =
- create_pipeline_layout(device, descriptor_set_layout).inspect_err(|_| {
+ let pipeline_layout = create_pipeline_layout(device, descriptor_set_layout, reflection_layout)
+ .inspect_err(|_| {
// SAFETY: The descriptor resources and render pass were created above and are rolled back on this device.
unsafe {
device.device().destroy_sampler(sampler, None);
@@ -509,25 +559,32 @@ fn create_swapchain_pipeline_bundle(
}
destroy_depth_attachment(device, &depth_attachment);
})?;
- let pipelines =
- create_graphics_pipeline_cache(device, render_pass, pipeline_layout, extent, draw_ranges)
- .inspect_err(|_| {
- // SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
- unsafe {
- device
- .device()
- .destroy_pipeline_layout(pipeline_layout, None);
- device.device().destroy_sampler(sampler, None);
- device
- .device()
- .destroy_descriptor_pool(descriptor_pool, None);
- device
- .device()
- .destroy_descriptor_set_layout(descriptor_set_layout, None);
- device.device().destroy_render_pass(render_pass, None);
- }
- destroy_depth_attachment(device, &depth_attachment);
- })?;
+ let pipelines = create_graphics_pipeline_cache(
+ device,
+ pipeline_cache,
+ render_pass,
+ pipeline_layout,
+ extent,
+ base_pipeline_states,
+ reflection_enabled,
+ )
+ .inspect_err(|_| {
+ // SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
+ unsafe {
+ device
+ .device()
+ .destroy_pipeline_layout(pipeline_layout, None);
+ device.device().destroy_sampler(sampler, None);
+ device
+ .device()
+ .destroy_descriptor_pool(descriptor_pool, None);
+ device
+ .device()
+ .destroy_descriptor_set_layout(descriptor_set_layout, None);
+ device.device().destroy_render_pass(render_pass, None);
+ }
+ destroy_depth_attachment(device, &depth_attachment);
+ })?;
Ok((
depth_attachment,
render_pass,
@@ -626,7 +683,7 @@ fn create_render_pass(
.load_op(vk::AttachmentLoadOp::CLEAR)
.store_op(vk::AttachmentStoreOp::STORE)
.initial_layout(vk::ImageLayout::UNDEFINED)
- .final_layout(vk::ImageLayout::PRESENT_SRC_KHR);
+ .final_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
let color_attachment_ref = vk::AttachmentReference::default()
.attachment(0)
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
@@ -651,7 +708,8 @@ fn create_render_pass(
.src_subpass(vk::SUBPASS_EXTERNAL)
.dst_subpass(0)
.src_stage_mask(
- vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+ vk::PipelineStageFlags::FRAGMENT_SHADER
+ | vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
| vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
)
@@ -660,7 +718,11 @@ fn create_render_pass(
| vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
)
- .src_access_mask(vk::AccessFlags::empty())
+ .src_access_mask(
+ vk::AccessFlags::SHADER_READ
+ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE
+ | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
+ )
.dst_access_mask(
vk::AccessFlags::COLOR_ATTACHMENT_WRITE
| vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
@@ -684,8 +746,9 @@ fn create_render_pass(
fn create_pipeline_layout(
device: &VulkanLogicalDeviceProbe,
descriptor_set_layout: vk::DescriptorSetLayout,
+ reflection_layout: vk::DescriptorSetLayout,
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
- let set_layouts = [descriptor_set_layout];
+ let set_layouts = [descriptor_set_layout, reflection_layout];
// Material data occupies one 128-byte push-constant allocation shared by
// both stages; the vertex stage also reads its specular pair from the
// dynamic storage buffer at descriptor binding 5. Camera data is supplied
@@ -935,25 +998,45 @@ pub(super) fn draw_viewport(extent: (u32, u32), material_mode: f32) -> vk::Viewp
}
}
+fn depth_bias_parameters(depth_bias: LegacyDepthBias) -> (bool, f32, f32) {
+ match depth_bias {
+ LegacyDepthBias::Disabled => (false, 0.0, 0.0),
+ // shortcut: Vulkan constantFactor=-1.0/slopeFactor=0.0 is empirical; verify native raster state before claiming parity.
+ LegacyDepthBias::ProjectedDecal => (true, -1.0, 0.0),
+ }
+}
+
fn create_graphics_pipeline_cache(
device: &VulkanLogicalDeviceProbe,
+ pipeline_cache: vk::PipelineCache,
render_pass: vk::RenderPass,
pipeline_layout: vk::PipelineLayout,
extent: (u32, u32),
- draw_ranges: &[super::VulkanStaticDrawRange],
+ base_pipeline_states: &[LegacyPipelineState],
+ reflection_enabled: bool,
) -> Result<BTreeMap<PipelineKey, vk::Pipeline>, VulkanSmokeRendererError> {
let mut pipelines = BTreeMap::new();
- for range in draw_ranges {
- let key = range.pipeline_key();
+ for state in base_pipeline_states
+ .iter()
+ .copied()
+ .flat_map(super::smoke::pipeline_variants)
+ .flat_map(|state| {
+ std::iter::once(state).chain(
+ reflection_enabled.then_some(super::reflection::reflected_pipeline_state(state)),
+ )
+ })
+ {
+ let key = PipelineKey::from(state);
if pipelines.contains_key(&key) {
continue;
}
let pipeline = match create_graphics_pipeline(
device,
+ pipeline_cache,
render_pass,
pipeline_layout,
extent,
- range.pipeline_state,
+ state,
) {
Ok(pipeline) => pipeline,
Err(error) => {
@@ -973,6 +1056,7 @@ fn create_graphics_pipeline_cache(
fn create_graphics_pipeline(
device: &VulkanLogicalDeviceProbe,
+ pipeline_cache: vk::PipelineCache,
render_pass: vk::RenderPass,
pipeline_layout: vk::PipelineLayout,
extent: (u32, u32),
@@ -1053,6 +1137,8 @@ fn create_graphics_pipeline(
let viewport_state = vk::PipelineViewportStateCreateInfo::default()
.viewports(&viewports)
.scissors(&scissors);
+ let (depth_bias_enable, depth_bias_constant_factor, depth_bias_slope_factor) =
+ depth_bias_parameters(state.depth_bias);
let rasterization_state = vk::PipelineRasterizationStateCreateInfo::default()
.depth_clamp_enable(false)
.rasterizer_discard_enable(false)
@@ -1064,7 +1150,10 @@ fn create_graphics_pipeline(
LegacyCullMode::FrontFace => vk::CullModeFlags::FRONT,
})
.front_face(vk::FrontFace::CLOCKWISE)
- .depth_bias_enable(false);
+ .depth_bias_enable(depth_bias_enable)
+ .depth_bias_constant_factor(depth_bias_constant_factor)
+ .depth_bias_slope_factor(depth_bias_slope_factor)
+ .depth_bias_clamp(0.0);
let multisample_state = vk::PipelineMultisampleStateCreateInfo::default()
.sample_shading_enable(false)
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
@@ -1128,7 +1217,7 @@ fn create_graphics_pipeline(
let pipeline_result = unsafe {
device
.device()
- .create_graphics_pipelines(vk::PipelineCache::null(), &create_infos, None)
+ .create_graphics_pipelines(pipeline_cache, &create_infos, None)
};
// SAFETY: The shader modules were created above on this live logical device and are no longer needed after pipeline creation.
unsafe {
@@ -1208,6 +1297,7 @@ pub(super) fn destroy_swapchain_resources(
command_pool: vk::CommandPool,
resources: VulkanSwapchainResources,
) {
+ drop(resources.reflection);
// SAFETY: All handles belong to this live logical device and are destroyed during renderer teardown/recreation.
unsafe {
if !resources.command_buffers.is_empty() {
@@ -1273,6 +1363,7 @@ fn destroy_partial_swapchain_resources(
command_pool: vk::CommandPool,
partial: PartialSwapchainResources,
) {
+ drop(partial.reflection);
// SAFETY: All handles in the partial bundle belong to this live logical device and are destroyed on setup failure.
unsafe {
if !partial.command_buffers.is_empty() {
@@ -1337,7 +1428,20 @@ fn destroy_partial_swapchain_resources(
#[cfg(test)]
mod tests {
- use super::draw_viewport;
+ use super::{depth_bias_parameters, draw_viewport};
+ use fparkan_render::LegacyDepthBias;
+
+ #[test]
+ fn projected_decal_bias_parameters_use_empirical_vulkan_factors() {
+ assert_eq!(
+ depth_bias_parameters(LegacyDepthBias::Disabled),
+ (false, 0.0, 0.0)
+ );
+ assert_eq!(
+ depth_bias_parameters(LegacyDepthBias::ProjectedDecal),
+ (true, -1.0, 0.0)
+ );
+ }
#[test]
fn world_viewport_depth_keeps_distant_geometry_in_front_of_screen_sun() {