OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain commits / a7d16f5

docs: split the README into docs/, and verify every claim against the bindings
The README had grown to ~500 lines of API. It is now 114: what it is, build, install, one example, and a table of links. The reference moved to docs/meshing.md, docs/api-dcterrain.md, docs/api-dclevel.md, docs/generators.md, docs/testing.md. Every API claim in those files was checked MECHANICALLY, not by eye. A script parses _bind_methods() out of the four .cpp files -- 174 DCTerrain methods, 63 properties, 65 DCLevel methods, plus the 27 macro-generated DCTerrainGeneratorNoise properties that stringify through BIND_FLOAT and so do not appear to a naive grep -- and checks the docs against it: argument COUNT and argument NAMES per method, that every #### heading names a bound method, that every `name: Type` line inside a fence is a real ADD_PROPERTY, and that the 25 hand-transcribed "0 .. 20" range comments match the binding hint strings. Plus link, path and anchor resolution. Two claims did not survive it, both taken from doc comments rather than from the code: - debug_chunk_info returns TEN keys. dc_terrain.h's comment above it lists eight -- mesh_state and parent_collision_stale are missing from the comment. The docs now list ten and say the implementation is the authority. - the GPU vertex layout: slot 10 is projection candidate 0, which I had skipped. Slots are now given in full, and which are uint bits. get_scalar_query_counts also returns collision_mode, which was undocumented and is what makes the eval_with_edits counter readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a7d16f553b65cd459ca39b311ebd90794f718e21
by plankatron <fleeky@gmail.com> · 2026-09-10T14:45:57.000Z

Changed files (6) +766 -419

