aboutsummaryrefslogtreecommitdiff
path: root/apps/fparkan-game/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'apps/fparkan-game/src/main.rs')
-rw-r--r--apps/fparkan-game/src/main.rs857
1 files changed, 769 insertions, 88 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index abfb6da..3fc12a7 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -32,8 +32,8 @@ use fparkan_path::ResourceName;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO};
use fparkan_render::{
- LegacyBlendMode, LegacyD3d7Projection, LegacyDepthMode, LegacyIron3dEulerTransform,
- LegacyPipelineState, RawCameraTransform,
+ LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode,
+ LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform,
};
use fparkan_render_vulkan::{
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
@@ -54,7 +54,9 @@ use fparkan_runtime::{
loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets,
MissionObjectDraft, MissionRequest,
};
-use fparkan_terrain::{FullSurfaceMask, SurfaceQuery, TerrainMaterialLayers, TerrainWorld};
+use fparkan_terrain::{
+ FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
+};
use fparkan_vfs::DirectoryVfs;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
@@ -183,7 +185,7 @@ struct FreeFlightCamera {
position: [f32; 3],
yaw: f32,
pitch: f32,
- vertical_fov: f32,
+ horizontal_fov: f32,
near_plane: f32,
far_plane: f32,
move_speed: f32,
@@ -238,9 +240,9 @@ impl FreeFlightCamera {
position,
yaw,
pitch,
- vertical_fov: std::f32::consts::FRAC_PI_3,
- near_plane: (radius * 0.01).clamp(0.05, 0.1),
- far_plane: (radius * 32.0).max(100.0),
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
// Keep the default traversal speed comfortable on a full mission
// map; Shift/Ctrl still provide the coarse speed controls.
move_speed: (radius * 0.03).clamp(4.0, 40.0),
@@ -333,6 +335,15 @@ impl FreeFlightCamera {
normalize3(cross3(self.right(), self.forward())).unwrap_or([0.0, 0.0, 1.0])
}
+ fn vertical_fov(self, aspect: f32) -> f32 {
+ let aspect = if aspect.is_finite() && aspect > f32::EPSILON {
+ aspect
+ } else {
+ 16.0 / 9.0
+ };
+ 2.0 * ((self.horizontal_fov * 0.5).tan() / aspect).atan()
+ }
+
fn look_delta(&mut self, dx: f64, dy: f64) {
const SENSITIVITY: f32 = 0.0025;
let dx = dx as f32;
@@ -413,7 +424,7 @@ impl FreeFlightCamera {
-dot3(forward, self.position),
1.0,
];
- let half_fov = (self.vertical_fov * 0.5).clamp(0.05, 1.5);
+ let half_fov = self.vertical_fov(aspect) * 0.5;
let focal = half_fov.tan().recip();
let near_plane = self.near_plane.max(0.001);
let far_plane = self.far_plane.max(near_plane + 0.001);
@@ -447,7 +458,7 @@ impl FreeFlightCamera {
forward: self.forward(),
right: self.right(),
up: self.up(),
- vertical_fov: self.vertical_fov,
+ vertical_fov: self.vertical_fov(aspect),
near_plane: self.near_plane,
far_plane: self.far_plane,
clip_from_world: camera.clip_from_world,
@@ -913,6 +924,113 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -
diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
}
+fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 {
+ let Some(origin) = transform.try_transform_scaled_point([0.0; 3], scale) else {
+ return 0;
+ };
+ let Some(x_axis) = transform
+ .try_transform_scaled_point([1.0, 0.0, 0.0], scale)
+ .map(|point| sub3(point, origin))
+ else {
+ return 0;
+ };
+ let Some(y_axis) = transform
+ .try_transform_scaled_point([0.0, 1.0, 0.0], scale)
+ .map(|point| sub3(point, origin))
+ else {
+ return 0;
+ };
+ let Some(z_axis) = transform
+ .try_transform_scaled_point([0.0, 0.0, 1.0], scale)
+ .map(|point| sub3(point, origin))
+ else {
+ return 0;
+ };
+ // Node and mount poses in the preview are unit-quaternion rotations, so
+ // they have determinant +1 and preserve this exact object-transform sign.
+ let determinant_sign = dot3(x_axis, cross3(y_axis, z_axis));
+ if !determinant_sign.is_finite() || determinant_sign == 0.0 {
+ 0
+ } else if determinant_sign < 0.0 {
+ -1
+ } else {
+ 1
+ }
+}
+
+fn native_msh_cull_mode(batch_flags: u16, handedness: i8) -> LegacyCullMode {
+ if batch_flags & 0x2 != 0 || batch_flags & 0x4000 != 0 || handedness == 0 {
+ LegacyCullMode::Disabled
+ } else if handedness > 0 {
+ // The Y-negated projection maps native d > 0 faces to Vulkan's back
+ // under the renderer's CLOCKWISE front-face convention.
+ LegacyCullMode::FrontFace
+ } else {
+ LegacyCullMode::BackFace
+ }
+}
+
+fn native_msh_first_triangle_visible(
+ mesh: &VulkanStaticMesh,
+ range: &VulkanStaticDrawRange,
+ eye_world: [f32; 3],
+ handedness: i8,
+) -> bool {
+ if range.batch_flags & 0x2 != 0 || range.batch_flags & 0x4000 == 0 {
+ return true;
+ }
+ if handedness == 0 {
+ return false;
+ }
+ let Some(indices) = usize::try_from(range.first_index)
+ .ok()
+ .and_then(|first| mesh.indices.get(first..first.checked_add(3)?))
+ else {
+ return false;
+ };
+ let Some(v0) = usize::try_from(indices[0])
+ .ok()
+ .and_then(|index| mesh.vertices.get(index))
+ else {
+ return false;
+ };
+ let Some(v1) = usize::try_from(indices[1])
+ .ok()
+ .and_then(|index| mesh.vertices.get(index))
+ else {
+ return false;
+ };
+ let Some(v2) = usize::try_from(indices[2])
+ .ok()
+ .and_then(|index| mesh.vertices.get(index))
+ else {
+ return false;
+ };
+ let [v0, v1, v2] = [v0.position, v1.position, v2.position];
+ let face = cross3(sub3(v1, v0), sub3(v2, v0));
+ let facing = dot3(sub3(eye_world, v0), face) * f32::from(handedness);
+ facing.is_finite() && facing > 0.0
+}
+
+fn world_draw_range_index_count(
+ mesh: &VulkanStaticMesh,
+ range: &VulkanStaticDrawRange,
+ original_index_count: u32,
+ diffuse_alpha: f32,
+ eye_world: [f32; 3],
+ native_msh_handedness: Option<i8>,
+) -> u32 {
+ if !(diffuse_alpha > 0.0) {
+ return 0;
+ }
+ if native_msh_handedness.is_some_and(|handedness| {
+ !native_msh_first_triangle_visible(mesh, range, eye_world, handedness)
+ }) {
+ return 0;
+ }
+ original_index_count
+}
+
fn configure_world_material_categories(
mesh: &mut VulkanStaticMesh,
materials: &[VulkanStaticMaterial],
@@ -1250,6 +1368,7 @@ fn append_environment_material(
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: matches!(fallback_row, 0..=2),
+ screen_tl: ENVIRONMENT_SPRITE_ROWS.contains(&fallback_row),
});
phase_material_indices.push(index);
}
@@ -1310,6 +1429,7 @@ fn append_environment_gradient_material(
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: true,
+ screen_tl: false,
});
Ok(index)
}
@@ -1360,6 +1480,7 @@ fn append_shadow_material(materials: &mut Vec<VulkanStaticMaterial>) -> Result<u
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
+ screen_tl: false,
});
Ok(index)
}
@@ -1390,24 +1511,55 @@ fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
fn terrain_shadow_receivers(
terrain: &fparkan_terrain_format::LandMeshDocument,
) -> Result<Vec<ShadowTriangle>, String> {
- terrain
- .faces
- .iter()
- .map(|face| {
- let positions = face.vertices.map(|index| {
- let source = terrain
- .positions
- .get(usize::from(index))
- .copied()
- .unwrap_or([0.0; 3]);
- source
- });
- if !positions.iter().flatten().all(|value| value.is_finite()) {
- return Err("terrain shadow receiver contains a non-finite position".to_string());
- }
- Ok(shadow_triangle(positions, face.flags.0))
- })
- .collect()
+ let assignments = terrain
+ .face_lod_assignments()
+ .map_err(|error| error.to_string())?;
+ let mut receivers = Vec::with_capacity(
+ assignments
+ .iter()
+ .filter(|assignment| assignment.lod == TerrainLod::Lod0)
+ .count(),
+ );
+ for assignment in assignments {
+ if assignment.lod != TerrainLod::Lod0 {
+ continue;
+ }
+ let face = terrain.faces.get(assignment.face_index).ok_or_else(|| {
+ format!(
+ "terrain LOD0 references unknown face {}",
+ assignment.face_index
+ )
+ })?;
+ let positions = [
+ terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[0])?,
+ terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[1])?,
+ terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[2])?,
+ ];
+ receivers.push(shadow_triangle(positions, face.flags.0));
+ }
+ Ok(receivers)
+}
+
+fn terrain_shadow_vertex_position(
+ terrain: &fparkan_terrain_format::LandMeshDocument,
+ face_index: usize,
+ vertex_index: u16,
+) -> Result<[f32; 3], String> {
+ let position = terrain
+ .positions
+ .get(usize::from(vertex_index))
+ .copied()
+ .ok_or_else(|| {
+ format!(
+ "terrain LOD0 shadow face {face_index} references unknown vertex {vertex_index}"
+ )
+ })?;
+ if !position.iter().all(|value| value.is_finite()) {
+ return Err(format!(
+ "terrain LOD0 shadow face {face_index} has a non-finite vertex {vertex_index}"
+ ));
+ }
+ Ok(position)
}
fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> {
@@ -1792,7 +1944,7 @@ fn append_environment_range(
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state,
- alpha_test_reference: 0,
+ alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
Ok(EnvironmentGpuRange {
range_index,
@@ -1901,7 +2053,7 @@ fn screen_gradient_quad_vertices(
let width = viewport[0].max(1.0);
let height = viewport[1].max(1.0);
let aspect = width / height;
- let fov = camera.vertical_fov.clamp(0.05, 3.0);
+ let fov = camera.vertical_fov;
// The quad is placed close to the camera and drawn with depth disabled.
// It therefore covers the complete projected viewport while the later
// dome, terrain, and object ranges provide their own depth-tested detail.
@@ -1948,6 +2100,7 @@ impl EnvironmentGpuScene {
fn new(
mesh: &mut VulkanStaticMesh,
+ native_handedness_by_range: &mut HashMap<usize, i8>,
materials: &mut Vec<VulkanStaticMaterial>,
preview_material_animations: &mut Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: &mut Vec<usize>,
@@ -2367,6 +2520,14 @@ impl EnvironmentGpuScene {
old_order.extend(sprite_indices.iter().copied());
old_order.extend([rain_index, snow_index, lightning_index]);
let remap = reorder_draw_ranges(mesh, &old_order)?;
+ let mut remapped_handedness = HashMap::with_capacity(native_handedness_by_range.len());
+ for (old_range_index, handedness) in std::mem::take(native_handedness_by_range) {
+ let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| {
+ format!("native MSH draw range remap is missing range {old_range_index}")
+ })?;
+ remapped_handedness.insert(new_range_index, handedness);
+ }
+ *native_handedness_by_range = remapped_handedness;
for layer in &mut scene.sky_layers {
layer.range.range_index = *remap
.get(layer.range.range_index)
@@ -2606,17 +2767,33 @@ impl EnvironmentGpuScene {
viewport,
horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)),
) {
- vertices = Some(sprite_quad_vertices_pixels(
- camera,
- frame.direction,
- (camera.far_plane * 0.5).max(camera.near_plane * 2.0),
- half_width,
- half_height,
- viewport,
- frame.color,
- frame.alpha,
- frame.uv,
- ));
+ if ENVIRONMENT_SPRITE_ROWS.contains(&sprite.row) {
+ if let Some(center_pixels) = projected {
+ vertices = Some(screen_tl_quad_vertices_pixels(
+ center_pixels,
+ half_width,
+ half_height,
+ viewport,
+ frame.color,
+ frame.alpha,
+ frame.uv,
+ ));
+ } else {
+ active = false;
+ }
+ } else {
+ vertices = Some(sprite_quad_vertices_pixels(
+ camera,
+ frame.direction,
+ (camera.far_plane * 0.5).max(camera.near_plane * 2.0),
+ half_width,
+ half_height,
+ viewport,
+ frame.color,
+ frame.alpha,
+ frame.uv,
+ ));
+ }
} else {
active = false;
}
@@ -2764,7 +2941,7 @@ fn horizontal_fov(camera: &PreviewCameraFrame, aspect: f32) -> f32 {
} else {
16.0 / 9.0
};
- 2.0 * ((camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan() * aspect).atan()
+ 2.0 * ((camera.vertical_fov * 0.5).tan() * aspect).atan()
}
/// Tests the camera-to-sun segment against the validated mission terrain.
@@ -2960,11 +3137,11 @@ fn project_direction_pixels(
return None;
}
let depth = dot3(camera.forward(), direction);
- if !depth.is_finite() || depth <= camera.near_plane.max(0.001) {
+ if !depth.is_finite() || depth <= 1.0e-4 {
return None;
}
let aspect = width / height;
- let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan();
+ let half_vertical = (camera.vertical_fov * 0.5).tan();
let half_horizontal = half_vertical * aspect;
let ndc_x = dot3(camera.right(), direction) / (depth * half_horizontal);
let ndc_y = dot3(camera.up(), direction) / (depth * half_vertical);
@@ -2988,7 +3165,7 @@ fn screen_ray_from_pixels(
return None;
}
let aspect = viewport[0] / viewport[1];
- let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan();
+ let half_vertical = (camera.vertical_fov * 0.5).tan();
let half_horizontal = half_vertical * aspect;
let ndc_x = pixels[0] / viewport[0] * 2.0 - 1.0;
let ndc_y = 1.0 - pixels[1] / viewport[1] * 2.0;
@@ -3020,7 +3197,7 @@ fn sprite_quad_vertices_pixels(
let width = viewport[0].max(1.0);
let height = viewport[1].max(1.0);
let aspect = width / height;
- let vertical_fov = camera.vertical_fov.clamp(0.05, 3.0);
+ let vertical_fov = camera.vertical_fov;
let horizontal_fov_radians = horizontal_fov(camera, aspect);
let world_per_pixel_x = 2.0 * depth * (horizontal_fov_radians * 0.5).tan() / width;
let world_per_pixel_y = 2.0 * depth * (vertical_fov * 0.5).tan() / height;
@@ -3041,6 +3218,50 @@ fn sprite_quad_vertices_pixels(
]
}
+fn screen_tl_quad_vertices_pixels(
+ center_pixels: [f32; 2],
+ half_width_pixels: f32,
+ half_height_pixels: f32,
+ viewport: [f32; 2],
+ color: [f32; 3],
+ alpha: f32,
+ uv: [[f32; 2]; 4],
+) -> [VulkanStaticVertex; 4] {
+ let width = viewport[0].max(1.0);
+ let height = viewport[1].max(1.0);
+ let half_width = half_width_pixels.max(0.0);
+ let half_height = half_height_pixels.max(0.0);
+ let pixel_corners = [
+ [
+ center_pixels[0] - half_width,
+ center_pixels[1] + half_height,
+ ],
+ [
+ center_pixels[0] + half_width,
+ center_pixels[1] + half_height,
+ ],
+ [
+ center_pixels[0] - half_width,
+ center_pixels[1] - half_height,
+ ],
+ [
+ center_pixels[0] + half_width,
+ center_pixels[1] - half_height,
+ ],
+ ];
+ // Positive-height Vulkan viewports map NDC -Y to top-down screen pixels.
+ let uv = [uv[1], uv[2], uv[0], uv[3]];
+ std::array::from_fn(|index| {
+ let pixel = pixel_corners[index];
+ let position = [
+ pixel[0] * (2.0 / width) - 1.0,
+ pixel[1] * (2.0 / height) - 1.0,
+ 0.999,
+ ];
+ environment_vertex_with_alpha(position, color, alpha, uv[index])
+ })
+}
+
fn precipitation_quad_vertices(
kind: PrecipitationKind,
screen: &ScreenBillboard,
@@ -3080,7 +3301,7 @@ fn world_from_ndc(
camera.near_plane.max(0.001)
};
let aspect = viewport[0].max(1.0) / viewport[1].max(1.0);
- let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan();
+ let half_vertical = (camera.vertical_fov * 0.5).tan();
let half_horizontal = half_vertical * aspect;
let offset = add3(
scale3(camera.right(), ndc[0] * half_horizontal),
@@ -3552,6 +3773,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
struct StaticPreviewScene {
mesh: VulkanStaticMesh,
+ native_handedness_by_range: HashMap<usize, i8>,
materials: Vec<VulkanStaticMaterial>,
material_animations: Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: Vec<usize>,
@@ -3763,6 +3985,7 @@ fn static_preview_mesh_and_materials(
indices: Vec::new(),
draw_ranges: Vec::new(),
};
+ let mut native_handedness_by_range = HashMap::new();
let mut materials = Vec::new();
let mut material_animations = Vec::new();
let mut sun_occlusion_range_indices = Vec::new();
@@ -3961,7 +4184,7 @@ fn static_preview_mesh_and_materials(
};
if should_render {
let pose_buffer = component_pose_buffers[component_index].as_ref();
- let component = match (pose_buffer, mount_pose, static_animation_frame) {
+ let mut component = match (pose_buffer, mount_pose, static_animation_frame) {
(Some(poses), Some(mount), _) => {
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
&model.validated,
@@ -4013,6 +4236,11 @@ fn static_preview_mesh_and_materials(
}
}
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
+ let component_handedness = native_msh_handedness(transform, mission_root.scale);
+ for range in &mut component.draw_ranges {
+ range.pipeline_state.cull =
+ native_msh_cull_mode(range.batch_flags, component_handedness);
+ }
let component_shadow_triangles = static_mesh_shadow_triangles(&component);
if root_native_kind.is_some() {
root_shadow_triangles.extend(component_shadow_triangles);
@@ -4051,6 +4279,9 @@ fn static_preview_mesh_and_materials(
&lightmap_textures,
Some(&mut material_animations),
)?;
+ for range_index in range_start..mesh.draw_ranges.len() {
+ native_handedness_by_range.insert(range_index, component_handedness);
+ }
if root_sun_eligible {
sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
}
@@ -4098,6 +4329,7 @@ fn static_preview_mesh_and_materials(
let shadow_material_index = append_shadow_material(&mut materials)?;
Ok(StaticPreviewScene {
mesh,
+ native_handedness_by_range,
materials,
material_animations,
sun_occlusion_range_indices,
@@ -4263,6 +4495,7 @@ fn static_preview_terrain_base_materials(
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
+ screen_tl: false,
});
phase_material_indices.push(usize::from(preview_selector));
}
@@ -4468,6 +4701,7 @@ fn static_preview_component_materials(
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
+ screen_tl: false,
});
phase_material_indices.push(usize::from(preview_selector));
}
@@ -4821,6 +5055,7 @@ fn run_static_vulkan_mode(
let sky_mesh = environment.sky.mesh().clone();
Some(EnvironmentGpuScene::new(
&mut preview.mesh,
+ &mut preview.native_handedness_by_range,
&mut preview.materials,
&mut preview.material_animations,
&mut preview.sun_occlusion_range_indices,
@@ -4858,6 +5093,7 @@ fn run_static_vulkan_mode(
struct StaticVulkanApp {
mesh: VulkanStaticMesh,
+ native_handedness_by_range: HashMap<usize, i8>,
materials: Vec<VulkanStaticMaterial>,
material_animations: Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: Vec<usize>,
@@ -4938,6 +5174,7 @@ impl StaticVulkanApp {
.collect();
Self {
mesh: preview.mesh,
+ native_handedness_by_range: preview.native_handedness_by_range,
materials: preview.materials,
material_animations: preview.material_animations,
sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
@@ -5016,7 +5253,14 @@ impl StaticVulkanApp {
renderer
.set_draw_range_index_count(
range_index,
- if alpha > 0.0 { range.index_count } else { 0 },
+ world_draw_range_index_count(
+ &self.mesh,
+ range,
+ range.index_count,
+ alpha,
+ self.view_camera.position,
+ self.native_handedness_by_range.get(&range_index).copied(),
+ ),
)
.map_err(|error| {
format!("configure world draw range {range_index} count: {error}")
@@ -5030,6 +5274,8 @@ impl StaticVulkanApp {
materials: &[VulkanStaticMaterial],
world_range_indices: &[usize],
world_range_index_counts: &HashMap<usize, u32>,
+ native_handedness_by_range: &HashMap<usize, i8>,
+ eye_world: [f32; 3],
active_materials: &HashMap<usize, ActiveMaterialState>,
renderer: &mut VulkanSmokeRenderer,
) -> Result<(), String> {
@@ -5050,13 +5296,17 @@ impl StaticVulkanApp {
renderer
.set_draw_range_transparency(range_index, native_world_transparent(range, alpha))
.map_err(|error| format!("update world draw range {range_index}: {error}"))?;
- let count = if alpha > 0.0 {
- *world_range_index_counts
- .get(&range_index)
- .ok_or_else(|| "world draw range count is missing".to_string())?
- } else {
- 0
- };
+ let original_index_count = *world_range_index_counts
+ .get(&range_index)
+ .ok_or_else(|| "world draw range count is missing".to_string())?;
+ let count = world_draw_range_index_count(
+ mesh,
+ range,
+ original_index_count,
+ alpha,
+ eye_world,
+ native_handedness_by_range.get(&range_index).copied(),
+ );
renderer
.set_draw_range_index_count(range_index, count)
.map_err(|error| format!("update world draw range {range_index} count: {error}"))?;
@@ -5215,6 +5465,8 @@ impl StaticVulkanApp {
&self.materials,
&self.world_range_indices,
&self.world_range_index_counts,
+ &self.native_handedness_by_range,
+ self.view_camera.position,
&active_materials,
renderer,
)?;
@@ -5938,6 +6190,20 @@ fn usage() -> String {
mod tests {
use super::*;
+ fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) {
+ const NODE_STRIDE: usize = 38;
+ const SLOT_STRIDE: usize = 68;
+ let mut node = vec![u8::MAX; NODE_STRIDE];
+ node[8..10].copy_from_slice(&0_u16.to_le_bytes());
+ node[10..12].copy_from_slice(&u16::MAX.to_le_bytes());
+ let mut slot = [0_u8; SLOT_STRIDE];
+ slot[..2].copy_from_slice(&0_u16.to_le_bytes());
+ let face_count = u16::try_from(terrain.faces.len()).expect("test face count fits");
+ slot[2..4].copy_from_slice(&face_count.to_le_bytes());
+ terrain.nodes_raw = node;
+ terrain.slots.slots_raw = vec![slot];
+ }
+
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
@@ -6382,7 +6648,7 @@ mod tests {
position: [0.0, 0.0, 0.0],
yaw: 0.0,
pitch: 0.0,
- vertical_fov: std::f32::consts::FRAC_PI_2,
+ horizontal_fov: std::f32::consts::FRAC_PI_2,
near_plane: 1.0,
far_plane: 11.0,
move_speed: 1.0,
@@ -6407,6 +6673,116 @@ mod tests {
}
#[test]
+ fn free_flight_camera_uses_native_horizontal_fov_for_every_viewport_aspect() {
+ let horizontal_fov = 1.3_f32;
+ let camera = FreeFlightCamera::from_bounds([0.0; 3], [100.0; 3])
+ .expect("finite free-flight bounds");
+ assert_eq!(camera.horizontal_fov, horizontal_fov);
+ assert_eq!(camera.near_plane, 0.5);
+ assert_eq!(camera.far_plane, 700.0);
+
+ let assert_edges = |frame: PreviewCameraFrame, aspect: f32| {
+ let expected_vertical_fov =
+ 2.0 * ((horizontal_fov * 0.5).tan() / aspect).atan();
+ assert!((frame.vertical_fov - expected_vertical_fov).abs() < 1.0e-5);
+ let depth = 10.0;
+ let center = add3(frame.position, scale3(frame.forward, depth));
+ let right_edge = add3(
+ center,
+ scale3(frame.right, depth * (horizontal_fov * 0.5).tan()),
+ );
+ let top_edge = add3(
+ center,
+ scale3(frame.up, depth * (expected_vertical_fov * 0.5).tan()),
+ );
+ let center_clip = row_vector_transform(
+ [center[0], center[1], center[2], 1.0],
+ frame.clip_from_world,
+ );
+ let right_clip = row_vector_transform(
+ [right_edge[0], right_edge[1], right_edge[2], 1.0],
+ frame.clip_from_world,
+ );
+ let top_clip = row_vector_transform(
+ [top_edge[0], top_edge[1], top_edge[2], 1.0],
+ frame.clip_from_world,
+ );
+ assert!(center_clip[3] > 0.0 && right_clip[3] > 0.0 && top_clip[3] > 0.0);
+ assert!(center_clip[0].abs() < 5.0e-4, "aspect {aspect}: {center_clip:?}");
+ assert!(center_clip[1].abs() < 5.0e-4, "aspect {aspect}: {center_clip:?}");
+ assert!(
+ (right_clip[0] / right_clip[3] - 1.0).abs() < 1.0e-4,
+ "aspect {aspect}: {right_clip:?}"
+ );
+ assert!(
+ (top_clip[1] / top_clip[3] + 1.0).abs() < 1.0e-4,
+ "aspect {aspect}: {top_clip:?}"
+ );
+ };
+
+ for aspect in [0.05, 0.75, 4.0 / 3.0, 16.0 / 9.0, 20.0] {
+ assert_edges(camera.preview_frame(aspect), aspect);
+
+ let projection = LegacyD3d7Projection {
+ viewport: [0, 0, 1000, (1000.0 / aspect).round() as i32],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: horizontal_fov,
+ };
+ let raw_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 captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection)
+ .expect("valid D3D7 projection");
+ assert_edges(captured, projection.viewport[2] as f32 / projection.viewport[3] as f32);
+ }
+ }
+
+ #[test]
+ fn direction_projection_accepts_front_offscreen_rays_but_rejects_horizon() {
+ let camera = FreeFlightCamera {
+ position: [0.0; 3],
+ yaw: 0.0,
+ pitch: 0.0,
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: 1.0,
+ }
+ .preview_frame(16.0 / 9.0);
+ let viewport = [1280.0, 720.0];
+ let offscreen = project_direction_pixels(&camera, [0.2, 0.979_795_9, 0.0], viewport)
+ .expect("front-facing directions are not rejected by the near distance");
+ assert!(offscreen[0] < 0.0 || offscreen[0] > viewport[0]);
+ assert!(project_direction_pixels(&camera, [1.0e-5, 1.0, 0.0], viewport).is_none());
+ assert!(project_direction_pixels(&camera, [-1.0, 0.0, 0.0], viewport).is_none());
+
+ let ray = screen_ray_from_pixels(&camera, [viewport[0] * 1.1, viewport[1] * 0.5], viewport)
+ .expect("an offscreen pixel still has a view ray");
+ let ray_pixels = project_direction_pixels(&camera, ray, viewport)
+ .expect("the offscreen ray remains in the front hemisphere");
+ assert!((ray_pixels[0] - viewport[0] * 1.1).abs() < 1.0e-3);
+ assert!((ray_pixels[1] - viewport[1] * 0.5).abs() < 1.0e-3);
+ }
+
+ #[test]
fn native_sky_heading_selects_matching_gradient_sectors_for_both_camera_origins() {
use fparkan_fx::atmosphere::{PackedColor, SkySample, SKY_COLOR_COUNT};
@@ -6440,7 +6816,7 @@ mod tests {
position: [0.0; 3],
yaw: forward[1].atan2(forward[0]),
pitch: 0.0,
- vertical_fov: std::f32::consts::FRAC_PI_3,
+ horizontal_fov: projection.field_of_view_radians,
near_plane: projection.near_plane,
far_plane: projection.far_plane,
move_speed: 1.0,
@@ -6497,7 +6873,7 @@ mod tests {
position: [10.0, 20.0, 30.0],
yaw: 0.0,
pitch: 0.0,
- vertical_fov: 1.0,
+ horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
@@ -6507,7 +6883,7 @@ mod tests {
assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0]));
assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0]));
- let land = fparkan_terrain_format::LandMeshDocument {
+ let mut land = fparkan_terrain_format::LandMeshDocument {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: fparkan_terrain_format::TerrainSlotTable {
@@ -6531,8 +6907,14 @@ mod tests {
raw: [0; 28],
}],
};
+ set_test_terrain_lod0_range(&mut land);
let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers");
assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]);
+ let mut invalid_shadow_face = land.clone();
+ invalid_shadow_face.faces[0].vertices[2] = u16::MAX;
+ assert!(terrain_shadow_receivers(&invalid_shadow_face)
+ .expect_err("bad terrain indices must fail instead of inventing vertices")
+ .contains("unknown vertex 65535"));
let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain");
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0));
let floor_camera =
@@ -6543,7 +6925,7 @@ mod tests {
position: [0.25, 0.25, 34.0],
yaw: 0.0,
pitch: 0.0,
- vertical_fov: 1.0,
+ horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
@@ -6583,31 +6965,32 @@ mod tests {
[0.0, 0.0, -1.0]
));
+ let mut excluded_land = fparkan_terrain_format::LandMeshDocument {
+ streams: Vec::new(),
+ nodes_raw: Vec::new(),
+ slots: fparkan_terrain_format::TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
+ normals: Vec::new(),
+ uv0: Vec::new(),
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![fparkan_terrain_format::TerrainFace28 {
+ flags: FullSurfaceMask(0x20),
+ material_tag: 0,
+ aux_tag: 0,
+ vertices: [0, 1, 2],
+ neighbors: [None, None, None],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ }],
+ };
+ set_test_terrain_lod0_range(&mut excluded_land);
let excluded_terrain =
- TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument {
- streams: Vec::new(),
- nodes_raw: Vec::new(),
- slots: fparkan_terrain_format::TerrainSlotTable {
- header_raw: Vec::new(),
- slots_raw: Vec::new(),
- },
- positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
- normals: Vec::new(),
- uv0: Vec::new(),
- accelerator: Vec::new(),
- aux14: Vec::new(),
- aux18: Vec::new(),
- faces: vec![fparkan_terrain_format::TerrainFace28 {
- flags: FullSurfaceMask(0x20),
- material_tag: 0,
- aux_tag: 0,
- vertices: [0, 1, 2],
- neighbors: [None, None, None],
- tail_raw: [0; 8],
- raw: [0; 28],
- }],
- })
- .expect("excluded terrain");
+ TerrainWorld::from_land_msh(&excluded_land).expect("excluded terrain");
assert!(native_sun_unoccluded_with_world(
&excluded_terrain,
&world_mesh,
@@ -6641,7 +7024,7 @@ mod tests {
position: [0.0, 0.0, 0.0],
yaw: 0.0,
pitch: 0.0,
- vertical_fov: 1.0,
+ horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 2.0,
@@ -6668,7 +7051,7 @@ mod tests {
position: [0.0, 0.0, 0.0],
yaw: 0.0,
pitch: 0.6,
- vertical_fov: 1.0,
+ horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 2.0,
@@ -7089,7 +7472,7 @@ mod tests {
position: [10.0, 20.0, 30.0],
yaw: 0.0,
pitch: 0.0,
- vertical_fov: std::f32::consts::FRAC_PI_2,
+ horizontal_fov: std::f32::consts::FRAC_PI_2,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
@@ -7140,7 +7523,7 @@ mod tests {
position: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
- vertical_fov: 1.0,
+ horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
@@ -7163,4 +7546,302 @@ mod tests {
[uv[1], uv[2], uv[0], uv[3]]
);
}
+
+ #[test]
+ fn native_tl_sprite_pixels_use_current_viewport_and_preserve_depth_999() {
+ let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
+ let small = screen_tl_quad_vertices_pixels(
+ [160.0, 120.0],
+ 40.0,
+ 30.0,
+ [640.0, 480.0],
+ [1.0; 3],
+ 1.0,
+ uv,
+ );
+ let resized = screen_tl_quad_vertices_pixels(
+ [320.0, 240.0],
+ 80.0,
+ 60.0,
+ [1280.0, 960.0],
+ [1.0; 3],
+ 1.0,
+ uv,
+ );
+ let wide = screen_tl_quad_vertices_pixels(
+ [640.0, 360.0],
+ 100.0,
+ 45.0,
+ [1280.0, 720.0],
+ [1.0; 3],
+ 1.0,
+ uv,
+ );
+
+ for (small, resized) in small.iter().zip(resized) {
+ for axis in 0..2 {
+ assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6);
+ }
+ assert_eq!(small.position[2], 0.999);
+ assert_eq!(resized.position[2], 0.999);
+ }
+ assert_eq!(small[0].uv, [0.0, 1.0]);
+ assert_eq!(small[2].uv, [0.0, 0.0]);
+ for (actual, expected) in [
+ (wide[0].position, [-0.15625, 0.125, 0.999]),
+ (wide[2].position, [-0.15625, -0.125, 0.999]),
+ ] {
+ for axis in 0..3 {
+ assert!((actual[axis] - expected[axis]).abs() < 1.0e-6);
+ }
+ }
+ }
+
+ #[test]
+ fn native_tl_depth_999_is_nearer_than_opaque_geometry_at_320_units() {
+ let camera = FreeFlightCamera {
+ position: [0.0; 3],
+ yaw: 0.0,
+ pitch: 0.0,
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: 1.0,
+ };
+ let frame = camera.preview_frame(16.0 / 9.0);
+ let opaque_clip = row_vector_transform([320.0, 0.0, 0.0, 1.0], frame.clip_from_world);
+ let opaque_depth = opaque_clip[2] / opaque_clip[3];
+
+ assert!((opaque_depth - 0.999151_18).abs() < 1.0e-6);
+ assert!(0.999 < opaque_depth);
+ }
+
+ fn facing_mesh(
+ batch_flags: u16,
+ positions: &[[f32; 3]],
+ ) -> (VulkanStaticMesh, VulkanStaticDrawRange) {
+ let range = VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: u32::try_from(positions.len()).expect("fixture index count fits u32"),
+ material_index: 0,
+ lightmap_index: 0,
+ batch_flags,
+ pipeline_state: LegacyPipelineState::default(),
+ alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
+ };
+ let mesh = VulkanStaticMesh {
+ vertices: positions
+ .iter()
+ .copied()
+ .map(|position| VulkanStaticVertex {
+ position,
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 0.0,
+ })
+ .collect(),
+ indices: (0..positions.len())
+ .map(|index| u32::try_from(index).expect("fixture index fits u32"))
+ .collect(),
+ draw_ranges: vec![range],
+ };
+ (mesh, range)
+ }
+
+ fn native_front_triangle() -> [[f32; 3]; 3] {
+ [[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]]
+ }
+
+ fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] {
+ let center = add3(camera.position, scale3(camera.forward, 10.0));
+ let mirror = if mirror { -1.0 } else { 1.0 };
+ [
+ add3(
+ add3(center, scale3(camera.right, -2.0 * mirror)),
+ scale3(camera.up, -2.0),
+ ),
+ add3(
+ add3(center, scale3(camera.right, 2.0 * mirror)),
+ scale3(camera.up, -2.0),
+ ),
+ add3(center, scale3(camera.up, 2.0)),
+ ]
+ }
+
+ fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 {
+ let screen = triangle.map(|point| {
+ let clip =
+ row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world);
+ assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite()));
+ // This is the positive-height viewport transform used by the Vulkan renderer.
+ [
+ (clip[0] / clip[3] + 1.0) * 0.5,
+ (clip[1] / clip[3] + 1.0) * 0.5,
+ ]
+ });
+ let shoelace = (0..3)
+ .map(|index| {
+ let next = (index + 1) % 3;
+ screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1]
+ })
+ .sum::<f32>()
+ * 0.5;
+ // Vulkan's framebuffer signed-area definition is the negative shoelace area.
+ -shoelace
+ }
+
+ #[test]
+ fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() {
+ let identity = 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, 1280, 1024],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: 1.3,
+ };
+ let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection)
+ .expect("valid legacy camera projection");
+ let free_flight = FreeFlightCamera {
+ position: legacy.position,
+ yaw: 0.0,
+ pitch: 0.0,
+ horizontal_fov: 1.3,
+ near_plane: 0.5,
+ far_plane: 700.0,
+ move_speed: 1.0,
+ }
+ .preview_frame(1280.0 / 1024.0);
+
+ for camera in [legacy, free_flight] {
+ let front = camera_face_triangle(camera, false);
+ let back = [front[0], front[2], front[1]];
+ let front_d = dot3(
+ sub3(camera.position, front[0]),
+ cross3(sub3(front[1], front[0]), sub3(front[2], front[0])),
+ );
+ let back_d = dot3(
+ sub3(camera.position, back[0]),
+ cross3(sub3(back[1], back[0]), sub3(back[2], back[0])),
+ );
+ assert!(front_d > 0.0);
+ assert!(back_d < 0.0);
+
+ let front_vk_area = projected_vulkan_area(camera, front);
+ let back_vk_area = projected_vulkan_area(camera, back);
+ // Vulkan CLOCKWISE front faces have negative framebuffer area.
+ // The native-visible d>0 face is therefore a Vulkan back face.
+ assert!(front_vk_area > 0.0, "front area: {front_vk_area}");
+ assert!(back_vk_area < 0.0, "back area: {back_vk_area}");
+ assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace);
+
+ let mirrored_front = camera_face_triangle(camera, true);
+ let mirrored_d = dot3(
+ sub3(camera.position, mirrored_front[0]),
+ cross3(
+ sub3(mirrored_front[1], mirrored_front[0]),
+ sub3(mirrored_front[2], mirrored_front[0]),
+ ),
+ );
+ assert!(mirrored_d < 0.0);
+ assert!(projected_vulkan_area(camera, mirrored_front) < 0.0);
+ assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace);
+ }
+ }
+
+ #[test]
+ fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() {
+ let rotated = LegacyIron3dEulerTransform {
+ translation: [8.0, -4.0, 3.0],
+ orientation_radians: [0.4, -0.7, 1.2],
+ };
+ assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1);
+ assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1);
+ assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0);
+ assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled);
+ }
+
+ #[test]
+ fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() {
+ let front = native_front_triangle();
+ let back = front.map(|point| [-point[0], point[1], point[2]]);
+ let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]];
+ let (mesh, range) = facing_mesh(0x4000, &front_then_back);
+ assert!(native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
+
+ let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]];
+ let (mesh, range) = facing_mesh(0x4000, &back_then_front);
+ assert!(!native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
+
+ let (mesh, range) = facing_mesh(0x4002, &back_then_front);
+ assert!(native_msh_first_triangle_visible(
+ &mesh, &range, [0.0; 3], 1
+ ));
+ assert_eq!(
+ native_msh_cull_mode(range.batch_flags, 1),
+ LegacyCullMode::Disabled
+ );
+ }
+
+ #[test]
+ fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() {
+ let triangle = native_front_triangle();
+ let (mesh, range) = facing_mesh(0x4000, &triangle);
+ let original_count = range.index_count;
+
+ assert_eq!(
+ world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)),
+ 0
+ );
+ assert_eq!(
+ world_draw_range_index_count(
+ &mesh,
+ &range,
+ original_count,
+ 1.0,
+ [0.0, 0.0, -2.0],
+ Some(1),
+ ),
+ 0
+ );
+ assert_eq!(
+ world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)),
+ original_count
+ );
+ assert_eq!(
+ world_draw_range_index_count(
+ &mesh,
+ &range,
+ original_count,
+ 1.0,
+ [0.0, 0.0, -2.0],
+ None,
+ ),
+ original_count
+ );
+ }
}