aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 18:51:19 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 18:51:19 +0300
commit5e940f92bad9d806cfc0645ce2b80aa4be386eb9 (patch)
tree1c21459d0425a45a323017d184ef5426b30ec104
parent0736c76d879fc0320ae5c61ee57c4f9984678f09 (diff)
downloadfparkan-5e940f92bad9d806cfc0645ce2b80aa4be386eb9.tar.xz
fparkan-5e940f92bad9d806cfc0645ce2b80aa4be386eb9.zip
feat(terrain): index surface queries with bvh
-rw-r--r--crates/fparkan-terrain/src/lib.rs262
-rw-r--r--docs/tomes/04-world.md21
2 files changed, 280 insertions, 3 deletions
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
index 4eef871..de67fd0 100644
--- a/crates/fparkan-terrain/src/lib.rs
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -36,6 +36,7 @@ pub struct TerrainWorld {
grid: RuntimeGrid,
adjacency: Vec<Vec<ArealId>>,
surfaces: Vec<RuntimeTriangle>,
+ surface_bvh: SurfaceBvh,
source_mesh: Option<LandMeshDocument>,
}
@@ -239,6 +240,7 @@ impl TerrainWorld {
grid,
adjacency,
surfaces: Vec::new(),
+ surface_bvh: SurfaceBvh::default(),
source_mesh: None,
})
}
@@ -252,9 +254,11 @@ impl TerrainWorld {
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
Ok(Self {
surfaces: build_surfaces(mesh)?,
+ surface_bvh: SurfaceBvh::default(),
source_mesh: Some(mesh.clone()),
..Self::default()
- })
+ }
+ .with_surface_bvh())
}
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
@@ -269,6 +273,7 @@ impl TerrainWorld {
) -> Result<Self, TerrainError> {
let mut world = Self::from_land_map(map)?;
world.surfaces = build_surfaces(mesh)?;
+ world.surface_bvh = SurfaceBvh::from_surfaces(&world.surfaces);
world.source_mesh = Some(mesh.clone());
Ok(world)
}
@@ -303,6 +308,11 @@ impl TerrainWorld {
.map(|mesh| mesh.positions.as_slice())
}
+ fn with_surface_bvh(mut self) -> Self {
+ self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
+ self
+ }
+
fn locate_by_candidates(
&self,
position: [f32; 3],
@@ -399,7 +409,8 @@ impl SurfaceQuery for TerrainWorld {
return Err(TerrainError::Unsupported);
}
let mut best = None;
- for triangle in &self.surfaces {
+ for index in self.surface_bvh.xy_candidates(position) {
+ let triangle = &self.surfaces[index];
if let Some(height) = triangle.height_at(position) {
best = Some(best.map_or(height, |current: f32| current.max(height)));
}
@@ -416,8 +427,11 @@ impl SurfaceQuery for TerrainWorld {
if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported);
}
+ let mut candidates = self.surface_bvh.ray_candidates(origin, direction);
+ candidates.sort_unstable();
let mut best: Option<SurfaceHit> = None;
- for triangle in &self.surfaces {
+ for index in candidates {
+ let triangle = &self.surfaces[index];
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
continue;
}
@@ -472,6 +486,202 @@ struct RuntimeTriangle {
vertices: [[f32; 3]; 3],
}
+const SURFACE_BVH_LEAF_SIZE: usize = 8;
+const SURFACE_BOUNDS_EPSILON: f32 = 1.0e-4;
+
+/// Deterministic CPU index over validated terrain triangles.
+///
+/// The index changes only candidate selection: triangle math and observable
+/// tie-breaking remain in [`SurfaceQuery`] above.
+#[derive(Clone, Debug, Default)]
+struct SurfaceBvh {
+ nodes: Vec<SurfaceBvhNode>,
+ triangle_indices: Vec<usize>,
+ root: Option<usize>,
+}
+
+#[derive(Clone, Debug)]
+struct SurfaceBvhNode {
+ min: [f32; 3],
+ max: [f32; 3],
+ kind: SurfaceBvhNodeKind,
+}
+
+#[derive(Clone, Debug)]
+enum SurfaceBvhNodeKind {
+ Leaf { first: usize, count: usize },
+ Branch { left: usize, right: usize },
+}
+
+impl SurfaceBvh {
+ fn from_surfaces(surfaces: &[RuntimeTriangle]) -> Self {
+ if surfaces.is_empty() {
+ return Self::default();
+ }
+ let mut index = Self {
+ nodes: Vec::with_capacity(surfaces.len().saturating_mul(2)),
+ triangle_indices: Vec::with_capacity(surfaces.len()),
+ root: None,
+ };
+ let indices: Vec<usize> = (0..surfaces.len()).collect();
+ index.root = Some(index.build(surfaces, indices));
+ index
+ }
+
+ fn build(&mut self, surfaces: &[RuntimeTriangle], mut indices: Vec<usize>) -> usize {
+ let (min, max) = surface_bounds(surfaces, &indices);
+ if indices.len() <= SURFACE_BVH_LEAF_SIZE {
+ indices.sort_unstable();
+ let first = self.triangle_indices.len();
+ let count = indices.len();
+ self.triangle_indices.extend(indices);
+ let node = self.nodes.len();
+ self.nodes.push(SurfaceBvhNode {
+ min,
+ max,
+ kind: SurfaceBvhNodeKind::Leaf { first, count },
+ });
+ return node;
+ }
+
+ let axis = longest_axis(min, max);
+ indices.sort_by(|left, right| {
+ surface_centroid(&surfaces[*left])[axis]
+ .total_cmp(&surface_centroid(&surfaces[*right])[axis])
+ .then_with(|| left.cmp(right))
+ });
+ let right_indices = indices.split_off(indices.len() / 2);
+ let left = self.build(surfaces, indices);
+ let right = self.build(surfaces, right_indices);
+ let node = self.nodes.len();
+ self.nodes.push(SurfaceBvhNode {
+ min,
+ max,
+ kind: SurfaceBvhNodeKind::Branch { left, right },
+ });
+ node
+ }
+
+ fn xy_candidates(&self, point: [f32; 2]) -> Vec<usize> {
+ let mut candidates = Vec::new();
+ let Some(root) = self.root else {
+ return candidates;
+ };
+ let mut pending = vec![root];
+ while let Some(node_index) = pending.pop() {
+ let node = &self.nodes[node_index];
+ if !point_within_surface_bounds(point, node.min, node.max) {
+ continue;
+ }
+ match node.kind {
+ SurfaceBvhNodeKind::Leaf { first, count } => {
+ candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
+ }
+ SurfaceBvhNodeKind::Branch { left, right } => {
+ pending.push(right);
+ pending.push(left);
+ }
+ }
+ }
+ candidates
+ }
+
+ fn ray_candidates(&self, origin: [f32; 3], direction: [f32; 3]) -> Vec<usize> {
+ let mut candidates = Vec::new();
+ let Some(root) = self.root else {
+ return candidates;
+ };
+ let mut pending = vec![root];
+ while let Some(node_index) = pending.pop() {
+ let node = &self.nodes[node_index];
+ if !ray_intersects_surface_bounds(origin, direction, node.min, node.max) {
+ continue;
+ }
+ match node.kind {
+ SurfaceBvhNodeKind::Leaf { first, count } => {
+ candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
+ }
+ SurfaceBvhNodeKind::Branch { left, right } => {
+ pending.push(right);
+ pending.push(left);
+ }
+ }
+ }
+ candidates
+ }
+}
+
+fn surface_bounds(surfaces: &[RuntimeTriangle], indices: &[usize]) -> ([f32; 3], [f32; 3]) {
+ let mut min = [f32::INFINITY; 3];
+ let mut max = [f32::NEG_INFINITY; 3];
+ for index in indices {
+ for vertex in surfaces[*index].vertices {
+ for axis in 0..3 {
+ min[axis] = min[axis].min(vertex[axis]);
+ max[axis] = max[axis].max(vertex[axis]);
+ }
+ }
+ }
+ (min, max)
+}
+
+fn surface_centroid(triangle: &RuntimeTriangle) -> [f32; 3] {
+ let mut centroid = [0.0; 3];
+ for vertex in triangle.vertices {
+ for axis in 0..3 {
+ centroid[axis] += vertex[axis] / 3.0;
+ }
+ }
+ centroid
+}
+
+fn longest_axis(min: [f32; 3], max: [f32; 3]) -> usize {
+ let extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
+ if extent[1] > extent[0] && extent[1] >= extent[2] {
+ 1
+ } else if extent[2] > extent[0] && extent[2] > extent[1] {
+ 2
+ } else {
+ 0
+ }
+}
+
+fn point_within_surface_bounds(point: [f32; 2], min: [f32; 3], max: [f32; 3]) -> bool {
+ point[0] >= min[0] - SURFACE_BOUNDS_EPSILON
+ && point[0] <= max[0] + SURFACE_BOUNDS_EPSILON
+ && point[1] >= min[1] - SURFACE_BOUNDS_EPSILON
+ && point[1] <= max[1] + SURFACE_BOUNDS_EPSILON
+}
+
+fn ray_intersects_surface_bounds(
+ origin: [f32; 3],
+ direction: [f32; 3],
+ min: [f32; 3],
+ max: [f32; 3],
+) -> bool {
+ let mut near = 0.0_f32;
+ let mut far = f32::INFINITY;
+ for axis in 0..3 {
+ if direction[axis].abs() <= f32::EPSILON {
+ if origin[axis] < min[axis] - SURFACE_BOUNDS_EPSILON
+ || origin[axis] > max[axis] + SURFACE_BOUNDS_EPSILON
+ {
+ return false;
+ }
+ continue;
+ }
+ let inverse = 1.0 / direction[axis];
+ let first = (min[axis] - origin[axis]) * inverse;
+ let second = (max[axis] - origin[axis]) * inverse;
+ near = near.max(first.min(second));
+ far = far.min(first.max(second));
+ if far < near {
+ return false;
+ }
+ }
+ far >= 0.0
+}
+
impl RuntimeTriangle {
fn height_at(&self, position: [f32; 2]) -> Option<f32> {
let a = [self.vertices[0][0], self.vertices[0][1]];
@@ -844,6 +1054,30 @@ mod tests {
}
#[test]
+ fn surface_bvh_reduces_candidates_without_changing_surface_math() {
+ let surfaces: Vec<_> = (0_u16..16)
+ .map(|face| {
+ let x = f32::from(face) * 10.0;
+ RuntimeTriangle {
+ face: usize::from(face),
+ mask: FullSurfaceMask(1),
+ vertices: [[x, 0.0, x], [x + 1.0, 0.0, x], [x, 1.0, x]],
+ }
+ })
+ .collect();
+ let index = SurfaceBvh::from_surfaces(&surfaces);
+
+ let xy = index.xy_candidates([0.25, 0.25]);
+ assert!(xy.contains(&0));
+ assert!(xy.len() < surfaces.len());
+ assert_eq!(surfaces[xy[0]].height_at([0.25, 0.25]), Some(0.0));
+
+ let ray = index.ray_candidates([0.25, 0.25, 5.0], [0.0, 0.0, -1.0]);
+ assert!(ray.contains(&0));
+ assert!(ray.len() < surfaces.len());
+ }
+
+ #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_maps_build_navigation_worlds() {
for (corpus, expected_files, expected_areals) in [
@@ -907,6 +1141,7 @@ mod tests {
let root = corpus_root(corpus);
let mut files = 0usize;
let mut faces = 0usize;
+ let mut indexed_queries = 0usize;
for path in files_under(&root) {
if !path
.file_name()
@@ -925,12 +1160,33 @@ mod tests {
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
let world = TerrainWorld::from_land_msh(&mesh)
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ if world.surface_count() > SURFACE_BVH_LEAF_SIZE {
+ let triangle = &world.surfaces[0];
+ let point = [
+ (triangle.vertices[0][0]
+ + triangle.vertices[1][0]
+ + triangle.vertices[2][0])
+ / 3.0,
+ (triangle.vertices[0][1]
+ + triangle.vertices[1][1]
+ + triangle.vertices[2][1])
+ / 3.0,
+ ];
+ let candidates = world.surface_bvh.xy_candidates(point);
+ assert!(candidates.contains(&0), "{corpus} {path:?} face zero");
+ assert!(
+ candidates.len() < world.surface_count(),
+ "{corpus} {path:?} surface index did not reduce candidates"
+ );
+ indexed_queries += 1;
+ }
files += 1;
faces += world.surface_count();
}
assert_eq!(files, expected_files, "{corpus} Land.msh count");
assert_eq!(faces, expected_faces, "{corpus} surface face count");
+ assert_eq!(indexed_queries, files, "{corpus} indexed Land.msh count");
}
}
diff --git a/docs/tomes/04-world.md b/docs/tomes/04-world.md
index 82d9e47..9961a49 100644
--- a/docs/tomes/04-world.md
+++ b/docs/tomes/04-world.md
@@ -555,6 +555,27 @@ candidate areas, затем выполняется точная геометри
Если область не найдена, caller получает явный miss и решает, допустим ли
fallback к ближайшей области.
+### Индекс поверхности
+
+`TerrainWorld` хранит отдельный deterministic BVH по валидированным
+`TerrainFace28` triangles. Он не заменяет `Land.map` grid: BVH отвечает за
+низкоуровневые surface queries (`height_at` и `raycast`), тогда как grid и
+areal graph остаются навигационным контрактом.
+
+При построении индекс делит triangles по centroid вдоль самой протяжённой оси;
+в leaf остаётся не более восьми face indices. XY query проходит только листья,
+чьи AABB покрывают точку, а raycast — только AABB, пересечённые лучом. После
+выбора кандидатов raycast сортирует их по исходному face index, поэтому при
+равной дистанции сохраняется прежний deterministic tie-break; `height_at`
+по-прежнему выбирает максимальную высоту среди действительно покрывающих
+точку triangles. Индекс меняет стоимость запроса, но не геометрическую
+семантику.
+
+Synthetic test проверяет сокращение candidate set и прежние height/raycast
+results. Licensed run проходит все 33 `Land.msh` Части 1 и 32 файла Части 2:
+для каждого mesh с числом faces выше leaf limit query у центра первого face
+включает этот face и использует меньше кандидатов, чем полный mesh.
+
### Маршрут
После определения начальной и целевой областей маршрут строится по графу