README.md +36 -419
……@@ -1,28 +1,30 @@
11# dc-terrain
22
3### [Full documentation in `docs/`](docs/)
4
35Dual Contouring voxel terrain for Godot 4, as a GDExtension.
46
5A signed distance field goes in, a sharp-featured triangle mesh comes out — streamed
6around the camera as an octree clipmap, edited at runtime with CSG brushes, and meshed
7either on the GPU across ten compute stages or on a pool of CPU worker threads.
7A signed distance field goes in, a sharp-featured triangle mesh comes out — streamed around
8the camera as an octree clipmap, edited at runtime with CSG brushes, and meshed on the GPU
9across ten compute stages or on a pool of CPU worker threads.
810
911## Features
1012
1113- **Sharp features.** Dual Contouring solves a QEF per cell, so a plane stays flat and a
1214 crease stays a crease. Marching Cubes cannot do this.
1315- **Octree clipmap LOD.** Detail follows the camera. Cross-LOD seams are stitched, not hidden.
14- **Six meshers, not one.** GPU dual contouring, CPU dual contouring, cross-LOD seam
15 stitching, exact brush meshing, voxel pockets inside the exact path, and two
16 simplifiers. See [Meshing paths](#meshing-paths) — they are not interchangeable.
17- **Runtime editing.** Spheres, boxes, convex brushes (Quake-style half-space sets), and
16- **Six meshers, not one.** GPU dual contouring, CPU dual contouring, seam stitching, exact
17 brush meshing, voxel pockets, and two simplifiers — see [Meshing paths](docs/meshing.md).
18 They are not interchangeable.
19- **Runtime editing.** Spheres, boxes, convex brushes (Quake-style half-space sets) and
1820 arbitrary meshes, added or subtracted, re-meshing only the chunks they touch.
1921- **Pluggable generators.** The density field is a `DCGenerator` — built-in noise, an OBJ
20 mesh SDF, a GPU Gems 3 volume field, a GDScript virtual, or a proc-nodes graph compiled
21 to GLSL.
22 mesh SDF, a GPU Gems 3 volume field, a GDScript virtual, or a proc-nodes graph compiled to
23 GLSL. See [Generators](docs/generators.md).
2224- **Two collision paths.** Concave trimesh bodies through `PhysicsServer3D`, or direct SDF
23 queries (`get_distance`, `get_capsule_push`) with no physics bodies at all.
24- **A separate exact path.** `DCLevel` meshes authored convex brushes exactly — no voxels,
25 no LOD — for the finite, sharp, low-poly parts of a world.
25 queries with no physics bodies at all.
26- **A separate exact path.** `DCLevel` meshes authored convex brushes exactly — no voxels, no
27 LOD — for the finite, sharp, low-poly parts of a world. See [DCLevel](docs/api-dclevel.md).
2628
2729`libdc/` is the meshing core and has no Godot dependency; `src/` is the GDExtension around it.
2830
……@@ -31,10 +33,10 @@ either on the GPU across ten compute stages or on a pool of CPU worker threads.
3133- Godot **4.3+** (`compatibility_minimum`; the demo project targets 4.6)
3234- `godot-cpp` at `./godot-cpp` — a submodule or a symlink
3335- SCons, a C++17 compiler, OpenMP
34- Vulkan headers (`libvulkan-dev` on Linux) — the GPU pipeline talks to `RenderingDevice`
35- [`proc-nodes`](../proc-nodes) checked out **beside** this repo. `SConstruct` adds
36 `../proc-nodes/src/shared/` to the include path for `erosion_filter.inc` and friends: one
37 copy of that math, compiled here as C++ and there as GLSL. Nothing else couples the two.
36- Vulkan headers (`libvulkan-dev` on Linux)
37- [`proc-nodes`](../proc-nodes) checked out **beside** this repo — `SConstruct` adds
38 `../proc-nodes/src/shared/` to the include path for the erosion filter, one copy of that
39 math compiled here as C++ and there as GLSL
3840
3941## Build
4042
……@@ -46,23 +48,16 @@ scons -j$(nproc) target=template_release
4648scons -j$(nproc) target=editor
4749```
4850
49Build **all three targets**. Godot picks the library by target, and an entry that is missing
50from the `.gdextension` silently falls back to another one — so a stale `template_debug`
51answers for the `editor` you thought you just rebuilt.
52
53Cross-compiling for Windows needs the posix-threads mingw variant (the win32 variant ships
54neither libgomp nor `<thread>`) and an explicit header path:
55
56```sh
57scons -j$(nproc) platform=windows vulkan_include=/path/to/Vulkan-Headers/include
58```
51Build **all three targets**. Godot picks the library by target, and a target missing from the
52`.gdextension` silently falls back to another — so a stale `template_debug` answers for the
53`editor` you thought you just rebuilt.
5954
6055## Install
6156
62Copy or symlink `dc_terrain.gdextension` and `bin/` into the consuming project, and put the
63`shaders/` directory **beside the library** — the GPU pipeline resolves its shader directory
64from its own module path at runtime (`dladdr` / `GetModuleFileName`), so `bin/linux/shaders`
65is a symlink to `../../shaders`. Override with `DC_TERRAIN_SHADER_DIR`.
57Copy or symlink `dc_terrain.gdextension` and `bin/` into the consuming project, and put
58`shaders/` **beside the library** — the GPU pipeline resolves its shader directory from its
59own module path at runtime, which is why `bin/linux/shaders` is a symlink to `../../shaders`.
60Override with `DC_TERRAIN_SHADER_DIR`.
6661
6762If the shaders are not found the extension does not fail: it falls back to CPU meshing,
6863quietly, at a fraction of the frame rate.
……@@ -77,407 +72,29 @@ extends Node3D
7772func _ready() -> void:
7873 terrain.voxel_size = 1.0
7974 terrain.octree_depth = 5
80 terrain.use_gpu = true # default is false — see below
75 terrain.use_gpu = true # default is false — and the CPU path is texture-blind
8176 terrain.collision_mode = 0 # 0 = Trimesh, 1 = SDF, 2 = Both
8277 terrain.rebuild()
8378
8479func _unhandled_input(event: InputEvent) -> void:
8580 if event.is_action_pressed("dig"):
86 # negative strength removes material, positive adds it
87 terrain.edit_terrain(_aim_point(), 3.0, -1.0)
81 terrain.edit_terrain(_aim_point(), 3.0, -1.0) # negative strength removes
8882```
8983
9084LOD follows the active `Camera3D` of the viewport — the editor viewport camera in the editor,
9185`get_viewport().get_camera_3d()` at runtime. There is nothing to assign.
9286
93`use_gpu` defaults to **false**. The demo scene sets no properties at all, so it runs the CPU
94mesher; every hyper-tps map sets `use_gpu = true` explicitly.
95
96## Meshing paths
97
98Six of them. They differ in what they can represent and in what attributes they produce, so
99they are not interchangeable.
100
101#### GPU dual contouring — `src/dc_gpu_mesh_pipeline.cpp`, `shaders/`
102
103Ten compute stages on one `RenderingDevice`:
104
105```
106density → edge_classify → scan → stream_compact → edge_intersect
107 → voxel_classify → scan → stream_compact → qef_solve → quad_emit → readback
108```
109
110The **only** path that produces the full vertex attribute set: position, normal,
111`material_id`, two face-projection candidates (`proj`, `proj2`), and per-vertex curvature in
112metres. Requires `use_gpu = true`.
113
114#### CPU dual contouring — `libdc/src/dc_contour.cpp`
115
116Density grid → octree → QEF → recursive contour (`cellProc`/`faceProc`/`edgeProc`) → clip →
117smooth, on a pool of worker threads auto-scaled to the core count.
118
119**It produces geometry only.** `materials`, `proj`, `proj2` and `curv` come back empty — the
120CPU path preserves them across vertex splits when they already exist but never generates
121them. So `use_textures` and Quake per-face projection do nothing here, and the terrain
122renders in flat debug colours whatever the atlas says. This is the default path, because
123`use_gpu` defaults to false.
124
125#### Seam stitching — `libdc/src/dc_seam.cpp`
126
127Cross-LOD gaps are not faded over, they are filled. Boundary nodes of adjacent cells are
128collected into a **temporary** octree, contoured, and emitted as a separate `ArrayMesh` per
129node, then discarded. Ported from ngildea/leven. `debug_seams` draws them.
130
131#### Exact brush meshing — `libdc/src/dc_level_mesh.cpp`
132
133The `DCLevel` path. **No QEF, no voxels, no smoothing.** Every face is triangulated with its
134own vertices carrying its own plane normal — nothing is welded, so a crease is sharp because
135the geometry says so rather than because an angle threshold noticed it.
136
137Render and collision diverge on purpose: render is the csg4-**clipped** visible skin with
138hidden surface removed; collision is the **unclipped** convex brush hull, which is both exact
139and cheaper than a trimesh. Clipping removes hidden surface, not volume.
140
141#### Voxel pockets — `libdc/include/dc_pocket.h`
142
143A second mesher living inside the exact path, and the one that is easiest to miss.
144
145`DCBrushStore::csg4_view` feeds the exact mesher only **clean** brushes; anything an active
146carve touches is excluded, because a carved solid is non-convex and the winding clip cannot
147represent it. Correct — but until something meshed the excluded brushes, a carve did not
148carve, it *deleted*. A pocket is that missing piece:
149
150```
151pocket = DC mesh of ( union(component brushes) − union(active carves) )
152```
153
154The brushes stay authoritative and the pocket is a derived cache that may be thrown away and
155rebuilt. Undo drops a carve and the component returns to the exact mesher unchanged — no
156plane re-fitting, so no error accumulates. Voxels are used here and nowhere else in the exact
157path because a sphere carve is not a half-space: mesh boolean is the wrong *shape* of cost,
158O(global), against voxel DC's O(local). `pocket_voxel_size` tunes it, and `get_stats()`
159reports `pocket_triangles` separately from `triangles` so a pocket that has quietly become
160most of the frame cannot hide inside one number.
161
162#### Simplification — `libdc/src/dc_simplify.cpp`
163
164Two, plus a reorder:
165
166- `dc_simplify_mca` — multiple-choice QEM (Wu & Kobbelt). Pick K random edges, collapse the
167 lowest-error one. Fast.
168- `dc_simplify_meshopt` — meshoptimizer with **border vertex locking**, which is what keeps
169 T-junctions from opening between LOD levels. Higher quality.
170- a vertex-cache reorder, which changes no geometry.
171
172`use_mesh_simplify` gates the post-process, and it runs on the **GPU path only**.
173
174## DCTerrain
175
176`Node3D`. The streaming, destructible world.
177
178#### `terrain.rebuild()`
179
180Drop every chunk and re-mesh from scratch. Bumps a generation counter so in-flight worker
181results are discarded. Most property setters call this for you.
182
183#### `terrain.warmup_shaders()`
184
185Draw a one-triangle mesh through each per-LOD terrain material so the render pipelines are
186compiled before real chunks arrive, instead of stuttering as each LOD appears for the first
187time.
188
189#### Shape
190
191```gdscript
192generator: DCTerrainGenerator # overrides the built-in noise when set
193use_noise: bool = true
194seed: int
195terrain_size: float # 10 .. 100000
196ground_bias: float # -1 .. 1
197solid_floor_depth: float
198sky_height: float
199flat_center_radius: float
200spherical: bool # planetoid mode
201planet_radius: float
202floating_islands: bool
203island_radius: float
204island_depth_ratio: float
205island_spacing: float
206```
207
208Plus the noise bands — `primary_`, `ridge_`, `tunnel_`, `warp_`, `detail_` each with a
209`_frequency` and `_strength` (and `tunnel_radius`) — and the Voronoi floor set
210(`voronoi_cell_count`, `floor_strength`, `floor_height_min`, `floor_height_max`,
211`room_height`). `max_noise_octaves` and `max_ridge_octaves` cap octave count.
212
213These properties configure the **built-in** noise generator. Assigning `generator` replaces
214it wholesale; the properties then describe nothing on screen. Ask
215`is_using_builtin_generator()` rather than assuming — a generator that fails to load falls
216back to noise, which renders a perfectly plausible landscape that is not the one you asked
217for.
218
219#### Meshing and LOD
220
221```gdscript
222voxel_size: float = 1.0 # 0.1 .. 10
223octree_depth: int = 5 # 3 .. 20
224use_gpu: bool = false
225seamless_lod: bool = false # force every chunk to LOD 0
226lod_mode: int # 0 = Eager Fade, 1 = Blocking Swap (default)
227lod_factor: float = 3.0
228hysteresis: float = 1.3
229fade_speed: float = 3.0
230use_screen_space_error: bool
231screen_split_threshold: float
232lod_frozen: bool # freeze traversal during a grapple or teleport
233use_adaptive_qef: bool = true
234use_shortest_diagonal: bool = true
235use_mesh_simplify: bool = true # meshoptimizer pass, GPU path only
236force_direct_eval: bool = false
237csg_refinement: int = 2 # 1 .. 4
238```
239
240#### Display
241
242```gdscript
243terrain_color: Color
244custom_material: Material # overrides the internal per-LOD materials
245use_textures: bool = false # sample the atlas instead of flat debug colors
246normal_perturbation: float = 0.12
247```
248
249`set_texture_atlas(atlas)`, `set_glow_atlas(atlas)`, `set_norm_atlas(atlas)` take a
250`Texture2DArray` whose layer index is the `material_id`. `set_projection_table(table)` takes
25112 floats per entry, indexed per-face.
252
253#### Collision
254
255```gdscript
256collision_mode: int = 0 # 0 = Trimesh, 1 = SDF, 2 = Both
257collision_lod_max: int = 5 # collision is built for LOD 0..N only
258collision_distance: float = 100.0
259```
260
261In SDF mode nothing is added to the scene tree — query the field directly:
262
263#### `terrain.get_distance(position) -> float`
264
265Signed distance to the surface. Positive inside solid, negative in air.
266
267#### `terrain.get_surface_normal(position) -> Vector3`
268
269Normalized gradient of the density field.
270
271#### `terrain.get_capsule_push(position, radius, height) -> Vector3`
272
273Push-out vector for a capsule, or zero. Tests bottom, centre and top.
274
275#### `terrain.sample_density(position) -> float`
87## Documentation
27688
277The raw field, for spawn placement and the like. Positive is solid.
278
279#### Editing
280
281#### `terrain.edit_terrain(position, radius, strength, op = 0, interactive = true, material_id = 0)`
282
283A sphere. `strength` positive adds material, negative removes it. `op` is `0` for the smooth
284metaball blend or `1` for hard SDF CSG.
285
286`interactive` picks the worker lane. `true` is a live carve and takes the priority lane for
287immediate feedback; `false` is a replayed or network-synced edit and goes at normal priority,
288because flooding the priority lane starves LOD pre-meshing.
289
290#### `terrain.edit_terrain_box(position, half_extents, strength, interactive = true, material_id = 0, persistent = false)`
291#### `terrain.edit_terrain_convex(planes, center, radius, strength, interactive = true, material_id = 0, face_proj = [])`
292
293A convex brush is an intersection of half-spaces: `planes` is flat `[nx, ny, nz, d, ...]` with
294outward normals, inside being `n·p <= d` — the same layout a Quake `.map` importer already
295produces. `center` and `radius` are the bounding sphere used for chunk culling.
296
297#### `terrain.stamp_mesh_obj(obj_path, position, scale, strength, interactive = true, material_id = 0)`
298#### `terrain.stamp_mesh_arrays(vertices, indices, position, scale, strength, interactive = true, material_id = 0, persistent = false)`
299
300`persistent = true` marks an edit as **map-authored landscape**: it survives `clear_edits()`.
301There are two journals over one edit list — the player's, which the game owns and replays, and
302the map's, which must not be dropped with it. Use `clear_landscape_edits()` to drop only the
303latter.
304
305```gdscript
306terrain.clear_edits() # player journal
307terrain.clear_landscape_edits() # map-authored landscape; does not rebuild
308terrain.get_edit_count()
309terrain.get_edits_as_array()
310terrain.load_edits_from_array(edits)
311```
312
313Brushes already placed can be picked and moved: `pick_brush(point)`, `get_brush_center(idx)`,
314`get_brush_radius(idx)`, `get_brush_hull_triangles(idx)`, `lift_brush(idx)`,
315`move_brush(idx, delta)`.
316
317#### CSG primitives
318
319Live primitives, distinct from the edit list — combined within a `group`, then unioned with
320the terrain.
321
322```gdscript
323terrain.add_csg_primitive(type, op, group, position, size)
324# type: 0 = sphere, 1 = box. op: 0 = union, 1 = subtract, 2 = intersect.
325# size: sphere = [radius, 0, 0], box = [hx, hy, hz]
326terrain.set_csg_primitive_position(index, position)
327terrain.set_csg_primitive_size(index, size)
328terrain.remove_csg_primitive(index)
329terrain.clear_csg_primitives()
330terrain.get_csg_primitive_count()
331```
332
333#### Stats and instruments
334
335```gdscript
336terrain.get_chunk_count()
337terrain.get_active_chunk_count()
338terrain.get_pending_count()
339terrain.get_collision_queue_size()
340terrain.get_splits_this_frame()
341```
342
343The debug flags — `debug_logging`, `debug_lod_colors`, `debug_seams`, `debug_clipmap`,
344`debug_wireframe`, `debug_edits` — draw overlays. The rest answer questions no overlay can:
345
346#### `terrain.build_stamp() -> String`
347
348Which binary this is, baked at compile time. The `.so` under a consuming project's `addons/`
349is typically a symlink into `dc-terrain/bin` and is not rebuilt by anything automatic — so
350"I changed the C++" and "the game runs the change" are independent facts. This settles the
351second one.
352
353#### `terrain.debug_gpu_density(origin, voxel_size, cells) -> PackedFloat32Array`
354
355The real density pipeline — same shader, same uniform set, same buffers as the mesher — over
356a small grid. `sample_density()` is the CPU twin; comparing the two is the parity check.
357Returns empty if the GPU refused, which is itself the answer. A local `RenderingDevice` is
358null under `--headless`, so this only answers in a real render session.
359
360#### `terrain.debug_chunk_info(position) -> Dictionary`
361
362The streaming state of the leaf under a point: `lod`, `voxel_size`, `chunk_size`, `state`,
363`triangles`, `has_collision`, `collision_eligible`, `origin`. Empty when no leaf contains the
364point — distinct from LOD 0.
365
366#### `terrain.debug_collision_faces(center, radius) -> PackedVector3Array`
367#### `terrain.debug_visual_faces(center, radius) -> PackedVector3Array`
368
369Collision triangles and drawn triangles, both in world space, for comparison. Collision is
370built as bare `PhysicsServer3D` RIDs rather than scene-tree nodes, so this is the only way to
371see it. Gaps are the finding: nothing is built past `collision_lod_max`, however solid the
372terrain looks.
373
374#### `terrain.bake_volume_mesh(glsl, origin, voxel_size, cells) -> ArrayMesh`
375
376Takes a `float proc_density(vec3 pos)` GLSL body, fills a density grid with it and meshes it,
377both through the GPU thread the terrain already runs. Returns null if the GPU declined. It
378lives here rather than in proc-nodes because a second `VkDevice` bring-up starves — one
379device, one GPU thread, and this borrows them.
380
381`get_scalar_query_counts()` / `reset_scalar_query_counts()` count scalar-path evaluations, so
382"how much of the frame is the CPU field" is a number rather than a guess.
383
384## DCLevel
385
386`Node3D`. The exact path: authored convex brushes, meshed exactly, with no voxels and no LOD.
387
388It shares no code with the octree clipmap. The world — procedural, destructible everywhere —
389keeps `DCTerrain`; the level — authored, finite, sharp, low-poly — gets this.
390
391```
392BrushStore --csg4--> visible skin --dc_level_mesh--> ArrayMesh
393 \--unclipped hulls--------------------> convex colliders
394```
395
396```gdscript
397var id := level.add_brush(planes, material_id, plane_proj, level_id, sky, key, clearance, group)
398level.rebuild()
399```
400
401`planes` is the same flat `(nx, ny, nz, d)` layout `edit_terrain_convex` takes, so an importer
402drives either. `key` is the brush's durable name — pass `""` for an import, which is named
403deterministically on every peer. `sky` renders and clips but gets no collider. `clearance`
404marks air the level owns — the headroom over a stair, the bore of an archway — rendering and
405colliding with nothing, existing only so `build_shell` encloses it.
406
407Editing is by durable key and is journalled and reversible:
408
409```gdscript
410level.translate_brush(id, delta)
411level.scale_brush(id, scale, pivot)
412level.split_brush(id, normal, d, key)
413level.subtract_brush(id, cutter, key)
414level.intersect_brush(id, other, key)
415level.merge_brushes(ids, key)
416level.add_face(id, normal, d, proj, key)
417level.remove_face(id, face)
418level.set_face_plane(id, face, normal, d)
419
420level.apply_carve_sphere(center, radius, key)
421level.apply_carve_box(center, half_extents, key)
422level.undo_carve_by_key(key)
423level.redo_carve_by_key(key)
424
425level.apply_journal(records) # replay
426level.fingerprint() # convergence check
427```
428
429`build_shell(cell, clamp_cells)` derives the shell the surrounding `DCTerrain` must be cut to.
430`build_shell_start` / `shell_building` / `shell_ready` / `build_shell_take` are the async form.
431
432`raycast(from, to, include_clearance)` and `pick_face(brush_id, point)` support editor picking;
433`get_stats()` reports brush, face, triangle, vertex, collider, carve and pocket counts plus
434per-stage milliseconds (`ms_split`, `ms_clip`, `ms_mesh`, `ms_upload`, `ms_collide`,
435`ms_pocket`). Clearance and pocket figures are reported *separately* on purpose: a level
436showing 1190 brushes and 12747 triangles has not lost seven of them, it has seven passages —
437and pocket triangles have a different cost curve from the exact mesh, so one combined number
438would hide a pocket that had quietly become most of the frame.
439
440## Generators
441
442Assign a `DCTerrainGenerator` resource to `DCTerrain.generator` to replace the built-in field.
443
444| Class | Field |
89| | |
44590|---|---|
446| `DCTerrainGeneratorNoise` | The built-in noise, as a resource. Same properties as `DCTerrain`. |
447| `DCTerrainGeneratorMeshSDF` | An SDF from an OBJ mesh. `mesh_path`, `reload()`, `get_triangle_count()`. |
448| `DCTerrainGeneratorGeiss` | GPU Gems 3 ch.1. **GPU only**, and produces nothing but air until `volume_dir` points at the eight `16³` volumes — deliberately, so a half-loaded field cannot pass for the real one. Check `is_loaded()`. |
449| `DCTerrainGeneratorProcNodes` | A proc-nodes graph compiled to a GLSL density function, reached by ClassDB reflection so there is no compile-time dependency. Float and Int socket defaults are auto-discovered and exposed as grouped inspector properties. |
450| `DCTerrainGeneratorProcNodesBounded` | The same graph bridge at **asset** scale: no UV-space mapping, no added noise layers, no vertical clip — `evaluate` takes world coordinates and the field is meshed at the size the graph says it is. Bounds come from the graph's own `Bounds Min` / `Bounds Max` group outputs; a graph declaring none is honestly unbounded. **GPU only** — `evaluate` returns air on the CPU path rather than inventing a field. |
451| `DCTerrainGeneratorScript` | Override `_evaluate(x, y, z, lod_voxel_size) -> float` in GDScript. Called from worker threads and serialized through a mutex, so it is slow — for prototyping, not production. |
452
453`DCTerrainUtils.mesh_from_generator(generator, origin, voxel_size, grid_size)` and
454`mesh_from_generator_auto(generator, voxel_size)` mesh a generator straight to an `ArrayMesh`
455without a terrain node.
456
457## Testing
458
459`libdc` is Godot-free, so its gates are plain executables. Each one carries its own build line
460in its header comment:
461
462```sh
463# from the repo root
464g++ -O2 -std=c++17 -I libdc/include -o /tmp/csg4_gate \
465 tests/test_csg4_gate.cpp libdc/src/dc_csg4.cpp
466/tmp/csg4_gate
467```
468
469`test/CMakeLists.txt` builds `test_fandisk`, the meshing-accuracy check against
470`test/data/fandisk.obj`:
471
472```sh
473cmake -S test -B test/build && cmake --build test/build && ./test/build/test_fandisk
474```
91| [Meshing paths](docs/meshing.md) | The six meshers, what each represents, and which vertex attributes each produces |
92| [DCTerrain API](docs/api-dcterrain.md) | Properties, editing, collision, and the debug instruments |
93| [DCLevel API](docs/api-dclevel.md) | The exact brush path — import, journalled editing, carves, shells |
94| [Generators](docs/generators.md) | The five generator resources and the `DCGenerator` interface |
95| [Testing](docs/testing.md) | How the gates are built and run |
47596
476GPU gates compile and dispatch **standalone**, on their own `VkDevice`, in their own process,
477linking `-lvulkan` — because the in-engine oracle submits work on the live device, and letting
478it run long enough to answer is what once interleaved its uniform set with the mesh pipeline's
479and emptied the world. `tests/gate_hblk_compile.sh` and `tests/gate_polish_units.py` are
480scripted gates; `demo/*.gd` are gates that need a running Godot.
97Design notes, postmortems and the ProcWorld archive are also under [`docs/`](docs/).
48198
48299## Layout
483100
……@@ -488,7 +105,7 @@ shaders/ compute shaders, loaded from disk beside the library at runtime
488105tests/ libdc + GPU gates, one file each, build line in the header
489106test/ CMake-built fandisk accuracy check
490107demo/ a Godot project: fly camera, CSG edits, CPU/GPU benchmark
491docs/ designs, postmortems, and a mirror of the ProcWorld archive
108docs/ API reference, designs, postmortems, ProcWorld archive
492109thirdparty/ meshoptimizer (MIT)
493110```
494111
docs/api-dclevel.md +152 -0
……@@ -0,0 +1,152 @@
1# DCLevel API
2
3`Node3D`. The exact path: authored convex brushes, meshed exactly, with no voxels and no LOD.
4
5It shares no code with the octree clipmap. The world — procedural, destructible everywhere —
6keeps [`DCTerrain`](api-dcterrain.md); the level — authored, finite, sharp, low-poly — gets
7this. That separation is the architectural pivot, not an optimisation.
8
9```
10BrushStore --csg4--> visible skin --dc_level_mesh--> ArrayMesh
11 \--unclipped hulls--------------------> convex colliders
12```
13
14How the mesh is built, and why carved brushes need a second mesher, is in
15[Meshing paths](meshing.md#exact-brush-meshing).
16
17## Import
18
19#### `add_brush(planes, material_id, plane_proj, level_id, sky, key, clearance, group) -> int`
20
21`planes` is a flat `(nx, ny, nz, d)` quad list — the same layout
22[`DCTerrain.edit_terrain_convex`](api-dcterrain.md#editing) takes, so one importer drives
23either. `plane_proj` is a parallel per-face projection index (1-based; empty = none).
24
25- `key` — the brush's **durable** name, the dot of the op that created it. Pass `""` for an
26 import, which is then named `i:<level_id>:<n>` deterministically on every peer.
27- `sky` — a Quake sky brush. Renders and clips normally but gets no collider, and is the
28 authored outside-wall marker.
29- `clearance` — **air the level owns** rather than geometry: the headroom over a flight of
30 stairs, the bore of an archway driven into a hillside. It renders nothing, collides with
31 nothing and clips nothing. Its only effect is that `build_shell` encloses it, so the
32 surrounding `DCTerrain` is cut away to leave the passage.
33- `group` — brushes in one group are one thing, so an arch is a single object rather than
34 nine brushes.
35
36Returns `0` when the plane set encloses nothing, **or** when a brush with this key already
37exists — in which case the return is that brush's id. Replay is not once-only, so a create
38must be idempotent.
39
40#### `apply_journal(records)`
41
42Replay a journal of ops.
43
44#### `fingerprint()`
45
46A convergence check — two peers that applied the same journal should agree.
47
48## Editing
49
50All of these take a durable `key` where an op creates or renames geometry.
51
52```gdscript
53translate_brush(brush_id, delta)
54scale_brush(brush_id, scale, pivot)
55move_brush_points(brush_id, from, delta, key)
56split_brush(brush_id, normal, d, key)
57subtract_brush(brush_id, cutter, key)
58intersect_brush(brush_id, other, key)
59merge_brushes(brush_ids, key)
60
61set_face_plane(brush_id, face, normal, d)
62add_face(brush_id, normal, d, proj, key)
63remove_face(brush_id, face)
64
65remove_brush(brush_id) / restore_brush(brush_id)
66remove_level(level_id) / restore_level(level_id)
67clear()
68```
69
70Hiding is a **view**, filtered at mesh emit and never at csg4's input — so a hidden brush
71still clips its neighbours:
72
73```gdscript
74set_brush_hidden(brush_id, hidden)
75is_brush_hidden(brush_id) -> bool
76get_hidden_brush_ids()
77clear_hidden()
78```
79
80## Carves
81
82A carve leaves brush-land, so it is meshed as a [voxel pocket](meshing.md#voxel-pockets).
83
84```gdscript
85apply_carve_sphere(center, radius, key) -> int
86apply_carve_box(center, half_extents, key) -> int
87undo_carve(carve_id) / redo_carve(carve_id)
88undo_carve_by_key(key) / redo_carve_by_key(key)
89```
90
91Undo drops the carve and the component returns to the exact mesher unchanged — no plane
92re-fitting, so no error accumulates over repeated undo/redo.
93
94## Shells
95
96#### `build_shell(cell, clamp_cells)`
97
98The sealed volume the level occupies, as a mesh, for one subtractive edit into the
99surrounding `DCTerrain`. Clearance brushes are enclosed by it.
100
101#### `build_shell_start(cell, clamp_cells)` · `shell_building()` · `shell_ready()` · `build_shell_take()`
102
103The same, off the caller's thread.
104
105## Queries
106
107```gdscript
108get_brush(brush_id) -> Dictionary
109brush_by_key(key) -> int / get_brush_key(brush_id) -> String
110get_brush_group(brush_id) / get_group_brush_ids(group) / get_group_keys()
111get_live_brush_ids() / get_sky_brush_ids() / get_clearance_brush_ids()
112
113pick_face(brush_id, point) -> int
114face_by_key(brush_id, key) -> int
115get_face_winding(brush_id, face)
116face_is_live(brush_id, face) -> bool
117raycast(from, to, include_clearance) -> Dictionary
118
119get_brush_edges() / get_brush_edges_of(brush_id) / get_brush_vertices_of(brush_id)
120get_clearance_edges()
121get_cut_preview(brush_id, normal, d)
122get_brush_id_from_shape(shape_index) -> int
123```
124
125## Properties and stats
126
127```gdscript
128build_collision: bool # the only inspector property on this class
129```
130
131`pocket_voxel_size` has a bound setter and getter but **no inspector property**, so it is
132reachable only from script.
133
134```gdscript
135get_brush_count() get_triangle_count() get_vertex_count()
136get_collider_count() get_carve_count() get_pocket_count()
137rebuild()
138```
139
140#### `get_stats() -> Dictionary`
141
142`brushes`, `faces`, `triangles`, `vertices`, `faces_removed`, `faces_fragmented`,
143`surface_area`, `max_normal_deviation`, `colliders`, `sky_brushes`, `clearance_brushes`,
144`hidden_brushes`, `carves`, `pockets`, `pocket_triangles`, `pocket_voxel_size`,
145`pockets_rebuilt`, and per-stage milliseconds `ms_split`, `ms_clip`, `ms_mesh`, `ms_upload`,
146`ms_collide`, `ms_pocket`.
147
148Clearance brushes are counted separately because `brushes` includes them and they are not
149geometry — a level reporting 1190 brushes and 12747 triangles has not lost seven of them, it
150has seven passages. `pocket_triangles` is separate from `triangles` for the same class of
151reason: the two have different cost curves, and one combined number would hide a pocket that
152had quietly become most of the frame.
docs/api-dcterrain.md +279 -0
……@@ -0,0 +1,279 @@
1# DCTerrain API
2
3`Node3D`. The streaming, destructible world. For the exact authored path see
4[DCLevel](api-dclevel.md); for what the meshers do and do not produce see
5[Meshing paths](meshing.md).
6
7## Lifecycle
8
9#### `rebuild()`
10
11Drop every chunk and re-mesh from scratch. Bumps a generation counter so in-flight worker
12results are discarded. Most property setters call this for you.
13
14#### `warmup_shaders()`
15
16Draw a one-triangle mesh through each per-LOD terrain material, so the render pipelines are
17compiled before real chunks arrive instead of stuttering as each LOD appears for the first
18time. This warms the **render** materials, not the compute pipelines.
19
20#### `is_using_builtin_generator() -> bool`
21
22Whether the built-in noise is what is actually meshing. Ask, rather than assume: a `generator`
23that fails to load falls back to noise, which renders a perfectly plausible landscape that is
24not the one you asked for.
25
26## Shape
27
28These configure the **built-in** noise generator. Assigning `generator` replaces it wholesale,
29after which they describe nothing on screen.
30
31```gdscript
32generator: DCTerrainGenerator # overrides the built-in noise when set
33use_noise: bool
34seed: int
35terrain_size: float # 10 .. 100000
36ground_bias: float # -1 .. 1
37solid_floor_depth: float # 0 .. 100
38sky_height: float # 0 .. 100
39flat_center_radius: float # 0 .. 50
40spherical: bool # planetoid mode
41planet_radius: float # 10 .. 1000000
42floating_islands: bool
43island_radius: float # 10 .. 500
44island_depth_ratio: float # 0.5 .. 10
45island_spacing: float # 50 .. 1000
46max_noise_octaves: int # 0 .. 20
47max_ridge_octaves: int # 0 .. 20
48```
49
50Noise bands, each a `_frequency` (0.001 .. 1.0) and a `_strength`:
51
52```gdscript
53primary_frequency / primary_strength # strength 0 .. 5
54ridge_frequency / ridge_strength # strength 0 .. 5
55tunnel_frequency / tunnel_strength # strength 0 .. 5, plus tunnel_radius 0 .. 2
56warp_frequency / warp_strength # strength 0 .. 30
57detail_frequency / detail_strength # strength 0 .. 2
58```
59
60Voronoi floors:
61
62```gdscript
63voronoi_cell_count: int # 0 .. 20
64floor_strength: float # 0 .. 3
65floor_height_min: float # -50 .. 50
66floor_height_max: float # -50 .. 50
67room_height: float # 1 .. 30
68```
69
70## Meshing and LOD
71
72```gdscript
73voxel_size: float # 0.1 .. 10
74octree_depth: int # 3 .. 20
75use_gpu: bool # default false — the CPU path is texture-blind
76seamless_lod: bool # force every chunk to LOD 0
77lod_mode: int # 0 = Eager Fade, 1 = Blocking Swap
78lod_factor: float # 1 .. 8
79hysteresis: float # 1 .. 2
80fade_speed: float # 0.5 .. 20
81use_screen_space_error: bool
82screen_split_threshold: float # 0.1 .. 4
83lod_frozen: bool # freeze traversal during a grapple or teleport
84use_adaptive_qef: bool
85use_shortest_diagonal: bool
86use_mesh_simplify: bool # meshoptimizer post-process, GPU path only
87adaptive_arch_lod: bool # experimental architecture sub-LOD
88arch_min_lod: int
89force_direct_eval: bool
90csg_refinement: int # 1 .. 4
91```
92
93`lod_mode` **Blocking Swap** holds a split until the children are meshed (the godot-voxel
94pattern); **Eager Fade** splits immediately and crossfades over the gap.
95
96#### `debug_kick_lod()`
97
98Force one LOD re-traversal — a pure `invalidate_settled`, with no freeze and no camera move.
99
100## Display
101
102```gdscript
103terrain_color: Color
104custom_material: Material # overrides the internal per-LOD materials
105use_textures: bool # sample the atlas instead of flat debug colours
106normal_perturbation: float # 0 .. 0.3
107```
108
109#### `set_texture_atlas(atlas)` · `set_glow_atlas(atlas)` · `set_norm_atlas(atlas)`
110
111`Texture2DArray` whose **layer index is the `material_id`**. Glow is RGBA — rgb emissive, a
112gloss that drives roughness. Norm is a tangent-space normal map.
113
114#### `set_projection_table(table)`
115
116`PackedFloat32Array`, 12 floats per entry, uploaded as an RGBA32F data texture and sampled by
117the shader through each vertex's projection index.
118
119These only have an effect on the GPU path, which is the only one that emits `material_id` and
120projection indices.
121
122## Collision
123
124```gdscript
125collision_mode: int # 0 = Trimesh, 1 = SDF, 2 = Both
126collision_lod_max: int # collision built for LOD 0..N
127collision_distance: float # 10 .. 500
128```
129
130Trimesh collision is built as bare `PhysicsServer3D` RIDs hung off the clipmap nodes, not as
131scene-tree `CollisionShape3D`s — so nothing in GDScript can see it directly. Requests beyond
132`collision_distance` are held in a deferred queue and promoted on approach rather than built.
133
134In SDF mode nothing is added to the scene tree; query the field:
135
136#### `get_distance(position) -> float`
137
138Signed distance to the surface. Positive inside solid, negative in air.
139
140#### `get_surface_normal(position) -> Vector3`
141
142Normalized gradient of the density field.
143
144#### `get_capsule_push(position, radius, height) -> Vector3`
145
146Push-out vector for a capsule, or zero if there is no intersection. Tests bottom, centre and
147top.
148
149#### `sample_density(position) -> float`
150
151The raw field, for spawn placement and similar. Positive is solid.
152
153## Editing
154
155#### `edit_terrain(position, radius, strength, op = 0, interactive = true, material_id = 0)`
156
157A sphere. `strength` positive adds material, negative removes it. `op` is `0` for the smooth
158metaball blend or `1` for hard SDF CSG — the sharp union/difference a box already does.
159
160`interactive` selects the worker lane. `true` is a live carve and takes the **priority** lane
161for immediate feedback; `false` is a replayed or network-synced edit and goes at normal
162priority, because flooding the priority lane starves LOD pre-meshing.
163
164#### `edit_terrain_box(position, half_extents, strength, interactive = true, material_id = 0, persistent = false)`
165
166#### `edit_terrain_convex(planes, center, radius, strength, interactive = true, material_id = 0, face_proj = [])`
167
168A convex brush is an intersection of half-spaces. `planes` is a flat
169`[nx, ny, nz, d, ...]` with outward normals, inside being `n·p <= d` — the layout a Quake
170`.map` importer already produces, and the same one [`DCLevel.add_brush`](api-dclevel.md)
171takes, so one importer drives either. `center` and `radius` are the bounding sphere used for
172chunk culling. `face_proj` is a parallel per-face projection index.
173
174#### `stamp_mesh_obj(obj_path, position, scale, strength, interactive = true, material_id = 0)`
175
176#### `stamp_mesh_arrays(vertices, indices, position, scale, strength, interactive = true, material_id = 0, persistent = false)`
177
178`persistent = true` marks an edit as **map-authored landscape**: it survives `clear_edits()`.
179There are two journals over one edit list — the player's, which the game owns and replays, and
180the map's, which must not be dropped with it.
181
182```gdscript
183clear_edits() # the player journal
184clear_landscape_edits() # map-authored landscape only; does NOT rebuild — stamp right after
185get_edit_count() -> int
186get_edits_as_array() -> Array
187load_edits_from_array(edits)
188```
189
190Placed brushes can be picked and moved:
191
192```gdscript
193pick_brush(point) -> int
194get_brush_center(idx) -> Vector3
195get_brush_radius(idx) -> float
196get_brush_hull_triangles(idx) -> PackedVector3Array
197lift_brush(idx) -> Dictionary
198move_brush(idx, delta)
199```
200
201## CSG primitives
202
203Live primitives, distinct from the edit list. Primitives sharing a `group` are combined, then
204unioned with the terrain.
205
206```gdscript
207add_csg_primitive(type, op, group, position, size)
208# type: 0 = sphere, 1 = box
209# op: 0 = union, 1 = subtract, 2 = intersect
210# size: sphere = (radius, 0, 0), box = (hx, hy, hz)
211
212set_csg_primitive_position(index, position)
213set_csg_primitive_size(index, size)
214remove_csg_primitive(index)
215clear_csg_primitives()
216get_csg_primitive_count() -> int
217```
218
219## Stats
220
221```gdscript
222get_chunk_count() -> int
223get_active_chunk_count() -> int
224get_pending_count() -> int
225get_collision_queue_size() -> int
226get_splits_this_frame() -> int
227```
228
229## Debug instruments
230
231The flags — `debug_logging`, `debug_lod_colors`, `debug_seams`, `debug_clipmap`,
232`debug_wireframe`, `debug_edits` — draw overlays. The rest answer questions no overlay can.
233
234#### `build_stamp() -> String`
235
236Which binary this is, baked in at compile time. The `.so` under a consuming project's
237`addons/` is typically a symlink into `dc-terrain/bin` and is not rebuilt by anything
238automatic, so "I changed the C++" and "the game runs the change" are independent facts. This
239settles the second.
240
241#### `debug_gpu_density(origin, voxel_size, cells) -> PackedFloat32Array`
242
243The real density pipeline — same shader, same uniform set, same buffers as the mesher — over a
244small grid. `sample_density(position)` is the CPU twin; comparing the two is the parity check.
245
246Returns **empty** if the GPU refused, which is itself the answer. A local `RenderingDevice` is
247null under `--headless`, so this only answers in a real render session.
248
249#### `debug_chunk_info(position) -> Dictionary`
250
251The streaming state of the leaf under a point: `lod`, `voxel_size`, `chunk_size`, `state`,
252`mesh_state`, `triangles`, `has_collision`, `collision_eligible`, `parent_collision_stale`,
253`origin`. **Empty** when no leaf contains the point — which is distinct from LOD 0.
254
255(The doc comment above this method in `dc_terrain.h` lists only eight of these ten keys; the
256implementation is the authority.)
257
258#### `debug_collision_faces(center, radius) -> PackedVector3Array`
259#### `debug_visual_faces(center, radius) -> PackedVector3Array`
260
261Collision triangles and drawn triangles, both in world space, for comparison. Since collision
262is bare RIDs, this is the only way to see it. Gaps are the finding: nothing is built past
263`collision_lod_max`, however solid the terrain looks.
264
265#### `debug_sdf_at(position)`
266
267Print the SDF contribution of every edit at a point.
268
269#### `get_scalar_query_counts() -> Dictionary` · `reset_scalar_query_counts()`
270
271Returns `sample_density`, `eval_with_edits` and `collision_mode`. Those two counters cover
272every scalar-path evaluation there is — `sample_density` for map scripts, `_eval_with_edits`
273for the SDF collision family, which only runs in SDF mode — so "how much of the frame is the
274CPU field" is a number rather than a guess. `collision_mode` is returned alongside because
275the second counter is meaningless without it.
276
277#### `bake_volume_mesh(glsl, origin, voxel_size, cells) -> ArrayMesh`
278
279See [one-shot meshing](meshing.md#one-shot-meshing).
docs/generators.md +97 -0
……@@ -0,0 +1,97 @@
1# Generators
2
3The density field is a `DCGenerator` — pure C++, no Godot. `DCTerrainGenerator` is the
4abstract `Resource` that wraps one so it can be assigned in the inspector or from GDScript.
5
6Assign it to [`DCTerrain.generator`](api-dcterrain.md#shape) to replace the built-in noise.
7Ask `is_using_builtin_generator()` afterwards rather than assuming it took: a generator that
8fails to load falls back to noise, which renders a plausible landscape that is not yours.
9
10## DCTerrainGeneratorNoise
11
12The built-in noise as a resource, with the same 27 properties `DCTerrain` exposes directly —
13`seed`, `use_noise`, the five noise bands, the terrain-shape set, `spherical` /
14`planet_radius`, and the Voronoi floors.
15
16## DCTerrainGeneratorMeshSDF
17
18An SDF built from an OBJ mesh, over a BVH. Use it to verify meshing accuracy (the fandisk
19benchmark) or to stamp an arbitrary mesh shape as terrain.
20
21```gdscript
22mesh_path: String # PROPERTY_HINT_GLOBAL_FILE, "*.obj"
23reload() # re-read from disk after changing mesh_path
24get_triangle_count() -> int
25```
26
27## DCTerrainGeneratorGeiss
28
29GPU Gems 3 chapter 1 — the Geiss terrain field. It exists to be **compared**: the same field
30hyper-tps raymarches, meshed here, so marching and meshing can be measured against each other
31on identical terrain.
32
33**GPU only.** The field samples eight 16³ noise volumes, and until `volume_dir` points at them
34`has_gpu_shader()` is false and the generator produces nothing but air — deliberately, so a
35half-loaded field can never be mistaken for the real one.
36
37```gdscript
38volume_dir: String # PROPERTY_HINT_DIR
39reload() -> bool # loads the eight .vol files. All or nothing.
40is_loaded() -> bool # check this before trusting a mesh built from it
41```
42
43## DCTerrainGeneratorProcNodes
44
45Bridges a proc-nodes `ProcNodeGraph`, reached through `ClassDB` reflection so there is no
46compile-time dependency on proc-nodes' classes.
47
48The graph is compiled to a GLSL density function via `compile_to_glsl_function()` and wrapped
49to match `evaluate_density(x, y, z)`. Float and Int socket defaults are auto-discovered from
50the graph and exposed as grouped inspector properties (e.g. `Gradient Noise/Scale`); changing
51one updates the graph, marks the GLSL dirty and triggers a rebuild.
52
53This is the **world** generator: it maps world metres into a UV-like node space through
54`terrain_height`, adds its own mountain / relief / detail layers, and clips density to
55`[floor_y, ceiling_y]`.
56
57## DCTerrainGeneratorProcNodesBounded
58
59The same bridge at **asset** scale, and a different class precisely because the world
60generator's assumptions are wrong for an asset.
61
62It scales nothing. `evaluate` takes world coordinates and the field is meshed at the size the
63graph says it is. Bounds are answered from the graph's own `Bounds Min` / `Bounds Max` group
64outputs, read by name; a graph declaring none falls back to "unbounded", which is the honest
65answer rather than a guessed box.
66
67**GPU only, deliberately** — `evaluate` returns air rather than inventing a CPU field, because
68neither the reflection call nor the graph's cached execution plan has been shown to be safe on
69dc-terrain's worker threads.
70
71## DCTerrainGeneratorScript
72
73Override a virtual in GDScript:
74
75```gdscript
76extends DCTerrainGeneratorScript
77
78func _evaluate(x: float, y: float, z: float, lod_voxel_size: float) -> float:
79 return 8.0 - y # positive = solid, negative = air
80```
81
82`_evaluate` is called from **worker threads**, and the GDScript VM is single-threaded, so the
83class serialises every call through a mutex and a deferred call. It is therefore much slower
84than a native generator. For prototyping, not production.
85
86## Meshing a generator directly
87
88`DCTerrainUtils` meshes one without a terrain node:
89
90```gdscript
91DCTerrainUtils.mesh_from_generator(generator, origin, voxel_size, grid_size) -> ArrayMesh
92DCTerrainUtils.mesh_from_generator_auto(generator, voxel_size) -> ArrayMesh
93```
94
95`origin` is the world-space corner of the meshing volume; `grid_size` is voxels per axis (32,
9664, 128 are typical). The `_auto` form takes bounds from the generator's `get_bounds()` with
97padding.
docs/meshing.md +140 -0
……@@ -0,0 +1,140 @@
1# Meshing paths
2
3Six of them. They differ in what they can represent and in which vertex attributes they
4produce, so they are not interchangeable.
5
6| path | representation | attributes produced |
7|---|---|---|
8| [GPU dual contouring](#gpu-dual-contouring) | voxel | position, normal, `material_id`, `proj`, `proj2`, curvature |
9| [CPU dual contouring](#cpu-dual-contouring) | voxel | position, normal **only** |
10| [Seam stitching](#seam-stitching) | temporary octree, discarded | one `ArrayMesh` per node |
11| [Exact brush meshing](#exact-brush-meshing) | brush planes, no QEF | per-face vertices, own plane normal |
12| [Voxel pockets](#voxel-pockets) | voxel, inside the exact path | derived cache, rebuildable |
13| [Simplification](#simplification) | post-process | — |
14
15## GPU dual contouring
16
17`src/dc_gpu_mesh_pipeline.cpp`, `shaders/`
18
19Ten compute stages on one `RenderingDevice`:
20
21```
22density → edge_classify → scan → stream_compact → edge_intersect
23 → voxel_classify → scan → stream_compact → qef_solve → quad_emit → readback
24```
25
26The **only** path that produces the full vertex attribute set. The readback fills
27`materials`, `proj`, `proj2` and `curv` alongside position and normal — 16 floats per vertex:
28slots 0-2 position, 3-5 normal, 8 `material_id`, 10 and 11 the two projection candidates
29(both read as uint bits), and 12 curvature in metres as a float.
30
31Requires `use_gpu = true`, and requires the shaders to be found on disk (see Install).
32
33## CPU dual contouring
34
35`libdc/src/dc_contour.cpp`, driven by `src/dc_worker_cpu.cpp`
36
37Density grid → octree → QEF → recursive contour (`cellProc` / `faceProc` / `edgeProc`) → clip
38→ smooth, on a pool of workers auto-scaled to the CPU core count. In the editor a single
39worker is shared across all `DCTerrain` instances, because multiple `RenderingDevice`s crash
40on some GPUs.
41
42**It produces geometry only.** `materials`, `proj`, `proj2` and `curv` come back empty —
43`DCMeshData` says so in its own field comments, and the contour code only *preserves* those
44arrays across vertex splits when they are already populated. It never generates them.
45
46The consequence is user-visible: `use_textures` and Quake per-face projection do nothing on
47this path, and the terrain renders in flat debug colours whatever the atlas holds. Since
48`use_gpu` defaults to `false`, this is the **default** path.
49
50## Seam stitching
51
52`libdc/src/dc_seam.cpp`
53
54Cross-LOD gaps are not faded over, they are filled. Boundary nodes of adjacent cells are
55collected into a **temporary** octree (arena-allocated, discarded after use), contoured, and
56emitted as a separate `ArrayMesh` per node. Ported from ngildea/leven.
57
58`debug_seams` draws them. `seamless_lod` sidesteps the problem entirely by forcing every
59chunk to LOD 0, at the obvious cost.
60
61## Exact brush meshing
62
63`libdc/src/dc_level_mesh.cpp` — the `DCLevel` path
64
65**No QEF, no voxels, no smoothing.** Every face is triangulated with its own vertices
66carrying its own plane normal. Nothing is welded and nothing is smoothed, so a crease is
67sharp because the geometry says so — not because an angle threshold noticed it. Kobbelt's
68non-convergence result for reconstructed normals does not apply to geometry that *is* the
69brush planes.
70
71Render and collision diverge deliberately:
72
73```
74render = the csg4-CLIPPED visible skin (hidden surface removed)
75collision = the UNCLIPPED convex brush hull (exact, and cheaper than a trimesh)
76```
77
78Clipping removes hidden **surface**, not **volume**.
79
80UVs are not computed here. Each vertex carries its face's `proj` index (1-based into the
81importer's projection table, 0 = none) plus its `material_id`, and the fragment shader
82resolves the projection from that table. On the exact mesh both projection candidates are the
83same face — a fragment's face is known exactly — so the shader's crease-pick becomes a no-op
84rather than a heuristic. The material-quantization problem disappears by construction rather
85than being solved.
86
87## Voxel pockets
88
89`libdc/include/dc_pocket.h`
90
91A second mesher living inside the exact path, and the one that is easiest to miss.
92
93`DCBrushStore::csg4_view` feeds the exact mesher only **clean** brushes; anything an active
94carve touches is excluded. That exclusion is correct — a carved solid is non-convex, so it is
95no longer a brush and the winding clip cannot represent it. But until something meshed the
96excluded brushes, a carve did not carve, it *deleted*. `tests/test_pocket.cpp` part 1 pins
97that behaviour so the claim stays checkable.
98
99```
100pocket = DC mesh of ( union(component brushes) − union(active carves) )
101```
102
103The brushes stay authoritative; the pocket is a derived cache that may be thrown away and
104rebuilt at any time. Undo drops a carve and the component returns to the exact mesher
105unchanged — no plane re-fitting, so no error accumulates.
106
107Voxels are used here and nowhere else in the exact path because a sphere carve is not a
108half-space: it leaves brush-land entirely. Mesh boolean is the wrong *shape* of cost —
109O(global) — against voxel DC's O(local).
110
111`pocket_voxel_size` tunes the resolution. `get_stats()` reports `pocket_triangles` separately
112from `triangles`, on purpose: the exact mesh and the voxel pockets have completely different
113cost curves, and one combined number would hide a pocket that had quietly become most of the
114frame.
115
116## Simplification
117
118`libdc/src/dc_simplify.cpp`
119
120Two algorithms plus a reorder:
121
122- **`dc_simplify_mca`** — multiple-choice QEM (Wu & Kobbelt). For each collapse, evaluate `k`
123 random edges and collapse the lowest-error one. Fast, and good enough for LOD terrain.
124- **`dc_simplify_meshopt`** — meshoptimizer with **border vertex locking**. Locking border
125 vertices is what stops T-junctions opening between LOD levels. Higher quality.
126- **a vertex-cache reorder**, which changes no geometry at all.
127
128`use_mesh_simplify` gates the post-process, and it runs on the **GPU path only**.
129
130## One-shot meshing
131
132Two entry points mesh something without a streaming terrain node at all:
133
134- `DCTerrainUtils.mesh_from_generator(...)` / `mesh_from_generator_auto(...)` — a generator
135 straight to an `ArrayMesh`, via `dc_mesher.h`'s convenience pipeline (fill grid → octree →
136 QEF → contour → post-process).
137- `DCTerrain.bake_volume_mesh(...)` — a GLSL `float proc_density(vec3)` body, filled and
138 meshed through the GPU thread the terrain already runs. It lives on `DCTerrain` rather than
139 in proc-nodes because a second `VkDevice` bring-up starves: one device, one GPU thread, and
140 this borrows them.
docs/testing.md +62 -0
……@@ -0,0 +1,62 @@
1# Testing
2
3## libdc gates — `tests/`
4
5`libdc` is Godot-free, so its gates are plain executables. There is **no build system** here:
6each file carries its own compile line in its header comment.
7
8```sh
9# from the repo root
10g++ -O2 -std=c++17 -I libdc/include -o /tmp/csg4_gate \
11 tests/test_csg4_gate.cpp libdc/src/dc_csg4.cpp
12/tmp/csg4_gate
13```
14
15Some gates read fixtures that are **not in the repo** (`/tmp/startmap_brushes.bin`, for
16instance) and abort without them. Most other `/tmp` paths in these files are outputs — `.obj`
17dumps written for inspection.
18
19Two kinds of file live here and nothing distinguishes them by name or exit code: **regression
20gates**, which assert, and **one-shot research oracles**, which test a hypothesis and whose
21failure is a recorded result rather than a broken build. `test_hermite_qef.cpp` is the second
22kind. Read the header before treating a non-zero exit as a regression.
23
24## GPU gates
25
26GPU gates compile and dispatch **standalone** — their own `VkDevice`, their own process,
27nothing else on the queue, linking `-lvulkan`.
28
29That pattern exists for a reason. The in-engine oracle *submits* density fills on the live
30device, and letting it run long enough to answer is what once interleaved its uniform set with
31the mesh pipeline's and emptied the world. Its short deadline is a guard, not a defect.
32
33A gate that inspects GLSL text or runs outside Godot cannot tell a correct shader from one
34that never ran — which is why `DCTerrain.debug_gpu_density()` exists as the in-engine
35counterpart, and why it only answers in a real render session.
36
37## Meshing accuracy — `test/`
38
39`test/CMakeLists.txt` builds `test_fandisk`, which reconstructs the fandisk benchmark model
40from its SDF and checks that sharp features survive:
41
42```sh
43cmake -S test -B test/build && cmake --build test/build
44./test/build/test_fandisk # defaults to test/data/fandisk.obj
45```
46
47The CMakeLists covers `test_fandisk` only; the other files in `test/` carry their own g++
48lines the same way `tests/` does.
49
50## Scripted gates
51
52- `tests/gate_hblk_compile.sh`
53- `tests/gate_polish_units.py`
54- `demo/*.gd` — gates that need a running Godot (`volume_bake_gate.gd`,
55 `gate_bounded_procnodes.gd`, `compile_failure_gate.gd`, `procnodes_eval_probe.gd`,
56 `pn_diag.gd`)
57
58## The demo
59
60`demo/` is a Godot project with a fly camera, CSG edit controls and a CPU/GPU benchmark state
61machine. Note that its scene sets **no** properties, so it runs on the defaults — which means
62the CPU mesher. Set `use_gpu` to exercise the GPU pipeline.