fix(camera): constrain free flight to terrain surface bounds
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:
4 files changed
+99
-4
No files matched your search
@@ -332,6 +332,11 @@ impl FreeFlightCamera {
|
|||||||
if !self.ground_clearance.is_finite() || self.ground_clearance < 0.0 {
|
if !self.ground_clearance.is_finite() || self.ground_clearance < 0.0 {
|
||||||
return Err("free-flight ground clearance must be finite and nonnegative".to_string());
|
return Err("free-flight ground clearance must be finite and nonnegative".to_string());
|
||||||
}
|
}
|
||||||
|
if let Some([min, max]) = terrain.surface_bounds() {
|
||||||
|
for axis in 0..2 {
|
||||||
|
self.position[axis] = self.position[axis].clamp(min[axis], max[axis]);
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(ground_height) = terrain
|
let Some(ground_height) = terrain
|
||||||
.height_at([self.position[0], self.position[1]])
|
.height_at([self.position[0], self.position[1]])
|
||||||
.map_err(|error| format!("free-flight terrain height query: {error}"))?
|
.map_err(|error| format!("free-flight terrain height query: {error}"))?
|
||||||
@@ -9021,15 +9026,27 @@ mod tests {
|
|||||||
assert_eq!(camera.position, [0.75, 0.25, 36.5]);
|
assert_eq!(camera.position, [0.75, 0.25, 36.5]);
|
||||||
|
|
||||||
let mut outside_camera = FreeFlightCamera {
|
let mut outside_camera = FreeFlightCamera {
|
||||||
position: [2.0, 2.0, -17.0],
|
position: [2.0, -2.0, -17.0],
|
||||||
ground_clearance: 2.0,
|
ground_clearance: 2.0,
|
||||||
..camera
|
..camera
|
||||||
};
|
};
|
||||||
let unchanged = outside_camera.position;
|
|
||||||
outside_camera
|
outside_camera
|
||||||
.keep_above_terrain(&terrain)
|
.keep_above_terrain(&terrain)
|
||||||
.expect("missing terrain sample leaves the camera alone");
|
.expect("clamp to the map before sampling the floor");
|
||||||
assert_eq!(outside_camera.position, unchanged);
|
assert_eq!(outside_camera.position, [1.0, 0.0, 36.0]);
|
||||||
|
|
||||||
|
outside_camera.position = [-2.0, 2.0, 100.0];
|
||||||
|
outside_camera
|
||||||
|
.keep_above_terrain(&terrain)
|
||||||
|
.expect("clamp the other map edges without limiting altitude");
|
||||||
|
assert_eq!(outside_camera.position, [0.0, 1.0, 100.0]);
|
||||||
|
|
||||||
|
outside_camera.move_speed = 40.0;
|
||||||
|
outside_camera.advance(&HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]), 0.1);
|
||||||
|
outside_camera
|
||||||
|
.keep_above_terrain(&terrain)
|
||||||
|
.expect("accelerated movement still stops at the map edge");
|
||||||
|
assert_eq!(outside_camera.position, [1.0, 1.0, 100.0]);
|
||||||
|
|
||||||
let mut unsupported_camera = FreeFlightCamera {
|
let mut unsupported_camera = FreeFlightCamera {
|
||||||
position: [0.25, 0.25, 35.5],
|
position: [0.25, 0.25, 35.5],
|
||||||
@@ -9042,6 +9059,49 @@ mod tests {
|
|||||||
.contains("terrain height query"));
|
.contains("terrain height query"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires the original GOG installation"]
|
||||||
|
fn free_flight_camera_stops_at_original_map_edges_under_accelerated_movement() {
|
||||||
|
let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
|
||||||
|
.expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
|
||||||
|
let mut engine =
|
||||||
|
create(EngineServices::new(Arc::new(DirectoryVfs::new(root)))).expect("engine");
|
||||||
|
load_mission_static_preview(
|
||||||
|
&mut engine,
|
||||||
|
MissionRequest {
|
||||||
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||||
|
mode: MissionMode::SingleMission,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("original mission");
|
||||||
|
let terrain = loaded_terrain(&engine).expect("original terrain");
|
||||||
|
let [min, max] = terrain.surface_bounds().expect("original map bounds");
|
||||||
|
let keys = HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]);
|
||||||
|
for (axis, yaw, edge) in [
|
||||||
|
(0, 0.0, max[0]),
|
||||||
|
(0, std::f32::consts::PI, min[0]),
|
||||||
|
(1, std::f32::consts::FRAC_PI_2, max[1]),
|
||||||
|
(1, -std::f32::consts::FRAC_PI_2, min[1]),
|
||||||
|
] {
|
||||||
|
let mut camera = FreeFlightCamera::from_bounds(min, max).expect("camera");
|
||||||
|
camera.position = [
|
||||||
|
(min[0] + max[0]) * 0.5,
|
||||||
|
(min[1] + max[1]) * 0.5,
|
||||||
|
max[2] + 10.0,
|
||||||
|
];
|
||||||
|
camera.yaw = yaw;
|
||||||
|
camera.pitch = 0.0;
|
||||||
|
for _ in 0..512 {
|
||||||
|
camera.advance(&keys, 0.1);
|
||||||
|
camera.keep_above_terrain(terrain).expect("map constraint");
|
||||||
|
for axis in 0..2 {
|
||||||
|
assert!((min[axis]..=max[axis]).contains(&camera.position[axis]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(camera.position[axis], edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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();
|
||||||
|
|||||||
@@ -409,6 +409,13 @@ impl TerrainWorld {
|
|||||||
self.surfaces.len()
|
self.surfaces.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounds of the indexed surface geometry, excluding unused source vertices.
|
||||||
|
#[must_use]
|
||||||
|
pub fn surface_bounds(&self) -> Option<[[f32; 3]; 2]> {
|
||||||
|
let root = self.surface_bvh.nodes.get(self.surface_bvh.root?)?;
|
||||||
|
Some([root.min, root.max])
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the validated source mesh retained for renderer integration.
|
/// Returns the validated source mesh retained for renderer integration.
|
||||||
///
|
///
|
||||||
/// Runtime collision queries use their own compact triangle representation;
|
/// Runtime collision queries use their own compact triangle representation;
|
||||||
@@ -1214,6 +1221,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn surface_bounds_cover_query_geometry_and_ignore_unused_positions() {
|
||||||
|
let mut mesh = synthetic_land_mesh();
|
||||||
|
mesh.positions.push([1000.0, -1000.0, 1000.0]);
|
||||||
|
let world = TerrainWorld::from_land_msh(&mesh).expect("world");
|
||||||
|
assert_eq!(world.surface_bounds(), Some([[0.0; 3], [1.0, 1.0, 2.0]]));
|
||||||
|
assert_eq!(TerrainWorld::default().surface_bounds(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn synthetic_surface_height_and_raycast_work() {
|
fn synthetic_surface_height_and_raycast_work() {
|
||||||
let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world");
|
let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world");
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ authoring API без динамического подтверждения.
|
|||||||
Не доказаны символами точные имена renderer vtable slots, полный набор CShade
|
Не доказаны символами точные имена renderer vtable slots, полный набор CShade
|
||||||
state transitions и окончательный порядок части transparent/FX/shadow subpasses.
|
state transitions и окончательный порядок части transparent/FX/shadow subpasses.
|
||||||
|
|
||||||
|
Свободная камера превью удерживается в XY-границах поверхностей карты и над
|
||||||
|
рельефом. Она пока не проверяет стены и проёмы строений; эти ограничения не
|
||||||
|
подтверждают поведение стратегической камеры или камеры робота оригинала.
|
||||||
|
|
||||||
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
|
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
|
||||||
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
|
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
|
||||||
log на минимальных сценах с одним типом материала.
|
log на минимальных сценах с одним типом материала.
|
||||||
|
|||||||
@@ -181,6 +181,21 @@ fn vertical_fov(self, aspect: f32) -> f32 {
|
|||||||
(`forward · direction <= 1e-4`). Лучи в передней полусфере могут проецироваться
|
(`forward · direction <= 1e-4`). Лучи в передней полусфере могут проецироваться
|
||||||
за край viewport; владелец спрайта отдельно решает, нужно ли его рисовать.
|
за край viewport; владелец спрайта отдельно решает, нужно ли его рисовать.
|
||||||
|
|
||||||
|
После движения свободная камера ограничивает X и Y прямоугольником
|
||||||
|
поверхностей карты, затем поднимается над найденным рельефом на заданный
|
||||||
|
зазор. Границы берутся из корня пространственного индекса `TerrainWorld`,
|
||||||
|
поэтому неиспользуемые вершины файла их не расширяют и повторного обхода
|
||||||
|
геометрии в каждом кадре нет:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
position[axis] = position[axis].clamp(min[axis], max[axis]); // axis = 0, 1
|
||||||
|
position[2] = position[2].max(ground_height + ground_clearance);
|
||||||
|
```
|
||||||
|
|
||||||
|
Высота полёта сверху не ограничена. Это ограничение диагностической камеры
|
||||||
|
превью; столкновения со стенами зданий и точные правила игровых камер оригинала
|
||||||
|
остаются отдельными задачами.
|
||||||
|
|
||||||
## Ограничения точного совпадения
|
## Ограничения точного совпадения
|
||||||
|
|
||||||
`VulkanStaticCamera::from_legacy_d3d7` выполняет восстановление вида и проекции
|
`VulkanStaticCamera::from_legacy_d3d7` выполняет восстановление вида и проекции
|
||||||
|
|||||||
Reference in new issue
Block a user