OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain commits / 9373143

docs: a README, and the drift the audit found writing it
dc-terrain had no README. This one documents the DCTerrain and DCLevel surfaces from the bindings and headers rather than from memory, in the holepunch shape: what it is, features, build, one runnable example, then signature-headed API. Writing it surfaced things worth naming here rather than only in the prose: - the root .gdextension still has no linux.editor entry. demo/'s copy carries the comment explaining why that is not inert -- a missing target falls back to another one, and a probe silently answers from a three-day-old library. Three manifests in the tree, three different platform sets. - SConstruct cross-builds Windows and bin/windows/ has the DLL, but neither manifest here declares a windows entry. - collision_lod_max defaults to 5 and its property hint range is 0..4, unclamped, so the inspector cannot express its own default. - use_gpu defaults to false and the demo scene sets no properties, so the only demo in the repo never runs the GPU pipeline. - tests/ has 50 gates and no build system (each carries its own g++ line), test/ has a CMakeLists covering 1 of its 12, and nothing distinguishes a regression gate from a one-shot experiment. - no LICENSE. The README says "not yet declared" rather than picking one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93731437118a67fb97cff0d5548a0d75edd59563
by plankatron <fleeky@gmail.com> · 2026-09-10T14:30:21.000Z

Changed files (1) +419 -0

