Skip to main content

lunar_bsp_build/
lib.rs

1//! offline BSP tree and PVS compiler for Lunar levels.
2//!
3//! call from your crate's `build.rs` to compile a level mesh into a binary blob
4//! that [`BspLevel`][lunar_bsp::level::BspLevel] can load at runtime with zero
5//! parsing cost.
6//!
7//! # build.rs example
8//!
9//! ```ignore
10//! fn main() {
11//!     let blob = lunar_bsp_build::compile_bsp_file("assets/levels/level1.glb").unwrap();
12//!     let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
13//!     std::fs::write(out.join("level1.bsp"), blob).unwrap();
14//!     println!("cargo:rerun-if-changed=assets/levels/level1.glb");
15//! }
16//! ```
17//!
18//! then in your game:
19//!
20//! ```ignore
21//! let level = BspLevel::from_binary(include_bytes!(concat!(env!("OUT_DIR"), "/level1.bsp")))
22//!     .expect("level bsp corrupt");
23//! app.insert_resource(level);
24//! ```
25//!
26//! # area ids
27//!
28//! name your GLTF meshes with an `areaN_` prefix (e.g. `area0_floor`, `area1_corridor`)
29//! to automatically assign area ids. meshes without this prefix are untagged and
30//! treated as always-visible geometry.
31//!
32//! portal hints let you override the auto-detected portals or add portals where
33//! the geometry auto-detection misses connections.
34
35pub mod gltf;
36pub mod partition;
37pub mod portal;
38pub mod pvs;
39
40pub use portal::BspPortalHint;
41
42use lunar_bsp::level::BspBlob;
43use lunar_math::Vec3;
44use partition::{InputTriangle, build_bsp};
45use portal::extract_portals;
46use pvs::compute_pvs;
47
48/// a mesh to include in the BSP compilation.
49///
50/// all vertices must be in world space (apply node transforms before passing in).
51/// if loading from GLTF, use [`compile_bsp_file`] which handles transforms automatically.
52pub struct BspInputMesh {
53	/// world-space vertex positions.
54	pub vertices: Vec<Vec3>,
55	/// triangle index list (every 3 indices = one triangle).
56	pub indices: Vec<u32>,
57	/// area id for portal culling. `None` = always-visible (not portal-culled).
58	pub area_id: Option<u32>,
59}
60
61/// configuration for the BSP compiler.
62pub struct BspCompileConfig {
63	/// maximum number of triangles per BSP leaf before forcing a split. default: 16.
64	pub max_leaf_size: usize,
65	/// number of random ray pairs to cast per leaf pair for PVS computation. default: 64.
66	pub pvs_samples: usize,
67	/// if > 0, leaf pairs whose centroids are farther apart than this distance (in
68	/// world units) are assumed not visible and skipped. default: 0 (always test).
69	pub pvs_skip_distance: f32,
70}
71
72impl Default for BspCompileConfig {
73	fn default() -> Self {
74		Self {
75			max_leaf_size: 16,
76			pvs_samples: 64,
77			pvs_skip_distance: 0.0,
78		}
79	}
80}
81
82/// compile a set of meshes into a BSP blob.
83///
84/// `hints` provides optional designer-placed portals. if empty, portals are
85/// auto-detected from adjacent leaf AABBs.
86///
87/// # Errors
88///
89/// returns an error string if no triangles are found or serialization fails.
90pub fn compile_bsp(
91	meshes: &[BspInputMesh],
92	hints: &[BspPortalHint],
93	config: &BspCompileConfig,
94) -> Result<Vec<u8>, String> {
95	// flatten all meshes into a single triangle list
96	let mut triangles: Vec<InputTriangle> = Vec::new();
97	let mut all_tris: Vec<[Vec3; 3]> = Vec::new();
98
99	for mesh in meshes {
100		let verts = &mesh.vertices;
101		let mut i = 0;
102		while i + 2 < mesh.indices.len() {
103			let i0 = mesh.indices[i] as usize;
104			let i1 = mesh.indices[i + 1] as usize;
105			let i2 = mesh.indices[i + 2] as usize;
106			if i0 >= verts.len() || i1 >= verts.len() || i2 >= verts.len() {
107				i += 3;
108				continue;
109			}
110			let orig_idx = triangles.len() as u32;
111			triangles.push(InputTriangle {
112				verts: [verts[i0], verts[i1], verts[i2]],
113				area_id: mesh.area_id,
114				original_index: orig_idx,
115			});
116			all_tris.push([verts[i0], verts[i1], verts[i2]]);
117			i += 3;
118		}
119	}
120
121	if triangles.is_empty() {
122		return Err("compile_bsp: no valid triangles in input meshes".into());
123	}
124
125	// build BSP tree
126	let partition = build_bsp(&triangles, config.max_leaf_size);
127
128	// compute PVS
129	let skip_dist_sq = if config.pvs_skip_distance > 0.0 {
130		config.pvs_skip_distance * config.pvs_skip_distance
131	} else {
132		0.0
133	};
134	let pvs = compute_pvs(
135		&partition.leaf_aabbs,
136		&all_tris,
137		config.pvs_samples,
138		skip_dist_sq,
139	);
140
141	// extract portals
142	let portals = extract_portals(&partition.leaf_aabbs, &partition.leaf_areas, hints);
143
144	// build area map: (leaf_index, area_id) for tagged leaves, sorted for binary search at runtime
145	let mut area_map: Vec<(u32, u32)> = partition
146		.leaf_areas
147		.iter()
148		.enumerate()
149		.filter_map(|(leaf, area)| area.map(|a| (leaf as u32, a)))
150		.collect();
151	area_map.sort_unstable_by_key(|&(li, _)| li);
152
153	let blob = BspBlob {
154		nodes: partition.nodes,
155		leaf_triangles: partition.leaf_triangles,
156		pvs: pvs.data,
157		pvs_stride: pvs.stride,
158		leaf_count: partition.leaf_count,
159		portals,
160		area_map,
161	};
162
163	bincode::serialize(&blob).map_err(|error| format!("bsp serialize error: {error}"))
164}
165
166/// compile a GLTF/GLB level file into a BSP blob using default settings.
167///
168/// equivalent to loading with [`gltf::load_gltf_meshes`] then calling [`compile_bsp`]
169/// with default config and no hints.
170///
171/// # Errors
172///
173/// returns an error if the file cannot be read, parsed, or compiled.
174pub fn compile_bsp_file(path: &str) -> Result<Vec<u8>, String> {
175	compile_bsp_file_with_config(path, &[], &BspCompileConfig::default())
176}
177
178/// compile a GLTF/GLB level file with explicit portal hints and compile config.
179///
180/// # Errors
181///
182/// returns an error if the file cannot be read, parsed, or compiled.
183pub fn compile_bsp_file_with_config(
184	path: &str,
185	hints: &[BspPortalHint],
186	config: &BspCompileConfig,
187) -> Result<Vec<u8>, String> {
188	let meshes = gltf::load_gltf_meshes(path)?;
189	compile_bsp(&meshes, hints, config)
190}
191
192#[cfg(test)]
193mod tests {
194	use super::*;
195	use lunar_bsp::level::BspLevel;
196
197	fn unit_cube_mesh(area_id: Option<u32>) -> BspInputMesh {
198		// 12 triangles for a unit cube [0,1]^3
199		let v = |x: f32, y: f32, z: f32| Vec3::new(x, y, z);
200		let verts = vec![
201			v(0.0, 0.0, 0.0),
202			v(1.0, 0.0, 0.0),
203			v(1.0, 1.0, 0.0),
204			v(0.0, 1.0, 0.0), // front
205			v(0.0, 0.0, 1.0),
206			v(1.0, 0.0, 1.0),
207			v(1.0, 1.0, 1.0),
208			v(0.0, 1.0, 1.0), // back
209		];
210		let indices = vec![
211			0, 1, 2, 0, 2, 3, // front
212			4, 6, 5, 4, 7, 6, // back
213			0, 4, 5, 0, 5, 1, // bottom
214			3, 2, 6, 3, 6, 7, // top
215			0, 3, 7, 0, 7, 4, // left
216			1, 5, 6, 1, 6, 2, // right
217		];
218		BspInputMesh {
219			vertices: verts,
220			indices,
221			area_id,
222		}
223	}
224
225	#[test]
226	fn compile_and_load_round_trip() {
227		let mesh = unit_cube_mesh(Some(0));
228		let blob = compile_bsp(&[mesh], &[], &BspCompileConfig::default()).unwrap();
229		let level = BspLevel::from_binary(&blob).unwrap();
230		assert!(level.is_loaded());
231		let leaf = level.camera_leaf(Vec3::new(0.5, 0.5, 0.5));
232		let visible = level.visible_leaves(leaf);
233		assert!(
234			!visible.is_empty(),
235			"camera leaf should see at least itself"
236		);
237	}
238
239	#[test]
240	fn two_area_portals_detected() {
241		// two cubes side-by-side, different areas — should auto-detect a portal
242		let mesh_a = unit_cube_mesh(Some(0));
243		let mesh_b = BspInputMesh {
244			vertices: unit_cube_mesh(Some(1))
245				.vertices
246				.iter()
247				.map(|v| Vec3::new(v.x + 1.0, v.y, v.z))
248				.collect(),
249			indices: unit_cube_mesh(Some(1)).indices,
250			area_id: Some(1),
251		};
252		let blob = compile_bsp(
253			&[mesh_a, mesh_b],
254			&[],
255			&BspCompileConfig {
256				pvs_samples: 8,
257				..Default::default()
258			},
259		)
260		.unwrap();
261		let level = BspLevel::from_binary(&blob).unwrap();
262		assert!(level.is_loaded());
263	}
264
265	#[test]
266	fn empty_input_is_error() {
267		assert!(compile_bsp(&[], &[], &BspCompileConfig::default()).is_err());
268	}
269
270	#[test]
271	fn file_not_found_is_error() {
272		assert!(compile_bsp_file("does_not_exist.glb").is_err());
273	}
274}