GameCult.Geometry.Csg/docs/realtime-csg-bevy-assembler.md
2026-08-26 17:38:13 +02:00

207 lines
8.4 KiB
Markdown

# Realtime CSG Bevy Assembler
## Source Read
LogicalError's RealtimeCSG is useful as an architectural reference, not as a
kernel we can directly port. The public repository describes a Unity editor
plugin for CSG level editing and states that runtime editing is not supported
through a maintained API. It also states that the native C++ source is not
public.
The public C# layer still exposes the parts worth stealing cleanly:
- control meshes made from vertices, half edges, polygons, and texgen/surface
metadata
- tree, branch, and brush handles
- explicit brush operation types
- 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.
## Current Port Shape
CultGeometry CSG is the lean Rust CSG organ.
It uses `bevy_math` for `Vec3`, `Vec2`, and Bevy-compatible spatial types while
keeping output as simple arrays:
```rust
use bevy_math::Vec3;
use gamecult_geometry_csg::{LevelDsl, MaterialId};
let mut level = LevelDsl::new();
level.solid_box(
"wall",
Vec3::new(0.0, 0.0, 1.5),
Vec3::new(6.0, 0.3, 3.0),
MaterialId(1),
);
level.cut_box(
"door",
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(1.2, 0.6, 2.0),
);
let output = level.assemble();
assert!(output.mesh.triangle_count() > 12);
```
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, subtraction emits the
outside fragments, and intersection keeps the inside remainder. This supports
angled box cuts, real `Common` branch output for convex boxes, and gives us the
correct shape for a future general convex brush kernel.
Polygons now carry the first routing metadata the mature kernel needs:
category, visibility, reversal, and bounds. Source polygons can be classified
as inside, outside, aligned, or reverse-aligned against a convex brush. When a
polygon crosses a cutter plane, it is split first and each piece is routed to a
category bucket. That matches the public demo's important shape: split crossing
polygons before final category decisions.
That is deliberately not sold as full parity yet. The current assembler still
emits meshes through convex fragment decomposition. The next parity step is to
make those category buckets drive boolean-tree mesh emission directly.
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.
The expanded doctrine lives in
`docs/realtime-csg-doctrine.md`; keep this file focused on the current
crate shape.
Procedural non-CSG primitives are additive:
- `CylinderZ` for tethers, shafts, columns, conduits, and ring anchors
- `DomeCapZ` for flat-base pressure bubbles and caps
- `FloretArm` for sunflower/Citadel-scale solar and radiator petals
Those primitives are intentionally in the same brush stream as CSG boxes, so a
level script can mix hard architectural cuts with habitat-specific forms:
```rust
level.dome_cap_z(
"city dome",
gamecult_geometry_csg::DomeCapZSpec {
center: Vec3::ZERO,
radius: 20.0,
height: 8.0,
rings: 12,
segments: 64,
material: MaterialId(20),
},
);
level.floret_arm(
"radiator petal",
gamecult_geometry_csg::FloretArmSpec {
anchor: Vec3::new(20.0, 0.0, 0.2),
direction: Vec3::X,
length: 120.0,
root_width: 6.0,
tip_width: 18.0,
thickness: 0.12,
tip_lift: 3.0,
material: MaterialId(21),
},
);
```
## Doctrine
Think of brushes as legal claims on volume.
An additive brush says: "this matter exists." A subtractive brush says: "this
space must remain empty." The assembler is the clerk that resolves claims in
order and emits triangles only after the claims stop arguing.
For interactive work, the brush list is the durable design surface. The mesh is
a cache. This matters because agents should revise intent by moving, adding, or
renaming brushes, not by poking triangles after the fact.
Use CSG where boolean space carries meaning:
- doorways, windows, hatches, service trenches
- docking sockets and tether anchor gaps
- ring terraces and mechanically repeated cutouts
- fast blockouts where the same cutter can punch many solids
- common-volume filters where rooms, shafts, or constraint regions overlap
Use additive procedural primitives where a form is better described by a field
or sweep than by carving:
- domes, rings, tethers, panels, radiators
- city-field parcels after street generation
- flower-like utility crests and other habitat-specific silhouettes
The immediate next upgrade is not "more primitives." It is the category-router
kernel: split crossing polygons, route classified source polygons through
boolean branches, and preserve material/surface metadata.
The next accelerator should not be a reflexive BVH-shaped apology. The
EpiphanyAquarium memory points at a stronger pattern from GigaVoxels, froxels,
and Dreams: let the consumer define the query frontier. For CSG, the consumer is
not always "the whole scene." It can be the moved brush, the current branch, the
editor tile, the requested output mesh, or the visible/view-tested surface set.
So the near-term spatial plan is:
- use branch and brush bounds as semantic gates before cutting;
- keep dirty/touched brush discovery separate from polygon classification;
- build affected brush-pair or surface-frontier batches from those gates;
- measure those batches before adding a generic BVH, loose grid, or spatial
hash;
- only promote an index if it beats the demand-shaped frontier on dirty rebuilds
and preserves exact output parity.
Sideways version: do not ask every cutter whether it matters. Ask the branch,
dirty region, and requested output which pairs deserve to exist this frame.
Performance fixtures live in `docs/csg-performance-fixtures.md` and
`tools/run_csg_perf.ps1`. They time our release fixture now and reserve a JSONL
slot for a public-demo/reference harness when one is available.
## Limits
CultGeometry CSG is not yet a full RealtimeCSG replacement.
- subtractive convex support currently covers axis-aligned and oriented boxes
- non-box subtractors are reported and ignored
- intersect/common support currently covers axis-aligned and oriented boxes
- 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
- no reference timing executable is wired yet; the perf wrapper records this
explicitly instead of inventing comparison numbers
Those limits are explicit because invisible ambition is how you get haunted by
your own abstractions. The useful loop exists now: script brushes, assemble,
inspect mesh stats, and replace the kernel under the same DSL when the target
demands it.