diff --git a/docs/realtime-csg-bevy-assembler.md b/docs/realtime-csg-bevy-assembler.md index 0058472..4408e27 100644 --- a/docs/realtime-csg-bevy-assembler.md +++ b/docs/realtime-csg-bevy-assembler.md @@ -17,6 +17,24 @@ The public C# layer still exposes the parts worth stealing cleanly: - dirty generation counters - a rebuild step that turns authored brushes into renderable meshes +The older public demo is more important than the Unity product for clean +algorithm work. Sander van Rossen's 2009-2010 blog series describes the +principles directly: consistent epsilon classification, half-edges, polygon +cutting, convex brush construction, boolean categorization, and the idea that +subtraction can be expressed as composed logical operations. The +`LogicalError/Realtime-CSG-demo` repository is public C# code from the article +"Real Time Constructive Solid Geometry" in *Game Development Tools*. It is now +the preferred research input for algorithmic behavior because it is public +source tied to the article rather than a closed Unity native kernel. + +Verified public sources: + +- Sander van Rossen, "Realtime CSG - Part 1" +- Sander van Rossen, "Realtime CSG - Part 5" +- Sander van Rossen and Matthew Baranowski, "Real-Time Constructive Solid + Geometry", *Game Development Tools* +- `LogicalError/Realtime-CSG-demo` + That shape maps well to Bevy: keep authoring data as lightweight brush records, rebuild only dirty worlds, and emit plain mesh buffers that can later be handed to Bevy render assets. @@ -49,10 +67,20 @@ let output = level.assemble(); assert!(output.mesh.triangle_count() > 12); ``` -The first actual CSG operator is exact AABB subtraction against additive AABB -solids. A cutter splits a source box into up to six surviving slabs. This is -enough for doors, windows, vents, corridor punches, terraces, anchor trenches, -and level blockout loops. +The first actual CSG operator was exact AABB subtraction against additive AABB +solids. The current kernel has moved to convex decomposition: an additive +convex solid is split against each outward cutter plane, outside fragments are +emitted, and the inside remainder is discarded after the final plane. This +supports angled box cuts and gives us the correct shape for a future general +convex brush kernel. + +The important doctrinal adjustment from the public demo and blog series is that +real-time CSG should think in **classification**, not only carving. A polygon is +inside, outside, aligned, or reverse-aligned relative to another node. Boolean +operations are then mostly routing tables for those categories. Our current +fragment-splitting implementation is a stepping stone; the faster mature kernel +should preserve brush polygons, classify them through the tree, and emit only +the visible categories. Procedural non-CSG primitives are additive: @@ -117,18 +145,22 @@ or sweep than by carving: - flower-like utility crests and other habitat-specific silhouettes The immediate next upgrade is not "more primitives." It is a stronger convex -brush kernel: plane classification, polygon clipping, fragment merge policy, -and a BVH or spatial hash so cutter cost does not scale like a punishment. +brush kernel: plane classification, polygon clipping, category routing, +fragment merge policy, and a BVH or spatial hash so cutter cost does not scale +like a punishment. ## Limits `vg_csg` is not yet a full RealtimeCSG replacement. -- only box subtractors affect box solids +- subtractive convex support currently covers axis-aligned and oriented boxes - non-box subtractors are reported and ignored - intersect brushes are reported and ignored - output is Bevy-compatible mesh data, not yet a Bevy `Mesh` asset constructor - there is no editor gizmo, ECS plugin, or incremental spatial index yet +- current subtraction emits split fragments with generated cap polygons; the + target kernel should move toward article/demo-style category routing over + brush polygons for speed and cleaner material behavior Those limits are explicit because invisible ambition is how you get haunted by your own abstractions. The useful loop exists now: script brushes, assemble, diff --git a/examples/csg_room.rs b/examples/csg_room.rs index c70252f..9b5e6f2 100644 --- a/examples/csg_room.rs +++ b/examples/csg_room.rs @@ -47,9 +47,9 @@ fn main() { let output = level.assemble(); println!( - "brushes={} box_fragments={} vertices={} triangles={} warnings={}", + "brushes={} convex_fragments={} vertices={} triangles={} warnings={}", output.report.input_brushes, - output.report.emitted_box_fragments, + output.report.emitted_convex_fragments, output.mesh.vertex_count(), output.mesh.triangle_count(), output.report.warnings.len() diff --git a/src/assembler.rs b/src/assembler.rs index 89ad719..aa7016b 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -1,8 +1,8 @@ use bevy_math::Vec3; use crate::{ - Aabb, Brush, BrushId, BrushOp, MaterialId, Primitive, TriangleMesh, append_cylinder_z, - append_dome_cap_z, append_floret_arm, + Aabb, Brush, BrushId, BrushOp, ConvexSolid, MaterialId, Primitive, TriangleMesh, + append_cylinder_z, append_dome_cap_z, append_floret_arm, primitives::{DomeCapZSpec, FloretArmSpec}, }; @@ -15,7 +15,7 @@ pub enum BuildWarning { #[derive(Clone, Debug, Default)] pub struct BuildReport { pub input_brushes: usize, - pub emitted_box_fragments: usize, + pub emitted_convex_fragments: usize, pub warnings: Vec, } @@ -26,12 +26,6 @@ pub struct BuildOutput { pub generation: u64, } -#[derive(Clone, Debug)] -struct BoxSolid { - bounds: Aabb, - material: MaterialId, -} - #[derive(Clone, Debug, Default)] pub struct Assembler { brushes: Vec, @@ -85,21 +79,71 @@ impl Assembler { ) } + pub fn solid_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: bevy_math::Quat, + material: MaterialId, + ) -> BrushId { + self.add_brush( + name, + BrushOp::Add, + Primitive::OrientedBox { + center, + size, + rotation, + }, + material, + ) + } + + pub fn cut_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: bevy_math::Quat, + ) -> BrushId { + self.add_brush( + name, + BrushOp::Subtract, + Primitive::OrientedBox { + center, + size, + rotation, + }, + MaterialId::default(), + ) + } + pub fn build(&self) -> BuildOutput { let mut report = BuildReport { input_brushes: self.brushes.len(), ..BuildReport::default() }; let mut mesh = TriangleMesh::new(); - let mut boxes: Vec = Vec::new(); + let mut solids: Vec = Vec::new(); for brush in &self.brushes { match brush.op { BrushOp::Add => match &brush.primitive { - Primitive::Box { bounds } => boxes.push(BoxSolid { - bounds: *bounds, - material: brush.material, - }), + Primitive::Box { bounds } => { + solids.push(ConvexSolid::from_aabb(*bounds, brush.material)); + } + Primitive::OrientedBox { + center, + size, + rotation, + } => { + solids.push(ConvexSolid::box_from_center_size( + *center, + *size, + *rotation, + brush.material, + )); + } Primitive::CylinderZ { center, radius, @@ -153,8 +197,8 @@ impl Assembler { ), }, BrushOp::Subtract => { - if let Some(cutter) = brush.as_box() { - boxes = subtract_from_boxes(&boxes, cutter); + if let Some(cutter) = convex_cutter_for_brush(brush) { + solids = subtract_from_solids(&solids, &cutter); } else { report .warnings @@ -171,9 +215,9 @@ impl Assembler { } } - report.emitted_box_fragments = boxes.len(); - for solid in boxes { - mesh.append_box(solid.bounds, solid.material); + report.emitted_convex_fragments = solids.len(); + for solid in solids { + solid.append_to_mesh(&mut mesh); } BuildOutput { @@ -205,15 +249,27 @@ impl Assembler { } } -fn subtract_from_boxes(boxes: &[BoxSolid], cutter: Aabb) -> Vec { - let mut out = Vec::with_capacity(boxes.len() + 4); - for solid in boxes { - for bounds in solid.bounds.subtract_box(cutter) { - out.push(BoxSolid { - bounds, - material: solid.material, - }); - } +fn convex_cutter_for_brush(brush: &Brush) -> Option { + match &brush.primitive { + Primitive::Box { bounds } => Some(ConvexSolid::from_aabb(*bounds, brush.material)), + Primitive::OrientedBox { + center, + size, + rotation, + } => Some(ConvexSolid::box_from_center_size( + *center, + *size, + *rotation, + brush.material, + )), + _ => None, + } +} + +fn subtract_from_solids(solids: &[ConvexSolid], cutter: &ConvexSolid) -> Vec { + let mut out = Vec::with_capacity(solids.len() + 4); + for solid in solids { + out.extend(solid.subtract_convex(cutter)); } out } @@ -240,7 +296,7 @@ mod tests { let output = Assembler::sample_room_with_door().build(); let cutter = Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)); - assert!(output.report.emitted_box_fragments > 2); + assert!(output.report.emitted_convex_fragments > 2); for tri in output.mesh.indices.chunks_exact(3) { let center = (output.mesh.positions[tri[0] as usize] + output.mesh.positions[tri[1] as usize] @@ -250,6 +306,28 @@ mod tests { } } + #[test] + fn rotated_door_cut_produces_diagonal_portal_faces() { + let mut asm = Assembler::new(); + asm.solid_box( + "wall", + Aabb::from_center_size(Vec3::new(0.0, 0.0, 1.5), Vec3::new(5.0, 0.5, 3.0)), + MaterialId(1), + ); + asm.cut_oriented_box( + "angled void", + Vec3::new(0.0, 0.0, 1.5), + Vec3::new(1.5, 2.0, 2.0), + bevy_math::Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), + ); + + let output = asm.build(); + assert!(output.report.emitted_convex_fragments > 2); + assert!(output.mesh.normals.iter().any(|normal| { + normal.x.abs() > 0.2 && normal.y.abs() > 0.2 && normal.z.abs() < 0.1 + })); + } + #[test] fn mesh_box_emits_twelve_triangles() { let mut mesh = TriangleMesh::new(); diff --git a/src/brush.rs b/src/brush.rs index d17e8a5..2f04216 100644 --- a/src/brush.rs +++ b/src/brush.rs @@ -149,6 +149,11 @@ pub enum Primitive { Box { bounds: Aabb, }, + OrientedBox { + center: Vec3, + size: Vec3, + rotation: Quat, + }, CylinderZ { center: Vec3, radius: f32, diff --git a/src/convex.rs b/src/convex.rs new file mode 100644 index 0000000..a00d810 --- /dev/null +++ b/src/convex.rs @@ -0,0 +1,381 @@ +use bevy_math::{Quat, Vec2, Vec3}; + +use crate::{Aabb, MaterialId, TriangleMesh}; + +const EPSILON: f32 = 1.0e-5; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Plane { + pub normal: Vec3, + pub distance: f32, +} + +impl Plane { + pub fn from_point_normal(point: Vec3, normal: Vec3) -> Self { + let normal = normal.normalize_or_zero(); + Self { + normal, + distance: -normal.dot(point), + } + } + + pub fn signed_distance(self, point: Vec3) -> f32 { + self.normal.dot(point) + self.distance + } +} + +#[derive(Clone, Debug)] +pub struct ConvexPolygon { + pub vertices: Vec, + pub normal: Vec3, + pub material: MaterialId, +} + +#[derive(Clone, Debug)] +pub struct ConvexSolid { + pub polygons: Vec, + pub clip_planes: Vec, + pub material: MaterialId, +} + +impl ConvexSolid { + pub fn from_aabb(bounds: Aabb, material: MaterialId) -> Self { + Self::box_from_center_size(bounds.center(), bounds.size(), Quat::IDENTITY, material) + } + + pub fn box_from_center_size( + center: Vec3, + size: Vec3, + rotation: Quat, + material: MaterialId, + ) -> Self { + let half = size * 0.5; + let local_corners = [ + Vec3::new(-half.x, -half.y, -half.z), + Vec3::new(half.x, -half.y, -half.z), + Vec3::new(half.x, half.y, -half.z), + Vec3::new(-half.x, half.y, -half.z), + Vec3::new(-half.x, -half.y, half.z), + Vec3::new(half.x, -half.y, half.z), + Vec3::new(half.x, half.y, half.z), + Vec3::new(-half.x, half.y, half.z), + ]; + let corners = local_corners.map(|point| center + rotation * point); + let faces = [ + ([0, 3, 2, 1], Vec3::NEG_Z), + ([4, 5, 6, 7], Vec3::Z), + ([0, 1, 5, 4], Vec3::NEG_Y), + ([3, 7, 6, 2], Vec3::Y), + ([0, 4, 7, 3], Vec3::NEG_X), + ([1, 2, 6, 5], Vec3::X), + ]; + + let mut polygons = Vec::with_capacity(6); + let mut clip_planes = Vec::with_capacity(6); + for (indices, local_normal) in faces { + let normal = rotation * local_normal; + let vertices = indices.map(|index| corners[index]).to_vec(); + clip_planes.push(Plane::from_point_normal(vertices[0], normal)); + polygons.push(ConvexPolygon { + vertices, + normal, + material, + }); + } + + Self { + polygons, + clip_planes, + material, + } + } + + pub fn subtract_convex(&self, cutter: &Self) -> Vec { + let mut fragments = Vec::new(); + let mut remainder = Some(self.clone()); + + for plane in &cutter.clip_planes { + let Some(current) = remainder.take() else { + break; + }; + + match current.split(*plane) { + SplitResult::Front(front) => { + fragments.push(front); + break; + } + SplitResult::Back(back) => { + remainder = Some(back); + } + SplitResult::Both { front, back } => { + fragments.push(front); + remainder = Some(back); + } + SplitResult::Coplanar(solid) => { + remainder = Some(solid); + } + } + } + + fragments + } + + pub fn append_to_mesh(&self, mesh: &mut TriangleMesh) { + for polygon in &self.polygons { + if polygon.vertices.len() < 3 { + continue; + } + let base = polygon.vertices[0]; + for i in 1..polygon.vertices.len() - 1 { + mesh.append_triangle( + [base, polygon.vertices[i], polygon.vertices[i + 1]], + polygon.normal, + [Vec2::ZERO, Vec2::X, Vec2::Y], + polygon.material, + ); + } + } + } + + fn split(&self, plane: Plane) -> SplitResult { + let mut saw_front = false; + let mut saw_back = false; + for polygon in &self.polygons { + for vertex in &polygon.vertices { + let distance = plane.signed_distance(*vertex); + saw_front |= distance > EPSILON; + saw_back |= distance < -EPSILON; + } + } + + match (saw_front, saw_back) { + (true, false) => return SplitResult::Front(self.clone()), + (false, true) => return SplitResult::Back(self.clone()), + (false, false) => return SplitResult::Coplanar(self.clone()), + (true, true) => {} + } + + let mut front_polygons = Vec::new(); + let mut back_polygons = Vec::new(); + let mut cap_points = Vec::new(); + + for polygon in &self.polygons { + let front = clip_polygon(&polygon.vertices, plane, KeepSide::Front, &mut cap_points); + if front.len() >= 3 { + front_polygons.push(ConvexPolygon { + normal: polygon.normal, + vertices: front, + material: polygon.material, + }); + } + + let back = clip_polygon(&polygon.vertices, plane, KeepSide::Back, &mut cap_points); + if back.len() >= 3 { + back_polygons.push(ConvexPolygon { + normal: polygon.normal, + vertices: back, + material: polygon.material, + }); + } + } + + let cap_points = unique_points_on_plane(cap_points); + if cap_points.len() >= 3 { + front_polygons.push(make_cap_polygon(&cap_points, -plane.normal, self.material)); + back_polygons.push(make_cap_polygon(&cap_points, plane.normal, self.material)); + } + + SplitResult::Both { + front: Self { + clip_planes: planes_from_polygons(&front_polygons), + polygons: front_polygons, + material: self.material, + }, + back: Self { + clip_planes: planes_from_polygons(&back_polygons), + polygons: back_polygons, + material: self.material, + }, + } + } +} + +enum SplitResult { + Front(ConvexSolid), + Back(ConvexSolid), + Both { + front: ConvexSolid, + back: ConvexSolid, + }, + Coplanar(ConvexSolid), +} + +#[derive(Clone, Copy)] +enum KeepSide { + Front, + Back, +} + +fn clip_polygon( + vertices: &[Vec3], + plane: Plane, + keep: KeepSide, + cap_points: &mut Vec, +) -> Vec { + let mut output = Vec::new(); + if vertices.is_empty() { + return output; + } + + let mut previous = *vertices.last().expect("checked non-empty"); + let mut previous_distance = plane.signed_distance(previous); + let mut previous_inside = inside(previous_distance, keep); + + for ¤t in vertices { + let current_distance = plane.signed_distance(current); + let current_inside = inside(current_distance, keep); + + if current_inside != previous_inside { + let t = previous_distance / (previous_distance - current_distance); + let intersection = previous + (current - previous) * t; + output.push(intersection); + cap_points.push(intersection); + } + + if current_inside { + output.push(current); + } + + previous = current; + previous_distance = current_distance; + previous_inside = current_inside; + } + + output +} + +fn inside(distance: f32, keep: KeepSide) -> bool { + match keep { + KeepSide::Front => distance >= -EPSILON, + KeepSide::Back => distance <= EPSILON, + } +} + +fn make_cap_polygon(points: &[Vec3], normal: Vec3, material: MaterialId) -> ConvexPolygon { + let center = points.iter().copied().sum::() / points.len() as f32; + let axis = if normal.z.abs() < 0.9 { + Vec3::Z + } else { + Vec3::Y + }; + let tangent = normal.cross(axis).normalize_or_zero(); + let bitangent = normal.cross(tangent).normalize_or_zero(); + let mut vertices = points.to_vec(); + + vertices.sort_by(|a, b| { + let da = *a - center; + let db = *b - center; + let aa = da.dot(bitangent).atan2(da.dot(tangent)); + let ab = db.dot(bitangent).atan2(db.dot(tangent)); + aa.total_cmp(&ab) + }); + + if polygon_normal(&vertices).dot(normal) < 0.0 { + vertices.reverse(); + } + + ConvexPolygon { + vertices, + normal, + material, + } +} + +fn unique_points_on_plane(points: Vec) -> Vec { + let mut unique = Vec::new(); + 'outer: for point in points { + for seen in &unique { + let delta: Vec3 = point - *seen; + if delta.length_squared() < EPSILON * EPSILON { + continue 'outer; + } + } + unique.push(point); + } + unique +} + +fn planes_from_polygons(polygons: &[ConvexPolygon]) -> Vec { + polygons + .iter() + .filter_map(|polygon| { + polygon + .vertices + .first() + .map(|point| Plane::from_point_normal(*point, polygon.normal)) + }) + .collect() +} + +fn polygon_normal(vertices: &[Vec3]) -> Vec3 { + if vertices.len() < 3 { + return Vec3::ZERO; + } + (vertices[1] - vertices[0]) + .cross(vertices[2] - vertices[0]) + .normalize_or_zero() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subtract_center_box_emits_six_convex_fragments() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let fragments = source.subtract_convex(&cutter); + assert_eq!(fragments.len(), 6); + assert!( + fragments + .iter() + .all(|fragment| !fragment.polygons.is_empty()) + ); + } + + #[test] + fn subtract_rotated_box_creates_non_axis_aligned_faces() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::new(2.0, 5.0, 2.0), + Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), + MaterialId(0), + ); + + let fragments = source.subtract_convex(&cutter); + assert!(fragments.len() > 2); + assert!(fragments.iter().any(|fragment| { + fragment + .polygons + .iter() + .any(|polygon| polygon.normal.x.abs() > 0.01 && polygon.normal.y.abs() > 0.01) + })); + } +} diff --git a/src/dsl.rs b/src/dsl.rs index dba27ab..1195a90 100644 --- a/src/dsl.rs +++ b/src/dsl.rs @@ -1,4 +1,4 @@ -use bevy_math::Vec3; +use bevy_math::{Quat, Vec3}; use crate::{ Aabb, Assembler, BrushId, BrushOp, BuildOutput, DomeCapZSpec, FloretArmSpec, MaterialId, @@ -31,6 +31,29 @@ impl LevelDsl { .cut_box(name, Aabb::from_center_size(center, size)) } + pub fn solid_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: Quat, + material: MaterialId, + ) -> BrushId { + self.assembler + .solid_oriented_box(name, center, size, rotation, material) + } + + pub fn cut_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: Quat, + ) -> BrushId { + self.assembler + .cut_oriented_box(name, center, size, rotation) + } + pub fn cylinder_z( &mut self, name: impl Into, diff --git a/src/lib.rs b/src/lib.rs index 17ba626..dae2d44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,12 +8,14 @@ mod assembler; mod brush; +mod convex; mod dsl; mod mesh; mod primitives; pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning}; pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, Primitive}; +pub use convex::{ConvexPolygon, ConvexSolid, Plane}; pub use dsl::LevelDsl; pub use mesh::TriangleMesh; pub use primitives::{