Add convex CSG brush splitting

This commit is contained in:
Meta Vens 2026-05-07 00:20:58 +01:00
commit 7cb637edd2
7 changed files with 560 additions and 39 deletions

View file

@ -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,

View file

@ -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()

View file

@ -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<BuildWarning>,
}
@ -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<Brush>,
@ -85,21 +79,71 @@ impl Assembler {
)
}
pub fn solid_oriented_box(
&mut self,
name: impl Into<String>,
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<String>,
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<BoxSolid> = Vec::new();
let mut solids: Vec<ConvexSolid> = 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<BoxSolid> {
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<ConvexSolid> {
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<ConvexSolid> {
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();

View file

@ -149,6 +149,11 @@ pub enum Primitive {
Box {
bounds: Aabb,
},
OrientedBox {
center: Vec3,
size: Vec3,
rotation: Quat,
},
CylinderZ {
center: Vec3,
radius: f32,

381
src/convex.rs Normal file
View file

@ -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<Vec3>,
pub normal: Vec3,
pub material: MaterialId,
}
#[derive(Clone, Debug)]
pub struct ConvexSolid {
pub polygons: Vec<ConvexPolygon>,
pub clip_planes: Vec<Plane>,
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<Self> {
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<Vec3>,
) -> Vec<Vec3> {
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 &current 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::<Vec3>() / 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<Vec3>) -> Vec<Vec3> {
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<Plane> {
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)
}));
}
}

View file

@ -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<String>,
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<String>,
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<String>,

View file

@ -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::{