From 5a48d0b26685526eaa2c7c824e6e0828177246b0 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 18 Oct 2026 11:23:35 +0400 Subject: [PATCH] feat(render): upload dynamic point lights per frame --- .../shaders/triangle.vert | 36 ++-- .../shaders/triangle.vert.spv | Bin 19136 -> 20096 bytes adapters/fparkan-render-vulkan/src/ffi.rs | 22 +- .../src/ffi/resources.rs | 202 +++++++++++++++++- .../fparkan-render-vulkan/src/ffi/smoke.rs | 143 +++++++++++-- .../src/ffi/smoke_types.rs | 19 ++ .../src/ffi/swapchain_resources.rs | 163 +++++++++++++- .../fparkan-render-vulkan/src/ffi/tests.rs | 45 ++++ 8 files changed, 575 insertions(+), 55 deletions(-) diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert index 83a1751..d1e2e04 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert @@ -39,6 +39,17 @@ layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular { vec4 overlay; } material_specular; +struct PointLightRecord { + vec4 position_range; + vec4 rgb_active; + vec4 coefficients_active; +}; + +layout(std430, set = 0, binding = 6) readonly buffer PointLightBuffer { + uvec4 header; + PointLightRecord records[]; +} point_light_buffer; + layout(push_constant) uniform MaterialConstants { layout(offset = 0) float alpha_cutoff; layout(offset = 4) float diffuse_alpha; @@ -180,29 +191,30 @@ LightingTerms native_material_lighting( 1.0 ); } - vec4 point_position_range = frame.point_position_range; - vec4 point_rgb_active = frame.point_rgb_active; - vec4 point_coefficients_active = frame.point_coefficients_active; - vec3 to_light = point_position_range.xyz - world_position; - float distance_squared = dot(to_light, to_light); - float range = point_position_range.w; - if (point_rgb_active.w > 0.5 && range > 0.0 && distance_squared <= range * range) { + for (uint index = 0u; index < point_light_buffer.header.x; ++index) { + PointLightRecord point_light = point_light_buffer.records[index]; + 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; + if (point_light.rgb_active.w <= 0.5 || range <= 0.0 || distance_squared > range * range) { + continue; + } float distance_to_light = sqrt(distance_squared); if (distance_to_light > 0.000001) { vec3 light_direction = to_light / distance_to_light; float ndotl = dot(normal, light_direction); if (ndotl > 0.0) { float t = (range - distance_to_light) / range; - float attenuation = point_coefficients_active.x - + point_coefficients_active.y * t - + point_coefficients_active.z * t * t; + float attenuation = point_light.coefficients_active.x + + point_light.coefficients_active.y * t + + point_light.coefficients_active.z * t * t; attenuation = max(attenuation, 0.0); - accumulated += ndotl * directional_rgb * point_rgb_active.rgb * attenuation; + accumulated += ndotl * directional_rgb * point_light.rgb_active.rgb * attenuation; specular += specular_from_light( normal, view_direction, light_direction, - point_rgb_active.rgb, + point_light.rgb_active.rgb, specular_rgb, specular_power, attenuation diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv index 535bde0dde838809f991c2721ebb52f2fc046d28..f74b1f81e5a1360c88f6f3752ec1e3d8e9ae40ff 100644 GIT binary patch literal 20096 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 Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> { - const FRAME_SLOTS: u64 = 2; const FRAME_BYTES: u64 = super::VULKAN_FRAME_UNIFORM_BYTES as u64; let limits = // SAFETY: The physical device belongs to this live instance and the query returns a value copy. @@ -532,12 +531,11 @@ pub(super) fn create_frame_uniform_buffer( }, )? / alignment * alignment; - let size = - stride - .checked_mul(FRAME_SLOTS) - .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { - context: "frame uniform buffer size overflow", - })?; + let size = stride + .checked_mul(super::VULKAN_FRAME_SLOT_COUNT as u64) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "frame uniform buffer size overflow", + })?; let bytes = vec![ 0_u8; usize::try_from(size).map_err(|_| { @@ -556,6 +554,192 @@ pub(super) fn create_frame_uniform_buffer( Ok((buffer, stride)) } +/// Returns the per-frame descriptor range, aligned slot stride, total bytes, +/// and maximum light count accepted by the device's storage-buffer limit. +pub(super) fn point_light_buffer_layout( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + capacity: usize, +) -> Result<(u64, u64, u64, usize), VulkanSmokeRendererError> { + let limits = + // SAFETY: The physical device belongs to this live instance and the query returns a value copy. + unsafe { instance.instance.get_physical_device_properties(device.physical_device()) } + .limits; + let max_range = u64::from(limits.max_storage_buffer_range); + let header_bytes = super::VULKAN_POINT_LIGHT_HEADER_BYTES as u64; + let record_bytes = super::VULKAN_POINT_LIGHT_RECORD_BYTES as u64; + if max_range < header_bytes { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "device storage-buffer range cannot hold the point-light header", + }); + } + let max_count = usize::try_from((max_range - header_bytes) / record_bytes).map_err(|_| { + VulkanSmokeRendererError::InvalidPointLights { + context: "device point-light capacity exceeds address space", + } + })?; + if capacity > max_count { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light count exceeds maxStorageBufferRange", + }); + } + let capacity_u64 = + u64::try_from(capacity).map_err(|_| VulkanSmokeRendererError::InvalidPointLights { + context: "point-light capacity exceeds address space", + })?; + let range = header_bytes + .checked_add(record_bytes.checked_mul(capacity_u64).ok_or( + VulkanSmokeRendererError::InvalidPointLights { + context: "point-light descriptor range overflow", + }, + )?) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light descriptor range overflow", + })?; + let alignment = u64::from(limits.min_storage_buffer_offset_alignment).max(1); + let stride = + range + .checked_add(alignment - 1) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light frame stride overflow", + })? + / alignment + * alignment; + let total = stride + .checked_mul(u64::try_from(super::VULKAN_FRAME_SLOT_COUNT).unwrap_or(u64::MAX)) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light buffer size overflow", + })?; + if stride + .checked_mul(u64::try_from(super::VULKAN_FRAME_SLOT_COUNT - 1).unwrap_or(u64::MAX)) + .is_none_or(|last_offset| last_offset > u64::from(u32::MAX)) + { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light dynamic offset exceeds Vulkan's 32-bit limit", + }); + } + Ok((range, stride, total, max_count)) +} + +/// Allocates a fence-safe point-light SSBO with one aligned segment per frame. +pub(super) fn create_point_light_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + capacity: usize, +) -> Result<(VulkanAllocatedBuffer, u64, u64, usize), VulkanSmokeRendererError> { + let (range, stride, total, max_count) = point_light_buffer_layout(instance, device, capacity)?; + let bytes = + vec![ + 0_u8; + usize::try_from(total).map_err(|_| VulkanSmokeRendererError::InvalidPointLights { + context: "point-light buffer size exceeds address space", + })? + ]; + let buffer = create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::STORAGE_BUFFER, + "point-light storage buffer", + )?; + Ok((buffer, range, stride, max_count)) +} + +pub(super) fn encode_point_lights( + lights: &[VulkanPointLight], +) -> Result, VulkanSmokeRendererError> { + let count = + u32::try_from(lights.len()).map_err(|_| VulkanSmokeRendererError::InvalidPointLights { + context: "point-light count exceeds the storage header", + })?; + let total = super::VULKAN_POINT_LIGHT_HEADER_BYTES + .checked_add( + super::VULKAN_POINT_LIGHT_RECORD_BYTES + .checked_mul(lights.len()) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light upload size overflow", + })?, + ) + .ok_or(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light upload size overflow", + })?; + let mut bytes = vec![0; total]; + bytes[0..4].copy_from_slice(&count.to_ne_bytes()); + let mut offset = super::VULKAN_POINT_LIGHT_HEADER_BYTES; + for light in lights { + for value in light + .position + .into_iter() + .chain([light.range]) + .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::()] + .copy_from_slice(&value.to_ne_bytes()); + offset += std::mem::size_of::(); + } + } + debug_assert_eq!(offset, total); + Ok(bytes) +} + +/// Uploads the current light set into the frame slot whose fence has completed. +pub(super) fn write_point_light_buffer( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + stride: u64, + frame_index: usize, + lights: &[VulkanPointLight], +) -> Result<(), VulkanSmokeRendererError> { + let slot = + u64::try_from(frame_index).map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "point-light frame slot index", + })?; + if frame_index >= super::VULKAN_FRAME_SLOT_COUNT { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "point-light frame slot is out of bounds", + }); + } + let offset = stride + .checked_mul(slot) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "point-light frame slot offset", + })?; + let bytes = encode_point_lights(lights)?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > stride { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light upload exceeds the allocated frame slot", + }); + } + let total = stride + .checked_mul(u64::try_from(super::VULKAN_FRAME_SLOT_COUNT).unwrap_or(u64::MAX)) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "point-light buffer mapped size", + })?; + let offset = + usize::try_from(offset).map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "point-light frame slot offset exceeds address space", + })?; + // Map the whole allocation because the Vulkan minimum map alignment may + // exceed minStorageBufferOffsetAlignment used by the dynamic frame offset. + let mapped = unsafe { + device + .device() + .map_memory(buffer.memory, 0, total, vk::MemoryMapFlags::empty()) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkMapMemory(point-light buffer)", + result, + })?; + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::().add(offset), bytes.len()); + device.device().unmap_memory(buffer.memory); + } + Ok(()) +} + /// Allocates one aligned storage-buffer record for every material descriptor. /// /// The descriptor uses a dynamic storage-buffer offset. Keeping the stride diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index ac359c2..b3d5ab3 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -11,14 +11,14 @@ use super::{ create_validation_messenger, create_vertex_buffer, create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request, create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent, destroy_allocated_buffer, destroy_allocated_image, - destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes, - update_static_texture_image, write_frame_uniform, write_material_specular_buffer, - write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, VulkanAllocatedBuffer, - VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact, - VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, - VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, - VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger, - VulkanValidationReport, + destroy_swapchain_resources, ensure_point_light_capacity, plan_vulkan_surface, + readback_buffer_bytes, update_static_texture_image, write_frame_uniform, + write_material_specular_buffer, write_point_light_buffer, write_static_mesh_index_buffer, + write_static_mesh_vertex_buffer, VulkanAllocatedBuffer, VulkanInstanceConfig, + VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, + VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanSurfaceProbe, VulkanSwapchainProbe, + VulkanSwapchainResources, VulkanValidationMessenger, VulkanValidationReport, }; use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION; use crate::shader::validate_triangle_shaders; @@ -440,6 +440,8 @@ impl VulkanSmokeRenderer { readback_enabled: false, camera: create_info.camera, frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera), + point_lights: Vec::new(), + point_lights_explicit: false, frame_sync: Vec::new(), images_in_flight: Vec::new(), current_frame: 0, @@ -576,6 +578,41 @@ impl VulkanSmokeRenderer { Ok(()) } + /// Replaces the complete dynamic point-light set for subsequent frames. + /// + /// The storage buffer grows on demand and rejects counts beyond the + /// device's `maxStorageBufferRange`; it never truncates scene lights. + /// Values are uploaded to the current fence-safe frame slot by + /// [`Self::draw_frame`]. + pub fn set_point_lights( + &mut self, + point_lights: &[super::VulkanPointLight], + ) -> Result<(), VulkanSmokeRendererError> { + let max_count = self.resources_ref()?.max_point_light_count; + if point_lights.len() > max_count { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light count exceeds maxStorageBufferRange", + }); + } + if point_lights.iter().any(|light| { + !light + .position + .into_iter() + .chain(light.rgb) + .chain([light.range]) + .chain(light.coefficients) + .all(f32::is_finite) + }) { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "non-finite point-light field", + }); + } + self.point_lights.clear(); + self.point_lights.extend_from_slice(point_lights); + self.point_lights_explicit = true; + Ok(()) + } + /// Enables the optional swapchain image copy used by screenshot/readback /// callers. Runtime frames leave it disabled to avoid a transfer every /// frame. @@ -1318,6 +1355,44 @@ impl VulkanSmokeRenderer { context: "vkWaitForFences", result: error, })?; + let legacy_point_light = self.frame_uniforms.point_light; + let point_light_count = if self.point_lights_explicit { + self.point_lights.len() + } else { + usize::from(legacy_point_light.active) + }; + let needs_point_light_growth = + point_light_count > self.resources_ref()?.point_light_capacity; + 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, + } + })?; + let instance = + self.instance + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "Vulkan instance during point-light growth", + })?; + let device = + self.device + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "logical device during point-light growth", + })?; + let resources = self.swapchain_resources.as_mut().ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "swapchain resources during point-light growth", + }, + )?; + ensure_point_light_capacity(instance, device, resources, point_light_count)?; + } if self.material_specular_dirty { // The storage buffer is shared by all in-flight frames. Wait for // every prior use before replacing the records so a setter cannot @@ -1377,14 +1452,42 @@ impl VulkanSmokeRenderer { } self.images_in_flight[image_index_usize] = in_flight_fence; let frame_bytes = self.frame_uniforms.to_ne_bytes(); - let resources = self.resources_ref()?; - write_frame_uniform( - self.device_ref()?, - &resources.frame_uniform_buffer, - resources.frame_uniform_stride, - self.current_frame, - &frame_bytes, - )?; + { + let explicit_point_lights = self.point_lights_explicit; + let legacy_point_light = self.frame_uniforms.point_light; + let point_lights = if explicit_point_lights { + self.point_lights.as_slice() + } else if legacy_point_light.active { + std::slice::from_ref(&legacy_point_light) + } else { + &[] + }; + let device = + self.device + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "logical device during frame upload", + })?; + let resources = self.swapchain_resources.as_ref().ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "swapchain resources during frame upload", + }, + )?; + write_frame_uniform( + device, + &resources.frame_uniform_buffer, + resources.frame_uniform_stride, + self.current_frame, + &frame_bytes, + )?; + write_point_light_buffer( + device, + &resources.point_light_buffer, + resources.point_light_stride, + self.current_frame, + point_lights, + )?; + } let render_finished = self .resources_ref()? .render_finished @@ -1693,6 +1796,12 @@ impl VulkanSmokeRenderer { .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", @@ -1704,7 +1813,7 @@ impl VulkanSmokeRenderer { resources.pipeline_layout, 0, std::slice::from_ref(descriptor_set), - &[frame_offset, material_offset], + &[frame_offset, material_offset, point_light_offset], ); let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes(); device.device().cmd_push_constants( diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 7a8837d..8abc53b 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -194,6 +194,15 @@ impl Default for VulkanPointLight { /// state. pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368; +/// Number of CPU/GPU frame slots shared by the swapchain renderer. +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. +pub const VULKAN_POINT_LIGHT_RECORD_BYTES: usize = 3 * 4 * std::mem::size_of::(); + /// Bytes occupied by one material's base and overlay specular records. /// /// Each record is one `vec4`: RGB followed by the native integer power stored @@ -1359,6 +1368,11 @@ pub enum VulkanSmokeRendererError { /// Validation failure detail. context: &'static str, }, + /// Submitted dynamic point-light data exceeds the Vulkan storage range or contains invalid values. + InvalidPointLights { + /// Validation failure detail. + context: &'static str, + }, /// The submitted static texture cannot be represented by this path. InvalidStaticTexture { /// Validation failure detail. @@ -1389,6 +1403,7 @@ impl std::fmt::Display for VulkanSmokeRendererError { Self::InvalidStaticCamera { context } => { write!(f, "invalid static camera: {context}") } + Self::InvalidPointLights { context } => write!(f, "invalid point lights: {context}"), Self::InvalidStaticTexture { context } => { write!(f, "invalid static texture: {context}") } @@ -1454,6 +1469,10 @@ pub struct VulkanSmokeRenderer { pub(super) readback_enabled: bool, pub(super) camera: VulkanStaticCamera, pub(super) frame_uniforms: VulkanFrameUniforms, + /// Dynamically sized point-light records submitted by the client. + pub(super) point_lights: Vec, + /// Whether `set_point_lights` has replaced legacy `frame_uniforms.point_light`. + pub(super) point_lights_explicit: bool, pub(super) frame_sync: Vec, pub(super) images_in_flight: Vec, pub(super) current_frame: usize, diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index b8049d3..89ca43a 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -9,9 +9,10 @@ use std::collections::BTreeMap; use super::{ color_subresource_range, create_depth_attachment, create_material_specular_buffer, - destroy_depth_attachment, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, - VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe, - TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES, + create_point_light_buffer, destroy_depth_attachment, VulkanAllocatedBuffer, + VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe, VulkanLogicalDeviceProbe, + VulkanSmokeRendererError, VulkanSwapchainProbe, TRIANGLE_FRAGMENT_SHADER_WORDS, + TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES, VULKAN_MATERIAL_SPECULAR_RECORD_BYTES, }; @@ -33,6 +34,11 @@ pub(super) struct VulkanSwapchainResources { pub(super) frame_uniform_stride: u64, pub(super) material_specular_buffer: VulkanAllocatedBuffer, pub(super) material_specular_stride: u64, + pub(super) point_light_buffer: VulkanAllocatedBuffer, + pub(super) point_light_range: u64, + pub(super) point_light_stride: u64, + pub(super) point_light_capacity: usize, + pub(super) max_point_light_count: usize, /// Live graphics pipelines indexed by canonical backend-neutral state. pub(super) pipelines: BTreeMap, pub(super) framebuffers: Vec, @@ -73,6 +79,71 @@ pub(super) fn update_material_texture_descriptor( Ok(()) } +/// Grows the dynamic light SSBO when the scene adds lights beyond its current +/// capacity. The caller must idle the device before updating these descriptors. +pub(super) fn ensure_point_light_capacity( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + resources: &mut VulkanSwapchainResources, + needed: usize, +) -> Result<(), VulkanSmokeRendererError> { + if needed <= resources.point_light_capacity { + return Ok(()); + } + if needed > resources.max_point_light_count { + return Err(VulkanSmokeRendererError::InvalidPointLights { + context: "point-light count exceeds maxStorageBufferRange", + }); + } + let target = resources + .point_light_capacity + .saturating_mul(2) + .max(1) + .max(needed) + .min(resources.max_point_light_count); + let (new_buffer, new_range, new_stride, max_count) = + create_point_light_buffer(instance, device, target)?; + if max_count != resources.max_point_light_count { + unsafe { + device.device().destroy_buffer(new_buffer.buffer, None); + device.device().free_memory(new_buffer.memory, None); + } + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "point-light capacity changed for the active device", + }); + } + let info = vk::DescriptorBufferInfo::default() + .buffer(new_buffer.buffer) + .offset(0) + .range(new_range); + let writes = resources + .descriptor_sets + .iter() + .copied() + .map(|descriptor_set| { + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(6) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .buffer_info(std::slice::from_ref(&info)) + }) + .collect::>(); + // SAFETY: The caller waited for device idle before replacing a descriptor + // that submitted command buffers may reference. + unsafe { device.device().update_descriptor_sets(&writes, &[]) }; + let old_buffer = std::mem::replace(&mut resources.point_light_buffer, new_buffer); + resources.point_light_range = new_range; + resources.point_light_stride = new_stride; + resources.point_light_capacity = target; + // SAFETY: All prior submissions have completed and descriptors now point + // at the replacement allocation. + unsafe { + device.device().destroy_buffer(old_buffer.buffer, None); + device.device().free_memory(old_buffer.memory, None); + } + Ok(()) +} + struct PartialSwapchainResources { image_views: Vec, readback_buffers: Vec, @@ -87,6 +158,11 @@ struct PartialSwapchainResources { frame_uniform_stride: u64, material_specular_buffer: Option, material_specular_stride: u64, + point_light_buffer: Option, + point_light_range: u64, + point_light_stride: u64, + point_light_capacity: usize, + max_point_light_count: usize, pipelines: BTreeMap, framebuffers: Vec, command_buffers: Vec, @@ -133,6 +209,11 @@ pub(super) fn create_swapchain_resources( frame_uniform_stride: 0, material_specular_buffer: None, material_specular_stride: 0, + point_light_buffer: None, + point_light_range: 0, + point_light_stride: 0, + point_light_capacity: 0, + max_point_light_count: 0, pipelines: BTreeMap::new(), framebuffers: Vec::new(), command_buffers: Vec::new(), @@ -173,6 +254,20 @@ pub(super) fn create_swapchain_resources( }; partial.material_specular_stride = material_specular_stride; partial.material_specular_buffer = Some(material_specular_buffer); + let point_light_capacity = 1; + let (point_light_buffer, point_light_range, point_light_stride, max_point_light_count) = + match create_point_light_buffer(instance, device, point_light_capacity) { + Ok(buffer) => buffer, + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + }; + partial.point_light_range = point_light_range; + partial.point_light_stride = point_light_stride; + partial.point_light_capacity = point_light_capacity; + partial.max_point_light_count = max_point_light_count; + partial.point_light_buffer = Some(point_light_buffer); let ( depth_attachment, render_pass, @@ -199,6 +294,11 @@ pub(super) fn create_swapchain_resources( .material_specular_buffer .as_ref() .expect("material specular buffer is present before descriptor creation"), + partial + .point_light_buffer + .as_ref() + .expect("point-light buffer is present before descriptor creation"), + partial.point_light_range, ) { Ok(bundle) => bundle, Err(error) => { @@ -320,6 +420,13 @@ pub(super) fn create_swapchain_resources( .material_specular_buffer .expect("material specular buffer is present after successful swapchain setup"), material_specular_stride: partial.material_specular_stride, + point_light_buffer: partial + .point_light_buffer + .expect("point-light buffer is present after successful swapchain setup"), + point_light_range: partial.point_light_range, + point_light_stride: partial.point_light_stride, + point_light_capacity: partial.point_light_capacity, + max_point_light_count: partial.max_point_light_count, pipelines: partial.pipelines, framebuffers: partial.framebuffers, command_buffers: partial.command_buffers, @@ -349,6 +456,8 @@ fn create_swapchain_pipeline_bundle( depth_request: DepthStencilSupport, frame_uniform_buffer: &VulkanAllocatedBuffer, material_specular_buffer: &VulkanAllocatedBuffer, + point_light_buffer: &VulkanAllocatedBuffer, + point_light_range: u64, ) -> Result< ( VulkanDepthAttachment, @@ -377,6 +486,8 @@ fn create_swapchain_pipeline_bundle( texture_slot_indices, frame_uniform_buffer, material_specular_buffer, + point_light_buffer, + point_light_range, ) .inspect_err(|_| { // SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure. @@ -603,6 +714,8 @@ fn create_texture_descriptor_bundle( texture_slot_indices: &[usize], frame_uniform_buffer: &VulkanAllocatedBuffer, material_specular_buffer: &VulkanAllocatedBuffer, + point_light_buffer: &VulkanAllocatedBuffer, + point_light_range: u64, ) -> Result< ( vk::DescriptorSetLayout, @@ -615,6 +728,7 @@ fn create_texture_descriptor_bundle( const TEXTURES_PER_MATERIAL: usize = 4; const FRAME_BINDING: u32 = 4; const MATERIAL_SPECULAR_BINDING: u32 = 5; + const POINT_LIGHT_BINDING: u32 = 6; let mut bindings = (0..TEXTURES_PER_MATERIAL) .map(|binding| { vk::DescriptorSetLayoutBinding::default() @@ -638,6 +752,13 @@ fn create_texture_descriptor_bundle( .descriptor_count(1) .stage_flags(vk::ShaderStageFlags::VERTEX), ); + bindings.push( + vk::DescriptorSetLayoutBinding::default() + .binding(POINT_LIGHT_BINDING) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX), + ); let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings); // SAFETY: The layout description is stack-owned and references no external memory. let layout = unsafe { @@ -676,10 +797,17 @@ fn create_texture_descriptor_bundle( let frame_pool_size = vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) .descriptor_count(material_count); - let material_specular_pool_size = vk::DescriptorPoolSize::default() + let storage_pool_size = vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) - .descriptor_count(material_count); - let pool_sizes = [pool_size, frame_pool_size, material_specular_pool_size]; + .descriptor_count(material_count.checked_mul(2).ok_or_else(|| { + // SAFETY: The layout was created above on this device and is + // rolled back before reporting an impossible descriptor count. + unsafe { device.device().destroy_descriptor_set_layout(layout, None) }; + VulkanSmokeRendererError::InvariantViolation { + context: "dynamic storage descriptor count overflow", + } + })?); + let pool_sizes = [pool_size, frame_pool_size, storage_pool_size]; let pool_info = vk::DescriptorPoolCreateInfo::default() .pool_sizes(&pool_sizes) .max_sets(material_count); @@ -751,7 +879,11 @@ fn create_texture_descriptor_bundle( .buffer(material_specular_buffer.buffer) .offset(0) .range(VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64); - let mut writes = Vec::with_capacity(descriptor_sets.len() * (TEXTURES_PER_MATERIAL + 2)); + let point_light_info = vk::DescriptorBufferInfo::default() + .buffer(point_light_buffer.buffer) + .offset(0) + .range(point_light_range); + let mut writes = Vec::with_capacity(descriptor_sets.len() * (TEXTURES_PER_MATERIAL + 3)); for (set_index, descriptor_set) in descriptor_sets.iter().copied().enumerate() { for binding in 0..TEXTURES_PER_MATERIAL { let image_info = &image_infos[set_index * TEXTURES_PER_MATERIAL + binding]; @@ -777,6 +909,13 @@ fn create_texture_descriptor_bundle( .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) .buffer_info(std::slice::from_ref(&material_specular_info)), ); + writes.push( + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(POINT_LIGHT_BINDING) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .buffer_info(std::slice::from_ref(&point_light_info)), + ); } // SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition. unsafe { device.device().update_descriptor_sets(&writes, &[]) }; @@ -1114,6 +1253,12 @@ pub(super) fn destroy_swapchain_resources( device .device() .free_memory(resources.material_specular_buffer.memory, None); + device + .device() + .destroy_buffer(resources.point_light_buffer.buffer, None); + device + .device() + .free_memory(resources.point_light_buffer.memory, None); } } @@ -1177,5 +1322,9 @@ fn destroy_partial_swapchain_resources( device.device().destroy_buffer(buffer.buffer, None); device.device().free_memory(buffer.memory, None); } + if let Some(buffer) = partial.point_light_buffer { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } } } diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs index 4ed61f7..b06bb26 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -491,6 +491,51 @@ fn fragment_shader_dispatches_all_four_material_modes() { assert!(!fragment.contains("material_mode > 2.5")); } +#[test] +fn point_light_storage_payload_is_counted_and_packed_as_three_vec4s() { + let lights = [VulkanPointLight { + position: [1.0, 2.0, 3.0], + rgb: [4.0, 5.0, 6.0], + range: 7.0, + coefficients: [8.0, 9.0, 10.0], + active: true, + }]; + let bytes = super::resources::encode_point_lights(&lights).expect("point-light payload"); + assert_eq!( + bytes.len(), + 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) + .map(|index| { + let offset = VULKAN_POINT_LIGHT_HEADER_BYTES + index * std::mem::size_of::(); + f32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap()) + }) + .collect::>(); + 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] + ); + + let many = vec![VulkanPointLight::default(); 33]; + let bytes = super::resources::encode_point_lights(&many).expect("uncapped point-light payload"); + assert_eq!(u32::from_ne_bytes(bytes[0..4].try_into().unwrap()), 33); + assert_eq!( + bytes.len(), + VULKAN_POINT_LIGHT_HEADER_BYTES + 33 * VULKAN_POINT_LIGHT_RECORD_BYTES + ); +} + +#[test] +fn vertex_shader_sums_the_runtime_point_light_array() { + let vertex = include_str!("../../shaders/triangle.vert"); + assert!( + vertex.contains("layout(std430, set = 0, binding = 6) readonly buffer PointLightBuffer") + ); + assert!(vertex.contains("for (uint index = 0u; index < point_light_buffer.header.x; ++index)")); + assert!(!vertex.contains("if (point_rgb_active.w")); +} + #[test] fn native_specular_reference_uses_squared_power_and_total_overflow_curve() { fn reference_power(cosine: f32, power: u8) -> f32 {