diff options
Diffstat (limited to 'adapters/fparkan-render-vulkan/src/ffi/smoke.rs')
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 1043 |
1 files changed, 714 insertions, 329 deletions
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 522f95f..a016645 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, + 0, + ); + } + device.device().cmd_end_render_pass(command_buffer); + if reflected { + let image = resources.reflection.color.as_ref().ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "reflection target", + }, + )?; + let barrier = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(image.image) + .subresource_range(super::color_subresource_range(1)); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::DependencyFlags::empty(), + &[], + &[], + &[barrier], + ); + } } - device.device().cmd_end_render_pass(command_buffer); + } + // SAFETY: The main pass has completed its writes to this acquired swapchain image. + unsafe { + let barrier = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .image(resources.images[image_index]) + .subresource_range(super::color_subresource_range(1)); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + vk::DependencyFlags::empty(), + &[], + &[], + &[barrier], + ); if self.readback_enabled { if let (Some(image), Some(readback)) = ( resources.images.get(image_index), @@ -2048,7 +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; @@ -2682,6 +2905,7 @@ mod tests { batch_flags: 0x108, pipeline_state: base_state, terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 17, }, VulkanStaticDrawRange { @@ -2692,6 +2916,7 @@ mod tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, ]; @@ -2731,6 +2956,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 { @@ -2761,6 +3073,7 @@ mod tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }]; let mut capacities = [6]; @@ -2947,6 +3260,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }) .collect::<Vec<_>>(); @@ -2971,6 +3285,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, super::super::VulkanStaticDrawRange { @@ -2981,6 +3296,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, super::super::VulkanStaticDrawRange { @@ -2991,6 +3307,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, ]; @@ -3015,6 +3332,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }) .collect::<Vec<_>>(); @@ -3108,6 +3426,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 +3464,75 @@ mod tests { } #[test] + fn control_fx_queue_follows_opaque_and_sky_without_reactivating_empty_ranges() { + const SCREEN_GRADIENT: usize = 0; + const CONTROL_FX: usize = 1; + const TERRAIN: usize = 2; + const BUILDING: usize = 3; + const SHADOW: usize = 4; + const NEBULA: usize = 5; + const DOME: usize = 6; + const CLOUDS: usize = 7; + const EMPTY_CONTROL_FX: usize = 8; + const SUN: usize = 9; + const WEATHER: usize = 10; + let mut ranges = (0..11) + .map(|index| VulkanStaticDrawRange { + first_index: index * 3, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: LegacyPipelineState::default(), + terrain_base_only: false, + terrain_reflection: false, + alpha_test_reference: 0, + }) + .collect::<Vec<_>>(); + ranges[EMPTY_CONTROL_FX].index_count = 0; + let mut world_queue = vec![None; ranges.len()]; + world_queue[TERRAIN] = Some(false); + world_queue[BUILDING] = Some(false); + world_queue[CONTROL_FX] = Some(true); + world_queue[EMPTY_CONTROL_FX] = Some(true); + let mut sort_keys = vec![None; ranges.len()]; + sort_keys[CONTROL_FX] = Some(20.0); + sort_keys[EMPTY_CONTROL_FX] = Some(30.0); + + let order = sorted_world_draw_order( + ranges.len(), + &ranges, + &world_queue, + &sort_keys, + &[NEBULA, DOME, CLOUDS], + ); + assert_eq!( + order, + vec![ + SCREEN_GRADIENT, + TERRAIN, + BUILDING, + SHADOW, + NEBULA, + DOME, + CLOUDS, + CONTROL_FX, + EMPTY_CONTROL_FX, + SUN, + WEATHER, + ] + ); + assert_eq!(ranges[EMPTY_CONTROL_FX].index_count, 0); + } + + #[test] fn material_category_and_transparency_preserve_world_state_in_either_order() { let original = LegacyPipelineState { blend: LegacyBlendMode::Opaque, depth: LegacyDepthMode::TestWrite, cull: LegacyCullMode::FrontFace, alpha_test: false, + depth_bias: LegacyDepthBias::ProjectedDecal, }; let (base, category_first) = world_material_pipeline_states(original, 1, Some(true)).expect("category 1"); @@ -3162,14 +3544,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), |
