CSG: a blockout wall with a doorway
Combine geometry with Boolean operations, enable collision and obtain separate mesh and shape resources.
What CSG solves
CSG combines and subtracts volumes to produce one result from simple shapes. Use it to test room dimensions, passageways and sight lines before final models are ready. It does not replace modeling, UV unwrapping or asset preparation.
The white sample wall has a real doorway: a ray through the middle passes through, while a ray through the side hits collision. The blue blocks nearby are a separate GridMap, allowing comparison of level-building approaches.
This is an actual frame from the current local 1.0.0 build, Compatibility/OpenGL, 640×420. It shows game output, not the editor interface or large-level performance.
Run the complete example
Use a Reckfell.NET.Sdk 1.0.0 C# project targeting net10.0 and a .rscene level rooted in Entity3D. Save both files below, run dotnet build, attach BlockoutLab3D.cs to the root and run the level. Code creates the camera and resources; no external models, textures or archives are required.
BlockoutLibrary.cs supplies both CSG materials and the blue-block library. BlockoutLab3D.cs creates both compositions. There is no character controller; native physics rays separately verify doorway collision.
BlockoutLibrary.cs
using Reckfell;
public static class BlockoutLibrary
{
public const int Floor = 0;
public const int Wall = 1;
public static StandardMaterial3D Material(Color color) => new()
{
AlbedoColor = color,
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded
};
public static MeshLibrary Create()
{
var library = new MeshLibrary();
AddBox(library, Floor, "Floor", new Vector3(2, 0.2f, 2),
new Vector3(0, -0.1f, 0), new Color(0.15f, 0.18f, 0.23f));
AddBox(library, Wall, "Wall", new Vector3(2, 3, 0.4f),
new Vector3(0, 1.5f, 0), new Color(0.08f, 0.25f, 1));
return library;
}
private static void AddBox(MeshLibrary library, int id, string name,
Vector3 size, Vector3 offset, Color color)
{
library.CreateItem(id);
library.SetItemName(id, name);
library.SetItemMesh(id, new BoxMesh { Size = size, Material = Material(color) });
var transform = new Transform3D(Basis.Identity, offset);
library.SetItemMeshTransform(id, transform);
library.SetItemShapes(id, new Reckfell.Collections.Array
{
new BoxShape3D { Size = size }, transform
});
}
}BlockoutLab3D.cs
using Reckfell;
public partial class BlockoutLab3D : Entity3D
{
public CsgCombiner3D Solid { get; private set; } = null!;
public CsgBox3D Wall { get; private set; } = null!;
public CsgBox3D DoorCut { get; private set; } = null!;
public GridMap Grid { get; private set; } = null!;
public override void _Ready()
{
Solid = new CsgCombiner3D
{
Position = new Vector3(-4, 1.5f, 0),
UseCollision = true,
CollisionLayer = 1,
CollisionMask = 1
};
AddChild(Solid);
Wall = new CsgBox3D
{
Size = new Vector3(5, 3, 0.6f),
Material = BlockoutLibrary.Material(Colors.White)
};
DoorCut = new CsgBox3D
{
Size = new Vector3(1.6f, 2.2f, 1.2f),
Position = new Vector3(0, -0.4f, 0),
Operation = CsgShape3D.OperationEnum.Subtraction
};
Solid.AddChild(Wall);
Solid.AddChild(DoorCut);
Grid = new GridMap
{
Position = new Vector3(4, 0, 0),
CellSize = new Vector3(2, 2, 2),
CellCenterX = true,
CellCenterY = false,
CellCenterZ = true,
MeshLibrary = BlockoutLibrary.Create(),
CollisionLayer = 1,
CollisionMask = 1
};
AddChild(Grid);
for (int x = -2; x <= 2; x++)
for (int z = -1; z <= 1; z++)
Grid.SetCellItem(new Vector3I(x, 0, z), BlockoutLibrary.Floor);
foreach (int x in new[] { -2, -1, 1, 2 })
Grid.SetCellItem(new Vector3I(x, 0, 1), BlockoutLibrary.Wall);
AddChild(new MeshInstance3D
{
Mesh = new PlaneMesh { Size = new Vector2(24, 16) },
Position = new Vector3(0, -0.21f, 0),
MaterialOverride = BlockoutLibrary.Material(new Color(0.06f, 0.07f, 0.09f))
});
var camera = new Camera3D { Current = true, Position = new Vector3(12, 9, 17), Fov = 55 };
AddChild(camera);
camera.LookAt(new Vector3(0, 1, 1));
}
}Understand operation order
Solid is the root CsgCombiner3D. Its first child Wall adds a 5×3×0.6 box. The next child DoorCut uses Operation=Subtraction to remove a 1.6×2.2×1.2 box. The cutter is thicker than the wall and passes completely through it; its bottom aligns with the floor level.
| Child CSG operation | Result |
|---|---|
| Union | Add volume to the accumulated geometry |
| Subtraction | Remove the overlapping part of the accumulated volume |
| Intersection | Keep only shared volume |
The runnable example verifies the initial wall union and doorway subtraction. Complex Intersection chains with imported meshes are not tested. Operand order matters: geometry must exist before another operand cuts it.
Operands are direct children of the CSG root. A separate nearby CSG root does not automatically subtract from another. Do not insert an ordinary Entity3D between these operands and Solid expecting the composition to remain unchanged.
Enable result physics
Solid sets UseCollision=true. Physics belongs to the root result; DoorCut does not become a separate invisible obstacle. CollisionLayer=1 places the wall on the first layer; the query or character must match that bit.
Rendering and collision update later. After changing dimensions or transforms, wait for updates before checking hits or retrieving a finished mesh. Do not rebuild a complex CSG composition every frame without profiling.
The native test casts through X/Y=(-4,1) and misses the doorway, then through (-5.5,1) and hits the side. After UseCollision=false, the side ray also misses while geometry remains visible. Visual visibility and physics collision are separate settings.
Obtain static resources
Call these on Solid after its result is ready:
- BakeStaticMesh() obtains an ArrayMesh. Check for null. It does not create a MeshInstance3D or save a file; this is the current root mesh resource, not a guaranteed independent deep copy.
- BakeCollisionShape() creates a separate ConcavePolygonShape3D. It works with UseCollision=false, but the resource alone does not add collision to the world.
- For a static replacement, assign the mesh to MeshInstance3D and the shape to CollisionShape3D inside StaticBody3D. Preserve the root transform. Avoid leaving two identical physics walls active.
- Save the mesh and shape separately through ResourceSaver.Save to .rdata paths and check Error.Ok. Reload and verify the data; a successful write does not prove the whole level is correct.
Native checks save both resources, reload without cache and compare geometry. A separate StaticBody3D using the baked shape preserves the wall and doorway. Full level-tree persistence and the editor baking command interface are not verified here.
Child Wall/DoorCut bake calls do not return an independent result: use the composition root. Use concave triangle geometry for static collision; choose primitive or convex shapes separately for moving physics bodies.
Diagnosis
| Symptom | Check |
|---|---|
| Doorway is absent | Operation, direct-child CSG order and cutter/wall overlap |
| Visible opening blocks a body | Old wall collision, pending update or duplicate physics body |
| Character passes through the wall | Root UseCollision and matching physics layers/masks |
| BakeStaticMesh returns null | Is this the root? Is its deferred mesh ready? |
| Baked mesh has no collision | StaticBody3D and CollisionShape3D with the separate shape |
| Stretched textures | Primitive scales and UVs; CSG does not perform full artistic UV unwrapping |