README.md +419 -0
……@@ -0,0 +1,419 @@
1# dc-terrain
2
3Dual Contouring voxel terrain for Godot 4, as a GDExtension.
4
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.
8
9## Features
10
11- **Sharp features.** Dual Contouring solves a QEF per cell, so a plane stays flat and a
12 crease stays a crease. Marching Cubes cannot do this.
13- **Octree clipmap LOD.** Detail follows the camera. Cross-LOD seams are stitched, not hidden.
14- **GPU meshing.** density → edge classify → scan → compact → edge intersect → voxel classify
15 → scan → compact → QEF solve → quad emit, on one `RenderingDevice`. CPU dual contouring
16 is the fallback and produces the same mesh.
17- **Runtime editing.** Spheres, boxes, convex brushes (Quake-style half-space sets), and
18 arbitrary meshes, added or subtracted, re-meshing only the chunks they touch.
19- **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- **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.
26
27`libdc/` is the meshing core and has no Godot dependency; `src/` is the GDExtension around it.
28
29## Requirements
30
31- Godot **4.3+** (`compatibility_minimum`; the demo project targets 4.6)
32- `godot-cpp` at `./godot-cpp` — a submodule or a symlink
33- 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.
38
39## Build
40
41```sh
42ln -s /path/to/godot-cpp godot-cpp # or: git submodule update --init --recursive
43
44scons -j$(nproc) target=template_debug
45scons -j$(nproc) target=template_release
46scons -j$(nproc) target=editor
47```
48
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```
59
60## Install
61
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`.
66
67If the shaders are not found the extension does not fail: it falls back to CPU meshing,
68quietly, at a fraction of the frame rate.
69
70## Usage
71
72```gdscript
73extends Node3D
74
75@onready var terrain: DCTerrain = $DCTerrain
76
77func _ready() -> void:
78 terrain.voxel_size = 1.0
79 terrain.octree_depth = 5
80 terrain.use_gpu = true # default is false — see below
81 terrain.collision_mode = 0 # 0 = Trimesh, 1 = SDF, 2 = Both
82 terrain.rebuild()
83
84func _unhandled_input(event: InputEvent) -> void:
85 if event.is_action_pressed("dig"):
86 # negative strength removes material, positive adds it
87 terrain.edit_terrain(_aim_point(), 3.0, -1.0)
88```
89
90LOD follows the active `Camera3D` of the viewport — the editor viewport camera in the editor,
91`get_viewport().get_camera_3d()` at runtime. There is nothing to assign.
92
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## DCTerrain
97
98`Node3D`. The streaming, destructible world.
99
100#### `terrain.rebuild()`
101
102Drop every chunk and re-mesh from scratch. Bumps a generation counter so in-flight worker
103results are discarded. Most property setters call this for you.
104
105#### `terrain.warmup_shaders()`
106
107Draw a one-triangle mesh through each per-LOD terrain material so the render pipelines are
108compiled before real chunks arrive, instead of stuttering as each LOD appears for the first
109time.
110
111#### Shape
112
113```gdscript
114generator: DCTerrainGenerator # overrides the built-in noise when set
115use_noise: bool = true
116seed: int
117terrain_size: float # 10 .. 100000
118ground_bias: float # -1 .. 1
119solid_floor_depth: float
120sky_height: float
121flat_center_radius: float
122spherical: bool # planetoid mode
123planet_radius: float
124floating_islands: bool
125island_radius: float
126island_depth_ratio: float
127island_spacing: float
128```
129
130Plus the noise bands — `primary_`, `ridge_`, `tunnel_`, `warp_`, `detail_` each with a
131`_frequency` and `_strength` (and `tunnel_radius`) — and the Voronoi floor set
132(`voronoi_cell_count`, `floor_strength`, `floor_height_min`, `floor_height_max`,
133`room_height`). `max_noise_octaves` and `max_ridge_octaves` cap octave count.
134
135These properties configure the **built-in** noise generator. Assigning `generator` replaces
136it wholesale; the properties then describe nothing on screen. Ask
137`is_using_builtin_generator()` rather than assuming — a generator that fails to load falls
138back to noise, which renders a perfectly plausible landscape that is not the one you asked
139for.
140
141#### Meshing and LOD
142
143```gdscript
144voxel_size: float = 1.0 # 0.1 .. 10
145octree_depth: int = 5 # 3 .. 20
146use_gpu: bool = false
147seamless_lod: bool = false # force every chunk to LOD 0
148lod_mode: int # 0 = Eager Fade, 1 = Blocking Swap (default)
149lod_factor: float = 3.0
150hysteresis: float = 1.3
151fade_speed: float = 3.0
152use_screen_space_error: bool
153screen_split_threshold: float
154lod_frozen: bool # freeze traversal during a grapple or teleport
155use_adaptive_qef: bool = true
156use_shortest_diagonal: bool = true
157use_mesh_simplify: bool = true # meshoptimizer pass, GPU path only
158force_direct_eval: bool = false
159csg_refinement: int = 2 # 1 .. 4
160```
161
162#### Display
163
164```gdscript
165terrain_color: Color
166custom_material: Material # overrides the internal per-LOD materials
167use_textures: bool = false # sample the atlas instead of flat debug colors
168normal_perturbation: float = 0.12
169```
170
171`set_texture_atlas(atlas)`, `set_glow_atlas(atlas)`, `set_norm_atlas(atlas)` take a
172`Texture2DArray` whose layer index is the `material_id`. `set_projection_table(table)` takes
17312 floats per entry, indexed per-face.
174
175#### Collision
176
177```gdscript
178collision_mode: int = 0 # 0 = Trimesh, 1 = SDF, 2 = Both
179collision_lod_max: int = 5 # collision is built for LOD 0..N only
180collision_distance: float = 100.0
181```
182
183In SDF mode nothing is added to the scene tree — query the field directly:
184
185#### `terrain.get_distance(position) -> float`
186
187Signed distance to the surface. Positive inside solid, negative in air.
188
189#### `terrain.get_surface_normal(position) -> Vector3`
190
191Normalized gradient of the density field.
192
193#### `terrain.get_capsule_push(position, radius, height) -> Vector3`
194
195Push-out vector for a capsule, or zero. Tests bottom, centre and top.
196
197#### `terrain.sample_density(position) -> float`
198
199The raw field, for spawn placement and the like. Positive is solid.
200
201#### Editing
202
203#### `terrain.edit_terrain(position, radius, strength, op = 0, interactive = true, material_id = 0)`
204
205A sphere. `strength` positive adds material, negative removes it. `op` is `0` for the smooth
206metaball blend or `1` for hard SDF CSG.
207
208`interactive` picks the worker lane. `true` is a live carve and takes the priority lane for
209immediate feedback; `false` is a replayed or network-synced edit and goes at normal priority,
210because flooding the priority lane starves LOD pre-meshing.
211
212#### `terrain.edit_terrain_box(position, half_extents, strength, interactive = true, material_id = 0, persistent = false)`
213#### `terrain.edit_terrain_convex(planes, center, radius, strength, interactive = true, material_id = 0, face_proj = [])`
214
215A convex brush is an intersection of half-spaces: `planes` is flat `[nx, ny, nz, d, ...]` with
216outward normals, inside being `n·p <= d` — the same layout a Quake `.map` importer already
217produces. `center` and `radius` are the bounding sphere used for chunk culling.
218
219#### `terrain.stamp_mesh_obj(obj_path, position, scale, strength, interactive = true, material_id = 0)`
220#### `terrain.stamp_mesh_arrays(vertices, indices, position, scale, strength, interactive = true, material_id = 0, persistent = false)`
221
222`persistent = true` marks an edit as **map-authored landscape**: it survives `clear_edits()`.
223There are two journals over one edit list — the player's, which the game owns and replays, and
224the map's, which must not be dropped with it. Use `clear_landscape_edits()` to drop only the
225latter.
226
227```gdscript
228terrain.clear_edits() # player journal
229terrain.clear_landscape_edits() # map-authored landscape; does not rebuild
230terrain.get_edit_count()
231terrain.get_edits_as_array()
232terrain.load_edits_from_array(edits)
233```
234
235Brushes already placed can be picked and moved: `pick_brush(point)`, `get_brush_center(idx)`,
236`get_brush_radius(idx)`, `get_brush_hull_triangles(idx)`, `lift_brush(idx)`,
237`move_brush(idx, delta)`.
238
239#### CSG primitives
240
241Live primitives, distinct from the edit list — combined within a `group`, then unioned with
242the terrain.
243
244```gdscript
245terrain.add_csg_primitive(type, op, group, position, size)
246# type: 0 = sphere, 1 = box. op: 0 = union, 1 = subtract, 2 = intersect.
247# size: sphere = [radius, 0, 0], box = [hx, hy, hz]
248terrain.set_csg_primitive_position(index, position)
249terrain.set_csg_primitive_size(index, size)
250terrain.remove_csg_primitive(index)
251terrain.clear_csg_primitives()
252terrain.get_csg_primitive_count()
253```
254
255#### Stats and instruments
256
257```gdscript
258terrain.get_chunk_count()
259terrain.get_active_chunk_count()
260terrain.get_pending_count()
261terrain.get_collision_queue_size()
262terrain.get_splits_this_frame()
263```
264
265The debug flags — `debug_logging`, `debug_lod_colors`, `debug_seams`, `debug_clipmap`,
266`debug_wireframe`, `debug_edits` — draw overlays. The rest answer questions no overlay can:
267
268#### `terrain.build_stamp() -> String`
269
270Which binary this is, baked at compile time. The `.so` under a consuming project's `addons/`
271is typically a symlink into `dc-terrain/bin` and is not rebuilt by anything automatic — so
272"I changed the C++" and "the game runs the change" are independent facts. This settles the
273second one.
274
275#### `terrain.debug_gpu_density(origin, voxel_size, cells) -> PackedFloat32Array`
276
277The real density pipeline — same shader, same uniform set, same buffers as the mesher — over
278a small grid. `sample_density()` is the CPU twin; comparing the two is the parity check.
279Returns empty if the GPU refused, which is itself the answer. A local `RenderingDevice` is
280null under `--headless`, so this only answers in a real render session.
281
282#### `terrain.debug_chunk_info(position) -> Dictionary`
283
284The streaming state of the leaf under a point: `lod`, `voxel_size`, `chunk_size`, `state`,
285`triangles`, `has_collision`, `collision_eligible`, `origin`. Empty when no leaf contains the
286point — distinct from LOD 0.
287
288#### `terrain.debug_collision_faces(center, radius) -> PackedVector3Array`
289#### `terrain.debug_visual_faces(center, radius) -> PackedVector3Array`
290
291Collision triangles and drawn triangles, both in world space, for comparison. Collision is
292built as bare `PhysicsServer3D` RIDs rather than scene-tree nodes, so this is the only way to
293see it. Gaps are the finding: nothing is built past `collision_lod_max`, however solid the
294terrain looks.
295
296#### `terrain.bake_volume_mesh(glsl, origin, voxel_size, cells) -> ArrayMesh`
297
298Takes a `float proc_density(vec3 pos)` GLSL body, fills a density grid with it and meshes it,
299both through the GPU thread the terrain already runs. Returns null if the GPU declined. It
300lives here rather than in proc-nodes because a second `VkDevice` bring-up starves — one
301device, one GPU thread, and this borrows them.
302
303`get_scalar_query_counts()` / `reset_scalar_query_counts()` count scalar-path evaluations, so
304"how much of the frame is the CPU field" is a number rather than a guess.
305
306## DCLevel
307
308`Node3D`. The exact path: authored convex brushes, meshed exactly, with no voxels and no LOD.
309
310It shares no code with the octree clipmap. The world — procedural, destructible everywhere —
311keeps `DCTerrain`; the level — authored, finite, sharp, low-poly — gets this.
312
313```
314BrushStore --csg4--> visible skin --dc_level_mesh--> ArrayMesh
315 \--unclipped hulls--------------------> convex colliders
316```
317
318```gdscript
319var id := level.add_brush(planes, material_id, plane_proj, level_id, sky, key, clearance, group)
320level.rebuild()
321```
322
323`planes` is the same flat `(nx, ny, nz, d)` layout `edit_terrain_convex` takes, so an importer
324drives either. `key` is the brush's durable name — pass `""` for an import, which is named
325deterministically on every peer. `sky` renders and clips but gets no collider. `clearance`
326marks air the level owns — the headroom over a stair, the bore of an archway — rendering and
327colliding with nothing, existing only so `build_shell` encloses it.
328
329Editing is by durable key and is journalled and reversible:
330
331```gdscript
332level.translate_brush(id, delta)
333level.scale_brush(id, scale, pivot)
334level.split_brush(id, normal, d, key)
335level.subtract_brush(id, cutter, key)
336level.intersect_brush(id, other, key)
337level.merge_brushes(ids, key)
338level.add_face(id, normal, d, proj, key)
339level.remove_face(id, face)
340level.set_face_plane(id, face, normal, d)
341
342level.apply_carve_sphere(center, radius, key)
343level.apply_carve_box(center, half_extents, key)
344level.undo_carve_by_key(key)
345level.redo_carve_by_key(key)
346
347level.apply_journal(records) # replay
348level.fingerprint() # convergence check
349```
350
351`build_shell(cell, clamp_cells)` derives the shell the surrounding `DCTerrain` must be cut to.
352`build_shell_start` / `shell_building` / `shell_ready` / `build_shell_take` are the async form.
353
354`raycast(from, to, include_clearance)` and `pick_face(brush_id, point)` support editor picking;
355`get_stats()` reports brush, face, triangle, vertex, collider, carve and pocket counts plus
356per-stage milliseconds (`ms_split`, `ms_clip`, `ms_mesh`, `ms_upload`, `ms_collide`,
357`ms_pocket`). Clearance and pocket figures are reported *separately* on purpose: a level
358showing 1190 brushes and 12747 triangles has not lost seven of them, it has seven passages —
359and pocket triangles have a different cost curve from the exact mesh, so one combined number
360would hide a pocket that had quietly become most of the frame.
361
362## Generators
363
364Assign a `DCTerrainGenerator` resource to `DCTerrain.generator` to replace the built-in field.
365
366| Class | Field |
367|---|---|
368| `DCTerrainGeneratorNoise` | The built-in noise, as a resource. Same properties as `DCTerrain`. |
369| `DCTerrainGeneratorMeshSDF` | An SDF from an OBJ mesh. `mesh_path`, `reload()`, `get_triangle_count()`. |
370| `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()`. |
371| `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. |
372| `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. |
373| `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. |
374
375`DCTerrainUtils.mesh_from_generator(generator, origin, voxel_size, grid_size)` and
376`mesh_from_generator_auto(generator, voxel_size)` mesh a generator straight to an `ArrayMesh`
377without a terrain node.
378
379## Testing
380
381`libdc` is Godot-free, so its gates are plain executables. Each one carries its own build line
382in its header comment:
383
384```sh
385# from the repo root
386g++ -O2 -std=c++17 -I libdc/include -o /tmp/csg4_gate \
387 tests/test_csg4_gate.cpp libdc/src/dc_csg4.cpp
388/tmp/csg4_gate
389```
390
391`test/CMakeLists.txt` builds `test_fandisk`, the meshing-accuracy check against
392`test/data/fandisk.obj`:
393
394```sh
395cmake -S test -B test/build && cmake --build test/build && ./test/build/test_fandisk
396```
397
398GPU gates compile and dispatch **standalone**, on their own `VkDevice`, in their own process,
399linking `-lvulkan` — because the in-engine oracle submits work on the live device, and letting
400it run long enough to answer is what once interleaved its uniform set with the mesh pipeline's
401and emptied the world. `tests/gate_hblk_compile.sh` and `tests/gate_polish_units.py` are
402scripted gates; `demo/*.gd` are gates that need a running Godot.
403
404## Layout
405
406```
407libdc/ meshing core — QEF, contouring, octree, CSG, brushes. No Godot.
408src/ the GDExtension — DCTerrain, DCLevel, generators, GPU pipeline, workers
409shaders/ compute shaders, loaded from disk beside the library at runtime
410tests/ libdc + GPU gates, one file each, build line in the header
411test/ CMake-built fandisk accuracy check
412demo/ a Godot project: fly camera, CSG edits, CPU/GPU benchmark
413docs/ designs, postmortems, and a mirror of the ProcWorld archive
414thirdparty/ meshoptimizer (MIT)
415```
416
417## License
418
419Not yet declared. `thirdparty/meshoptimizer` is MIT, © Arseny Kapoulkine.