aboutsummaryrefslogtreecommitdiff
path: root/adapters/fparkan-render-vulkan/src
diff options
context:
space:
mode:
Diffstat (limited to 'adapters/fparkan-render-vulkan/src')
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs935
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs67
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/instance.rs44
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs697
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs1582
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs683
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/surface.rs20
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs472
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/tests.rs326
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs6
-rw-r--r--adapters/fparkan-render-vulkan/src/planning_backend.rs168
-rw-r--r--adapters/fparkan-render-vulkan/src/policy.rs183
-rw-r--r--adapters/fparkan-render-vulkan/src/shader.rs82
-rw-r--r--adapters/fparkan-render-vulkan/src/shader_manifest.rs399
14 files changed, 4263 insertions, 1401 deletions
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index 79a42a4..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,7 +738,48 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses(
})
}
-#[allow(clippy::too_many_arguments)]
+/// 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,
@@ -451,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 =
@@ -489,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()
@@ -497,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]);
@@ -522,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
@@ -596,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<_>, _>>()?;
@@ -639,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<_>, _>>()?;
@@ -696,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> {
@@ -719,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;
@@ -734,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)
@@ -755,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,
});
}
@@ -844,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,
@@ -858,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,
}
}
@@ -876,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,
@@ -903,6 +1436,7 @@ mod tests {
positions: Vec::new(),
normals: None,
uv0: None,
+ uv1: None,
indices: Vec::new(),
batches: vec![
batch(0, 0, 0),
@@ -941,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,
}]
);
@@ -957,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]
@@ -973,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,
@@ -985,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]
@@ -1012,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]],
@@ -1049,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,
@@ -1076,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,
@@ -1114,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],
@@ -1152,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,
},
]
@@ -1182,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(),
@@ -1196,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]
@@ -1211,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(),
@@ -1224,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 79ca919..83fdbbf 100644
--- a/adapters/fparkan-render-vulkan/src/ffi.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi.rs
@@ -1,22 +1,3 @@
-#![cfg_attr(
- test,
- allow(
- clippy::cast_possible_truncation,
- clippy::cast_possible_wrap,
- clippy::cast_precision_loss,
- clippy::expect_used,
- clippy::float_cmp,
- clippy::identity_op,
- clippy::too_many_lines,
- clippy::uninlined_format_args,
- clippy::map_unwrap_or,
- clippy::needless_raw_string_hashes,
- clippy::semicolon_if_nothing_returned,
- clippy::type_complexity,
- clippy::panic,
- clippy::unwrap_used
- )
-)]
#![deny(unsafe_op_in_unsafe_fn)]
//! Vulkan adapter facade and migration-ready backend surface contract.
//!
@@ -40,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,
@@ -57,42 +44,48 @@ pub use self::capabilities::{
};
pub use self::instance::{
create_vulkan_instance_probe, plan_vulkan_instance, probe_vulkan_loader,
- render_instance_plan_json, render_loader_probe_report_json, vulkan_entry_symbol_name,
- VulkanInstanceConfig, VulkanInstanceError, VulkanInstancePlan, VulkanInstanceProbe,
- VulkanLoaderError, VulkanLoaderProbeReport,
+ vulkan_entry_symbol_name, VulkanInstanceConfig, VulkanInstanceError, VulkanInstancePlan,
+ VulkanInstanceProbe, VulkanLoaderError, VulkanLoaderProbeReport,
};
#[cfg(test)]
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;
pub use self::surface::{
- create_vulkan_surface_probe, plan_vulkan_surface, render_surface_plan_json, VulkanSurfaceError,
- VulkanSurfacePlan, VulkanSurfaceProbe,
+ create_vulkan_surface_probe, plan_vulkan_surface, VulkanSurfaceError, VulkanSurfacePlan,
+ VulkanSurfaceProbe,
};
pub use self::swapchain::{
create_vulkan_swapchain_probe, create_vulkan_swapchain_probe_for_extent, VulkanSwapchainProbe,
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;
@@ -489,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 {
@@ -504,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/instance.rs b/adapters/fparkan-render-vulkan/src/ffi/instance.rs
index 42b9bdb..463fa03 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/instance.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/instance.rs
@@ -1,7 +1,6 @@
#![allow(unsafe_code)]
use ash::vk;
-use serde::Serialize;
use std::collections::BTreeSet;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
@@ -10,7 +9,6 @@ use super::{
EXT_DEBUG_UTILS_EXTENSION, KHR_PORTABILITY_ENUMERATION_EXTENSION, MIN_VULKAN_API_VERSION,
VALIDATION_LAYER_NAME,
};
-use crate::policy::{format_api_version, serialize_json_or_fallback};
/// Vulkan instance bootstrap configuration.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -236,28 +234,6 @@ pub fn create_vulkan_instance_probe(
})
}
-/// Renders a deterministic JSON Vulkan instance plan.
-#[must_use]
-pub fn render_instance_plan_json(plan: &VulkanInstancePlan) -> String {
- #[derive(Serialize)]
- struct InstancePlanJson<'a> {
- schema: u32,
- create_flags: u32,
- validation_requested: bool,
- enabled_extensions: &'a [String],
- }
-
- serialize_json_or_fallback(
- &InstancePlanJson {
- schema: plan.schema,
- create_flags: plan.create_flags,
- validation_requested: plan.validation_requested,
- enabled_extensions: &plan.enabled_extensions,
- },
- "{\"schema\":0,\"create_flags\":0,\"validation_requested\":false,\"enabled_extensions\":[]}",
- )
-}
-
/// Opens the Vulkan loader and reports the supported instance API version.
///
/// # Errors
@@ -288,26 +264,6 @@ pub fn vulkan_entry_symbol_name() -> &'static CStr {
c"vkGetInstanceProcAddr"
}
-/// Renders a deterministic JSON Vulkan loader report.
-#[must_use]
-pub fn render_loader_probe_report_json(report: &VulkanLoaderProbeReport) -> String {
- #[derive(Serialize)]
- struct LoaderProbeReportJson {
- schema: u32,
- loader_available: bool,
- instance_api: String,
- }
-
- serialize_json_or_fallback(
- &LoaderProbeReportJson {
- schema: report.schema,
- loader_available: report.loader_available,
- instance_api: format_api_version(report.instance_api_version),
- },
- "{\"schema\":0,\"loader_available\":false,\"instance_api\":\"0.0.0\"}",
- )
-}
-
fn available_instance_extensions(entry: &ash::Entry) -> Result<Vec<String>, VulkanInstanceError> {
let available_extensions =
// SAFETY: Enumerating instance extensions reads loader-owned immutable metadata.
diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
index 1d38503..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.
@@ -26,7 +29,6 @@ pub(super) struct VulkanDepthAttachment {
pub(super) format: vk::Format,
}
-#[allow(clippy::too_many_lines)]
pub(super) fn create_depth_attachment(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
@@ -135,6 +137,9 @@ pub(super) fn create_depth_attachment(
image,
memory,
view,
+ width: extent.0,
+ height: extent.1,
+ mip_levels: 1,
},
format,
})
@@ -187,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,
}
@@ -211,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(
@@ -236,30 +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",
)
}
-#[allow(clippy::too_many_lines)]
+/// 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",
)?;
@@ -271,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)
@@ -338,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 {
@@ -352,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.
@@ -373,10 +509,211 @@ pub(super) fn create_static_texture_image(
image,
memory,
view,
+ width: texture.width,
+ height: texture.height,
+ mip_levels,
})
}
-#[allow(clippy::too_many_lines)]
+/// 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,
@@ -384,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)
@@ -410,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)
@@ -418,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)
@@ -454,7 +807,7 @@ fn upload_static_texture(
staging_buffer,
image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
- &[region],
+ &regions,
);
device.device().cmd_pipeline_barrier(
command_buffer,
@@ -503,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,
@@ -627,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,
@@ -665,7 +1246,6 @@ pub(super) fn create_frame_sync(
};
sync.push(VulkanFrameSync {
image_available,
- render_finished,
fence,
});
}
@@ -679,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);
}
}
@@ -741,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 e41f0f9..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,
@@ -17,7 +21,7 @@ use super::{
VulkanValidationReport,
};
use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION;
-use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest};
+use crate::shader::validate_triangle_shaders;
#[cfg(test)]
fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>(
@@ -103,7 +107,7 @@ impl VulkanSmokeRenderer {
///
/// Returns [`VulkanSmokeRendererError`] when Vulkan bootstrap, pipeline creation,
/// memory allocation, or synchronization resource creation fails.
- #[allow(clippy::too_many_lines)]
+
pub fn new(
create_info: &VulkanSmokeRendererCreateInfo,
) -> Result<Self, VulkanSmokeRendererError> {
@@ -120,8 +124,7 @@ impl VulkanSmokeRenderer {
resolve_draw_texture_indices(&create_info.mesh.draw_ranges, &create_info.materials)
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
let bootstrap_progress = create_info.bootstrap_progress.as_ref();
- let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
- .map_err(VulkanSmokeRendererError::ShaderManifest)?;
+ validate_triangle_shaders().map_err(VulkanSmokeRendererError::Shader)?;
let surface_plan = plan_vulkan_surface(Some(create_info.native_handles))
.map_err(VulkanSmokeRendererError::Surface)?;
let mut instance_config = VulkanInstanceConfig::smoke(&create_info.application_name);
@@ -186,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,
@@ -228,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,
@@ -240,7 +458,6 @@ impl VulkanSmokeRenderer {
pending_extent: None,
swapchain_recreate_count: 0,
report: VulkanSmokeRendererReport {
- shader_manifest_hash: shader_manifest.manifest_hash.clone(),
portability_enumeration: instance_config.enable_portability_enumeration,
portability_subset_enabled: false,
device_name: String::new(),
@@ -260,7 +477,6 @@ impl VulkanSmokeRenderer {
let device_ref = renderer.device_ref()?;
let swapchain_ref = renderer.swapchain_ref()?;
renderer.report = VulkanSmokeRendererReport {
- shader_manifest_hash: shader_manifest.manifest_hash,
portability_enumeration: renderer
.instance
.as_ref()
@@ -321,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
@@ -338,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(())
}
@@ -426,7 +1227,7 @@ impl VulkanSmokeRenderer {
///
/// Returns [`VulkanSmokeRendererError`] when synchronization, command recording,
/// submission, or presentation fails.
- #[allow(clippy::too_many_lines)]
+
pub fn draw_frame(&mut self) -> Result<VulkanSmokeFrameOutcome, VulkanSmokeRendererError> {
if let Some(extent) = self.pending_extent.take() {
if extent.0 == 0 || extent.1 == 0 {
@@ -437,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 {
@@ -451,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(
@@ -488,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 {
@@ -495,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]];
@@ -518,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);
}
@@ -610,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,
@@ -624,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;
@@ -632,11 +1477,54 @@ impl VulkanSmokeRenderer {
Ok(())
}
- #[allow(clippy::too_many_lines)]
+ /// 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()?;
@@ -706,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",
@@ -725,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",
@@ -736,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,
@@ -756,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],
+ );
+ }
}
}
@@ -836,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);
}
}
@@ -941,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(
@@ -990,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)
@@ -1010,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;
@@ -1105,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]),
@@ -1222,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 4686eaf..75877ad 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -10,7 +10,7 @@ use super::{
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources,
VulkanValidationMessenger,
};
-use crate::shader_manifest::VulkanShaderManifestError;
+use crate::shader::VulkanShaderError;
/// Creates a live native Vulkan renderer for the Stage 0 smoke loop.
#[derive(Clone, Debug)]
@@ -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);
}
}
@@ -536,7 +1165,7 @@ impl VulkanSmokeBootstrapProgress {
/// Stable snapshot of measured bootstrap progress.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[allow(clippy::struct_excessive_bools)]
+
pub struct VulkanSmokeBootstrapSnapshot {
/// Whether the Vulkan loader was resolved.
pub loader_available: bool,
@@ -562,8 +1191,6 @@ const BOOTSTRAP_SWAPCHAIN_CREATED: u8 = 1 << 5;
/// Stable smoke renderer bootstrap report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSmokeRendererReport {
- /// Checked-in shader manifest hash used by the renderer.
- pub shader_manifest_hash: String,
/// Whether portability enumeration was enabled at instance creation.
pub portability_enumeration: bool,
/// Whether the logical device enabled `VK_KHR_portability_subset`.
@@ -652,8 +1279,8 @@ pub enum VulkanSmokeRendererError {
LogicalDevice(VulkanLogicalDeviceError),
/// Swapchain bootstrap failed.
Swapchain(VulkanSwapchainProbeError),
- /// Shader manifest validation failed.
- ShaderManifest(VulkanShaderManifestError),
+ /// Checked-in shader validation failed.
+ Shader(VulkanShaderError),
/// Vulkan operation failed.
VulkanOperation {
/// Operation context.
@@ -695,7 +1322,7 @@ impl std::fmt::Display for VulkanSmokeRendererError {
Self::Surface(error) => write!(f, "{error}"),
Self::LogicalDevice(error) => write!(f, "{error}"),
Self::Swapchain(error) => write!(f, "{error}"),
- Self::ShaderManifest(error) => write!(f, "{error}"),
+ Self::Shader(error) => write!(f, "{error}"),
Self::VulkanOperation { context, result } => {
write!(f, "{context}: {result:?}")
}
@@ -729,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/surface.rs b/adapters/fparkan-render-vulkan/src/ffi/surface.rs
index 8fc9c5a..65a8b7f 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/surface.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/surface.rs
@@ -2,12 +2,10 @@
use ash::{khr::surface, vk};
use fparkan_platform::NativeWindowHandles;
-use serde::Serialize;
use std::ffi::CStr;
use std::os::raw::c_char;
use super::VulkanInstanceProbe;
-use crate::policy::serialize_json_or_fallback;
/// Deterministic Vulkan surface creation plan.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -132,24 +130,6 @@ pub fn create_vulkan_surface_probe(
})
}
-/// Renders a deterministic JSON Vulkan surface plan.
-#[must_use]
-pub fn render_surface_plan_json(plan: &VulkanSurfacePlan) -> String {
- #[derive(Serialize)]
- struct SurfacePlanJson<'a> {
- schema: u32,
- required_instance_extensions: &'a [String],
- }
-
- serialize_json_or_fallback(
- &SurfacePlanJson {
- schema: plan.schema,
- required_instance_extensions: &plan.required_instance_extensions,
- },
- "{\"schema\":0,\"required_instance_extensions\":[]}",
- )
-}
-
pub(super) fn extension_name(extension: *const c_char) -> Result<String, VulkanSurfaceError> {
// SAFETY: `ash-window` returns extension pointers to static NUL-terminated Vulkan names.
let name = unsafe { CStr::from_ptr(extension) };
diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
index 7558db6..b8049d3 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
@@ -8,44 +8,90 @@ 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>,
}
-#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub(super) fn create_swapchain_resources(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
@@ -54,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,
@@ -75,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,
@@ -100,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) => {
@@ -109,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,
@@ -169,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,
@@ -205,15 +338,17 @@ fn create_swapchain_image_views(
Ok(image_views)
}
-#[allow(clippy::type_complexity)]
fn create_swapchain_pipeline_bundle(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
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,
@@ -236,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);
@@ -291,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.
@@ -341,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 {
@@ -386,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];
@@ -411,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);
@@ -433,10 +597,12 @@ fn create_pipeline_layout(
})
}
-#[allow(clippy::too_many_lines)]
fn create_texture_descriptor_bundle(
device: &VulkanLogicalDeviceProbe,
textures: &[VulkanAllocatedImage],
+ texture_slot_indices: &[usize],
+ frame_uniform_buffer: &VulkanAllocatedBuffer,
+ material_specular_buffer: &VulkanAllocatedBuffer,
) -> Result<
(
vk::DescriptorSetLayout,
@@ -446,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 {
@@ -463,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| {
@@ -490,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);
@@ -514,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| {
@@ -528,38 +733,60 @@ 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))
}
-#[allow(clippy::cast_precision_loss)]
fn extent_component_to_f32(value: u32) -> f32 {
value as f32
}
-#[allow(clippy::too_many_lines)]
fn create_graphics_pipeline_cache(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
@@ -596,7 +823,6 @@ fn create_graphics_pipeline_cache(
Ok(pipelines)
}
-#[allow(clippy::too_many_lines)]
fn create_graphics_pipeline(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
@@ -604,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()
@@ -626,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()
@@ -642,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()
@@ -691,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
@@ -698,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()
@@ -744,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,
}
})
@@ -827,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);
}
@@ -835,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);
}
}
@@ -877,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 {
@@ -887,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 a842c73..b8a3d71 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs
@@ -1,114 +1,9 @@
use super::*;
+use crate::ffi::{SPIRV_MAGIC, SPIRV_VERSION_1_0};
use crate::policy::{KHR_PORTABILITY_SUBSET_EXTENSION, KHR_SWAPCHAIN_EXTENSION};
-use crate::shader_manifest::{
- SHADER_COMPILER_BINARY_SHA256, SHADER_COMPILER_NAME, SHADER_COMPILER_VERSION,
- SHADER_MANIFEST_SCHEMA, SHADER_TARGET_ENV, SPIRV_MAGIC, SPIRV_VALIDATOR_BINARY_SHA256,
- SPIRV_VALIDATOR_NAME, SPIRV_VALIDATOR_VERSION, SPIRV_VERSION_1_0,
- TRIANGLE_VERTEX_COMPILE_COMMAND, TRIANGLE_VERTEX_SOURCE_PATH, TRIANGLE_VERTEX_SOURCE_SHA256,
- TRIANGLE_VERTEX_SPIRV_PATH, TRIANGLE_VERTEX_VALIDATE_COMMAND,
-};
+use crate::shader::{validate_spirv_container, validate_triangle_shaders, VulkanShaderError};
use crate::*;
use fparkan_platform::{DepthStencilSupport, RenderRequest};
-use fparkan_render::{
- DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, LegacyPipelineState, RenderCommand,
- RenderPhase,
-};
-use fparkan_render::{RenderBackend, RenderError};
-
-#[test]
-fn planning_backend_tracks_render_request_and_simulated_present() -> Result<(), RenderError> {
- let mut backend = VulkanPlanningBackend::new();
- let request = RenderRequest {
- presentation: fparkan_platform::PresentationMode::Immediate,
- ..RenderRequest::conservative()
- };
- backend.set_render_request(request);
- assert_eq!(backend.render_request(), request);
- assert_eq!(backend.report().request.current_request, request);
- assert_eq!(backend.report().request.request_updates, 1);
-
- let commands = fparkan_render::RenderCommandList {
- commands: vec![
- RenderCommand::BeginFrame,
- RenderCommand::Draw(DrawCommand {
- id: DrawId(11),
- phase: RenderPhase::Opaque,
- object_id: None,
- mesh: GpuMeshId(1),
- material: GpuMaterialId(2),
- pipeline_key: LegacyPipelineState::default().into(),
- transform: [1.0; 16],
- range: IndexRange { start: 0, count: 3 },
- stable_order: 7,
- }),
- RenderCommand::EndFrame,
- ],
- };
-
- backend.execute(&commands)?;
- assert_eq!(backend.state(), VulkanPlanningBackendState::Configured);
- assert_eq!(backend.report().execution.planned_frames, 1);
- assert_eq!(backend.report().execution.submission_plans, 1);
- assert_eq!(backend.report().execution.simulated_presents, 1);
- assert!(backend.report().execution.last_capture_size > 0);
- assert_eq!(
- backend.report().last_frame_submission,
- Some(VulkanFrameSubmissionPlan {
- schema: 1,
- frames_in_flight: 2,
- command_buffers: 2,
- semaphores_per_frame: 2,
- fences_per_frame: 1,
- draw_count: 1,
- indexed_vertex_count: 3,
- })
- );
- Ok(())
-}
-
-#[test]
-fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> {
- let commands = fparkan_render::RenderCommandList {
- commands: vec![
- RenderCommand::BeginFrame,
- RenderCommand::Draw(DrawCommand {
- id: DrawId(11),
- phase: RenderPhase::Opaque,
- object_id: None,
- mesh: GpuMeshId(1),
- material: GpuMaterialId(2),
- pipeline_key: LegacyPipelineState::default().into(),
- transform: [1.0; 16],
- range: IndexRange { start: 0, count: 3 },
- stable_order: 7,
- }),
- RenderCommand::EndFrame,
- ],
- };
- let swapchain = VulkanSwapchainPlan {
- schema: 1,
- extent: (1, 1),
- format: VulkanSurfaceFormat {
- format: vk::Format::B8G8R8A8_SRGB.as_raw(),
- color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
- },
- present_mode: vk::PresentModeKHR::FIFO.as_raw(),
- image_count: 3,
- image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
- };
-
- let plan = plan_vulkan_frame_submission(&swapchain, &commands)?;
-
- assert_eq!(plan.frames_in_flight, 2);
- assert_eq!(plan.command_buffers, 3);
- assert_eq!(plan.draw_count, 1);
- assert_eq!(plan.indexed_vertex_count, 3);
- assert_eq!(
- render_frame_submission_plan_json(&plan),
- "{\"schema\":1,\"frames_in_flight\":2,\"command_buffers\":3,\"semaphores_per_frame\":2,\"fences_per_frame\":1,\"draw_count\":1,\"indexed_vertex_count\":3}"
- );
- Ok(())
-}
#[test]
fn device_scoring_is_deterministic_and_prefers_discrete_unified_queue() {
@@ -391,38 +286,6 @@ fn capability_report_preserves_informational_sampled_formats_and_limits() {
}
#[test]
-fn capability_report_json_is_stable() {
- let mut rejected = device("Rejected", VulkanDeviceType::IntegratedGpu, 0, true, false);
- rejected.present_modes.clear();
- let report = select_physical_device(&[
- rejected,
- device("GPU \"A\"", VulkanDeviceType::DiscreteGpu, 3, true, false),
- ])
- .expect("selected device");
-
- assert_eq!(
- render_capability_report_json(&report),
- "{\"schema\":1,\"vulkan_api\":\"1.1.0\",\"device_name\":\"GPU \\\"A\\\"\",\"score\":1101,\"graphics_queue_family\":3,\"present_queue_family\":3,\"portability_subset\":false,\"enabled_extensions\":[\"VK_KHR_swapchain\"],\"informational_capabilities\":{\"sampled_color_formats\":[50],\"sampled_depth_formats\":[126],\"limits\":{\"max_image_dimension_2d\":4096,\"max_sampler_allocation_count\":4096,\"max_per_stage_descriptor_samplers\":16,\"max_bound_descriptor_sets\":4}},\"rejected_devices\":[{\"device_name\":\"Rejected\",\"reason_code\":\"missing_present_mode\",\"reason\":\"Vulkan device Rejected has no supported present mode\"}]}"
- );
-}
-
-#[test]
-fn loader_probe_report_json_is_stable() {
- assert_eq!(
- vulkan_entry_symbol_name().to_bytes(),
- b"vkGetInstanceProcAddr"
- );
- assert_eq!(
- render_loader_probe_report_json(&VulkanLoaderProbeReport {
- schema: 1,
- loader_available: true,
- instance_api_version: vk::API_VERSION_1_2,
- }),
- "{\"schema\":1,\"loader_available\":true,\"instance_api\":\"1.2.0\"}"
- );
-}
-
-#[test]
fn loader_error_display_is_actionable() {
assert_eq!(
VulkanLoaderError::Unavailable {
@@ -446,9 +309,15 @@ fn instance_plan_is_sorted_deduplicated_and_portability_aware() {
enable_validation: true,
});
+ assert_eq!(plan.create_flags, 1);
+ assert!(plan.validation_requested);
assert_eq!(
- render_instance_plan_json(&plan),
- "{\"schema\":1,\"create_flags\":1,\"validation_requested\":true,\"enabled_extensions\":[\"VK_EXT_debug_utils\",\"VK_KHR_portability_enumeration\",\"VK_KHR_surface\"]}"
+ plan.enabled_extensions,
+ vec![
+ "VK_EXT_debug_utils".to_string(),
+ KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(),
+ "VK_KHR_surface".to_string(),
+ ]
);
}
@@ -510,20 +379,6 @@ fn surface_plan_requires_native_handles() {
}
#[test]
-fn surface_plan_json_is_stable() {
- assert_eq!(
- render_surface_plan_json(&VulkanSurfacePlan {
- schema: 1,
- required_instance_extensions: vec![
- "VK_KHR_surface".to_string(),
- "VK_EXT_metal_surface".to_string(),
- ],
- }),
- "{\"schema\":1,\"required_instance_extensions\":[\"VK_KHR_surface\",\"VK_EXT_metal_surface\"]}"
- );
-}
-
-#[test]
fn static_surface_extension_name_is_decoded() {
let name = extension_name(ash::khr::surface::NAME.as_ptr()).expect("extension name");
@@ -531,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(),
}
);
@@ -589,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(),
}
);
@@ -620,128 +475,91 @@ fn swapchain_plan_rejects_missing_surface_data_and_empty_extent() {
}
#[test]
-fn swapchain_plan_json_and_recreation_reports_are_stable() {
- let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
- assert_eq!(
- render_swapchain_plan_json(&plan),
- "{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3,\"image_usage\":16}"
- );
-
- let report = swapchain_recreation_report(
- VulkanSwapchainRecreationReason::OutOfDate,
- (1024, 720),
- (1280, 720),
- );
- assert_eq!(
- render_swapchain_recreation_report_json(&report),
- "{\"schema\":1,\"reason\":\"out_of_date\",\"previous_extent\":[1024,720],\"next_extent\":[1280,720]}"
- );
+fn checked_in_shaders_have_valid_spirv_containers() {
+ assert_eq!(validate_triangle_shaders(), Ok(()));
}
#[test]
-fn triangle_shader_manifest_hashes_are_stable() {
- let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
+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"));
+}
- assert_eq!(report.schema, SHADER_MANIFEST_SCHEMA);
- assert_eq!(report.target_env, SHADER_TARGET_ENV);
- assert_eq!(
- report.compiler,
- VulkanShaderToolManifest {
- name: SHADER_COMPILER_NAME,
- version: SHADER_COMPILER_VERSION,
- binary_sha256: SHADER_COMPILER_BINARY_SHA256,
+#[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;
}
- );
- assert_eq!(
- report.validator,
- VulkanShaderToolManifest {
- name: SPIRV_VALIDATOR_NAME,
- version: SPIRV_VALIDATOR_VERSION,
- binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
+ let mut result = cosine;
+ for _ in 0..power.saturating_sub(1) {
+ result *= result;
}
- );
- assert_eq!(report.modules.len(), 2);
- assert_eq!(report.modules[0].name, "triangle.vert");
- assert_eq!(report.modules[0].stage, VulkanShaderStage::Vertex);
- assert_eq!(report.modules[0].source_path, TRIANGLE_VERTEX_SOURCE_PATH);
- assert_eq!(
- report.modules[0].source_sha256,
- TRIANGLE_VERTEX_SOURCE_SHA256
- );
- assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH);
- assert_eq!(report.modules[0].word_count, 358);
- assert_eq!(
- report.modules[0].sha256,
- "4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d"
- );
- assert_eq!(report.modules[0].descriptor_sets, 0);
- assert_eq!(report.modules[0].push_constant_bytes, 64);
- assert_eq!(
- report.modules[0].compile_command,
- TRIANGLE_VERTEX_COMPILE_COMMAND
- );
- assert_eq!(
- report.modules[0].validate_command,
- TRIANGLE_VERTEX_VALIDATE_COMMAND
- );
- assert!(!report.modules[0].interface_hash.is_empty());
- assert_eq!(
- report.modules[1].sha256,
- "8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b"
- );
- assert_eq!(
- report.manifest_hash,
- "cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"
- );
-}
+ 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
+ }
+ }
-#[test]
-fn shader_manifest_report_json_is_stable() {
- let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
- let json = render_shader_manifest_report_json(&report);
-
- assert!(json.contains(SHADER_COMPILER_NAME));
- assert!(json.contains(SPIRV_VALIDATOR_NAME));
- assert!(json.contains(TRIANGLE_VERTEX_SOURCE_PATH));
- assert!(json.contains(TRIANGLE_VERTEX_COMPILE_COMMAND));
+ 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 checked_in_shader_manifest_matches_generated_report() {
- let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
- assert_eq!(
- render_shader_manifest_report_json(&report),
- include_str!("../../shaders/manifest.json").trim()
- );
+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_manifest_rejects_invalid_spirv_containers() {
- let mut module = triangle_shader_manifest().remove(0);
- module.words = &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0];
+fn shader_container_rejects_invalid_spirv() {
assert_eq!(
- validate_shader_manifest(&[module]),
- Err(VulkanShaderManifestError::InvalidMagic {
+ validate_spirv_container("triangle.vert", &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0]),
+ Err(VulkanShaderError::InvalidMagic {
name: "triangle.vert",
found: 0xFFFF_FFFF,
})
);
- let mut module = triangle_shader_manifest().remove(0);
- module.words = &[SPIRV_MAGIC, 0, 0, 1, 0];
assert_eq!(
- validate_shader_manifest(&[module]),
- Err(VulkanShaderManifestError::UnsupportedVersion {
+ validate_spirv_container("triangle.vert", &[SPIRV_MAGIC, 0, 0, 1, 0]),
+ Err(VulkanShaderError::UnsupportedVersion {
name: "triangle.vert",
found: 0,
})
);
- let mut module = triangle_shader_manifest().remove(0);
- module.words = &[SPIRV_MAGIC, SPIRV_VERSION_1_0, 0, 0, 0];
assert_eq!(
- validate_shader_manifest(&[module]),
- Err(VulkanShaderManifestError::InvalidBound {
+ validate_spirv_container("triangle.vert", &[SPIRV_MAGIC, SPIRV_VERSION_1_0, 0, 0, 0]),
+ Err(VulkanShaderError::InvalidBound {
name: "triangle.vert",
})
);
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs
index b039cd1..a5beafd 100644
--- a/adapters/fparkan-render-vulkan/src/lib.rs
+++ b/adapters/fparkan-render-vulkan/src/lib.rs
@@ -2,11 +2,9 @@
//! Vulkan adapter public surface.
mod ffi;
-mod planning_backend;
mod policy;
-mod shader_manifest;
+mod shader;
pub use ffi::*;
-pub use planning_backend::*;
pub use policy::*;
-pub use shader_manifest::*;
+pub use shader::VulkanShaderError;
diff --git a/adapters/fparkan-render-vulkan/src/planning_backend.rs b/adapters/fparkan-render-vulkan/src/planning_backend.rs
deleted file mode 100644
index 9a91712..0000000
--- a/adapters/fparkan-render-vulkan/src/planning_backend.rs
+++ /dev/null
@@ -1,168 +0,0 @@
-use ash::vk;
-use fparkan_platform::RenderRequest;
-use fparkan_render::{
- canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
-};
-
-use crate::{
- plan_vulkan_frame_submission, VulkanFrameSubmissionPlan, VulkanSurfaceFormat,
- VulkanSwapchainPlan,
-};
-
-/// Vulkan backend migration readiness.
-#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
-pub enum VulkanPlanningBackendState {
- /// Planning facade is configured and able to accept command lists.
- #[default]
- Configured,
- /// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
- Degraded,
- /// Adapter has encountered a non-recoverable error.
- Error,
-}
-
-/// Diagnostics for planning-facade request tracking.
-#[derive(Clone, Debug, PartialEq)]
-pub struct VulkanPlanningRequestReport {
- /// Last render request observed by the planning facade.
- pub current_request: RenderRequest,
- /// Number of meaningful request updates applied to the facade.
- pub request_updates: u64,
-}
-
-impl Default for VulkanPlanningRequestReport {
- fn default() -> Self {
- Self {
- current_request: RenderRequest::conservative(),
- request_updates: 0,
- }
- }
-}
-
-/// Diagnostics for planning-facade execution telemetry.
-#[derive(Clone, Debug, Default, PartialEq)]
-pub struct VulkanPlanningExecutionReport {
- /// Total frames planned by the facade.
- pub planned_frames: u64,
- /// Total frame-submission plans emitted by the facade.
- pub submission_plans: u64,
- /// Last command-capture byte size.
- pub last_capture_size: usize,
- /// Number of simulated present calls issued by the planning facade.
- pub simulated_presents: u64,
-}
-
-/// Diagnostics for Vulkan planning backend setup and frame progression.
-#[derive(Clone, Debug, Default, PartialEq)]
-pub struct VulkanPlanningBackendReport {
- /// Request-tracking telemetry.
- pub request: VulkanPlanningRequestReport,
- /// Execution-planning telemetry.
- pub execution: VulkanPlanningExecutionReport,
- /// Last deterministic frame submission plan.
- pub last_frame_submission: Option<VulkanFrameSubmissionPlan>,
-}
-
-/// Vulkan planning backend facade used by the game entrypoint.
-#[derive(Debug)]
-pub struct VulkanPlanningBackend {
- state: VulkanPlanningBackendState,
- report: VulkanPlanningBackendReport,
- swapchain_plan: VulkanSwapchainPlan,
-}
-
-impl Default for VulkanPlanningBackend {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl VulkanPlanningBackend {
- /// Creates a new Vulkan planning backend facade.
- #[must_use]
- pub fn new() -> Self {
- Self {
- state: VulkanPlanningBackendState::Configured,
- report: VulkanPlanningBackendReport::default(),
- swapchain_plan: default_stage0_swapchain_plan(),
- }
- }
-
- /// Replaces active surface/profile request.
- pub fn set_render_request(&mut self, request: RenderRequest) {
- if self.report.request.current_request != request {
- self.report.request.current_request = request;
- self.report.request.request_updates =
- self.report.request.request_updates.saturating_add(1);
- }
- }
-
- /// Returns active render request policy.
- #[must_use]
- pub const fn render_request(&self) -> RenderRequest {
- self.report.request.current_request
- }
-
- /// Replaces active swapchain plan used for frame submission planning.
- pub fn set_swapchain_plan(&mut self, plan: VulkanSwapchainPlan) {
- self.swapchain_plan = plan;
- }
-
- /// Returns active swapchain plan.
- #[must_use]
- pub const fn swapchain_plan(&self) -> &VulkanSwapchainPlan {
- &self.swapchain_plan
- }
-
- /// Returns adapter state.
- #[must_use]
- pub const fn state(&self) -> VulkanPlanningBackendState {
- self.state
- }
-
- /// Returns backend report.
- #[must_use]
- pub fn report(&self) -> &VulkanPlanningBackendReport {
- &self.report
- }
-
- fn simulate_present(&mut self) {
- self.report.execution.simulated_presents =
- self.report.execution.simulated_presents.saturating_add(1);
- }
-}
-
-impl RenderBackend for VulkanPlanningBackend {
- fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
- if !matches!(
- self.state,
- VulkanPlanningBackendState::Configured | VulkanPlanningBackendState::Degraded
- ) {
- return Err(RenderError::InvalidRange);
- }
- let capture = canonical_capture(commands)?;
- let frame_plan = plan_vulkan_frame_submission(&self.swapchain_plan, commands)?;
- self.report.execution.planned_frames =
- self.report.execution.planned_frames.saturating_add(1);
- self.report.execution.submission_plans =
- self.report.execution.submission_plans.saturating_add(1);
- self.report.execution.last_capture_size = capture.len();
- self.report.last_frame_submission = Some(frame_plan);
- self.simulate_present();
- Ok(FrameOutput)
- }
-}
-
-fn default_stage0_swapchain_plan() -> VulkanSwapchainPlan {
- VulkanSwapchainPlan {
- schema: 1,
- extent: (1, 1),
- format: VulkanSurfaceFormat {
- format: vk::Format::B8G8R8A8_SRGB.as_raw(),
- color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
- },
- present_mode: vk::PresentModeKHR::FIFO.as_raw(),
- image_count: 2,
- image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
- }
-}
diff --git a/adapters/fparkan-render-vulkan/src/policy.rs b/adapters/fparkan-render-vulkan/src/policy.rs
index c79b8dc..e1f01c8 100644
--- a/adapters/fparkan-render-vulkan/src/policy.rs
+++ b/adapters/fparkan-render-vulkan/src/policy.rs
@@ -1,7 +1,5 @@
use ash::vk;
use fparkan_platform::{DepthStencilSupport, RenderRequest};
-use fparkan_render::{validate_command_list, RenderCommand, RenderCommandList, RenderError};
-use serde::Serialize;
const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
pub(crate) const KHR_SWAPCHAIN_EXTENSION: &str = "VK_KHR_swapchain";
@@ -147,25 +145,6 @@ pub struct VulkanSwapchainRecreationReport {
pub next_extent: (u32, u32),
}
-/// Deterministic frame submission plan for command buffers and sync objects.
-#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-pub struct VulkanFrameSubmissionPlan {
- /// Report schema version.
- pub schema: u32,
- /// Frames allowed in flight.
- pub frames_in_flight: u32,
- /// Swapchain-backed primary command buffers.
- pub command_buffers: u32,
- /// Binary semaphores allocated per frame.
- pub semaphores_per_frame: u32,
- /// Fences allocated per frame.
- pub fences_per_frame: u32,
- /// Draw commands encoded into the frame.
- pub draw_count: u32,
- /// Total indexed vertices submitted by draw commands.
- pub indexed_vertex_count: u32,
-}
-
/// Synthetic physical-device capabilities used by negative tests and reports.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanPhysicalDeviceRecord {
@@ -204,7 +183,7 @@ impl VulkanPhysicalDeviceRecord {
}
/// Informational device limits relevant to future Stage 0 capability growth.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanDeviceLimits {
/// Maximum 2D image dimension supported by the device.
pub max_image_dimension_2d: u32,
@@ -217,7 +196,7 @@ pub struct VulkanDeviceLimits {
}
/// Informational capabilities preserved in deterministic Stage 0 reports.
-#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanInformationalCapabilities {
/// Color formats that support sampled-image usage.
pub sampled_color_formats: Vec<i32>,
@@ -253,7 +232,7 @@ pub struct VulkanCapabilityReport {
}
/// Deterministic rejection reason for an unsuitable physical device.
-#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanRejectedDeviceReport {
/// Human-readable device name.
pub device_name: String,
@@ -446,136 +425,6 @@ pub const fn swapchain_recreation_report(
}
}
-/// Builds a deterministic frame submission plan for a validated command list.
-///
-/// Stage 0 keeps this as a pure planning boundary so command-pool, command-buffer
-/// and synchronization policy can be tested without requiring a native surface.
-///
-/// # Errors
-///
-/// Returns [`RenderError`] when the command list has invalid frame framing,
-/// ordering, draw ranges, mesh bounds, or non-finite transforms.
-pub fn plan_vulkan_frame_submission(
- swapchain: &VulkanSwapchainPlan,
- commands: &RenderCommandList,
-) -> Result<VulkanFrameSubmissionPlan, RenderError> {
- validate_command_list(commands)?;
- let mut draw_count = 0_u32;
- let mut indexed_vertex_count = 0_u32;
- for command in &commands.commands {
- if let RenderCommand::Draw(draw) = command {
- draw_count = draw_count.saturating_add(1);
- indexed_vertex_count = indexed_vertex_count.saturating_add(draw.range.count);
- }
- }
- Ok(VulkanFrameSubmissionPlan {
- schema: 1,
- frames_in_flight: swapchain.image_count.clamp(1, 2),
- command_buffers: swapchain.image_count,
- semaphores_per_frame: 2,
- fences_per_frame: 1,
- draw_count,
- indexed_vertex_count,
- })
-}
-
-/// Renders a deterministic JSON capability report.
-#[must_use]
-pub fn render_capability_report_json(report: &VulkanCapabilityReport) -> String {
- #[derive(Serialize)]
- struct CapabilityReportJson<'a> {
- schema: u32,
- vulkan_api: String,
- device_name: &'a str,
- score: i32,
- graphics_queue_family: u32,
- present_queue_family: u32,
- portability_subset: bool,
- enabled_extensions: &'a [String],
- informational_capabilities: &'a VulkanInformationalCapabilities,
- rejected_devices: &'a [VulkanRejectedDeviceReport],
- }
-
- serialize_json_or_fallback(
- &CapabilityReportJson {
- schema: report.schema,
- vulkan_api: format_api_version(report.vulkan_api_version),
- device_name: &report.device_name,
- score: report.score,
- graphics_queue_family: report.graphics_queue_family,
- present_queue_family: report.present_queue_family,
- portability_subset: report.portability_subset,
- enabled_extensions: &report.enabled_extensions,
- informational_capabilities: &report.informational_capabilities,
- rejected_devices: &report.rejected_devices,
- },
- "{\"schema\":0,\"vulkan_api\":\"0.0.0\",\"device_name\":\"unknown\",\"score\":0,\"graphics_queue_family\":0,\"present_queue_family\":0,\"portability_subset\":false,\"enabled_extensions\":[],\"informational_capabilities\":{\"sampled_color_formats\":[],\"sampled_depth_formats\":[],\"limits\":{\"max_image_dimension_2d\":0,\"max_sampler_allocation_count\":0,\"max_per_stage_descriptor_samplers\":0,\"max_bound_descriptor_sets\":0}},\"rejected_devices\":[]}",
- )
-}
-
-/// Renders a deterministic JSON swapchain plan.
-#[must_use]
-pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String {
- #[derive(Serialize)]
- struct SwapchainPlanJson {
- schema: u32,
- extent: [u32; 2],
- format: i32,
- color_space: i32,
- present_mode: i32,
- image_count: u32,
- image_usage: u32,
- }
-
- serialize_json_or_fallback(
- &SwapchainPlanJson {
- schema: plan.schema,
- extent: [plan.extent.0, plan.extent.1],
- format: plan.format.format,
- color_space: plan.format.color_space,
- present_mode: plan.present_mode,
- image_count: plan.image_count,
- image_usage: plan.image_usage,
- },
- "{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0,\"image_usage\":0}",
- )
-}
-
-/// Renders a deterministic JSON swapchain recreation report.
-#[must_use]
-pub fn render_swapchain_recreation_report_json(report: &VulkanSwapchainRecreationReport) -> String {
- #[derive(Serialize)]
- struct SwapchainRecreationReportJson<'a> {
- schema: u32,
- reason: &'a str,
- previous_extent: [u32; 2],
- next_extent: [u32; 2],
- }
-
- serialize_json_or_fallback(
- &SwapchainRecreationReportJson {
- schema: report.schema,
- reason: match report.reason {
- VulkanSwapchainRecreationReason::Resize => "resize",
- VulkanSwapchainRecreationReason::OutOfDate => "out_of_date",
- VulkanSwapchainRecreationReason::Suboptimal => "suboptimal",
- },
- previous_extent: [report.previous_extent.0, report.previous_extent.1],
- next_extent: [report.next_extent.0, report.next_extent.1],
- },
- "{\"schema\":0,\"reason\":\"unknown\",\"previous_extent\":[0,0],\"next_extent\":[0,0]}",
- )
-}
-
-/// Renders a deterministic JSON frame submission plan.
-#[must_use]
-pub fn render_frame_submission_plan_json(plan: &VulkanFrameSubmissionPlan) -> String {
- serialize_json_or_fallback(
- plan,
- "{\"schema\":0,\"frames_in_flight\":0,\"command_buffers\":0,\"semaphores_per_frame\":0,\"fences_per_frame\":0,\"draw_count\":0,\"indexed_vertex_count\":0}",
- )
-}
-
pub(crate) fn select_composite_alpha(
supported: vk::CompositeAlphaFlagsKHR,
) -> vk::CompositeAlphaFlagsKHR {
@@ -590,13 +439,6 @@ pub(crate) fn select_composite_alpha(
}
}
-pub(crate) fn serialize_json_or_fallback<T: Serialize>(value: &T, fallback: &str) -> String {
- match serde_json::to_string(value) {
- Ok(json) => json,
- Err(_) => fallback.to_string(),
- }
-}
-
pub(crate) fn format_api_version(version: u32) -> String {
format!(
"{}.{}.{}",
@@ -616,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)
}
@@ -628,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,
diff --git a/adapters/fparkan-render-vulkan/src/shader.rs b/adapters/fparkan-render-vulkan/src/shader.rs
new file mode 100644
index 0000000..2e3184a
--- /dev/null
+++ b/adapters/fparkan-render-vulkan/src/shader.rs
@@ -0,0 +1,82 @@
+use crate::ffi::{
+ SPIRV_MAGIC, SPIRV_VERSION_1_0, TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
+};
+
+/// Error found while checking a checked-in SPIR-V module.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum VulkanShaderError {
+ /// SPIR-V module is too short to contain a header.
+ TooShort {
+ /// Shader name.
+ name: &'static str,
+ },
+ /// SPIR-V module has an invalid magic word.
+ InvalidMagic {
+ /// Shader name.
+ name: &'static str,
+ /// Found magic word.
+ found: u32,
+ },
+ /// SPIR-V module version is below 1.0.
+ UnsupportedVersion {
+ /// Shader name.
+ name: &'static str,
+ /// Found version word.
+ found: u32,
+ },
+ /// SPIR-V module declares an invalid bound.
+ InvalidBound {
+ /// Shader name.
+ name: &'static str,
+ },
+}
+
+impl std::fmt::Display for VulkanShaderError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::TooShort { name } => write!(f, "shader {name} SPIR-V module is too short"),
+ Self::InvalidMagic { name, found } => {
+ write!(f, "shader {name} has invalid SPIR-V magic 0x{found:08x}")
+ }
+ Self::UnsupportedVersion { name, found } => write!(
+ f,
+ "shader {name} has unsupported SPIR-V version 0x{found:08x}"
+ ),
+ Self::InvalidBound { name } => write!(f, "shader {name} has invalid SPIR-V bound"),
+ }
+ }
+}
+
+impl std::error::Error for VulkanShaderError {}
+
+/// Checks the checked-in triangle shader containers before creating a pipeline.
+pub(crate) fn validate_triangle_shaders() -> Result<(), VulkanShaderError> {
+ validate_spirv_container("triangle.vert", TRIANGLE_VERTEX_SHADER_WORDS)?;
+ validate_spirv_container("triangle.frag", TRIANGLE_FRAGMENT_SHADER_WORDS)?;
+ Ok(())
+}
+
+pub(crate) fn validate_spirv_container(
+ name: &'static str,
+ words: &[u32],
+) -> Result<(), VulkanShaderError> {
+ if words.len() < 5 {
+ return Err(VulkanShaderError::TooShort { name });
+ }
+ if words[0] != SPIRV_MAGIC {
+ return Err(VulkanShaderError::InvalidMagic {
+ name,
+ found: words[0],
+ });
+ }
+ if words[1] < SPIRV_VERSION_1_0 {
+ return Err(VulkanShaderError::UnsupportedVersion {
+ name,
+ found: words[1],
+ });
+ }
+ if words[3] == 0 {
+ return Err(VulkanShaderError::InvalidBound { name });
+ }
+ Ok(())
+}
diff --git a/adapters/fparkan-render-vulkan/src/shader_manifest.rs b/adapters/fparkan-render-vulkan/src/shader_manifest.rs
deleted file mode 100644
index be86e50..0000000
--- a/adapters/fparkan-render-vulkan/src/shader_manifest.rs
+++ /dev/null
@@ -1,399 +0,0 @@
-use fparkan_binary::{sha256, sha256_hex};
-use serde::Serialize;
-
-pub(crate) use crate::ffi::{
- SPIRV_MAGIC, SPIRV_VERSION_1_0, TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
-};
-use crate::policy::serialize_json_or_fallback;
-
-pub(crate) const SHADER_MANIFEST_SCHEMA: u32 = 2;
-pub(crate) const SHADER_TARGET_ENV: &str = "vulkan1.1";
-pub(crate) const SHADER_COMPILER_NAME: &str = "glslangValidator";
-pub(crate) const SHADER_COMPILER_VERSION: &str = "11:16.3.0";
-pub(crate) const SHADER_COMPILER_BINARY_SHA256: &str =
- "9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f";
-pub(crate) const SPIRV_VALIDATOR_NAME: &str = "spirv-val";
-pub(crate) const SPIRV_VALIDATOR_VERSION: &str =
- "SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00";
-pub(crate) const SPIRV_VALIDATOR_BINARY_SHA256: &str =
- "f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f";
-pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str =
- "adapters/fparkan-render-vulkan/shaders/triangle.vert";
-pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str =
- "fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9";
-pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str =
- "adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
-pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str =
- "glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
-pub(crate) const TRIANGLE_VERTEX_VALIDATE_COMMAND: &str =
- "spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
-const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag";
-const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str =
- "fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329";
-const TRIANGLE_FRAGMENT_SPIRV_PATH: &str =
- "adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
-const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str =
- "glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
-const TRIANGLE_FRAGMENT_VALIDATE_COMMAND: &str =
- "spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
-
-fn shader_compiler_name() -> &'static str {
- option_env!("FPARKAN_BUILD_SHADER_COMPILER_NAME").unwrap_or(SHADER_COMPILER_NAME)
-}
-
-fn shader_compiler_version() -> &'static str {
- option_env!("FPARKAN_BUILD_SHADER_COMPILER_VERSION").unwrap_or(SHADER_COMPILER_VERSION)
-}
-
-fn shader_compiler_binary_sha256() -> &'static str {
- option_env!("FPARKAN_BUILD_SHADER_COMPILER_SHA256").unwrap_or(SHADER_COMPILER_BINARY_SHA256)
-}
-
-fn spirv_validator_name() -> &'static str {
- option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_NAME").unwrap_or(SPIRV_VALIDATOR_NAME)
-}
-
-fn spirv_validator_version() -> &'static str {
- option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_VERSION").unwrap_or(SPIRV_VALIDATOR_VERSION)
-}
-
-fn spirv_validator_binary_sha256() -> &'static str {
- option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_SHA256").unwrap_or(SPIRV_VALIDATOR_BINARY_SHA256)
-}
-
-/// Shader tool metadata pinned in the Stage 0 manifest.
-#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-pub struct VulkanShaderToolManifest {
- /// Tool executable name.
- pub name: &'static str,
- /// Tool version string.
- pub version: &'static str,
- /// Tool binary SHA-256.
- pub binary_sha256: &'static str,
-}
-
-/// Vulkan shader stage.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub enum VulkanShaderStage {
- /// Vertex stage.
- Vertex,
- /// Fragment stage.
- Fragment,
-}
-
-/// Offline SPIR-V shader manifest entry.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct VulkanShaderModuleManifest {
- /// Logical shader name.
- pub name: &'static str,
- /// Shader stage.
- pub stage: VulkanShaderStage,
- /// SPIR-V entry point.
- pub entry_point: &'static str,
- /// Descriptor set count.
- pub descriptor_sets: u32,
- /// Push constant byte count.
- pub push_constant_bytes: u32,
- /// Checked-in GLSL source path.
- pub source_path: &'static str,
- /// Checked-in GLSL source SHA-256.
- pub source_sha256: &'static str,
- /// Checked-in SPIR-V module path.
- pub spirv_path: &'static str,
- /// Exact offline compile command used for the checked-in SPIR-V artifact.
- pub compile_command: &'static str,
- /// Exact offline validation command used for the checked-in SPIR-V artifact.
- pub validate_command: &'static str,
- /// SPIR-V words.
- pub words: &'static [u32],
-}
-
-/// Shader manifest validation report.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct VulkanShaderManifestReport {
- /// Report schema version.
- pub schema: u32,
- /// Explicit Vulkan target environment for the checked-in SPIR-V.
- pub target_env: &'static str,
- /// Pinned compiler metadata.
- pub compiler: VulkanShaderToolManifest,
- /// Pinned validator metadata.
- pub validator: VulkanShaderToolManifest,
- /// Shader module reports.
- pub modules: Vec<VulkanShaderModuleReport>,
- /// Hash of the normalized shader manifest.
- pub manifest_hash: String,
-}
-
-/// Shader module validation report.
-#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-pub struct VulkanShaderModuleReport {
- /// Logical shader name.
- pub name: &'static str,
- /// Shader stage.
- pub stage: VulkanShaderStage,
- /// SPIR-V entry point.
- pub entry_point: &'static str,
- /// Checked-in GLSL source path.
- pub source_path: &'static str,
- /// Checked-in GLSL source SHA-256.
- pub source_sha256: &'static str,
- /// Checked-in SPIR-V module path.
- pub spirv_path: &'static str,
- /// SPIR-V word count.
- pub word_count: usize,
- /// SPIR-V byte hash.
- pub sha256: String,
- /// Descriptor set count.
- pub descriptor_sets: u32,
- /// Push constant byte count.
- pub push_constant_bytes: u32,
- /// Exact offline compile command used for the checked-in SPIR-V artifact.
- pub compile_command: &'static str,
- /// Exact offline validation command used for the checked-in SPIR-V artifact.
- pub validate_command: &'static str,
- /// Stable hash of the reflected interface contract for this module.
- pub interface_hash: String,
-}
-
-/// Shader manifest validation error.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum VulkanShaderManifestError {
- /// SPIR-V module is too short to contain a header.
- TooShort {
- /// Shader name.
- name: &'static str,
- },
- /// SPIR-V module has an invalid magic word.
- InvalidMagic {
- /// Shader name.
- name: &'static str,
- /// Found magic word.
- found: u32,
- },
- /// SPIR-V module version is below 1.0.
- UnsupportedVersion {
- /// Shader name.
- name: &'static str,
- /// Found version word.
- found: u32,
- },
- /// SPIR-V module declares an invalid bound.
- InvalidBound {
- /// Shader name.
- name: &'static str,
- },
-}
-
-impl std::fmt::Display for VulkanShaderManifestError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- Self::TooShort { name } => write!(f, "shader {name} SPIR-V module is too short"),
- Self::InvalidMagic { name, found } => {
- write!(f, "shader {name} has invalid SPIR-V magic 0x{found:08x}")
- }
- Self::UnsupportedVersion { name, found } => write!(
- f,
- "shader {name} has unsupported SPIR-V version 0x{found:08x}"
- ),
- Self::InvalidBound { name } => write!(f, "shader {name} has invalid SPIR-V bound"),
- }
- }
-}
-
-impl std::error::Error for VulkanShaderManifestError {}
-
-/// Returns the built-in Stage 0 indexed-triangle shader manifest.
-#[must_use]
-pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
- vec![
- VulkanShaderModuleManifest {
- name: "triangle.vert",
- stage: VulkanShaderStage::Vertex,
- entry_point: "main",
- descriptor_sets: 0,
- push_constant_bytes: 64,
- source_path: TRIANGLE_VERTEX_SOURCE_PATH,
- source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256,
- spirv_path: TRIANGLE_VERTEX_SPIRV_PATH,
- compile_command: TRIANGLE_VERTEX_COMPILE_COMMAND,
- validate_command: TRIANGLE_VERTEX_VALIDATE_COMMAND,
- words: TRIANGLE_VERTEX_SHADER_WORDS,
- },
- VulkanShaderModuleManifest {
- name: "triangle.frag",
- stage: VulkanShaderStage::Fragment,
- entry_point: "main",
- descriptor_sets: 1,
- push_constant_bytes: 68,
- source_path: TRIANGLE_FRAGMENT_SOURCE_PATH,
- source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256,
- spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH,
- compile_command: TRIANGLE_FRAGMENT_COMPILE_COMMAND,
- validate_command: TRIANGLE_FRAGMENT_VALIDATE_COMMAND,
- words: TRIANGLE_FRAGMENT_SHADER_WORDS,
- },
- ]
-}
-
-/// Validates shader SPIR-V containers and renders a deterministic report.
-///
-/// # Errors
-///
-/// Returns [`VulkanShaderManifestError`] when a module fails Stage 0 SPIR-V
-/// container validation.
-pub fn validate_shader_manifest(
- modules: &[VulkanShaderModuleManifest],
-) -> Result<VulkanShaderManifestReport, VulkanShaderManifestError> {
- let mut reports = Vec::with_capacity(modules.len());
- for module in modules {
- validate_spirv_container(module)?;
- let bytes = spirv_words_to_bytes(module.words);
- reports.push(VulkanShaderModuleReport {
- name: module.name,
- stage: module.stage,
- entry_point: module.entry_point,
- source_path: module.source_path,
- source_sha256: module.source_sha256,
- spirv_path: module.spirv_path,
- word_count: module.words.len(),
- sha256: sha256_hex(&sha256(&bytes)),
- descriptor_sets: module.descriptor_sets,
- push_constant_bytes: module.push_constant_bytes,
- compile_command: module.compile_command,
- validate_command: module.validate_command,
- interface_hash: shader_interface_hash(module),
- });
- }
- let normalized = render_shader_manifest_without_hash_json(&reports);
- Ok(VulkanShaderManifestReport {
- schema: SHADER_MANIFEST_SCHEMA,
- target_env: SHADER_TARGET_ENV,
- compiler: VulkanShaderToolManifest {
- name: shader_compiler_name(),
- version: shader_compiler_version(),
- binary_sha256: shader_compiler_binary_sha256(),
- },
- validator: VulkanShaderToolManifest {
- name: spirv_validator_name(),
- version: spirv_validator_version(),
- binary_sha256: spirv_validator_binary_sha256(),
- },
- modules: reports,
- manifest_hash: sha256_hex(&sha256(normalized.as_bytes())),
- })
-}
-
-/// Renders a deterministic JSON shader manifest report.
-#[must_use]
-pub fn render_shader_manifest_report_json(report: &VulkanShaderManifestReport) -> String {
- #[derive(Serialize)]
- struct ShaderManifestReportJson<'a> {
- schema: u32,
- target_env: &'a str,
- compiler: &'a VulkanShaderToolManifest,
- validator: &'a VulkanShaderToolManifest,
- modules: &'a [VulkanShaderModuleReport],
- manifest_hash: &'a str,
- }
-
- serialize_json_or_fallback(
- &ShaderManifestReportJson {
- schema: report.schema,
- target_env: report.target_env,
- compiler: &report.compiler,
- validator: &report.validator,
- modules: &report.modules,
- manifest_hash: &report.manifest_hash,
- },
- "{\"schema\":0,\"target_env\":\"unknown\",\"compiler\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"validator\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"modules\":[],\"manifest_hash\":\"unknown\"}",
- )
-}
-
-fn shader_interface_hash(module: &VulkanShaderModuleManifest) -> String {
- #[derive(Serialize)]
- struct ShaderInterfaceHashJson<'a> {
- stage: VulkanShaderStage,
- entry_point: &'a str,
- descriptor_sets: u32,
- push_constant_bytes: u32,
- }
-
- let normalized = serialize_json_or_fallback(
- &ShaderInterfaceHashJson {
- stage: module.stage,
- entry_point: module.entry_point,
- descriptor_sets: module.descriptor_sets,
- push_constant_bytes: module.push_constant_bytes,
- },
- "{\"stage\":\"vertex\",\"entry_point\":\"main\",\"descriptor_sets\":0,\"push_constant_bytes\":0}",
- );
- sha256_hex(&sha256(normalized.as_bytes()))
-}
-
-fn validate_spirv_container(
- module: &VulkanShaderModuleManifest,
-) -> Result<(), VulkanShaderManifestError> {
- if module.words.len() < 5 {
- return Err(VulkanShaderManifestError::TooShort { name: module.name });
- }
- if module.words[0] != SPIRV_MAGIC {
- return Err(VulkanShaderManifestError::InvalidMagic {
- name: module.name,
- found: module.words[0],
- });
- }
- if module.words[1] < SPIRV_VERSION_1_0 {
- return Err(VulkanShaderManifestError::UnsupportedVersion {
- name: module.name,
- found: module.words[1],
- });
- }
- if module.words[3] == 0 {
- return Err(VulkanShaderManifestError::InvalidBound { name: module.name });
- }
- Ok(())
-}
-
-fn spirv_words_to_bytes(words: &[u32]) -> Vec<u8> {
- let mut out = Vec::with_capacity(words.len() * 4);
- for word in words {
- out.extend_from_slice(&word.to_le_bytes());
- }
- out
-}
-
-fn render_shader_manifest_without_hash_json(modules: &[VulkanShaderModuleReport]) -> String {
- #[derive(Serialize)]
- struct ShaderManifestWithoutHashJson<'a> {
- schema: u32,
- target_env: &'a str,
- compiler: VulkanShaderToolManifest,
- validator: VulkanShaderToolManifest,
- modules: &'a [VulkanShaderModuleReport],
- }
-
- let json = serialize_json_or_fallback(
- &ShaderManifestWithoutHashJson {
- schema: SHADER_MANIFEST_SCHEMA,
- target_env: SHADER_TARGET_ENV,
- compiler: VulkanShaderToolManifest {
- name: SHADER_COMPILER_NAME,
- version: SHADER_COMPILER_VERSION,
- binary_sha256: SHADER_COMPILER_BINARY_SHA256,
- },
- validator: VulkanShaderToolManifest {
- name: SPIRV_VALIDATOR_NAME,
- version: SPIRV_VALIDATOR_VERSION,
- binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
- },
- modules,
- },
- "{\"schema\":0,\"target_env\":\"unknown\",\"compiler\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"validator\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"modules\":[]}",
- );
- match json.strip_suffix('}') {
- Some(stripped) => stripped.to_string(),
- None => json,
- }
-}