aboutsummaryrefslogtreecommitdiff
path: root/adapters/fparkan-render-vulkan
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-11 12:12:50 +0300
committerValentin Popov <valentin@popov.link>2026-10-11 12:12:50 +0300
commita8faba8aad441e87f8a8b94c148c01f9c075d621 (patch)
tree97aa4972990edba9991ff2e125edac3a7883f50f /adapters/fparkan-render-vulkan
parentaa51f3574d3d4d4e0fa59653eedd718fceb5d1bd (diff)
downloadfparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.tar.xz
fparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.zip
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.
Diffstat (limited to 'adapters/fparkan-render-vulkan')
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag126
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag.spvbin1184 -> 10388 bytes
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert264
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin1432 -> 18580 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs934
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs39
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs694
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs1570
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs671
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs466
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/tests.rs66
-rw-r--r--adapters/fparkan-render-vulkan/src/policy.rs19
12 files changed, 4534 insertions, 315 deletions
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
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
Binary files 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
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files 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::<Result<Vec<_>, _>>()?;
@@ -282,6 +313,17 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform(
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<Pose>,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
if !transform
.translation
.iter()
@@ -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::<Result<Vec<_>, _>>()?;
@@ -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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<u16>,
+ node_index: usize,
+) -> Option<VulkanNodePose> {
+ 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<u16>,
+ node_index: usize,
+) -> Option<VulkanNodePose> {
+ 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<VulkanNodePose> {
+ 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<VulkanNodePose> {
+ 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<Pose> {
+ 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<u16>,
+ node_index: usize,
+ root_replaced: bool,
+) -> Option<VulkanNodePose> {
+ 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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<fparkan_animation::NodePoseBuffer>,
+ mount_pose: Option<Pose>,
+ hide_root_node: bool,
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<fparkan_animation::NodePoseBuffer> {
+ 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<u32>,
draw_ranges: &mut Vec<VulkanStaticDrawRange>,
) -> 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::<Result<Vec<_>, _>>()?;
@@ -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::<Result<Vec<_>, _>>()?;
@@ -695,6 +1181,38 @@ fn static_terrain_indices(terrain: &LandMeshDocument) -> Result<Vec<u32>, 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<Vec<VulkanStaticDrawRange>, 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]
@@ -1011,6 +1548,65 @@ mod tests {
}
#[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::<Vec<_>>();
+ 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(
vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]],
@@ -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,
@@ -1113,6 +1712,248 @@ mod tests {
}
#[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(
[1.0, 0.0, 0.0],
@@ -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<const WORD_COUNT: usize>(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<const WORD_COUNT: usize>(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<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
- let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
- 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<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
+ 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<Vec<u8>, VulkanSmokeRendererError> {
+ let byte_len = vertices
+ .len()
+ .checked_mul(14)
+ .and_then(|count| count.checked_mul(std::mem::size_of::<f32>()))
+ .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::<u8>(), 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<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
- let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
- 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<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
+ 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<Vec<u8>, VulkanSmokeRendererError> {
+ let byte_len = indices
+ .len()
+ .checked_mul(std::mem::size_of::<u32>())
+ .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<VulkanAllocatedImage, VulkanSmokeRendererError> {
+ 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::<f32>()]
+ .copy_from_slice(&value.to_ne_bytes());
+ offset += std::mem::size_of::<f32>();
+ }
+ }
+ 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::<u8>(), 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::<u8>(), 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],
+ &regions,
);
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,
+ &regions,
+ );
+ 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<u8>, 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<u8>,
+ ) -> 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::<Vec<_>>();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 12,
+ &material_lighting,
+ );
+ let material_uv_transforms = self.material_uv_transforms.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material UV transforms",
+ },
+ )?;
+ let material_uv_transforms = material_uv_transforms
+ .iter()
+ .flatten()
+ .flat_map(|value| value.to_ne_bytes())
+ .collect::<Vec<_>>();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 60,
+ &material_uv_transforms,
+ );
+ let 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<bool>],
+ keys: &[Option<f32>],
+) -> Vec<usize> {
+ let mut next = (0..range_count).collect::<Vec<_>>();
+ 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;
@@ -1101,6 +2292,84 @@ mod tests {
}
#[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 = [
(vec![TeardownStep::Instance], vec![TeardownStep::Instance]),
@@ -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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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<Self> {
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::<f32>();
+
+/// 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::<f32>();
+ bytes[offset..offset + std::mem::size_of::<f32>()]
+ .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<VulkanStaticDrawRange>,
}
+/// 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<VulkanStaticTexture>,
+ /// Optional Land1 overlay texture selected by the face tag high byte.
+ pub overlay_texture: Option<VulkanStaticTexture>,
+ /// Optional Land2 detail texture paired with the overlay texture.
+ pub overlay_detail_texture: Option<VulkanStaticTexture>,
+ /// 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<f32>,
+ /// 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<u8>,
+ /// 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<u8>,
}
/// 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<u8>,
+ /// Authored mip levels, including mip zero, when available.
+ ///
+ /// An empty vector requests deterministic CPU generation from `rgba8`.
+ pub mip_levels: Vec<VulkanStaticTextureMip>,
}
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<Vec<usize>, &'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");
}
@@ -312,6 +779,53 @@ 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();
@@ -358,6 +872,54 @@ mod static_mesh_tests {
}
#[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::<f32>();
+ let clip_w = (0..4)
+ .map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 3])
+ .sum::<f32>();
+
+ 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<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
+ /// Positional material slots mapped to the owned image list. Fallback
+ /// stages may share one image without coupling independently animated
+ /// material textures.
+ pub(super) texture_slot_indices: Vec<usize>,
pub(super) textures: Vec<VulkanAllocatedImage>,
+ 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<u32>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
pub(super) draw_texture_indices: Vec<usize>,
+ /// Stable range handles used when recording the native pass order.
+ pub(super) draw_order: Vec<usize>,
+ /// Native camera-sort values returned by the draw-item camera callback.
+ pub(super) draw_range_sort_keys: Vec<Option<f32>>,
+ /// 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<Option<bool>>,
+ /// Pipeline state before transparent queue depth policy is applied.
+ pub(super) draw_range_base_pipeline_states: Vec<LegacyPipelineState>,
+ 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<u16>,
+ /// Whether the host-side specular records need a fence-safe GPU upload.
+ pub(super) material_specular_dirty: bool,
+ /// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native
+ /// sky nebula/stars texture composite, 3 native lightmap composite.
+ pub(super) material_unlit: Vec<f32>,
+ pub(super) readback_enabled: bool,
pub(super) camera: VulkanStaticCamera,
+ pub(super) frame_uniforms: VulkanFrameUniforms,
pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>,
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<vk::ImageView>,
pub(super) images: Vec<vk::Image>,
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
- pub(super) depth_attachment: VulkanDepthAttachment,
+ /// One depth image per swapchain image so frames can overlap safely.
+ pub(super) depth_attachments: Vec<VulkanDepthAttachment>,
+ /// A render-finished semaphore dedicated to each swapchain image.
+ pub(super) render_finished: Vec<vk::Semaphore>,
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<vk::DescriptorSet>,
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<PipelineKey, vk::Pipeline>,
pub(super) framebuffers: Vec<vk::Framebuffer>,
pub(super) command_buffers: Vec<vk::CommandBuffer>,
}
+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<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>,
- depth_attachment: Option<VulkanDepthAttachment>,
+ depth_attachments: Vec<VulkanDepthAttachment>,
+ render_finished: Vec<vk::Semaphore>,
render_pass: Option<vk::RenderPass>,
pipeline_layout: Option<vk::PipelineLayout>,
descriptor_set_layout: Option<vk::DescriptorSetLayout>,
descriptor_pool: Option<vk::DescriptorPool>,
sampler: Option<vk::Sampler>,
+ frame_uniform_buffer: Option<VulkanAllocatedBuffer>,
+ frame_uniform_stride: u64,
+ material_specular_buffer: Option<VulkanAllocatedBuffer>,
+ material_specular_stride: u64,
pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
framebuffers: Vec<vk::Framebuffer>,
command_buffers: Vec<vk::CommandBuffer>,
@@ -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<Vec<vk::Framebuffer>, 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<vk::PipelineLayout, VulkanSmokeRendererError> {
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::<f32>()).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::<f32>()).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::<Vec<_>>();
+ 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::<Vec<_>>();
- 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::<Vec<_>>();
+ .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<vk::Pipeline, VulkanSmokeRendererError> {
- 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::<f32>()).unwrap_or(u32::MAX))
+ .stride(u32::try_from(14 * std::mem::size_of::<f32>()).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::<f32>()).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::<f32>()).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::<f32>()).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::<f32>()).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<vk::ShaderModule, VulkanSmokeRendererError> {
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(),
}
);
@@ -480,6 +480,66 @@ fn checked_in_shaders_have_valid_spirv_containers() {
}
#[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!(
validate_spirv_container("triangle.vert", &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0]),
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<VulkanSurfaceFormat> {
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,