aboutsummaryrefslogtreecommitdiff
path: root/adapters/fparkan-render-vulkan/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-11-01 05:12:44 +0300
committerValentin Popov <valentin@popov.link>2026-11-01 05:12:44 +0300
commit05572c7bf446572264d2cf97c531be6c3bfaaf30 (patch)
tree9aa68d9c49fe525f4e632b960653cc0082936e95 /adapters/fparkan-render-vulkan/src
parentdfa2db65cc7b052d91c30ce179df8bc07af0cb1b (diff)
downloadfparkan-05572c7bf446572264d2cf97c531be6c3bfaaf30.tar.xz
fparkan-05572c7bf446572264d2cf97c531be6c3bfaaf30.zip
feat(render): animate water with native EMBM waves
Diffstat (limited to 'adapters/fparkan-render-vulkan/src')
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/reflection.rs129
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs13
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs47
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs20
4 files changed, 182 insertions, 27 deletions
diff --git a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
index eb4025e..62a0d1e 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
@@ -36,14 +36,42 @@ pub(super) fn mesh_reflection_planes(mesh: &super::VulkanStaticMesh) -> Vec<Opti
.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,
@@ -54,25 +82,31 @@ impl ReflectionResources {
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 binding = vk::DescriptorSetLayoutBinding::default()
- .binding(0)
- .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
- .descriptor_count(1)
- .stage_flags(vk::ShaderStageFlags::FRAGMENT);
- let layout_info =
- vk::DescriptorSetLayoutCreateInfo::default().bindings(std::slice::from_ref(&binding));
+ 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
@@ -85,7 +119,7 @@ impl ReflectionResources {
})?;
let size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
- .descriptor_count(2);
+ .descriptor_count(4);
let pool_info = vk::DescriptorPoolCreateInfo::default()
.max_sets(2)
.pool_sizes(std::slice::from_ref(&size));
@@ -136,6 +170,42 @@ impl ReflectionResources {
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);
}
@@ -255,13 +325,35 @@ impl ReflectionResources {
.sampler(self.sampler)
.image_view(view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
- let write = vk::WriteDescriptorSet::default()
- .dst_set(set)
- .dst_binding(0)
- .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
- .image_info(std::slice::from_ref(&image));
+ 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(&[write], &[]) };
+ unsafe { self.device.update_descriptor_sets(&writes, &[]) };
}
}
@@ -273,12 +365,14 @@ impl Drop for ReflectionResources {
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);
@@ -320,12 +414,19 @@ mod tests {
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,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index 5968a47..bd745ad 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -373,12 +373,17 @@ impl VulkanSmokeRenderer {
plane_heights.sort_unstable();
plane_heights.dedup();
if !plane_heights.is_empty() {
- eprintln!("render_coverage planar_reflection_embm_unsupported={} (flat reflection; native shifted camera and wave distortion pending)", plane_heights.len());
+ 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,
@@ -424,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(),
@@ -1571,8 +1576,8 @@ impl VulkanSmokeRenderer {
});
}
let mut bytes = frame.to_ne_bytes();
- let length = bytes.len();
- bytes[length - 4..].copy_from_slice(&height.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()?;
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index bc8aedd..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,14 @@ 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()))
@@ -364,6 +378,13 @@ impl VulkanFrameUniforms {
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>();
@@ -862,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);
@@ -880,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]
diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
index 4365cd2..af20dfa 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
@@ -249,14 +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) {
- Ok(reflection) => Some(reflection),
- Err(error) => {
- destroy_partial_swapchain_resources(device, command_pool, partial);
- return Err(error);
- }
- };
+ 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) {