| … | … | @@ -0,0 +1,419 @@ |
| 1 | # dc-terrain |
| 2 | |
| 3 | Dual Contouring voxel terrain for Godot 4, as a GDExtension. |
| 4 | |
| 5 | A signed distance field goes in, a sharp-featured triangle mesh comes out — streamed |
| 6 | around the camera as an octree clipmap, edited at runtime with CSG brushes, and meshed |
| 7 | either 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 |
| 42 | ln -s /path/to/godot-cpp godot-cpp # or: git submodule update --init --recursive |
| 43 | |
| 44 | scons -j$(nproc) target=template_debug |
| 45 | scons -j$(nproc) target=template_release |
| 46 | scons -j$(nproc) target=editor |
| 47 | ``` |
| 48 | |
| 49 | Build **all three targets**. Godot picks the library by target, and an entry that is missing |
| 50 | from the `.gdextension` silently falls back to another one — so a stale `template_debug` |
| 51 | answers for the `editor` you thought you just rebuilt. |
| 52 | |
| 53 | Cross-compiling for Windows needs the posix-threads mingw variant (the win32 variant ships |
| 54 | neither libgomp nor `<thread>`) and an explicit header path: |
| 55 | |
| 56 | ```sh |
| 57 | scons -j$(nproc) platform=windows vulkan_include=/path/to/Vulkan-Headers/include |
| 58 | ``` |
| 59 | |
| 60 | ## Install |
| 61 | |
| 62 | Copy 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 |
| 64 | from its own module path at runtime (`dladdr` / `GetModuleFileName`), so `bin/linux/shaders` |
| 65 | is a symlink to `../../shaders`. Override with `DC_TERRAIN_SHADER_DIR`. |
| 66 | |
| 67 | If the shaders are not found the extension does not fail: it falls back to CPU meshing, |
| 68 | quietly, at a fraction of the frame rate. |
| 69 | |
| 70 | ## Usage |
| 71 | |
| 72 | ```gdscript |
| 73 | extends Node3D |
| 74 | |
| 75 | @onready var terrain: DCTerrain = $DCTerrain |
| 76 | |
| 77 | func _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 | |
| 84 | func _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 | |
| 90 | LOD 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 |
| 94 | mesher; every hyper-tps map sets `use_gpu = true` explicitly. |
| 95 | |
| 96 | ## DCTerrain |
| 97 | |
| 98 | `Node3D`. The streaming, destructible world. |
| 99 | |
| 100 | #### `terrain.rebuild()` |
| 101 | |
| 102 | Drop every chunk and re-mesh from scratch. Bumps a generation counter so in-flight worker |
| 103 | results are discarded. Most property setters call this for you. |
| 104 | |
| 105 | #### `terrain.warmup_shaders()` |
| 106 | |
| 107 | Draw a one-triangle mesh through each per-LOD terrain material so the render pipelines are |
| 108 | compiled before real chunks arrive, instead of stuttering as each LOD appears for the first |
| 109 | time. |
| 110 | |
| 111 | #### Shape |
| 112 | |
| 113 | ```gdscript |
| 114 | generator: DCTerrainGenerator # overrides the built-in noise when set |
| 115 | use_noise: bool = true |
| 116 | seed: int |
| 117 | terrain_size: float # 10 .. 100000 |
| 118 | ground_bias: float # -1 .. 1 |
| 119 | solid_floor_depth: float |
| 120 | sky_height: float |
| 121 | flat_center_radius: float |
| 122 | spherical: bool # planetoid mode |
| 123 | planet_radius: float |
| 124 | floating_islands: bool |
| 125 | island_radius: float |
| 126 | island_depth_ratio: float |
| 127 | island_spacing: float |
| 128 | ``` |
| 129 | |
| 130 | Plus 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 | |
| 135 | These properties configure the **built-in** noise generator. Assigning `generator` replaces |
| 136 | it wholesale; the properties then describe nothing on screen. Ask |
| 137 | `is_using_builtin_generator()` rather than assuming — a generator that fails to load falls |
| 138 | back to noise, which renders a perfectly plausible landscape that is not the one you asked |
| 139 | for. |
| 140 | |
| 141 | #### Meshing and LOD |
| 142 | |
| 143 | ```gdscript |
| 144 | voxel_size: float = 1.0 # 0.1 .. 10 |
| 145 | octree_depth: int = 5 # 3 .. 20 |
| 146 | use_gpu: bool = false |
| 147 | seamless_lod: bool = false # force every chunk to LOD 0 |
| 148 | lod_mode: int # 0 = Eager Fade, 1 = Blocking Swap (default) |
| 149 | lod_factor: float = 3.0 |
| 150 | hysteresis: float = 1.3 |
| 151 | fade_speed: float = 3.0 |
| 152 | use_screen_space_error: bool |
| 153 | screen_split_threshold: float |
| 154 | lod_frozen: bool # freeze traversal during a grapple or teleport |
| 155 | use_adaptive_qef: bool = true |
| 156 | use_shortest_diagonal: bool = true |
| 157 | use_mesh_simplify: bool = true # meshoptimizer pass, GPU path only |
| 158 | force_direct_eval: bool = false |
| 159 | csg_refinement: int = 2 # 1 .. 4 |
| 160 | ``` |
| 161 | |
| 162 | #### Display |
| 163 | |
| 164 | ```gdscript |
| 165 | terrain_color: Color |
| 166 | custom_material: Material # overrides the internal per-LOD materials |
| 167 | use_textures: bool = false # sample the atlas instead of flat debug colors |
| 168 | normal_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 |
| 173 | 12 floats per entry, indexed per-face. |
| 174 | |
| 175 | #### Collision |
| 176 | |
| 177 | ```gdscript |
| 178 | collision_mode: int = 0 # 0 = Trimesh, 1 = SDF, 2 = Both |
| 179 | collision_lod_max: int = 5 # collision is built for LOD 0..N only |
| 180 | collision_distance: float = 100.0 |
| 181 | ``` |
| 182 | |
| 183 | In SDF mode nothing is added to the scene tree — query the field directly: |
| 184 | |
| 185 | #### `terrain.get_distance(position) -> float` |
| 186 | |
| 187 | Signed distance to the surface. Positive inside solid, negative in air. |
| 188 | |
| 189 | #### `terrain.get_surface_normal(position) -> Vector3` |
| 190 | |
| 191 | Normalized gradient of the density field. |
| 192 | |
| 193 | #### `terrain.get_capsule_push(position, radius, height) -> Vector3` |
| 194 | |
| 195 | Push-out vector for a capsule, or zero. Tests bottom, centre and top. |
| 196 | |
| 197 | #### `terrain.sample_density(position) -> float` |
| 198 | |
| 199 | The 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 | |
| 205 | A sphere. `strength` positive adds material, negative removes it. `op` is `0` for the smooth |
| 206 | metaball 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 |
| 209 | immediate feedback; `false` is a replayed or network-synced edit and goes at normal priority, |
| 210 | because 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 | |
| 215 | A convex brush is an intersection of half-spaces: `planes` is flat `[nx, ny, nz, d, ...]` with |
| 216 | outward normals, inside being `n·p <= d` — the same layout a Quake `.map` importer already |
| 217 | produces. `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()`. |
| 223 | There are two journals over one edit list — the player's, which the game owns and replays, and |
| 224 | the map's, which must not be dropped with it. Use `clear_landscape_edits()` to drop only the |
| 225 | latter. |
| 226 | |
| 227 | ```gdscript |
| 228 | terrain.clear_edits() # player journal |
| 229 | terrain.clear_landscape_edits() # map-authored landscape; does not rebuild |
| 230 | terrain.get_edit_count() |
| 231 | terrain.get_edits_as_array() |
| 232 | terrain.load_edits_from_array(edits) |
| 233 | ``` |
| 234 | |
| 235 | Brushes 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 | |
| 241 | Live primitives, distinct from the edit list — combined within a `group`, then unioned with |
| 242 | the terrain. |
| 243 | |
| 244 | ```gdscript |
| 245 | terrain.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] |
| 248 | terrain.set_csg_primitive_position(index, position) |
| 249 | terrain.set_csg_primitive_size(index, size) |
| 250 | terrain.remove_csg_primitive(index) |
| 251 | terrain.clear_csg_primitives() |
| 252 | terrain.get_csg_primitive_count() |
| 253 | ``` |
| 254 | |
| 255 | #### Stats and instruments |
| 256 | |
| 257 | ```gdscript |
| 258 | terrain.get_chunk_count() |
| 259 | terrain.get_active_chunk_count() |
| 260 | terrain.get_pending_count() |
| 261 | terrain.get_collision_queue_size() |
| 262 | terrain.get_splits_this_frame() |
| 263 | ``` |
| 264 | |
| 265 | The 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 | |
| 270 | Which binary this is, baked at compile time. The `.so` under a consuming project's `addons/` |
| 271 | is 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 |
| 273 | second one. |
| 274 | |
| 275 | #### `terrain.debug_gpu_density(origin, voxel_size, cells) -> PackedFloat32Array` |
| 276 | |
| 277 | The real density pipeline — same shader, same uniform set, same buffers as the mesher — over |
| 278 | a small grid. `sample_density()` is the CPU twin; comparing the two is the parity check. |
| 279 | Returns empty if the GPU refused, which is itself the answer. A local `RenderingDevice` is |
| 280 | null under `--headless`, so this only answers in a real render session. |
| 281 | |
| 282 | #### `terrain.debug_chunk_info(position) -> Dictionary` |
| 283 | |
| 284 | The 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 |
| 286 | point — distinct from LOD 0. |
| 287 | |
| 288 | #### `terrain.debug_collision_faces(center, radius) -> PackedVector3Array` |
| 289 | #### `terrain.debug_visual_faces(center, radius) -> PackedVector3Array` |
| 290 | |
| 291 | Collision triangles and drawn triangles, both in world space, for comparison. Collision is |
| 292 | built as bare `PhysicsServer3D` RIDs rather than scene-tree nodes, so this is the only way to |
| 293 | see it. Gaps are the finding: nothing is built past `collision_lod_max`, however solid the |
| 294 | terrain looks. |
| 295 | |
| 296 | #### `terrain.bake_volume_mesh(glsl, origin, voxel_size, cells) -> ArrayMesh` |
| 297 | |
| 298 | Takes a `float proc_density(vec3 pos)` GLSL body, fills a density grid with it and meshes it, |
| 299 | both through the GPU thread the terrain already runs. Returns null if the GPU declined. It |
| 300 | lives here rather than in proc-nodes because a second `VkDevice` bring-up starves — one |
| 301 | device, 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 | |
| 310 | It shares no code with the octree clipmap. The world — procedural, destructible everywhere — |
| 311 | keeps `DCTerrain`; the level — authored, finite, sharp, low-poly — gets this. |
| 312 | |
| 313 | ``` |
| 314 | BrushStore --csg4--> visible skin --dc_level_mesh--> ArrayMesh |
| 315 | \--unclipped hulls--------------------> convex colliders |
| 316 | ``` |
| 317 | |
| 318 | ```gdscript |
| 319 | var id := level.add_brush(planes, material_id, plane_proj, level_id, sky, key, clearance, group) |
| 320 | level.rebuild() |
| 321 | ``` |
| 322 | |
| 323 | `planes` is the same flat `(nx, ny, nz, d)` layout `edit_terrain_convex` takes, so an importer |
| 324 | drives either. `key` is the brush's durable name — pass `""` for an import, which is named |
| 325 | deterministically on every peer. `sky` renders and clips but gets no collider. `clearance` |
| 326 | marks air the level owns — the headroom over a stair, the bore of an archway — rendering and |
| 327 | colliding with nothing, existing only so `build_shell` encloses it. |
| 328 | |
| 329 | Editing is by durable key and is journalled and reversible: |
| 330 | |
| 331 | ```gdscript |
| 332 | level.translate_brush(id, delta) |
| 333 | level.scale_brush(id, scale, pivot) |
| 334 | level.split_brush(id, normal, d, key) |
| 335 | level.subtract_brush(id, cutter, key) |
| 336 | level.intersect_brush(id, other, key) |
| 337 | level.merge_brushes(ids, key) |
| 338 | level.add_face(id, normal, d, proj, key) |
| 339 | level.remove_face(id, face) |
| 340 | level.set_face_plane(id, face, normal, d) |
| 341 | |
| 342 | level.apply_carve_sphere(center, radius, key) |
| 343 | level.apply_carve_box(center, half_extents, key) |
| 344 | level.undo_carve_by_key(key) |
| 345 | level.redo_carve_by_key(key) |
| 346 | |
| 347 | level.apply_journal(records) # replay |
| 348 | level.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 |
| 356 | per-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 |
| 358 | showing 1190 brushes and 12747 triangles has not lost seven of them, it has seven passages — |
| 359 | and pocket triangles have a different cost curve from the exact mesh, so one combined number |
| 360 | would hide a pocket that had quietly become most of the frame. |
| 361 | |
| 362 | ## Generators |
| 363 | |
| 364 | Assign 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` |
| 377 | without 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 |
| 382 | in its header comment: |
| 383 | |
| 384 | ```sh |
| 385 | # from the repo root |
| 386 | g++ -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 |
| 395 | cmake -S test -B test/build && cmake --build test/build && ./test/build/test_fandisk |
| 396 | ``` |
| 397 | |
| 398 | GPU gates compile and dispatch **standalone**, on their own `VkDevice`, in their own process, |
| 399 | linking `-lvulkan` — because the in-engine oracle submits work on the live device, and letting |
| 400 | it run long enough to answer is what once interleaved its uniform set with the mesh pipeline's |
| 401 | and emptied the world. `tests/gate_hblk_compile.sh` and `tests/gate_polish_units.py` are |
| 402 | scripted gates; `demo/*.gd` are gates that need a running Godot. |
| 403 | |
| 404 | ## Layout |
| 405 | |
| 406 | ``` |
| 407 | libdc/ meshing core — QEF, contouring, octree, CSG, brushes. No Godot. |
| 408 | src/ the GDExtension — DCTerrain, DCLevel, generators, GPU pipeline, workers |
| 409 | shaders/ compute shaders, loaded from disk beside the library at runtime |
| 410 | tests/ libdc + GPU gates, one file each, build line in the header |
| 411 | test/ CMake-built fandisk accuracy check |
| 412 | demo/ a Godot project: fly camera, CSG edits, CPU/GPU benchmark |
| 413 | docs/ designs, postmortems, and a mirror of the ProcWorld archive |
| 414 | thirdparty/ meshoptimizer (MIT) |
| 415 | ``` |
| 416 | |
| 417 | ## License |
| 418 | |
| 419 | Not yet declared. `thirdparty/meshoptimizer` is MIT, © Arseny Kapoulkine. |