diff options
Diffstat (limited to 'adapters/fparkan-render-vulkan')
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.frag | 34 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.frag.spv | bin | 10632 -> 14716 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert | 25 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert.spv | bin | 20096 -> 20860 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/asset_mesh.rs | 56 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi.rs | 6 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/packet_distance.rs | 52 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/reflection.rs | 453 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/resources.rs | 52 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 1053 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs | 161 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs | 176 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/tests.rs | 37 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/policy.rs | 2 |
14 files changed, 1690 insertions, 417 deletions
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag index 6a6e752..2c71278 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,34 @@ 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; + if ((mode & 128) != 0) { + reflection_uv = plane_uv + displacement; + } else { + // shortcut: non-perspective API cameras retain the projective mirror fallback. + 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 +111,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 Binary files differindex 7b7c3da..6deef9d 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert index d1e2e04..3accd2e 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 { @@ -42,7 +45,8 @@ layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular { struct PointLightRecord { vec4 position_range; vec4 rgb_active; - vec4 coefficients_active; + vec3 coefficients; + uint owner_scope; }; layout(std430, set = 0, binding = 6) readonly buffer PointLightBuffer { @@ -130,12 +134,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) { @@ -193,6 +198,9 @@ LightingTerms native_material_lighting( } for (uint index = 0u; index < point_light_buffer.header.x; ++index) { PointLightRecord point_light = point_light_buffer.records[index]; + if (point_light.owner_scope != 0u && point_light.owner_scope != uint(gl_InstanceIndex)) { + continue; + } vec3 to_light = point_light.position_range.xyz - world_position; float distance_squared = dot(to_light, to_light); float range = point_light.position_range.w; @@ -205,9 +213,9 @@ LightingTerms native_material_lighting( float ndotl = dot(normal, light_direction); if (ndotl > 0.0) { float t = (range - distance_to_light) / range; - float attenuation = point_light.coefficients_active.x - + point_light.coefficients_active.y * t - + point_light.coefficients_active.z * t * t; + float attenuation = point_light.coefficients.x + + point_light.coefficients.y * t + + point_light.coefficients.z * t * t; attenuation = max(attenuation, 0.0); accumulated += ndotl * directional_rgb * point_light.rgb_active.rgb * attenuation; specular += specular_from_light( @@ -240,6 +248,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 Binary files differindex f74b1f8..74adcc7 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index f691b34..f25ab89 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -859,6 +859,7 @@ fn append_node_fallback_batch( indices.push(vertex_index); } draw_ranges.push(VulkanStaticDrawRange { + owner_scope: 0, first_index, index_count: u32::from(batch.index_count), material_index: u16::from(batch.material_index), @@ -866,6 +867,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 +1107,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 +1228,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,12 +1237,17 @@ 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; } end_face += 1; } ranges.push(VulkanStaticDrawRange { + owner_scope: 0, first_index: u32::try_from(start_face) .map_err(|_| VulkanAssetMeshError::IndexOutOfRange)? .checked_mul(3) @@ -1239,6 +1261,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; @@ -1278,6 +1301,7 @@ fn static_model_indices_and_ranges( indices.push(index); } draw_ranges.push(VulkanStaticDrawRange { + owner_scope: 0, first_index, index_count: u32::from(batch.index_count), material_index: u16::from(batch.material_index), @@ -1285,6 +1309,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, }); } @@ -1485,6 +1510,7 @@ mod tests { assert_eq!( mesh.draw_ranges, vec![VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, material_index: 0, @@ -1492,6 +1518,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, }] ); @@ -2015,6 +2042,7 @@ mod tests { mesh.draw_ranges, vec![ VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, material_index: 0, @@ -2022,10 +2050,12 @@ 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, }, VulkanStaticDrawRange { + owner_scope: 0, first_index: 3, index_count: 3, material_index: 7, @@ -2033,6 +2063,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 +2149,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 +2315,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..04be2d3 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -11,6 +11,8 @@ mod asset_mesh; mod capabilities; mod instance; +mod packet_distance; +mod reflection; mod resources; mod runtime; mod smoke; @@ -22,7 +24,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, @@ -48,6 +51,7 @@ pub use self::instance::{ }; #[cfg(test)] use self::instance::{cstring_vec, ensure_instance_extensions_available}; +pub use self::packet_distance::legacy_packet_distance_squared; use self::resources::{ color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync, create_frame_uniform_buffer, create_index_buffer, create_material_specular_buffer, diff --git a/adapters/fparkan-render-vulkan/src/ffi/packet_distance.rs b/adapters/fparkan-render-vulkan/src/ffi/packet_distance.rs new file mode 100644 index 0000000..b6b8938 --- /dev/null +++ b/adapters/fparkan-render-vulkan/src/ffi/packet_distance.rs @@ -0,0 +1,52 @@ +#![allow(unsafe_code)] +//! The native CPU instruction used for render-packet distance keys. + +/// Reproduces Ngi32's squared length multiplied by `RSQRTSS` on x86-64. +/// Other architectures retain the portable distance and report that boundary. +#[must_use] +pub fn legacy_packet_distance_squared(squared: f32) -> Option<f32> { + if !squared.is_finite() || squared < 0.0 { + return None; + } + if squared == 0.0 { + static NOTICE: std::sync::Once = std::sync::Once::new(); + NOTICE.call_once(|| { + eprintln!("render_coverage packet_zero_distance_fallback=1 (stable zero instead of native 0 * infinity)"); + }); + return Some(0.0); + } + #[cfg(target_arch = "x86_64")] + let distance = { + use core::arch::x86_64::{_mm_cvtss_f32, _mm_rsqrt_ss, _mm_set_ss}; + // SAFETY: SSE is part of the x86-64 baseline; these intrinsics only + // operate on register values and do not access memory. + let inverse = unsafe { _mm_cvtss_f32(_mm_rsqrt_ss(_mm_set_ss(squared))) }; + squared * inverse + }; + #[cfg(not(target_arch = "x86_64"))] + let distance = { + static NOTICE: std::sync::Once = std::sync::Once::new(); + NOTICE.call_once(|| { + eprintln!("render_coverage packet_distance_native_rsqrt_unsupported=1 (using portable square root)"); + }); + squared.sqrt() + }; + distance.is_finite().then_some(distance) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packet_distance_preserves_the_native_approximation_error_bound() { + for squared in [0.000_001_f32, 1.0, 9.0, 11_393.854, 1_000_000.0] { + let distance = legacy_packet_distance_squared(squared).unwrap(); + assert!((distance / squared.sqrt() - 1.0).abs() < 0.000_3); + } + assert_eq!(legacy_packet_distance_squared(0.0), Some(0.0)); + for invalid in [-1.0, f32::NAN, f32::INFINITY] { + assert_eq!(legacy_packet_distance_squared(invalid), None); + } + } +} 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..6eb6c67 --- /dev/null +++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs @@ -0,0 +1,453 @@ +#![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 side = fparkan_render::water::legacy_embm_reflection_size([extent.0, extent.1]); + let extent = (side, side); + eprintln!("render_reflection target_extent={side}x{side}"); + 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: The validated swapchain format is retained; the square side is in 2..=1024. + 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 { + owner_scope: 0, + 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..449750f 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", })?; @@ -674,12 +687,13 @@ pub(super) fn encode_point_lights( .chain(light.rgb) .chain([if light.active { 1.0 } else { 0.0 }]) .chain(light.coefficients) - .chain([if light.active { 1.0 } else { 0.0 }]) { bytes[offset..offset + std::mem::size_of::<f32>()] .copy_from_slice(&value.to_ne_bytes()); offset += std::mem::size_of::<f32>(); } + bytes[offset..offset + 4].copy_from_slice(&light.owner_scope.to_ne_bytes()); + offset += 4; } debug_assert_eq!(offset, total); Ok(bytes) @@ -875,7 +889,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 +1386,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..c4f0264 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_render_context_approx={} (native shifted window, target size and bump UV; main-view depth range and sky/fog/FX remain approximations; non-perspective cameras use mirror fallback)", 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,34 @@ 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::water::legacy_embm_reflection_clip( + frame.clip_from_world, + frame.camera_position, + height, + frame.embm_bounds, + ) + .or_else(|| { + 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 +1613,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 +1761,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 +1788,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 +1825,16 @@ impl VulkanSmokeRenderer { self.rebuild_draw_order(); self.draw_order_dirty = false; } + let reflection_plane = self.active_reflection_plane(); + let shifted_reflection = reflection_plane.is_some_and(|height| { + fparkan_render::water::legacy_embm_reflection_clip( + self.frame_uniforms.clip_from_world, + self.frame_uniforms.camera_position, + height, + self.frame_uniforms.embm_bounds, + ) + .is_some() + }); let device = self.device_ref()?; let swapchain = self.swapchain_ref()?; let resources = self.resources_ref()?; @@ -1800,192 +1874,283 @@ impl VulkanSmokeRenderer { }, }, ]; - let render_area = vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent: vk::Extent2D { - width: swapchain.report.plan.extent.0, - 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( + for reflected in [true, false] { + if reflected && reflection_plane.is_none() { + continue; + } + let extent = if reflected { + let target = resources.reflection.color.as_ref().ok_or( VulkanSmokeRendererError::InvariantViolation { - context: "static draw pipeline variant", + context: "active reflection without a color target", }, )?; - device.device().cmd_bind_pipeline( + (target.width, target.height) + } else { + swapchain.report.plan.extent + }; + let render_area = vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent: vk::Extent2D { + width: extent.0, + height: extent.1, + }, + }; + 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, + vk::IndexType::UINT32, ); - 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, - ); - 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 | if shifted_reflection { 128 } else { 0 } + } else { + 0 + }) as f32; + let viewport = super::swapchain_resources::draw_viewport(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, + range.owner_scope, + ); + } + 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 +2213,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 +2246,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 +2405,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 +2613,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 +2792,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; @@ -2675,6 +2898,7 @@ mod tests { }; let mut ranges = vec![ VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, material_index: 7, @@ -2682,9 +2906,11 @@ mod tests { batch_flags: 0x108, pipeline_state: base_state, terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 17, }, VulkanStaticDrawRange { + owner_scope: 0, first_index: 3, index_count: 3, material_index: 9, @@ -2692,6 +2918,7 @@ mod tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, ]; @@ -2731,6 +2958,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, + ©, + &authored, + &different_mip, + &different_extent, + ©, + ]; + for source in sources { + source.validate().expect("valid texture"); + } + let (unique, slots) = deduplicate_texture_sources(&sources); + assert_eq!(unique.len(), 4); + assert_eq!(slots, [0, 0, 1, 2, 3, 0]); + } + + #[test] + fn geometry_upload_preserves_prefix_until_allocation_grows() { + assert_eq!(geometry_upload_range(12, 8, 16), Ok(8..12)); + assert_eq!(geometry_upload_range(8, 8, 16), Ok(8..8)); + 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 { @@ -2754,6 +3068,7 @@ mod tests { #[test] fn geometry_updates_dirty_world_order_on_activation_and_deactivation() { let mut ranges = [VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 0, material_index: 0, @@ -2761,6 +3076,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]; @@ -2940,6 +3256,7 @@ mod tests { fn native_transparent_order_is_descending_and_keeps_fixed_pass_slots() { let ranges = (0..5) .map(|_| super::super::VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, material_index: 0, @@ -2947,6 +3264,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<_>>(); @@ -2964,6 +3282,7 @@ mod tests { fn inactive_world_range_is_not_reused_as_a_sort_slot() { let ranges = vec![ super::super::VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, material_index: 0, @@ -2971,9 +3290,11 @@ 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 { + owner_scope: 0, first_index: 3, index_count: 0, material_index: 0, @@ -2981,9 +3302,11 @@ 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 { + owner_scope: 0, first_index: 3, index_count: 3, material_index: 0, @@ -2991,6 +3314,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, ]; @@ -3008,6 +3332,7 @@ mod tests { fn native_equal_key_insertion_uses_inclusive_midpoint() { let ranges = (0..4) .map(|index| super::super::VulkanStaticDrawRange { + owner_scope: 0, first_index: index * 3, index_count: 3, material_index: 0, @@ -3015,6 +3340,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<_>>(); @@ -3101,6 +3427,7 @@ mod tests { const WEATHER: usize = 10; let ranges = (0..11) .map(|index| VulkanStaticDrawRange { + owner_scope: 0, first_index: index * 3, index_count: 3, material_index: 0, @@ -3108,6 +3435,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 +3473,76 @@ 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 { + owner_scope: 0, + 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 +3554,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..9fd0582 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -158,6 +158,8 @@ impl Default for VulkanDirectionalLight { /// One point light in the frame lighting contract. #[derive(Clone, Copy, Debug, PartialEq)] pub struct VulkanPointLight { + /// Zero lights the world; a nonzero scope lights only matching object ranges. + pub owner_scope: u32, /// World-space position. pub position: [f32; 3], /// Light RGB multiplier. @@ -173,6 +175,7 @@ pub struct VulkanPointLight { impl Default for VulkanPointLight { fn default() -> Self { Self { + owner_scope: 0, position: [0.0; 3], rgb: [0.0; 3], range: 0.0, @@ -192,7 +195,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; @@ -200,7 +204,7 @@ pub const VULKAN_FRAME_SLOT_COUNT: usize = 2; /// `uvec4` header bytes for the dynamic point-light storage buffer. pub const VULKAN_POINT_LIGHT_HEADER_BYTES: usize = 16; -/// Three `vec4` records per dynamically uploaded point light. +/// Two vec4 records, three attenuation floats and one u32 owner scope. pub const VULKAN_POINT_LIGHT_RECORD_BYTES: usize = 3 * 4 * std::mem::size_of::<f32>(); /// Bytes occupied by one material's base and overlay specular records. @@ -235,6 +239,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 +266,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 +293,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 +364,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>(); @@ -410,6 +447,8 @@ pub struct VulkanDynamicDrawRange { /// One indexed triangle-list draw retained from an original mesh batch. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct VulkanStaticDrawRange { + /// Root-object lighting scope; zero denotes terrain or unowned geometry. + pub owner_scope: u32, /// First index in the shared index buffer. pub first_index: u32, /// Number of indices in this draw. @@ -424,6 +463,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 +587,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 +598,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, @@ -751,13 +792,15 @@ impl VulkanStaticMesh { ], indices: vec![0, 1, 2], draw_ranges: vec![VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, 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, }], } @@ -835,6 +878,7 @@ mod static_mesh_tests { VulkanDirectionalLight::default(), ], point_light: VulkanPointLight { + owner_scope: 0, position: [28.0, 29.0, 30.0], rgb: [31.0, 32.0, 33.0], range: 34.0, @@ -846,6 +890,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 +910,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 +943,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: [ @@ -987,13 +1080,15 @@ mod static_mesh_tests { vertices: VulkanStaticMesh::smoke_triangle().vertices, indices: vec![0, 1], draw_ranges: vec![VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 2, 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, }], }; @@ -1018,23 +1113,27 @@ mod static_mesh_tests { fn material_descriptors_follow_source_batch_selectors() { let ranges = [ VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, 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 { + owner_scope: 0, first_index: 3, index_count: 3, 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 +1200,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 +1213,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 +1251,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 +1261,8 @@ mod static_mesh_tests { alpha_test: true, ..LegacyPipelineState::default() }, + terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 128, ..base }; @@ -1167,6 +1273,8 @@ mod static_mesh_tests { alpha_test: true, ..LegacyPipelineState::default() }, + terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, ..base }; @@ -1176,6 +1284,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 +1398,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 +1552,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 +1566,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 +1595,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() { diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs index b06bb26..8dda737 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -419,6 +419,32 @@ fn swapchain_plan_enables_transfer_source_when_surface_supports_it() { } #[test] +fn swapchain_plan_falls_back_to_immediate_without_overriding_supported_preference() { + let mut request = swapchain_request(); + request.present_modes = vec![ + vk::PresentModeKHR::IMMEDIATE.as_raw(), + vk::PresentModeKHR::FIFO.as_raw(), + ]; + assert_eq!( + plan_vulkan_swapchain(&request).unwrap().present_mode, + vk::PresentModeKHR::IMMEDIATE.as_raw() + ); + request.preferred_present_mode = vk::PresentModeKHR::FIFO.as_raw(); + assert_eq!( + plan_vulkan_swapchain(&request).unwrap().present_mode, + vk::PresentModeKHR::FIFO.as_raw() + ); + request + .present_modes + .push(vk::PresentModeKHR::MAILBOX.as_raw()); + request.preferred_present_mode = vk::PresentModeKHR::MAILBOX.as_raw(); + assert_eq!( + plan_vulkan_swapchain(&request).unwrap().present_mode, + vk::PresentModeKHR::MAILBOX.as_raw() + ); +} + +#[test] fn swapchain_plan_uses_fifo_and_current_extent_fallbacks() { let mut request = swapchain_request(); request.preferred_present_mode = vk::PresentModeKHR::IMMEDIATE.as_raw(); @@ -492,8 +518,9 @@ fn fragment_shader_dispatches_all_four_material_modes() { } #[test] -fn point_light_storage_payload_is_counted_and_packed_as_three_vec4s() { +fn point_light_storage_payload_preserves_integer_owner_scope_and_stride() { let lights = [VulkanPointLight { + owner_scope: 0x1234_5678, position: [1.0, 2.0, 3.0], rgb: [4.0, 5.0, 6.0], range: 7.0, @@ -506,7 +533,7 @@ fn point_light_storage_payload_is_counted_and_packed_as_three_vec4s() { VULKAN_POINT_LIGHT_HEADER_BYTES + VULKAN_POINT_LIGHT_RECORD_BYTES ); assert_eq!(u32::from_ne_bytes(bytes[0..4].try_into().unwrap()), 1); - let values = (0..12) + let values = (0..11) .map(|index| { let offset = VULKAN_POINT_LIGHT_HEADER_BYTES + index * std::mem::size_of::<f32>(); f32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap()) @@ -514,7 +541,11 @@ fn point_light_storage_payload_is_counted_and_packed_as_three_vec4s() { .collect::<Vec<_>>(); assert_eq!( values, - vec![1.0, 2.0, 3.0, 7.0, 4.0, 5.0, 6.0, 1.0, 8.0, 9.0, 10.0, 1.0] + vec![1.0, 2.0, 3.0, 7.0, 4.0, 5.0, 6.0, 1.0, 8.0, 9.0, 10.0] + ); + assert_eq!( + u32::from_ne_bytes(bytes[60..64].try_into().unwrap()), + 0x1234_5678 ); let many = vec![VulkanPointLight::default(); 33]; diff --git a/adapters/fparkan-render-vulkan/src/policy.rs b/adapters/fparkan-render-vulkan/src/policy.rs index e1f01c8..8eceb22 100644 --- a/adapters/fparkan-render-vulkan/src/policy.rs +++ b/adapters/fparkan-render-vulkan/src/policy.rs @@ -495,6 +495,8 @@ fn undefined_surface_format_override( fn select_present_mode(present_modes: &[i32], preferred: i32) -> Result<i32, VulkanSwapchainError> { if present_modes.contains(&preferred) { Ok(preferred) + } else if present_modes.contains(&vk::PresentModeKHR::IMMEDIATE.as_raw()) { + Ok(vk::PresentModeKHR::IMMEDIATE.as_raw()) } else if present_modes.contains(&vk::PresentModeKHR::FIFO.as_raw()) { Ok(vk::PresentModeKHR::FIFO.as_raw()) } else { |
