Chapter 6: CSG Mesh and Rendering Pipeline
In Chapter 5: Research Provenance and Evidence Pipeline, we followed verified simulation results into Studies, Assessments, Datasets, Models, and Predictions.
Now we will look at the other direction: how RAMAL-EBX turns analytical geometry into something visible on screen.
Imagine a cell described by PHITS:
Inside the outer box
Outside the cylindrical channel
PHITS understands this as a physical volume. The GPU does not understand PHITS expressions. It needs triangles, buffers, materials, and drawing commands.
The CSG Mesh and Rendering Pipeline performs that translation.
The central use case
Suppose we want to display a concrete shield with a hole:
Outer shape: rectangular box
Cut-out: cylindrical channel
Material: concrete
Display: filled or wireframe
The complete path is:
flowchart LR
A[PHITS cell expression] --> B[Signed-distance evaluator]
B --> C[Triangle mesh]
C --> D[Exterior trimming]
D --> E[CPU mesh cache]
E --> F[GPU buffers]
F --> G[Filled or wireframe rendering]
The important idea is:
Analytical geometry remains the source of truth. The mesh is a generated visual copy.
This means the renderer can display a cell without changing the scientific geometry.
Why this abstraction is needed
A PHITS cell is described using surfaces and logical rules:
inside surface 10
outside surface 20
outside cell 30
A GPU usually draws indexed triangles:
vertices + indices
The renderer therefore needs to solve several problems:
- Evaluate whether a point is inside a cell.
- Estimate the cell’s boundary.
- Convert the boundary into triangles.
- Hide faces covered by other cells.
- Avoid rebuilding meshes every frame.
- Upload finished meshes to GPU memory.
- Draw them with lighting, depth, sections, and visibility controls.
Each part has a separate responsibility.
A beginner’s mental model
Think of the process as building a physical model from a blueprint:
| Pipeline part | Physical-model analogy |
|---|---|
| Cell expression | Blueprint instructions |
| Signed distance | Measuring how close a point is to a wall |
| Marching cubes | Cutting the model into triangle pieces |
| Exterior trimming | Removing hidden pieces |
| Mesh cache | Storing completed model parts |
| GPU buffer | Loading parts onto the display machine |
| Renderer | Showing the final model |
The blueprint is precise, but the displayed model is an approximation made from triangles.
The main data types
The generated CPU mesh is represented by CSG_Mesh:
CSG_Mesh :: struct {
vertices: [dynamic]Render_Vertex,
indices: [dynamic]u32,
bounds: Render_Bounds,
}
vertices contain positions, colors, normals, and other rendering data.
indices describe which vertices form triangles.
For example:
vertices: 0, 1, 2, 3
indices: 0, 1, 2
The first three vertices form one triangle.
The bounds describe the region occupied by the mesh.
The rendering vertex
The renderer uses Render_Vertex:
Render_Vertex :: struct {
position: [3]f32,
color: [4]f32,
uv: [2]f32,
normal: [3]f32,
tangent: [4]f32,
joints: [4]f32,
weights: [4]f32,
}
For CSG cells, the most important fields are:
position: where the vertex is.color: the default vertex color.normal: which direction the surface faces.
Normals are important because lighting uses them to decide whether a surface faces toward or away from the light.
Exact meshes and sampled meshes
RAMAL-EBX uses two mesh-generation strategies.
Exact rectangular meshes
Simple cells can be built directly.
For example:
Inside one RPP
Outside one smaller RPP
No transform
This can become an outer box plus an inner box-shaped cavity.
The exact path is handled by:
csg_mesh_build_exact_rpp_cell(geometry, cell)
It is fast because it does not need to sample a grid.
Sampled meshes
More complex cells use a sampled signed-distance field.
Examples include:
- Spheres.
- Cylinders.
- Transformed cells.
- Nested cell exclusions.
- Several intersecting surfaces.
The general path is:
csg_mesh_build_cell(geometry, cell, allocator, settings)
It first tries the exact rectangular path. If that is not possible, it uses marching cubes.
Signed distance
A signed distance answers:
“How far is this point from the cell boundary?”
RAMAL-EBX uses a convention where:
negative value = inside
positive value = outside
zero = boundary
A simple evaluator call looks like this:
distance, ok := csg_evaluator_distance(
&evaluator,
root_index,
point,
)
If distance is negative, the point is inside the evaluated cell.
If it is close to zero, the point is near the surface.
The evaluator also supports a simpler containment test:
inside := csg_evaluator_contains(
&evaluator,
root_index,
point,
)
This returns true when the point satisfies every cell-expression term.
How cell expressions become distance rules
Suppose the cell says:
Inside box
Outside cylinder
The evaluator calculates both terms.
Conceptually:
box distance
cylinder distance
The outside-cylinder rule reverses the cylinder’s meaning. The complete expression combines the terms so that the final result describes the remaining volume.
For a nested cell exclusion:
Inside outer cell
Outside inner cell
the evaluator recursively evaluates the inner cell.
Compiling a cell
Before evaluating points, RAMAL-EBX compiles a cell into CSG_Compiled_Cell.
CSG_Compiled_Cell :: struct {
id: Cell_ID,
basis: [9]f32,
offset: [3]f32,
terms: [dynamic]CSG_Compiled_Term,
compiling: bool,
}
The compiled form contains:
- The cell ID.
- Transform information.
- Resolved surface pointers.
- Resolved nested-cell references.
This avoids repeatedly looking up the same objects while sampling many grid points.
The compiler also rejects invalid nested references:
if term.cell == root_cell_id {
return -1, false
}
This prevents a cell from excluding itself.
Transforms during evaluation
A transformed cell is evaluated in local coordinates.
The conceptual flow is:
world point
↓ inverse transform
local point
↓ evaluate surfaces
inside or outside
The compiled basis and offset store the coordinate conversion:
local_point :=
phits_transform_mat3_vec(cell.basis, point) + cell.offset
This lets the same surface definition be positioned elsewhere in the scene.
Marching cubes
Marching cubes converts sampled values into triangles.
Imagine placing a three-dimensional grid around the cell:
+---+---+---+
| | | |
+---+---+---+
| | | |
+---+---+---+
At every grid point, RAMAL-EBX stores a signed distance.
Each grid cube has eight corner values. If some corners are inside and others are outside, the surface crosses that cube.
The algorithm:
- Reads the eight corner distances.
- Determines which corners are inside.
- Looks up a triangle pattern.
- Places vertices along crossed edges.
- Adds triangles to the mesh.
sequenceDiagram
participant Cell as Cell expression
participant Eval as Distance evaluator
participant Grid as Sampling grid
participant Mesh as Triangle mesh
participant GPU as Renderer
Cell->>Eval: Evaluate grid points
Eval->>Grid: Store signed distances
Grid->>Mesh: Create boundary triangles
Mesh->>GPU: Upload completed buffers
Sampling the cell
The mesh builder first finds bounds:
min, max, ok :=
cell_expression_sample_bounds(geometry, cell)
It adds a small padding area around those bounds:
padding := extent * settings.padding_fraction
min -= padding
max += padding
Padding helps ensure that the boundary is not clipped exactly at the edge of the sampling region.
The sampling resolution comes from CSG_Mesh_Settings:
CSG_Mesh_Settings :: struct {
resolution: int,
complex_resolution: int,
padding_fraction: f32,
min_padding: f32,
}
Higher resolution usually produces more detail but requires more CPU work and memory.
Building the distance grid
The builder evaluates every grid point:
for z := 0; z <= count.z; z += 1 {
for y := 0; y <= count.y; y += 1 {
for x := 0; x <= count.x; x += 1 {
point := grid_point(x, y, z)
distances[index_of(x, y, z)] = evaluate(point)
}
}
}
The real implementation calculates the point and checks errors carefully.
The result is a three-dimensional array of signed distances.
Finding triangle intersections
For every grid cube, the builder gathers eight points and distances:
if case_index == 0 || case_index == 255 {
continue
}
A case of 0 means every corner is outside.
A case of 255 means every corner is inside.
Neither case crosses the surface, so no triangles are needed.
All other cases use the marching-cubes lookup table.
Interpolating edge points
When an edge crosses the boundary, the exact crossing point is estimated:
position, t :=
csg_mesh_edge_point(points, distances, a, b)
The interpolation is approximately:
distance at A = positive
distance at B = negative
crossing lies between A and B
The value t describes where along the edge the crossing occurs.
This creates a smoother surface than simply choosing one grid corner.
Calculating normals
Lighting needs a normal for each vertex.
RAMAL-EBX estimates normals from the distance field:
normal := csg_grid_gradient(
distances,
grid_x,
grid_y,
grid_z,
count,
cell_size,
)
The gradient points in the direction where the signed distance changes most quickly.
That direction approximates the surface normal.
The normal is interpolated between edge endpoints and stored in the vertex.
Rejecting bad triangles
Some sampled configurations can produce tiny or degenerate triangles.
RAMAL-EBX checks triangle edge lengths and area:
if area_squared <= threshold {
return
}
Very small or flat triangles are skipped.
This keeps the generated mesh cleaner and avoids rendering unstable geometry.
Exact rectangular mesh generation
The exact RPP path creates box corners and faces directly.
Its simplified shape is:
corners := box_corners(min, max)
for face in box_faces {
append_face(&mesh, corners, face)
}
A cut-out box is represented by a second box with reversed normals and winding.
This is why simple rectangular cells can render efficiently without a full distance-field scan.
Exterior trimming
A cell may be mathematically correct but visually hidden by another cell.
For example:
Cell A: large concrete block
Cell B: smaller steel block inside it
If both are drawn, Cell A’s internal faces may appear through Cell B or create unnecessary overlap.
RAMAL-EBX creates an exterior mesh that keeps only surfaces visible from outside.
The operation is:
csg_mesh_visible_exterior(
&mesh,
geometry,
owner,
visible_cells,
)
It checks other visible cells and tests whether a triangle is buried.
How trimming works
For each candidate triangle, RAMAL-EBX:
- Finds other cells whose bounds overlap the triangle.
- Probes points slightly outward from the triangle.
- Checks whether those points are inside another cell.
- Keeps, removes, or subdivides the triangle.
A triangle that is fully buried is removed.
A triangle near a seam may be subdivided for a more accurate decision.
flowchart TD
A[Triangle] --> B{Overlaps another cell?}
B -->|No| C[Keep triangle]
B -->|Yes| D[Probe triangle points]
D --> E{Buried?}
E -->|Yes| F[Remove triangle]
E -->|No| G[Keep or subdivide]
This is called exterior trimming.
Why triangles are subdivided
A large triangle might overlap a small hidden cell even if its center is not buried.
RAMAL-EBX recursively splits the triangle into four smaller triangles:
if depth >= 8 || longest_edge <= target_edge {
keep_or_remove_triangle()
return
}
This allows the trimming decision to become more precise near small seams and cut-outs.
Wireframe generation
Wireframe rendering should show meaningful boundaries, not every internal triangle diagonal.
RAMAL-EBX derives line segments from the mesh:
wire_vertices, wire_indices :=
csg_mesh_wireframe(&mesh)
It groups edges by quantized endpoint positions.
Two triangles sharing a coplanar edge usually create an interior diagonal. That edge is omitted.
Non-coplanar seams remain visible.
coplanar shared edge → hidden
cut-out or corner seam → visible
open boundary → visible
This makes wireframe mode easier to read.
Mesh caching
Mesh generation can be expensive, so RAMAL-EBX stores completed meshes in CSG_Mesh_Cache.
CSG_Mesh_Cache_Entry :: struct {
cell: Cell_ID,
settings: CSG_Mesh_Settings,
key: [64]byte,
mesh: CSG_Mesh,
mesh_ok: bool,
exterior_mesh: CSG_Mesh,
exterior_ok: bool,
}
The cache key includes:
- Cell expression.
- Referenced surfaces.
- Transforms.
- Mesh settings.
If the same geometry is requested again, the cached result can be reused.
Disk caching
RAMAL-EBX can also store meshes on disk.
The disk cache uses a content key and a binary mesh format.
A cache file contains:
magic value
vertex stride
vertex count
index count
bounds
vertex data
index data
Before loading, RAMAL-EBX checks limits and file size:
if vertex_count == 0 ||
vertex_count > CSG_Disk_Cache_Max_Vertices {
return mesh, false
}
This prevents malformed cache files from allocating unreasonable amounts of memory.
Cache invalidation
A cached mesh is only valid for matching geometry.
RAMAL-EBX tracks:
geometry pointer
geometry revision
cell ID
content key
mesh settings
visibility key
If the cell changes, the content key changes.
If visibility changes, the exterior-trimmed mesh may need rebuilding even when the base mesh remains valid.
This allows RAMAL-EBX to reuse the expensive base mesh while recalculating only visibility-dependent results.
Worker threads
Mesh generation runs on worker threads so that the frame loop remains responsive.
A job contains a cloned geometry:
job.geometry = geometry_clone(geometry)
job.cell = cell.id
job.settings = settings
The worker then builds the mesh:
job.mesh, job.mesh_ok =
csg_mesh_build_cell(
&job.geometry,
cell,
context.allocator,
job.settings,
)
The worker may also perform exterior trimming.
The important ownership rule is:
Workers build CPU data. The render thread owns GPU resources.
Polling completed jobs
The main thread checks whether workers are finished:
csg_mesh_job_poll(
&app.csg_mesh_job,
&app.csg_mesh_cache,
visibility_key,
)
A completed job is accepted only if it still matches the current geometry and visibility state.
If the user edited the scene while the worker was running, the old result is discarded instead of replacing current data.
Uploading to the GPU
Once a CPU mesh is ready, the renderer uploads it:
gpu_upload_mesh_u32(
Render_Vertex,
gpu,
vertices,
indices,
)
The generated mesh becomes a Render_Model containing GPU buffers:
Render_Mesh :: struct {
wire_vertex_buf: ^sdl.GPUBuffer,
wire_index_buf: ^sdl.GPUBuffer,
vertex_buf: ^sdl.GPUBuffer,
index_buf: ^sdl.GPUBuffer,
vertex_count: int,
}
The renderer also stores primitives and materials.
Uploading a CSG cell model
The renderer accepts a completed cell mesh through:
renderer_upload_cell_csg_model(
renderer,
gpu,
geometry,
cell.id,
settings,
visibility_key,
mesh,
wire_mesh,
)
The function verifies that the geometry revision and settings still match.
It then uploads:
- Filled vertices and indices.
- Optional wireframe vertices and indices.
- Bounds.
- A default material.
The GPU upload happens on the rendering thread.
Why filled and wire meshes are separate
Filled rendering needs triangles.
Wireframe rendering needs line segments.
The filled mesh may also be empty after exterior trimming. This can happen when a cell is completely enclosed by other visible cells.
The complete original mesh is still useful for wireframe mode:
filled mesh: empty
wire mesh: available
RAMAL-EBX keeps both representations.
The renderer’s pipelines
renderer_init creates several GPU pipelines.
The main cell pipeline uses triangles and back-face culling:
renderer.cell_pipeline = gpu_graphics_pipeline_create(
gpu,
"shaders/compiled",
"basic",
vertex_input,
color_target,
depth_state,
...
)
The debug pipeline uses lines:
triangle pipeline → filled geometry
line pipeline → wireframe and overlays
Other pipelines support:
- Transparent source fills.
- Visible source lines.
- Hidden source lines.
- Inspection overlays.
Vertex input layout
The renderer describes how the GPU reads each vertex:
vertex_desc := [?]sdl.GPUVertexBufferDescription{
{slot = 0, pitch = size_of(Render_Vertex)},
}
Attributes are mapped by location:
location 0 → position
location 1 → color
location 2 → UV
location 3 → normal
This must agree with the compiled shaders.
If the CPU structure and shader layout disagree, geometry may render incorrectly.
Materials and lighting
A Render_Material stores display information:
Render_Material :: struct {
base_color: [4]f32,
texture: ^sdl.GPUTexture,
normal_texture: ^sdl.GPUTexture,
alpha_cutoff: f32,
alpha_clip: bool,
}
For CSG cells, material colors usually come from the cell’s PHITS material.
The renderer sends lighting information through Render_Material_Uniform:
Render_Material_Uniform :: struct {
base_color: [4]f32,
light_direction: [4]f32,
lighting: [4]f32,
section_plane: [4]f32,
}
The shader uses the normal and light direction to shade the surface.
Drawing a render item
The application creates Render_Item values:
Render_Item :: struct {
model: ^Render_Model,
model_matrix: Render_Mat4,
color_override: [4]f32,
use_color_override: bool,
cull_backfaces: bool,
wireframe: bool,
}
A cell can choose:
filled or wireframe
material color or selected color
back-face culling or no culling
The renderer then draws each item using its GPU buffers.
Depth testing
Depth testing prevents objects behind other objects from appearing in front.
The main depth state is:
depth_state := sdl.GPUDepthStencilState{
compare_op = .LESS,
enable_depth_test = true,
enable_depth_write = true,
}
The depth buffer stores how close previously drawn geometry is.
A new fragment is drawn only when it is closer according to the comparison rule.
This is essential when several cells overlap in screen space.
Filled and wireframe rendering
During drawing, RAMAL-EBX chooses the pipeline:
pipeline := renderer.pipeline
if item.cull_backfaces {
pipeline = renderer.cell_pipeline
}
if item.wireframe {
pipeline = renderer.source_line_pipeline
}
For filled geometry, it binds:
vertex_buf
index_buf
triangle pipeline
For wireframe geometry, it binds:
wire_vertex_buf
wire_index_buf
line pipeline
The same logical cell can therefore have two visual modes.
Section planes
A section plane lets the user inspect the inside of geometry.
The view stores the plane:
Render_View :: struct {
view_projection: Render_Mat4,
light_direction: [3]f32,
section_plane: [4]f32,
}
The application fills this value from editor settings:
render_view.section_plane[axis] = 1
render_view.section_plane.w = position
The shader uses the plane to discard fragments on one side.
For a section cap, RAMAL-EBX separately creates a mesh that closes the exposed cut surface.
Section-cap generation
A section cap samples the cell on a plane:
csg_mesh_build_section_cap(
geometry,
cell,
axis,
position,
kept_side,
)
The function:
- Creates a two-dimensional grid on the section plane.
- Evaluates signed distances.
- Finds inside/outside crossings.
- Builds polygons.
- Triangulates those polygons.
- Assigns a plane-facing normal.
This produces a visible surface where the model was cut.
Visibility controls
The editor can hide cells.
The application creates a visibility array:
visible_cells := make(
[]bool,
len(viewport_geometry.cells),
context.temp_allocator,
)
Each cell is checked:
visible_cells[index] =
editor_cell_visible(&app.editor, cell.id)
The visibility state contributes to a visibility_key.
That key tells the exterior-trimming cache whether the visible set changed.
The frame loop
The render frame follows this broad sequence:
flowchart TD
A[Poll simulation and mesh workers] --> B[Collect visible cells]
B --> C[Request missing CPU meshes]
C --> D[Upload completed meshes]
D --> E[Build render items]
E --> F[Begin GPU render pass]
F --> G[Draw filled or wireframe models]
G --> H[Draw overlays and UI]
H --> I[Submit GPU commands]
The frame loop coordinates work, but expensive geometry generation happens elsewhere.
A simplified frame example
The application first polls mesh jobs:
csg_mesh_job_poll(
&app.csg_mesh_job,
&app.csg_mesh_cache,
visibility_key,
)
Then it asks the cell renderer to append visible models:
renderer_cell_render_items_append(
app,
renderer,
gpu,
viewport_scene,
visible_cells,
visibility_key,
&render_items,
)
Finally, it draws the collected items:
renderer_draw_items(
renderer,
cmd,
pass,
render_items[:],
render_view,
)
The frame uses only meshes that are already ready.
Stale results and safe replacement
Suppose a worker starts building a mesh. The user then moves a surface.
The geometry revision changes:
old revision: 12
new revision: 13
When the worker finishes, its result still says revision 12.
The application compares the result with the current geometry and rejects it.
This prevents an old mesh from appearing on new geometry.
The previous uploaded model may remain visible temporarily while the new mesh is prepared.
That gives the user a stable viewport during rebuilding.
Cleanup and GPU ownership
GPU resources must be released explicitly.
renderer_release_mesh(gpu, &model.mesh)
This releases:
- Filled vertex buffer.
- Filled index buffer.
- Wireframe vertex buffer.
- Wireframe index buffer.
- Textures.
- Primitive arrays.
- Material arrays.
When the renderer shuts down, it releases models, pipelines, textures, samplers, and depth targets.
Rendering targets
The renderer creates a depth texture and, when multisampling is supported, a multisample color texture.
renderer_ensure_targets(
renderer,
gpu,
swap_width,
swap_height,
)
The depth target is cleared at the beginning of the scene pass.
The final color is resolved to the swapchain texture before the user interface is drawn.
The complete shield example
Let us combine everything.
The cell is:
Inside concrete box
Outside cylindrical channel
RAMAL-EBX performs:
- Finds the cell bounds.
- Tries the exact RPP path.
- Falls back to signed-distance sampling if needed.
- Builds triangles with marching cubes.
- Calculates normals.
- Trims faces hidden by visible cells.
- Stores the result in the mesh cache.
- Uploads the mesh to GPU buffers.
- Draws it with concrete color and lighting.
- Optionally draws its wireframe edges.
- Applies depth testing and a section plane.
The user sees a concrete shield with a visible channel, even though the original data was only analytical PHITS geometry.
Where this code lives
csg_mesh.odin handles:
- Signed-distance evaluation.
- Cell compilation.
- Exact rectangular meshes.
- Marching cubes.
- Normals.
- Exterior trimming.
- Wireframe extraction.
- Section caps.
csg_mesh_cache.odin handles:
- In-memory mesh caching.
- Disk caching.
- Cache keys.
- Worker threads.
- Mesh-job polling.
- Revision checks.
renderer.odin handles:
- GPU pipelines.
- Vertex layouts.
- GPU buffers.
- Materials.
- Depth targets.
- Filled and wireframe drawing.
- Lighting and section uniforms.
renderer_cells.odin handles:
- Cell visibility.
- Mesh-job requests.
- Upload budgets.
- Cell render items.
- Stale-model fallback.
renderer_preview.odin handles:
- Construction previews.
- Section-cap previews.
- Temporary editor meshes.
app_runtime.odin coordinates:
- Frame polling.
- Viewport setup.
- Render passes.
- Render-item collection.
- GPU command submission.
A beginner’s mental model
The whole abstraction can be remembered as:
PHITS cell
↓
signed-distance evaluator
↓
marching cubes or exact box builder
↓
CPU mesh
↓
exterior trimming
↓
mesh cache
↓
GPU vertex and index buffers
↓
filled or wireframe rendering
The most important ownership rules are:
Geometry owns scientific truth.
Workers build CPU meshes.
The render thread owns GPU resources.
The cache avoids repeated work.
The renderer only draws completed models.
Conclusion
You learned that:
- PHITS cells describe analytical volumes, not GPU triangles.
- Signed distance tells RAMAL-EBX whether points are inside, outside, or near a boundary.
- Simple rectangular cells can use an exact mesh path.
- Complex cells use sampled signed-distance fields and marching cubes.
- Gradients provide normals for lighting.
- Exterior trimming removes surfaces hidden by other visible cells.
- Wireframe generation keeps meaningful seams while removing interior diagonals.
- Mesh caches and disk caches avoid repeating expensive work.
- Worker threads keep mesh generation away from the frame loop.
- The render thread uploads completed CPU meshes into GPU buffers.
- Materials, depth testing, lighting, section planes, and visibility controls shape the final image.
- Filled and wireframe models can use separate GPU representations.
- Revision and visibility keys prevent stale meshes from replacing current geometry.
The CSG Mesh and Rendering Pipeline is the bridge between scientific geometry and interactive visualization.
It lets RAMAL-EBX display complex PHITS designs while keeping the analytical model, application state, CPU work, and GPU resources clearly separated.
Generated by AI Codebase Knowledge Builder