From a8faba8aad441e87f8a8b94c148c01f9c075d621 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 11 Oct 2026 13:12:50 +0400 Subject: feat: complete map viewer scene and static CTL pose preview Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling. --- .../fparkan-render-vulkan/shaders/triangle.frag | 126 +- .../shaders/triangle.frag.spv | Bin 1184 -> 10388 bytes .../fparkan-render-vulkan/shaders/triangle.vert | 264 +++- .../shaders/triangle.vert.spv | Bin 1432 -> 18580 bytes adapters/fparkan-render-vulkan/src/asset_mesh.rs | 934 +++++++++++- adapters/fparkan-render-vulkan/src/ffi.rs | 39 +- .../fparkan-render-vulkan/src/ffi/resources.rs | 694 ++++++++- adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 1570 ++++++++++++++++++-- .../fparkan-render-vulkan/src/ffi/smoke_types.rs | 671 ++++++++- .../src/ffi/swapchain_resources.rs | 466 ++++-- adapters/fparkan-render-vulkan/src/ffi/tests.rs | 66 +- adapters/fparkan-render-vulkan/src/policy.rs | 19 +- 12 files changed, 4534 insertions(+), 315 deletions(-) (limited to 'adapters') diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag index bcc3d43..89896cf 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag @@ -2,18 +2,132 @@ layout(location = 0) in vec3 in_color; layout(location = 1) in vec2 in_uv; +layout(location = 2) in vec2 in_detail_uv; +layout(location = 3) in float in_overlay_alpha; +layout(location = 4) in float in_fog_factor; +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 = 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(push_constant) uniform AlphaTestConstants { - layout(offset = 64) - float alpha_cutoff; -} alpha_test; +layout(std140, set = 0, binding = 4) uniform FrameConstants { + mat4 clip_from_world; + vec4 directional_direction[4]; + vec4 directional_rgb[4]; + vec4 directional_coefficients[4]; + vec4 point_position_range; + vec4 point_rgb_active; + vec4 point_coefficients_active; + vec4 lighting_floor; + vec4 fog_color; + vec4 fog_distances; + vec4 camera_position; +} frame; + +layout(push_constant) uniform MaterialConstants { + layout(offset = 0) float alpha_cutoff; + layout(offset = 4) float diffuse_alpha; + layout(offset = 8) float overlay_diffuse_alpha; + layout(offset = 12) float directional_r; + layout(offset = 16) float directional_g; + layout(offset = 20) float directional_b; + layout(offset = 24) float additive_r; + layout(offset = 28) float additive_g; + layout(offset = 32) float additive_b; + layout(offset = 36) float overlay_directional_r; + layout(offset = 40) float overlay_directional_g; + layout(offset = 44) float overlay_directional_b; + layout(offset = 48) float overlay_additive_r; + layout(offset = 52) float overlay_additive_g; + layout(offset = 56) float overlay_additive_b; + layout(offset = 60) float base_page_x; + layout(offset = 64) float base_page_y; + layout(offset = 68) float base_page_w; + layout(offset = 72) float base_page_h; + layout(offset = 76) float detail_page_x; + layout(offset = 80) float detail_page_y; + layout(offset = 84) float detail_page_w; + layout(offset = 88) float detail_page_h; + layout(offset = 92) float overlay_page_x; + layout(offset = 96) float overlay_page_y; + layout(offset = 100) float overlay_page_w; + layout(offset = 104) float overlay_page_h; + layout(offset = 108) float overlay_detail_page_x; + layout(offset = 112) float overlay_detail_page_y; + layout(offset = 116) float overlay_detail_page_w; + layout(offset = 120) float overlay_detail_page_h; + layout(offset = 124) float material_mode; +} material; void main() { - out_color = texture(base_color, in_uv) * vec4(in_color, 1.0); - if (out_color.a < alpha_test.alpha_cutoff) { + 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) + + in_detail_uv * vec2(material.detail_page_w, material.detail_page_h); + vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y) + + in_uv * vec2(material.overlay_page_w, material.overlay_page_h); + vec2 overlay_detail_uv = vec2(material.overlay_detail_page_x, material.overlay_detail_page_y) + + in_detail_uv * vec2(material.overlay_detail_page_w, material.overlay_detail_page_h); + vec4 base = texture(base_color, base_uv); + 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 + // mix(TEX0.rgb, TEX1.rgb, TEX1.a) * DIFFUSE.rgb and carries DIFFUSE.a + // through as the pass alpha. + if (combiner_mode == 2) { + vec3 sky_rgb = mix(base.rgb, detail.rgb, clamp(detail.a, 0.0, 1.0)); + out_color = vec4(sky_rgb * in_color, clamp(in_overlay_alpha, 0.0, 1.0)); + if (out_color.a < material.alpha_cutoff) { + discard; + } + return; + } + + // Native terrain mode 3 combines the base texture and the lightmap + // without the regular terrain 2x combiner or NdotL calculation. + if (combiner_mode == 3) { + vec3 lightmap_lighting = max( + vec3(material.additive_r, material.additive_g, material.additive_b), + frame.lighting_floor.rgb + ); + vec3 vertex_rgb = base.rgb * detail.rgb * lightmap_lighting * in_color; + vec3 final_rgb = mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor); + out_color = vec4(final_rgb, base.a * material.diffuse_alpha); + if (out_color.a < material.alpha_cutoff) { + discard; + } + return; + } + + float overlay_alpha = clamp( + in_overlay_alpha * material.overlay_diffuse_alpha, + 0.0, + 1.0 + ); + vec3 base_rgb = 2.0 * base.rgb * detail.rgb; + vec3 overlay_rgb = 2.0 * overlay.rgb * overlay_detail_sample.rgb; + vec3 base_lit_rgb = base_rgb * in_base_diffuse + in_base_specular; + vec3 overlay_lit_rgb = overlay_rgb * in_overlay_diffuse + in_overlay_specular; + vec3 lit_rgb = mix(base_lit_rgb, overlay_lit_rgb, overlay_alpha); + vec3 vertex_rgb = lit_rgb * in_color; + vec3 final_rgb = combiner_mode == 0 + ? mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor) + : vertex_rgb; + float final_alpha = combiner_mode == 1 + ? base.a * material.diffuse_alpha * in_overlay_alpha + : base.a * material.diffuse_alpha; + out_color = vec4(final_rgb, final_alpha); + if (out_color.a < material.alpha_cutoff) { discard; } } diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv index 46b1e6f..8b867bb 100644 Binary files a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv and b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv differ diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert index dfcdccb..201c31a 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert @@ -2,17 +2,273 @@ layout(location = 0) in vec3 in_position; layout(location = 1) in vec3 in_color; -layout(location = 2) in vec2 in_uv; +layout(location = 2) in vec3 in_normal; +layout(location = 3) in vec2 in_uv; +layout(location = 4) in vec2 in_detail_uv; +layout(location = 5) in float in_overlay_alpha; layout(location = 0) out vec3 out_color; layout(location = 1) out vec2 out_uv; +layout(location = 2) out vec2 out_detail_uv; +layout(location = 3) out float out_overlay_alpha; +// Native D3D vertex fog is written to SPECULAR.a and interpolated by the +// fixed-function rasterizer. Keep the existing varying slot, but carry only +// that scalar instead of recomputing distance in the fragment stage. +layout(location = 4) out float out_fog_factor; +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(push_constant) uniform CameraConstants { +layout(std140, set = 0, binding = 4) uniform FrameConstants { mat4 clip_from_world; -} camera; + vec4 directional_direction[4]; + vec4 directional_rgb[4]; + vec4 directional_coefficients[4]; + vec4 point_position_range; + vec4 point_rgb_active; + vec4 point_coefficients_active; + vec4 lighting_floor; + vec4 fog_color; + vec4 fog_distances; + vec4 camera_position; +} frame; + +layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular { + vec4 base; + vec4 overlay; +} material_specular; + +layout(push_constant) uniform MaterialConstants { + layout(offset = 0) float alpha_cutoff; + layout(offset = 4) float diffuse_alpha; + layout(offset = 8) float overlay_diffuse_alpha; + layout(offset = 12) float directional_r; + layout(offset = 16) float directional_g; + layout(offset = 20) float directional_b; + layout(offset = 24) float additive_r; + layout(offset = 28) float additive_g; + layout(offset = 32) float additive_b; + layout(offset = 36) float overlay_directional_r; + layout(offset = 40) float overlay_directional_g; + layout(offset = 44) float overlay_directional_b; + layout(offset = 48) float overlay_additive_r; + layout(offset = 52) float overlay_additive_g; + layout(offset = 56) float overlay_additive_b; + layout(offset = 60) float base_page_x; + layout(offset = 64) float base_page_y; + layout(offset = 68) float base_page_w; + layout(offset = 72) float base_page_h; + layout(offset = 76) float detail_page_x; + layout(offset = 80) float detail_page_y; + layout(offset = 84) float detail_page_w; + layout(offset = 88) float detail_page_h; + layout(offset = 92) float overlay_page_x; + layout(offset = 96) float overlay_page_y; + layout(offset = 100) float overlay_page_w; + layout(offset = 104) float overlay_page_h; + layout(offset = 108) float overlay_detail_page_x; + layout(offset = 112) float overlay_detail_page_y; + layout(offset = 116) float overlay_detail_page_w; + layout(offset = 120) float overlay_detail_page_h; + layout(offset = 124) float material_mode; +} material; + +struct LightingTerms { + vec3 diffuse; + vec3 specular; +}; + +float native_range_compress(float value) { + if (value <= 1.0) { + return value; + } + if (value <= 7.0) { + return value / 6.0 + 5.0 / 6.0; + } + return 2.0; +} + +float native_specular_curve(float value) { + if (value <= 1.0) { + return 0.8 * value; + } + if (value <= 3.0) { + return 0.1 * value + 0.7; + } + return 1.0; +} + +// The original callback uses the reflected vector and squares the cosine for +// power-1 iterations. A bounded loop keeps the native byte power while +// remaining valid for every material record. +float native_specular_power(float cosine, float power) { + // CShade enables specular only when the material power byte is non-zero. + if (power <= 0.0 || cosine <= 0.0) { + return 0.0; + } + float result = cosine; + int iterations = clamp(int(power) - 1, 0, 254); + for (int index = 0; index < 254; ++index) { + if (index >= iterations) { + break; + } + result *= result; + } + return result; +} + +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) { + return 0.0; + } + return clamp((distance_to_camera - frame.fog_distances.x) / fog_span, 0.0, 1.0); +} + +vec3 normalized_or(vec3 value, vec3 fallback) { + float length_squared = dot(value, value); + return length_squared > 0.0000001 ? value * inversesqrt(length_squared) : fallback; +} + +vec3 specular_from_light( + vec3 normal, + vec3 view_direction, + vec3 light_direction, + vec3 light_rgb, + vec3 material_rgb, + float power, + float attenuation +) { + float ndotl = dot(normal, light_direction); + if (ndotl <= 0.0 || attenuation <= 0.0) { + return vec3(0.0); + } + vec3 reflection = reflect(-light_direction, normal); + float cosine = max(dot(reflection, view_direction), 0.0); + return material_rgb * light_rgb * native_specular_power(cosine, power) * attenuation; +} + +LightingTerms native_material_lighting( + vec3 world_position, + vec3 normal, + vec3 directional_rgb, + vec3 additive_rgb, + vec3 specular_rgb, + float specular_power +) { + vec3 accumulated = additive_rgb; + vec3 specular = vec3(0.0); + vec3 view_direction = normalized_or(frame.camera_position.xyz - world_position, vec3(0.0, 0.0, 1.0)); + for (int index = 0; index < 4; ++index) { + vec4 direction_record = frame.directional_direction[index]; + vec4 rgb_record = frame.directional_rgb[index]; + if (direction_record.w <= 0.5 || rgb_record.w <= 0.5) { + continue; + } + vec3 light_direction = normalized_or(-direction_record.xyz, vec3(0.0, 0.0, 1.0)); + float ndotl = max(dot(normal, light_direction), 0.0); + accumulated += ndotl * directional_rgb * rgb_record.rgb; + specular += specular_from_light( + normal, + view_direction, + light_direction, + rgb_record.rgb, + specular_rgb, + specular_power, + 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) { + 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; + attenuation = max(attenuation, 0.0); + accumulated += ndotl * directional_rgb * point_rgb_active.rgb * attenuation; + specular += specular_from_light( + normal, + view_direction, + light_direction, + point_rgb_active.rgb, + specular_rgb, + specular_power, + attenuation + ); + } + } + } + accumulated = max(accumulated, frame.lighting_floor.rgb); + vec3 compressed = vec3( + native_range_compress(accumulated.r), + native_range_compress(accumulated.g), + native_range_compress(accumulated.b) + ); + LightingTerms terms; + terms.diffuse = min(compressed, vec3(1.0)); + vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0)); + terms.specular = vec3( + native_specular_curve(total_specular.r), + native_specular_curve(total_specular.g), + native_specular_curve(total_specular.b) + ); + return terms; +} void main() { out_color = in_color; out_uv = in_uv; - gl_Position = camera.clip_from_world * vec4(in_position, 1.0); + out_detail_uv = in_detail_uv; + out_overlay_alpha = in_overlay_alpha; + out_fog_factor = native_fog_factor(in_position); + int mode = int(round(material.material_mode)); + int combiner_mode = mode & 3; + if (combiner_mode == 0) { + LightingTerms base_terms = native_material_lighting( + in_position, + in_normal, + vec3(material.directional_r, material.directional_g, material.directional_b), + vec3(material.additive_r, material.additive_g, material.additive_b), + material_specular.base.rgb, + material_specular.base.w + ); + LightingTerms overlay_terms = native_material_lighting( + in_position, + in_normal, + vec3(material.overlay_directional_r, material.overlay_directional_g, material.overlay_directional_b), + vec3(material.overlay_additive_r, material.overlay_additive_g, material.overlay_additive_b), + material_specular.overlay.rgb, + material_specular.overlay.w + ); + out_base_diffuse = base_terms.diffuse; + out_base_specular = base_terms.specular; + out_overlay_diffuse = overlay_terms.diffuse; + out_overlay_specular = overlay_terms.specular; + } else { + out_base_diffuse = vec3(1.0); + out_base_specular = vec3(0.0); + out_overlay_diffuse = vec3(1.0); + out_overlay_specular = vec3(0.0); + } + vec4 clip_position = frame.clip_from_world * vec4(in_position, 1.0); + // Sky far-depth is a Vulkan projection choice for layers authored around + // the camera. Keep world positions, normals, and UVs intact while placing + // the resulting primitive at the far depth; this does not assert native + // D3D depth-bit equivalence. + if ((mode & 4) != 0) { + clip_position.z = clip_position.w; + } + gl_Position = clip_position; } diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv index 84d1d9f..9574e1c 100644 Binary files a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv and b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv differ diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 236361b..4ce86eb 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -1,16 +1,24 @@ //! Format-to-GPU geometry bridge for the initial static asset renderer. use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; +use fparkan_animation::{NodePoseBuffer, Pose}; use fparkan_msh::{ draw_batches, node38_fallback_hierarchy, node38_sampled_hierarchy, selected_slot, Group, Lod, ModelAsset, NodeId, }; -use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState}; +use fparkan_render::{LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState}; use fparkan_terrain_format::LandMeshDocument; /// Legacy `Land.msh` stored-height to mission-world-height scale. const LEGACY_LAND_HEIGHT_SCALE: f32 = 1.0 / 32.0; +fn world_pipeline_state() -> LegacyPipelineState { + LegacyPipelineState { + depth: LegacyDepthMode::TestWrite, + ..LegacyPipelineState::default() + } +} + /// Error returned when a validated MSH cannot enter the current static GPU path. #[derive(Clone, Debug, Eq, PartialEq)] pub enum VulkanAssetMeshError { @@ -22,6 +30,22 @@ pub enum VulkanAssetMeshError { DegenerateViewExtent, /// The indexed model exceeds the current 16-bit Vulkan input contract. IndexOutOfRange, + /// A caller-supplied node pose buffer has a different length than its model. + NodePoseCountMismatch { + /// Number of nodes in the model. + expected: usize, + /// Number of supplied node poses. + actual: usize, + }, +} + +/// Evaluated world pose of one standard Node38 node in model-local space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanNodePose { + /// Translation in the model's local coordinate system. + pub translation: [f32; 3], + /// Unit quaternion in `[x, y, z, w]` order. + pub rotation: [f32; 4], } /// Shared XY frame for a deliberately top-down diagnostic static scene. @@ -83,6 +107,10 @@ impl std::fmt::Display for VulkanAssetMeshError { Self::NonFinitePosition => write!(f, "MSH contains a non-finite position"), Self::DegenerateViewExtent => write!(f, "MSH has a degenerate XY view extent"), Self::IndexOutOfRange => write!(f, "MSH index exceeds the static Vulkan u16 contract"), + Self::NodePoseCountMismatch { expected, actual } => write!( + f, + "node pose buffer has {actual} poses, but the MSH has {expected} nodes" + ), } } } @@ -153,8 +181,9 @@ pub fn project_msh_to_static_mesh_in_xy_frame( frame.project(transformed)[1], 0.0, ], - color: [0.82, 0.72, 0.31], - // Iron3D stores Res5 UV0 as signed fixed point with 1/1024 units. + color: [1.0, 1.0, 1.0], + normal: model_normal(model, index), + // Iron3D stores Res5 UV0 as unsigned fixed point with 1/1024 units. // Models that omit this optional stream retain the static viewer's // XY planar fallback instead of receiving fabricated raw UV values. uv: model @@ -164,6 +193,8 @@ pub fn project_msh_to_static_mesh_in_xy_frame( .map_or(frame.planar_uv(transformed), |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: model_detail_uv(model, index), + overlay_alpha: 0.0, }) }) .collect::, _>>()?; @@ -281,6 +312,17 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform( model: &ModelAsset, transform: LegacyIron3dEulerTransform, scale: [f32; 3], +) -> Result { + project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose( + model, transform, scale, None, + ) +} + +fn project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + root_pose: Option, ) -> Result { if !transform .translation @@ -300,12 +342,35 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform( if !position.iter().all(|value| value.is_finite()) { return Err(VulkanAssetMeshError::NonFinitePosition); } + let local_position = root_pose.map_or( + Pose { + translation: *position, + rotation: [0.0, 0.0, 0.0, 1.0], + }, + |root| { + compose_pose( + root, + Pose { + translation: *position, + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ) + }, + ); let position = transform - .try_transform_scaled_point(*position, scale) + .try_transform_scaled_point(local_position.translation, scale) .ok_or(VulkanAssetMeshError::NonFinitePosition)?; Ok(VulkanStaticVertex { position, - color: [0.82, 0.72, 0.31], + color: [1.0, 1.0, 1.0], + normal: transform_normal( + transform, + root_pose.map_or(model_normal(model, index), |root| { + rotate_by_quaternion(model_normal(model, index), root.rotation) + }), + scale, + ) + .ok_or(VulkanAssetMeshError::NonFinitePosition)?, uv: model .uv0 .as_ref() @@ -313,6 +378,8 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform( .map_or([0.0, 0.0], |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: model_detail_uv(model, index), + overlay_alpha: 0.0, }) }) .collect::, _>>()?; @@ -347,6 +414,8 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( transform, scale, node38_fallback_hierarchy(model), + None, + false, ) } @@ -372,6 +441,203 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( transform, scale, node38_sampled_hierarchy(model, animation_frame), + None, + false, + ) +} + +/// Projects a Node38 model using a hierarchy sampled by the caller. +/// +/// This keeps geometry in sync with any sockets or bounds that the caller +/// derives from the same pose buffer. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot +/// be represented by the current static Vulkan input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + node_poses: &NodePoseBuffer, +) -> Result { + project_msh_to_static_mesh_in_world_space_with_node_poses( + model, + transform, + scale, + Some(node_poses.clone()), + None, + false, + ) +} + +/// Projects a mounted Node38 model using a caller-sampled hierarchy and the +/// parent socket pose derived from its own caller-sampled hierarchy. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot +/// be represented by the current static Vulkan input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + node_poses: &NodePoseBuffer, + mount_pose: VulkanNodePose, +) -> Result { + project_msh_to_static_mesh_in_world_space_with_node_poses( + model, + transform, + scale, + Some(node_poses.clone()), + Some(Pose { + translation: mount_pose.translation, + rotation: mount_pose.rotation, + }), + true, + ) +} + +/// Evaluates one Node38 node pose for a unit mount socket. +#[must_use] +pub fn node38_pose( + model: &ModelAsset, + animation_frame: Option, + node_index: usize, +) -> Option { + node38_pose_with_root_replacement(model, animation_frame, node_index, false) +} + +/// Evaluates one Node38 socket relative to a replacement root pose. +/// +/// This is used when the model itself is already mounted: node zero belongs to +/// the parent socket and must not be composed a second time. +#[must_use] +pub fn node38_pose_relative_to_root( + model: &ModelAsset, + animation_frame: Option, + node_index: usize, +) -> Option { + node38_pose_with_root_replacement(model, animation_frame, node_index, true) +} + +/// Returns one node's evaluated pose from an already sampled hierarchy. +#[must_use] +pub fn node38_pose_from_hierarchy( + hierarchy: &NodePoseBuffer, + node_index: usize, +) -> Option { + node38_pose_value_from_hierarchy(hierarchy, node_index, false).map(vulkan_node_pose) +} + +/// Returns a node's pose relative to a root pose that will be replaced by a +/// parent socket. +#[must_use] +pub fn node38_pose_relative_to_root_from_hierarchy( + hierarchy: &NodePoseBuffer, + node_index: usize, +) -> Option { + node38_pose_value_from_hierarchy(hierarchy, node_index, true).map(vulkan_node_pose) +} + +fn node38_pose_value_from_hierarchy( + hierarchy: &NodePoseBuffer, + node_index: usize, + root_replaced: bool, +) -> Option { + let pose = *hierarchy.poses.get(node_index)?; + if !root_replaced { + return Some(pose); + } + if node_index == 0 { + return Some(Pose::default()); + } + let root = *hierarchy.poses.first()?; + Some(compose_pose(inverse_pose(root), pose)) +} + +fn vulkan_node_pose(pose: Pose) -> VulkanNodePose { + VulkanNodePose { + translation: pose.translation, + rotation: pose.rotation, + } +} + +fn node38_pose_with_root_replacement( + model: &ModelAsset, + animation_frame: Option, + node_index: usize, + root_replaced: bool, +) -> Option { + let hierarchy = animation_frame.map_or_else( + || node38_fallback_hierarchy(model), + |frame| node38_sampled_hierarchy(model, frame), + )?; + let hierarchy = if root_replaced { + strip_node38_root_pose(hierarchy)? + } else { + hierarchy + }; + let pose = *hierarchy.poses.get(node_index)?; + Some(VulkanNodePose { + translation: pose.translation, + rotation: pose.rotation, + }) +} + +/// Projects a mounted Node38 model while preserving its local hierarchy. +/// +/// The native unit loader mounts a child root at the parent's selected local +/// socket. The child root node itself is a socket placeholder and is hidden; +/// nodes `1..` retain their evaluated local hierarchy and are composed with the +/// parent's world socket pose. The mission transform remains applied once. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when geometry or transforms cannot be +/// represented by the static input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + animation_frame: u16, + mount_pose: VulkanNodePose, +) -> Result { + project_msh_to_static_mesh_in_world_space_with_node_poses( + model, + transform, + scale, + node38_sampled_hierarchy(model, animation_frame), + Some(Pose { + translation: mount_pose.translation, + rotation: mount_pose.rotation, + }), + true, + ) +} + +/// Projects a mounted Node38 model at its decoded fallback pose. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when geometry or transforms cannot be +/// represented by the static input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + mount_pose: VulkanNodePose, +) -> Result { + project_msh_to_static_mesh_in_world_space_with_node_poses( + model, + transform, + scale, + node38_fallback_hierarchy(model), + Some(Pose { + translation: mount_pose.translation, + rotation: mount_pose.rotation, + }), + true, ) } @@ -380,7 +646,17 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( transform: LegacyIron3dEulerTransform, scale: [f32; 3], hierarchy: Option, + mount_pose: Option, + hide_root_node: bool, ) -> Result { + if let Some(hierarchy) = hierarchy.as_ref() { + if hierarchy.poses.len() != model.node_count { + return Err(VulkanAssetMeshError::NodePoseCountMismatch { + expected: model.node_count, + actual: hierarchy.poses.len(), + }); + } + } if !transform .translation .iter() @@ -391,10 +667,19 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( return Err(VulkanAssetMeshError::NonFinitePosition); } if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() { - return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale); + return project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose( + model, transform, scale, mount_pose, + ); } + let hierarchy = if mount_pose.is_some() { + hierarchy.and_then(strip_node38_root_pose) + } else { + hierarchy + }; let Some(hierarchy) = hierarchy else { - return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale); + return project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose( + model, transform, scale, mount_pose, + ); }; let mut vertices = Vec::new(); @@ -406,7 +691,12 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( let Some(slot) = selected_slot(model, node, Lod(0), Group(0)) else { continue; }; - let pose = hierarchy.poses[node_index]; + if hide_root_node && node_index == 0 { + continue; + } + let pose = mount_pose.map_or(hierarchy.poses[node_index], |mount| { + compose_pose(mount, hierarchy.poses[node_index]) + }); if !pose .translation .iter() @@ -430,7 +720,16 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( } } if indices.is_empty() { - return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale); + // A valid Node38 hierarchy may intentionally select no drawable + // batches for this component (for example, a mounted attachment + // whose root is hidden at the chosen LOD/group). Preserve that empty + // result instead of falling back to every source batch and drawing + // geometry that the node selection excluded. + return Ok(VulkanStaticMesh { + vertices, + indices, + draw_ranges, + }); } Ok(VulkanStaticMesh { vertices, @@ -439,6 +738,48 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( }) } +/// Replaces Node38 node zero with the identity pose while retaining every +/// descendant's pose relative to that root. Mounted components use this +/// because the native loader substitutes their root with the parent socket. +fn strip_node38_root_pose( + mut hierarchy: fparkan_animation::NodePoseBuffer, +) -> Option { + let root = *hierarchy.poses.first()?; + let inverse_root = inverse_pose(root); + hierarchy.poses[0] = Pose::default(); + for pose in hierarchy.poses.iter_mut().skip(1) { + *pose = compose_pose(inverse_root, *pose); + } + Some(hierarchy) +} + +fn inverse_pose(pose: Pose) -> Pose { + let [x, y, z, w] = pose.rotation; + let length_squared = x * x + y * y + z * z + w * w; + let inverse_rotation = if length_squared.is_finite() && length_squared > f32::EPSILON { + [ + -x / length_squared, + -y / length_squared, + -z / length_squared, + w / length_squared, + ] + } else { + [0.0, 0.0, 0.0, 1.0] + }; + let translation = rotate_by_quaternion( + [ + -pose.translation[0], + -pose.translation[1], + -pose.translation[2], + ], + inverse_rotation, + ); + Pose { + translation, + rotation: inverse_rotation, + } +} + fn append_node_fallback_batch( model: &ModelAsset, batch: &fparkan_msh::Batch, @@ -450,6 +791,15 @@ fn append_node_fallback_batch( indices: &mut Vec, draw_ranges: &mut Vec, ) -> Result<(), VulkanAssetMeshError> { + // Some authored MSH tables retain empty alternate batches. They do not + // describe a drawable range and would violate the renderer's contiguous + // non-empty draw-range contract when a full mission root set is loaded. + if batch.index_count == 0 { + return Ok(()); + } + if !usize::from(batch.index_count).is_multiple_of(3) { + return Err(VulkanAssetMeshError::IndexOutOfRange); + } let first_index = u32::try_from(indices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?; let start = @@ -488,7 +838,12 @@ fn append_node_fallback_batch( u32::try_from(vertices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?; vertices.push(VulkanStaticVertex { position, - color: [0.82, 0.72, 0.31], + color: [1.0, 1.0, 1.0], + normal: { + let normal = rotate_by_quaternion(model_normal(model, source_index), rotation); + transform_normal(transform, normal, scale) + .ok_or(VulkanAssetMeshError::NonFinitePosition)? + }, uv: model .uv0 .as_ref() @@ -496,19 +851,46 @@ fn append_node_fallback_batch( .map_or([0.0, 0.0], |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: model_detail_uv(model, source_index), + overlay_alpha: 0.0, }); indices.push(vertex_index); } draw_ranges.push(VulkanStaticDrawRange { first_index, index_count: u32::from(batch.index_count), - material_index: batch.material_index, - pipeline_state: LegacyPipelineState::default(), + material_index: u16::from(batch.material_index), + lightmap_index: batch.lightmap_index, + batch_flags: batch.batch_flags, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }); Ok(()) } +fn compose_pose(parent: Pose, child: Pose) -> Pose { + let translation = rotate_by_quaternion(child.translation, parent.rotation); + Pose { + translation: [ + parent.translation[0] + translation[0], + parent.translation[1] + translation[1], + parent.translation[2] + translation[2], + ], + rotation: multiply_quaternions(parent.rotation, child.rotation), + } +} + +fn multiply_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] { + let [lx, ly, lz, lw] = left; + let [rx, ry, rz, rw] = right; + [ + lw * rx + lx * rw + ly * rz - lz * ry, + lw * ry - lx * rz + ly * rw + lz * rx, + lw * rz + lx * ry - ly * rx + lz * rw, + lw * rw - lx * rx - ly * ry - lz * rz, + ] +} + fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { let [x, y, z, w] = rotation; let tx = 2.0 * (y * position[2] - z * position[1]); @@ -521,6 +903,104 @@ fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { ] } +fn model_normal(model: &ModelAsset, index: usize) -> [f32; 3] { + model + .normals + .as_ref() + .and_then(|normals| normals.get(index)) + .map_or([0.0, 0.0, 1.0], |raw| { + // Indexed MSH stores signed bytes. The native decoder divides + // by 127 without clamping or renormalizing, so the packed length + // (including -128/127) reaches the shader unchanged. + [ + f32::from(raw[0]) / 127.0, + f32::from(raw[1]) / 127.0, + f32::from(raw[2]) / 127.0, + ] + }) +} + +/// Decodes the optional MSH type-18 secondary UV stream. Native model and +/// terrain samplers use the same unsigned 16-bit fixed-point 1/1024 scale for +/// both texture stages; an absent stream leaves the neutral detail coordinate. +fn model_detail_uv(model: &ModelAsset, index: usize) -> [f32; 2] { + model + .uv1 + .as_ref() + .and_then(|uv1| uv1.get(index)) + .map_or([0.0, 0.0], |uv| { + [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] + }) +} + +fn terrain_normal(terrain: &LandMeshDocument, index: usize) -> [f32; 3] { + terrain.normals.get(index).map_or([0.0, 0.0, 1.0], |raw| { + // Ngi32's normal decoder sign-extends each source byte and multiplies + // it by the literal 1/128. Preserve the packed magnitude here: the + // shader consumes the native vector directly, and normalizing it + // changes the lighting response for every authored byte. + [ + f32::from(raw[0]) * (1.0 / 128.0), + f32::from(raw[1]) * (1.0 / 128.0), + f32::from(raw[2]) * (1.0 / 128.0), + ] + }) +} + +fn transform_normal( + transform: LegacyIron3dEulerTransform, + normal: [f32; 3], + scale: [f32; 3], +) -> Option<[f32; 3]> { + if !normal.iter().all(|value| value.is_finite()) + || !scale + .iter() + .all(|value| value.is_finite() && value.abs() > f32::EPSILON) + { + return None; + } + // Positions use `R * (scale * point)`. Normals therefore use the + // inverse-transpose linear transform: inverse scale first, then R. + let inverse_scaled = [ + normal[0] / scale[0], + normal[1] / scale[1], + normal[2] / scale[2], + ]; + let matrix = transform.try_row_major()?; + let transformed = [ + matrix[0] * inverse_scaled[0] + + matrix[1] * inverse_scaled[1] + + matrix[2] * inverse_scaled[2], + matrix[4] * inverse_scaled[0] + + matrix[5] * inverse_scaled[1] + + matrix[6] * inverse_scaled[2], + matrix[8] * inverse_scaled[0] + + matrix[9] * inverse_scaled[1] + + matrix[10] * inverse_scaled[2], + ]; + let transformed_length = transformed[0].mul_add( + transformed[0], + transformed[1].mul_add(transformed[1], transformed[2] * transformed[2]), + ); + if !transformed_length.is_finite() || transformed_length <= f32::EPSILON { + return Some([0.0; 3]); + } + let source_length = normal[0].mul_add( + normal[0], + normal[1].mul_add(normal[1], normal[2] * normal[2]), + ); + if !source_length.is_finite() || source_length <= f32::EPSILON { + return Some([0.0; 3]); + } + let magnitude = source_length.sqrt(); + let inverse_transformed_length = transformed_length.sqrt().recip(); + Some([ + transformed[0] * inverse_transformed_length * magnitude, + transformed[1] * inverse_transformed_length * magnitude, + transformed[2] * inverse_transformed_length * magnitude, + ]) +} + /// Projects validated `Land.msh` terrain geometry into the static Vulkan path. /// /// This bridge preserves the source triangle order from `TerrainFace28` and @@ -595,13 +1075,16 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame( frame.project(*position)[1], 0.0, ], - color: [0.31, 0.58, 0.27], + color: [1.0, 1.0, 1.0], + normal: terrain_normal(terrain, index), uv: terrain .uv0 .get(index) .map_or(frame.planar_uv(*position), |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: terrain_detail_uv(terrain, index), + overlay_alpha: terrain_overlay_alpha(terrain, index), }) }) .collect::, _>>()?; @@ -638,10 +1121,13 @@ pub fn project_land_msh_to_static_mesh_in_world_space( } Ok(VulkanStaticVertex { position: *position, - color: [0.31, 0.58, 0.27], + color: [1.0, 1.0, 1.0], + normal: terrain_normal(terrain, index), uv: terrain.uv0.get(index).map_or([0.0, 0.0], |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: terrain_detail_uv(terrain, index), + overlay_alpha: terrain_overlay_alpha(terrain, index), }) }) .collect::, _>>()?; @@ -695,6 +1181,38 @@ fn static_terrain_indices(terrain: &LandMeshDocument) -> Result, Vulkan Ok(indices) } +/// Decodes the terrain type-18 repeating detail coordinate. The original +/// stream stores two unsigned 16-bit fixed-point coordinates with the same +/// 1/1024 texture scale used by the texture sampler. +fn terrain_detail_uv(terrain: &LandMeshDocument, index: usize) -> [f32; 2] { + terrain.aux18.get(index).map_or([0.0, 0.0], |raw| { + [ + f32::from(u16::from_le_bytes([raw[0], raw[1]])) / 1024.0, + f32::from(u16::from_le_bytes([raw[2], raw[3]])) / 1024.0, + ] + }) +} + +/// Decodes the terrain type-14 overlay mask as the source float alpha. +/// Invalid or out-of-range values are disabled at the static bridge boundary; +/// valid map data is the continuous 0..1 mask consumed by the original shade +/// path. +fn terrain_overlay_alpha(terrain: &LandMeshDocument, index: usize) -> f32 { + terrain + .aux14 + .get(index) + .map_or(0.0, |raw| f32::from_le_bytes(*raw)) + .is_finite() + .then_some( + terrain + .aux14 + .get(index) + .map_or(0.0, |raw| f32::from_le_bytes(*raw)) + .clamp(0.0, 1.0), + ) + .unwrap_or(0.0) +} + fn static_terrain_draw_ranges( terrain: &LandMeshDocument, ) -> Result, VulkanAssetMeshError> { @@ -718,7 +1236,9 @@ fn static_terrain_draw_ranges( .checked_mul(3) .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, material_index, - pipeline_state: LegacyPipelineState::default(), + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }); start_face = end_face; @@ -733,6 +1253,12 @@ fn static_model_indices_and_ranges( let batches = static_model_preview_batches(model)?; let mut draw_ranges = Vec::with_capacity(batches.len()); for batch in batches { + if batch.index_count == 0 { + continue; + } + if !usize::from(batch.index_count).is_multiple_of(3) { + return Err(VulkanAssetMeshError::IndexOutOfRange); + } let first_index = u32::try_from(indices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?; let start = usize::try_from(batch.index_start) @@ -754,8 +1280,10 @@ fn static_model_indices_and_ranges( draw_ranges.push(VulkanStaticDrawRange { first_index, index_count: u32::from(batch.index_count), - material_index: batch.material_index, - pipeline_state: LegacyPipelineState::default(), + material_index: u16::from(batch.material_index), + lightmap_index: batch.lightmap_index, + batch_flags: batch.batch_flags, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }); } @@ -843,10 +1371,13 @@ mod tests { node_stride: 0, node_count: 0, nodes_raw: Vec::new(), + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, slots: Vec::new(), positions, normals: None, uv0: None, + uv1: None, indices, batches, node_names: None, @@ -857,12 +1388,13 @@ mod tests { fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> Batch { Batch { batch_flags: 0, + material_index_hi: 0, material_index: 0, - opaque4: 0, - opaque6: 0, + lightmap_index: 0, + local_batch_index: 0, index_count, index_start, - opaque14: 0, + vertex_count: 0, base_vertex, } } @@ -875,6 +1407,8 @@ mod tests { node_stride: 38, node_count: 1, nodes_raw: node, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, slots: vec![ Slot { tri_start: 0, @@ -902,6 +1436,7 @@ mod tests { positions: Vec::new(), normals: None, uv0: None, + uv1: None, indices: Vec::new(), batches: vec![ batch(0, 0, 0), @@ -940,7 +1475,9 @@ mod tests { first_index: 0, index_count: 3, material_index: 0, - pipeline_state: LegacyPipelineState::default(), + lightmap_index: 0, + batch_flags: 0, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }] ); @@ -956,13 +1493,13 @@ mod tests { vec![0, 1, 2], vec![batch(0, 3, 0)], ); - source.uv0 = Some(vec![[1024, -512], [0, 2048], [-1024, 512]]); + source.uv0 = Some(vec![[1024, 65024], [0, 2048], [64512, 512]]); let mesh = project_msh_to_static_mesh(&source).expect("representable MSH"); - assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); assert_eq!(mesh.vertices[1].uv, [0.0, 2.0]); - assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); + assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]); } #[test] @@ -972,7 +1509,7 @@ mod tests { vec![0, 1, 2], vec![batch(0, 3, 0)], ); - source.uv0 = Some(vec![[1024, -512], [0, 2048], [-1024, 512]]); + source.uv0 = Some(vec![[1024, 65024], [0, 2048], [64512, 512]]); let mesh = project_msh_to_static_mesh_in_world_space( &source, @@ -984,7 +1521,7 @@ mod tests { assert_eq!(mesh.indices, vec![0, 1, 2]); assert_eq!(mesh.vertices[0].position, [104.0, 203.0, 302.0]); assert_eq!(mesh.vertices[1].position, [100.0, 199.0, 301.0]); - assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); } #[test] @@ -1010,6 +1547,65 @@ mod tests { assert_eq!(mesh.vertices[2].position, [10.0, 22.0, 30.0]); } + #[test] + fn world_space_msh_transforms_normals_with_inverse_scale() { + let mut source = model( + vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + vec![0, 1, 2], + vec![batch(0, 3, 0)], + ); + source.normals = Some(vec![[127, 127, 0, 0]; 3]); + + let mesh = project_msh_to_static_mesh_in_world_space_with_transform( + &source, + LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + [2.0, 3.0, 1.0], + ) + .expect("finite transformed normals"); + + let expected_magnitude = 2.0_f32.sqrt() * (127.0 / 127.0); + assert!((mesh.vertices[0].normal[0] - 0.83205 * expected_magnitude).abs() < 0.0001); + assert!((mesh.vertices[0].normal[1] - 0.55470 * expected_magnitude).abs() < 0.0001); + assert_eq!(mesh.vertices[0].normal[2], 0.0); + } + + #[test] + fn indexed_msh_normal_bytes_keep_packed_magnitude_through_uniform_scale() { + let mut source = model( + vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + vec![0, 1, 2], + vec![batch(0, 3, 0)], + ); + source.normals = Some(vec![[-128, 0, 0, 0], [64, 0, 0, 0], [0, 0, 127, 0]]); + let mesh = project_msh_to_static_mesh_in_world_space_with_transform( + &source, + LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2], + }, + [2.0; 3], + ) + .expect("finite packed normals"); + let lengths = mesh + .vertices + .iter() + .map(|vertex| { + vertex.normal[0] + .mul_add( + vertex.normal[0], + vertex.normal[1] + .mul_add(vertex.normal[1], vertex.normal[2] * vertex.normal[2]), + ) + .sqrt() + }) + .collect::>(); + assert!((lengths[0] - 128.0 / 127.0).abs() < 0.0001); + assert!((lengths[1] - 64.0 / 127.0).abs() < 0.0001); + } + #[test] fn xy_preview_applies_recovered_iron3d_orientation_before_framing() { let source = model( @@ -1048,6 +1644,8 @@ mod tests { node_stride: 38, node_count: 2, nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, slots: vec![ Slot { tri_start: 0, @@ -1075,6 +1673,7 @@ mod tests { positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], normals: None, uv0: None, + uv1: None, indices: vec![0, 1, 2, 0, 1, 2], batches: vec![batch(0, 3, 0), batch(3, 3, 0)], node_names: None, @@ -1112,6 +1711,248 @@ mod tests { assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]); } + #[test] + fn mounted_socket_pose_strips_only_a_replaced_parent_root() { + let mut nodes = vec![0_u8; 76]; + nodes[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + nodes[6..8].copy_from_slice(&0_u16.to_le_bytes()); + nodes[38 + 2..38 + 4].copy_from_slice(&0_u16.to_le_bytes()); + nodes[38 + 6..38 + 8].copy_from_slice(&1_u16.to_le_bytes()); + let source = ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, + slots: Vec::new(), + positions: Vec::new(), + normals: None, + uv0: None, + uv1: None, + indices: Vec::new(), + batches: Vec::new(), + node_names: None, + animation: Some(ModelAnimation { + keys: vec![ + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: [5.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: [2.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + ], + frame_map: Vec::new(), + frame_count: 0, + }), + }; + + assert_eq!( + node38_pose(&source, None, 1) + .expect("full socket") + .translation, + [7.0, 0.0, 0.0] + ); + assert_eq!( + node38_pose_relative_to_root(&source, None, 1) + .expect("relative socket") + .translation, + [2.0, 0.0, 0.0] + ); + } + + #[test] + fn mounted_child_uses_precomputed_hierarchies_for_socket_and_geometry() { + let parent_hierarchy = NodePoseBuffer { + poses: vec![ + Pose { + translation: [20.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + Pose { + translation: [25.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ], + }; + let parent_socket = node38_pose_relative_to_root_from_hierarchy(&parent_hierarchy, 1) + .expect("sampled parent socket relative to its mounted root"); + assert_eq!(parent_socket.translation, [5.0, 0.0, 0.0]); + assert_eq!( + node38_pose_from_hierarchy(&parent_hierarchy, 1) + .expect("sampled parent socket") + .translation, + [25.0, 0.0, 0.0] + ); + + let mut child_root = vec![0_u8; 38]; + child_root[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + child_root[6..8].copy_from_slice(&0_u16.to_le_bytes()); + let mut child_node = vec![0_u8; 38]; + child_node[2..4].copy_from_slice(&0_u16.to_le_bytes()); + child_node[6..8].copy_from_slice(&1_u16.to_le_bytes()); + child_node[8..10].copy_from_slice(&1_u16.to_le_bytes()); + let mut nodes = child_root; + nodes.extend(child_node); + let child = ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, + slots: vec![ + Slot { + tri_start: 0, + tri_count: 0, + batch_start: 0, + batch_count: 1, + aabb_min: [0.0; 3], + aabb_max: [1.0; 3], + sphere_center: [0.0; 3], + sphere_radius: 1.0, + opaque: [0; 5], + }, + Slot { + tri_start: 0, + tri_count: 0, + batch_start: 1, + batch_count: 1, + aabb_min: [0.0; 3], + aabb_max: [1.0; 3], + sphere_center: [0.0; 3], + sphere_radius: 1.0, + opaque: [0; 5], + }, + ], + positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + normals: None, + uv0: None, + uv1: None, + indices: vec![0, 1, 2, 0, 1, 2], + batches: vec![batch(0, 3, 0), batch(3, 3, 0)], + node_names: None, + animation: Some(ModelAnimation { + keys: vec![AnimKey24 { + time: AnimationTime(0.0), + pose: Pose::default(), + }], + frame_map: Vec::new(), + frame_count: 0, + }), + }; + let child_hierarchy = NodePoseBuffer { + poses: vec![ + Pose { + translation: [10.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + Pose { + translation: [14.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ], + }; + let child_socket = node38_pose_relative_to_root_from_hierarchy(&child_hierarchy, 1) + .expect("child node relative to its mounted root"); + assert_eq!(child_socket.translation, [4.0, 0.0, 0.0]); + + // The parent component's world root is at x=100, so its sampled socket + // at x=5 places the child root at x=105. The child geometry uses the + // same child hierarchy as the socket path: root x=10 is stripped and + // node 1 contributes x=4 before the source vertex at x=1. + let mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &child, + LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + &child_hierarchy, + VulkanNodePose { + translation: [105.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ) + .expect("mounted child geometry from sampled pose buffer"); + assert_eq!(mesh.vertices[0].position, [110.0, 0.0, 0.0]); + assert_eq!(mesh.indices, vec![0, 1, 2]); + } + + #[test] + fn supplied_node_pose_buffer_must_match_model_node_count() { + let source = model(Vec::new(), Vec::new(), Vec::new()); + let error = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + &source, + LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + &NodePoseBuffer { + poses: vec![Pose::default()], + }, + ) + .expect_err("mismatched node pose count"); + assert_eq!( + error, + VulkanAssetMeshError::NodePoseCountMismatch { + expected: 0, + actual: 1, + } + ); + } + + #[test] + fn node_pose_projection_preserves_an_intentionally_empty_component() { + let mut node = vec![0_u8; 38]; + node[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + node[6..8].copy_from_slice(&0_u16.to_le_bytes()); + node[8..10].copy_from_slice(&u16::MAX.to_le_bytes()); + let source = ModelAsset { + node_stride: 38, + node_count: 1, + nodes_raw: node, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, + slots: Vec::new(), + positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + normals: None, + uv0: None, + uv1: None, + indices: vec![0, 1, 2], + batches: vec![batch(0, 3, 0)], + node_names: None, + animation: Some(ModelAnimation { + keys: vec![AnimKey24 { + time: AnimationTime(0.0), + pose: Pose::default(), + }], + frame_map: Vec::new(), + frame_count: 0, + }), + }; + + let mesh = project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( + &source, + LegacyIron3dEulerTransform { + translation: [10.0, 20.0, 30.0], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + ) + .expect("empty node component remains representable"); + assert!(mesh.vertices.is_empty()); + assert!(mesh.indices.is_empty()); + assert!(mesh.draw_ranges.is_empty()); + } + #[test] fn rotates_point_by_unit_quaternion() { let rotated = rotate_by_quaternion( @@ -1151,14 +1992,18 @@ mod tests { first_index: 0, index_count: 3, material_index: 0, - pipeline_state: LegacyPipelineState::default(), + lightmap_index: 0, + batch_flags: 0, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }, VulkanStaticDrawRange { first_index: 3, index_count: 3, material_index: 7, - pipeline_state: LegacyPipelineState::default(), + lightmap_index: 0, + batch_flags: 0, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }, ] @@ -1181,7 +2026,7 @@ mod tests { [2.0, 3.0, 1.0], ], normals: Vec::new(), - uv0: vec![[1024, -512], [0, 2048], [-1024, 512], [512, 0]], + uv0: vec![[1024, 65024], [0, 2048], [64512, 512], [512, 0]], accelerator: Vec::new(), aux14: Vec::new(), aux18: Vec::new(), @@ -1195,8 +2040,31 @@ mod tests { assert_eq!(mesh.draw_ranges[0].index_count, 6); assert_eq!(mesh.vertices[0].position, [-0.8, -0.8, 0.0]); assert_eq!(mesh.vertices[3].position, [0.8, 0.8, 0.0]); - assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); - assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); + assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); + assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]); + } + + #[test] + fn terrain_normals_keep_native_one_over_128_scale() { + let terrain = LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + normals: vec![[127, -128, 64, 0]; 3], + uv0: vec![[0, 0]; 3], + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![terrain_face([0, 1, 2])], + }; + + let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain) + .expect("representable terrain normals"); + assert_eq!(mesh.vertices[0].normal, [127.0 / 128.0, -1.0, 0.5]); } #[test] @@ -1210,7 +2078,7 @@ mod tests { }, positions: vec![[10.0, 20.0, 30.0], [40.0, 50.0, 60.0], [70.0, 80.0, 90.0]], normals: Vec::new(), - uv0: vec![[1024, -512], [0, 2048], [-1024, 512]], + uv0: vec![[1024, 65024], [0, 2048], [64512, 512]], accelerator: Vec::new(), aux14: Vec::new(), aux18: Vec::new(), @@ -1223,7 +2091,7 @@ mod tests { assert_eq!(mesh.indices, vec![2, 0, 1]); assert_eq!(mesh.vertices[0].position, [10.0, 20.0, 30.0]); assert_eq!(mesh.vertices[2].position, [70.0, 80.0, 90.0]); - assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); } #[test] diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 640551a..83fdbbf 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -21,16 +21,22 @@ mod swapchain_resources; mod validation; pub use self::asset_mesh::{ - project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space, + node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, + node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh, + project_land_msh_to_static_mesh_in_legacy_world_space, 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, + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount, + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer, + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount, project_msh_to_static_mesh_in_world_space_with_node_sampled_poses, + project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount, project_msh_to_static_mesh_in_world_space_with_transform, project_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError, - VulkanStaticXyFrame, + VulkanNodePose, VulkanStaticXyFrame, }; pub use self::capabilities::{ probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, @@ -45,21 +51,27 @@ pub use self::instance::{ use self::instance::{cstring_vec, ensure_instance_extensions_available}; 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, create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, - create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image, - destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage, - VulkanDepthAttachment, VulkanFrameSync, + create_static_texture_image, create_vertex_buffer, destroy_allocated_buffer, + destroy_allocated_image, destroy_depth_attachment, 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, + VulkanAllocatedImage, VulkanDepthAttachment, VulkanFrameSync, }; pub use self::runtime::{ create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request, VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport, }; pub use self::smoke_types::{ + VulkanDirectionalLight, VulkanDynamicDrawRange, VulkanFrameUniforms, VulkanPointLight, VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh, - VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport, + VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex, VulkanValidationReport, + VULKAN_DIRECTIONAL_LIGHT_COUNT, VULKAN_FRAME_UNIFORM_BYTES, + VULKAN_MATERIAL_SPECULAR_RECORD_BYTES, }; #[cfg(test)] use self::surface::extension_name; @@ -72,7 +84,8 @@ pub use self::swapchain::{ VulkanSwapchainProbeError, VulkanSwapchainReport, }; use self::swapchain_resources::{ - create_swapchain_resources, destroy_swapchain_resources, VulkanSwapchainResources, + create_swapchain_resources, destroy_swapchain_resources, update_material_texture_descriptor, + VulkanSwapchainResources, }; use self::validation::{create_validation_messenger, VulkanValidationMessenger}; use ash::vk; @@ -469,6 +482,8 @@ const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[ ]; const fn spirv_words(bytes: &[u8]) -> [u32; WORD_COUNT] { + assert!(bytes.len() % 4 == 0); + assert!(bytes.len() == WORD_COUNT * 4); let mut words = [0_u32; WORD_COUNT]; let mut index = 0; while index < WORD_COUNT { @@ -484,10 +499,12 @@ const fn spirv_words(bytes: &[u8]) -> [u32; WORD_COUNT] words } -static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] = - spirv_words(include_bytes!("../shaders/triangle.vert.spv")); -static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] = - spirv_words(include_bytes!("../shaders/triangle.frag.spv")); +const TRIANGLE_VERTEX_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.vert.spv"); +const TRIANGLE_FRAGMENT_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.frag.spv"); +static TRIANGLE_VERTEX_SHADER_DATA: [u32; TRIANGLE_VERTEX_SHADER_BYTES.len() / 4] = + spirv_words(TRIANGLE_VERTEX_SHADER_BYTES); +static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; TRIANGLE_FRAGMENT_SHADER_BYTES.len() / 4] = + spirv_words(TRIANGLE_FRAGMENT_SHADER_BYTES); pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA; pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA; diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs index f1fd579..b841588 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -18,6 +18,9 @@ pub(super) struct VulkanAllocatedImage { pub(super) image: vk::Image, pub(super) memory: vk::DeviceMemory, pub(super) view: vk::ImageView, + pub(super) width: u32, + pub(super) height: u32, + pub(super) mip_levels: u32, } /// Depth/stencil image and the exact format selected for its render pass. @@ -134,6 +137,9 @@ pub(super) fn create_depth_attachment( image, memory, view, + width: extent.0, + height: extent.1, + mip_levels: 1, }, format, }) @@ -186,7 +192,6 @@ fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags { pub(super) struct VulkanFrameSync { pub(super) image_available: vk::Semaphore, - pub(super) render_finished: vk::Semaphore, pub(super) fence: vk::Fence, } @@ -210,24 +215,112 @@ pub(super) fn create_static_mesh_vertex_buffer( device: &VulkanLogicalDeviceProbe, mesh: &VulkanStaticMesh, ) -> Result { - let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::()); - for vertex in &mesh.vertices { + let bytes = encode_static_vertices(&mesh.vertices)?; + create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::VERTEX_BUFFER, + "static mesh vertex buffer", + ) +} + +/// Allocates a vertex buffer for a frame's retained mesh capacity. +pub(super) fn create_vertex_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + vertices: &[super::VulkanStaticVertex], +) -> Result { + let bytes = encode_static_vertices(vertices)?; + create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::VERTEX_BUFFER, + "dynamic mesh 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 +/// 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, + vertices: &[super::VulkanStaticVertex], +) -> Result<(), VulkanSmokeRendererError> { + let bytes = encode_static_vertices(vertices)?; + write_mapped_buffer( + device, + buffer, + &bytes, + "vkMapMemory(static mesh vertex update)", + ) +} + +fn encode_static_vertices( + vertices: &[super::VulkanStaticVertex], +) -> Result, VulkanSmokeRendererError> { + let byte_len = vertices + .len() + .checked_mul(14) + .and_then(|count| count.checked_mul(std::mem::size_of::())) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh vertex data size overflows address space", + })?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(byte_len) + .map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh vertex data exceeds addressable memory", + })?; + for vertex in vertices { for value in vertex .position .into_iter() .chain(vertex.color) + .chain(vertex.normal) .chain(vertex.uv) + .chain(vertex.detail_uv) + .chain([vertex.overlay_alpha]) { bytes.extend_from_slice(&value.to_ne_bytes()); } } - create_host_visible_buffer( - instance, - device, - &bytes, - vk::BufferUsageFlags::VERTEX_BUFFER, - "static mesh vertex buffer", - ) + Ok(bytes) +} + +fn write_mapped_buffer( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + 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", + }); + } + // 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()) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { context, result })?; + // SAFETY: `mapped` covers exactly the byte slice copied below. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::(), bytes.len()); + device.device().unmap_memory(buffer.memory); + } + Ok(()) } pub(super) fn create_static_mesh_index_buffer( @@ -235,29 +328,73 @@ pub(super) fn create_static_mesh_index_buffer( device: &VulkanLogicalDeviceProbe, mesh: &VulkanStaticMesh, ) -> Result { - let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::()); - for &index in &mesh.indices { - bytes.extend_from_slice(&index.to_ne_bytes()); - } + create_index_buffer(instance, device, &mesh.indices) +} + +/// Allocates an index buffer for a frame's retained mesh capacity. +pub(super) fn create_index_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + indices: &[u32], +) -> Result { + let bytes = encode_static_indices(indices)?; create_host_visible_buffer( instance, device, &bytes, vk::BufferUsageFlags::INDEX_BUFFER, - "static mesh index buffer", + "dynamic mesh index buffer", ) } +/// Replaces the bytes of a host-visible static index allocation. +pub(super) fn write_static_mesh_index_buffer( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + indices: &[u32], +) -> Result<(), VulkanSmokeRendererError> { + let bytes = encode_static_indices(indices)?; + write_mapped_buffer( + device, + buffer, + &bytes, + "vkMapMemory(static mesh index update)", + ) +} + +fn encode_static_indices(indices: &[u32]) -> Result, VulkanSmokeRendererError> { + let byte_len = indices + .len() + .checked_mul(std::mem::size_of::()) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh index data size overflows address space", + })?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(byte_len) + .map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh index data exceeds addressable memory", + })?; + for &index in indices { + bytes.extend_from_slice(&index.to_ne_bytes()); + } + Ok(bytes) +} + pub(super) fn create_static_texture_image( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, command_pool: vk::CommandPool, texture: &VulkanStaticTexture, ) -> Result { + texture + .validate() + .map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?; + let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture); let staging = create_host_visible_buffer( instance, device, - &texture.rgba8, + &mip_bytes, vk::BufferUsageFlags::TRANSFER_SRC, "static texture staging buffer", )?; @@ -269,7 +406,7 @@ pub(super) fn create_static_texture_image( height: texture.height, depth: 1, }) - .mip_levels(1) + .mip_levels(mip_levels) .array_layers(1) .samples(vk::SampleCountFlags::TYPE_1) .tiling(vk::ImageTiling::OPTIMAL) @@ -336,6 +473,7 @@ pub(super) fn create_static_texture_image( image, texture.width, texture.height, + mip_levels, ) { // SAFETY: Both resources were created on this device and are being rolled back once. unsafe { @@ -350,7 +488,7 @@ pub(super) fn create_static_texture_image( .image(image) .view_type(vk::ImageViewType::TYPE_2D) .format(vk::Format::R8G8B8A8_UNORM) - .subresource_range(color_subresource_range()); + .subresource_range(color_subresource_range(mip_levels)); // SAFETY: The image is live, initialized and has the stated color subresource. let view = { // SAFETY: The image is live, initialized and has the stated color subresource. @@ -371,9 +509,211 @@ pub(super) fn create_static_texture_image( image, memory, view, + width: texture.width, + height: texture.height, + mip_levels, }) } +/// Allocates two aligned camera slots used by the dynamic frame UBO. +pub(super) fn create_frame_uniform_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, +) -> 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. + unsafe { instance.instance.get_physical_device_properties(device.physical_device()) }; + let alignment = u64::from(limits.limits.min_uniform_buffer_offset_alignment).max(1); + let stride = FRAME_BYTES.checked_add(alignment - 1).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "frame uniform stride overflow", + }, + )? / alignment + * alignment; + let size = + stride + .checked_mul(FRAME_SLOTS) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "frame uniform buffer size overflow", + })?; + let bytes = vec![ + 0_u8; + usize::try_from(size).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "frame uniform buffer size exceeds address space", + } + })? + ]; + let buffer = create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::UNIFORM_BUFFER, + "frame uniform buffer", + )?; + Ok((buffer, stride)) +} + +/// Allocates one aligned storage-buffer record for every material descriptor. +/// +/// The descriptor uses a dynamic storage-buffer offset. Keeping the stride +/// aligned to the physical device limit lets the renderer select a material +/// record with the same descriptor set for every draw without baking material +/// indices into the pipeline layout. +pub(super) fn create_material_specular_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + material_count: usize, +) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> { + if material_count == 0 { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "material specular buffer has no material records", + }); + } + 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()) }; + let alignment = limits.limits.min_storage_buffer_offset_alignment.max(1); + let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64; + let stride = record_bytes.checked_add(alignment - 1).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "material specular stride overflow", + }, + )? / alignment + * alignment; + let count = + u64::try_from(material_count).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { + context: "material specular count exceeds address space", + })?; + let size = stride + .checked_mul(count) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "material specular buffer size overflow", + })?; + let bytes = + vec![ + 0_u8; + usize::try_from(size).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { + context: "material specular buffer size exceeds address space", + })? + ]; + let buffer = create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::STORAGE_BUFFER, + "material specular buffer", + )?; + Ok((buffer, stride)) +} + +/// Replaces all material specular records in one host-coherent upload. +pub(super) fn write_material_specular_buffer( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + stride: u64, + records: &[[[f32; 4]; 2]], +) -> Result<(), VulkanSmokeRendererError> { + if records.is_empty() { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload has no records", + }); + } + let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES; + let total_bytes = stride + .checked_mul(u64::try_from(records.len()).unwrap_or(u64::MAX)) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload size", + })?; + let total_bytes_usize = + usize::try_from(total_bytes).map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload size exceeds address space", + })?; + let mut bytes = vec![0_u8; total_bytes_usize]; + for (record_index, record) in records.iter().enumerate() { + let record_offset = usize::try_from( + stride + .checked_mul(u64::try_from(record_index).unwrap_or(u64::MAX)) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload offset", + })?, + ) + .map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload offset exceeds address space", + })?; + let mut offset = record_offset; + for vector in record { + for value in vector { + bytes[offset..offset + std::mem::size_of::()] + .copy_from_slice(&value.to_ne_bytes()); + offset += std::mem::size_of::(); + } + } + debug_assert_eq!(offset - record_offset, record_bytes); + } + // SAFETY: The storage buffer is host-visible and the caller has waited for + // all prior uses before replacing its records. + let mapped = unsafe { + device + .device() + .map_memory(buffer.memory, 0, total_bytes, vk::MemoryMapFlags::empty()) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkMapMemory(material specular buffer)", + result, + })?; + // SAFETY: The mapped range is the complete upload and the destination is + // valid for the exact byte count copied below. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::(), bytes.len()); + device.device().unmap_memory(buffer.memory); + } + Ok(()) +} + +/// Writes one complete frame block into the slot selected by the +/// fence-safe frame index. +pub(super) fn write_frame_uniform( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + stride: u64, + frame_index: usize, + bytes: &[u8; super::VULKAN_FRAME_UNIFORM_BYTES], +) -> Result<(), VulkanSmokeRendererError> { + let slot = + u64::try_from(frame_index).map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "frame uniform slot index", + })?; + let offset = stride + .checked_mul(slot) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "frame uniform slot offset", + })?; + // SAFETY: The offset is one of the two allocated aligned slots and the + // mapped range is exactly one complete frame block. + let mapped = unsafe { + device.device().map_memory( + buffer.memory, + offset, + super::VULKAN_FRAME_UNIFORM_BYTES as u64, + vk::MemoryMapFlags::empty(), + ) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkMapMemory(frame uniform buffer)", + result, + })?; + // SAFETY: The destination is the mapped frame slot and the byte slice lives + // for this copy. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::(), bytes.len()); + device.device().unmap_memory(buffer.memory); + } + Ok(()) +} + fn upload_static_texture( device: &VulkanLogicalDeviceProbe, command_pool: vk::CommandPool, @@ -381,6 +721,7 @@ fn upload_static_texture( image: vk::Image, width: u32, height: u32, + mip_levels: u32, ) -> Result<(), VulkanSmokeRendererError> { let allocate_info = vk::CommandBufferAllocateInfo::default() .command_pool(command_pool) @@ -407,7 +748,7 @@ fn upload_static_texture( result, }); } - let range = color_subresource_range(); + let range = color_subresource_range(mip_levels); let to_transfer = vk::ImageMemoryBarrier::default() .old_layout(vk::ImageLayout::UNDEFINED) .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) @@ -415,19 +756,34 @@ fn upload_static_texture( .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE) .image(image) .subresource_range(range); - let region = vk::BufferImageCopy::default() - .image_subresource( - vk::ImageSubresourceLayers::default() - .aspect_mask(vk::ImageAspectFlags::COLOR) - .mip_level(0) - .base_array_layer(0) - .layer_count(1), - ) - .image_extent(vk::Extent3D { - width, - height, - depth: 1, - }); + let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0)); + let mut buffer_offset = 0_u64; + let mut mip_width = width; + let mut mip_height = height; + for mip_level in 0..mip_levels { + let mip_bytes = u64::from(mip_width) + .saturating_mul(u64::from(mip_height)) + .saturating_mul(4); + regions.push( + vk::BufferImageCopy::default() + .buffer_offset(buffer_offset) + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .mip_level(mip_level) + .base_array_layer(0) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width: mip_width, + height: mip_height, + depth: 1, + }), + ); + buffer_offset = buffer_offset.saturating_add(mip_bytes); + mip_width = (mip_width / 2).max(1); + mip_height = (mip_height / 2).max(1); + } let to_sampled = vk::ImageMemoryBarrier::default() .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) @@ -451,7 +807,7 @@ fn upload_static_texture( staging_buffer, image, vk::ImageLayout::TRANSFER_DST_OPTIMAL, - &[region], + ®ions, ); device.device().cmd_pipeline_barrier( command_buffer, @@ -500,6 +856,246 @@ fn upload_static_texture( }) } +/// Replaces the contents of an already sampled texture without changing its +/// image view or descriptor set. The caller must wait for the renderer's +/// submitted work before invoking this function. +pub(super) fn update_static_texture_image( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + command_pool: vk::CommandPool, + image: &VulkanAllocatedImage, + texture: &VulkanStaticTexture, +) -> Result<(), VulkanSmokeRendererError> { + texture + .validate() + .map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?; + let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture); + if texture.width != image.width + || texture.height != image.height + || mip_levels != image.mip_levels + { + return Err(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic texture extent or mip count differs from allocation", + }); + } + let staging = create_host_visible_buffer( + instance, + device, + &mip_bytes, + vk::BufferUsageFlags::TRANSFER_SRC, + "dynamic texture staging buffer", + )?; + let allocate_info = vk::CommandBufferAllocateInfo::default() + .command_pool(command_pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1); + // SAFETY: The command pool is live and owned by the current logical device. + let command_buffer = unsafe { device.device().allocate_command_buffers(&allocate_info) } + .map_err(|result| { + destroy_allocated_buffer(device, &staging); + VulkanSmokeRendererError::VulkanOperation { + context: "vkAllocateCommandBuffers(dynamic texture upload)", + result, + } + })?[0]; + let begin = + vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT); + // SAFETY: The command buffer is freshly allocated from the current pool. + if let Err(result) = unsafe { device.device().begin_command_buffer(command_buffer, &begin) } { + // SAFETY: The command buffer belongs to this pool and is released on failure. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + }; + destroy_allocated_buffer(device, &staging); + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkBeginCommandBuffer(dynamic texture upload)", + result, + }); + } + let range = color_subresource_range(mip_levels); + let to_transfer = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .src_access_mask(vk::AccessFlags::SHADER_READ) + .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .image(image.image) + .subresource_range(range); + let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0)); + let mut buffer_offset = 0_u64; + let mut mip_width = texture.width; + let mut mip_height = texture.height; + for mip_level in 0..mip_levels { + let mip_bytes = u64::from(mip_width) + .saturating_mul(u64::from(mip_height)) + .saturating_mul(4); + regions.push( + vk::BufferImageCopy::default() + .buffer_offset(buffer_offset) + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .mip_level(mip_level) + .base_array_layer(0) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width: mip_width, + height: mip_height, + depth: 1, + }), + ); + buffer_offset = buffer_offset.saturating_add(mip_bytes); + mip_width = (mip_width / 2).max(1); + mip_height = (mip_height / 2).max(1); + } + let to_sampled = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(image.image) + .subresource_range(range); + // SAFETY: The caller has made all prior shader reads idle, and the image + // remains exclusively owned by this graphics queue. + unsafe { + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_transfer], + ); + device.device().cmd_copy_buffer_to_image( + command_buffer, + staging.buffer, + image.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + ®ions, + ); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_sampled], + ); + } + // SAFETY: Command recording is complete on the current command buffer. + if let Err(result) = unsafe { device.device().end_command_buffer(command_buffer) } { + // SAFETY: The command buffer belongs to this pool and is released on failure. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + }; + destroy_allocated_buffer(device, &staging); + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkEndCommandBuffer(dynamic texture upload)", + result, + }); + } + let command_buffers = [command_buffer]; + let submit = [vk::SubmitInfo::default().command_buffers(&command_buffers)]; + // SAFETY: The graphics queue and submitted command buffer remain live + // until this synchronous wait completes. + let submit_result = unsafe { + device + .device() + .queue_submit(device.graphics_queue(), &submit, vk::Fence::null()) + }; + let result = submit_result + .and_then(|()| unsafe { device.device().queue_wait_idle(device.graphics_queue()) }); + // SAFETY: Submission completed or failed synchronously. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + } + destroy_allocated_buffer(device, &staging); + result.map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkQueueSubmit/WaitIdle(dynamic texture upload)", + result, + }) +} + +/// Builds an RGBA8 mip chain on the CPU so texture upload does not depend on +/// optional linear-blit support for the device's UNORM format. +fn build_static_texture_mip_chain(texture: &VulkanStaticTexture) -> (Vec, u32) { + if !texture.mip_levels.is_empty() { + let mut bytes = Vec::new(); + for mip in &texture.mip_levels { + bytes.extend_from_slice(&mip.rgba8); + } + return ( + bytes, + u32::try_from(texture.mip_levels.len()).unwrap_or(u32::MAX), + ); + } + let mut bytes = texture.rgba8.clone(); + let mut level = texture.rgba8.clone(); + let mut width = texture.width; + let mut height = texture.height; + let mut mip_levels = 1_u32; + + while width > 1 || height > 1 { + let next_width = (width / 2).max(1); + let next_height = (height / 2).max(1); + let mut next = vec![ + 0_u8; + usize::try_from(next_width) + .unwrap_or(0) + .saturating_mul(usize::try_from(next_height).unwrap_or(0)) + .saturating_mul(4) + ]; + for y in 0..next_height { + for x in 0..next_width { + let dst = (usize::try_from(y).unwrap_or(0) + * usize::try_from(next_width).unwrap_or(0) + + usize::try_from(x).unwrap_or(0)) + * 4; + let x0 = x.saturating_mul(2).min(width.saturating_sub(1)); + let y0 = y.saturating_mul(2).min(height.saturating_sub(1)); + let x1 = x0.saturating_add(1).min(width.saturating_sub(1)); + let y1 = y0.saturating_add(1).min(height.saturating_sub(1)); + let source_width = usize::try_from(width).unwrap_or(0); + let samples = [ + (usize::try_from(y0).unwrap_or(0) * source_width + + usize::try_from(x0).unwrap_or(0)) + * 4, + (usize::try_from(y0).unwrap_or(0) * source_width + + usize::try_from(x1).unwrap_or(0)) + * 4, + (usize::try_from(y1).unwrap_or(0) * source_width + + usize::try_from(x0).unwrap_or(0)) + * 4, + (usize::try_from(y1).unwrap_or(0) * source_width + + usize::try_from(x1).unwrap_or(0)) + * 4, + ]; + for channel in 0..4 { + let sum: u16 = samples + .iter() + .map(|&offset| u16::from(level[offset + channel])) + .sum(); + next[dst + channel] = u8::try_from((sum + 2) / 4).unwrap_or(u8::MAX); + } + } + } + bytes.extend_from_slice(&next); + level = next; + width = next_width; + height = next_height; + mip_levels = mip_levels.saturating_add(1); + } + (bytes, mip_levels) +} + pub(super) fn destroy_allocated_image( device: &VulkanLogicalDeviceProbe, image: &VulkanAllocatedImage, @@ -624,36 +1220,24 @@ pub(super) fn create_frame_sync( let mut sync = Vec::with_capacity(2); for _ in 0..2 { // SAFETY: The sync objects belong to this live logical device and are destroyed at teardown. - let image_available = unsafe { device.device().create_semaphore(&semaphore_info, None) } - .map_err(|error| VulkanSmokeRendererError::VulkanOperation { - context: "vkCreateSemaphore(image_available)", - result: error, - })?; - let render_finished = { - // SAFETY: The sync objects belong to this live logical device and are destroyed at teardown. + let image_available = match unsafe { device.device().create_semaphore(&semaphore_info, None) } { - Ok(render_finished) => render_finished, + Ok(image_available) => image_available, Err(error) => { destroy_frame_sync_objects(device, &sync); - // SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure. - unsafe { device.device().destroy_semaphore(image_available, None) }; return Err(VulkanSmokeRendererError::VulkanOperation { - context: "vkCreateSemaphore(render_finished)", + context: "vkCreateSemaphore(image_available)", result: error, }); } - } - }; + }; // SAFETY: The fence belongs to this live logical device and is destroyed at teardown. let fence = match unsafe { device.device().create_fence(&fence_info, None) } { Ok(fence) => fence, Err(error) => { destroy_frame_sync_objects(device, &sync); - // SAFETY: These semaphores were created above on this logical device and are destroyed on setup failure. - unsafe { - device.device().destroy_semaphore(image_available, None); - device.device().destroy_semaphore(render_finished, None); - } + // SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure. + unsafe { device.device().destroy_semaphore(image_available, None) }; return Err(VulkanSmokeRendererError::VulkanOperation { context: "vkCreateFence", result: error, @@ -662,7 +1246,6 @@ pub(super) fn create_frame_sync( }; sync.push(VulkanFrameSync { image_available, - render_finished, fence, }); } @@ -676,9 +1259,6 @@ fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanF device .device() .destroy_semaphore(frame_sync.image_available, None); - device - .device() - .destroy_semaphore(frame_sync.render_finished, None); device.device().destroy_fence(frame_sync.fence, None); } } @@ -738,11 +1318,11 @@ pub(super) fn readback_buffer_bytes( Ok(bytes) } -pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange { +pub(super) fn color_subresource_range(level_count: u32) -> vk::ImageSubresourceRange { vk::ImageSubresourceRange::default() .aspect_mask(vk::ImageAspectFlags::COLOR) .base_mip_level(0) - .level_count(1) + .level_count(level_count) .base_array_layer(0) .layer_count(1) } diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index e2f6c34..e8e18ac 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -1,15 +1,19 @@ #![allow(unsafe_code)] use ash::vk; +use fparkan_render::LegacyDepthMode; use super::smoke_types::resolve_draw_texture_indices; +use super::update_material_texture_descriptor; use super::{ - create_command_pool, create_frame_sync, create_static_mesh_index_buffer, + create_command_pool, create_frame_sync, create_index_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources, - create_validation_messenger, create_vulkan_instance_probe, + 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, VulkanAllocatedBuffer, + 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, @@ -185,38 +189,225 @@ impl VulkanSmokeRenderer { return Err(error); } }; - // Keep the compatibility triangle path valid: it samples a white texel - // when no original TEXM was supplied. + // Keep the compatibility triangle path valid: a white base texel, + // neutral detail texels and a transparent white overlay make the + // additional terrain stages mathematically transparent. let fallback_texture = super::VulkanStaticTexture { width: 1, height: 1, rgba8: vec![255, 255, 255, 255], + mip_levels: Vec::new(), }; + let fallback_detail_texture = super::VulkanStaticTexture { + width: 1, + height: 1, + rgba8: vec![128, 128, 128, 255], + mip_levels: Vec::new(), + }; + let fallback_overlay_texture = super::VulkanStaticTexture { + width: 1, + height: 1, + 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. let texture_sources = if create_info.materials.is_empty() { - vec![&fallback_texture] + vec![ + (&fallback_texture, Some(0_usize)), + (&fallback_detail_texture, Some(1_usize)), + (&fallback_overlay_texture, Some(2_usize)), + (&fallback_detail_texture, Some(1_usize)), + ] + } 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(( + material + .detail_texture + .as_ref() + .map_or(&fallback_detail_texture, |texture| texture), + material.detail_texture.is_none().then_some(1_usize), + )); + 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(( + material + .overlay_detail_texture + .as_ref() + .map_or(&fallback_detail_texture, |texture| texture), + material.overlay_detail_texture.is_none().then_some(1_usize), + )); + } + 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, + ) { + 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); + texture_index + }; + texture_slot_indices.push(texture_index); + } + let material_alphas = if create_info.materials.is_empty() { + vec![[1.0, 0.0]] } else { create_info .materials .iter() - .map(|material| &material.texture) + .map(|material| { + [ + material.diffuse_alpha, + material.overlay_diffuse_alpha.unwrap_or(0.0), + ] + }) + .collect() + }; + let material_lighting = if create_info.materials.is_empty() { + vec![[[1.0; 3], [0.0; 3], [1.0; 3], [0.0; 3]]] + } else { + create_info + .materials + .iter() + .map(|material| { + [ + material.directional_rgb, + material.additive_rgb, + material.overlay_directional_rgb.unwrap_or([1.0; 3]), + material.overlay_additive_rgb.unwrap_or([0.0; 3]), + ] + }) + .collect() + }; + let material_uv_transforms = if create_info.materials.is_empty() { + vec![[[0.0, 0.0, 1.0, 1.0]; 4]] + } else { + create_info + .materials + .iter() + .map(|material| { + [ + material.uv_transform, + material.detail_uv_transform, + material + .overlay_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]), + material + .overlay_detail_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]), + ] + }) + .collect() + }; + let material_unlit = if create_info.materials.is_empty() { + vec![0.0] + } else { + create_info + .materials + .iter() + .map(|material| { + let combiner_mode = if material.lightmap_mode { + 3.0 + } else if material.sky_nebula_stars { + 2.0 + } else if material.unlit { + 1.0 + } else { + 0.0 + }; + combiner_mode + if material.sky_far_depth { 4.0 } else { 0.0 } + }) + .collect() + }; + let material_specular = if create_info.materials.is_empty() { + vec![[[0.0; 4]; 2]] + } else { + create_info + .materials + .iter() + .map(|material| { + [ + [ + material.specular_rgb[0], + material.specular_rgb[1], + material.specular_rgb[2], + f32::from(material.specular_power), + ], + [ + material.overlay_specular_rgb.unwrap_or([0.0; 3])[0], + material.overlay_specular_rgb.unwrap_or([0.0; 3])[1], + material.overlay_specular_rgb.unwrap_or([0.0; 3])[2], + f32::from(material.overlay_specular_power.unwrap_or(0)), + ], + ] + }) + .collect() + }; + let material_selectors = if create_info.materials.is_empty() { + vec![0] + } else { + create_info + .materials + .iter() + .map(|material| material.material_index) .collect() }; - let mut textures = Vec::with_capacity(texture_sources.len()); - for texture_source in texture_sources { - match create_static_texture_image(&instance, &device, command_pool, texture_source) { - Ok(image) => textures.push(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 mut renderer = Self { instance: Some(instance), validation, @@ -227,10 +418,38 @@ impl VulkanSmokeRenderer { swapchain_resources: None, vertex_buffer: Some(vertex_buffer), index_buffer: Some(index_buffer), + texture_slot_indices, textures, + vertex_count: create_info.mesh.vertices.len(), + index_capacity: create_info.mesh.indices.len(), + draw_range_capacities: create_info + .mesh + .draw_ranges + .iter() + .map(|range| range.index_count) + .collect(), draw_ranges: create_info.mesh.draw_ranges.clone(), 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()], + draw_order_dirty: false, + draw_range_world_queue: vec![None; create_info.mesh.draw_ranges.len()], + draw_range_base_pipeline_states: create_info + .mesh + .draw_ranges + .iter() + .map(|range| range.pipeline_state) + .collect(), + material_alphas, + material_lighting, + material_uv_transforms, + material_unlit, + material_specular, + material_selectors, + material_specular_dirty: true, + readback_enabled: false, camera: create_info.camera, + frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera), frame_sync: Vec::new(), images_in_flight: Vec::new(), current_frame: 0, @@ -318,6 +537,12 @@ impl VulkanSmokeRenderer { self.camera } + /// Returns the frame state uploaded before the next recorded frame. + #[must_use] + pub const fn frame_uniforms(&self) -> super::VulkanFrameUniforms { + self.frame_uniforms + } + /// Replaces the camera for subsequent frames without recreating GPU resources. /// /// # Errors @@ -335,6 +560,585 @@ impl VulkanSmokeRenderer { }); } self.camera = camera; + self.frame_uniforms.clip_from_world = camera.clip_from_world; + Ok(()) + } + + /// Replaces lighting, fog, camera-position and matrix state for + /// subsequent frames without recreating GPU resources. + /// + /// # Errors + /// + /// Returns [`VulkanSmokeRendererError::InvalidStaticCamera`] when any + /// submitted value is non-finite. The caller must update only between + /// [`Self::draw_frame`] calls on the renderer-owning thread. + pub fn set_frame_uniforms( + &mut self, + frame_uniforms: super::VulkanFrameUniforms, + ) -> Result<(), VulkanSmokeRendererError> { + if !frame_uniforms.is_finite() { + return Err(VulkanSmokeRendererError::InvalidStaticCamera { + context: "non-finite frame uniform", + }); + } + self.camera.clip_from_world = frame_uniforms.clip_from_world; + self.frame_uniforms = frame_uniforms; + Ok(()) + } + + /// Enables the optional swapchain image copy used by screenshot/readback + /// callers. Runtime frames leave it disabled to avoid a transfer every + /// frame. + pub fn set_readback_enabled(&mut self, enabled: bool) { + self.readback_enabled = enabled; + if !enabled { + self.last_readback_image_index = None; + } + } + + /// Replaces the shared vertex allocation between frames. + /// + /// 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 + /// no submitted command can observe a partial update. + pub fn update_vertices( + &mut self, + vertices: &[super::VulkanStaticVertex], + ) -> Result<(), VulkanSmokeRendererError> { + if vertices.len() != self.vertex_count { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + 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) + } + + /// Uploads the current shared mesh while retaining dynamic draw state. + /// + /// Environment geometry and projected shadow geometry share the original + /// vertex/index allocations. `geometry_ranges` carries only the ranges + /// whose index segment changed; material selection, blend/depth state, + /// 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. + /// + /// 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 + /// Vulkan never receives a zero-sized allocation. + pub fn update_mesh_geometry( + &mut self, + vertices: &[super::VulkanStaticVertex], + indices: &[u32], + geometry_ranges: &[super::VulkanDynamicDrawRange], + ) -> Result<(), VulkanSmokeRendererError> { + validate_geometry_upload( + vertices, + indices, + &self.draw_ranges, + geometry_ranges, + self.vertex_count, + 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, + } + })?; + + let (new_vertex_buffer, new_index_buffer) = if vertex_grew || index_grew { + let instance = self.instance_ref()?; + let device = self.device_ref()?; + let new_vertex_buffer = if vertex_grew { + Some(create_vertex_buffer(instance, device, vertices)?) + } else { + None + }; + let new_index_buffer = if index_grew { + match create_index_buffer(instance, device, indices) { + Ok(buffer) => Some(buffer), + Err(error) => { + if let Some(buffer) = new_vertex_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + return Err(error); + } + } + } else { + None + }; + if !vertex_grew { + if let Err(error) = + write_static_mesh_vertex_buffer(device, self.vertex_buffer_ref()?, vertices) + { + if let Some(buffer) = new_vertex_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + if let Some(buffer) = new_index_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + return Err(error); + } + } + if !index_grew { + if let Err(error) = + write_static_mesh_index_buffer(device, self.index_buffer_ref()?, indices) + { + if let Some(buffer) = new_vertex_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + if let Some(buffer) = new_index_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + return Err(error); + } + } + (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)?; + (None, None) + }; + + if let Some(buffer) = new_vertex_buffer { + let old = self.vertex_buffer.replace(buffer); + if let Some(old) = old { + destroy_allocated_buffer(self.device_ref()?, &old); + } + self.vertex_count = vertices.len(); + } + if let Some(buffer) = new_index_buffer { + let old = self.index_buffer.replace(buffer); + if let Some(old) = old { + destroy_allocated_buffer(self.device_ref()?, &old); + } + self.index_capacity = indices.len(); + } + + apply_geometry_range_updates( + &mut self.draw_ranges, + &mut self.draw_range_capacities, + geometry_ranges, + ); + Ok(()) + } + + /// Replaces one material texture in place while retaining its descriptor + /// set and sampler. This is used by dynamic fixed-size atlases such as + /// the native 256 by 256 projected-shadow texture. + /// + /// 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 + /// this between frame updates without taking ownership of Vulkan handles. + pub fn update_material_texture( + &mut self, + material_index: usize, + texture_slot: usize, + texture: &super::VulkanStaticTexture, + ) -> Result<(), VulkanSmokeRendererError> { + if texture_slot >= 4 { + return Err(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture slot is out of bounds", + }); + } + let logical_texture_index = material_index + .checked_mul(4) + .and_then(|index| index.checked_add(texture_slot)) + .ok_or(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture index overflows address space", + })?; + let texture_index = self + .texture_slot_indices + .get(logical_texture_index) + .copied() + .ok_or(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture index is out of bounds", + })?; + if self.textures.get(texture_index).is_none() { + return Err(VulkanSmokeRendererError::InvalidStaticTexture { + 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, + } + })?; + } + let shared_texture = self + .texture_slot_indices + .iter() + .filter(|mapped_index| **mapped_index == texture_index) + .count() + > 1; + if shared_texture { + // A fallback 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 = { + let instance = self.instance_ref()?; + let device = self.device_ref()?; + create_static_texture_image(instance, device, self.command_pool, texture)? + }; + let new_texture_index = self.textures.len(); + self.textures.push(image); + self.texture_slot_indices[logical_texture_index] = new_texture_index; + let device = self.device_ref()?; + let resources = self.resources_ref()?; + update_material_texture_descriptor( + device, + resources, + material_index, + texture_slot, + &self.textures[new_texture_index], + ) + } else { + let instance = self.instance_ref()?; + let device = self.device_ref()?; + let image = self.textures.get(texture_index).ok_or( + VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture index is out of bounds", + }, + )?; + update_static_texture_image(instance, device, self.command_pool, image, texture) + } + } + + /// Changes one draw range's active index count while retaining its + /// pipeline and material selector. This lets a fixed-capacity weather + /// allocation draw only the particles emitted in the current frame. + pub fn set_draw_range_index_count( + &mut self, + range_index: usize, + index_count: u32, + ) -> Result<(), VulkanSmokeRendererError> { + if index_count % 3 != 0 { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index count is not a triangle count", + }); + } + let capacity = self.draw_range_capacities.get(range_index).copied().ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + if index_count > capacity { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range exceeds its index allocation", + }); + } + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + range.index_count = index_count; + Ok(()) + } + + /// Sets the native per-range alpha reference in legacy 0..=255 units. + /// + /// A value of zero selects the renderer's proven native default (one) for + /// alpha-test ranges. The update is dynamic and does not recreate a + /// graphics pipeline. + pub fn set_draw_range_alpha_test_reference( + &mut self, + range_index: usize, + alpha_test_reference: u8, + ) -> Result<(), VulkanSmokeRendererError> { + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + range.alpha_test_reference = alpha_test_reference; + Ok(()) + } + + /// Marks a draw range as belonging to the native transparent queue. + /// + /// The flag is deliberately independent of the blend category: native + /// materials can blend while retaining depth writes, and some alpha + /// materials are depth-tested opaque. When enabled, a range that uses a + /// depth attachment switches to depth-test/read-only. The original depth + /// state is retained so disabling the flag restores the caller's state. + /// + /// The transparent flag only participates in the world draw order. The + /// backend keeps every unmarked range in its original slot, which leaves + /// the fixed screen/sky/weather pass order intact. + pub fn set_draw_range_transparency( + &mut self, + range_index: usize, + transparent: bool, + ) -> Result<(), VulkanSmokeRendererError> { + let base_state = *self + .draw_range_base_pipeline_states + .get(range_index) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + })?; + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + let depth = if transparent && base_state.depth != LegacyDepthMode::Disabled { + LegacyDepthMode::TestReadOnly + } else { + base_state.depth + }; + let mut next_state = base_state; + next_state.depth = depth; + 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 + // intentionally owned by the caller. + self.draw_range_world_queue[range_index] = Some(transparent); + self.draw_order_dirty = true; + if pipeline_changed { + self.rebuild_pipeline_resources()?; + } + Ok(()) + } + + /// Supplies the native camera-sort key returned by the draw-item camera + /// callback. The native callback computes Euclidean distance from the + /// camera to the first world vertex of the whole indexed batch. + /// + /// The recovered comparator inserts in descending floating-point camera + /// key order. Keeping the key separate from the range handle mirrors the + /// native layer's callback result and its `[item pointer, key]` entries. + pub fn set_draw_range_sort_key( + &mut self, + range_index: usize, + sort_key: f32, + ) -> Result<(), VulkanSmokeRendererError> { + if !sort_key.is_finite() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "native draw sort key must be finite", + }); + } + let key = self.draw_range_sort_keys.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + *key = Some(sort_key); + self.draw_order_dirty = true; + Ok(()) + } + + /// Returns the current stable range-handle order used by command + /// recording. Handles are indices into the original `draw_ranges` vector. + #[must_use] + pub fn draw_order(&self) -> &[usize] { + &self.draw_order + } + + /// Changes the diffuse and overlay alpha values carried by one material's + /// push constants. The descriptor images remain unchanged. + pub fn set_material_alphas( + &mut self, + material_index: usize, + diffuse_alpha: f32, + overlay_alpha: f32, + ) -> Result<(), VulkanSmokeRendererError> { + if !diffuse_alpha.is_finite() + || !overlay_alpha.is_finite() + || !(0.0..=1.0).contains(&diffuse_alpha) + || !(0.0..=1.0).contains(&overlay_alpha) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material alpha must be finite and 0..1", + }); + } + let alphas = self.material_alphas.get_mut(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + *alphas = [diffuse_alpha, overlay_alpha]; + Ok(()) + } + + /// Changes the base directional and additive lighting coefficients of one + /// material while retaining its descriptor images and pipeline state. + pub fn set_material_lighting( + &mut self, + material_index: usize, + directional_rgb: [f32; 3], + additive_rgb: [f32; 3], + ) -> Result<(), VulkanSmokeRendererError> { + self.set_material_lighting_with_overlay( + material_index, + directional_rgb, + additive_rgb, + None, + None, + ) + } + + /// Changes base and optional overlay directional/additive coefficients + /// while retaining descriptor images and pipeline state. + pub fn set_material_lighting_with_overlay( + &mut self, + material_index: usize, + directional_rgb: [f32; 3], + additive_rgb: [f32; 3], + overlay_directional_rgb: Option<[f32; 3]>, + overlay_additive_rgb: Option<[f32; 3]>, + ) -> Result<(), VulkanSmokeRendererError> { + if directional_rgb + .into_iter() + .chain(additive_rgb) + .chain(overlay_directional_rgb.into_iter().flatten()) + .chain(overlay_additive_rgb.into_iter().flatten()) + .any(|value| !value.is_finite() || value < 0.0) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material lighting must be finite and non-negative", + }); + } + let lighting = self.material_lighting.get_mut(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + lighting[0] = directional_rgb; + lighting[1] = additive_rgb; + lighting[2] = overlay_directional_rgb.unwrap_or([1.0; 3]); + lighting[3] = overlay_additive_rgb.unwrap_or([0.0; 3]); + Ok(()) + } + + /// Replaces the four Page transforms carried by one material's push + /// constants. The transform order is base, detail, overlay, and overlay + /// detail, matching the material shader's texture bindings. + /// + /// The update is kept CPU-side and is consumed by the next recorded draw; + /// no descriptor or image resource is recreated. This is intentionally a + /// complete replacement so an animated MAT0 phase cannot leave one stale + /// Page rectangle behind when its texture set is unchanged. + pub fn set_material_uv_transforms( + &mut self, + material_index: usize, + uv_transforms: [[f32; 4]; 4], + ) -> Result<(), VulkanSmokeRendererError> { + if uv_transforms + .into_iter() + .flatten() + .any(|value| !value.is_finite()) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material UV transform must be finite", + }); + } + let transforms = self.material_uv_transforms.get_mut(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + *transforms = uv_transforms; + Ok(()) + } + + /// Replaces the base and overlay specular records used by the vertex + /// Gouraud lighting path. RGB values remain normalized; powers retain + /// the native MAT0 byte and are encoded as the fourth float in each GPU + /// record. + pub fn set_material_specular( + &mut self, + material_index: usize, + specular_rgb: [f32; 3], + power: u8, + overlay_specular_rgb: Option<[f32; 3]>, + overlay_power: Option, + ) -> Result<(), VulkanSmokeRendererError> { + if specular_rgb + .into_iter() + .chain(overlay_specular_rgb.into_iter().flatten()) + .any(|value| !value.is_finite() || value < 0.0) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material specular RGB must be finite and non-negative", + }); + } + let specular = self.material_specular.get_mut(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + let overlay_rgb = overlay_specular_rgb.unwrap_or([0.0; 3]); + let next = [ + [ + specular_rgb[0], + specular_rgb[1], + specular_rgb[2], + f32::from(power), + ], + [ + overlay_rgb[0], + overlay_rgb[1], + overlay_rgb[2], + f32::from(overlay_power.unwrap_or(0)), + ], + ]; + if *specular == next { + return Ok(()); + } + *specular = next; + self.material_specular_dirty = true; + Ok(()) + } + + /// Rebinds one draw range to a positional material slot without + /// reallocating any vertex or descriptor resources. + pub fn set_draw_range_material( + &mut self, + range_index: usize, + material_index: usize, + ) -> Result<(), VulkanSmokeRendererError> { + let selector = *self.material_selectors.get(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + range.material_index = selector; + let texture_index = self.draw_texture_indices.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + *texture_index = material_index; Ok(()) } @@ -434,9 +1238,12 @@ impl VulkanSmokeRenderer { return Ok(VulkanSmokeFrameOutcome::Recreated); } - let sync = &self.frame_sync[self.current_frame]; + let sync = self.frame_sync.get(self.current_frame).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "frame synchronization state", + }, + )?; let image_available = sync.image_available; - let render_finished = sync.render_finished; let in_flight_fence = sync.fence; // SAFETY: The fence belongs to this live logical device and is waited from one thread. unsafe { @@ -448,6 +1255,27 @@ impl VulkanSmokeRenderer { context: "vkWaitForFences", result: error, })?; + 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 + // 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, + } + })?; + let resources = self.resources_ref()?; + write_material_specular_buffer( + self.device_ref()?, + &resources.material_specular_buffer, + resources.material_specular_stride, + &self.material_specular, + )?; + self.material_specular_dirty = false; + } // SAFETY: The swapchain, semaphore and fence inputs are live for the duration of the acquire call. let acquire = unsafe { self.swapchain_ref()?.loader().acquire_next_image( @@ -485,6 +1313,26 @@ 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 render_finished = self + .resources_ref()? + .render_finished + .get(image_index_usize) + .copied() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "render-finished semaphore for swapchain image", + })?; + self.record_command_buffer(image_index_usize)?; + // Reset only after recording succeeds. A recording error leaves the + // signaled fence available for the next recovery attempt. // SAFETY: The fence belongs to this frame context and is not in use after the wait above. unsafe { self.device_ref()?.device().reset_fences(&[in_flight_fence]) }.map_err( |error| VulkanSmokeRendererError::VulkanOperation { @@ -492,8 +1340,6 @@ impl VulkanSmokeRenderer { result: error, }, )?; - - self.record_command_buffer(image_index_usize)?; let wait_semaphores = [image_available]; let wait_stages = [vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT]; let command_buffers = [self.resources_ref()?.command_buffers[image_index_usize]]; @@ -515,7 +1361,7 @@ impl VulkanSmokeRenderer { context: "vkQueueSubmit", result: error, })?; - if !self.resources_ref()?.readback_buffers.is_empty() { + if self.readback_enabled && !self.resources_ref()?.readback_buffers.is_empty() { self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1); self.last_readback_image_index = Some(image_index_usize); } @@ -607,6 +1453,7 @@ impl VulkanSmokeRenderer { self.vertex_buffer_ref()?, self.index_buffer_ref()?, self.textures_ref()?, + &self.texture_slot_indices, &self.draw_ranges, self.depth_request, reuse_command_pool, @@ -621,6 +1468,7 @@ impl VulkanSmokeRenderer { self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()]; self.frame_sync = frame_sync; self.last_readback_image_index = None; + self.material_specular_dirty = true; self.report.swapchain_extent = swapchain_extent; self.report.swapchain_image_count = swapchain_image_count; self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format; @@ -629,10 +1477,54 @@ impl VulkanSmokeRenderer { 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. Only ranges explicitly + /// marked transparent are reordered, and they remain in the slots occupied + /// by that queue. This preserves the caller's fixed sky/screen/weather + /// pass order while matching the native descending key comparator for the + /// world transparent queue. + fn rebuild_draw_order(&mut self) { + let range_count = self.draw_ranges.len(); + if self.draw_order.len() != range_count { + self.draw_order = (0..range_count).collect(); + } + self.draw_order = sorted_world_draw_order( + range_count, + &self.draw_ranges, + &self.draw_range_world_queue, + &self.draw_range_sort_keys, + ); + } + fn record_command_buffer( &mut self, image_index: usize, ) -> Result<(), VulkanSmokeRendererError> { + if self.draw_order_dirty { + self.rebuild_draw_order(); + self.draw_order_dirty = false; + } let device = self.device_ref()?; let swapchain = self.swapchain_ref()?; let resources = self.resources_ref()?; @@ -702,15 +1594,17 @@ impl VulkanSmokeRenderer { 0, vk::IndexType::UINT32, ); - let clip_from_world = matrix_bytes(self.camera.clip_from_world); - device.device().cmd_push_constants( - command_buffer, - resources.pipeline_layout, - vk::ShaderStageFlags::VERTEX, - 0, - &clip_from_world, - ); - for (range, &texture_index) in self.draw_ranges.iter().zip(&self.draw_texture_indices) { + for &range_index in &self.draw_order { + let range = self.draw_ranges.get(range_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "stable draw order range handle", + }, + )?; + let texture_index = *self.draw_texture_indices.get(range_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "stable draw texture range handle", + }, + )?; let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or( VulkanSmokeRendererError::InvariantViolation { context: "static draw pipeline variant", @@ -721,6 +1615,18 @@ impl VulkanSmokeRenderer { vk::PipelineBindPoint::GRAPHICS, *pipeline, ); + 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 descriptor_set = resources.descriptor_sets.get(texture_index).ok_or( VulkanSmokeRendererError::InvariantViolation { context: "static material descriptor set", @@ -732,16 +1638,80 @@ impl VulkanSmokeRenderer { resources.pipeline_layout, 0, std::slice::from_ref(descriptor_set), - &[], + &[frame_offset, material_offset], ); let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes(); device.device().cmd_push_constants( command_buffer, resources.pipeline_layout, - vk::ShaderStageFlags::FRAGMENT, - 64, + 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 material_alphas = [ + material_alpha[0].to_ne_bytes(), + material_alpha[1].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::>(); + 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::>(); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 60, + &material_uv_transforms, + ); + let unlit = self.material_unlit.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material unlit flag", + }, + )?; + let unlit = [unlit.to_ne_bytes()]; + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 124, + &unlit.concat(), + ); device.device().cmd_draw_indexed( command_buffer, range.index_count, @@ -752,63 +1722,65 @@ impl VulkanSmokeRenderer { ); } device.device().cmd_end_render_pass(command_buffer); - if let (Some(image), Some(readback)) = ( - resources.images.get(image_index), - resources.readback_buffers.get(image_index), - ) { - let range = super::color_subresource_range(); - let to_transfer = vk::ImageMemoryBarrier::default() - .old_layout(vk::ImageLayout::PRESENT_SRC_KHR) - .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) - .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) - .dst_access_mask(vk::AccessFlags::TRANSFER_READ) - .image(*image) - .subresource_range(range); - let back_to_present = vk::ImageMemoryBarrier::default() - .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) - .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) - .src_access_mask(vk::AccessFlags::TRANSFER_READ) - .dst_access_mask(vk::AccessFlags::empty()) - .image(*image) - .subresource_range(range); - let region = vk::BufferImageCopy::default() - .image_subresource( - vk::ImageSubresourceLayers::default() - .aspect_mask(vk::ImageAspectFlags::COLOR) - .mip_level(0) - .base_array_layer(0) - .layer_count(1), - ) - .image_extent(vk::Extent3D { - width: swapchain.report.plan.extent.0, - height: swapchain.report.plan.extent.1, - depth: 1, - }); - device.device().cmd_pipeline_barrier( - command_buffer, - vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, - vk::PipelineStageFlags::TRANSFER, - vk::DependencyFlags::empty(), - &[], - &[], - &[to_transfer], - ); - device.device().cmd_copy_image_to_buffer( - command_buffer, - *image, - vk::ImageLayout::TRANSFER_SRC_OPTIMAL, - readback.buffer, - &[region], - ); - device.device().cmd_pipeline_barrier( - command_buffer, - vk::PipelineStageFlags::TRANSFER, - vk::PipelineStageFlags::BOTTOM_OF_PIPE, - vk::DependencyFlags::empty(), - &[], - &[], - &[back_to_present], - ); + if self.readback_enabled { + if let (Some(image), Some(readback)) = ( + resources.images.get(image_index), + resources.readback_buffers.get(image_index), + ) { + let range = super::color_subresource_range(1); + let to_transfer = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .dst_access_mask(vk::AccessFlags::TRANSFER_READ) + .image(*image) + .subresource_range(range); + let back_to_present = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .src_access_mask(vk::AccessFlags::TRANSFER_READ) + .dst_access_mask(vk::AccessFlags::empty()) + .image(*image) + .subresource_range(range); + let region = vk::BufferImageCopy::default() + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .mip_level(0) + .base_array_layer(0) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width: swapchain.report.plan.extent.0, + height: swapchain.report.plan.extent.1, + depth: 1, + }); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_transfer], + ); + device.device().cmd_copy_image_to_buffer( + command_buffer, + *image, + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + readback.buffer, + &[region], + ); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + vk::DependencyFlags::empty(), + &[], + &[], + &[back_to_present], + ); + } } } @@ -832,9 +1804,6 @@ impl VulkanSmokeRenderer { device .device() .destroy_semaphore(sync.image_available, None); - device - .device() - .destroy_semaphore(sync.render_finished, None); device.device().destroy_fence(sync.fence, None); } } @@ -937,7 +1906,7 @@ impl VulkanSmokeRenderer { .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { context: "completed readback byte length", })?; - if resources.readback_buffers.is_empty() { + if !self.readback_enabled || resources.readback_buffers.is_empty() { return Ok(None); } let Some(image_index) = completed_readback_buffer_index( @@ -986,14 +1955,6 @@ fn completed_readback_buffer_index( } } -fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] { - let mut bytes = [0; 64]; - for (index, value) in matrix.into_iter().enumerate() { - bytes[index * 4..(index + 1) * 4].copy_from_slice(&value.to_ne_bytes()); - } - bytes -} - fn fnv1a64(bytes: &[u8]) -> u64 { bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) @@ -1006,12 +1967,242 @@ impl Drop for VulkanSmokeRenderer { } } +fn validate_geometry_upload( + vertices: &[super::VulkanStaticVertex], + indices: &[u32], + current_ranges: &[super::VulkanStaticDrawRange], + geometry_ranges: &[super::VulkanDynamicDrawRange], + retained_vertex_capacity: usize, + retained_index_capacity: usize, +) -> Result<(), VulkanSmokeRendererError> { + if vertices.is_empty() || indices.is_empty() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh upload cannot have zero-sized geometry", + }); + } + if !indices.len().is_multiple_of(3) { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh index upload is not a triangle list", + }); + } + if vertices.iter().any(|vertex| { + !vertex + .position + .iter() + .chain(vertex.color.iter()) + .chain(vertex.normal.iter()) + .chain(vertex.uv.iter()) + .chain(vertex.detail_uv.iter()) + .chain(std::iter::once(&vertex.overlay_alpha)) + .all(|value| value.is_finite()) + || !(0.0..=1.0).contains(&vertex.overlay_alpha) + }) { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh vertex attributes must be finite and overlay alpha must be 0..1", + }); + } + if indices.iter().any(|&index| { + usize::try_from(index) + .ok() + .is_none_or(|index| index >= vertices.len()) + }) { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh index exceeds vertex count", + }); + } + if retained_vertex_capacity == 0 || retained_index_capacity == 0 { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "dynamic mesh retained allocation is empty", + }); + } + + let mut next_ranges = current_ranges.to_vec(); + let mut changed = vec![false; current_ranges.len()]; + for geometry in geometry_ranges { + if geometry.range_index >= current_ranges.len() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range index is out of bounds", + }); + } + if changed[geometry.range_index] { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range is updated more than once", + }); + } + changed[geometry.range_index] = true; + if geometry.first_index % 3 != 0 || geometry.index_count % 3 != 0 { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range is not triangle aligned", + }); + } + let first = usize::try_from(geometry.first_index).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range first index exceeds address space", + } + })?; + let count = usize::try_from(geometry.index_count).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range index count exceeds address space", + } + })?; + let end = first + .checked_add(count) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range exceeds address space", + })?; + if end > indices.len() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range exceeds uploaded indices", + }); + } + next_ranges[geometry.range_index].first_index = geometry.first_index; + next_ranges[geometry.range_index].index_count = geometry.index_count; + } + + for range in next_ranges { + if range.first_index % 3 != 0 || range.index_count % 3 != 0 { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range is not triangle aligned", + }); + } + let first = usize::try_from(range.first_index).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range first index exceeds address space", + } + })?; + let count = usize::try_from(range.index_count).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range index count exceeds address space", + } + })?; + let end = first + .checked_add(count) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range exceeds address space", + })?; + if end > indices.len() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range exceeds uploaded indices", + }); + } + } + Ok(()) +} + +fn apply_geometry_range_updates( + ranges: &mut [super::VulkanStaticDrawRange], + capacities: &mut [u32], + geometry_ranges: &[super::VulkanDynamicDrawRange], +) { + for geometry in geometry_ranges { + // `validate_geometry_upload` has already checked every handle. Keep + // this helper infallible so state is changed only after the Vulkan + // upload succeeds. + let range = &mut ranges[geometry.range_index]; + range.first_index = geometry.first_index; + range.index_count = geometry.index_count; + // A moved segment no longer owns the old tail. Keeping the previous + // range capacity would let a later count setter read indices from a + // different segment. The shared GPU allocation itself remains + // retained; this is the logical capacity of this range's segment. + capacities[geometry.range_index] = geometry.index_count; + } +} + +/// Replays the native queue construction from the original stable range +/// order. Fixed passes stay in their original slots; world opaque ranges are +/// emitted first, followed by world transparent ranges inserted by descending +/// camera distance. Zero-index-count ranges are inactive and are omitted. +fn sorted_world_draw_order( + range_count: usize, + ranges: &[super::VulkanStaticDrawRange], + world_queue: &[Option], + keys: &[Option], +) -> Vec { + let mut next = (0..range_count).collect::>(); + let mut world_opaque = Vec::new(); + let mut world_transparent = Vec::new(); + for range_index in 0..range_count { + if ranges + .get(range_index) + .is_none_or(|range| range.index_count == 0) + { + continue; + } + match world_queue.get(range_index).copied().flatten() { + Some(false) => world_opaque.push(range_index), + Some(true) => world_transparent.push(range_index), + None => {} + } + } + + // Native equal-key insertion is a midpoint insertion rather than a + // documented stable sort. Replay that insertion shape from original range + // order instead of repeatedly sorting the previous frame's order. + let mut ordered_transparent = Vec::with_capacity(world_transparent.len()); + for range_index in world_transparent { + let new_key = keys.get(range_index).and_then(|key| *key); + let mut low = 0usize; + let mut high = ordered_transparent.len().saturating_sub(1); + while high > low { + let middle = low + (high - low) / 2; + let existing_key = keys.get(ordered_transparent[middle]).and_then(|key| *key); + match (new_key, existing_key) { + (Some(new_key), Some(existing_key)) if new_key > existing_key => high = middle, + (Some(new_key), Some(existing_key)) if new_key < existing_key => low = middle + 1, + (Some(_), Some(_)) => { + low = middle; + break; + } + (Some(_), None) => high = middle, + (None, Some(_)) => low = middle + 1, + (None, None) => { + low = middle; + break; + } + } + } + if !ordered_transparent.is_empty() && high == low { + let existing_key = keys.get(ordered_transparent[low]).and_then(|key| *key); + let insert_after = match (new_key, existing_key) { + (Some(new_key), Some(existing_key)) => new_key < existing_key, + (None, Some(_)) => true, + _ => false, + }; + if insert_after { + low += 1; + } + } + ordered_transparent.insert(low, range_index); + } + + let mut world_order = world_opaque; + world_order.extend(ordered_transparent); + let mut world_cursor = 0usize; + for slot in &mut next { + if world_queue.get(*slot).copied().flatten().is_some() + && ranges + .get(*slot) + .is_some_and(|range| range.index_count != 0) + { + if let Some(&replacement) = world_order.get(world_cursor) { + *slot = replacement; + world_cursor += 1; + } + } + } + next +} + #[cfg(test)] mod tests { + use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh}; use super::{ - completed_readback_buffer_index, take_runtime_children_with_validation_snapshot, - take_runtime_owners_in_dependency_order, RollbackOnDrop, + apply_geometry_range_updates, completed_readback_buffer_index, sorted_world_draw_order, + take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order, + validate_geometry_upload, RollbackOnDrop, }; + use fparkan_render::{LegacyBlendMode, LegacyDepthMode, LegacyPipelineState}; use std::cell::RefCell; use std::rc::Rc; @@ -1100,6 +2291,84 @@ mod tests { assert!(completed_readback_buffer_index(Some(2), 2).is_err()); } + #[test] + fn geometry_updates_preserve_other_range_runtime_state() { + let base_state = LegacyPipelineState { + blend: LegacyBlendMode::SourceAlpha, + depth: LegacyDepthMode::TestReadOnly, + ..LegacyPipelineState::default() + }; + let mut ranges = vec![ + VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 7, + lightmap_index: 4, + batch_flags: 0x108, + pipeline_state: base_state, + alpha_test_reference: 17, + }, + VulkanStaticDrawRange { + first_index: 3, + index_count: 3, + material_index: 9, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: LegacyPipelineState::default(), + alpha_test_reference: 0, + }, + ]; + let original = ranges[0]; + let mut capacities = vec![3, 3]; + apply_geometry_range_updates( + &mut ranges, + &mut capacities, + &[VulkanDynamicDrawRange { + range_index: 1, + first_index: 3, + index_count: 9, + }], + ); + assert_eq!(ranges[0], original); + assert_eq!(capacities, vec![3, 9]); + assert_eq!(ranges[1].first_index, 3); + assert_eq!(ranges[1].index_count, 9); + + apply_geometry_range_updates( + &mut ranges, + &mut capacities, + &[VulkanDynamicDrawRange { + range_index: 1, + first_index: 3, + index_count: 3, + }], + ); + assert_eq!(ranges[0], original); + assert_eq!(capacities, vec![3, 3]); + assert_eq!(ranges[1].index_count, 3); + } + + #[test] + fn geometry_update_can_disable_empty_shadow_range_without_zero_sized_upload() { + let mesh = VulkanStaticMesh::smoke_triangle(); + let geometry = [VulkanDynamicDrawRange { + range_index: 0, + first_index: 3, + index_count: 0, + }]; + assert_eq!( + validate_geometry_upload( + &mesh.vertices, + &mesh.indices, + &mesh.draw_ranges, + &geometry, + 3, + 3, + ), + Ok(()) + ); + } + #[test] fn runtime_owners_drop_remaining_children_after_partial_init_failures() { let cases = [ @@ -1218,4 +2487,83 @@ mod tests { assert_eq!(log.borrow().as_slice(), &[TeardownStep::Swapchain]); } + + #[test] + fn native_transparent_order_is_descending_and_keeps_fixed_pass_slots() { + let ranges = (0..5) + .map(|_| super::super::VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }) + .collect::>(); + let order = sorted_world_draw_order( + 5, + &ranges, + &[None, Some(true), None, Some(true), Some(false)], + &[None, Some(2.0), None, Some(9.0), Some(-3.0)], + ); + assert_eq!(order, vec![0, 4, 2, 3, 1]); + } + + #[test] + fn inactive_world_range_is_not_reused_as_a_sort_slot() { + let ranges = vec![ + super::super::VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }, + super::super::VulkanStaticDrawRange { + first_index: 3, + index_count: 0, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }, + super::super::VulkanStaticDrawRange { + first_index: 3, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }, + ]; + let order = sorted_world_draw_order( + 3, + &ranges, + &[None, Some(true), Some(true)], + &[None, Some(100.0), Some(1.0)], + ); + assert_eq!(order, vec![0, 1, 2]); + } + + #[test] + fn native_equal_key_insertion_uses_inclusive_midpoint() { + let ranges = (0..4) + .map(|index| super::super::VulkanStaticDrawRange { + first_index: index * 3, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }) + .collect::>(); + let order = sorted_world_draw_order(4, &ranges, &[Some(true); 4], &[Some(1.0); 4]); + assert_eq!(order, vec![2, 3, 1, 0]); + } } diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 217df3f..75877ad 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -48,8 +48,20 @@ pub struct VulkanStaticVertex { pub position: [f32; 3], /// Linear RGB vertex color. pub color: [f32; 3], + /// Unit vertex normal in world/object coordinates. + pub normal: [f32; 3], /// Texture coordinate consumed by the static material bridge. pub uv: [f32; 2], + /// Repeating detail texture coordinate from terrain type-18 UV data. + /// + /// Non-terrain geometry supplies `[0.0; 2]`; its neutral detail texture + /// keeps the additional stage mathematically transparent. + pub detail_uv: [f32; 2], + /// Interpolated terrain overlay mask from type-14 data. + /// + /// Non-terrain geometry supplies `0.0`, disabling the transparent overlay + /// slot while retaining one vertex contract for every static draw. + pub overlay_alpha: f32, } /// A static geometry camera represented in the shader's matrix memory order. @@ -71,7 +83,9 @@ impl VulkanStaticCamera { projection: LegacyD3d7Projection, ) -> Option { let view = transform.try_direct3d7_view_row_major()?; - let projection = projection.try_direct3d7_projection_row_major()?; + let mut projection = projection.try_direct3d7_projection_row_major()?; + // D3D7's top-down viewport and Vulkan's positive-height viewport invert NDC Y. + projection[5] = -projection[5]; Self::from_row_major_view_projection(view, projection) } @@ -108,6 +122,242 @@ impl Default for VulkanStaticCamera { } } +/// Number of directional records carried by [`VulkanFrameUniforms`]. +pub const VULKAN_DIRECTIONAL_LIGHT_COUNT: usize = 4; + +/// One directional light in the frame lighting contract. +/// +/// The direction is the vector from the light toward the surface. The +/// coefficients are retained in the same three-component record as the +/// native point callback so frame construction can copy the original light +/// table without a lossy conversion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanDirectionalLight { + /// Direction from the light toward the surface. + pub direction: [f32; 3], + /// Light RGB multiplier. + pub rgb: [f32; 3], + /// Native light coefficients. Directional callbacks normally use the + /// neutral `[0, 0, 1]` record; the field keeps the shared light ABI. + pub coefficients: [f32; 3], + /// Whether the light participates in this frame. + pub active: bool, +} + +impl Default for VulkanDirectionalLight { + fn default() -> Self { + Self { + direction: [0.0, 0.0, 1.0], + rgb: [0.0; 3], + coefficients: [0.0, 0.0, 1.0], + active: false, + } + } +} + +/// One point light in the frame lighting contract. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanPointLight { + /// World-space position. + pub position: [f32; 3], + /// Light RGB multiplier. + pub rgb: [f32; 3], + /// Radius used by native normalized distance attenuation. + pub range: f32, + /// Native attenuation coefficients `a0`, `a1`, and `a2`. + pub coefficients: [f32; 3], + /// Whether the light participates in this frame. + pub active: bool, +} + +impl Default for VulkanPointLight { + fn default() -> Self { + Self { + position: [0.0; 3], + rgb: [0.0; 3], + range: 0.0, + // A neutral record is explicit. In particular, this is the + // proven native environment contract and avoids an accidental + // zero polynomial when a caller enables the point slot. + coefficients: [0.0, 0.0, 1.0], + active: false, + } + } +} + +/// Number of bytes occupied by one frame-uniform slot. +/// +/// The block is deliberately made entirely from `mat4`/`vec4`-sized records +/// so its CPU representation follows Vulkan's `std140` alignment rules on +/// 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; + +/// Bytes occupied by one material's base and overlay specular records. +/// +/// Each record is one `vec4`: RGB followed by the native integer power stored +/// as an IEEE-754 float for direct shader use. The allocation stride is +/// rounded up to the device's `minStorageBufferOffsetAlignment`. +pub const VULKAN_MATERIAL_SPECULAR_RECORD_BYTES: usize = 2 * 4 * std::mem::size_of::(); + +/// Per-frame data shared by the vertex and fragment stages. +/// +/// `clip_from_world` is row-major D3D-style storage for the same reason as +/// [`VulkanStaticCamera`]. A directional light direction points from the +/// light toward the surface; the shader negates it when taking the +/// surface-normal dot product. Fog distances are disabled when +/// `fog_end <= fog_start`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanFrameUniforms { + /// Row-major clip-from-world transform. + pub clip_from_world: [f32; 16], + /// Up to four native directional/celestial lights. + pub directional_lights: [VulkanDirectionalLight; VULKAN_DIRECTIONAL_LIGHT_COUNT], + /// Native point light slot. + pub point_light: VulkanPointLight, + /// Per-channel lighting floor applied before native range compression. + pub lighting_floor: [f32; 3], + /// Linear fog RGB color. + pub fog_color: [f32; 3], + /// Fog start distance. + pub fog_start: f32, + /// Fog end distance. + pub fog_end: f32, + /// World-space camera position used for fog distance. + pub camera_position: [f32; 3], +} + +impl VulkanFrameUniforms { + /// Builds neutral frame state from a static camera. + #[must_use] + pub fn from_camera(camera: VulkanStaticCamera) -> Self { + let mut directional_lights = + [VulkanDirectionalLight::default(); VULKAN_DIRECTIONAL_LIGHT_COUNT]; + directional_lights[0] = VulkanDirectionalLight { + direction: [0.35, 0.45, 0.82], + rgb: [1.0; 3], + coefficients: [0.0, 0.0, 1.0], + active: true, + }; + Self { + clip_from_world: camera.clip_from_world, + directional_lights, + point_light: VulkanPointLight::default(), + lighting_floor: [0.30; 3], + fog_color: [0.0; 3], + fog_start: f32::MAX, + fog_end: f32::MAX, + camera_position: [0.0; 3], + } + } + + /// Returns whether all submitted frame values are finite. + #[must_use] + pub fn is_finite(self) -> bool { + self.clip_from_world + .into_iter() + .chain(self.directional_lights.into_iter().flat_map(|light| { + light + .direction + .into_iter() + .chain(light.rgb) + .chain(light.coefficients) + .chain([if light.active { 1.0 } else { 0.0 }]) + })) + .chain(self.point_light.position) + .chain(self.point_light.rgb) + .chain([self.point_light.range]) + .chain(self.point_light.coefficients) + .chain([if self.point_light.active { 1.0 } else { 0.0 }]) + .chain(self.lighting_floor) + .chain(self.fog_color) + .chain([self.fog_start, self.fog_end]) + .chain(self.camera_position) + .all(f32::is_finite) + } + + /// Serializes one frame slot in the exact `std140` member order. + #[must_use] + pub fn to_ne_bytes(self) -> [u8; VULKAN_FRAME_UNIFORM_BYTES] { + let mut values = [0.0_f32; VULKAN_FRAME_UNIFORM_BYTES / 4]; + values[..16].copy_from_slice(&self.clip_from_world); + for (index, light) in self.directional_lights.into_iter().enumerate() { + let direction_offset = 16 + index * 4; + values[direction_offset..direction_offset + 4].copy_from_slice(&[ + light.direction[0], + light.direction[1], + light.direction[2], + if light.active { 1.0 } else { 0.0 }, + ]); + let rgb_offset = 32 + index * 4; + values[rgb_offset..rgb_offset + 4].copy_from_slice(&[ + light.rgb[0], + light.rgb[1], + light.rgb[2], + if light.active { 1.0 } else { 0.0 }, + ]); + let coefficient_offset = 48 + index * 4; + values[coefficient_offset..coefficient_offset + 4].copy_from_slice(&[ + light.coefficients[0], + light.coefficients[1], + light.coefficients[2], + if light.active { 1.0 } else { 0.0 }, + ]); + } + values[64..68].copy_from_slice(&[ + self.point_light.position[0], + self.point_light.position[1], + self.point_light.position[2], + self.point_light.range, + ]); + values[68..72].copy_from_slice(&[ + self.point_light.rgb[0], + self.point_light.rgb[1], + self.point_light.rgb[2], + if self.point_light.active { 1.0 } else { 0.0 }, + ]); + values[72..76].copy_from_slice(&[ + self.point_light.coefficients[0], + self.point_light.coefficients[1], + self.point_light.coefficients[2], + if self.point_light.active { 1.0 } else { 0.0 }, + ]); + values[76..80].copy_from_slice(&[ + self.lighting_floor[0], + self.lighting_floor[1], + self.lighting_floor[2], + 0.0, + ]); + values[80..84].copy_from_slice(&[ + self.fog_color[0], + self.fog_color[1], + self.fog_color[2], + 0.0, + ]); + values[84..88].copy_from_slice(&[self.fog_start, self.fog_end, 0.0, 0.0]); + values[88..92].copy_from_slice(&[ + self.camera_position[0], + self.camera_position[1], + self.camera_position[2], + 0.0, + ]); + let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES]; + for (index, value) in values.into_iter().enumerate() { + let offset = index * std::mem::size_of::(); + bytes[offset..offset + std::mem::size_of::()] + .copy_from_slice(&value.to_ne_bytes()); + } + bytes + } +} + +impl Default for VulkanFrameUniforms { + fn default() -> Self { + Self::from_camera(VulkanStaticCamera::default()) + } +} + fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { let mut result = [0.0; 16]; for row in 0..4 { @@ -131,6 +381,23 @@ pub struct VulkanStaticMesh { pub draw_ranges: Vec, } +/// Geometry-only changes applied to one retained draw range. +/// +/// The renderer owns the material, blend/depth state, active count changes, +/// and queue classification for every range. A frame builder uses this +/// record only when a variable-size geometry stream (such as projected +/// shadows) moves its index segment or changes its triangle count. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VulkanDynamicDrawRange { + /// Stable draw-range handle returned by the initial mesh layout. + pub range_index: usize, + /// First index in the current shared index upload. + pub first_index: u32, + /// Active triangle-list index count. Zero disables this range for the + /// current frame without allocating a zero-sized Vulkan buffer. + pub index_count: u32, +} + /// One indexed triangle-list draw retained from an original mesh batch. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct VulkanStaticDrawRange { @@ -140,6 +407,12 @@ pub struct VulkanStaticDrawRange { pub index_count: u32, /// Original positional `Batch20.material_index` selector. pub material_index: u16, + /// Original positional `Batch20.lightmap_index` selector. + pub lightmap_index: u8, + /// Original MSH Batch20 flag word used by the native transparent queue. + /// Bits `0x100` and `0x008` opt a range into that queue independently of + /// the material blend category. + pub batch_flags: u16, /// Backend-neutral fixed-function state for this source range. pub pipeline_state: LegacyPipelineState, /// Alpha-test reference in legacy 0..=255 units; dynamic material data. @@ -147,6 +420,13 @@ pub struct VulkanStaticDrawRange { } impl VulkanStaticDrawRange { + /// The value installed by the native renderer before a material-specific + /// alpha reference is written. The native Ngi32 initialiser stores one + /// in `D3DRS_ALPHAREF`; callers that do not have a material-specific value + /// should pass this constant explicitly. Zero remains a valid explicit + /// alpha reference. + pub const NATIVE_DEFAULT_ALPHA_TEST_REFERENCE: u8 = 1; + /// Returns the canonical key used for Vulkan pipeline selection. #[must_use] pub fn pipeline_key(self) -> PipelineKey { @@ -165,12 +445,75 @@ impl VulkanStaticDrawRange { } /// One diffuse material texture keyed by an original MSH batch selector. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct VulkanStaticMaterial { /// Positional material selector used by one or more source batches. pub material_index: u16, /// Decoded RGBA8 diffuse texture for this selector. pub texture: VulkanStaticTexture, + /// Phase parameters 4..6, normalized directional light RGB coefficient. + pub directional_rgb: [f32; 3], + /// Phase parameters 0..2, normalized additive RGB coefficient. + pub additive_rgb: [f32; 3], + /// Normalized atlas transform for base texture `[x, y, w, h]`. + pub uv_transform: [f32; 4], + /// Optional Land2 detail texture paired with the base texture. + pub detail_texture: Option, + /// Optional Land1 overlay texture selected by the face tag high byte. + pub overlay_texture: Option, + /// Optional Land2 detail texture paired with the overlay texture. + pub overlay_detail_texture: Option, + /// MAT0 phase diffuse alpha, normalized from the legacy 0..=255 byte. + pub diffuse_alpha: f32, + /// MAT0 phase alpha for the optional overlay material. + pub overlay_diffuse_alpha: Option, + /// Phase parameters 4..6 for the optional overlay material. + pub overlay_directional_rgb: Option<[f32; 3]>, + /// Phase parameters 0..2 for the optional overlay material. + pub overlay_additive_rgb: Option<[f32; 3]>, + /// Phase parameters 8..10, normalized base specular RGB coefficient. + /// + /// Specular data is kept in a storage buffer instead of push constants so + /// the vertex stage can evaluate the native Gouraud highlight while the + /// material's other dynamic values retain their existing ABI. + pub specular_rgb: [f32; 3], + /// Phase parameter 16 for the base material, carried as the native byte. + pub specular_power: u8, + /// Phase parameters 8..10 for the optional overlay material. + pub overlay_specular_rgb: Option<[f32; 3]>, + /// Phase parameter 16 for the optional overlay material. + pub overlay_specular_power: Option, + /// Normalized atlas transform for the optional detail texture. + pub detail_uv_transform: [f32; 4], + /// Normalized atlas transform for the optional overlay texture. + pub overlay_uv_transform: Option<[f32; 4]>, + /// Normalized atlas transform for the optional overlay detail texture. + pub overlay_detail_uv_transform: Option<[f32; 4]>, + /// Environment layers bypass terrain lighting and fog while retaining the + /// same descriptor/pipeline path. + pub unlit: bool, + /// Native sky mode 4: blend base and secondary textures by the secondary + /// texture alpha before multiplying by the vertex diffuse color. + pub sky_nebula_stars: bool, + /// Native terrain mode 3: combine base and secondary lightmap textures + /// without the regular terrain `2 * TEX0 * TEX1` combiner or NdotL. + pub lightmap_mode: bool, + /// Push material mode bit 4 and map this sky layer to the Vulkan far + /// plane without changing its world-space vertices or UVs. This is a + /// renderer projection choice; it is not a claim about the original D3D + /// depth-bit encoding. + pub sky_far_depth: bool, +} + +/// One authored RGBA8 mip level accepted by the Vulkan texture upload path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanStaticTextureMip { + /// Mip width in texels. + pub width: u32, + /// Mip height in texels. + pub height: u32, + /// Row-major RGBA8 pixels. + pub rgba8: Vec, } /// Decoded RGBA8 image accepted by the initial Vulkan texture upload path. @@ -182,6 +525,10 @@ pub struct VulkanStaticTexture { pub height: u32, /// Row-major RGBA8 pixels. pub rgba8: Vec, + /// Authored mip levels, including mip zero, when available. + /// + /// An empty vector requests deterministic CPU generation from `rgba8`. + pub mip_levels: Vec, } impl VulkanStaticTexture { @@ -200,7 +547,46 @@ impl VulkanStaticTexture { Some(expected) if expected != self.rgba8.len() => { Err("static texture rgba8 byte count does not match extent") } - Some(_) => Ok(()), + Some(_) => { + if self.mip_levels.is_empty() { + return Ok(()); + } + // TEXM files commonly stop at the authored 4x4 level. Vulkan + // permits a partial mip chain; only the supplied level + // extents and bytes need validation. + if self.mip_levels.len() + > usize::try_from(self.width.max(self.height)) + .unwrap_or(usize::MAX) + .ilog2() as usize + + 1 + { + return Err("static texture authored mip count exceeds extent"); + } + let mut width = self.width; + let mut height = self.height; + for (level, mip) in self.mip_levels.iter().enumerate() { + if mip.width != width || mip.height != height { + return Err("static texture authored mip extent does not match level"); + } + let expected = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)); + if expected != Some(mip.rgba8.len()) { + return Err("static texture authored mip byte count does not match extent"); + } + if level == 0 && mip.rgba8 != self.rgba8 { + return Err("static texture authored mip zero does not match rgba8"); + } + width = (width / 2).max(1); + height = (height / 2).max(1); + } + Ok(()) + } } } } @@ -216,6 +602,60 @@ pub(super) fn resolve_draw_texture_indices( ) -> Result, &'static str> { for (index, material) in materials.iter().enumerate() { material.texture.validate()?; + if let Some(texture) = &material.detail_texture { + texture.validate()?; + } + if let Some(texture) = &material.overlay_texture { + texture.validate()?; + } + if let Some(texture) = &material.overlay_detail_texture { + texture.validate()?; + } + if !material.diffuse_alpha.is_finite() || !(0.0..=1.0).contains(&material.diffuse_alpha) { + return Err("static material diffuse alpha must be finite and 0..1"); + } + if material + .directional_rgb + .into_iter() + .chain(material.additive_rgb) + .chain(material.specular_rgb) + .chain(material.overlay_specular_rgb.into_iter().flatten()) + .any(|value| !value.is_finite() || value < 0.0) + { + return Err("static material lighting and specular RGB parameters must be finite and non-negative"); + } + if material + .uv_transform + .into_iter() + .chain(material.detail_uv_transform) + .any(|value| !value.is_finite()) + { + return Err("static material UV transforms must be finite"); + } + if material + .overlay_diffuse_alpha + .is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha)) + { + return Err("static material overlay diffuse alpha must be finite and 0..1"); + } + if material + .overlay_directional_rgb + .into_iter() + .flatten() + .chain(material.overlay_additive_rgb.into_iter().flatten()) + .any(|value| !value.is_finite() || value < 0.0) + { + return Err("static material overlay RGB parameters must be finite and non-negative"); + } + if material + .overlay_uv_transform + .into_iter() + .flatten() + .chain(material.overlay_detail_uv_transform.into_iter().flatten()) + .any(|value| !value.is_finite()) + { + return Err("static material overlay UV transforms must be finite"); + } if materials[..index] .iter() .any(|previous| previous.material_index == material.material_index) @@ -247,17 +687,26 @@ impl VulkanStaticMesh { VulkanStaticVertex { position: [0.0, -0.55, 0.0], color: [1.0, 0.2, 0.2], + normal: [0.0, 0.0, 1.0], uv: [0.5, 0.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }, VulkanStaticVertex { position: [0.55, 0.55, 0.0], color: [0.2, 1.0, 0.2], + normal: [0.0, 0.0, 1.0], uv: [1.0, 1.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }, VulkanStaticVertex { position: [-0.55, 0.55, 0.0], color: [0.2, 0.4, 1.0], + normal: [0.0, 0.0, 1.0], uv: [0.0, 1.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }, ], indices: vec![0, 1, 2], @@ -265,6 +714,8 @@ impl VulkanStaticMesh { first_index: 0, index_count: 3, material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, pipeline_state: LegacyPipelineState::default(), alpha_test_reference: 0, }], @@ -275,6 +726,22 @@ impl VulkanStaticMesh { if self.vertices.is_empty() { return Err("static mesh has no vertices"); } + if self.vertices.iter().any(|vertex| { + !vertex + .position + .iter() + .chain(vertex.color.iter()) + .chain(vertex.normal.iter()) + .chain(vertex.uv.iter()) + .chain(vertex.detail_uv.iter()) + .chain(std::iter::once(&vertex.overlay_alpha)) + .all(|value| value.is_finite()) + || !(0.0..=1.0).contains(&vertex.overlay_alpha) + }) { + return Err( + "static mesh vertex attributes must be finite and overlay alpha must be 0..1", + ); + } if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) { return Err("static mesh indices must contain complete triangles"); } @@ -311,6 +778,53 @@ impl VulkanStaticMesh { mod static_mesh_tests { use super::*; + #[test] + fn frame_uniforms_use_std140_sized_slots_and_preserve_dynamic_values() { + let frame = VulkanFrameUniforms { + clip_from_world: std::array::from_fn(|index| index as f32), + directional_lights: [ + VulkanDirectionalLight { + direction: [1.0, 2.0, 3.0], + rgb: [4.0, 5.0, 6.0], + coefficients: [7.0, 8.0, 9.0], + active: true, + }, + VulkanDirectionalLight::default(), + VulkanDirectionalLight::default(), + VulkanDirectionalLight::default(), + ], + point_light: VulkanPointLight { + position: [28.0, 29.0, 30.0], + rgb: [31.0, 32.0, 33.0], + range: 34.0, + coefficients: [35.0, 36.0, 37.0], + active: true, + }, + lighting_floor: [7.0, 8.0, 9.0], + fog_color: [10.0, 11.0, 12.0], + fog_start: 13.0, + fog_end: 14.0, + camera_position: [15.0, 16.0, 17.0], + }; + let bytes = frame.to_ne_bytes(); + assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES); + let read = + |offset: usize| f32::from_ne_bytes(bytes[offset..offset + 4].try_into().expect("f32")); + assert_eq!(read(0), 0.0); + assert_eq!(read(60), 15.0); + assert_eq!(read(64), 1.0); + assert_eq!(read(76), 1.0); + assert_eq!(read(128), 4.0); + assert_eq!(read(192), 7.0); + assert_eq!(read(256), 28.0); + assert_eq!(read(272), 31.0); + assert_eq!(read(288), 35.0); + assert_eq!(read(304), 7.0); + assert_eq!(read(320), 10.0); + assert_eq!(read(336), 13.0); + assert_eq!(read(352), 15.0); + } + #[test] fn smoke_triangle_is_valid_complete_geometry() { let mesh = VulkanStaticMesh::smoke_triangle(); @@ -357,6 +871,54 @@ mod static_mesh_tests { assert_eq!(camera.clip_from_world[7], 0.65_f32.sin()); } + #[test] + fn legacy_camera_maps_world_up_to_top_of_positive_height_vulkan_viewport() { + let transform = RawCameraTransform { + words: [ + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + ], + }; + let projection = LegacyD3d7Projection { + viewport: [0, 0, 1024, 768], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, + }; + + let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection) + .expect("recovered camera inputs are valid"); + // The raw identity camera's recovered view maps world +X to depth + // and world +Z to camera up. + let world_up_at_positive_depth = [10.0, 0.0, 1.0, 1.0]; + let clip_y = (0..4) + .map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 1]) + .sum::(); + let clip_w = (0..4) + .map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 3]) + .sum::(); + + assert!(clip_w > 0.0); + assert!( + clip_y / clip_w < 0.0, + "world up must map to the viewport top" + ); + } + #[test] fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() { let identity = VulkanStaticCamera::default().clip_from_world; @@ -387,6 +949,8 @@ mod static_mesh_tests { first_index: 0, index_count: 2, material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, pipeline_state: LegacyPipelineState::default(), alpha_test_reference: 0, }], @@ -415,6 +979,8 @@ mod static_mesh_tests { first_index: 0, index_count: 3, material_index: 7, + lightmap_index: u8::MAX, + batch_flags: 0, pipeline_state: LegacyPipelineState::default(), alpha_test_reference: 0, }, @@ -422,6 +988,8 @@ mod static_mesh_tests { first_index: 3, index_count: 3, material_index: 2, + lightmap_index: u8::MAX, + batch_flags: 0, pipeline_state: LegacyPipelineState::default(), alpha_test_reference: 0, }, @@ -430,15 +998,58 @@ mod static_mesh_tests { width: 1, height: 1, rgba8: vec![255; 4], + mip_levels: Vec::new(), }; let materials = [ VulkanStaticMaterial { material_index: 2, texture: texture(), + detail_texture: None, + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + specular_rgb: [0.0; 3], + specular_power: 0, + overlay_specular_rgb: None, + overlay_specular_power: None, + uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_directional_rgb: None, + overlay_additive_rgb: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + unlit: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, }, VulkanStaticMaterial { material_index: 7, texture: texture(), + detail_texture: None, + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + specular_rgb: [0.0; 3], + specular_power: 0, + overlay_specular_rgb: None, + overlay_specular_power: None, + uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_directional_rgb: None, + overlay_additive_rgb: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + unlit: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, }, ]; @@ -480,6 +1091,24 @@ mod static_mesh_tests { }; assert_eq!(disabled.alpha_test_cutoff(), 0.0); assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.0); + let explicit_zero = VulkanStaticDrawRange { + pipeline_state: LegacyPipelineState { + alpha_test: true, + ..LegacyPipelineState::default() + }, + alpha_test_reference: 0, + ..base + }; + assert_eq!(explicit_zero.alpha_test_cutoff(), 0.0); + let native_default = VulkanStaticDrawRange { + pipeline_state: LegacyPipelineState { + alpha_test: true, + ..LegacyPipelineState::default() + }, + alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, + ..base + }; + assert_eq!(native_default.alpha_test_cutoff(), 1.0 / 255.0); } } @@ -727,10 +1356,46 @@ pub struct VulkanSmokeRenderer { pub(super) swapchain_resources: Option, pub(super) vertex_buffer: Option, pub(super) index_buffer: Option, + /// Positional material slots mapped to the owned image list. Fallback + /// stages may share one image without coupling independently animated + /// material textures. + pub(super) texture_slot_indices: Vec, pub(super) textures: Vec, + pub(super) vertex_count: usize, + /// Retained index allocation length. The upload path grows this only when + /// a frame actually needs more shared indices and never shrinks it. + pub(super) index_capacity: usize, + pub(super) draw_range_capacities: Vec, pub(super) draw_ranges: Vec, pub(super) draw_texture_indices: Vec, + /// Stable range handles used when recording the native pass order. + pub(super) draw_order: Vec, + /// Native camera-sort values returned by the draw-item camera callback. + pub(super) draw_range_sort_keys: Vec>, + /// Whether the stable order must be rebuilt before the next command + /// buffer. Range setters are called in batches by the game loop. + pub(super) draw_order_dirty: bool, + /// World queue classification for each stable range handle. `None` keeps + /// a fixed screen/sky/weather pass; `Some(false)` is world opaque and + /// `Some(true)` is world transparent. + pub(super) draw_range_world_queue: Vec>, + /// Pipeline state before transparent queue depth policy is applied. + pub(super) draw_range_base_pipeline_states: Vec, + pub(super) material_alphas: Vec<[f32; 2]>, + pub(super) material_lighting: Vec<[[f32; 3]; 4]>, + pub(super) material_uv_transforms: Vec<[[f32; 4]; 4]>, + /// Base and overlay specular records uploaded to the dynamic SSBO. + pub(super) material_specular: Vec<[[f32; 4]; 2]>, + /// Original selector for each positional material record. + pub(super) material_selectors: Vec, + /// 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. + pub(super) material_unlit: Vec, + pub(super) readback_enabled: bool, pub(super) camera: VulkanStaticCamera, + pub(super) frame_uniforms: VulkanFrameUniforms, 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 c175f0d..b8049d3 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -8,38 +8,85 @@ use fparkan_render::{ use std::collections::BTreeMap; use super::{ - color_subresource_range, create_depth_attachment, destroy_depth_attachment, - VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe, - VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe, - TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, + 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, + VULKAN_MATERIAL_SPECULAR_RECORD_BYTES, }; pub(super) struct VulkanSwapchainResources { pub(super) image_views: Vec, pub(super) images: Vec, pub(super) readback_buffers: Vec, - pub(super) depth_attachment: VulkanDepthAttachment, + /// One depth image per swapchain image so frames can overlap safely. + pub(super) depth_attachments: Vec, + /// A render-finished semaphore dedicated to each swapchain image. + pub(super) render_finished: Vec, pub(super) render_pass: vk::RenderPass, pub(super) pipeline_layout: vk::PipelineLayout, pub(super) descriptor_set_layout: vk::DescriptorSetLayout, pub(super) descriptor_pool: vk::DescriptorPool, pub(super) descriptor_sets: Vec, pub(super) sampler: vk::Sampler, + pub(super) frame_uniform_buffer: VulkanAllocatedBuffer, + pub(super) frame_uniform_stride: u64, + pub(super) material_specular_buffer: VulkanAllocatedBuffer, + pub(super) material_specular_stride: u64, /// Live graphics pipelines indexed by canonical backend-neutral state. pub(super) pipelines: BTreeMap, pub(super) framebuffers: Vec, pub(super) command_buffers: Vec, } +pub(super) fn update_material_texture_descriptor( + device: &VulkanLogicalDeviceProbe, + resources: &VulkanSwapchainResources, + material_index: usize, + texture_slot: usize, + texture: &VulkanAllocatedImage, +) -> Result<(), VulkanSmokeRendererError> { + if texture_slot >= 4 { + return Err(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture slot is out of bounds", + }); + } + let descriptor_set = resources + .descriptor_sets + .get(material_index) + .copied() + .ok_or(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material descriptor set is out of bounds", + })?; + let image_info = vk::DescriptorImageInfo::default() + .sampler(resources.sampler) + .image_view(texture.view) + .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL); + let write = vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(u32::try_from(texture_slot).unwrap_or(u32::MAX)) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(std::slice::from_ref(&image_info)); + // SAFETY: the renderer idles the device before changing a descriptor that + // may be referenced by an in-flight command buffer. + unsafe { device.device().update_descriptor_sets(&[write], &[]) }; + Ok(()) +} + struct PartialSwapchainResources { image_views: Vec, readback_buffers: Vec, - depth_attachment: Option, + depth_attachments: Vec, + render_finished: Vec, render_pass: Option, pipeline_layout: Option, descriptor_set_layout: Option, descriptor_pool: Option, sampler: Option, + frame_uniform_buffer: Option, + frame_uniform_stride: u64, + material_specular_buffer: Option, + material_specular_stride: u64, pipelines: BTreeMap, framebuffers: Vec, command_buffers: Vec, @@ -53,6 +100,7 @@ pub(super) fn create_swapchain_resources( vertex_buffer: &VulkanAllocatedBuffer, index_buffer: &VulkanAllocatedBuffer, textures: &[VulkanAllocatedImage], + texture_slot_indices: &[usize], draw_ranges: &[super::VulkanStaticDrawRange], depth_request: DepthStencilSupport, reuse_command_pool: bool, @@ -74,16 +122,57 @@ pub(super) fn create_swapchain_resources( swapchain.report.plan.format.format, )?, readback_buffers: Vec::new(), - depth_attachment: None, + depth_attachments: Vec::new(), + render_finished: Vec::new(), render_pass: None, pipeline_layout: None, descriptor_set_layout: None, descriptor_pool: None, sampler: None, + frame_uniform_buffer: None, + frame_uniform_stride: 0, + material_specular_buffer: None, + material_specular_stride: 0, pipelines: BTreeMap::new(), framebuffers: Vec::new(), command_buffers: Vec::new(), }; + let semaphore_info = vk::SemaphoreCreateInfo::default(); + for _ in &images { + // SAFETY: The semaphore belongs to this live logical device and is + // destroyed with the swapchain resources. + match unsafe { device.device().create_semaphore(&semaphore_info, None) } { + Ok(semaphore) => partial.render_finished.push(semaphore), + Err(result) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateSemaphore(render_finished)", + result, + }); + } + } + } + let (frame_uniform_buffer, frame_uniform_stride) = + match super::create_frame_uniform_buffer(instance, device) { + Ok(buffer) => buffer, + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + }; + partial.frame_uniform_stride = frame_uniform_stride; + partial.frame_uniform_buffer = Some(frame_uniform_buffer); + let material_count = texture_slot_indices.len() / 4; + let (material_specular_buffer, material_specular_stride) = + match create_material_specular_buffer(instance, device, material_count) { + Ok(buffer) => buffer, + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + }; + partial.material_specular_stride = material_specular_stride; + partial.material_specular_buffer = Some(material_specular_buffer); let ( depth_attachment, render_pass, @@ -99,8 +188,17 @@ pub(super) fn create_swapchain_resources( swapchain.report.plan.format.format, swapchain.report.plan.extent, textures, + texture_slot_indices, draw_ranges, depth_request, + partial + .frame_uniform_buffer + .as_ref() + .expect("frame uniform buffer is present before descriptor creation"), + partial + .material_specular_buffer + .as_ref() + .expect("material specular buffer is present before descriptor creation"), ) { Ok(bundle) => bundle, Err(error) => { @@ -108,19 +206,35 @@ pub(super) fn create_swapchain_resources( return Err(error); } }; - let depth_view = depth_attachment.image.view; - partial.depth_attachment = Some(depth_attachment); + // Keep every handle from the bundle in the partial owner before creating + // the remaining per-image depth attachments. That loop can fail after the + // first attachment, and its error path must destroy the whole bundle. + partial.depth_attachments.push(depth_attachment); partial.render_pass = Some(render_pass); partial.pipeline_layout = Some(pipeline_layout); partial.descriptor_set_layout = Some(descriptor_set_layout); partial.descriptor_pool = Some(descriptor_pool); partial.sampler = Some(sampler); partial.pipelines = pipelines; + for _ in 1..images.len() { + match create_depth_attachment( + instance, + device, + swapchain.report.plan.extent, + depth_request, + ) { + Ok(attachment) => partial.depth_attachments.push(attachment), + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + } + } let framebuffers = match create_swapchain_framebuffers( device, render_pass, &partial.image_views, - depth_view, + &partial.depth_attachments, swapchain.report.plan.extent, ) { Ok(framebuffers) => framebuffers, @@ -168,24 +282,44 @@ pub(super) fn create_swapchain_resources( }; partial.command_buffers = command_buffers; let _ = (vertex_buffer, index_buffer); - let depth_attachment = - partial - .depth_attachment - .take() - .ok_or(VulkanSmokeRendererError::InvariantViolation { - context: "depth attachment ownership after swapchain setup", - })?; + if partial.depth_attachments.len() != images.len() + || partial.render_finished.len() != images.len() + { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "swapchain per-image resource count after setup", + }); + } Ok(VulkanSwapchainResources { image_views: partial.image_views, images, readback_buffers: partial.readback_buffers, - depth_attachment, - render_pass, - pipeline_layout, - descriptor_set_layout, - descriptor_pool, + depth_attachments: partial.depth_attachments, + render_finished: partial.render_finished, + render_pass: partial + .render_pass + .expect("render pass is present after successful swapchain setup"), + pipeline_layout: partial + .pipeline_layout + .expect("pipeline layout is present after successful swapchain setup"), + descriptor_set_layout: partial + .descriptor_set_layout + .expect("descriptor set layout is present after successful swapchain setup"), + descriptor_pool: partial + .descriptor_pool + .expect("descriptor pool is present after successful swapchain setup"), descriptor_sets, - sampler, + sampler: partial + .sampler + .expect("sampler is present after successful swapchain setup"), + frame_uniform_buffer: partial + .frame_uniform_buffer + .expect("frame uniform buffer is present after successful swapchain setup"), + frame_uniform_stride: partial.frame_uniform_stride, + material_specular_buffer: partial + .material_specular_buffer + .expect("material specular buffer is present after successful swapchain setup"), + material_specular_stride: partial.material_specular_stride, pipelines: partial.pipelines, framebuffers: partial.framebuffers, command_buffers: partial.command_buffers, @@ -210,8 +344,11 @@ fn create_swapchain_pipeline_bundle( format: i32, extent: (u32, u32), textures: &[VulkanAllocatedImage], + texture_slot_indices: &[usize], draw_ranges: &[super::VulkanStaticDrawRange], depth_request: DepthStencilSupport, + frame_uniform_buffer: &VulkanAllocatedBuffer, + material_specular_buffer: &VulkanAllocatedBuffer, ) -> Result< ( VulkanDepthAttachment, @@ -234,7 +371,14 @@ fn create_swapchain_pipeline_bundle( } }; let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) = - create_texture_descriptor_bundle(device, textures).inspect_err(|_| { + create_texture_descriptor_bundle( + device, + textures, + texture_slot_indices, + frame_uniform_buffer, + material_specular_buffer, + ) + .inspect_err(|_| { // SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure. unsafe { device.device().destroy_render_pass(render_pass, None) }; destroy_depth_attachment(device, &depth_attachment); @@ -289,12 +433,23 @@ fn create_swapchain_framebuffers( device: &VulkanLogicalDeviceProbe, render_pass: vk::RenderPass, image_views: &[vk::ImageView], - depth_view: vk::ImageView, + depth_attachments: &[VulkanDepthAttachment], extent: (u32, u32), ) -> Result, VulkanSmokeRendererError> { + if image_views.len() != depth_attachments.len() { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "swapchain color/depth attachment count", + }); + } let mut framebuffers = Vec::with_capacity(image_views.len()); - for image_view in image_views.iter().copied() { - match create_framebuffer(device, render_pass, image_view, depth_view, extent) { + for (image_view, depth_attachment) in image_views.iter().copied().zip(depth_attachments) { + match create_framebuffer( + device, + render_pass, + image_view, + depth_attachment.image.view, + extent, + ) { Ok(framebuffer) => framebuffers.push(framebuffer), Err(error) => { // SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure. @@ -339,7 +494,7 @@ fn create_image_view( .image(image) .view_type(vk::ImageViewType::TYPE_2D) .format(vk::Format::from_raw(format)) - .subresource_range(color_subresource_range()); + .subresource_range(color_subresource_range(1)); // SAFETY: The image comes from the live swapchain and the subresource range covers its color aspect. unsafe { device.device().create_image_view(&create_info, None) }.map_err(|error| { VulkanSmokeRendererError::VulkanOperation { @@ -384,10 +539,21 @@ fn create_render_pass( let dependency = vk::SubpassDependency::default() .src_subpass(vk::SUBPASS_EXTERNAL) .dst_subpass(0) - .src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) - .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) + .src_stage_mask( + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT + | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS + | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS, + ) + .dst_stage_mask( + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT + | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS + | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS, + ) .src_access_mask(vk::AccessFlags::empty()) - .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE); + .dst_access_mask( + vk::AccessFlags::COLOR_ATTACHMENT_WRITE + | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE, + ); let attachments = [color_attachment, depth_attachment]; let subpasses = [subpass]; let dependencies = [dependency]; @@ -409,16 +575,16 @@ fn create_pipeline_layout( descriptor_set_layout: vk::DescriptorSetLayout, ) -> Result { let set_layouts = [descriptor_set_layout]; - let push_constant_ranges = [ - vk::PushConstantRange::default() - .stage_flags(vk::ShaderStageFlags::VERTEX) - .offset(0) - .size(64), - vk::PushConstantRange::default() - .stage_flags(vk::ShaderStageFlags::FRAGMENT) - .offset(64) - .size(u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX)), - ]; + // 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 + // by the dynamic frame UBO at descriptor binding 4. + // Expose the complete block from offset zero so drivers validate the + // fragment member's declared offset against the pipeline layout. + let push_constant_ranges = [vk::PushConstantRange::default() + .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT) + .offset(0) + .size(32 * u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX))]; let create_info = vk::PipelineLayoutCreateInfo::default() .set_layouts(&set_layouts) .push_constant_ranges(&push_constant_ranges); @@ -434,6 +600,9 @@ fn create_pipeline_layout( fn create_texture_descriptor_bundle( device: &VulkanLogicalDeviceProbe, textures: &[VulkanAllocatedImage], + texture_slot_indices: &[usize], + frame_uniform_buffer: &VulkanAllocatedBuffer, + material_specular_buffer: &VulkanAllocatedBuffer, ) -> Result< ( vk::DescriptorSetLayout, @@ -443,12 +612,32 @@ fn create_texture_descriptor_bundle( ), VulkanSmokeRendererError, > { - let binding = vk::DescriptorSetLayoutBinding::default() - .binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .descriptor_count(1) - .stage_flags(vk::ShaderStageFlags::FRAGMENT); - let bindings = [binding]; + const TEXTURES_PER_MATERIAL: usize = 4; + const FRAME_BINDING: u32 = 4; + const MATERIAL_SPECULAR_BINDING: u32 = 5; + let mut bindings = (0..TEXTURES_PER_MATERIAL) + .map(|binding| { + vk::DescriptorSetLayoutBinding::default() + .binding(u32::try_from(binding).unwrap_or(u32::MAX)) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::FRAGMENT) + }) + .collect::>(); + bindings.push( + vk::DescriptorSetLayoutBinding::default() + .binding(FRAME_BINDING) + .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT), + ); + bindings.push( + vk::DescriptorSetLayoutBinding::default() + .binding(MATERIAL_SPECULAR_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 { @@ -460,23 +649,40 @@ fn create_texture_descriptor_bundle( context: "vkCreateDescriptorSetLayout", result, })?; - let texture_count = u32::try_from(textures.len()).map_err(|_| { + let texture_count = u32::try_from(texture_slot_indices.len()).map_err(|_| { VulkanSmokeRendererError::InvariantViolation { context: "static material texture count exceeds Vulkan descriptor limit", } })?; - if texture_count == 0 { + if texture_count == 0 + || !texture_slot_indices + .len() + .is_multiple_of(TEXTURES_PER_MATERIAL) + || texture_slot_indices + .iter() + .any(|texture_index| *texture_index >= textures.len()) + { + // SAFETY: The layout was created above on this device and is rolled + // back before reporting malformed material-slot input. + unsafe { device.device().destroy_descriptor_set_layout(layout, None) }; return Err(VulkanSmokeRendererError::InvariantViolation { - context: "static material texture list is empty", + context: "static material texture slots are invalid", }); } + let material_count = texture_count / u32::try_from(TEXTURES_PER_MATERIAL).unwrap_or(1); let pool_size = vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .descriptor_count(texture_count); - let pool_sizes = [pool_size]; + 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() + .ty(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .descriptor_count(material_count); + let pool_sizes = [pool_size, frame_pool_size, material_specular_pool_size]; let pool_info = vk::DescriptorPoolCreateInfo::default() .pool_sizes(&pool_sizes) - .max_sets(texture_count); + .max_sets(material_count); let pool = // SAFETY: The pool description is stack-owned and reserves exactly one descriptor. unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| { @@ -487,7 +693,7 @@ fn create_texture_descriptor_bundle( result, } })?; - let layouts = vec![layout; textures.len()]; + let layouts = vec![layout; usize::try_from(material_count).unwrap_or(0)]; let allocate_info = vk::DescriptorSetAllocateInfo::default() .descriptor_pool(pool) .set_layouts(&layouts); @@ -511,7 +717,9 @@ fn create_texture_descriptor_bundle( .address_mode_u(vk::SamplerAddressMode::REPEAT) .address_mode_v(vk::SamplerAddressMode::REPEAT) .address_mode_w(vk::SamplerAddressMode::REPEAT) - .max_lod(0.0); + // The uploaded images carry a complete CPU-generated mip chain; the + // sampler clamps to the largest legal level for each image. + .max_lod(16.0); let sampler = // SAFETY: The sampler create info is stack-owned and has no unsupported optional features. unsafe { device.device().create_sampler(&sampler_info, None) }.map_err(|result| { @@ -525,27 +733,51 @@ fn create_texture_descriptor_bundle( result, } })?; - let image_infos = textures + let image_infos = texture_slot_indices .iter() - .map(|texture| { + .map(|texture_index| { + let texture = &textures[*texture_index]; vk::DescriptorImageInfo::default() .sampler(sampler) .image_view(texture.view) .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) }) .collect::>(); - let writes = descriptor_sets - .iter() - .copied() - .zip(image_infos.iter()) - .map(|(descriptor_set, image_info)| { + let frame_info = vk::DescriptorBufferInfo::default() + .buffer(frame_uniform_buffer.buffer) + .offset(0) + .range(VULKAN_FRAME_UNIFORM_BYTES as u64); + let material_specular_info = vk::DescriptorBufferInfo::default() + .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)); + 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]; + writes.push( + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(u32::try_from(binding).unwrap_or(u32::MAX)) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(std::slice::from_ref(image_info)), + ); + } + writes.push( vk::WriteDescriptorSet::default() .dst_set(descriptor_set) - .dst_binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .image_info(std::slice::from_ref(image_info)) - }) - .collect::>(); + .dst_binding(FRAME_BINDING) + .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) + .buffer_info(std::slice::from_ref(&frame_info)), + ); + writes.push( + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(MATERIAL_SPECULAR_BINDING) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .buffer_info(std::slice::from_ref(&material_specular_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, &[]) }; Ok((layout, pool, descriptor_sets, sampler)) @@ -598,15 +830,16 @@ fn create_graphics_pipeline( extent: (u32, u32), state: LegacyPipelineState, ) -> Result { - let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?; - let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) { - Ok(module) => module, - Err(error) => { - // SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure. - unsafe { device.device().destroy_shader_module(vertex_shader, None) }; - return Err(error); - } - }; + let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS, "vertex")?; + let fragment_shader = + match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS, "fragment") { + Ok(module) => module, + Err(error) => { + // SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure. + unsafe { device.device().destroy_shader_module(vertex_shader, None) }; + return Err(error); + } + }; let entry_point = c"main"; let shader_stages = [ vk::PipelineShaderStageCreateInfo::default() @@ -620,7 +853,7 @@ fn create_graphics_pipeline( ]; let vertex_binding = vk::VertexInputBindingDescription::default() .binding(0) - .stride(u32::try_from(8 * std::mem::size_of::()).unwrap_or(u32::MAX)) + .stride(u32::try_from(14 * std::mem::size_of::()).unwrap_or(u32::MAX)) .input_rate(vk::VertexInputRate::VERTEX); let vertex_attributes = [ vk::VertexInputAttributeDescription::default() @@ -636,8 +869,23 @@ fn create_graphics_pipeline( vk::VertexInputAttributeDescription::default() .binding(0) .location(2) - .format(vk::Format::R32G32_SFLOAT) + .format(vk::Format::R32G32B32_SFLOAT) .offset(u32::try_from(6 * std::mem::size_of::()).unwrap_or(u32::MAX)), + vk::VertexInputAttributeDescription::default() + .binding(0) + .location(3) + .format(vk::Format::R32G32_SFLOAT) + .offset(u32::try_from(9 * std::mem::size_of::()).unwrap_or(u32::MAX)), + vk::VertexInputAttributeDescription::default() + .binding(0) + .location(4) + .format(vk::Format::R32G32_SFLOAT) + .offset(u32::try_from(11 * std::mem::size_of::()).unwrap_or(u32::MAX)), + vk::VertexInputAttributeDescription::default() + .binding(0) + .location(5) + .format(vk::Format::R32_SFLOAT) + .offset(u32::try_from(13 * std::mem::size_of::()).unwrap_or(u32::MAX)), ]; let vertex_bindings = [vertex_binding]; let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default() @@ -685,6 +933,21 @@ fn create_graphics_pipeline( .depth_compare_op(vk::CompareOp::LESS_OR_EQUAL) .depth_bounds_test_enable(false) .stencil_test_enable(false); + let (blend_enable, src_color_blend_factor, dst_color_blend_factor) = match state.blend { + LegacyBlendMode::Opaque => (false, vk::BlendFactor::ONE, vk::BlendFactor::ZERO), + LegacyBlendMode::SourceAlpha => ( + true, + vk::BlendFactor::SRC_ALPHA, + vk::BlendFactor::ONE_MINUS_SRC_ALPHA, + ), + LegacyBlendMode::Additive => (true, vk::BlendFactor::SRC_ALPHA, vk::BlendFactor::ONE), + LegacyBlendMode::ZeroSourceColor => { + (true, vk::BlendFactor::ZERO, vk::BlendFactor::SRC_COLOR) + } + LegacyBlendMode::DestColorSourceColor => { + (true, vk::BlendFactor::DST_COLOR, vk::BlendFactor::SRC_COLOR) + } + }; let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default() .color_write_mask( vk::ColorComponentFlags::R @@ -692,12 +955,12 @@ fn create_graphics_pipeline( | vk::ColorComponentFlags::B | vk::ColorComponentFlags::A, ) - .blend_enable(state.blend == LegacyBlendMode::SourceAlpha) - .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA) - .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) + .blend_enable(blend_enable) + .src_color_blend_factor(src_color_blend_factor) + .dst_color_blend_factor(dst_color_blend_factor) .color_blend_op(vk::BlendOp::ADD) - .src_alpha_blend_factor(vk::BlendFactor::ONE) - .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) + .src_alpha_blend_factor(src_color_blend_factor) + .dst_alpha_blend_factor(dst_color_blend_factor) .alpha_blend_op(vk::BlendOp::ADD); let color_blend_attachments = [color_blend_attachment]; let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default() @@ -738,12 +1001,17 @@ fn create_graphics_pipeline( fn create_shader_module( device: &VulkanLogicalDeviceProbe, words: &[u32], + stage: &'static str, ) -> Result { let create_info = vk::ShaderModuleCreateInfo::default().code(words); // SAFETY: The SPIR-V slice points to static checked-in words and lives for the duration of the call. unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| { VulkanSmokeRendererError::VulkanOperation { - context: "vkCreateShaderModule", + context: if stage == "vertex" { + "vkCreateShaderModule(vertex)" + } else { + "vkCreateShaderModule(fragment)" + }, result: error, } }) @@ -821,7 +1089,9 @@ pub(super) fn destroy_swapchain_resources( device .device() .destroy_render_pass(resources.render_pass, None); - destroy_depth_attachment(device, &resources.depth_attachment); + for depth_attachment in resources.depth_attachments { + destroy_depth_attachment(device, &depth_attachment); + } for image_view in resources.image_views { device.device().destroy_image_view(image_view, None); } @@ -829,6 +1099,21 @@ pub(super) fn destroy_swapchain_resources( device.device().destroy_buffer(buffer.buffer, None); device.device().free_memory(buffer.memory, None); } + for semaphore in resources.render_finished { + device.device().destroy_semaphore(semaphore, None); + } + device + .device() + .destroy_buffer(resources.frame_uniform_buffer.buffer, None); + device + .device() + .free_memory(resources.frame_uniform_buffer.memory, None); + device + .device() + .destroy_buffer(resources.material_specular_buffer.buffer, None); + device + .device() + .free_memory(resources.material_specular_buffer.memory, None); } } @@ -871,7 +1156,7 @@ fn destroy_partial_swapchain_resources( if let Some(render_pass) = partial.render_pass { device.device().destroy_render_pass(render_pass, None); } - if let Some(depth_attachment) = partial.depth_attachment { + for depth_attachment in partial.depth_attachments { destroy_depth_attachment(device, &depth_attachment); } for image_view in partial.image_views { @@ -881,5 +1166,16 @@ fn destroy_partial_swapchain_resources( device.device().destroy_buffer(buffer.buffer, None); device.device().free_memory(buffer.memory, None); } + for semaphore in partial.render_finished { + device.device().destroy_semaphore(semaphore, None); + } + if let Some(buffer) = partial.frame_uniform_buffer { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } + if let Some(buffer) = partial.material_specular_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 740c13d..b8a3d71 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -386,13 +386,13 @@ fn static_surface_extension_name_is_decoded() { } #[test] -fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() { +fn swapchain_plan_prefers_unorm_mailbox_and_clamps_extent() { let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan"); assert_eq!( plan.format, VulkanSurfaceFormat { - format: vk::Format::B8G8R8A8_SRGB.as_raw(), + format: vk::Format::R8G8B8A8_UNORM.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), } ); @@ -444,7 +444,7 @@ fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() { assert_eq!( plan.format, VulkanSurfaceFormat { - format: vk::Format::B8G8R8A8_SRGB.as_raw(), + format: vk::Format::R8G8B8A8_UNORM.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), } ); @@ -479,6 +479,66 @@ fn checked_in_shaders_have_valid_spirv_containers() { assert_eq!(validate_triangle_shaders(), Ok(())); } +#[test] +fn fragment_shader_dispatches_all_four_material_modes() { + let fragment = include_str!("../../shaders/triangle.frag"); + assert!(fragment.contains("int mode = int(round(material.material_mode));")); + assert!(fragment.contains("mode == 0")); + assert!(fragment.contains("mode == 1")); + assert!(fragment.contains("mode == 2")); + assert!(fragment.contains("mode == 3")); + assert!(!fragment.contains("material_mode > 1.5")); + assert!(!fragment.contains("material_mode > 2.5")); +} + +#[test] +fn native_specular_reference_uses_squared_power_and_total_overflow_curve() { + fn reference_power(cosine: f32, power: u8) -> f32 { + if power == 0 { + return 0.0; + } + let mut result = cosine; + for _ in 0..power.saturating_sub(1) { + result *= result; + } + result + } + fn reference_curve(value: f32) -> f32 { + if value <= 1.0 { + 0.8 * value + } else if value <= 3.0 { + 0.1 * value + 0.7 + } else { + 1.0 + } + } + + assert_eq!(reference_power(0.5, 0), 0.0); + assert!((reference_power(0.5, 1) - 0.5).abs() < 1e-6); + assert!((reference_power(0.5, 2) - 0.25).abs() < 1e-6); + assert!((reference_power(0.5, 3) - 0.0625).abs() < 1e-6); + let compressed_diffuse = 1.5; + let material_specular = 0.75; + assert!((reference_curve(material_specular + (compressed_diffuse - 1.0)) - 0.825).abs() < 1e-6); + + let vertex = include_str!("../../shaders/triangle.vert"); + assert!(vertex.contains("result *= result;")); + assert!( + vertex.contains("vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));") + ); + assert!(vertex.contains("if (power <= 0.0 || cosine <= 0.0)")); +} + +#[test] +fn shader_computes_radial_fog_per_vertex_and_interpolates_it() { + let vertex = include_str!("../../shaders/triangle.vert"); + let fragment = include_str!("../../shaders/triangle.frag"); + assert!(vertex.contains("out_fog_factor = native_fog_factor(in_position);")); + assert!(fragment.contains("layout(location = 4) in float in_fog_factor;")); + assert!(!fragment.contains("distance(in_world_position")); + assert!(!fragment.contains("float fog_factor()")); +} + #[test] fn shader_container_rejects_invalid_spirv() { assert_eq!( diff --git a/adapters/fparkan-render-vulkan/src/policy.rs b/adapters/fparkan-render-vulkan/src/policy.rs index ce7b59e..e1f01c8 100644 --- a/adapters/fparkan-render-vulkan/src/policy.rs +++ b/adapters/fparkan-render-vulkan/src/policy.rs @@ -458,9 +458,24 @@ fn select_surface_format( .iter() .copied() .find(|format| { - format.format == vk::Format::B8G8R8A8_SRGB.as_raw() + format.format == vk::Format::R8G8B8A8_UNORM.as_raw() && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw() }) + .or_else(|| { + formats.iter().copied().find(|format| { + format.format == vk::Format::B8G8R8A8_UNORM.as_raw() + && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw() + }) + }) + // Keep an sRGB attachment as the final compatibility fallback. The + // fragment shader emits legacy byte-equivalent linear values, so an + // UNORM attachment is preferred whenever the surface exposes one. + .or_else(|| { + formats.iter().copied().find(|format| { + format.format == vk::Format::B8G8R8A8_SRGB.as_raw() + && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw() + }) + }) .or_else(|| formats.first().copied()) .ok_or(VulkanSwapchainError::MissingSurfaceFormat) } @@ -470,7 +485,7 @@ fn undefined_surface_format_override( ) -> Option { match formats { [format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat { - format: vk::Format::B8G8R8A8_SRGB.as_raw(), + format: vk::Format::R8G8B8A8_UNORM.as_raw(), color_space: format.color_space, }), _ => None, -- cgit v1.2.3