fix(camera): preserve free-flight terrain clearance
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
This commit is contained in:
2 files changed
+119
-1
No files matched your search
@@ -191,6 +191,7 @@ struct FreeFlightCamera {
|
|||||||
near_plane: f32,
|
near_plane: f32,
|
||||||
far_plane: f32,
|
far_plane: f32,
|
||||||
move_speed: f32,
|
move_speed: f32,
|
||||||
|
ground_clearance: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FreeFlightCamera {
|
impl FreeFlightCamera {
|
||||||
@@ -248,6 +249,7 @@ impl FreeFlightCamera {
|
|||||||
// Keep the default traversal speed comfortable on a full mission
|
// Keep the default traversal speed comfortable on a full mission
|
||||||
// map; Shift/Ctrl still provide the coarse speed controls.
|
// map; Shift/Ctrl still provide the coarse speed controls.
|
||||||
move_speed: (radius * 0.03).clamp(4.0, 40.0),
|
move_speed: (radius * 0.03).clamp(4.0, 40.0),
|
||||||
|
ground_clearance: 0.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +315,7 @@ impl FreeFlightCamera {
|
|||||||
.ok_or_else(|| "free-flight terrain has no queryable spawn surface".to_string())?;
|
.ok_or_else(|| "free-flight terrain has no queryable spawn surface".to_string())?;
|
||||||
let target_ground = height_world(center).unwrap_or(spawn_ground);
|
let target_ground = height_world(center).unwrap_or(spawn_ground);
|
||||||
let clearance = (extent * 0.005).clamp(2.0, 8.0);
|
let clearance = (extent * 0.005).clamp(2.0, 8.0);
|
||||||
|
camera.ground_clearance = clearance;
|
||||||
camera.position = [spawn_xy[0], spawn_xy[1], spawn_ground + clearance];
|
camera.position = [spawn_xy[0], spawn_xy[1], spawn_ground + clearance];
|
||||||
let target = [center[0], center[1], target_ground + 1.5];
|
let target = [center[0], center[1], target_ground + 1.5];
|
||||||
let direction = sub3(target, camera.position);
|
let direction = sub3(target, camera.position);
|
||||||
@@ -323,6 +326,27 @@ impl FreeFlightCamera {
|
|||||||
Ok(camera)
|
Ok(camera)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn keep_above_terrain(&mut self, terrain: &TerrainWorld) -> Result<(), String> {
|
||||||
|
if !self.ground_clearance.is_finite() || self.ground_clearance < 0.0 {
|
||||||
|
return Err("free-flight ground clearance must be finite and nonnegative".to_string());
|
||||||
|
}
|
||||||
|
let Some(ground_height) = terrain
|
||||||
|
.height_at([self.position[0], self.position[1]])
|
||||||
|
.map_err(|error| format!("free-flight terrain height query: {error}"))?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if !ground_height.is_finite() {
|
||||||
|
return Err("free-flight terrain height is not finite".to_string());
|
||||||
|
}
|
||||||
|
let minimum_z = ground_height + self.ground_clearance;
|
||||||
|
if !minimum_z.is_finite() {
|
||||||
|
return Err("free-flight terrain floor is not finite".to_string());
|
||||||
|
}
|
||||||
|
self.position[2] = self.position[2].max(minimum_z);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn forward(self) -> [f32; 3] {
|
fn forward(self) -> [f32; 3] {
|
||||||
let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
|
let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
|
||||||
let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
|
let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
|
||||||
@@ -5735,6 +5759,7 @@ impl StaticVulkanApp {
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| "free-flight camera disappeared".to_string())?;
|
.ok_or_else(|| "free-flight camera disappeared".to_string())?;
|
||||||
free_camera.advance(&self.pressed_keys, elapsed.as_secs_f32());
|
free_camera.advance(&self.pressed_keys, elapsed.as_secs_f32());
|
||||||
|
free_camera.keep_above_terrain(&self.terrain)?;
|
||||||
free_camera.preview_frame(aspect)
|
free_camera.preview_frame(aspect)
|
||||||
};
|
};
|
||||||
let camera = view_camera.vulkan_camera();
|
let camera = view_camera.vulkan_camera();
|
||||||
@@ -7072,6 +7097,7 @@ mod tests {
|
|||||||
near_plane: 0.1,
|
near_plane: 0.1,
|
||||||
far_plane: 100.0,
|
far_plane: 100.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
}
|
}
|
||||||
.preview_frame(16.0 / 9.0);
|
.preview_frame(16.0 / 9.0);
|
||||||
let preview = StaticPreviewScene {
|
let preview = StaticPreviewScene {
|
||||||
@@ -7662,6 +7688,7 @@ mod tests {
|
|||||||
near_plane: 1.0,
|
near_plane: 1.0,
|
||||||
far_plane: 11.0,
|
far_plane: 11.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-5);
|
let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-5);
|
||||||
@@ -7775,6 +7802,7 @@ mod tests {
|
|||||||
near_plane: 0.5,
|
near_plane: 0.5,
|
||||||
far_plane: 700.0,
|
far_plane: 700.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
}
|
}
|
||||||
.preview_frame(16.0 / 9.0);
|
.preview_frame(16.0 / 9.0);
|
||||||
let viewport = [1280.0, 720.0];
|
let viewport = [1280.0, 720.0];
|
||||||
@@ -7830,6 +7858,7 @@ mod tests {
|
|||||||
near_plane: projection.near_plane,
|
near_plane: projection.near_plane,
|
||||||
far_plane: projection.far_plane,
|
far_plane: projection.far_plane,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
}
|
}
|
||||||
.preview_frame(1280.0 / 720.0);
|
.preview_frame(1280.0 / 720.0);
|
||||||
|
|
||||||
@@ -7887,6 +7916,7 @@ mod tests {
|
|||||||
near_plane: 0.1,
|
near_plane: 0.1,
|
||||||
far_plane: 100.0,
|
far_plane: 100.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
}
|
}
|
||||||
.preview_frame(16.0 / 9.0);
|
.preview_frame(16.0 / 9.0);
|
||||||
let terrain = TerrainWorld::default();
|
let terrain = TerrainWorld::default();
|
||||||
@@ -7931,6 +7961,7 @@ mod tests {
|
|||||||
FreeFlightCamera::from_mesh_and_terrain(&VulkanStaticMesh::smoke_triangle(), &terrain)
|
FreeFlightCamera::from_mesh_and_terrain(&VulkanStaticMesh::smoke_triangle(), &terrain)
|
||||||
.expect("camera above nonzero terrain");
|
.expect("camera above nonzero terrain");
|
||||||
assert_eq!(floor_camera.position[2], 34.0);
|
assert_eq!(floor_camera.position[2], 34.0);
|
||||||
|
assert_eq!(floor_camera.ground_clearance, 2.0);
|
||||||
let blocked_camera = FreeFlightCamera {
|
let blocked_camera = FreeFlightCamera {
|
||||||
position: [0.25, 0.25, 34.0],
|
position: [0.25, 0.25, 34.0],
|
||||||
yaw: 0.0,
|
yaw: 0.0,
|
||||||
@@ -7939,6 +7970,7 @@ mod tests {
|
|||||||
near_plane: 0.1,
|
near_plane: 0.1,
|
||||||
far_plane: 100.0,
|
far_plane: 100.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
}
|
}
|
||||||
.preview_frame(16.0 / 9.0);
|
.preview_frame(16.0 / 9.0);
|
||||||
assert!(!native_sun_unoccluded(
|
assert!(!native_sun_unoccluded(
|
||||||
@@ -8020,6 +8052,84 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn free_flight_camera_keeps_clearance_after_descending_and_moving_uphill() {
|
||||||
|
let mut 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, 34.0], [0.0, 1.0, 36.0]],
|
||||||
|
normals: Vec::new(),
|
||||||
|
uv0: Vec::new(),
|
||||||
|
accelerator: Vec::new(),
|
||||||
|
aux14: Vec::new(),
|
||||||
|
aux18: Vec::new(),
|
||||||
|
faces: vec![fparkan_terrain_format::TerrainFace28 {
|
||||||
|
flags: FullSurfaceMask(1),
|
||||||
|
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 land);
|
||||||
|
let terrain = TerrainWorld::from_land_msh(&land).expect("sloped terrain");
|
||||||
|
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(33.5));
|
||||||
|
assert_eq!(terrain.height_at([0.75, 0.25]).unwrap(), Some(34.5));
|
||||||
|
|
||||||
|
let mut camera = FreeFlightCamera {
|
||||||
|
position: [0.25, 0.25, 37.0],
|
||||||
|
yaw: 0.0,
|
||||||
|
pitch: 0.0,
|
||||||
|
horizontal_fov: 1.0,
|
||||||
|
near_plane: 0.1,
|
||||||
|
far_plane: 100.0,
|
||||||
|
move_speed: 4.0,
|
||||||
|
ground_clearance: 2.0,
|
||||||
|
};
|
||||||
|
let mut keys = HashSet::from([KeyCode::KeyQ]);
|
||||||
|
camera.advance(&keys, 1.0);
|
||||||
|
camera
|
||||||
|
.keep_above_terrain(&terrain)
|
||||||
|
.expect("floor query after descent");
|
||||||
|
assert_eq!(camera.position, [0.25, 0.25, 35.5]);
|
||||||
|
|
||||||
|
keys.clear();
|
||||||
|
keys.insert(KeyCode::KeyW);
|
||||||
|
camera.move_speed = 1.0;
|
||||||
|
camera.advance(&keys, 0.5);
|
||||||
|
camera
|
||||||
|
.keep_above_terrain(&terrain)
|
||||||
|
.expect("floor query after uphill movement");
|
||||||
|
assert_eq!(camera.position, [0.75, 0.25, 36.5]);
|
||||||
|
|
||||||
|
let mut outside_camera = FreeFlightCamera {
|
||||||
|
position: [2.0, 2.0, -17.0],
|
||||||
|
ground_clearance: 2.0,
|
||||||
|
..camera
|
||||||
|
};
|
||||||
|
let unchanged = outside_camera.position;
|
||||||
|
outside_camera
|
||||||
|
.keep_above_terrain(&terrain)
|
||||||
|
.expect("missing terrain sample leaves the camera alone");
|
||||||
|
assert_eq!(outside_camera.position, unchanged);
|
||||||
|
|
||||||
|
let mut unsupported_camera = FreeFlightCamera {
|
||||||
|
position: [0.25, 0.25, 35.5],
|
||||||
|
ground_clearance: 2.0,
|
||||||
|
..camera
|
||||||
|
};
|
||||||
|
assert!(unsupported_camera
|
||||||
|
.keep_above_terrain(&TerrainWorld::default())
|
||||||
|
.expect_err("terrain query errors must propagate")
|
||||||
|
.contains("terrain height query"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn world_sort_key_uses_the_first_indexed_vertex() {
|
fn world_sort_key_uses_the_first_indexed_vertex() {
|
||||||
let mesh = VulkanStaticMesh::smoke_triangle();
|
let mesh = VulkanStaticMesh::smoke_triangle();
|
||||||
@@ -8038,6 +8148,7 @@ mod tests {
|
|||||||
near_plane: 0.1,
|
near_plane: 0.1,
|
||||||
far_plane: 100.0,
|
far_plane: 100.0,
|
||||||
move_speed: 2.0,
|
move_speed: 2.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
};
|
};
|
||||||
let mut keys = HashSet::new();
|
let mut keys = HashSet::new();
|
||||||
keys.insert(KeyCode::KeyW);
|
keys.insert(KeyCode::KeyW);
|
||||||
@@ -8065,6 +8176,7 @@ mod tests {
|
|||||||
near_plane: 0.1,
|
near_plane: 0.1,
|
||||||
far_plane: 100.0,
|
far_plane: 100.0,
|
||||||
move_speed: 2.0,
|
move_speed: 2.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
};
|
};
|
||||||
let old_right = camera.right();
|
let old_right = camera.right();
|
||||||
let old_forward = camera.forward();
|
let old_forward = camera.forward();
|
||||||
@@ -8551,6 +8663,7 @@ mod tests {
|
|||||||
near_plane: 0.1,
|
near_plane: 0.1,
|
||||||
far_plane: 100.0,
|
far_plane: 100.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
}
|
}
|
||||||
.preview_frame(1.0);
|
.preview_frame(1.0);
|
||||||
let screen = ScreenBillboard {
|
let screen = ScreenBillboard {
|
||||||
@@ -8602,6 +8715,7 @@ mod tests {
|
|||||||
near_plane: 0.1,
|
near_plane: 0.1,
|
||||||
far_plane: 100.0,
|
far_plane: 100.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
}
|
}
|
||||||
.preview_frame(1.0);
|
.preview_frame(1.0);
|
||||||
let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]];
|
let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]];
|
||||||
@@ -8682,6 +8796,7 @@ mod tests {
|
|||||||
near_plane: 0.5,
|
near_plane: 0.5,
|
||||||
far_plane: 700.0,
|
far_plane: 700.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
};
|
};
|
||||||
let frame = camera.preview_frame(16.0 / 9.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_clip = row_vector_transform([320.0, 0.0, 0.0, 1.0], frame.clip_from_world);
|
||||||
@@ -8806,6 +8921,7 @@ mod tests {
|
|||||||
near_plane: 0.5,
|
near_plane: 0.5,
|
||||||
far_plane: 700.0,
|
far_plane: 700.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
|
ground_clearance: 0.0,
|
||||||
}
|
}
|
||||||
.preview_frame(1280.0 / 1024.0);
|
.preview_frame(1280.0 / 1024.0);
|
||||||
|
|
||||||
|
|||||||
@@ -1036,7 +1036,9 @@ animation phase; object batches сохраняют их legacy blend/depth/alpha
|
|||||||
|
|
||||||
После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
|
После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
|
||||||
`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и на каждом
|
`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и на каждом
|
||||||
кадре собирает environment frame. Mission-local `sky.ske`/`sky.wea` выбирают
|
кадре собирает environment frame. После шага движения камера проверяет высоту
|
||||||
|
`TerrainWorld`: над конечной поверхностью сохраняется начальный просвет, а вне
|
||||||
|
поверхности высота не ограничивается. Mission-local `sky.ske`/`sky.wea` выбирают
|
||||||
атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт
|
атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт
|
||||||
lightning visual; weather particles, flares, sun/moon, point lights,
|
lightning visual; weather particles, flares, sun/moon, point lights,
|
||||||
projected shadows и audio events обновляются вместе с камерой и временем.
|
projected shadows и audio events обновляются вместе с камерой и временем.
|
||||||
|
|||||||
Reference in new issue
Block a user